diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index de887cdedb9..062dd641006 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -27,6 +27,10 @@ consumer. Phase 6 adds a default-off Paperclip adapter at the public control-plane/session boundary, durable PRP replay and result finalization, and a kill switch that keeps the legacy adapter path as the default. +Its audit-only attention path also terminates through the public port/finalizer +call graph: duplicate and stale requests commit exact interaction receipts with +no status decision, wake, notification, or issue status/version change, and a +committed coordinator makes replay a no-op. ## Phase 0–5 quick start diff --git a/packages/paperclip-runner/docs/design/phase-6-thin-paperclip-adapter.md b/packages/paperclip-runner/docs/design/phase-6-thin-paperclip-adapter.md index 5d979aa0843..0521abf97ac 100644 --- a/packages/paperclip-runner/docs/design/phase-6-thin-paperclip-adapter.md +++ b/packages/paperclip-runner/docs/design/phase-6-thin-paperclip-adapter.md @@ -886,11 +886,22 @@ contract identity from database bindings. A same-company eligible delegate is materialized by `issueService`; a human-authority request is materialized as a typed issue-thread interaction; an explicit cross-company target is rejected into an immutable decision, recovery action, failed native finalization, and -activity receipt. The corpus mutates the accepted result row and calls the -persisted-result ingress. The database integration test starts one layer -earlier at `PaperclipControlPlanePort.completeRun` and then calls the same -production finalizer. `routeNativeAttention` remains an internal policy-to- -owner helper and is no longer itself accepted as the operational proof. +activity receipt. The corpus mutates the accepted result row and calls +`finalizeNativeRun`, which owns the persisted-result ingress. The database +integration test starts one layer earlier at +`PaperclipControlPlanePort.completeRun` and then calls the same production +finalizer. `routePersistedNativeResultAttention` and `routeNativeAttention` +remain internal helpers and are no longer accepted as the operational proof. + +Audit-only duplicate and stale requests are a terminal success even though +they intentionally create no status decision. The finalizer accepts that shape +only when every request has an `attention_duplicate_suppressed` receipt bound +to the exact durable interaction target. It commits the coordinator with a null +decision, projects the successful heartbeat, and preserves issue status and +version. A committed zero-decision coordinator is replayable only while those +durable receipts remain present; replay does not update the named interaction a +second time. Missing or cross-company interaction bindings fail closed into +named finalization recovery. MIG-08 now represents the actual instance-global transition. The production heartbeat selector reads the persisted mode first: an already-selected native @@ -917,6 +928,111 @@ the canonical PRP result can carry richer target metadata, which the server validates as untrusted routing hints. Richer provider authoring UX and additional resolver kinds remain deferred. +### Remediation 7 authoritative exact-file reconciliation (2026-08-09) + +The provisional list and seam amendments above remain the decision history. +For final diff auditing, their authoritative union is the following exact +93-file allowlist. It matches the Phase 6 branch diff from the parent of the +authorization commit through this remediation, including the package README, +the Section 18.13 checker, package/runtime tests, `canonical.ts`, and +`runtime-mode.test.ts`. No file outside this list is authorized as Phase 6 work. + +```text +packages/adapter-utils/package.json +packages/adapter-utils/src/types.ts +packages/db/src/migrations/0211_famous_guardsmen.sql +packages/db/src/migrations/meta/0211_snapshot.json +packages/db/src/migrations/meta/_journal.json +packages/db/src/schema/completion_contracts.ts +packages/db/src/schema/heartbeat_run_events.ts +packages/db/src/schema/heartbeat_runs.ts +packages/db/src/schema/index.ts +packages/db/src/schema/issues.ts +packages/db/src/schema/native_run_finalizations.ts +packages/db/src/schema/native_run_results.ts +packages/db/src/schema/status_decision_effects.ts +packages/db/src/schema/status_decisions.ts +packages/db/src/schema/work_assessments.ts +packages/paperclip-runner/README.md +packages/paperclip-runner/docs/architecture.md +packages/paperclip-runner/docs/design/phase-6-thin-paperclip-adapter.md +packages/paperclip-runner/docs/index.md +packages/paperclip-runner/docs/phase-06-thin-paperclip-adapter.md +packages/paperclip-runner/docs/tutorials/phase-06-thin-paperclip-adapter.md +packages/paperclip-runner/knowledge/evidence/2026-08-09-phase-06-verification.md +packages/paperclip-runner/knowledge/evidence/index.md +packages/paperclip-runner/knowledge/journal/2026-08-09-phase-06-design.md +packages/paperclip-runner/knowledge/journal/2026-08-09-phase-06-implementation.md +packages/paperclip-runner/knowledge/journal/index.md +packages/paperclip-runner/knowledge/log.md +packages/paperclip-runner/package.json +packages/paperclip-runner/spec/fixtures/status-authority-phase5.json +packages/paperclip-runner/spec/paperclip-native-runner-implementation-plan.md +packages/paperclip-runner/spec/paperclip-native-runner-spike-spec.md +packages/paperclip-runner/src/backends/codex-native-backend.ts +packages/paperclip-runner/src/backends/harness-driver-backend.test.ts +packages/paperclip-runner/src/backends/harness-driver-backend.ts +packages/paperclip-runner/src/cli/phase6-paperclip.ts +packages/paperclip-runner/src/conformance/control-plane-port.test.ts +packages/paperclip-runner/src/conformance/control-plane-port.ts +packages/paperclip-runner/src/contracts/control-plane-port.ts +packages/paperclip-runner/src/contracts/native-execution.test.ts +packages/paperclip-runner/src/contracts/native-execution.ts +packages/paperclip-runner/src/contracts/native-session-backend.ts +packages/paperclip-runner/src/index.ts +packages/paperclip-runner/src/mock-core/mock-control-plane-adapter.ts +packages/paperclip-runner/src/native-session-runtime.test.ts +packages/paperclip-runner/src/native-session-runtime.ts +packages/shared/src/feature-catalog.ts +packages/shared/src/index.ts +packages/shared/src/types/index.ts +packages/shared/src/types/instance.ts +packages/shared/src/types/native-finalization.ts +packages/shared/src/validators/index.ts +packages/shared/src/validators/instance.ts +packages/shared/src/validators/native-finalization.ts +scripts/check-runner-phase5-spec.mjs +server/package.json +server/src/__tests__/heartbeat-native-runner-cancellation.test.ts +server/src/__tests__/heartbeat-native-runner-selection.test.ts +server/src/__tests__/heartbeat-run-event-sequencing.test.ts +server/src/__tests__/legacy-finalization-regression.test.ts +server/src/__tests__/native-finalization-migration.test.ts +server/src/__tests__/native-finalization-recovery.test.ts +server/src/__tests__/native-interaction-bridge.test.ts +server/src/__tests__/native-run-finalizer.test.ts +server/src/__tests__/native-runner-input-boundary.test.ts +server/src/__tests__/native-runner-phase6.integration.test.ts +server/src/__tests__/native-session-resumption.test.ts +server/src/__tests__/native-status-arbiter-corpus.test.ts +server/src/services/heartbeat-run-events.ts +server/src/services/heartbeat.ts +server/src/services/instance-settings.ts +server/src/services/issues.ts +server/src/services/native-runtime/canonical.ts +server/src/services/native-runtime/completion-contracts.ts +server/src/services/native-runtime/evidence-classifier.test.ts +server/src/services/native-runtime/evidence-classifier.ts +server/src/services/native-runtime/index.ts +server/src/services/native-runtime/native-execution-input.ts +server/src/services/native-runtime/native-finalization-reconciler.ts +server/src/services/native-runtime/native-interaction-bridge.ts +server/src/services/native-runtime/native-result-ingestion.ts +server/src/services/native-runtime/native-run-finalizer.ts +server/src/services/native-runtime/native-session-executor.test.ts +server/src/services/native-runtime/native-session-executor.ts +server/src/services/native-runtime/native-workspace-finalizer.ts +server/src/services/native-runtime/paperclip-control-plane-port.test.ts +server/src/services/native-runtime/paperclip-control-plane-port.ts +server/src/services/native-runtime/runtime-mode.test.ts +server/src/services/native-runtime/runtime-mode.ts +server/src/services/native-runtime/status-arbiter.test.ts +server/src/services/native-runtime/status-arbiter.ts +server/src/services/native-runtime/status-decision-committer.ts +server/src/services/native-runtime/work-assessments.ts +server/src/services/recovery/service.ts +``` + ## Commands the implementation must make runnable These commands are the acceptance contract for the implementation issue. They diff --git a/packages/paperclip-runner/docs/phase-06-thin-paperclip-adapter.md b/packages/paperclip-runner/docs/phase-06-thin-paperclip-adapter.md index 7923fa6f30d..2151bd47a73 100644 --- a/packages/paperclip-runner/docs/phase-06-thin-paperclip-adapter.md +++ b/packages/paperclip-runner/docs/phase-06-thin-paperclip-adapter.md @@ -90,7 +90,8 @@ fail closed; no credentials enter the model envelope. The deterministic package suite and the embedded-PostgreSQL Paperclip suite prove selection, replay/conflict handling, authoritative evidence, status/liveness atomicity, migration repair, bounded cancellation/retry, and a -byte-equivalent legacy read snapshot. The database suite is the internal +byte-equivalent legacy read snapshot. The thirteen-file database gate contains +46 tests with zero skips. The database suite is the internal native canary: it executes one selected task through the public package session contract and applies one server-owned decision. Its post-kill-switch legacy case has zero native history rows. @@ -119,7 +120,12 @@ entrypoint. Accepted attention starts at the immutable package-result row: request-specific assessment, and then delegates an eligible same-company target through `issueService`, creates a human issue-thread interaction, or persists a cross-company rejection decision/recovery/audit trail. -`routeNativeAttention` is an internal helper, not the operational test ingress. +Duplicate and stale targets finish as committed audit-only coordinators with +zero decisions, wakes, notifications, or issue status/version changes. Their +run receipt names the exact interaction target, and replay neither adds an +assessment nor updates that interaction again. Missing and cross-company audit +targets fail closed. `routePersistedNativeResultAttention` and +`routeNativeAttention` are internal helpers, not operational test ingresses. Reconciliation selects the persisted status/evidence/policy branches and executes workspace recovery through the workspace operation recorder. The rollout kill switch is only the global @@ -131,12 +137,14 @@ profile reset. Pending replay only acknowledges its original decision-scoped target after verifying that the target still exists. -Direct resolver calls are explicitly pure policy/read-model tests. They do not +Direct resolver and persisted-router calls are explicitly policy/internal +helper tests. They do not satisfy an operational fixture without a runtime-reachable entrypoint receipt -and concrete durable target. Sabotage removes the persisted accepted-attention -input, changes the real global-flag input, removes cancellation/reconciliation -actions, and deletes a pending replay target; mapped fixtures fail while their -pure resolver labels remain unchanged. +and concrete durable target. Sabotage removes the finalizer-owned accepted- +attention path for the duplicate and stale fixtures, changes the real global- +flag input, removes cancellation/reconciliation actions, and deletes a pending +replay target; mapped fixtures fail while their pure resolver labels remain +unchanged. Result-less same-run recovery is also proven through the production heartbeat seam: a persisted envelope/checkpoint is leased by `reapOrphanedRuns`, enters diff --git a/packages/paperclip-runner/docs/tutorials/phase-06-thin-paperclip-adapter.md b/packages/paperclip-runner/docs/tutorials/phase-06-thin-paperclip-adapter.md index 0820019f7c3..f7bfecfb672 100644 --- a/packages/paperclip-runner/docs/tutorials/phase-06-thin-paperclip-adapter.md +++ b/packages/paperclip-runner/docs/tutorials/phase-06-thin-paperclip-adapter.md @@ -60,7 +60,7 @@ pnpm --filter @paperclipai/server exec vitest run \ ``` This runs the unchanged package conformance suite against the real Paperclip -port. Forty-five targeted tests check company binding, duplicate/gap replay, +port. Forty-six 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, typed interaction materialization, @@ -84,6 +84,14 @@ target-state mutation, and pending replay may only acknowledge its original decision-scoped target. Sabotage tests remove each live action and the replay target and require failure even while the pure resolver label remains correct. +Duplicate and stale audit-only cases start at +`PaperclipControlPlanePort.completeRun`, continue through `finalizeNativeRun`, +and commit the coordinator with no status decision. The run succeeds, the issue +status/version remains unchanged, and the receipt names the exact mutated +interaction. Replaying the finalizer does not create an assessment or update +the interaction again. A mixed duplicate/stale list has the same behavior; +missing and cross-company interaction bindings fail closed. + All eleven expected fields and all 70 matrix-row joins are still checked, and an unknown-effect case proves the status transaction fails closed without partial rows. The recovery case uses a scripted provider only at the backend diff --git a/packages/paperclip-runner/knowledge/evidence/2026-08-09-phase-06-verification.md b/packages/paperclip-runner/knowledge/evidence/2026-08-09-phase-06-verification.md index 092ea3feb8c..ac4ac74aef4 100644 --- a/packages/paperclip-runner/knowledge/evidence/2026-08-09-phase-06-verification.md +++ b/packages/paperclip-runner/knowledge/evidence/2026-08-09-phase-06-verification.md @@ -4,7 +4,7 @@ title: Phase 6 thin Paperclip adapter verification description: Targeted package, database-port, finalization, security-boundary, feature-selection, and legacy fallback evidence. tags: [native-runner, phase-6, verification, paperclip, replay, finalization] status: stable -generated: { by: openai/gpt-5.6, at: 2026-08-09T05:52:00Z } +generated: { by: openai/gpt-5.6, at: 2026-08-09T07:42:08Z } --- # Scope @@ -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 45 tests in thirteen files against +The complete acceptance gate below passed 46 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 @@ -75,6 +75,11 @@ The focused corpus also proved: authority creates an issue-thread interaction, and cross-company targeting persists a rejection decision, recovery action, failed finalization, and audit receipt; +- valid duplicate and stale targets, including a mixed list, enter through + `PaperclipControlPlanePort.completeRun -> finalizeNativeRun`, commit a + replay-stable zero-decision coordinator, project a successful run, preserve + issue status/version, create no wake or notification, and record the exact + durable interaction target; missing and cross-company bindings fail closed; - 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`; @@ -87,7 +92,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 passed as one 13-file, 45-test gate: +The acceptance-matrix entry points passed as one 13-file, 46-test gate: ```sh pnpm --filter @paperclipai/server exec vitest run \ @@ -111,9 +116,11 @@ bounded retry exhaustion, actual flag-off legacy execution, and executable Section 18.13 fixture-to-consumer traceability. All 52 fixtures and 70 rows are classified as either live operational proofs or explicit pure policy/read-model checks. Cancellation persists its decision or audit-only receipt through -`cancelNativeSession`; attention enters from the accepted result through the -persisted-result router, then resolves a same-company eligible target through -the issue service; REC-04/06/07/08 enter the reconciler, with REC-04 +`cancelNativeSession`; attention enters from the accepted result through +`finalizeNativeRun`, which owns the persisted-result router, then resolves a +same-company eligible target through the issue service. The mapped duplicate +and stale fixtures require that finalizer receipt and fail sabotage that leaves +only the pure resolver/internal router. REC-04/06/07/08 enter the reconciler, with REC-04 recording an observed workspace operation and REC-06/07/08 writing a newly classified append-only assessment before superseding the prior decision; and MIG-08 is enforced by the production heartbeat selector using the global flag: @@ -124,7 +131,8 @@ All eleven expected fields remain derived from entrypoint returns and durable rows. Every required native effect is joined to its owning target state, each decision replay retains one identity and one delivery attempt, and pending replay verifies the original company/issue/decision target before acknowledging -it. The negative suite removes each live action, changes the real global-flag +it. Zero-decision audit replay retains the coordinator and named interaction +without another mutation. The negative suite removes each live action, changes the real global-flag input, and deletes a replay target; the mapped fixtures fail while the direct policy resolver still returns the expected label. An unknown effect rolls the entire transaction back. No @@ -133,6 +141,33 @@ and recovery canaries remain scripted only at the provider boundary, while the production Paperclip persistence/finalization paths and the flag-off legacy adapter/finalizer path execute for real. +# Remediation 7 rerun and call-graph self-review + +The final rerun on 2026-08-09 produced: + +- thirteen server files, 46 tests passed, zero skipped; +- Section 18.13 checker: 19 checks passed, zero failed; +- runner documentation validation: 58 links and the 25-concept/four-index OKF + bundle passed; +- server TypeScript and runner TypeScript/protocol typechecks passed; +- the design's authoritative 93-file allowlist matched the Phase 6 diff exactly; +- `git diff --check` passed. + +The final owner review traced the successful audit-only path as +`PaperclipControlPlanePort.completeRun -> native_run_results -> +finalizeNativeRun -> routePersistedNativeResultAttention -> +recordNativeAttentionAssessment -> routeNativeAttention -> +applyNativeAttentionStatusDecision -> native_run_finalizations/heartbeat_runs`. +The public port owns immutable result validation and persistence; the finalizer +owns coordinator lease/terminal projection; the internal router derives all +identity from persisted bindings; and the audit materializer requires the exact +same-company issue interaction. The finalizer accepts a null decision only when +every receipt is `attention_duplicate_suppressed` and names a durable target. +Committed replay validates those receipts before short-circuiting, so it cannot +create another assessment or mutate the interaction timestamp. Missing or +cross-company targets throw before the coordinator commits and enter named +retryable recovery with the issue status/version unchanged. + # Compile and migration checks ```sh diff --git a/packages/paperclip-runner/knowledge/journal/2026-08-09-phase-06-implementation.md b/packages/paperclip-runner/knowledge/journal/2026-08-09-phase-06-implementation.md index 04a2908dfb2..bdf064a3077 100644 --- a/packages/paperclip-runner/knowledge/journal/2026-08-09-phase-06-implementation.md +++ b/packages/paperclip-runner/knowledge/journal/2026-08-09-phase-06-implementation.md @@ -4,7 +4,7 @@ title: Phase 6 feature-flagged Paperclip adapter implementation description: Implementation decisions and evidence for the default-off native runner seam, durable replay/finalization, and legacy kill switch. tags: [native-runner, phase-6, implementation, paperclip, security, replay] status: stable -generated: { by: openai/gpt-5.6, at: 2026-08-09T03:01:00Z } +generated: { by: openai/gpt-5.6, at: 2026-08-09T07:42:08Z } entry_kind: phase phase: "6" --- @@ -73,6 +73,11 @@ existing server authority and keep legacy execution as the default. native runs retain native recovery after flag-off; fresh unresolved runs select legacy. Never rewrite the agent profile or persist a second rollback control. +21. Treat duplicate and stale attention as terminal audit-only finalization. + The public port persists the result, the finalizer binds the exact durable + interaction target and commits a null-decision coordinator, and replay + short-circuits without another assessment or interaction mutation. Missing + and cross-company targets remain named fail-closed recovery. # Evidence @@ -80,7 +85,7 @@ existing server authority and keep legacy execution as the default. - [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, seven tests passed. -- Phase 6 acceptance-matrix entry points: thirteen files, forty-five tests passed +- Phase 6 acceptance-matrix entry points: thirteen files, forty-six tests passed with zero skips, including the database-backed selected-task canary, six atomic-liveness failpoints, migration, sequencing, bounded recovery, and legacy compatibility. @@ -88,7 +93,8 @@ existing server authority and keep legacy execution as the default. 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-specific database executions passed - through named production consumers. Consumer returns and persisted rows + through named production consumers. The duplicate/stale rows enter through + the real finalizer owner rather than the persisted router directly. Consumer returns and persisted rows supplied all eleven assertions: run status, status/preserve action, reason, required/forbidden effects, live path, claim preservation, native-record behavior, decision count, maximum wake count, and maximum notification @@ -99,8 +105,8 @@ existing server authority and keep legacy execution as the default. recovery, run, workspace-operation, contract, decision, or governance state. Unknown effects fail before any decision, ledger, status-version, or coordinator mutation. -- Negative entrypoint proofs remove cancellation auditing and the persisted - attention ingress, change the global flag input, remove reconciliation/ +- Negative entrypoint proofs remove cancellation auditing and the finalizer- + owned attention ingress, change the global flag input, remove reconciliation/ workspace execution, and delete the original pending-effect target. Every mapped fixture fails even though its pure resolver still returns the expected policy label. @@ -111,6 +117,16 @@ existing server authority and keep legacy execution as the default. turn, or legacy call. The provider boundary remains scripted, not live Codex. - Runner, shared, server, and database typechecks passed; migration safety passed. +- The remediation-7 rerun passed the 13-file/46-test zero-skip matrix, all 19 + runner-spec checks, runner docs validation, relevant TypeScript checks, and + `git diff --check`. A final call-graph review confirmed the public port owns + result persistence, the finalizer alone commits the zero-decision outcome, + the internal router binds the exact same-company interaction, and committed + replay short-circuits without another assessment or target mutation. +- The design's authoritative 93-file allowlist was mechanically compared with + the Phase 6 branch diff and had no missing or extra path, including the README, + spec checker, canonical helper, runtime-mode test, and database-backed port + test paths. # Failures diff --git a/scripts/check-runner-phase5-spec.mjs b/scripts/check-runner-phase5-spec.mjs index 538a5a72b20..748b0945e75 100644 --- a/scripts/check-runner-phase5-spec.mjs +++ b/scripts/check-runner-phase5-spec.mjs @@ -224,6 +224,31 @@ check("duplicate attention creates no status decision or unbounded loop", () => return `${duplicates.length} duplicate fixture(s) bounded at zero decisions/wakes/notifications`; }); +check("audit-only duplicate and stale fixtures retain exact durable targets", () => { + const expected = new Map([ + ["duplicate-and-fresh-key-question", ["equivalent_attention_family", "link_canonical_request"]], + ["stale-attention-response", ["response_after_supersession", "record_stale_response"]], + ]); + for (const [id, [completionState, effect]] of expected) { + const fixture = corpus.fixtures.find((candidate) => candidate.id === id); + if (!fixture) throw new Error(`missing ${id}`); + if ( + fixture.mode !== "native" + || fixture.given.completionState !== completionState + || fixture.expected.runStatus !== "succeeded" + || fixture.expected.statusAction !== "preserve" + || fixture.expected.reasonCode !== "attention_duplicate_suppressed" + || fixture.expected.decisionCount !== 0 + || fixture.expected.maxWakeCount !== 0 + || fixture.expected.maxNotificationCount !== 0 + || !fixture.expected.requiredEffects.includes(effect) + ) { + throw new Error(`${id} does not pin the zero-decision ${effect} outcome`); + } + } + return "duplicate and stale targets are zero-decision audit outcomes"; +}); + check("completion-gating human judgment is explicit", () => { const fixture = corpus.fixtures.find((candidate) => candidate.id === "human-authority-required"); if (!fixture) throw new Error("missing human-authority-required fixture"); diff --git a/server/src/__tests__/native-interaction-bridge.test.ts b/server/src/__tests__/native-interaction-bridge.test.ts index f88b444b8d6..2853202e9df 100644 --- a/server/src/__tests__/native-interaction-bridge.test.ts +++ b/server/src/__tests__/native-interaction-bridge.test.ts @@ -1,6 +1,8 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { randomUUID } from "node:crypto"; import { activityLog, + agentWakeupRequests, agents, companies, completionContracts, @@ -378,4 +380,317 @@ describe("P6-19 native interaction bridge", () => { ]); } }, 30_000); + + it("commits duplicate and stale attention as replay-stable zero-decision audit outcomes", async () => { + const auditSummary = "Response retained for audit after native supersession."; + const seedAuditRun = async (input: { + label: string; + requests: Record[]; + interactions: Array; + }) => { + const localIssueId = randomUUID(); + const localRunId = randomUUID(); + const localContractId = randomUUID(); + const contractSha = `audit-contract:${input.label}`; + await db.insert(issues).values({ + id: localIssueId, + companyId, + title: `Audit-only native attention ${input.label}`, + status: "in_progress", + assigneeAgentId: agentId, + workMode: "standard", + }); + await db.insert(completionContracts).values({ + id: localContractId, + companyId, + issueId: localIssueId, + revision: 1, + schemaVersion: "paperclip.completion-contract.v1", + policyVersion: "phase6-v1", + risk: "standard", + completionAuthority: "server_arbiter", + incompleteCriteriaPolicy: "preserve_non_terminal", + contractJson: { + revision: "audit-v1", + objective: "Retain superseded attention for audit", + criteria: [{ id: "objective", requirement: "Preserve the authoritative issue state" }], + }, + canonicalSha256: contractSha, + createdByActorType: "system", + createdByActorId: "test", + }); + await db.insert(heartbeatRuns).values({ + id: localRunId, + companyId, + agentId, + status: "running", + runtimeMode: "native", + runtimeModeResolverVersion: "phase6-v1", + runtimeModeReason: "eligible_opt_in", + runtimeModeResolvedAt: new Date(), + completionContractId: localContractId, + completionContractSha256: contractSha, + contextSnapshot: { issueId: localIssueId }, + }); + if (input.interactions.length > 0) { + await db.insert(issueThreadInteractions).values(input.interactions.map((interaction) => ({ + ...interaction, + companyId: interaction.companyId ?? companyId, + issueId: interaction.issueId ?? localIssueId, + }))); + } + const port = new PaperclipControlPlanePort(db, { + companyId, + issueId: localIssueId, + runId: localRunId, + agentId, + completionContractId: localContractId, + completionContractSha256: contractSha, + sourceInstanceId: `audit-runner:${input.label}`, + controlPlaneSourceInstanceId: `audit-control:${input.label}`, + }); + await port.completeRun({ + result: { + schema: "paperclip.run_result.v1", + reportedWorkDisposition: "yielded", + summary: `Audit-only native attention ${input.label}`, + completionClaim: { + contractRevision: "audit-v1", + objectiveSatisfied: false, + criteria: [{ criterionId: "objective", status: "unknown", evidenceRefs: [] }], + remainingWork: [{ description: "The canonical request remains authoritative", blocksCompletion: true }], + }, + evidence: [], + verification: [{ commandOrCheck: "audit-only attention", status: "not_run" }], + attentionRequests: input.requests, + artifacts: [], + continuation: { + kind: "response_wake", + summary: "Wait for the canonical request", + idempotencyKey: `audit-only:${input.label}`, + }, + }, + terminal: { + schema: "paperclip.prp.terminal.v1", + turnTerminalState: "completed", + runTerminalState: "succeeded", + reportedWorkDisposition: "yielded", + }, + turnId: `audit-turn:${input.label}`, + }); + return { issueId: localIssueId, runId: localRunId }; + }; + + const cases = ["duplicate", "stale", "mixed"] as const; + for (const label of cases) { + const canonicalId = randomUUID(); + const duplicateId = randomUUID(); + const staleId = randomUUID(); + const duplicateRequest = { + id: `audit:${label}:duplicate`, + requestedCapability: "duplicate", + requiredAuthority: "agent", + target: { ownerClass: "current_agent", companyId }, + summary: `Duplicate attention ${label}`, + responseState: "none", + targetInteractionId: duplicateId, + canonicalRequestId: canonicalId, + }; + const staleRequest = { + id: `audit:${label}:stale`, + requestedCapability: "context_lookup", + requiredAuthority: "agent", + target: { ownerClass: "current_agent", companyId }, + summary: `Stale attention ${label}`, + responseState: "stale", + targetInteractionId: staleId, + }; + const requests = label === "duplicate" + ? [duplicateRequest] + : label === "stale" ? [staleRequest] : [duplicateRequest, staleRequest]; + const interactions: Array = []; + if (label !== "stale") { + interactions.push( + { + id: canonicalId, + kind: "request_confirmation", + status: "pending", + payload: { version: 1, prompt: `Canonical ${label}` }, + }, + { + id: duplicateId, + kind: "request_confirmation", + status: "expired", + resolvedAt: new Date(), + payload: { version: 1, prompt: `Duplicate ${label}` }, + result: { version: 1, outcome: "superseded_by_newer_request", supersededByInteractionId: canonicalId }, + }, + ); + } + if (label !== "duplicate") { + interactions.push({ + id: staleId, + kind: "request_confirmation", + status: "expired", + resolvedAt: new Date(), + payload: { version: 1, prompt: `Stale ${label}` }, + result: { version: 1, outcome: "superseded_by_comment", commentId: randomUUID() }, + }); + } + const seeded = await seedAuditRun({ label, requests, interactions }); + const finalized = await finalizeNativeRun({ + db, + runId: seeded.runId, + workspaceFinalizeStatus: "succeeded", + projectRunStatus: true, + }); + expect(finalized).toMatchObject({ phase: "committed", decisionId: null }); + expect(finalized.attentionReceipts).toHaveLength(requests.length); + expect(finalized.attentionReceipts).toEqual(expect.arrayContaining(requests.map((request) => + expect.objectContaining({ + requestId: request.id, + decisionId: null, + reasonCode: "attention_duplicate_suppressed", + materializedTargets: [expect.objectContaining({ + targetType: "issue_thread_interaction", + targetId: request.targetInteractionId, + })], + }), + ))); + + await expect(db.select().from(issues).where(eq(issues.id, seeded.issueId))).resolves.toEqual([ + expect.objectContaining({ status: "in_progress", statusVersion: 0, lastStatusDecisionId: null }), + ]); + await expect(db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seeded.runId))).resolves.toEqual([ + expect.objectContaining({ + status: "succeeded", + nativePhase: "committed", + resultJson: expect.objectContaining({ + finalizationPhase: "committed", + decisionId: null, + authoritativeDecision: null, + nativeAttentionRouting: expect.any(Array), + }), + }), + ]); + await expect(db.select().from(nativeRunFinalizations) + .where(eq(nativeRunFinalizations.runId, seeded.runId))).resolves.toEqual([ + expect.objectContaining({ phase: "committed", decisionId: null, failureCode: null }), + ]); + await expect(db.select().from(statusDecisions).where(eq(statusDecisions.issueId, seeded.issueId))).resolves.toHaveLength(0); + await expect(db.select().from(statusDecisionEffects).where(eq(statusDecisionEffects.issueId, seeded.issueId))).resolves.toHaveLength(0); + await expect(db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, companyId))).resolves.not.toEqual( + expect.arrayContaining([expect.objectContaining({ payload: expect.objectContaining({ issueId: seeded.issueId }) })]), + ); + + const targetIds = requests.map((request) => request.targetInteractionId); + const beforeReplay = await db.select({ + id: issueThreadInteractions.id, + summary: issueThreadInteractions.summary, + updatedAt: issueThreadInteractions.updatedAt, + }).from(issueThreadInteractions).where(and( + eq(issueThreadInteractions.issueId, seeded.issueId), + )); + expect(beforeReplay.filter((interaction) => targetIds.includes(interaction.id))).toEqual( + expect.arrayContaining(requests.map((request) => expect.objectContaining({ + id: request.targetInteractionId, + summary: request.responseState === "stale" + ? auditSummary + : `Canonical native attention request: ${canonicalId}`, + }))), + ); + const assessmentCount = await db.select().from(workAssessments) + .where(eq(workAssessments.runId, seeded.runId)).then((rows) => rows.length); + if (label === "stale") { + // Model a restart after the audit target/receipt became durable but + // before the coordinator commit. The router must rediscover the + // request without applying the interaction mutation a second time. + await db.update(nativeRunFinalizations).set({ + phase: "arbitrating", + decisionId: null, + leaseOwner: null, + leaseExpiresAt: null, + }).where(eq(nativeRunFinalizations.runId, seeded.runId)); + } + await expect(finalizeNativeRun({ + db, + runId: seeded.runId, + workspaceFinalizeStatus: "succeeded", + projectRunStatus: true, + })).resolves.toMatchObject({ phase: "committed", decisionId: null }); + const afterReplay = await db.select({ + id: issueThreadInteractions.id, + summary: issueThreadInteractions.summary, + updatedAt: issueThreadInteractions.updatedAt, + }).from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, seeded.issueId)); + expect(afterReplay).toEqual(beforeReplay); + await expect(db.select().from(workAssessments) + .where(eq(workAssessments.runId, seeded.runId)).then((rows) => rows.length)).resolves.toBe(assessmentCount); + const coordinatorBeforeCommittedReplay = await db.select({ attempt: nativeRunFinalizations.attempt }) + .from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, seeded.runId)) + .then((rows) => rows[0] ?? null); + await expect(finalizeNativeRun({ + db, + runId: seeded.runId, + workspaceFinalizeStatus: "succeeded", + projectRunStatus: true, + })).resolves.toMatchObject({ phase: "committed", decisionId: null }); + await expect(db.select({ attempt: nativeRunFinalizations.attempt }) + .from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, seeded.runId)) + .then((rows) => rows[0] ?? null)).resolves.toEqual(coordinatorBeforeCommittedReplay); + } + + const outsideCompanyId = randomUUID(); + const outsideIssueId = randomUUID(); + const outsideInteractionId = randomUUID(); + await db.insert(companies).values({ id: outsideCompanyId, name: "Outside audit target", issuePrefix: "OAT" }); + await db.insert(issues).values({ + id: outsideIssueId, + companyId: outsideCompanyId, + title: "Outside audit target", + status: "in_progress", + workMode: "standard", + }); + await db.insert(issueThreadInteractions).values({ + id: outsideInteractionId, + companyId: outsideCompanyId, + issueId: outsideIssueId, + kind: "request_confirmation", + status: "expired", + resolvedAt: new Date(), + payload: { version: 1, prompt: "Outside stale response" }, + result: { version: 1, outcome: "superseded_by_comment", commentId: randomUUID() }, + }); + for (const [label, targetInteractionId] of [ + ["missing", randomUUID()], + ["cross-company", outsideInteractionId], + ] as const) { + const seeded = await seedAuditRun({ + label: `invalid-${label}`, + requests: [{ + id: `audit:invalid:${label}`, + requestedCapability: "context_lookup", + requiredAuthority: "agent", + target: { ownerClass: "current_agent", companyId }, + summary: `Invalid audit target ${label}`, + responseState: "stale", + targetInteractionId, + }], + interactions: [], + }); + await expect(finalizeNativeRun({ + db, + runId: seeded.runId, + workspaceFinalizeStatus: "succeeded", + projectRunStatus: true, + })).resolves.toMatchObject({ phase: "retryable_failure", failureCode: "side_effect_planning_failed" }); + await expect(db.select().from(issues).where(eq(issues.id, seeded.issueId))).resolves.toEqual([ + expect.objectContaining({ status: "in_progress", statusVersion: 0, lastStatusDecisionId: null }), + ]); + await expect(db.select().from(statusDecisions).where(eq(statusDecisions.issueId, seeded.issueId))).resolves.toHaveLength(0); + } + await expect(db.select({ summary: issueThreadInteractions.summary }) + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.id, outsideInteractionId))).resolves.toEqual([{ summary: null }]); + }, 30_000); }); diff --git a/server/src/__tests__/native-status-arbiter-corpus.test.ts b/server/src/__tests__/native-status-arbiter-corpus.test.ts index 09a943998e7..d9e1bd02bb5 100644 --- a/server/src/__tests__/native-status-arbiter-corpus.test.ts +++ b/server/src/__tests__/native-status-arbiter-corpus.test.ts @@ -27,7 +27,6 @@ import { applyNativeAttentionStatusDecision, materializeNativeInteractionResponses, rejectUnsupportedNativeRuntimeRequest, - routePersistedNativeResultAttention, resolveNativeAttentionStatus, } from "../services/native-runtime/native-interaction-bridge.js"; import { @@ -55,6 +54,7 @@ import { import { projectNativeTerminalRunStatus, recordNativeFinalizationFailure, + finalizeNativeRun, resolveNativeFinalizerStatus, } from "../services/native-runtime/native-run-finalizer.js"; import { @@ -706,7 +706,13 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => { }, }, }).where(eq(nativeRunResults.id, seeded.resultId!)); - const [receipt] = await routePersistedNativeResultAttention({ db, runId: seeded.runId }); + const finalized = await finalizeNativeRun({ + db, + runId: seeded.runId, + workspaceFinalizeStatus: "succeeded", + projectRunStatus: true, + }); + const [receipt] = "attentionReceipts" in finalized ? finalized.attentionReceipts : []; if (!receipt) throw new Error(`${fixture.id}: persisted attention route missing`); const persistedDecision = receipt.decisionId ? await db.select().from(statusDecisions).where(eq(statusDecisions.id, receipt.decisionId)) @@ -728,14 +734,43 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => { liveEntrypointCommitted = true; for (const effect of routedDecision.effects) materializedEffects.add(effect.kind); consumerExecutions.push({ - consumer: "native-attention-entrypoint", + consumer: "native-attention-finalizer", observed: { + phase: finalized.phase, decisionId: receipt.decisionId, resolvedTargetAgentId: receipt.resolvedTargetAgentId, reasonCode: receipt.reasonCode, + materializedTargets: receipt.materializedTargets, persistedResultId: seeded.resultId, }, }); + if (receipt.decisionId === null) { + const beforeReplay = liveAttentionInteractionId + ? await db.select({ + summary: issueThreadInteractions.summary, + updatedAt: issueThreadInteractions.updatedAt, + }).from(issueThreadInteractions).where(eq(issueThreadInteractions.id, liveAttentionInteractionId)) + .then((rows) => rows[0] ?? null) + : null; + const assessmentCount = await db.select().from(workAssessments) + .where(eq(workAssessments.runId, seeded.runId)).then((rows) => rows.length); + const replayed = await finalizeNativeRun({ + db, + runId: seeded.runId, + workspaceFinalizeStatus: "succeeded", + projectRunStatus: true, + }); + expect(replayed, `${fixture.id}:audit replay coordinator`).toMatchObject({ phase: "committed", decisionId: null }); + if (liveAttentionInteractionId) { + await expect(db.select({ + summary: issueThreadInteractions.summary, + updatedAt: issueThreadInteractions.updatedAt, + }).from(issueThreadInteractions).where(eq(issueThreadInteractions.id, liveAttentionInteractionId)) + .then((rows) => rows[0] ?? null)).resolves.toEqual(beforeReplay); + } + await expect(db.select().from(workAssessments) + .where(eq(workAssessments.runId, seeded.runId)).then((rows) => rows.length)).resolves.toBe(assessmentCount); + } if (attentionFacts.route === "agent" && completionState !== "cross_company_target") { expect(receipt.resolvedTargetAgentId, `${fixture.id}:eligible delegate`).toBe(delegateAgentId); } @@ -1694,9 +1729,9 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => { if ( attentionFacts && ["attention_response", "attention_candidate", "interaction", "monitor"].includes(String(fixture.given.trigger)) - && !consumerExecutions.some((execution) => execution.consumer === "native-attention-entrypoint") + && !consumerExecutions.some((execution) => execution.consumer === "native-attention-finalizer") ) { - throw new Error(`${fixture.id}: live attention proof missing`); + throw new Error(`${fixture.id}: live attention finalizer proof missing`); } if ( ["REC-04", "REC-06", "REC-07", "REC-08"].some((row) => (fixture.covers.reconciliationRows ?? []).includes(row)) @@ -1832,7 +1867,13 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => { }).reasonCode).toBe("attention_routed_to_agent"); await expect(executeFixture(byId("human-request-routed-to-agent"), { disableLiveEntrypoint: "attention", - })).rejects.toThrow("live attention proof missing"); + })).rejects.toThrow("live attention finalizer proof missing"); + await expect(executeFixture(byId("duplicate-and-fresh-key-question"), { + disableLiveEntrypoint: "attention", + })).rejects.toThrow("live attention finalizer proof missing"); + await expect(executeFixture(byId("stale-attention-response"), { + disableLiveEntrypoint: "attention", + })).rejects.toThrow("live attention finalizer proof missing"); expect(resolveNativeReconciliationStatus({ facts: { workspaceOperationPending: true }, diff --git a/server/src/services/native-runtime/native-interaction-bridge.ts b/server/src/services/native-runtime/native-interaction-bridge.ts index 588a6faf62e..e6a7e0f482f 100644 --- a/server/src/services/native-runtime/native-interaction-bridge.ts +++ b/server/src/services/native-runtime/native-interaction-bridge.ts @@ -66,7 +66,10 @@ export async function applyNativeAttentionStatusDecision(input: { if (effect.kind === "link_canonical_request") { const boundIds = [input.canonicalRequestId, input.targetInteractionId] .filter((id): id is string => typeof id === "string"); - const interactions = await tx.select({ id: issueThreadInteractions.id }) + const interactions = await tx.select({ + id: issueThreadInteractions.id, + summary: issueThreadInteractions.summary, + }) .from(issueThreadInteractions).where(and( eq(issueThreadInteractions.companyId, input.companyId), eq(issueThreadInteractions.issueId, input.issueId), @@ -81,25 +84,34 @@ export async function applyNativeAttentionStatusDecision(input: { if (!canonical || !duplicate || canonical.id === duplicate.id) { throw new NativeInteractionBridgeError("native_attention_canonical_pair_missing", "Canonical and duplicate requests are required"); } - await tx.update(issueThreadInteractions).set({ - summary: `Canonical native attention request: ${canonical.id}`, - updatedAt: new Date(), - }).where(eq(issueThreadInteractions.id, duplicate.id)); + const summary = `Canonical native attention request: ${canonical.id}`; + if (duplicate.summary !== summary) { + await tx.update(issueThreadInteractions).set({ + summary, + updatedAt: new Date(), + }).where(eq(issueThreadInteractions.id, duplicate.id)); + } targets.push({ effectKind: effect.kind, targetType: "issue_thread_interaction", targetId: duplicate.id }); continue; } if (effect.kind === "record_stale_response") { - const interaction = await tx.select({ id: issueThreadInteractions.id }) + const interaction = await tx.select({ + id: issueThreadInteractions.id, + summary: issueThreadInteractions.summary, + }) .from(issueThreadInteractions).where(and( eq(issueThreadInteractions.companyId, input.companyId), eq(issueThreadInteractions.issueId, input.issueId), ...(input.targetInteractionId ? [eq(issueThreadInteractions.id, input.targetInteractionId)] : []), )).limit(1).then((rows) => rows[0] ?? null); if (!interaction) throw new NativeInteractionBridgeError("native_stale_response_missing", "Stale response is not durable"); - await tx.update(issueThreadInteractions).set({ - summary: "Response retained for audit after native supersession.", - updatedAt: new Date(), - }).where(eq(issueThreadInteractions.id, interaction.id)); + const summary = "Response retained for audit after native supersession."; + if (interaction.summary !== summary) { + await tx.update(issueThreadInteractions).set({ + summary, + updatedAt: new Date(), + }).where(eq(issueThreadInteractions.id, interaction.id)); + } targets.push({ effectKind: effect.kind, targetType: "issue_thread_interaction", targetId: interaction.id }); continue; } @@ -402,9 +414,11 @@ function candidateFromPersistedRequest(request: Record): Native export type PersistedNativeAttentionReceipt = { requestId: string; route: NativeAttentionCandidate["route"]; + assessmentId: string; decisionId: string | null; reasonCode: string | null; resolvedTargetAgentId: string | null; + materializedTargets: NativeAttentionMaterializedTarget[]; replayed: boolean; }; @@ -504,9 +518,11 @@ export async function routePersistedNativeResultAttention(input: { receipts.push({ requestId, route: candidate.route, + assessmentId: existingAssessment.id, decisionId: existingDecision.id, reasonCode: existingDecision.reasonCode, resolvedTargetAgentId: nonEmptyText(delegation?.agentId), + materializedTargets: [], replayed: true, }); continue; @@ -554,10 +570,12 @@ export async function routePersistedNativeResultAttention(input: { receipts.push({ requestId, route: candidate.route, + assessmentId: assessment.id, decisionId: routed.decisionId, reasonCode: routed.decision.reasonCode, resolvedTargetAgentId: routed.resolvedTargetAgentId, - replayed: false, + materializedTargets: routed.targets, + replayed: existingAssessment !== null, }); } diff --git a/server/src/services/native-runtime/native-run-finalizer.ts b/server/src/services/native-runtime/native-run-finalizer.ts index 20b7e1f34eb..c12161e62ba 100644 --- a/server/src/services/native-runtime/native-run-finalizer.ts +++ b/server/src/services/native-runtime/native-run-finalizer.ts @@ -42,6 +42,18 @@ function authoritativeStatus(value: string): NativeAuthoritativeIssueStatus { return value as NativeAuthoritativeIssueStatus; } +function isCommittedAuditOnlyRouting(value: unknown) { + const receipts = record(value).nativeAttentionRouting; + return Array.isArray(receipts) && receipts.length > 0 && receipts.every((candidate) => { + const receipt = record(candidate); + const targets = receipt.materializedTargets; + return receipt.decisionId === null + && receipt.reasonCode === "attention_duplicate_suppressed" + && Array.isArray(targets) + && targets.length > 0; + }); +} + /** Server-owned terminal conversion used by live finalization and read models. */ export function projectNativeTerminalRunStatus( terminalState: "succeeded" | "failed" | "cancelled" | "active", @@ -101,7 +113,20 @@ async function claimCoordinator(input: { db: Db; runId: string }) { .where(eq(nativeRunFinalizations.runId, input.runId)).for("update").limit(1) .then((rows) => rows[0] ?? null); if (!coordinator?.resultId) throw new Error("native_finalization_missing"); - if (coordinator.phase === "committed" && coordinator.decisionId) { + if (coordinator.phase === "committed") { + if (!coordinator.decisionId) { + const run = await tx.select({ resultJson: heartbeatRuns.resultJson }) + .from(heartbeatRuns) + .where(and( + eq(heartbeatRuns.id, coordinator.runId), + eq(heartbeatRuns.companyId, coordinator.companyId), + )) + .limit(1) + .then((rows) => rows[0] ?? null); + if (!run || !isCommittedAuditOnlyRouting(run.resultJson)) { + throw new Error("native_finalization_committed_outcome_missing"); + } + } return { coordinator, leaseOwner: null }; } if (coordinator.phase === "terminal_failure") throw new Error("native_finalization_terminal_failure"); @@ -385,12 +410,37 @@ export async function finalizeNativeRun(input: { eq(statusDecisions.issueId, authoritativeIssue.id), )).limit(1).then((rows) => rows[0] ?? null) : null; - if (!latestCoordinator || !latestIssue || !latestDecision) { + const auditOnly = attentionReceipts.length > 0 + && attentionReceipts.every((receipt) => + receipt.decisionId === null + && receipt.reasonCode === "attention_duplicate_suppressed" + && receipt.materializedTargets.length > 0 + ); + if (!latestCoordinator || !latestIssue || (!latestDecision && !auditOnly)) { throw new Error("native_attention_routing_incomplete"); } + const committedCoordinator = auditOnly + ? await input.db.update(nativeRunFinalizations).set({ + phase: "committed", + assessmentId: attentionReceipts.at(-1)?.assessmentId ?? latestCoordinator.assessmentId, + decisionId: null, + leaseOwner: null, + leaseExpiresAt: null, + failureCode: null, + failureDetail: null, + nextAttemptAt: null, + updatedAt: new Date(), + }).where(and( + eq(nativeRunFinalizations.runId, input.runId), + eq(nativeRunFinalizations.companyId, run.companyId), + eq(nativeRunFinalizations.issueId, authoritativeIssue.id), + eq(nativeRunFinalizations.leaseOwner, claim.leaseOwner!), + )).returning().then((rows) => rows[0] ?? null) + : latestCoordinator; + if (!committedCoordinator) throw new Error("native_attention_audit_commit_failed"); const now = new Date(); - const attentionFailed = latestCoordinator.phase === "retryable_failure" - || latestCoordinator.phase === "terminal_failure"; + const attentionFailed = committedCoordinator.phase === "retryable_failure" + || committedCoordinator.phase === "terminal_failure"; const currentRun = await input.db.select({ resultJson: heartbeatRuns.resultJson }) .from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)) .limit(1).then((rows) => rows[0] ?? null); @@ -401,14 +451,14 @@ export async function finalizeNativeRun(input: { : terminalState === "succeeded" ? "succeeded" : terminalState === "cancelled" ? "cancelled" : "failed", finishedAt: now, } : {}), - nativePhase: latestCoordinator.phase, + nativePhase: committedCoordinator.phase, nativePhaseUpdatedAt: now, resultJson: { ...record(currentRun?.resultJson), - finalizationPhase: latestCoordinator.phase, - assessmentId: latestCoordinator.assessmentId, - decisionId: latestCoordinator.decisionId, - authoritativeDecision: latestDecision.toStatus, + finalizationPhase: committedCoordinator.phase, + assessmentId: committedCoordinator.assessmentId, + decisionId: committedCoordinator.decisionId, + authoritativeDecision: latestDecision?.toStatus ?? null, issueStatusBefore: authoritativeIssue.status, issueStatusAfter: latestIssue.status, statusVersionBefore: Number(authoritativeIssue.statusVersion), @@ -418,7 +468,7 @@ export async function finalizeNativeRun(input: { }, updatedAt: now, }).where(eq(heartbeatRuns.id, run.id)); - return { ...latestCoordinator, attentionReceipts }; + return { ...committedCoordinator, attentionReceipts }; } catch (error) { if (error instanceof NativeStatusRaceError && attempt < 2) { supersedesAssessmentId = assessmentRow.id;