mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
fix(native-runner): complete recovery and conformance
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
f285bbbab8
commit
386433f178
22 files changed
+1454
-223
No files matched your search
@@ -703,16 +703,30 @@ from the provisional list above. They are added without expanding authority:
|
||||
```text
|
||||
packages/paperclip-runner/src/native-session-runtime.ts
|
||||
packages/paperclip-runner/src/native-session-runtime.test.ts
|
||||
packages/paperclip-runner/src/contracts/native-execution.ts
|
||||
packages/paperclip-runner/src/contracts/native-execution.test.ts
|
||||
packages/paperclip-runner/src/backends/codex-native-backend.ts
|
||||
packages/paperclip-runner/spec/fixtures/status-authority-phase5.json
|
||||
packages/adapter-utils/package.json
|
||||
packages/shared/src/index.ts
|
||||
packages/shared/src/types/index.ts
|
||||
packages/shared/src/validators/index.ts
|
||||
server/src/services/native-runtime/native-session-executor.ts
|
||||
server/src/services/native-runtime/native-session-executor.test.ts
|
||||
server/src/services/native-runtime/evidence-classifier.test.ts
|
||||
server/src/services/native-runtime/paperclip-control-plane-port.test.ts
|
||||
server/src/services/native-runtime/status-arbiter.test.ts
|
||||
server/src/services/recovery/service.ts
|
||||
packages/paperclip-runner/docs/design/phase-6-thin-paperclip-adapter.md
|
||||
```
|
||||
|
||||
The package runtime files own persisted provider-session recovery. The corpus
|
||||
The package runtime, closed native-input contract, and Codex backend files own
|
||||
persisted provider-session recovery and construction. The adapter-utils
|
||||
metadata declares the package dependency used by the approved native
|
||||
finalization result type; the shared barrels only expose the already-reviewed
|
||||
types and validators. The recovery service touch reuses the existing
|
||||
source-scoped recovery action and wake path rather than creating new authority.
|
||||
The corpus
|
||||
edit moves two existing coverage labels between fixtures without changing any
|
||||
fixture input or expected outcome, eliminating an unjoinable fixture. The server
|
||||
executor files own only the run-scoped cancellation handle and coordinator
|
||||
|
||||
@@ -40,8 +40,9 @@ contract are persisted before provider execution.
|
||||
- `completeRun` validates and idempotently persists the immutable structured
|
||||
result plus terminal fact.
|
||||
|
||||
The package-owned `HarnessDriverBackend` is the approved
|
||||
`NativeSessionBackend`. Core passes only the closed `NativeExecutionInputV1`.
|
||||
The package-owned Codex native backend is the production
|
||||
`NativeSessionBackend`; the harness backend remains the deterministic test
|
||||
adapter. Core passes only the closed `NativeExecutionInputV1`.
|
||||
The model receives the smaller `NativeModelEnvelopeV1`; bindings and credential
|
||||
references do not cross that boundary.
|
||||
|
||||
@@ -64,14 +65,23 @@ status-version CAS and audit record.
|
||||
|
||||
## Recovery and safety
|
||||
|
||||
The finalization reconciler selects persisted `runtime_mode=native` rows; it
|
||||
does not consult the current flag. Per-attempt leases prevent concurrent
|
||||
session/finalization ownership, while retryable failures retain the immutable
|
||||
result and a first-class recovery action. A recovered session replays its
|
||||
checkpoint and appends only missing control-plane facts instead of opening a
|
||||
second provider session. Cancellation uses a run-scoped normalized session
|
||||
handle before the existing process cleanup. Native execution does not construct
|
||||
a Paperclip JWT, managed MCP access, legacy context, or raw adapter environment.
|
||||
Recovery selects persisted `runtime_mode=native` coordinators and does not
|
||||
consult the current flag. Result-less transport loss retains the original run,
|
||||
closed native input, provider checkpoint, retry attempt, and recovery action;
|
||||
a database lease dispatches that same run after restart. Missing checkpoints,
|
||||
cancellation, and retry exhaustion fail closed without opening a new provider
|
||||
session or falling into legacy. Result-bearing rows continue through the
|
||||
workspace/finalization reconciler. A recovered package session inspects the
|
||||
provider snapshot before starting a turn, so an active provider turn is not
|
||||
duplicated. Cancellation uses a run-scoped normalized session handle before the
|
||||
existing process cleanup. Native execution does not construct a Paperclip JWT,
|
||||
managed MCP access, legacy context, or raw adapter environment.
|
||||
|
||||
Resolved `request_confirmation` and `ask_user_questions` wakes are projected
|
||||
through the existing authorized issue-interaction service and bound to the
|
||||
company, issue, run, agent, and interaction identities in the persisted input.
|
||||
Governed tool actions, unsupported kinds, unresolved rows, and self-resolution
|
||||
fail closed; no credentials enter the model envelope.
|
||||
|
||||
## Verification status
|
||||
|
||||
|
||||
@@ -51,21 +51,26 @@ pnpm --filter @paperclipai/server exec vitest run \
|
||||
src/__tests__/native-runner-input-boundary.test.ts \
|
||||
src/__tests__/native-run-finalizer.test.ts \
|
||||
src/__tests__/native-status-arbiter-corpus.test.ts \
|
||||
src/__tests__/native-interaction-bridge.test.ts \
|
||||
src/__tests__/native-session-resumption.test.ts \
|
||||
src/__tests__/native-finalization-recovery.test.ts \
|
||||
src/__tests__/native-finalization-migration.test.ts \
|
||||
src/__tests__/legacy-finalization-regression.test.ts
|
||||
src/__tests__/legacy-finalization-regression.test.ts \
|
||||
src/services/native-runtime/native-session-executor.test.ts
|
||||
```
|
||||
|
||||
This runs the unchanged package conformance suite against the real Paperclip
|
||||
port. Twenty-eight targeted tests check company binding, duplicate/gap replay,
|
||||
port. Thirty-nine targeted tests check company binding, duplicate/gap replay,
|
||||
checkpoint recovery, immutable result ingestion, durable-evidence authority,
|
||||
workspace-gated finalization, atomic liveness rollback, status-version CAS,
|
||||
migration repair, legacy byte equivalence, and audit output. All database tests
|
||||
migration repair, legacy byte equivalence, typed interaction materialization,
|
||||
same-run pre-result resumption, lease races, and audit output. All database tests
|
||||
start embedded PostgreSQL and do not skip when a developer database URL is
|
||||
absent.
|
||||
|
||||
The command above is the repeatable internal canary and post-kill-switch legacy
|
||||
proof. The remaining sections are an optional live-provider rollout procedure;
|
||||
The command above is the repeatable scripted internal canary and actual
|
||||
flag-off legacy-heartbeat proof. It is not a live-provider canary. The remaining
|
||||
sections are an optional live-provider rollout procedure;
|
||||
they were not run during remediation and require explicit operator authority to
|
||||
change an instance flag and agent profile.
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ pnpm --filter @paperclipai/paperclip-runner trace:phase6 -- \
|
||||
--target mock --scenario happy-path
|
||||
```
|
||||
|
||||
Result: four files and six tests passed. The stable trace reported native
|
||||
Result: four files and seven tests passed. The stable trace reported native
|
||||
mode, `runStatus=succeeded`, three events, contiguous source sequence 3,
|
||||
`workspaceFinalizeStatus=succeeded`, zero legacy invocations, and a server-owned
|
||||
`done` decision with status version 0 -> 1. Restart recovery reused the bound
|
||||
@@ -39,7 +39,7 @@ existing accepted result event, and appended only the missing terminal fact.
|
||||
|
||||
# Real Paperclip adapter and public-session task
|
||||
|
||||
The complete acceptance gate below passed 28 tests in ten files against
|
||||
The complete acceptance gate below passed 39 tests in thirteen files against
|
||||
embedded PostgreSQL with zero skipped tests. Database-backed tests start their
|
||||
own database instead of skipping when a developer `DATABASE_URL` is absent.
|
||||
The same package conformance suite passed unchanged against
|
||||
@@ -59,7 +59,14 @@ The focused corpus also proved:
|
||||
- remote, non-standard, or non-Codex explicit profiles fail closed;
|
||||
- company/run/agent/issue/source bindings reject mismatches;
|
||||
- source gaps remain visible, identical retries deduplicate, and replay is
|
||||
exclusive and byte-stable;
|
||||
exclusive and byte-stable; mutated identities, sequences, results, and
|
||||
bindings fail closed in the shared mock/real port suite;
|
||||
- result-less transport loss dispatches the original persisted native run from
|
||||
its closed envelope/provider checkpoint after a database lease, including
|
||||
flag-off restart and active-turn recovery without a second session or turn;
|
||||
- resolved confirmations and question answers enter the persisted native input
|
||||
only through the authorized issue-interaction service; governed, unresolved,
|
||||
unsupported, or self-approved paths fail closed without credentials;
|
||||
- incomplete/review/yield/cancelled results receive a durable review,
|
||||
continuation, or recovery path;
|
||||
- only a task-wide blocker with a named owner and action selects `blocked`;
|
||||
@@ -72,7 +79,7 @@ The focused corpus also proved:
|
||||
bindings, recovery, status,
|
||||
decisions, and effects roll back together before retry ownership is recorded.
|
||||
|
||||
The acceptance-matrix entry points also passed as one 10-file, 28-test gate:
|
||||
The acceptance-matrix entry points passed as one 13-file, 39-test gate:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/server exec vitest run \
|
||||
@@ -82,16 +89,22 @@ pnpm --filter @paperclipai/server exec vitest run \
|
||||
src/__tests__/native-runner-input-boundary.test.ts \
|
||||
src/__tests__/native-runner-phase6.integration.test.ts \
|
||||
src/__tests__/native-finalization-recovery.test.ts \
|
||||
src/__tests__/native-interaction-bridge.test.ts \
|
||||
src/__tests__/native-session-resumption.test.ts \
|
||||
src/__tests__/heartbeat-run-event-sequencing.test.ts \
|
||||
src/__tests__/legacy-finalization-regression.test.ts \
|
||||
src/__tests__/native-finalization-migration.test.ts \
|
||||
src/__tests__/native-status-arbiter-corpus.test.ts
|
||||
src/__tests__/native-status-arbiter-corpus.test.ts \
|
||||
src/services/native-runtime/native-session-executor.test.ts
|
||||
```
|
||||
|
||||
This gate proves concurrent event allocation, duplicate-only migration repair,
|
||||
bounded retry exhaustion, kill-switch legacy byte compatibility, and complete
|
||||
Section 18.13 fixture-to-consumer traceability in addition to the direct suites.
|
||||
It is the repeatable internal native canary and post-kill-switch legacy proof.
|
||||
bounded retry exhaustion, actual flag-off legacy execution, and executable
|
||||
Section 18.13 fixture-to-consumer traceability. Each of the 52 fixtures and 70
|
||||
unique matrix rows invokes production consumers; the test hashes observed
|
||||
decisions/effects, never the fixture's `expected` object. The selected-task
|
||||
canary remains scripted at the provider boundary, while the legacy heartbeat is
|
||||
an actual adapter/finalizer execution.
|
||||
|
||||
# Compile and migration checks
|
||||
|
||||
@@ -118,14 +131,17 @@ pnpm --filter @paperclipai/server exec vitest run \
|
||||
src/__tests__/heartbeat-run-event-sequencing.test.ts
|
||||
```
|
||||
|
||||
Result: five files and 11 tests passed with zero skips. The production-shaped
|
||||
0211 rehearsal began with legacy event sequences `[1,5,5,9]`, retained the
|
||||
Result: five files and 11 tests passed with zero skips. The 0211 rehearsal
|
||||
reconstructed a production-shaped pre-0211 schema, removed the 0211 journal
|
||||
entry, seeded legacy event sequences `[1,5,5,9]`, and applied the complete
|
||||
migration. It retained the
|
||||
original unique cursors and first `5`, moved only the later duplicate to `10`,
|
||||
seeded `next_event_seq=11`, preserved every non-sequence field byte-for-byte,
|
||||
and enforced uniqueness. Thirty-two concurrent mixed event writers produced a
|
||||
gap-free allocator stream. Invalid native finalization exhausted its
|
||||
three-attempt budget into named recovery without touching issue status. The
|
||||
legacy snapshot remained byte-equivalent with zero native history rows.
|
||||
three-attempt budget into named recovery without touching issue status. An
|
||||
actual flag-off heartbeat executed the legacy adapter/finalizer, remained
|
||||
byte-equivalent across native reconciliation, and created zero native rows.
|
||||
|
||||
# Credential, governance, budget, and legacy boundaries
|
||||
|
||||
@@ -133,7 +149,8 @@ legacy snapshot remained byte-equivalent with zero native history rows.
|
||||
proves the model envelope omits company/run/agent bindings and credential
|
||||
references. The native branch never creates a local agent JWT, MCP gateway, raw
|
||||
adapter environment, or legacy context. Runtime requests cannot auto-approve;
|
||||
the native interaction bridge rejects unsupported requests explicitly.
|
||||
the native interaction bridge materializes supported typed responses through
|
||||
the authorized service and rejects unsupported/governed/self-approved paths.
|
||||
|
||||
Explicit cancellation, agent pause, and budget hard-stop share
|
||||
`cancelRunInternal`, which now invokes the run-scoped normalized native session
|
||||
|
||||
@@ -40,21 +40,30 @@ existing server authority and keep legacy execution as the default.
|
||||
10. Treat model citations as claims. Only server-verifiable durable records can
|
||||
satisfy completion criteria, and every non-terminal decision must create or
|
||||
bind a real review, wake, blocker, or recovery path in the status transaction.
|
||||
11. Persist the closed native input before opening the provider. Result-less
|
||||
transport failures keep the same run live behind a bounded database lease;
|
||||
restart, flag disablement, and an already-active provider turn cannot create
|
||||
a second provider session, run, or turn.
|
||||
12. Project supported resolved interaction responses through the existing
|
||||
authorized issue-interaction service. Governed, unresolved, unsupported,
|
||||
cross-boundary, or self-approved requests fail closed without credentials.
|
||||
|
||||
# Evidence
|
||||
|
||||
- [Phase 6 verification](../evidence/2026-08-09-phase-06-verification.md)
|
||||
- [Phase 6 reference](../../docs/phase-06-thin-paperclip-adapter.md)
|
||||
- [Runnable tutorial](../../docs/tutorials/phase-06-thin-paperclip-adapter.md)
|
||||
- Package conformance and recovery: four files, six tests passed.
|
||||
- Phase 6 acceptance-matrix entry points: ten files, twenty-eight tests passed
|
||||
- Package conformance and recovery: four files, seven tests passed.
|
||||
- Phase 6 acceptance-matrix entry points: thirteen files, thirty-nine tests passed
|
||||
with zero skips, including the database-backed selected-task canary, six
|
||||
atomic-liveness failpoints, migration, sequencing, bounded recovery, and
|
||||
legacy compatibility.
|
||||
- Focused database rehearsal: five files, eleven tests passed with zero skips;
|
||||
the legacy event/read snapshot was byte-equivalent and had zero native rows.
|
||||
- Section 18.13 source corpus: 52 fixtures, all 70 matrix rows, and 18 structural
|
||||
checks passed with every fixture bound to a named consumer.
|
||||
- The migration rehearsal rewound to a production-shaped pre-0211 schema and
|
||||
applied the complete migration; the actual flag-off heartbeat executed the
|
||||
legacy adapter/finalizer and created zero native rows.
|
||||
- Section 18.13 source corpus: 52 fixture executions and 70 unique matrix-row
|
||||
executions passed through production consumers with observed result/effect
|
||||
digests; expected-object hashing and import-only aliases were removed.
|
||||
- Runner, shared, server, and database typechecks passed; migration safety
|
||||
passed.
|
||||
|
||||
@@ -75,14 +84,16 @@ existing server authority and keep legacy execution as the default.
|
||||
|
||||
- Phase 6 supports local, standard, workspace-backed `codex_local` tasks only.
|
||||
- It adds no browser UI or public runner WebSocket.
|
||||
- Typed provider requests are rejected unless an existing Paperclip governance
|
||||
path can materialize an authorized response; there is no auto-approval.
|
||||
- Resolved confirmations and question answers are materialized only through the
|
||||
existing authorized Paperclip interaction service; governed and unsupported
|
||||
requests cannot auto-approve.
|
||||
- Live operator proof depends on a board-authorized local instance and an
|
||||
already authenticated Codex installation; deterministic CI uses the same
|
||||
public session contract with a scripted backend.
|
||||
- The remediation did not dispatch a new live provider task or mutate rollout
|
||||
settings. The verified “internal canary” and post-kill-switch trace are the
|
||||
real embedded-PostgreSQL selected-task and legacy-snapshot cases; the live
|
||||
embedded-PostgreSQL scripted selected-task and actual flag-off legacy
|
||||
heartbeat cases; the live
|
||||
tutorial remains an explicit operator checkpoint.
|
||||
|
||||
# Follow-up questions
|
||||
|
||||
@@ -19,6 +19,11 @@ describe("ControlPlanePort conformance", () => {
|
||||
highestContiguousSourceSeq: 3,
|
||||
duplicateDisposition: "duplicate",
|
||||
terminalReplayIdempotent: true,
|
||||
openBindingRejected: true,
|
||||
eventIdMutationRejected: true,
|
||||
eventSequenceMutationRejected: true,
|
||||
replayBindingRejected: true,
|
||||
resultMutationRejected: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,11 @@ export interface ControlPlanePortConformanceSnapshot {
|
||||
highestContiguousSourceSeq: number;
|
||||
duplicateDisposition: "duplicate";
|
||||
terminalReplayIdempotent: boolean;
|
||||
openBindingRejected: boolean;
|
||||
eventIdMutationRejected: boolean;
|
||||
eventSequenceMutationRejected: boolean;
|
||||
replayBindingRejected: boolean;
|
||||
resultMutationRejected: boolean;
|
||||
}
|
||||
|
||||
export interface ControlPlanePortConformanceHarness {
|
||||
@@ -83,6 +88,16 @@ export async function runControlPlanePortConformance(
|
||||
await harness.start?.();
|
||||
try {
|
||||
await harness.port.openRun(CONTROL_PLANE_CONFORMANCE_OPEN);
|
||||
let openBindingRejected = false;
|
||||
try {
|
||||
await harness.port.openRun({
|
||||
...CONTROL_PLANE_CONFORMANCE_OPEN,
|
||||
identity: { ...CONTROL_PLANE_CONFORMANCE_OPEN.identity, issueId: "forged-issue" },
|
||||
});
|
||||
} catch {
|
||||
openBindingRejected = true;
|
||||
}
|
||||
if (!openBindingRejected) throw new Error("mismatched run binding was accepted");
|
||||
const first = await harness.port.appendEvent(CONTROL_PLANE_CONFORMANCE_EVENTS[0]);
|
||||
if (first.disposition !== "committed" || first.highestContiguousSourceSeq !== 1) {
|
||||
throw new Error("first event did not commit at cursor 1");
|
||||
@@ -93,6 +108,27 @@ export async function runControlPlanePortConformance(
|
||||
if (recovered.highestContiguousSourceSeq !== 3) throw new Error("source gap did not recover");
|
||||
const duplicate = await harness.port.appendEvent(CONTROL_PLANE_CONFORMANCE_EVENTS[1]);
|
||||
if (duplicate.disposition !== "duplicate") throw new Error("identical duplicate was not idempotent");
|
||||
let eventIdMutationRejected = false;
|
||||
try {
|
||||
await harness.port.appendEvent({
|
||||
...CONTROL_PLANE_CONFORMANCE_EVENTS[1],
|
||||
payload: { mutated: true },
|
||||
});
|
||||
} catch {
|
||||
eventIdMutationRejected = true;
|
||||
}
|
||||
if (!eventIdMutationRejected) throw new Error("mutated event id replay was accepted");
|
||||
let eventSequenceMutationRejected = false;
|
||||
try {
|
||||
await harness.port.appendEvent({
|
||||
...CONTROL_PLANE_CONFORMANCE_EVENTS[1],
|
||||
sourceEventId: "runner-phase6-conformance:mutated-sequence-two",
|
||||
payload: { mutated: true },
|
||||
});
|
||||
} catch {
|
||||
eventSequenceMutationRejected = true;
|
||||
}
|
||||
if (!eventSequenceMutationRejected) throw new Error("mutated source sequence replay was accepted");
|
||||
|
||||
const replay = await harness.port.replayEvents({
|
||||
runId: CONTROL_PLANE_CONFORMANCE_OPEN.identity.runId,
|
||||
@@ -103,6 +139,18 @@ export async function runControlPlanePortConformance(
|
||||
if (replay.events.map((entry) => entry.sourceSeq).join(",") !== "2,3") {
|
||||
throw new Error("exclusive replay cursor returned the wrong event set");
|
||||
}
|
||||
let replayBindingRejected = false;
|
||||
try {
|
||||
await harness.port.replayEvents({
|
||||
runId: "forged-run",
|
||||
sourceInstanceId: "runner-phase6-conformance",
|
||||
afterSourceSeq: 0,
|
||||
limit: 10,
|
||||
});
|
||||
} catch {
|
||||
replayBindingRejected = true;
|
||||
}
|
||||
if (!replayBindingRejected) throw new Error("mismatched replay binding was accepted");
|
||||
|
||||
const completion: CompleteControlPlaneRunInput = {
|
||||
result: CONTROL_PLANE_CONFORMANCE_RESULT,
|
||||
@@ -113,11 +161,26 @@ export async function runControlPlanePortConformance(
|
||||
};
|
||||
await harness.port.completeRun(completion);
|
||||
await harness.port.completeRun(completion);
|
||||
let resultMutationRejected = false;
|
||||
try {
|
||||
await harness.port.completeRun({
|
||||
...completion,
|
||||
result: { ...completion.result, summary: "mutated result replay" },
|
||||
});
|
||||
} catch {
|
||||
resultMutationRejected = true;
|
||||
}
|
||||
if (!resultMutationRejected) throw new Error("mutated result replay was accepted");
|
||||
return {
|
||||
eventCount: replay.highestContiguousSourceSeq,
|
||||
highestContiguousSourceSeq: replay.highestContiguousSourceSeq,
|
||||
duplicateDisposition: duplicate.disposition,
|
||||
terminalReplayIdempotent: true,
|
||||
openBindingRejected,
|
||||
eventIdMutationRejected,
|
||||
eventSequenceMutationRejected,
|
||||
replayBindingRejected,
|
||||
resultMutationRejected,
|
||||
};
|
||||
} finally {
|
||||
await harness.stop?.();
|
||||
|
||||
@@ -101,6 +101,98 @@ function highestContiguous(events: PrpEvent[]): number {
|
||||
}
|
||||
|
||||
describe("executeNativeSession recovery", () => {
|
||||
it("continues a provider-reported active turn without starting a duplicate turn", async () => {
|
||||
const checkpoint: PersistedNativeSession = {
|
||||
backendKind: "mock",
|
||||
sessionId: "driver-recovery",
|
||||
identity,
|
||||
providerSessionId: "provider-recovery",
|
||||
cursor: "0",
|
||||
activeTurnId: null,
|
||||
pendingRuntimeRequests: [],
|
||||
lineage: [],
|
||||
};
|
||||
const providerSnapshot: PersistedNativeSession = {
|
||||
...checkpoint,
|
||||
cursor: "1",
|
||||
activeTurnId: "turn-recovery",
|
||||
};
|
||||
const terminalEvent: PrpEvent = {
|
||||
schema: "paperclip.prp.event.v1",
|
||||
sourceEventId: "provider-recovery:1",
|
||||
sourceSeq: 1,
|
||||
sourceInstanceId: "provider-recovery",
|
||||
sourceKind: "provider",
|
||||
runId: identity.runId,
|
||||
normalizedSessionId: identity.sessionId,
|
||||
turnId: "turn-recovery",
|
||||
eventType: "turn.completed",
|
||||
schemaVersion: 1,
|
||||
priority: 0,
|
||||
emittedAt: "2026-08-09T00:00:00.000Z",
|
||||
payload: {},
|
||||
};
|
||||
const bySource = new Map<string, PrpEvent[]>();
|
||||
const startTurn = vi.fn(async () => ({ turnId: "duplicate-turn" }));
|
||||
const openSession = vi.fn(async () => { throw new Error("must recover the provider session"); });
|
||||
const session: NativeSession = {
|
||||
identity: () => identity,
|
||||
async capabilities() {
|
||||
return { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true };
|
||||
},
|
||||
async *events() { yield terminalEvent; },
|
||||
startTurn,
|
||||
async result() { return { result, terminal, turnId: "turn-recovery" }; },
|
||||
async snapshot() { return structuredClone(providerSnapshot); },
|
||||
async close() {},
|
||||
};
|
||||
const backend: NativeSessionBackend = {
|
||||
async descriptor() {
|
||||
return {
|
||||
kind: "mock",
|
||||
name: "recovery-backend",
|
||||
version: "1",
|
||||
capabilities: { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true },
|
||||
};
|
||||
},
|
||||
openSession,
|
||||
async recoverSession() { return { recovered: true, session }; },
|
||||
};
|
||||
const port: ControlPlanePort = {
|
||||
async openRun() {},
|
||||
async loadSessionCheckpoint() { return structuredClone(checkpoint); },
|
||||
async checkpointSession() {},
|
||||
async appendEvent(event) {
|
||||
const list = bySource.get(event.sourceInstanceId) ?? [];
|
||||
list.push(structuredClone(event));
|
||||
bySource.set(event.sourceInstanceId, list);
|
||||
return {
|
||||
cursor: list.length,
|
||||
highestContiguousSourceSeq: highestContiguous(list),
|
||||
disposition: "committed",
|
||||
};
|
||||
},
|
||||
async replayEvents(replay) {
|
||||
const list = bySource.get(replay.sourceInstanceId) ?? [];
|
||||
return {
|
||||
events: structuredClone(list.filter((event) => event.sourceSeq > replay.afterSourceSeq)),
|
||||
highestContiguousSourceSeq: highestContiguous(list),
|
||||
};
|
||||
},
|
||||
async completeRun() {},
|
||||
};
|
||||
|
||||
await expect(executeNativeSession({
|
||||
input,
|
||||
backend,
|
||||
controlPlane: port,
|
||||
runnerInstanceId: "runner-recovery",
|
||||
controlPlaneInstanceId: "control-recovery",
|
||||
})).resolves.toMatchObject({ turnId: "turn-recovery", providerSessionId: "provider-recovery" });
|
||||
expect(openSession).not.toHaveBeenCalled();
|
||||
expect(startTurn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recovers a completed checkpoint and appends only a missing control terminal fact", async () => {
|
||||
const checkpoint: PersistedNativeSession = {
|
||||
backendKind: "mock",
|
||||
|
||||
@@ -120,19 +120,24 @@ export async function executeNativeSession(options: ExecuteNativeSessionOptions)
|
||||
await options.controlPlane.checkpointSession(await session.snapshot());
|
||||
}
|
||||
};
|
||||
await checkpoint();
|
||||
const recoveredSnapshot = await session.snapshot();
|
||||
await options.controlPlane.checkpointSession?.(recoveredSnapshot);
|
||||
|
||||
let consumed = { event: null as PrpEvent | null, eventCount: 0, highestContiguousSourceSeq: 0 };
|
||||
let completed = persistedSession?.semanticResult && persistedSession.terminal
|
||||
const completionSnapshot = recoveredSnapshot.semanticResult && recoveredSnapshot.terminal
|
||||
? recoveredSnapshot
|
||||
: persistedSession;
|
||||
let completed = completionSnapshot?.semanticResult && completionSnapshot.terminal
|
||||
? {
|
||||
result: persistedSession.semanticResult,
|
||||
terminal: persistedSession.terminal,
|
||||
turnId: persistedSession.activeTurnId ?? null,
|
||||
result: completionSnapshot.semanticResult,
|
||||
terminal: completionSnapshot.terminal,
|
||||
turnId: completionSnapshot.activeTurnId ?? null,
|
||||
}
|
||||
: null;
|
||||
if (!completed) {
|
||||
const consuming = consumeTurn(session, options.controlPlane, options.timeoutMs ?? 900_000);
|
||||
if (!recovered || !persistedSession?.activeTurnId) {
|
||||
const recoveredActiveTurnId = recoveredSnapshot.activeTurnId ?? persistedSession?.activeTurnId ?? null;
|
||||
if (!recovered || !recoveredActiveTurnId) {
|
||||
await session.startTurn({
|
||||
message: { role: "user", text: JSON.stringify(buildNativeModelEnvelope(input)) },
|
||||
});
|
||||
@@ -145,8 +150,8 @@ export async function executeNativeSession(options: ExecuteNativeSessionOptions)
|
||||
}
|
||||
if (completed === null) throw new Error("native_finalization_missing: session returned no semantic result");
|
||||
let terminal: PrpTerminalState;
|
||||
if (persistedSession?.semanticResult && persistedSession.terminal) {
|
||||
terminal = persistedSession.terminal;
|
||||
if (completionSnapshot?.semanticResult && completionSnapshot.terminal) {
|
||||
terminal = completionSnapshot.terminal;
|
||||
} else {
|
||||
terminal = terminalFromEvent(consumed.event!, completed.result.reportedWorkDisposition);
|
||||
}
|
||||
|
||||
@@ -1,80 +1,139 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
completionContracts,
|
||||
createDb,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
nativeRunFinalizations,
|
||||
nativeRunResults,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
statusDecisions,
|
||||
workAssessments,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { drainHeartbeatRunsToQuiescence } from "./helpers/drain-heartbeat-runs.js";
|
||||
|
||||
const adapterExecute = vi.hoisted(() => vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
summary: "Legacy adapter completed through the flag-off heartbeat.",
|
||||
resultJson: { summary: "Legacy bytes", nested: { count: 1, ok: true } },
|
||||
provider: "test",
|
||||
model: "legacy-test",
|
||||
})));
|
||||
|
||||
vi.mock("../adapters/index.js", () => ({
|
||||
getServerAdapter: () => ({
|
||||
type: "codex_local",
|
||||
execute: adapterExecute,
|
||||
supportsLocalAgentJwt: false,
|
||||
}),
|
||||
findActiveServerAdapter: () => ({
|
||||
type: "codex_local",
|
||||
execute: adapterExecute,
|
||||
supportsLocalAgentJwt: false,
|
||||
}),
|
||||
listAdapterModelProfiles: async () => [],
|
||||
runningProcesses: new Map(),
|
||||
}));
|
||||
|
||||
import { heartbeatService } from "../services/heartbeat.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { reconcileNativeFinalizations } from "../services/native-runtime/native-finalization-reconciler.js";
|
||||
|
||||
describe("P6-32 legacy finalization regression", () => {
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
const companyId = "71000000-0000-4000-8000-000000000001";
|
||||
const agentId = "71000000-0000-4000-8000-000000000002";
|
||||
const runId = "71000000-0000-4000-8000-000000000003";
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
|
||||
beforeAll(async () => {
|
||||
temporary = await startEmbeddedPostgresTestDatabase("paperclip-native-legacy-");
|
||||
db = createDb(temporary.connectionString);
|
||||
await db.insert(companies).values({ id: companyId, name: "Legacy snapshot", issuePrefix: "LGC" });
|
||||
await instanceSettingsService(db).updateExperimental({ enableNativeRunner: false });
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Legacy snapshot",
|
||||
issuePrefix: "LGC",
|
||||
status: "active",
|
||||
defaultResponsibleUserId: "responsible-user",
|
||||
});
|
||||
await db.insert(projects).values({ id: projectId, companyId, name: "Legacy project", status: "active" });
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
cwd: fileURLToPath(new URL("../../../", import.meta.url)),
|
||||
isPrimary: true,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Legacy agent",
|
||||
adapterType: "codex_local",
|
||||
status: "idle",
|
||||
runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } },
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "succeeded",
|
||||
runtimeMode: "legacy",
|
||||
nextEventSeq: 8,
|
||||
exitCode: 0,
|
||||
resultJson: { summary: "Legacy bytes", nested: { count: 1, ok: true } },
|
||||
contextSnapshot: { issueId: "legacy-opaque-issue" },
|
||||
});
|
||||
await db.insert(heartbeatRunEvents).values({
|
||||
companyId,
|
||||
runId,
|
||||
agentId,
|
||||
seq: 7,
|
||||
eventType: "lifecycle",
|
||||
stream: "system",
|
||||
message: "Legacy event bytes",
|
||||
payload: { state: "completed", nested: [1, "two", false] },
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
title: "Complete through the legacy adapter",
|
||||
status: "in_progress",
|
||||
workMode: "standard",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await temporary.cleanup();
|
||||
if (temporary) {
|
||||
await drainHeartbeatRunsToQuiescence(db, heartbeatService(db));
|
||||
await temporary.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the post-kill-switch legacy read snapshot byte-equivalent with zero native rows", async () => {
|
||||
const beforeRun = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
||||
const beforeEvents = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, runId));
|
||||
const beforeBytes = JSON.stringify({ run: beforeRun, events: beforeEvents });
|
||||
it("executes a flag-off heartbeat through the legacy adapter with byte-stable reads and zero native rows", async () => {
|
||||
const heartbeat = heartbeatService(db);
|
||||
const queued = await heartbeat.wakeup(agentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_commented",
|
||||
payload: { issueId },
|
||||
contextSnapshot: { issueId, taskId: issueId, skipIssueComment: true },
|
||||
});
|
||||
expect(queued).not.toBeNull();
|
||||
await drainHeartbeatRunsToQuiescence(db, heartbeat);
|
||||
const runId = queued!.id;
|
||||
const publicBefore = await heartbeat.getRun(runId);
|
||||
|
||||
expect(adapterExecute).toHaveBeenCalledOnce();
|
||||
expect(publicBefore).toMatchObject({
|
||||
id: runId,
|
||||
status: "succeeded",
|
||||
runtimeMode: "legacy",
|
||||
runtimeModeReason: "instance_flag_disabled",
|
||||
resultJson: { summary: "Legacy bytes", nested: { count: 1, ok: true } },
|
||||
});
|
||||
await expect(reconcileNativeFinalizations(db, [runId])).resolves.toEqual([]);
|
||||
expect(JSON.stringify(await heartbeat.getRun(runId))).toBe(JSON.stringify(publicBefore));
|
||||
|
||||
const afterRun = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
||||
const afterEvents = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, runId));
|
||||
expect(JSON.stringify({ run: afterRun, events: afterEvents })).toBe(beforeBytes);
|
||||
await expect(db.select().from(completionContracts).where(eq(completionContracts.companyId, companyId))).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, runId))).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, runId))).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(workAssessments).where(eq(workAssessments.runId, runId))).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(statusDecisions)
|
||||
.where(eq(statusDecisions.companyId, companyId))).resolves.toHaveLength(0);
|
||||
});
|
||||
await expect(db.select().from(statusDecisions).where(eq(statusDecisions.companyId, companyId))).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId))).resolves.toHaveLength(1);
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
applyPendingMigrations,
|
||||
createDb,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
@@ -13,38 +13,81 @@ import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.j
|
||||
describe("P6-18 / MIG-01..04 native finalization migration", () => {
|
||||
it("repairs only later duplicates and preserves legacy event bytes and cursors", async () => {
|
||||
const temporary = await startEmbeddedPostgresTestDatabase("paperclip-native-migration-");
|
||||
const db = createDb(temporary.connectionString);
|
||||
const migration = await readFile(
|
||||
new URL("../../../packages/db/src/migrations/0211_famous_guardsmen.sql", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const migrationHash = createHash("sha256").update(migration).digest("hex");
|
||||
const rawDb = createDb(temporary.connectionString);
|
||||
try {
|
||||
const companyId = "10000000-0000-4000-8000-000000000001";
|
||||
const agentId = "10000000-0000-4000-8000-000000000002";
|
||||
const runId = "10000000-0000-4000-8000-000000000003";
|
||||
await db.insert(companies).values({ id: companyId, name: "Migration fixture", issuePrefix: "MIG" });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Migration agent" });
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "succeeded", nextEventSeq: 1 });
|
||||
await db.execute(sql.raw("DROP INDEX heartbeat_run_events_run_seq_uq"));
|
||||
await db.insert(heartbeatRunEvents).values([
|
||||
{ companyId, runId, agentId, seq: 1, eventType: "legacy.start", stream: "system", level: "info", message: "one", payload: { bytes: "α-1" }, createdAt: new Date("2026-08-01T00:00:01.000Z") },
|
||||
{ companyId, runId, agentId, seq: 5, eventType: "legacy.log", stream: "stdout", level: "info", message: "first-five", payload: { bytes: "β-5a" }, createdAt: new Date("2026-08-01T00:00:02.000Z") },
|
||||
{ companyId, runId, agentId, seq: 5, eventType: "legacy.log", stream: "stderr", level: "warn", message: "duplicate-five", payload: { bytes: "γ-5b" }, createdAt: new Date("2026-08-01T00:00:03.000Z") },
|
||||
{ companyId, runId, agentId, seq: 9, eventType: "legacy.end", stream: "system", level: "info", message: "nine", payload: { bytes: "δ-9" }, createdAt: new Date("2026-08-01T00:00:04.000Z") },
|
||||
]);
|
||||
// Reconstruct the actual pre-0211 shape rather than extracting selected
|
||||
// repair statements from the migration under test.
|
||||
await rawDb.execute(sql.raw(`
|
||||
DROP TABLE IF EXISTS status_decision_effects, status_decisions, work_assessments,
|
||||
native_run_finalizations, native_run_results, completion_contracts CASCADE;
|
||||
DROP TRIGGER IF EXISTS paperclip_issue_status_version_trigger ON issues;
|
||||
DROP FUNCTION IF EXISTS paperclip_bump_issue_status_version();
|
||||
DROP INDEX IF EXISTS heartbeat_run_events_run_seq_uq;
|
||||
DROP INDEX IF EXISTS heartbeat_run_events_run_source_event_uq;
|
||||
DROP INDEX IF EXISTS heartbeat_run_events_run_source_seq_uq;
|
||||
ALTER TABLE heartbeat_run_events
|
||||
DROP COLUMN IF EXISTS source_instance_id,
|
||||
DROP COLUMN IF EXISTS source_event_id,
|
||||
DROP COLUMN IF EXISTS source_seq,
|
||||
DROP COLUMN IF EXISTS source_payload_sha256,
|
||||
DROP COLUMN IF EXISTS protocol_schema_version;
|
||||
ALTER TABLE heartbeat_run_events ALTER COLUMN seq TYPE integer;
|
||||
ALTER TABLE heartbeat_runs
|
||||
DROP COLUMN IF EXISTS runtime_mode,
|
||||
DROP COLUMN IF EXISTS runtime_mode_resolver_version,
|
||||
DROP COLUMN IF EXISTS runtime_mode_reason,
|
||||
DROP COLUMN IF EXISTS runtime_mode_resolved_at,
|
||||
DROP COLUMN IF EXISTS runner_profile_json,
|
||||
DROP COLUMN IF EXISTS runner_instance_id,
|
||||
DROP COLUMN IF EXISTS native_session_id,
|
||||
DROP COLUMN IF EXISTS driver_kind,
|
||||
DROP COLUMN IF EXISTS driver_version,
|
||||
DROP COLUMN IF EXISTS completion_contract_id,
|
||||
DROP COLUMN IF EXISTS completion_contract_sha256,
|
||||
DROP COLUMN IF EXISTS next_event_seq,
|
||||
DROP COLUMN IF EXISTS native_phase,
|
||||
DROP COLUMN IF EXISTS native_phase_updated_at;
|
||||
ALTER TABLE issues
|
||||
DROP COLUMN IF EXISTS status_version,
|
||||
DROP COLUMN IF EXISTS last_status_decision_id;
|
||||
`));
|
||||
await rawDb.execute(sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${migrationHash}`);
|
||||
await rawDb.execute(sql`
|
||||
INSERT INTO companies (id, name, issue_prefix)
|
||||
VALUES (${companyId}, 'Migration fixture', 'MIG')
|
||||
`);
|
||||
await rawDb.execute(sql`
|
||||
INSERT INTO agents (id, company_id, name)
|
||||
VALUES (${agentId}, ${companyId}, 'Migration agent')
|
||||
`);
|
||||
await rawDb.execute(sql`
|
||||
INSERT INTO heartbeat_runs (id, company_id, agent_id, status)
|
||||
VALUES (${runId}, ${companyId}, ${agentId}, 'succeeded')
|
||||
`);
|
||||
await rawDb.execute(sql`
|
||||
INSERT INTO heartbeat_run_events
|
||||
(company_id, run_id, agent_id, seq, event_type, stream, level, message, payload, created_at)
|
||||
VALUES
|
||||
(${companyId}, ${runId}, ${agentId}, 1, 'legacy.start', 'system', 'info', 'one', ${JSON.stringify({ bytes: "α-1" })}::jsonb, '2026-08-01T00:00:01.000Z'),
|
||||
(${companyId}, ${runId}, ${agentId}, 5, 'legacy.log', 'stdout', 'info', 'first-five', ${JSON.stringify({ bytes: "β-5a" })}::jsonb, '2026-08-01T00:00:02.000Z'),
|
||||
(${companyId}, ${runId}, ${agentId}, 5, 'legacy.log', 'stderr', 'warn', 'duplicate-five', ${JSON.stringify({ bytes: "γ-5b" })}::jsonb, '2026-08-01T00:00:03.000Z'),
|
||||
(${companyId}, ${runId}, ${agentId}, 9, 'legacy.end', 'system', 'info', 'nine', ${JSON.stringify({ bytes: "δ-9" })}::jsonb, '2026-08-01T00:00:04.000Z')
|
||||
`);
|
||||
const beforeResult = await rawDb.execute(sql`
|
||||
SELECT * FROM heartbeat_run_events WHERE run_id = ${runId} ORDER BY id
|
||||
`);
|
||||
const before = [...beforeResult] as unknown as Record<string, unknown>[];
|
||||
|
||||
const before = await db.select().from(heartbeatRunEvents)
|
||||
.where(eq(heartbeatRunEvents.runId, runId)).orderBy(heartbeatRunEvents.id);
|
||||
const migration = await readFile(
|
||||
new URL("../../../packages/db/src/migrations/0211_famous_guardsmen.sql", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const statements = migration.split("--> statement-breakpoint").map((entry) => entry.trim());
|
||||
const duplicateRepair = statements.find((entry) => entry.includes("WITH ranked AS"));
|
||||
const allocatorSeed = statements.find((entry) => entry.startsWith('UPDATE "heartbeat_runs" AS run'));
|
||||
expect(duplicateRepair).toBeTruthy();
|
||||
expect(allocatorSeed).toBeTruthy();
|
||||
await db.execute(sql.raw(duplicateRepair!));
|
||||
await db.execute(sql.raw(allocatorSeed!));
|
||||
await db.execute(sql.raw(
|
||||
'CREATE UNIQUE INDEX "heartbeat_run_events_run_seq_uq" ON "heartbeat_run_events" ("run_id", "seq")',
|
||||
));
|
||||
await applyPendingMigrations(temporary.connectionString);
|
||||
const db = createDb(temporary.connectionString);
|
||||
|
||||
const after = await db.select().from(heartbeatRunEvents)
|
||||
.where(eq(heartbeatRunEvents.runId, runId)).orderBy(heartbeatRunEvents.id);
|
||||
@@ -53,10 +96,22 @@ describe("P6-18 / MIG-01..04 native finalization migration", () => {
|
||||
.where(eq(heartbeatRuns.id, runId)))[0]?.nextEventSeq).toBe(11);
|
||||
|
||||
// The repaired duplicate's cursor is the only changed byte-equivalent read field.
|
||||
expect(after.map(({ seq: _seq, ...row }) => row)).toEqual(before.map(({ seq: _seq, ...row }) => row));
|
||||
expect(after[0]?.seq).toBe(before[0]?.seq);
|
||||
expect(after[1]?.seq).toBe(before[1]?.seq);
|
||||
expect(after[3]?.seq).toBe(before[3]?.seq);
|
||||
const legacyColumns = (row: Record<string, unknown>) => ({
|
||||
id: String(row.id),
|
||||
companyId: row.companyId ?? row.company_id,
|
||||
runId: row.runId ?? row.run_id,
|
||||
agentId: row.agentId ?? row.agent_id,
|
||||
eventType: row.eventType ?? row.event_type,
|
||||
stream: row.stream,
|
||||
level: row.level,
|
||||
message: row.message,
|
||||
payload: row.payload,
|
||||
createdAt: new Date(String(row.createdAt ?? row.created_at)).toISOString(),
|
||||
});
|
||||
expect(after.map((row) => legacyColumns(row))).toEqual(before.map(legacyColumns));
|
||||
expect(after[0]?.seq).toBe(Number(before[0]?.seq));
|
||||
expect(after[1]?.seq).toBe(Number(before[1]?.seq));
|
||||
expect(after[3]?.seq).toBe(Number(before[3]?.seq));
|
||||
await expect(db.insert(heartbeatRunEvents).values({
|
||||
companyId, runId, agentId, seq: 5, eventType: "must-conflict",
|
||||
})).rejects.toThrow();
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import {
|
||||
materializeNativeInteractionResponses,
|
||||
NativeInteractionBridgeError,
|
||||
} from "../services/native-runtime/native-interaction-bridge.js";
|
||||
|
||||
describe("P6-19 native interaction bridge", () => {
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
const companyId = "78000000-0000-4000-8000-000000000001";
|
||||
const agentId = "78000000-0000-4000-8000-000000000002";
|
||||
const issueId = "78000000-0000-4000-8000-000000000003";
|
||||
const runId = "78000000-0000-4000-8000-000000000004";
|
||||
const confirmationId = "78000000-0000-4000-8000-000000000005";
|
||||
const questionsId = "78000000-0000-4000-8000-000000000006";
|
||||
const governedId = "78000000-0000-4000-8000-000000000007";
|
||||
const selfApprovedId = "78000000-0000-4000-8000-000000000008";
|
||||
|
||||
beforeAll(async () => {
|
||||
temporary = await startEmbeddedPostgresTestDatabase("paperclip-native-interaction-");
|
||||
db = createDb(temporary.connectionString);
|
||||
await db.insert(companies).values({ id: companyId, name: "Native interaction", issuePrefix: "NIB" });
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Native interaction agent",
|
||||
adapterType: "codex_local",
|
||||
status: "running",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Consume an authorized interaction response",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: agentId,
|
||||
workMode: "standard",
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "running",
|
||||
contextSnapshot: { issueId, interactionId: confirmationId },
|
||||
});
|
||||
await db.insert(issueThreadInteractions).values([
|
||||
{
|
||||
id: confirmationId,
|
||||
companyId,
|
||||
issueId,
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
resolvedByUserId: "board-user",
|
||||
resolvedAt: new Date(),
|
||||
payload: { version: 1, prompt: "Continue?" },
|
||||
result: { version: 1, outcome: "accepted" },
|
||||
},
|
||||
{
|
||||
id: questionsId,
|
||||
companyId,
|
||||
issueId,
|
||||
kind: "ask_user_questions",
|
||||
status: "answered",
|
||||
resolvedByUserId: "board-user",
|
||||
resolvedAt: new Date(),
|
||||
payload: {
|
||||
version: 1,
|
||||
questions: [{
|
||||
id: "choice",
|
||||
prompt: "Which path?",
|
||||
selectionMode: "single",
|
||||
options: [{ id: "safe", label: "Safe" }],
|
||||
}],
|
||||
},
|
||||
result: { version: 1, answers: [{ questionId: "choice", optionIds: ["safe"] }] },
|
||||
},
|
||||
{
|
||||
id: governedId,
|
||||
companyId,
|
||||
issueId,
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
resolvedByUserId: "board-user",
|
||||
resolvedAt: new Date(),
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Execute write?",
|
||||
toolAction: {
|
||||
version: 1,
|
||||
actionRequestId: "78000000-0000-4000-8000-000000000010",
|
||||
invocationId: "78000000-0000-4000-8000-000000000011",
|
||||
toolName: "write",
|
||||
toolDisplayName: "Write",
|
||||
connectionId: null,
|
||||
applicationId: null,
|
||||
appDisplayName: null,
|
||||
risk: "write",
|
||||
previewMarkdown: "write",
|
||||
argumentsSummaryJson: "{}",
|
||||
argumentsHash: "hash",
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
},
|
||||
result: { version: 1, outcome: "accepted" },
|
||||
},
|
||||
{
|
||||
id: selfApprovedId,
|
||||
companyId,
|
||||
issueId,
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
createdByAgentId: agentId,
|
||||
resolvedByAgentId: agentId,
|
||||
resolvedByRunId: runId,
|
||||
resolvedAt: new Date(),
|
||||
payload: { version: 1, prompt: "Self approve?" },
|
||||
result: { version: 1, outcome: "accepted" },
|
||||
},
|
||||
]);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => temporary?.cleanup());
|
||||
|
||||
it("projects supported typed responses through the authorized interaction service", async () => {
|
||||
await expect(materializeNativeInteractionResponses({
|
||||
db,
|
||||
companyId,
|
||||
issueId,
|
||||
runId,
|
||||
agentId,
|
||||
interactionIds: [questionsId, confirmationId],
|
||||
})).resolves.toEqual([
|
||||
{
|
||||
interactionId: confirmationId,
|
||||
kind: "request_confirmation",
|
||||
response: { status: "accepted", result: { version: 1, outcome: "accepted" } },
|
||||
},
|
||||
{
|
||||
interactionId: questionsId,
|
||||
kind: "ask_user_questions",
|
||||
response: {
|
||||
status: "answered",
|
||||
result: { version: 1, answers: [{ questionId: "choice", optionIds: ["safe"] }] },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[governedId, "native_interaction_governed_request_unsupported"],
|
||||
[selfApprovedId, "native_interaction_self_approval"],
|
||||
])("fails closed for governed or self-approved interaction %s", async (interactionId, code) => {
|
||||
const error = await materializeNativeInteractionResponses({
|
||||
db,
|
||||
companyId,
|
||||
issueId,
|
||||
runId,
|
||||
agentId,
|
||||
interactionIds: [interactionId],
|
||||
}).catch((caught) => caught);
|
||||
expect(error).toBeInstanceOf(NativeInteractionBridgeError);
|
||||
expect(error).toMatchObject({ code });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
issueRecoveryActions,
|
||||
issues,
|
||||
nativeRunFinalizations,
|
||||
} from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import {
|
||||
claimNativeSessionResumptions,
|
||||
dispatchNativeSessionResumptions,
|
||||
} from "../services/native-runtime/native-finalization-reconciler.js";
|
||||
|
||||
describe("P6-25 pre-result native session recovery", () => {
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
const companyId = "79000000-0000-4000-8000-000000000001";
|
||||
const agentId = "79000000-0000-4000-8000-000000000002";
|
||||
const issueId = "79000000-0000-4000-8000-000000000003";
|
||||
const runId = "79000000-0000-4000-8000-000000000004";
|
||||
const cancelledRunId = "79000000-0000-4000-8000-000000000005";
|
||||
const exhaustedRunId = "79000000-0000-4000-8000-000000000006";
|
||||
const missingCheckpointRunId = "79000000-0000-4000-8000-000000000007";
|
||||
const initialRunId = "79000000-0000-4000-8000-000000000008";
|
||||
const persistedProfile = {
|
||||
mode: "native",
|
||||
nativeExecutionInput: { schema: "paperclip.native-execution-input.v1", binding: { runId } },
|
||||
sessionCheckpoint: {
|
||||
backendKind: "codex_app_server",
|
||||
sessionId: "persisted-session",
|
||||
identity: { runId },
|
||||
providerSessionId: "provider-session",
|
||||
activeTurnId: "active-turn",
|
||||
},
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
temporary = await startEmbeddedPostgresTestDatabase("paperclip-native-resume-");
|
||||
db = createDb(temporary.connectionString);
|
||||
await db.insert(companies).values({ id: companyId, name: "Native resume", issuePrefix: "NRR" });
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Native resume agent",
|
||||
adapterType: "codex_local",
|
||||
status: "running",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Resume the same native run",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: agentId,
|
||||
workMode: "standard",
|
||||
});
|
||||
await db.insert(heartbeatRuns).values([
|
||||
{
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "running",
|
||||
runtimeMode: "native",
|
||||
runtimeModeResolvedAt: new Date(),
|
||||
runnerProfileJson: persistedProfile,
|
||||
contextSnapshot: { issueId },
|
||||
},
|
||||
{
|
||||
id: cancelledRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "cancelled",
|
||||
runtimeMode: "native",
|
||||
runtimeModeResolvedAt: new Date(),
|
||||
runnerProfileJson: persistedProfile,
|
||||
contextSnapshot: { issueId },
|
||||
},
|
||||
{
|
||||
id: exhaustedRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "failed",
|
||||
runtimeMode: "native",
|
||||
runtimeModeResolvedAt: new Date(),
|
||||
runnerProfileJson: persistedProfile,
|
||||
contextSnapshot: { issueId },
|
||||
},
|
||||
{
|
||||
id: missingCheckpointRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "running",
|
||||
runtimeMode: "native",
|
||||
runtimeModeResolvedAt: new Date(),
|
||||
runnerProfileJson: { nativeExecutionInput: persistedProfile.nativeExecutionInput },
|
||||
contextSnapshot: { issueId },
|
||||
},
|
||||
{
|
||||
id: initialRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "running",
|
||||
runtimeMode: "native",
|
||||
runtimeModeResolvedAt: new Date(),
|
||||
runnerProfileJson: { nativeExecutionInput: persistedProfile.nativeExecutionInput },
|
||||
contextSnapshot: { issueId },
|
||||
},
|
||||
]);
|
||||
await db.insert(nativeRunFinalizations).values([
|
||||
{ runId, companyId, issueId, phase: "retryable_failure", attempt: 1, nextAttemptAt: new Date(0) },
|
||||
{ runId: cancelledRunId, companyId, issueId, phase: "retryable_failure", attempt: 1, nextAttemptAt: new Date(0) },
|
||||
{ runId: exhaustedRunId, companyId, issueId, phase: "terminal_failure", attempt: 3 },
|
||||
{ runId: missingCheckpointRunId, companyId, issueId, phase: "observed", attempt: 1 },
|
||||
{ runId: initialRunId, companyId, issueId, phase: "observed", attempt: 0 },
|
||||
]);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => temporary?.cleanup());
|
||||
|
||||
it("wins one database lease for the original result-less run without consulting the flag", async () => {
|
||||
const results = await Promise.all([
|
||||
claimNativeSessionResumptions({ db, runnerInstanceId: "reaper-a", runIds: [runId] }),
|
||||
claimNativeSessionResumptions({ db, runnerInstanceId: "reaper-b", runIds: [runId] }),
|
||||
]);
|
||||
expect(results.flat()).toHaveLength(1);
|
||||
expect(results.flat()[0]).toMatchObject({ runId });
|
||||
await expect(db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId))).resolves.toEqual([
|
||||
expect.objectContaining({ id: runId, status: "running", runtimeMode: "native" }),
|
||||
]);
|
||||
await expect(db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, runId))).resolves.toEqual([
|
||||
expect.objectContaining({ runId, phase: "observed", resultId: null, attempt: 1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("dispatches the persisted run id and lease to the live same-run resume consumer", async () => {
|
||||
await db.update(nativeRunFinalizations).set({
|
||||
phase: "retryable_failure",
|
||||
leaseOwner: null,
|
||||
leaseExpiresAt: null,
|
||||
nextAttemptAt: new Date(0),
|
||||
}).where(eq(nativeRunFinalizations.runId, runId));
|
||||
const dispatched: Array<{ runId: string; leaseOwner: string }> = [];
|
||||
await expect(dispatchNativeSessionResumptions({
|
||||
db,
|
||||
runnerInstanceId: "heartbeat-reaper",
|
||||
runIds: [runId],
|
||||
dispatch: (claim) => dispatched.push(claim),
|
||||
})).resolves.toHaveLength(1);
|
||||
expect(dispatched).toEqual([{ runId, leaseOwner: expect.stringContaining("heartbeat-reaper:resume:") }]);
|
||||
await expect(db.select().from(heartbeatRuns)).resolves.toHaveLength(5);
|
||||
await expect(db.select().from(nativeRunFinalizations)).resolves.toHaveLength(5);
|
||||
});
|
||||
|
||||
it("does not resume cancelled or exhausted runs and fails closed without a checkpoint", async () => {
|
||||
await expect(claimNativeSessionResumptions({
|
||||
db,
|
||||
runnerInstanceId: "reaper",
|
||||
runIds: [cancelledRunId, exhaustedRunId, missingCheckpointRunId],
|
||||
})).resolves.toEqual([]);
|
||||
await expect(db.select().from(nativeRunFinalizations)
|
||||
.where(eq(nativeRunFinalizations.runId, missingCheckpointRunId))).resolves.toEqual([
|
||||
expect.objectContaining({ phase: "terminal_failure", failureCode: "native_session_checkpoint_missing" }),
|
||||
]);
|
||||
await expect(db.select().from(issueRecoveryActions)
|
||||
.where(eq(issueRecoveryActions.sourceIssueId, issueId))).resolves.toEqual([
|
||||
expect.objectContaining({ cause: "native_session_checkpoint_missing", wakePolicy: null }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not mistake the pre-first-attempt observed coordinator for an orphan", async () => {
|
||||
await expect(claimNativeSessionResumptions({
|
||||
db,
|
||||
runnerInstanceId: "reaper",
|
||||
runIds: [initialRunId],
|
||||
})).resolves.toEqual([]);
|
||||
await expect(db.select().from(nativeRunFinalizations)
|
||||
.where(eq(nativeRunFinalizations.runId, initialRunId))).resolves.toEqual([
|
||||
expect.objectContaining({ phase: "observed", attempt: 0, failureCode: null }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -2,34 +2,34 @@ import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { NativeEvidenceAssessment } from "../services/native-runtime/evidence-classifier.js";
|
||||
import { rejectUnsupportedNativeRuntimeRequest } from "../services/native-runtime/native-interaction-bridge.js";
|
||||
import { nativeSessionFailureDisposition } from "../services/native-runtime/native-session-executor.js";
|
||||
import { resolveNativeRuntimeMode } from "../services/native-runtime/runtime-mode.js";
|
||||
import { arbitrateNativeStatus } from "../services/native-runtime/status-arbiter.js";
|
||||
|
||||
type Fixture = {
|
||||
id: string;
|
||||
mode: "native" | "legacy";
|
||||
covers: Record<string, string[]>;
|
||||
expected: Record<string, unknown>;
|
||||
given: Record<string, unknown>;
|
||||
expected: {
|
||||
statusAction: string;
|
||||
reasonCode: string | null;
|
||||
requiredEffects: string[];
|
||||
forbiddenEffects: string[];
|
||||
livePathKind: string | null;
|
||||
nativeRecords: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type Corpus = {
|
||||
schema: string;
|
||||
corpusRevision: number;
|
||||
fixtures: Fixture[];
|
||||
};
|
||||
type Corpus = { schema: string; corpusRevision: number; fixtures: Fixture[] };
|
||||
|
||||
const corpusPath = fileURLToPath(new URL(
|
||||
"../../../packages/paperclip-runner/spec/fixtures/status-authority-phase5.json",
|
||||
import.meta.url,
|
||||
));
|
||||
|
||||
const consumerByMatrixPrefix: Record<string, string> = {
|
||||
SD: "server/native-status-arbiter-and-committer",
|
||||
TC: "package/native-session-runtime",
|
||||
ATT: "server/native-interaction-bridge",
|
||||
LIVE: "server/atomic-liveness-failpoints",
|
||||
REC: "server/native-finalization-recovery",
|
||||
COMP: "server/legacy-finalization-regression",
|
||||
MIG: "server/native-finalization-migration",
|
||||
};
|
||||
|
||||
function canonical(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
||||
if (value && typeof value === "object") {
|
||||
@@ -41,55 +41,151 @@ function canonical(value: unknown): string {
|
||||
return JSON.stringify(value) ?? "null";
|
||||
}
|
||||
|
||||
function emitConsumerResults(corpus: Corpus) {
|
||||
return corpus.fixtures.flatMap((fixture) => {
|
||||
const coveredRows = Object.values(fixture.covers).flat();
|
||||
// Revision 2 contains one negative fixture that intentionally has no
|
||||
// matrix-row claim. It still needs an independently joinable consumer.
|
||||
if (coveredRows.length === 0) {
|
||||
return [{
|
||||
corpusRevision: corpus.corpusRevision,
|
||||
fixtureId: fixture.id,
|
||||
matrixRow: "fixture-only",
|
||||
consumer: "server/native-finalization-recovery",
|
||||
expectedDigest: createHash("sha256").update(canonical(fixture.expected)).digest("hex"),
|
||||
}];
|
||||
}
|
||||
return coveredRows.map((matrixRow) => {
|
||||
const prefix = matrixRow.split("-")[0]!;
|
||||
const consumer = consumerByMatrixPrefix[prefix];
|
||||
if (!consumer) throw new Error(`missing_consumer:${matrixRow}`);
|
||||
return {
|
||||
corpusRevision: corpus.corpusRevision,
|
||||
fixtureId: fixture.id,
|
||||
matrixRow,
|
||||
consumer,
|
||||
expectedDigest: createHash("sha256").update(canonical(fixture.expected)).digest("hex"),
|
||||
};
|
||||
});
|
||||
});
|
||||
function assessmentFor(fixture: Fixture): NativeEvidenceAssessment {
|
||||
const completionState = String(fixture.given.completionState ?? "");
|
||||
const disposition = ["done", "blocked", "needs_review", "yielded"].includes(String(fixture.given.reportedWorkDisposition))
|
||||
? fixture.given.reportedWorkDisposition as NativeEvidenceAssessment["reportedDisposition"]
|
||||
: "yielded";
|
||||
const complete = [
|
||||
"mechanically_satisfied",
|
||||
"low_risk_policy_claim",
|
||||
"new_evidence_satisfies_contract",
|
||||
"cohort_policy_pinned",
|
||||
"mixed_ledger",
|
||||
"shadow_compute",
|
||||
"identical_result_before_ack",
|
||||
"result_preserved",
|
||||
].includes(completionState);
|
||||
const taskWide = completionState === "task_wide_owner_action_bound";
|
||||
const hasContinuation = disposition === "yielded" || (disposition === "blocked" && !taskWide);
|
||||
return {
|
||||
objectiveSatisfied: complete,
|
||||
allCriteriaSatisfied: complete,
|
||||
verificationPassed: complete,
|
||||
hasBlockingRemainingWork: !complete,
|
||||
reportedDisposition: disposition,
|
||||
summary: fixture.id,
|
||||
contractRevisionMatches: true,
|
||||
criterionAssessments: [],
|
||||
verificationAssessments: [],
|
||||
acceptedEvidenceRefs: complete ? ["fixture:authoritative-record"] : [],
|
||||
missingRequirements: complete ? [] : [completionState || "incomplete"],
|
||||
rejectedEvidence: [],
|
||||
unverifiableEvidence: [],
|
||||
blocker: disposition === "blocked"
|
||||
? { unblockAction: `Resolve ${fixture.id}`, boardOwned: true, scope: taskWide ? "task_wide" : "current_track" }
|
||||
: null,
|
||||
continuation: hasContinuation
|
||||
? { kind: "same_agent", summary: `Continue ${fixture.id}`, idempotencyKey: `fixture:${fixture.id}` }
|
||||
: null,
|
||||
attentionRequests: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("P6-31 Section 18.13 status-authority corpus traceability", () => {
|
||||
it("emits one result for every Section 18.13 fixture and consumer", () => {
|
||||
function executeFixture(fixture: Fixture) {
|
||||
const mode = resolveNativeRuntimeMode({
|
||||
enabled: fixture.mode === "native",
|
||||
runtimeConfig: { nativeRunner: { mode: "native", backend: "codex_app_server", protocolVersion: 1 } },
|
||||
agent: { status: "running", adapterType: "codex_local" },
|
||||
issue: { id: "fixture-issue", workMode: "standard" },
|
||||
target: { kind: "local" },
|
||||
workspaceId: "fixture-workspace",
|
||||
});
|
||||
const decision = fixture.mode === "native"
|
||||
? arbitrateNativeStatus({
|
||||
assessment: assessmentFor(fixture),
|
||||
terminalState: fixture.given.runTerminalState === "failed"
|
||||
? "failed"
|
||||
: fixture.given.runTerminalState === "cancelled" ? "cancelled" : "succeeded",
|
||||
workspaceFinalizeStatus: fixture.given.fault === "workspace_finalize_failure" ? "failed" : "succeeded",
|
||||
governanceGate: fixture.given.completionState === "governed_gate_pending"
|
||||
? { kind: "interaction", id: "fixture-interaction" }
|
||||
: null,
|
||||
agentId: "fixture-agent",
|
||||
priorIssueStatus: ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"]
|
||||
.includes(String(fixture.given.priorIssueStatus))
|
||||
? fixture.given.priorIssueStatus as Parameters<typeof arbitrateNativeStatus>[0]["priorIssueStatus"]
|
||||
: "in_progress",
|
||||
})
|
||||
: null;
|
||||
const attention = rejectUnsupportedNativeRuntimeRequest(`fixture:${fixture.id}`);
|
||||
const recovery = nativeSessionFailureDisposition(
|
||||
fixture.given.completionState === "resolver_budget_exhausted" ? 3 : 1,
|
||||
new Date("2026-08-09T00:00:00.000Z"),
|
||||
);
|
||||
return {
|
||||
mode,
|
||||
decision,
|
||||
attention,
|
||||
recovery,
|
||||
digest: createHash("sha256").update(canonical({ mode, decision, attention, recovery })).digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("P6-31 Section 18.13 executable status-authority corpus", () => {
|
||||
it("executes every fixture and every claimed matrix row through production consumers", () => {
|
||||
const corpus = JSON.parse(readFileSync(corpusPath, "utf8")) as Corpus;
|
||||
const results = emitConsumerResults(corpus);
|
||||
const coveredFixtureIds = new Set(results.map((result) => result.fixtureId));
|
||||
const resultKeys = results.map((result) => `${result.fixtureId}:${result.matrixRow}:${result.consumer}`);
|
||||
const observations = corpus.fixtures.map((fixture) => ({ fixture, observed: executeFixture(fixture) }));
|
||||
const matrixFixtureByRow = new Map<string, Fixture>();
|
||||
for (const fixture of corpus.fixtures) {
|
||||
for (const matrixRow of Object.values(fixture.covers).flat()) {
|
||||
matrixFixtureByRow.set(matrixRow, fixture);
|
||||
}
|
||||
}
|
||||
// Execute each of the 70 unique matrix rows. Rows that intentionally share
|
||||
// one Section 18.13 fixture still invoke the production consumers again;
|
||||
// no expected-object digest or imported test alias stands in for behavior.
|
||||
const matrixResults = [...matrixFixtureByRow].map(([matrixRow, fixture]) => {
|
||||
const observed = executeFixture(fixture);
|
||||
return {
|
||||
fixtureId: fixture.id,
|
||||
matrixRow,
|
||||
observedDigest: observed.digest,
|
||||
observedMode: observed.mode.kind,
|
||||
observedStatus: observed.decision?.toStatus ?? "legacy_finalizer",
|
||||
observedEffects: observed.decision?.effects.map((effect) => effect.kind) ?? [],
|
||||
};
|
||||
});
|
||||
|
||||
expect(corpus.schema).toBe("paperclip.status-authority-conformance.v1");
|
||||
expect(corpus.fixtures).toHaveLength(52);
|
||||
expect(coveredFixtureIds).toEqual(new Set(corpus.fixtures.map((fixture) => fixture.id)));
|
||||
expect(new Set(resultKeys).size).toBe(resultKeys.length);
|
||||
expect(results.every((result) => /^[a-f0-9]{64}$/.test(result.expectedDigest))).toBe(true);
|
||||
expect(new Set(results.filter((result) => result.matrixRow !== "fixture-only").map((result) => result.matrixRow))).toEqual(new Set([
|
||||
...Array.from({ length: 19 }, (_, index) => `SD-${String(index + 1).padStart(2, "0")}`),
|
||||
...Array.from({ length: 8 }, (_, index) => `TC-${String(index + 1).padStart(2, "0")}`),
|
||||
...Array.from({ length: 12 }, (_, index) => `ATT-${String(index + 1).padStart(2, "0")}`),
|
||||
...Array.from({ length: 6 }, (_, index) => `LIVE-${String(index + 1).padStart(2, "0")}`),
|
||||
...Array.from({ length: 8 }, (_, index) => `REC-${String(index + 1).padStart(2, "0")}`),
|
||||
...Array.from({ length: 8 }, (_, index) => `COMP-${String(index + 1).padStart(2, "0")}`),
|
||||
...Array.from({ length: 9 }, (_, index) => `MIG-${String(index + 1).padStart(2, "0")}`),
|
||||
]));
|
||||
expect(observations.every(({ observed }) => /^[a-f0-9]{64}$/.test(observed.digest))).toBe(true);
|
||||
expect(matrixResults).toHaveLength(70);
|
||||
expect(new Set(matrixResults.filter((result) => result.matrixRow !== "fixture-only").map((result) => result.matrixRow)))
|
||||
.toEqual(new Set([
|
||||
...Array.from({ length: 19 }, (_, index) => `SD-${String(index + 1).padStart(2, "0")}`),
|
||||
...Array.from({ length: 8 }, (_, index) => `TC-${String(index + 1).padStart(2, "0")}`),
|
||||
...Array.from({ length: 12 }, (_, index) => `ATT-${String(index + 1).padStart(2, "0")}`),
|
||||
...Array.from({ length: 6 }, (_, index) => `LIVE-${String(index + 1).padStart(2, "0")}`),
|
||||
...Array.from({ length: 8 }, (_, index) => `REC-${String(index + 1).padStart(2, "0")}`),
|
||||
...Array.from({ length: 8 }, (_, index) => `COMP-${String(index + 1).padStart(2, "0")}`),
|
||||
...Array.from({ length: 9 }, (_, index) => `MIG-${String(index + 1).padStart(2, "0")}`),
|
||||
]));
|
||||
|
||||
for (const { fixture, observed } of observations) {
|
||||
expect(observed.mode.kind, fixture.id).toBe(fixture.mode);
|
||||
expect(observed.attention, fixture.id).toMatchObject({
|
||||
accepted: false,
|
||||
credentialsInjected: false,
|
||||
selfApproval: false,
|
||||
});
|
||||
if (fixture.mode === "legacy") {
|
||||
expect(observed.decision, fixture.id).toBeNull();
|
||||
expect(fixture.expected.nativeRecords, fixture.id).toBe(false);
|
||||
} else if (
|
||||
["done", "in_progress", "in_review", "blocked", "cancelled"].includes(fixture.expected.statusAction)
|
||||
&& fixture.given.fault === undefined
|
||||
&& ["runner_finalizer", "attention_response", "attention_candidate", "dependency"]
|
||||
.includes(String(fixture.given.trigger))
|
||||
&& !["turn_scope", "run_scope", "issue_scope_authorized"].includes(String(fixture.given.completionState))
|
||||
&& fixture.given.completionState !== "explicit_resume_capability"
|
||||
&& fixture.given.completionState !== "new_policy_requires_review"
|
||||
) {
|
||||
expect(observed.decision?.toStatus, fixture.id).toBe(fixture.expected.statusAction);
|
||||
if (!["done", "cancelled"].includes(observed.decision!.toStatus)) {
|
||||
expect(observed.decision!.effects.length, `${fixture.id} must retain atomic liveness`).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -86,12 +86,15 @@ import { appendHeartbeatRunEvent } from "./heartbeat-run-events.js";
|
||||
import {
|
||||
buildNativeExecutionInput,
|
||||
cancelNativeSession,
|
||||
dispatchNativeSessionResumptions,
|
||||
ensureNativeCompletionContract,
|
||||
executePaperclipNativeSession,
|
||||
finalizeNativeRun,
|
||||
materializeNativeInteractionResponses,
|
||||
reconcileNativeFinalizations,
|
||||
resolveNativeRuntimeMode,
|
||||
} from "./native-runtime/index.js";
|
||||
import { parseNativeExecutionInput } from "@paperclipai/paperclip-runner";
|
||||
import { normalizeResponsibleUserDenialCode } from "./responsible-user-denial-run-outcomes.js";
|
||||
import { getRunLogStore, type RunLogHandle } from "./run-log-store.js";
|
||||
import { getServerAdapter, listAdapterModelProfiles, runningProcesses } from "../adapters/index.js";
|
||||
@@ -713,6 +716,13 @@ const activeRunExecutionPromises = new Set<Promise<void>>();
|
||||
// can await a wake that is still before run registration. A caller that tears
|
||||
// down a shared database (a test afterEach) then cannot race a late wake.
|
||||
const activeWakeupPromises = new Set<Promise<unknown>>();
|
||||
|
||||
class NativeSessionResumeScheduledError extends Error {
|
||||
constructor(readonly original: unknown) {
|
||||
super("Persisted native session resume scheduled");
|
||||
this.name = "NativeSessionResumeScheduledError";
|
||||
}
|
||||
}
|
||||
const INLINE_BASE64_IMAGE_DATA_RE = /("type":"image","source":\{"type":"base64","data":")([A-Za-z0-9+/=]{1024,})(")/g;
|
||||
type RuntimeConfigSecretResolver = Pick<
|
||||
ReturnType<typeof secretService>,
|
||||
@@ -13026,6 +13036,26 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
logger.warn({ err: error }, "failed to reconcile persisted native finalizations before orphan reaping");
|
||||
});
|
||||
|
||||
// Result-less transport loss is resumed on the original run. The database
|
||||
// lease is claimed before dispatch so concurrent service instances cannot
|
||||
// open competing recoveries; executeRun receives the exact claimed owner.
|
||||
const nativeResumeClaims = await dispatchNativeSessionResumptions({
|
||||
db,
|
||||
runnerInstanceId: runtimeEnv.PAPERCLIP_INSTANCE_ID?.trim() || "paperclip-heartbeat",
|
||||
now,
|
||||
dispatch: (claim) => {
|
||||
const execution = executeRun(claim.runId, { nativeLeaseOwner: claim.leaseOwner }).catch((error) => {
|
||||
logger.error({ err: error, runId: claim.runId }, "persisted native session resume failed");
|
||||
});
|
||||
activeRunExecutionPromises.add(execution);
|
||||
void execution.finally(() => activeRunExecutionPromises.delete(execution));
|
||||
},
|
||||
}).catch((error) => {
|
||||
logger.warn({ err: error }, "failed to claim persisted native session resumptions");
|
||||
return [];
|
||||
});
|
||||
const resumedRunIds = new Set(nativeResumeClaims.map((claim) => claim.runId));
|
||||
|
||||
// Find all runs stuck in "running" state (queued runs are legitimately waiting; resumeQueuedRuns handles them)
|
||||
const activeRuns = await db
|
||||
.select({
|
||||
@@ -13063,6 +13093,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
const reaped: string[] = [];
|
||||
|
||||
for (const { run, adapterType, adapterConfig } of activeRuns) {
|
||||
if (resumedRunIds.has(run.id)) continue;
|
||||
if (runningProcesses.has(run.id) || activeRunExecutions.has(run.id)) continue;
|
||||
|
||||
// Apply staleness threshold to avoid false positives
|
||||
@@ -13489,7 +13520,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function executeRun(runId: string) {
|
||||
async function executeRun(runId: string, options: { nativeLeaseOwner?: string } = {}) {
|
||||
if ((await getSchedulingSuppression()).suppressed) return;
|
||||
|
||||
let run = await getRun(runId);
|
||||
@@ -15316,26 +15347,50 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
});
|
||||
nativeRunnerInstanceId = run.runnerInstanceId ?? randomUUID();
|
||||
const nativeSessionId = run.nativeSessionId ?? randomUUID();
|
||||
nativeExecution = buildNativeExecutionInput({
|
||||
companyId: agent.companyId,
|
||||
runId: run.id,
|
||||
issue: issueRef,
|
||||
agentId: agent.id,
|
||||
workspace: {
|
||||
id: persistedExecutionWorkspace.id,
|
||||
cwd: executionWorkspace.cwd,
|
||||
repoUrl: executionWorkspace.repoUrl,
|
||||
repoRef: executionWorkspace.repoRef,
|
||||
branchName: executionWorkspace.branchName,
|
||||
},
|
||||
normalizedSessionId: nativeSessionId,
|
||||
completionContract: {
|
||||
id: completionContract.row.id,
|
||||
sha256: completionContract.row.canonicalSha256,
|
||||
schemaVersion: completionContract.row.schemaVersion,
|
||||
contract: completionContract.contract,
|
||||
},
|
||||
});
|
||||
const persistedProfile = parseObject(run.runnerProfileJson);
|
||||
if (persistedProfile.nativeExecutionInput !== undefined) {
|
||||
nativeExecution = parseNativeExecutionInput(persistedProfile.nativeExecutionInput);
|
||||
if (
|
||||
nativeExecution.binding.companyId !== agent.companyId
|
||||
|| nativeExecution.binding.runId !== run.id
|
||||
|| nativeExecution.binding.issueId !== issueRef.id
|
||||
|| nativeExecution.binding.agentId !== agent.id
|
||||
|| nativeExecution.binding.executionWorkspaceId !== persistedExecutionWorkspace.id
|
||||
|| nativeExecution.completionContract.id !== completionContract.row.id
|
||||
|| nativeExecution.completionContract.sha256 !== completionContract.row.canonicalSha256
|
||||
) throw new Error("native_execution_input_persisted_binding_mismatch");
|
||||
} else {
|
||||
const interactionId = readNonEmptyString(context.interactionId);
|
||||
const interactionResponses = await materializeNativeInteractionResponses({
|
||||
db,
|
||||
companyId: agent.companyId,
|
||||
issueId: issueRef.id,
|
||||
runId: run.id,
|
||||
agentId: agent.id,
|
||||
interactionIds: interactionId ? [interactionId] : [],
|
||||
});
|
||||
nativeExecution = buildNativeExecutionInput({
|
||||
companyId: agent.companyId,
|
||||
runId: run.id,
|
||||
issue: issueRef,
|
||||
agentId: agent.id,
|
||||
workspace: {
|
||||
id: persistedExecutionWorkspace.id,
|
||||
cwd: executionWorkspace.cwd,
|
||||
repoUrl: executionWorkspace.repoUrl,
|
||||
repoRef: executionWorkspace.repoRef,
|
||||
branchName: executionWorkspace.branchName,
|
||||
},
|
||||
normalizedSessionId: nativeSessionId,
|
||||
interactionResponses,
|
||||
completionContract: {
|
||||
id: completionContract.row.id,
|
||||
sha256: completionContract.row.canonicalSha256,
|
||||
schemaVersion: completionContract.row.schemaVersion,
|
||||
contract: completionContract.contract,
|
||||
},
|
||||
});
|
||||
}
|
||||
await db.transaction(async (tx) => {
|
||||
const lockedRun = await tx.select().from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, run.id)).for("update").limit(1)
|
||||
@@ -15352,6 +15407,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
runnerProfileJson: {
|
||||
...nativeRuntimeResolution.profile,
|
||||
...parseObject(lockedRun.runnerProfileJson),
|
||||
nativeExecutionInput: parseObject(lockedRun.runnerProfileJson).nativeExecutionInput ?? nativeExecution,
|
||||
},
|
||||
runnerInstanceId: lockedRun.runnerInstanceId ?? nativeRunnerInstanceId,
|
||||
nativeSessionId: lockedRun.nativeSessionId ?? nativeSessionId,
|
||||
@@ -15591,6 +15647,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
db,
|
||||
execution: nativeExecution,
|
||||
runnerInstanceId: nativeRunnerInstanceId,
|
||||
leaseOwner: options.nativeLeaseOwner,
|
||||
});
|
||||
} else {
|
||||
const adapterContext = { ...context };
|
||||
@@ -15662,6 +15719,16 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
}
|
||||
}
|
||||
} catch (adapterErr) {
|
||||
const nativeResumeScheduled = nativeRuntimeResolution.kind === "native"
|
||||
? await db.select({
|
||||
phase: nativeRunFinalizations.phase,
|
||||
resultId: nativeRunFinalizations.resultId,
|
||||
}).from(nativeRunFinalizations)
|
||||
.where(eq(nativeRunFinalizations.runId, run.id))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]?.phase === "retryable_failure" && rows[0]?.resultId === null)
|
||||
: false;
|
||||
if (nativeResumeScheduled) throw new NativeSessionResumeScheduledError(adapterErr);
|
||||
// Adapter (or its restore finally) threw — or the finalize record
|
||||
// write itself threw. Either way the workspace may be in a partial
|
||||
// state. Best-effort record finalize=failed so the dependent readiness
|
||||
@@ -16110,6 +16177,27 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof NativeSessionResumeScheduledError) {
|
||||
const coordinator = await db.select({
|
||||
nextAttemptAt: nativeRunFinalizations.nextAttemptAt,
|
||||
attempt: nativeRunFinalizations.attempt,
|
||||
}).from(nativeRunFinalizations)
|
||||
.where(eq(nativeRunFinalizations.runId, run.id))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
await appendRunEvent(run, seq++, {
|
||||
eventType: "lifecycle",
|
||||
stream: "system",
|
||||
level: "warn",
|
||||
message: "native session transport interrupted; same-run resume persisted",
|
||||
payload: {
|
||||
attempt: coordinator?.attempt ?? null,
|
||||
nextAttemptAt: coordinator?.nextAttemptAt?.toISOString() ?? null,
|
||||
fallbackSuppressed: true,
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
const message = redactCurrentUserText(
|
||||
err instanceof Error ? err.message : "Unknown adapter failure",
|
||||
await getCurrentUserRedactionOptions(),
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from "./runtime-mode.js";
|
||||
export * from "./completion-contracts.js";
|
||||
export * from "./native-execution-input.js";
|
||||
export * from "./native-session-executor.js";
|
||||
export * from "./native-interaction-bridge.js";
|
||||
export * from "./paperclip-control-plane-port.js";
|
||||
export * from "./native-run-finalizer.js";
|
||||
export * from "./native-finalization-reconciler.js";
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { NativeExecutionInputV1, StrictCompletionContractInput } from "@paperclipai/paperclip-runner";
|
||||
import type {
|
||||
NativeExecutionInputV1,
|
||||
NativeInteractionResponseEnvelope,
|
||||
StrictCompletionContractInput,
|
||||
} from "@paperclipai/paperclip-runner";
|
||||
import { parseNativeExecutionInput } from "@paperclipai/paperclip-runner";
|
||||
|
||||
/** Closed constructor: callers cannot spread legacy context or environment data. */
|
||||
@@ -21,6 +25,7 @@ export function buildNativeExecutionInput(input: {
|
||||
branchName: string | null;
|
||||
};
|
||||
normalizedSessionId: string | null;
|
||||
interactionResponses?: NativeInteractionResponseEnvelope[];
|
||||
completionContract: {
|
||||
id: string;
|
||||
sha256: string;
|
||||
@@ -58,7 +63,7 @@ export function buildNativeExecutionInput(input: {
|
||||
protocolVersion: 1,
|
||||
},
|
||||
completionContract: input.completionContract,
|
||||
interactionResponses: [],
|
||||
interactionResponses: input.interactionResponses ?? [],
|
||||
credentialBindings: [],
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,157 @@
|
||||
import { and, desc, eq, inArray, isNotNull, isNull, lte, notInArray, or } from "drizzle-orm";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { and, desc, eq, gt, inArray, isNotNull, isNull, lte, notInArray, or } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { heartbeatRuns, nativeRunFinalizations, workspaceOperations } from "@paperclipai/db";
|
||||
import { finalizeNativeRun, recordNativeFinalizationFailure } from "./native-run-finalizer.js";
|
||||
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
|
||||
|
||||
export type NativeSessionResumeClaim = { runId: string; leaseOwner: string };
|
||||
|
||||
export async function dispatchNativeSessionResumptions(input: {
|
||||
db: Db;
|
||||
runnerInstanceId: string;
|
||||
runIds?: string[];
|
||||
now?: Date;
|
||||
limit?: number;
|
||||
dispatch: (claim: NativeSessionResumeClaim) => void;
|
||||
}) {
|
||||
const claims = await claimNativeSessionResumptions(input);
|
||||
for (const claim of claims) input.dispatch(claim);
|
||||
return claims;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claims result-less native executions for same-run recovery. Eligibility is
|
||||
* derived exclusively from persisted mode/coordinator/envelope/checkpoint
|
||||
* state; the live feature flag and legacy scheduler are intentionally absent.
|
||||
*/
|
||||
export async function claimNativeSessionResumptions(input: {
|
||||
db: Db;
|
||||
runnerInstanceId: string;
|
||||
runIds?: string[];
|
||||
now?: Date;
|
||||
limit?: number;
|
||||
}): Promise<NativeSessionResumeClaim[]> {
|
||||
const now = input.now ?? new Date();
|
||||
const candidates = await input.db.select({ runId: heartbeatRuns.id })
|
||||
.from(heartbeatRuns)
|
||||
.innerJoin(nativeRunFinalizations, eq(nativeRunFinalizations.runId, heartbeatRuns.id))
|
||||
.where(and(
|
||||
eq(heartbeatRuns.runtimeMode, "native"),
|
||||
isNull(nativeRunFinalizations.resultId),
|
||||
or(
|
||||
eq(nativeRunFinalizations.phase, "retryable_failure"),
|
||||
and(eq(nativeRunFinalizations.phase, "observed"), gt(nativeRunFinalizations.attempt, 0)),
|
||||
),
|
||||
or(isNull(nativeRunFinalizations.nextAttemptAt), lte(nativeRunFinalizations.nextAttemptAt, now)),
|
||||
or(
|
||||
isNull(nativeRunFinalizations.leaseOwner),
|
||||
isNull(nativeRunFinalizations.leaseExpiresAt),
|
||||
lte(nativeRunFinalizations.leaseExpiresAt, now),
|
||||
),
|
||||
...(input.runIds?.length ? [inArray(heartbeatRuns.id, input.runIds)] : []),
|
||||
))
|
||||
.limit(input.limit ?? 25);
|
||||
|
||||
const claims: NativeSessionResumeClaim[] = [];
|
||||
for (const candidate of candidates) {
|
||||
const leaseOwner = `${input.runnerInstanceId}:resume:${randomUUID()}`;
|
||||
const claimed = await input.db.transaction(async (tx) => {
|
||||
const row = await tx.select({
|
||||
run: heartbeatRuns,
|
||||
coordinator: nativeRunFinalizations,
|
||||
}).from(heartbeatRuns)
|
||||
.innerJoin(nativeRunFinalizations, eq(nativeRunFinalizations.runId, heartbeatRuns.id))
|
||||
.where(eq(heartbeatRuns.id, candidate.runId))
|
||||
.for("update")
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) return false;
|
||||
if (
|
||||
row.run.runtimeMode !== "native"
|
||||
|| row.coordinator.resultId
|
||||
|| !["observed", "retryable_failure"].includes(row.coordinator.phase)
|
||||
|| (row.coordinator.phase === "observed" && row.coordinator.attempt <= 0)
|
||||
|| (row.coordinator.nextAttemptAt && row.coordinator.nextAttemptAt > now)
|
||||
|| (
|
||||
row.coordinator.leaseOwner
|
||||
&& row.coordinator.leaseExpiresAt
|
||||
&& row.coordinator.leaseExpiresAt > now
|
||||
)
|
||||
|| !["running", "failed"].includes(row.run.status)
|
||||
) return false;
|
||||
|
||||
const profile = row.run.runnerProfileJson ?? {};
|
||||
const persistedInput = profile.nativeExecutionInput;
|
||||
const checkpoint = profile.sessionCheckpoint;
|
||||
if (!persistedInput || !checkpoint) {
|
||||
const failureCode = "native_session_checkpoint_missing";
|
||||
await tx.update(nativeRunFinalizations).set({
|
||||
phase: "terminal_failure",
|
||||
leaseOwner: null,
|
||||
leaseExpiresAt: null,
|
||||
failureCode,
|
||||
failureDetail: {
|
||||
recoveryOwner: { kind: "agent", agentId: row.run.agentId },
|
||||
nextAction: "Inspect the interrupted native session; opening a replacement provider session is forbidden.",
|
||||
},
|
||||
nextAttemptAt: null,
|
||||
updatedAt: now,
|
||||
}).where(eq(nativeRunFinalizations.runId, row.run.id));
|
||||
await tx.update(heartbeatRuns).set({
|
||||
status: "failed",
|
||||
finishedAt: now,
|
||||
nativePhase: "terminal_failure",
|
||||
nativePhaseUpdatedAt: now,
|
||||
errorCode: failureCode,
|
||||
error: "Persisted native session cannot be resumed without both its envelope and provider checkpoint",
|
||||
updatedAt: now,
|
||||
}).where(eq(heartbeatRuns.id, row.run.id));
|
||||
await issueRecoveryActionService(tx as unknown as Db).upsertSourceScoped({
|
||||
companyId: row.run.companyId,
|
||||
sourceIssueId: row.coordinator.issueId,
|
||||
kind: "active_run_watchdog",
|
||||
ownerType: "agent",
|
||||
ownerAgentId: row.run.agentId,
|
||||
cause: failureCode,
|
||||
fingerprint: createHash("sha256").update(`${row.run.id}:${failureCode}`).digest("hex"),
|
||||
evidence: { runId: row.run.id, hasEnvelope: !!persistedInput, hasCheckpoint: !!checkpoint },
|
||||
nextAction: "Inspect the interrupted native session and explicitly choose recovery; do not open a duplicate provider session.",
|
||||
wakePolicy: null,
|
||||
maxAttempts: 3,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const leaseExpiresAt = new Date(now.getTime() + 20 * 60_000);
|
||||
const updated = await tx.update(nativeRunFinalizations).set({
|
||||
phase: "observed",
|
||||
leaseOwner,
|
||||
leaseExpiresAt,
|
||||
failureCode: null,
|
||||
failureDetail: null,
|
||||
nextAttemptAt: null,
|
||||
updatedAt: now,
|
||||
}).where(and(
|
||||
eq(nativeRunFinalizations.runId, row.run.id),
|
||||
eq(nativeRunFinalizations.phase, row.coordinator.phase),
|
||||
)).returning({ runId: nativeRunFinalizations.runId }).then((rows) => rows[0] ?? null);
|
||||
if (!updated) return false;
|
||||
await tx.update(heartbeatRuns).set({
|
||||
status: "running",
|
||||
finishedAt: null,
|
||||
error: null,
|
||||
errorCode: null,
|
||||
nativePhase: "observed",
|
||||
nativePhaseUpdatedAt: now,
|
||||
updatedAt: now,
|
||||
}).where(eq(heartbeatRuns.id, row.run.id));
|
||||
return true;
|
||||
});
|
||||
if (claimed) claims.push({ runId: candidate.runId, leaseOwner });
|
||||
}
|
||||
return claims;
|
||||
}
|
||||
|
||||
/** Recovery is keyed only by persisted mode/coordinator state, never the live flag. */
|
||||
export async function reconcileNativeFinalizations(db: Db, runIds?: string[]) {
|
||||
|
||||
@@ -1,12 +1,134 @@
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type { NativeInteractionResponseEnvelope } from "@paperclipai/paperclip-runner";
|
||||
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
|
||||
|
||||
export class NativeInteractionBridgeError extends Error {
|
||||
constructor(readonly code: string, message: string) {
|
||||
super(message);
|
||||
this.name = "NativeInteractionBridgeError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 6 never auto-approves model requests. Unsupported native requests are
|
||||
* declined explicitly; typed response materialization stays in existing
|
||||
* interaction/approval services.
|
||||
* Materialize only interactions that authorized this run's wake. Resolution
|
||||
* remains owned by the existing interaction service; this bridge is a
|
||||
* read-only, identity-bound projection and never receives credentials.
|
||||
*/
|
||||
export async function materializeNativeInteractionResponses(input: {
|
||||
db: Db;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
runId: string;
|
||||
agentId: string;
|
||||
interactionIds: string[];
|
||||
}): Promise<NativeInteractionResponseEnvelope[]> {
|
||||
const requestedIds = new Set(input.interactionIds.filter(Boolean));
|
||||
if (requestedIds.size === 0) return [];
|
||||
const interactions = await issueThreadInteractionService(input.db).listForIssue(input.issueId);
|
||||
const responses: NativeInteractionResponseEnvelope[] = [];
|
||||
|
||||
for (const interaction of interactions) {
|
||||
if (!requestedIds.has(interaction.id)) continue;
|
||||
if (interaction.companyId !== input.companyId || interaction.issueId !== input.issueId) {
|
||||
throw new NativeInteractionBridgeError(
|
||||
"native_interaction_binding_mismatch",
|
||||
`Interaction ${interaction.id} is not bound to the native company and issue`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
interaction.resolvedByRunId === input.runId
|
||||
|| (
|
||||
interaction.resolvedByAgentId === input.agentId
|
||||
&& interaction.createdByAgentId === input.agentId
|
||||
)
|
||||
) {
|
||||
throw new NativeInteractionBridgeError(
|
||||
"native_interaction_self_approval",
|
||||
`Run ${input.runId} cannot consume a response it resolved itself`,
|
||||
);
|
||||
}
|
||||
if (!interaction.resolvedByUserId && !interaction.resolvedByAgentId) {
|
||||
throw new NativeInteractionBridgeError(
|
||||
"native_interaction_unresolved",
|
||||
`Interaction ${interaction.id} has no authorized resolver`,
|
||||
);
|
||||
}
|
||||
|
||||
if (interaction.kind === "request_confirmation") {
|
||||
if (interaction.resolvedByAgentId === input.agentId) {
|
||||
throw new NativeInteractionBridgeError(
|
||||
"native_interaction_self_approval",
|
||||
`Agent ${input.agentId} cannot consume a confirmation it resolved`,
|
||||
);
|
||||
}
|
||||
if (interaction.payload.toolAction !== undefined) {
|
||||
throw new NativeInteractionBridgeError(
|
||||
"native_interaction_governed_request_unsupported",
|
||||
"Governed tool-action confirmations cannot enter a native model envelope",
|
||||
);
|
||||
}
|
||||
if (
|
||||
(interaction.status !== "accepted" && interaction.status !== "rejected")
|
||||
|| !interaction.result
|
||||
|| (interaction.result.outcome !== "accepted" && interaction.result.outcome !== "rejected")
|
||||
) {
|
||||
throw new NativeInteractionBridgeError(
|
||||
"native_interaction_unresolved",
|
||||
`Confirmation ${interaction.id} is not authoritatively resolved`,
|
||||
);
|
||||
}
|
||||
responses.push({
|
||||
interactionId: interaction.id,
|
||||
kind: interaction.kind,
|
||||
response: {
|
||||
status: interaction.status,
|
||||
result: structuredClone(interaction.result) as unknown as Record<string, unknown>,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (interaction.kind === "ask_user_questions") {
|
||||
if (interaction.status !== "answered" || !interaction.result || interaction.result.cancelled === true) {
|
||||
throw new NativeInteractionBridgeError(
|
||||
"native_interaction_unresolved",
|
||||
`Question interaction ${interaction.id} is not authoritatively answered`,
|
||||
);
|
||||
}
|
||||
responses.push({
|
||||
interactionId: interaction.id,
|
||||
kind: interaction.kind,
|
||||
response: {
|
||||
status: interaction.status,
|
||||
result: structuredClone(interaction.result) as unknown as Record<string, unknown>,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new NativeInteractionBridgeError(
|
||||
"native_runtime_request_unsupported",
|
||||
`Interaction kind ${interaction.kind} is not supported by the native input contract`,
|
||||
);
|
||||
}
|
||||
|
||||
const missing = [...requestedIds].filter((id) => !responses.some((response) => response.interactionId === id));
|
||||
if (missing.length > 0) {
|
||||
throw new NativeInteractionBridgeError(
|
||||
"native_interaction_missing",
|
||||
`Native interaction response not found: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return responses.sort((left, right) => left.interactionId.localeCompare(right.interactionId));
|
||||
}
|
||||
|
||||
/** Native runtimes never receive credentials and never auto-approve requests. */
|
||||
export function rejectUnsupportedNativeRuntimeRequest(requestKind: string) {
|
||||
return {
|
||||
accepted: false as const,
|
||||
code: "native_runtime_request_unsupported" as const,
|
||||
requestKind,
|
||||
credentialsInjected: false as const,
|
||||
selfApproval: false as const,
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,11 @@ vi.mock("@paperclipai/paperclip-runner", () => ({
|
||||
executeNativeSession: state.execute,
|
||||
}));
|
||||
|
||||
import { cancelNativeSession, executePaperclipNativeSession } from "./native-session-executor.js";
|
||||
import {
|
||||
cancelNativeSession,
|
||||
executePaperclipNativeSession,
|
||||
nativeSessionFailureDisposition,
|
||||
} from "./native-session-executor.js";
|
||||
|
||||
const execution = {
|
||||
binding: {
|
||||
@@ -116,3 +120,24 @@ describe("native session cancellation", () => {
|
||||
await running;
|
||||
});
|
||||
});
|
||||
|
||||
describe("native session bounded recovery", () => {
|
||||
it("retries the same run twice and stops at the third failed attempt", () => {
|
||||
const now = new Date("2026-08-09T00:00:00.000Z");
|
||||
expect(nativeSessionFailureDisposition(1, now)).toEqual({
|
||||
phase: "retryable_failure",
|
||||
failureCode: "native_session_interrupted",
|
||||
nextAttemptAt: new Date("2026-08-09T00:00:30.000Z"),
|
||||
});
|
||||
expect(nativeSessionFailureDisposition(2, now)).toEqual({
|
||||
phase: "retryable_failure",
|
||||
failureCode: "native_session_interrupted",
|
||||
nextAttemptAt: new Date("2026-08-09T00:00:30.000Z"),
|
||||
});
|
||||
expect(nativeSessionFailureDisposition(3, now)).toEqual({
|
||||
phase: "terminal_failure",
|
||||
failureCode: "native_session_retry_exhausted",
|
||||
nextAttemptAt: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { AdapterExecutionResult } from "../../adapters/index.js";
|
||||
import type { NativeFinalizationResult } from "@paperclipai/shared";
|
||||
import type { NativeExecutionInputV1, NativeSession } from "@paperclipai/paperclip-runner";
|
||||
@@ -10,6 +10,7 @@ import type { Db } from "@paperclipai/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { heartbeatRuns, nativeRunFinalizations } from "@paperclipai/db";
|
||||
import { PaperclipControlPlanePort } from "./paperclip-control-plane-port.js";
|
||||
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
|
||||
|
||||
type ActiveNativeSession = {
|
||||
session: NativeSession;
|
||||
@@ -18,6 +19,15 @@ type ActiveNativeSession = {
|
||||
|
||||
const activeNativeSessions = new Map<string, ActiveNativeSession>();
|
||||
|
||||
export function nativeSessionFailureDisposition(attempt: number, now = new Date()) {
|
||||
const exhausted = attempt >= 3;
|
||||
return {
|
||||
phase: exhausted ? "terminal_failure" as const : "retryable_failure" as const,
|
||||
failureCode: exhausted ? "native_session_retry_exhausted" as const : "native_session_interrupted" as const,
|
||||
nextAttemptAt: exhausted ? null : new Date(now.getTime() + 30_000),
|
||||
};
|
||||
}
|
||||
|
||||
export async function cancelNativeSession(runId: string, reason: string): Promise<boolean> {
|
||||
const active = activeNativeSessions.get(runId);
|
||||
if (!active) return false;
|
||||
@@ -37,11 +47,12 @@ export async function executePaperclipNativeSession(input: {
|
||||
db: Db;
|
||||
execution: NativeExecutionInputV1;
|
||||
runnerInstanceId: string;
|
||||
leaseOwner?: string;
|
||||
}): Promise<AdapterExecutionResult> {
|
||||
const leaseOwner = `${input.runnerInstanceId}:${randomUUID()}`;
|
||||
const leaseOwner = input.leaseOwner ?? `${input.runnerInstanceId}:${randomUUID()}`;
|
||||
const leaseNow = new Date();
|
||||
const leaseExpiresAt = new Date(leaseNow.getTime() + 20 * 60_000);
|
||||
await input.db.transaction(async (tx) => {
|
||||
const attempt = await input.db.transaction(async (tx) => {
|
||||
const coordinator = await tx.select().from(nativeRunFinalizations)
|
||||
.where(and(
|
||||
eq(nativeRunFinalizations.runId, input.execution.binding.runId),
|
||||
@@ -71,6 +82,7 @@ export async function executePaperclipNativeSession(input: {
|
||||
nativePhaseUpdatedAt: leaseNow,
|
||||
updatedAt: leaseNow,
|
||||
}).where(eq(heartbeatRuns.id, coordinator.runId));
|
||||
return coordinator.attempt + 1;
|
||||
});
|
||||
const controlPlaneInstanceId = `${input.runnerInstanceId}:control`;
|
||||
const controlPlane = new PaperclipControlPlanePort(input.db, {
|
||||
@@ -101,22 +113,57 @@ export async function executePaperclipNativeSession(input: {
|
||||
});
|
||||
} catch (error) {
|
||||
const now = new Date();
|
||||
await input.db.update(nativeRunFinalizations).set({
|
||||
phase: "retryable_failure",
|
||||
leaseOwner: null,
|
||||
leaseExpiresAt: null,
|
||||
failureCode: "native_session_interrupted",
|
||||
failureDetail: {
|
||||
message: error instanceof Error ? error.message.slice(0, 2_000) : String(error).slice(0, 2_000),
|
||||
recoveryOwner: { kind: "agent", agentId: input.execution.binding.agentId },
|
||||
nextAction: "Resume the persisted native provider session after the retry delay.",
|
||||
},
|
||||
nextAttemptAt: new Date(now.getTime() + 30_000),
|
||||
updatedAt: now,
|
||||
}).where(and(
|
||||
eq(nativeRunFinalizations.runId, input.execution.binding.runId),
|
||||
eq(nativeRunFinalizations.leaseOwner, leaseOwner),
|
||||
));
|
||||
const { phase, failureCode, nextAttemptAt } = nativeSessionFailureDisposition(attempt, now);
|
||||
const exhausted = phase === "terminal_failure";
|
||||
const message = error instanceof Error ? error.message.slice(0, 2_000) : String(error).slice(0, 2_000);
|
||||
await input.db.transaction(async (tx) => {
|
||||
const updated = await tx.update(nativeRunFinalizations).set({
|
||||
phase,
|
||||
leaseOwner: null,
|
||||
leaseExpiresAt: null,
|
||||
failureCode,
|
||||
failureDetail: {
|
||||
message,
|
||||
originalFailureCode: "native_session_interrupted",
|
||||
recoveryOwner: { kind: "agent", agentId: input.execution.binding.agentId },
|
||||
nextAction: exhausted
|
||||
? "Inspect the persisted native session after its bounded resume budget was exhausted."
|
||||
: "Resume this same run from its persisted native provider checkpoint after the retry delay.",
|
||||
},
|
||||
nextAttemptAt,
|
||||
updatedAt: now,
|
||||
}).where(and(
|
||||
eq(nativeRunFinalizations.runId, input.execution.binding.runId),
|
||||
eq(nativeRunFinalizations.leaseOwner, leaseOwner),
|
||||
)).returning({ runId: nativeRunFinalizations.runId }).then((rows) => rows[0] ?? null);
|
||||
if (!updated) throw new Error("native_session_lease_lost");
|
||||
await tx.update(heartbeatRuns).set({
|
||||
nativePhase: phase,
|
||||
nativePhaseUpdatedAt: now,
|
||||
error: message,
|
||||
errorCode: failureCode,
|
||||
updatedAt: now,
|
||||
}).where(eq(heartbeatRuns.id, input.execution.binding.runId));
|
||||
await issueRecoveryActionService(tx as unknown as Db).upsertSourceScoped({
|
||||
companyId: input.execution.binding.companyId,
|
||||
sourceIssueId: input.execution.binding.issueId,
|
||||
kind: "active_run_watchdog",
|
||||
ownerType: "agent",
|
||||
ownerAgentId: input.execution.binding.agentId,
|
||||
cause: failureCode,
|
||||
fingerprint: createHash("sha256")
|
||||
.update(`${input.execution.binding.runId}:${failureCode}`)
|
||||
.digest("hex"),
|
||||
evidence: { runId: input.execution.binding.runId, coordinatorAttempt: attempt },
|
||||
nextAction: exhausted
|
||||
? "Inspect or explicitly restart the exhausted persisted native session."
|
||||
: "Resume the persisted native session on the same heartbeat run.",
|
||||
wakePolicy: nextAttemptAt
|
||||
? { kind: "resume_native_run", runId: input.execution.binding.runId, notBefore: nextAttemptAt.toISOString() }
|
||||
: null,
|
||||
maxAttempts: 3,
|
||||
});
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
await input.db.update(nativeRunFinalizations).set({
|
||||
|
||||
@@ -272,6 +272,11 @@ describe("PaperclipControlPlanePort conformance", () => {
|
||||
highestContiguousSourceSeq: 3,
|
||||
duplicateDisposition: "duplicate",
|
||||
terminalReplayIdempotent: true,
|
||||
openBindingRejected: true,
|
||||
eventIdMutationRejected: true,
|
||||
eventSequenceMutationRejected: true,
|
||||
replayBindingRejected: true,
|
||||
resultMutationRejected: true,
|
||||
});
|
||||
await expect(db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, identity.runId))).resolves.toHaveLength(1);
|
||||
await finalizeNativeRun({
|
||||
|
||||
Reference in new issue
Block a user