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 4d73fc7f4db..5d979aa0843 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 @@ -843,14 +843,16 @@ Policy resolvers remain pure and may be tested directly, but their return value is not operational proof. Cancellation fixtures enter through `cancelNativeSession`, which commits the decision or an explicit audit-only replacement-turn outcome. Attention fixtures enter through -`routeNativeAttention`, which resolves a same-company eligible delegate and -uses the company-scoped issue service. REC-04/06/07/08 fixtures enter through +the persisted accepted-result ingress, which calls `routeNativeAttention` +internally, resolves a same-company eligible delegate, and uses the +company-scoped issue service. REC-04/06/07/08 fixtures enter through `reconcileNativeFinalizations`; REC-04 invokes the workspace operation recorder and observes the actual finalizer result. REC-06/07/08 reclassify the persisted result and contract into a new append-only assessment before an explicitly authorized superseding commit; REC-07 cannot be selected by an unrelated work -product. MIG-08 persists the kill switch in the agent-owned runtime profile, -and `resolveNativeRuntimeMode` enforces that state for subsequent dispatches. +product. MIG-08 uses the instance-global flag through the production heartbeat selector: +persisted native mode wins for an active run and a fresh unresolved run selects +legacy while the agent profile remains unchanged. `resolveNativeFinalizerStatus`, `resolveNativeAttentionStatus`, `resolveNativeCancellationStatus`, `resolveNativeReconciliationStatus`, the @@ -863,6 +865,58 @@ tests deliberately remove each live entrypoint/action and the replay target; the mapped fixture fails even though the corresponding pure resolver still returns its expected label. +### Remediation 6 persisted-attention and global-kill-switch amendment (2026-08-09) + +Accepted native attention now enters through the runtime finalization call +graph, not through a corpus-owned call to `routeNativeAttention`: + +```text +PaperclipControlPlanePort.completeRun + -> native_run_results (accepted immutable package result) + -> finalizeNativeRun + -> routePersistedNativeResultAttention + -> recordNativeAttentionAssessment + -> routeNativeAttention + -> StatusDecisionCommitter + -> issueService / issueThreadInteractionService / recovery + activity owners +``` + +The persisted-result ingress derives company, issue, run, agent, result, and +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. + +MIG-08 now represents the actual instance-global transition. The production +heartbeat selector reads the persisted mode first: an already-selected native +run remains native and can finish or reconcile from its coordinator after +`experimental.enableNativeRunner` turns off. An unresolved fresh run for the +same still-opted-in agent resolves to legacy with +`instance_flag_disabled`. The agent's `runtimeConfig.nativeRunner` profile is +not rewritten, and no `disabledByNativeKillSwitch` or other second rollback +control is persisted. Re-enabling the instance flag therefore restores normal +fresh-run selection without an operator profile repair. + +Sabotage changes the production inputs rather than the policy labels: removing +the persisted-result ingress breaks the attention fixtures, and changing the +global flag input from off to on breaks MIG-08 while +`resolveNativeAttentionStatus` and `resolveNativeMigrationStatus` continue to +return their pure labels. The full recovery test observes both durable +outcomes: the original native rows reach committed terminal state and the +fresh run persists legacy mode with zero native result/finalization rows. + +This amendment does not add an attention UI, a public native-attention route, +external-system execution, credential delegation, or auto-approval. The Codex +v1 structured-output surface still emits the compact `kind`/`summary` form; +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. + ## 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 39eceb73968..7923fa6f30d 100644 --- a/packages/paperclip-runner/docs/phase-06-thin-paperclip-adapter.md +++ b/packages/paperclip-runner/docs/phase-06-thin-paperclip-adapter.md @@ -114,21 +114,29 @@ decision, ledger, issue-version, or coordinator mutation. Operational rows use the production call graph, not resolver calls as a proxy. Cancellation is committed and audited by the normalized session cancellation -entrypoint. Attention resolves an eligible same-company target and delegates -through the issue service. Reconciliation selects the persisted +entrypoint. Accepted attention starts at the immutable package-result row: +`finalizeNativeRun` calls `routePersistedNativeResultAttention`, records a +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. +Reconciliation selects the persisted status/evidence/policy branches and executes workspace recovery through the -workspace operation recorder. The rollout kill switch is stored in the owning -agent profile and consumed by subsequent runtime selection. Pending replay +workspace operation recorder. The rollout kill switch is only the global +`experimental.enableNativeRunner` flag. Persisted native runs keep their mode +and finish/reconcile natively after flag-off; fresh unresolved runs for the +same agent persist legacy mode with `instance_flag_disabled`. The opted-in +agent profile remains unchanged, so re-enabling the flag does not require a +profile reset. Pending replay only acknowledges its original decision-scoped target after verifying that the target still exists. -Direct calls to the finalizer, attention, cancellation, reconciliation, -compatibility, and migration resolvers are explicitly pure policy/read-model -tests. They do not satisfy an operational fixture without a live-entrypoint -receipt and concrete target state. Sabotage cases remove cancellation, -attention, reconciliation, rollout enforcement, and a pending replay target; -each mapped fixture fails while its pure resolver continues to return the -expected label. +Direct resolver calls are explicitly pure policy/read-model 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. Result-less same-run recovery is also proven through the production heartbeat seam: a persisted envelope/checkpoint is leased by `reapOrphanedRuns`, enters @@ -146,6 +154,13 @@ the instance flag and one agent profile. The package `trace:phase6` command is a mock contract tracer; real Paperclip proof is the database-backed server matrix and the explicitly gated procedure in the tutorial. +Deferred boundaries are unchanged: there is no attention UI or public +attention API, no external-system action execution, no credential delegation, +and no auto-approval. The Codex v1 structured-output schema authors compact +`kind`/`summary` attention requests; richer canonical PRP target metadata is +accepted and company-validated at the server boundary but does not yet have +provider authoring UX. + See the [runnable tutorial](tutorials/phase-06-thin-paperclip-adapter.md), the [verification record](../knowledge/evidence/2026-08-09-phase-06-verification.md), and the [approved design](design/phase-6-thin-paperclip-adapter.md). 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 91dde775220..0820019f7c3 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-three targeted tests check company binding, duplicate/gap replay, +port. Forty-five 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, @@ -69,11 +69,16 @@ start embedded PostgreSQL and do not skip when a developer database URL is absent. The corpus cases dispatch fixture-specific database shapes through live -cancellation, attention, reconciliation, and rollout entrypoints whenever an -operational effect is claimed. Workspace recovery records the actual operation -result, delegation uses an eligible same-company agent and the issue service, -and a persisted kill switch changes the next runtime selection. Pure resolver -and read-model calls remain useful policy tests but cannot satisfy these rows. +cancellation, persisted-result attention, reconciliation, and heartbeat +selection entrypoints whenever an operational effect is claimed. Workspace +recovery records the actual operation result. Accepted attention is read back +from `native_run_results`, records a request-specific assessment, delegates an +eligible same-company agent through the issue service, routes human authority +through an issue-thread interaction, and rejects a cross-company target with a +durable decision/recovery/audit trail. The kill-switch case uses the global +instance flag: an active persisted native run remains native while a fresh run +for the same unchanged agent profile selects legacy. Pure resolver and +read-model calls remain useful policy tests but cannot satisfy these rows. Each native effect must have a delivered, company-bound target or concrete target-state mutation, and pending replay may only acknowledge its original decision-scoped target. Sabotage tests remove each live action and the replay @@ -212,7 +217,9 @@ curl -fsS \ Expected: the new run reports `legacy` and `instance_flag_disabled`; an already selected native run still reconciles from its persisted native coordinator and -never falls through to the legacy adapter. +never falls through to the legacy adapter. The agent's `nativeRunner` profile +must still say `mode=native`; flag-off must not persist a second per-agent +disable bit. Restore the saved profile after the proof: @@ -231,4 +238,12 @@ jq -n \ Stop if a native failure invokes legacy, credentials appear in model input or stored PRP bytes, a workspace-finalization failure reports success, replay -changes bytes, or a model-reported disposition bypasses the server arbiter. +changes bytes, a model-reported disposition bypasses the server arbiter, or +flag-off rewrites the agent profile. + +This procedure does not exercise an attention UI or public attention endpoint; +neither exists in Phase 6. The Codex v1 result tool authors compact +`kind`/`summary` attention requests. Rich target metadata is a canonical PRP +input accepted and company-validated by the server, but provider authoring UX, +external-system action execution, credential delegation, and auto-approval are +deferred. 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 952501a145d..092ea3feb8c 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 @@ -13,7 +13,7 @@ This record verifies the default-off Phase 6 tracer at the approved `ControlPlanePort`/`NativeSessionBackend` boundary. No browser surface changed, so Phase 6 has command/database evidence rather than screenshots. -The counts below are the PAP-16879 remediation rerun. “Internal canary” means +The counts below are the PAP-16880 remediation rerun. “Internal canary” means the selected-task database test through the public package session contract; it does not mean that a new live Codex provider task was dispatched. @@ -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 43 tests in thirteen files against +The complete acceptance gate below passed 45 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 @@ -70,6 +70,11 @@ The focused corpus also proved: - 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; +- accepted package-result attention enters through `finalizeNativeRun` and the + persisted-result router: same-company delegation uses `issueService`, human + authority creates an issue-thread interaction, and cross-company targeting + persists a rejection decision, recovery action, failed finalization, and + audit receipt; - 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`; @@ -82,7 +87,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, 43-test gate: +The acceptance-matrix entry points passed as one 13-file, 45-test gate: ```sh pnpm --filter @paperclipai/server exec vitest run \ @@ -106,18 +111,21 @@ 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 resolves a same-company eligible target and -uses the issue service; REC-04/06/07/08 enter the reconciler, with REC-04 +`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 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 next runtime selection from the agent-owned -kill-switch profile. +MIG-08 is enforced by the production heartbeat selector using the global flag: +the persisted active run remains native, a fresh unresolved run selects legacy, +and the agent profile remains unchanged. 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 and deletes a replay target; +it. 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 test-owned scenario policy supplies an observation. The selected-task @@ -176,7 +184,14 @@ Explicit cancellation, agent pause, and budget hard-stop share before existing process/resource cleanup. The default and kill-switch paths execute the pre-existing adapter branch; no same-run native-to-legacy fallback exists. Recovery queries persisted native mode/coordinator state rather than -the current flag. +the current flag. The kill switch does not mutate `agents.runtime_config` or +persist a per-agent disable bit. + +No attention UI or public attention endpoint is claimed. The Codex v1 result +tool authors compact `kind`/`summary` requests; richer canonical PRP target +metadata is server-validated but does not yet have provider authoring UX. +External-system execution, credential delegation, and auto-approval remain +deferred. ```sh pnpm --filter @paperclipai/server exec vitest run \ 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 1645dec5e41..04a2908dfb2 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 @@ -62,11 +62,17 @@ existing server authority and keep legacy execution as the default. reconciliation, or runtime selection and retain that entrypoint receipt. 17. Resume workspace finalization through the workspace operation recorder and observe its actual result; never insert a pre-succeeded recovery marker. -18. Resolve attention delegates from eligible same-company agents and create - delegated work through the issue service. Persist authorized status and - rollout changes through their owning issue and agent services. +18. Begin accepted attention routing at the immutable package-result boundary. + The finalizer records a request-specific assessment, resolves delegates + from eligible same-company agents, creates delegated work through the issue + service, routes human authority through the interaction service, and + records cross-company rejection through decision/recovery/audit owners. 19. Keep pending replay acknowledgement-only, scoped to its original decision, and fail when the owning transaction's target no longer exists. +20. Treat `experimental.enableNativeRunner` as the only kill switch. Persisted + native runs retain native recovery after flag-off; fresh unresolved runs + select legacy. Never rewrite the agent profile or persist a second rollback + control. # Evidence @@ -74,7 +80,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-three tests passed +- Phase 6 acceptance-matrix entry points: thirteen files, forty-five tests passed with zero skips, including the database-backed selected-task canary, six atomic-liveness failpoints, migration, sequencing, bounded recovery, and legacy compatibility. @@ -93,10 +99,11 @@ 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, attention routing, - reconciliation/workspace execution, rollout selector enforcement, and the - original pending-effect target. Every mapped fixture fails even though its - pure resolver still returns the expected policy label. +- Negative entrypoint proofs remove cancellation auditing and the persisted + 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. - The production heartbeat reaper resumed one persisted result-less run through the original execution/finalization path with the flag disabled. It recovered an already-active scripted provider turn and produced exactly one result, @@ -125,6 +132,10 @@ existing server authority and keep legacy execution as the default. - Resolved confirmations and question answers are materialized only through the existing authorized Paperclip interaction service; governed and unsupported requests cannot auto-approve. +- Phase 6 adds no attention UI or public attention endpoint. The Codex v1 tool + authors compact `kind`/`summary` requests; richer canonical PRP target hints + are server-validated but do not yet have provider authoring UX. External + execution, credential delegation, and auto-approval remain deferred. - 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. diff --git a/packages/paperclip-runner/spec/fixtures/status-authority-phase5.json b/packages/paperclip-runner/spec/fixtures/status-authority-phase5.json index e6fffd30a04..86ded7f2ec8 100644 --- a/packages/paperclip-runner/spec/fixtures/status-authority-phase5.json +++ b/packages/paperclip-runner/spec/fixtures/status-authority-phase5.json @@ -261,8 +261,8 @@ "covers": { "decisionRows": [], "terminalRows": [], "attentionRows": ["ATT-09"], "livenessRows": [], "reconciliationRows": [], "compatibilityRows": [], "migrationRows": [] }, "tags": ["excessive_human_request"], "given": { "priorIssueStatus": "in_progress", "turnTerminalState": "completed", "runTerminalState": "succeeded", "reportedWorkDisposition": "yielded", "nativeFinalization": "present", "completionState": "cross_company_target", "trigger": "attention_candidate" }, - "expected": { "runStatus": "failed", "statusAction": "preserve", "reasonCode": "result_schema_rejected", "requiredEffects": ["record_finalization_error"], "forbiddenEffects": ["create_interaction", "notify_owner", "enqueue_continuation"], "livePathKind": null, "preserveClaim": true, "nativeRecords": true, "decisionCount": 0, "maxWakeCount": 0, "maxNotificationCount": 0 }, - "replay": { "attempts": 2, "sameDecisionDigest": true, "maxSemanticDecisions": 0, "maxDomainEffectsPerKey": 1 } + "expected": { "runStatus": "failed", "statusAction": "preserve", "reasonCode": "result_schema_rejected", "requiredEffects": ["record_finalization_error"], "forbiddenEffects": ["create_interaction", "notify_owner", "enqueue_continuation"], "livePathKind": "recovery", "preserveClaim": true, "nativeRecords": true, "decisionCount": 1, "maxWakeCount": 0, "maxNotificationCount": 0 }, + "replay": { "attempts": 2, "sameDecisionDigest": true, "maxSemanticDecisions": 1, "maxDomainEffectsPerKey": 1 } }, { "id": "stale-attention-response", @@ -459,7 +459,7 @@ "covers": { "decisionRows": [], "terminalRows": [], "attentionRows": [], "livenessRows": [], "reconciliationRows": [], "compatibilityRows": [], "migrationRows": ["MIG-08"] }, "tags": ["rollback", "native_legacy_distinction", "reconciliation"], "given": { "priorIssueStatus": "in_progress", "turnTerminalState": "completed", "runTerminalState": "succeeded", "reportedWorkDisposition": "yielded", "nativeFinalization": "present", "completionState": "kill_switch_during_active_native_run", "trigger": "runner_finalizer" }, - "expected": { "runStatus": "succeeded", "statusAction": "in_progress", "reasonCode": "live_continuation_registered", "requiredEffects": ["enqueue_continuation", "finish_as_native", "stop_new_native_dispatch"], "forbiddenEffects": ["convert_active_run_to_legacy", "drop_coordinator"], "livePathKind": "continuation", "preserveClaim": true, "nativeRecords": true, "decisionCount": 1, "maxWakeCount": 1, "maxNotificationCount": 0 }, + "expected": { "runStatus": "succeeded", "statusAction": "in_progress", "reasonCode": "live_continuation_registered", "requiredEffects": ["enqueue_continuation", "finish_as_native", "fresh_flag_off_run_selects_legacy"], "forbiddenEffects": ["convert_active_run_to_legacy", "persist_agent_kill_switch", "drop_coordinator"], "livePathKind": "continuation", "preserveClaim": true, "nativeRecords": true, "decisionCount": 1, "maxWakeCount": 1, "maxNotificationCount": 0 }, "replay": { "attempts": 3, "sameDecisionDigest": true, "maxSemanticDecisions": 1, "maxDomainEffectsPerKey": 1 } }, { diff --git a/scripts/check-runner-phase5-spec.mjs b/scripts/check-runner-phase5-spec.mjs index 4c6bb08bf03..538a5a72b20 100644 --- a/scripts/check-runner-phase5-spec.mjs +++ b/scripts/check-runner-phase5-spec.mjs @@ -262,9 +262,11 @@ check("native and legacy expectations remain intentionally distinct", () => { check("rollback fixture forbids active-run mode conversion", () => { const rollback = corpus.fixtures.find((fixture) => fixture.covers.migrationRows.includes("MIG-08")); if (!rollback) throw new Error("missing MIG-08 fixture"); - if (!rollback.expected.requiredEffects.includes("stop_new_native_dispatch") || - !rollback.expected.forbiddenEffects.includes("convert_active_run_to_legacy")) { - throw new Error("MIG-08 does not prove dispatch stop plus immutable active-run mode"); + if (!rollback.expected.requiredEffects.includes("fresh_flag_off_run_selects_legacy") || + !rollback.expected.requiredEffects.includes("finish_as_native") || + !rollback.expected.forbiddenEffects.includes("convert_active_run_to_legacy") || + !rollback.expected.forbiddenEffects.includes("persist_agent_kill_switch")) { + throw new Error("MIG-08 does not prove global flag-off selection plus immutable active-run mode"); } return rollback.id; }); diff --git a/server/src/__tests__/native-interaction-bridge.test.ts b/server/src/__tests__/native-interaction-bridge.test.ts index 55e6fa65efa..f88b444b8d6 100644 --- a/server/src/__tests__/native-interaction-bridge.test.ts +++ b/server/src/__tests__/native-interaction-bridge.test.ts @@ -1,17 +1,27 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { + activityLog, agents, companies, + completionContracts, createDb, heartbeatRuns, + issueRecoveryActions, issueThreadInteractions, issues, + nativeRunFinalizations, + statusDecisionEffects, + statusDecisions, + workAssessments, } from "@paperclipai/db"; +import { and, eq } from "drizzle-orm"; import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; import { materializeNativeInteractionResponses, NativeInteractionBridgeError, } from "../services/native-runtime/native-interaction-bridge.js"; +import { PaperclipControlPlanePort } from "../services/native-runtime/paperclip-control-plane-port.js"; +import { finalizeNativeRun } from "../services/native-runtime/native-run-finalizer.js"; describe("P6-19 native interaction bridge", () => { let temporary: Awaited> | null = null; @@ -169,4 +179,203 @@ describe("P6-19 native interaction bridge", () => { expect(error).toBeInstanceOf(NativeInteractionBridgeError); expect(error).toMatchObject({ code }); }); + + it("routes accepted package-result attention through live issue and interaction owners", async () => { + const delegateId = "78000000-0000-4000-8000-000000000020"; + const outsideCompanyId = "78000000-0000-4000-8000-000000000021"; + const outsideAgentId = "78000000-0000-4000-8000-000000000022"; + await db.insert(companies).values({ id: outsideCompanyId, name: "Outside attention", issuePrefix: "NIO" }); + await db.insert(agents).values([ + { + id: delegateId, + companyId, + name: "Native attention delegate", + adapterType: "codex_local", + status: "idle", + }, + { + id: outsideAgentId, + companyId: outsideCompanyId, + name: "Outside attention delegate", + adapterType: "codex_local", + status: "idle", + }, + ]); + + const scenarios = [ + { + key: "agent", + issueId: "78000000-0000-4000-8000-000000000023", + runId: "78000000-0000-4000-8000-000000000024", + contractId: "78000000-0000-4000-8000-000000000025", + disposition: "yielded" as const, + request: { + id: "attention-agent", + requestedCapability: "domain_expertise", + summary: "Delegate a same-company native investigation", + target: { ownerClass: "agent", agentId: delegateId, companyId }, + }, + }, + { + key: "human", + issueId: "78000000-0000-4000-8000-000000000026", + runId: "78000000-0000-4000-8000-000000000027", + contractId: "78000000-0000-4000-8000-000000000028", + disposition: "needs_review" as const, + request: { + id: "attention-human", + requestedCapability: "subjective_decision", + requiredAuthority: "board", + summary: "Choose the acceptable native result", + target: { ownerClass: "board_user", companyId }, + }, + }, + { + key: "cross-company", + issueId: "78000000-0000-4000-8000-000000000029", + runId: "78000000-0000-4000-8000-000000000030", + contractId: "78000000-0000-4000-8000-000000000031", + disposition: "yielded" as const, + request: { + id: "attention-cross-company", + requestedCapability: "domain_expertise", + summary: "Reject an outside-company native delegate", + target: { ownerClass: "agent", agentId: outsideAgentId, companyId: outsideCompanyId }, + }, + }, + ]; + + for (const scenario of scenarios) { + const contractSha = `attention-contract:${scenario.key}`; + await db.insert(issues).values({ + id: scenario.issueId, + companyId, + title: `Native attention ${scenario.key}`, + status: "in_progress", + assigneeAgentId: agentId, + workMode: "standard", + }); + await db.insert(completionContracts).values({ + id: scenario.contractId, + companyId, + issueId: scenario.issueId, + revision: 1, + schemaVersion: "paperclip.completion-contract.v1", + policyVersion: "phase6-v1", + risk: "standard", + completionAuthority: "server_arbiter", + incompleteCriteriaPolicy: "preserve_non_terminal", + contractJson: { revision: "attention-v1", objective: scenario.key, criteria: [{ id: "objective" }] }, + canonicalSha256: contractSha, + createdByActorType: "system", + createdByActorId: "test", + }); + await db.insert(heartbeatRuns).values({ + id: scenario.runId, + companyId, + agentId, + status: "running", + runtimeMode: "native", + runtimeModeResolverVersion: "phase6-v1", + runtimeModeReason: "eligible_opt_in", + runtimeModeResolvedAt: new Date(), + completionContractId: scenario.contractId, + completionContractSha256: contractSha, + contextSnapshot: { issueId: scenario.issueId }, + }); + const port = new PaperclipControlPlanePort(db, { + companyId, + issueId: scenario.issueId, + runId: scenario.runId, + agentId, + completionContractId: scenario.contractId, + completionContractSha256: contractSha, + sourceInstanceId: `runner:${scenario.key}`, + controlPlaneSourceInstanceId: `control:${scenario.key}`, + }); + await port.completeRun({ + result: { + schema: "paperclip.run_result.v1", + reportedWorkDisposition: scenario.disposition, + summary: `Native attention ${scenario.key}`, + completionClaim: { + contractRevision: "attention-v1", + objectiveSatisfied: false, + criteria: [{ criterionId: "objective", status: "unknown", evidenceRefs: [] }], + remainingWork: [{ description: "Resolve attention", blocksCompletion: true }], + }, + evidence: [], + verification: [{ commandOrCheck: "attention", status: "not_run" }], + attentionRequests: [scenario.request], + artifacts: [], + ...(scenario.disposition === "yielded" ? { + continuation: { + kind: "response_wake" as const, + summary: "Resume after attention", + idempotencyKey: `attention:${scenario.key}`, + }, + } : {}), + }, + terminal: { + schema: "paperclip.prp.terminal.v1", + turnTerminalState: "completed", + runTerminalState: "succeeded", + reportedWorkDisposition: scenario.disposition, + }, + turnId: `turn:${scenario.key}`, + }); + await finalizeNativeRun({ + db, + runId: scenario.runId, + workspaceFinalizeStatus: "succeeded", + projectRunStatus: true, + }); + } + + const delegated = await db.select().from(issues).where(and( + eq(issues.parentId, scenarios[0]!.issueId), + eq(issues.companyId, companyId), + )); + expect(delegated).toEqual([ + expect.objectContaining({ assigneeAgentId: delegateId, status: "todo" }), + ]); + const humanInteractions = await db.select().from(issueThreadInteractions) + .where(eq(issueThreadInteractions.issueId, scenarios[1]!.issueId)); + expect(humanInteractions).toEqual([ + expect.objectContaining({ kind: "ask_user_questions", status: "pending", sourceRunId: scenarios[1]!.runId }), + ]); + await expect(db.select().from(issues).where(eq(issues.id, scenarios[1]!.issueId))).resolves.toEqual([ + expect.objectContaining({ status: "in_review" }), + ]); + await expect(db.select().from(issues).where(eq(issues.parentId, scenarios[2]!.issueId))).resolves.toHaveLength(0); + await expect(db.select().from(issueRecoveryActions) + .where(eq(issueRecoveryActions.sourceIssueId, scenarios[2]!.issueId))).resolves.toEqual([ + expect.objectContaining({ cause: "result_schema_rejected", ownerAgentId: agentId }), + ]); + await expect(db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, scenarios[2]!.runId))).resolves.toEqual([ + expect.objectContaining({ status: "failed", nativePhase: "retryable_failure" }), + ]); + + for (const scenario of scenarios) { + const assessments = await db.select().from(workAssessments) + .where(eq(workAssessments.issueId, scenario.issueId)); + expect(assessments.map((row) => row.triggerKind)).toEqual(expect.arrayContaining(["native_result", "native_attention"])); + const decisions = await db.select().from(statusDecisions) + .where(eq(statusDecisions.issueId, scenario.issueId)); + expect(decisions).toHaveLength(1); + const effects = await db.select().from(statusDecisionEffects) + .where(eq(statusDecisionEffects.decisionId, decisions[0]!.id)); + expect(effects.length).toBeGreaterThan(0); + await expect(db.select().from(activityLog).where(and( + eq(activityLog.entityId, scenario.issueId), + eq(activityLog.runId, scenario.runId), + ))).resolves.toEqual(expect.arrayContaining([ + expect.objectContaining({ actorId: "native-status-committer" }), + ])); + await expect(db.select().from(nativeRunFinalizations) + .where(eq(nativeRunFinalizations.runId, scenario.runId))).resolves.toEqual([ + expect.objectContaining({ assessmentId: decisions[0]!.assessmentId, decisionId: decisions[0]!.id }), + ]); + } + }, 30_000); }); diff --git a/server/src/__tests__/native-session-resumption.test.ts b/server/src/__tests__/native-session-resumption.test.ts index 3462737e51b..335d69532d6 100644 --- a/server/src/__tests__/native-session-resumption.test.ts +++ b/server/src/__tests__/native-session-resumption.test.ts @@ -38,9 +38,15 @@ import { dispatchNativeSessionResumptions, } from "../services/native-runtime/native-finalization-reconciler.js"; -const legacyAdapterExecute = vi.hoisted(() => vi.fn(async () => { - throw new Error("legacy adapter must not execute while resuming a persisted native run"); -})); +const legacyAdapterExecute = vi.hoisted(() => vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + summary: "Fresh flag-off run completed through legacy.", + resultJson: { summary: "fresh legacy after persisted native recovery" }, + provider: "test", + model: "legacy-test", +}))); vi.mock("../adapters/index.js", async () => { const actual = await vi.importActual("../adapters/index.js"); @@ -234,6 +240,7 @@ describe("P6-25 persisted reaper-to-finalization recovery", () => { const projectWorkspaceId = randomUUID(); const executionWorkspaceId = randomUUID(); const issueId = randomUUID(); + const freshIssueId = randomUUID(); const runId = randomUUID(); const contractId = randomUUID(); const workProductId = randomUUID(); @@ -471,7 +478,7 @@ describe("P6-25 persisted reaper-to-finalization recovery", () => { } }); - it("runs the production reaper, original heartbeat, recovered active turn, and finalizer exactly once", async () => { + it("finishes the persisted native run after flag-off and selects legacy only for a fresh run", async () => { legacyAdapterExecute.mockClear(); const backendFactory = vi.fn(() => backend); const heartbeat = heartbeatService(db, { @@ -537,5 +544,45 @@ describe("P6-25 persisted reaper-to-finalization recovery", () => { await expect(db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, runId))).resolves.toHaveLength(1); await expect(db.select().from(workAssessments).where(eq(workAssessments.runId, runId))).resolves.toHaveLength(1); await expect(db.select().from(statusDecisions).where(eq(statusDecisions.issueId, issueId))).resolves.toHaveLength(1); + + await db.insert(issues).values({ + id: freshIssueId, + companyId, + projectId, + projectWorkspaceId, + issueNumber: 2, + identifier: "NRR-2", + title: "Start only after the native kill switch is off", + status: "in_progress", + assigneeAgentId: agentId, + workMode: "standard", + }); + const fresh = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { issueId: freshIssueId }, + contextSnapshot: { issueId: freshIssueId, taskId: freshIssueId, skipIssueComment: true }, + }); + expect(fresh).not.toBeNull(); + await drainHeartbeatRunsToQuiescence(db, heartbeat); + expect(legacyAdapterExecute).toHaveBeenCalledOnce(); + await expect(db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fresh!.id))).resolves.toEqual([ + expect.objectContaining({ + agentId, + runtimeMode: "legacy", + runtimeModeReason: "instance_flag_disabled", + status: "succeeded", + }), + ]); + await expect(db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, fresh!.id))).resolves.toHaveLength(0); + await expect(db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, fresh!.id))).resolves.toHaveLength(0); + await expect(db.select({ runtimeConfig: agents.runtimeConfig }).from(agents).where(eq(agents.id, agentId))).resolves.toEqual([ + expect.objectContaining({ + runtimeConfig: expect.objectContaining({ + nativeRunner: { mode: "native", backend: "codex_app_server", protocolVersion: 1 }, + }), + }), + ]); }, 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 85e493aef4a..09a943998e7 100644 --- a/server/src/__tests__/native-status-arbiter-corpus.test.ts +++ b/server/src/__tests__/native-status-arbiter-corpus.test.ts @@ -27,7 +27,7 @@ import { applyNativeAttentionStatusDecision, materializeNativeInteractionResponses, rejectUnsupportedNativeRuntimeRequest, - routeNativeAttention, + routePersistedNativeResultAttention, resolveNativeAttentionStatus, } from "../services/native-runtime/native-interaction-bridge.js"; import { @@ -38,6 +38,7 @@ import { import { inspectNativeCompatibilityState, inspectNativeMigrationState, + resolveHeartbeatNativeRuntimeMode, resolveNativeCompatibilityStatus, resolveNativeMigrationStatus, resolveNativeRuntimeMode, @@ -188,7 +189,7 @@ const matrixRowConsumers: Record = { "MIG-05": "native-migration-status", "MIG-06": "native-migration-status", "MIG-07": "native-migration-status", - "MIG-08": "native-migration-status", + "MIG-08": "heartbeat-runtime-selection", "MIG-09": "native-migration-read-model", }; @@ -221,7 +222,7 @@ const completeEvidenceStates = new Set([ ]); const zeroDecisionStates = new Set([ - "safe_partial_parse", "equivalent_attention_family", "cross_company_target", + "safe_partial_parse", "equivalent_attention_family", "response_after_supersession", "reused_id_changed_material", "decision_committed_delivery_pending", ]); @@ -232,7 +233,7 @@ const nativeStatusEffectKinds = new Set([ "append_superseding_assessment", "dispatch_pending_effect", "increment_status_version", "schedule_reconciliation", "record_shadow_decision", "render_four_layers", "materialize_contract", "record_mode_labeled_divergence", "record_mode_native", - "record_policy_version", "finish_as_native", "stop_new_native_dispatch", + "record_policy_version", "finish_as_native", "resume_workspace_operation", "record_expiry", "record_stale_response", "link_canonical_request", "record_recovery", "release_checkout", ]); @@ -552,6 +553,7 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => { const seeded = await seedFixture(fixture); const consumerExecutions: ConsumerExecution[] = []; const materializedEffects = new Set(); + const operationalEffects = new Set(); const priorIssueStatus = String(fixture.given.priorIssueStatus ?? "in_progress") as Parameters[0]["priorIssueStatus"]; const mode = resolveNativeRuntimeMode({ enabled: fixture.mode === "native", @@ -658,34 +660,84 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => { payload: { version: 1, questions: [] }, }); } - const routed = await routeNativeAttention({ - db, - runId: seeded.runId, - candidate: { - route: attentionFacts.route, - summary: attentionFacts.summary, - responseState: attentionFacts.responseState, - budgetExhausted: attentionFacts.budgetExhausted, - governanceGate: attentionFacts.governanceGate, - targetAgentId: completionState === "cross_company_target" ? outsideAgentId : null, - targetInteractionId: liveAttentionInteractionId ?? undefined, - canonicalRequestId, + const target = attentionFacts.route === "agent" + ? { + ownerClass: "agent", + agentId: completionState === "cross_company_target" ? outsideAgentId : delegateAgentId, + companyId: completionState === "cross_company_target" ? outsideCompanyId : companyId, + } + : attentionFacts.route === "human" + ? { ownerClass: "board_user", companyId } + : { ownerClass: "current_agent", companyId }; + const requestedCapability = attentionFacts.route === "context" + ? "context_lookup" + : attentionFacts.route === "retry" + ? "retry" + : attentionFacts.route === "duplicate" + ? "duplicate" + : attentionFacts.route === "alternate_track" + ? "alternate_track" + : attentionFacts.route === "human" + ? "subjective_decision" + : "domain_expertise"; + const resultRow = await db.select({ resultJson: nativeRunResults.resultJson }) + .from(nativeRunResults).where(eq(nativeRunResults.id, seeded.resultId!)) + .then((rows) => rows[0] ?? null); + if (!resultRow) throw new Error(`${fixture.id}: persisted result missing`); + const resultEnvelope = resultRow.resultJson as Record; + const persistedResult = resultEnvelope.result as Record; + await db.update(nativeRunResults).set({ + resultJson: { + ...resultEnvelope, + result: { + ...persistedResult, + attentionRequests: [{ + id: `attention:${fixture.id}`, + requestedCapability, + requiredAuthority: attentionFacts.route === "human" ? "board" : "agent", + target, + summary: attentionFacts.summary, + responseState: attentionFacts.responseState, + budgetExhausted: attentionFacts.budgetExhausted === true, + governanceGate: attentionFacts.governanceGate, + targetInteractionId: liveAttentionInteractionId, + canonicalRequestId, + }], + }, }, - }); + }).where(eq(nativeRunResults.id, seeded.resultId!)); + const [receipt] = await routePersistedNativeResultAttention({ db, runId: seeded.runId }); + 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)) + .then((rows) => rows[0] ?? null) + : null; + const persistedDecisionJson = persistedDecision?.decisionJson as Record | undefined; + const routedDecision = persistedDecision + ? { + policyVersion: persistedDecision.policyVersion, + statusAction: persistedDecisionJson?.statusAction, + toStatus: persistedDecision.toStatus, + reasonCode: persistedDecision.reasonCode, + unblockDescriptor: persistedDecisionJson?.unblockDescriptor ?? null, + effects: persistedDecisionJson?.effects, + } as NativeStatusDecision + : resolveNativeAttentionStatus({ facts: attentionFacts, priorIssueStatus, agentId }); semanticConsumer = "native-attention-resolver"; - consumerDecision = pushDecisionConsumer(semanticConsumer, routed.decision); + consumerDecision = pushDecisionConsumer(semanticConsumer, routedDecision); liveEntrypointCommitted = true; - for (const target of routed.targets) materializedEffects.add(target.effectKind); + for (const effect of routedDecision.effects) materializedEffects.add(effect.kind); consumerExecutions.push({ consumer: "native-attention-entrypoint", observed: { - decisionId: routed.decisionId, - resolvedTargetAgentId: routed.resolvedTargetAgentId, - targetCount: routed.targets.length, + decisionId: receipt.decisionId, + resolvedTargetAgentId: receipt.resolvedTargetAgentId, + reasonCode: receipt.reasonCode, + persistedResultId: seeded.resultId, }, }); if (attentionFacts.route === "agent" && completionState !== "cross_company_target") { - expect(routed.resolvedTargetAgentId, `${fixture.id}:eligible delegate`).toBe(delegateAgentId); + expect(receipt.resolvedTargetAgentId, `${fixture.id}:eligible delegate`).toBe(delegateAgentId); } } if (["turn_scope", "run_scope", "issue_scope_authorized", "replacement_turn_accepted"].includes(completionState)) { @@ -749,6 +801,56 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => { priorIssueStatus, agentId, })); + } else if ( + completionState === "kill_switch_during_active_native_run" + && (fixture.covers.migrationRows ?? []).includes("MIG-08") + ) { + const runtimeConfig = { + nativeRunner: { mode: "native", backend: "codex_app_server", protocolVersion: 1 }, + }; + const enabled = options.disableLiveEntrypoint === "rollout"; + const activeResolution = resolveHeartbeatNativeRuntimeMode({ + persisted: { + runtimeMode: "native", + runtimeModeReason: "eligible_opt_in", + runtimeModeResolvedAt: new Date(), + }, + enabled, + runtimeConfig, + agent: { id: agentId, status: "running", adapterType: "codex_local" }, + issue: { id: seeded.issueId, workMode: "standard" }, + target: { kind: "local" }, + workspaceId: "fixture-workspace", + }); + const freshResolution = resolveHeartbeatNativeRuntimeMode({ + persisted: { runtimeMode: null, runtimeModeReason: null, runtimeModeResolvedAt: null }, + enabled, + runtimeConfig, + agent: { id: agentId, status: "running", adapterType: "codex_local" }, + issue: { id: seeded.issueId, workMode: "standard" }, + target: { kind: "local" }, + workspaceId: "fixture-workspace", + }); + if ( + activeResolution.kind !== "native" + || freshResolution.kind !== "legacy" + || freshResolution.reason !== "instance_flag_disabled" + || runtimeConfig.nativeRunner.mode !== "native" + ) { + throw new Error(`${fixture.id}: global kill-switch transition missing`); + } + semanticConsumer = "native-migration-status"; + consumerDecision = pushDecisionConsumer(semanticConsumer, activeResolution.authorityDecision); + operationalEffects.add("fresh_flag_off_run_selects_legacy"); + consumerExecutions.push({ + consumer: "heartbeat-runtime-selection", + observed: { + activeMode: activeResolution.kind, + freshMode: freshResolution.kind, + freshReason: freshResolution.reason, + profileMode: runtimeConfig.nativeRunner.mode, + }, + }); } else if (migrationFacts && (fixture.covers.migrationRows ?? []).length > 0 && (fixture.covers.decisionRows ?? []).length === 0) { semanticConsumer = "native-migration-status"; consumerDecision = pushDecisionConsumer( @@ -1437,19 +1539,6 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => { if (persistedEffects.includes("record_mode_native")) expect(persistedRun?.runtimeMode, `${fixture.id}:mode target`).toBe("native"); if (persistedEffects.includes("record_policy_version")) expect(persistedRunnerProfile.nativeStatusPolicyVersion, `${fixture.id}:policy target`).toBe("phase6-v2"); if (persistedEffects.includes("finish_as_native")) expect(persistedRunResult.nativeKillSwitchDisposition, `${fixture.id}:finish-native target`).toBe("finish_as_native"); - if (persistedEffects.includes("stop_new_native_dispatch")) { - const runtimeConfig = await db.select({ runtimeConfig: agents.runtimeConfig }).from(agents) - .where(eq(agents.id, agentId)).then((rows) => rows[0]?.runtimeConfig ?? {}); - expect((runtimeConfig as { nativeRunner?: { mode?: string } }).nativeRunner?.mode, `${fixture.id}:kill-switch target`).toBe("legacy"); - expect(resolveNativeRuntimeMode({ - enabled: true, - runtimeConfig, - agent: { id: agentId, status: "running", adapterType: "codex_local" }, - issue: { id: seeded.issueId, workMode: "standard" }, - target: { kind: "local" }, - workspaceId: "fixture-workspace", - })).toEqual(expect.objectContaining({ kind: "legacy", reason: "native_kill_switch_persisted" })); - } if (materializedEffects.has("link_canonical_request")) { expect(interactionRows.some((row) => row.summary?.startsWith("Canonical native attention request:")), `${fixture.id}:canonical link target`).toBe(true); } @@ -1482,6 +1571,7 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => { let effects = [...new Set([ ...persistedEffects, ...(consumerDecision?.effects.map((effect) => effect.kind) ?? []), + ...operationalEffects, ])]; if (rolledBack) effects = ["record_finalization_error"]; if (!consumerDecision) { @@ -1572,6 +1662,13 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => { } else if (consumer === "status-decision-committer") { const expectedApplicationState = rolledBack ? "rolled_back" : "applied"; if (execution.observed.applicationState !== expectedApplicationState) continue; + } else if (consumer === "heartbeat-runtime-selection") { + if ( + execution.observed.activeMode !== "native" + || execution.observed.freshMode !== "legacy" + || execution.observed.freshReason !== "instance_flag_disabled" + || execution.observed.profileMode !== "native" + ) continue; } consumerEvidenceByRow.set(matrixRow, consumer); } @@ -1750,10 +1847,10 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => { facts: { killSwitchActiveForNewRuns: true }, priorIssueStatus: "in_progress", agentId, - }).effects.map((effect) => effect.kind)).toContain("stop_new_native_dispatch"); + }).effects.map((effect) => effect.kind)).toContain("finish_as_native"); await expect(executeFixture(byId("migration-kill-switch-rollback"), { disableLiveEntrypoint: "rollout", - })).rejects.toThrow("kill-switch target"); + })).rejects.toThrow("global kill-switch transition missing"); const pendingFixture = byId("crash-after-decision-commit"); const seeded = await seedFixture(pendingFixture); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 18a8925b275..86b2731bc56 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -92,7 +92,7 @@ import { finalizeNativeRun, materializeNativeInteractionResponses, reconcileNativeFinalizations, - resolveNativeRuntimeMode, + resolveHeartbeatNativeRuntimeMode, } from "./native-runtime/index.js"; import { parseNativeExecutionInput, @@ -15310,27 +15310,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const adapter = getServerAdapter(agent.adapterType); // Runtime selection is immutable once persisted. In particular, turning the instance flag // off prevents new native runs without changing the recovery path for an already-native run. - const nativeRuntimeResolution = run.runtimeModeResolvedAt - ? run.runtimeMode === "native" - ? { - kind: "native" as const, - resolverVersion: "phase6-v1" as const, - reason: "eligible_opt_in" as const, - profile: { mode: "native" as const, backend: "codex_app_server" as const, protocolVersion: 1 as const }, - } - : { - kind: "legacy" as const, - resolverVersion: "phase6-v1" as const, - reason: run.runtimeModeReason ?? "persisted_legacy_selection", - } - : resolveNativeRuntimeMode({ - enabled: resolvedInstanceSettings.experimental.enableNativeRunner === true, - runtimeConfig: agent.runtimeConfig, - agent: { id: agent.id, status: runningAgent.status, adapterType: agent.adapterType }, - issue: issueRef, - target: executionTarget, - workspaceId: persistedExecutionWorkspace?.id ?? null, - }); + const nativeRuntimeResolution = resolveHeartbeatNativeRuntimeMode({ + persisted: run, + enabled: resolvedInstanceSettings.experimental.enableNativeRunner === true, + runtimeConfig: agent.runtimeConfig, + agent: { id: agent.id, status: runningAgent.status, adapterType: agent.adapterType }, + issue: issueRef, + target: executionTarget, + workspaceId: persistedExecutionWorkspace?.id ?? null, + }); let nativeExecution: ReturnType | null = null; let nativeRunnerInstanceId: string | null = null; if (nativeRuntimeResolution.kind === "native") { diff --git a/server/src/services/native-runtime/native-interaction-bridge.ts b/server/src/services/native-runtime/native-interaction-bridge.ts index c2374fbfd49..588a6faf62e 100644 --- a/server/src/services/native-runtime/native-interaction-bridge.ts +++ b/server/src/services/native-runtime/native-interaction-bridge.ts @@ -1,10 +1,21 @@ import type { Db } from "@paperclipai/db"; import type { NativeInteractionResponseEnvelope } from "@paperclipai/paperclip-runner"; import { and, asc, eq, inArray } from "drizzle-orm"; -import { agents, heartbeatRuns, issues, issueThreadInteractions, nativeRunFinalizations } from "@paperclipai/db"; +import { + agents, + heartbeatRuns, + issues, + issueThreadInteractions, + nativeRunFinalizations, + nativeRunResults, + statusDecisions, + workAssessments, +} from "@paperclipai/db"; import { getAgentWorkEligibility } from "@paperclipai/shared"; import { issueThreadInteractionService } from "../issue-thread-interactions.js"; import { commitNativeStatusDecision } from "./status-decision-committer.js"; +import { nativeSha256 } from "./canonical.js"; +import { recordNativeAttentionAssessment } from "./work-assessments.js"; import { NATIVE_STATUS_ARBITER_POLICY_VERSION, type NativeAuthoritativeIssueStatus, @@ -28,7 +39,7 @@ function record(value: unknown): Record { export type NativeAttentionMaterializedTarget = { effectKind: string; - targetType: "issue_thread_interaction" | "native_run_finalization"; + targetType: string; targetId: string; }; @@ -329,21 +340,255 @@ export type NativeAttentionCandidate = { summary: string; responseState?: NativeAttentionFacts["responseState"]; targetAgentId?: string | null; + targetCompanyId?: string | null; budgetExhausted?: boolean; governanceGate?: NativeGovernanceGate | null; targetInteractionId?: string; canonicalRequestId?: string; }; +function nonEmptyText(value: unknown) { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function candidateFromPersistedRequest(request: Record): NativeAttentionCandidate { + const summary = nonEmptyText(request.summary); + if (!summary) { + throw new NativeInteractionBridgeError( + "native_attention_request_invalid", + "Persisted native attention requires a summary", + ); + } + const target = record(request.target); + const capability = nonEmptyText(request.requestedCapability) ?? nonEmptyText(request.kind); + const ownerClass = nonEmptyText(target.ownerClass); + const requiredAuthority = nonEmptyText(request.requiredAuthority); + const responseState = nonEmptyText(request.responseState); + const governanceGate = record(request.governanceGate); + let route: NativeAttentionCandidate["route"]; + if (capability === "context_lookup") route = "context"; + else if (capability === "retry") route = "retry"; + else if (capability === "duplicate") route = "duplicate"; + else if (capability === "alternate_track" || ownerClass === "current_agent") route = "alternate_track"; + else if (capability === "domain_expertise" || ownerClass === "agent") route = "agent"; + else if ( + ["review", "credential_grant", "governed_approval", "subjective_decision", "external_action"].includes(capability ?? "") + || ["board_user", "external_system"].includes(ownerClass ?? "") + || ["board", "external"].includes(requiredAuthority ?? "") + ) route = "human"; + else { + throw new NativeInteractionBridgeError( + "native_attention_request_invalid", + `Persisted native attention capability ${capability ?? "unknown"} has no authorized route`, + ); + } + return { + route, + summary, + responseState: ["none", "resolved", "stale", "expired"].includes(responseState ?? "") + ? responseState as NativeAttentionFacts["responseState"] + : undefined, + targetAgentId: nonEmptyText(target.agentId), + targetCompanyId: nonEmptyText(target.companyId), + budgetExhausted: request.budgetExhausted === true, + governanceGate: governanceGate.kind === "interaction" && nonEmptyText(governanceGate.id) + ? { kind: "interaction", id: nonEmptyText(governanceGate.id)! } + : null, + targetInteractionId: nonEmptyText(request.targetInteractionId) ?? undefined, + canonicalRequestId: nonEmptyText(request.canonicalRequestId) ?? undefined, + }; +} + +export type PersistedNativeAttentionReceipt = { + requestId: string; + route: NativeAttentionCandidate["route"]; + decisionId: string | null; + reasonCode: string | null; + resolvedTargetAgentId: string | null; + replayed: boolean; +}; + /** - * Canonical production ingress for native attention. It resolves the run and - * issue binding, derives company scope, chooses an eligible in-company - * delegate, and commits (or explicitly audit-materializes) the resulting + * Runtime-reachable ingress for accepted attention facts. It starts from the + * immutable package result row, derives an authorized route, appends one + * assessment/decision lineage per request, and lets the existing issue and + * interaction owners materialize the effect. + */ +export async function routePersistedNativeResultAttention(input: { + db: Db; + runId: string; +}): Promise { + const binding = await input.db.select({ + companyId: heartbeatRuns.companyId, + runtimeMode: heartbeatRuns.runtimeMode, + resultId: nativeRunFinalizations.resultId, + issueId: nativeRunFinalizations.issueId, + }).from(heartbeatRuns) + .innerJoin(nativeRunFinalizations, eq(nativeRunFinalizations.runId, heartbeatRuns.id)) + .where(eq(heartbeatRuns.id, input.runId)) + .limit(1) + .then((rows) => rows[0] ?? null); + if (!binding || binding.runtimeMode !== "native" || !binding.resultId) { + throw new NativeInteractionBridgeError( + "native_attention_binding_mismatch", + "Accepted native result binding not found", + ); + } + const resultRow = await input.db.select().from(nativeRunResults).where(and( + eq(nativeRunResults.id, binding.resultId), + eq(nativeRunResults.companyId, binding.companyId), + eq(nativeRunResults.issueId, binding.issueId), + eq(nativeRunResults.runId, input.runId), + eq(nativeRunResults.schemaStatus, "accepted"), + )).limit(1).then((rows) => rows[0] ?? null); + if (!resultRow) { + throw new NativeInteractionBridgeError( + "native_attention_result_missing", + "Accepted native result row not found", + ); + } + const result = record(record(resultRow.resultJson).result); + const requests = Array.isArray(result.attentionRequests) + ? result.attentionRequests.map(record) + : []; + const receipts: PersistedNativeAttentionReceipt[] = []; + + for (const [index, request] of requests.entries()) { + const candidate = candidateFromPersistedRequest(request); + const requestId = nonEmptyText(request.id) ?? nativeSha256({ + runId: input.runId, + resultId: resultRow.id, + index, + request, + }); + const existingAssessment = await input.db.select({ id: workAssessments.id }) + .from(workAssessments).where(and( + eq(workAssessments.companyId, binding.companyId), + eq(workAssessments.issueId, binding.issueId), + eq(workAssessments.runId, input.runId), + eq(workAssessments.triggerKind, "native_attention"), + eq(workAssessments.triggerRef, requestId), + )).limit(1).then((rows) => rows[0] ?? null); + if (existingAssessment) { + const existingDecision = await input.db.select({ + id: statusDecisions.id, + reasonCode: statusDecisions.reasonCode, + decisionJson: statusDecisions.decisionJson, + }).from(statusDecisions).where(and( + eq(statusDecisions.companyId, binding.companyId), + eq(statusDecisions.assessmentId, existingAssessment.id), + )).limit(1).then((rows) => rows[0] ?? null); + if (existingDecision) { + const decisionJson = record(existingDecision.decisionJson); + const effects = Array.isArray(decisionJson.effects) ? decisionJson.effects.map(record) : []; + const delegation = effects.find((effect) => effect.kind === "create_delegated_issue"); + const finalizationError = effects.find((effect) => effect.kind === "record_finalization_error"); + await input.db.update(nativeRunFinalizations).set({ + phase: finalizationError ? "retryable_failure" : "committed", + assessmentId: existingAssessment.id, + decisionId: existingDecision.id, + leaseOwner: null, + leaseExpiresAt: null, + failureCode: nonEmptyText(finalizationError?.cause), + failureDetail: finalizationError ? { + originalFailureCode: finalizationError.cause, + nextAction: finalizationError.nextAction, + } : null, + nextAttemptAt: finalizationError ? new Date(Date.now() + 30_000) : null, + updatedAt: new Date(), + }).where(and( + eq(nativeRunFinalizations.runId, input.runId), + eq(nativeRunFinalizations.companyId, binding.companyId), + eq(nativeRunFinalizations.issueId, binding.issueId), + )); + receipts.push({ + requestId, + route: candidate.route, + decisionId: existingDecision.id, + reasonCode: existingDecision.reasonCode, + resolvedTargetAgentId: nonEmptyText(delegation?.agentId), + replayed: true, + }); + continue; + } + } + + const issue = await input.db.select({ + status: issues.status, + statusVersion: issues.statusVersion, + lastStatusDecisionId: issues.lastStatusDecisionId, + }).from(issues).where(and( + eq(issues.id, binding.issueId), + eq(issues.companyId, binding.companyId), + )).limit(1).then((rows) => rows[0] ?? null); + if (!issue) throw new NativeInteractionBridgeError("native_attention_binding_mismatch", "Issue binding not found"); + const coordinator = await input.db.select({ + phase: nativeRunFinalizations.phase, + assessmentId: nativeRunFinalizations.assessmentId, + }).from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, input.runId)) + .limit(1).then((rows) => rows[0] ?? null); + if (!coordinator) throw new NativeInteractionBridgeError("native_attention_binding_mismatch", "Coordinator binding not found"); + const assessment = await recordNativeAttentionAssessment({ + db: input.db, + companyId: binding.companyId, + issueId: binding.issueId, + runId: input.runId, + turnId: resultRow.turnId, + contractId: resultRow.completionContractId, + resultId: resultRow.id, + requestId, + request, + routingFacts: candidate as unknown as Record, + priorIssueStatus: issue.status, + priorStatusVersion: Number(issue.statusVersion), + priorDecisionId: issue.lastStatusDecisionId, + supersedesAssessmentId: coordinator.assessmentId, + }); + const routed = await routeNativeAttention({ + db: input.db, + runId: input.runId, + assessmentId: assessment.id, + allowSupersedingCommittedDecision: coordinator.phase === "committed", + candidate, + }); + receipts.push({ + requestId, + route: candidate.route, + decisionId: routed.decisionId, + reasonCode: routed.decision.reasonCode, + resolvedTargetAgentId: routed.resolvedTargetAgentId, + replayed: false, + }); + } + + if (receipts.length > 0) { + await input.db.transaction(async (tx) => { + const run = await tx.select({ resultJson: heartbeatRuns.resultJson }) + .from(heartbeatRuns).where(and( + eq(heartbeatRuns.id, input.runId), + eq(heartbeatRuns.companyId, binding.companyId), + )).for("update").limit(1).then((rows) => rows[0] ?? null); + if (!run) throw new NativeInteractionBridgeError("native_attention_binding_mismatch", "Heartbeat run missing"); + await tx.update(heartbeatRuns).set({ + resultJson: { ...record(run.resultJson), nativeAttentionRouting: receipts }, + updatedAt: new Date(), + }).where(eq(heartbeatRuns.id, input.runId)); + }); + } + return receipts; +} + +/** + * Internal owner-routing step for native attention. The persisted-result + * ingress above supplies the server-owned assessment and untrusted candidate; + * this step resolves company scope and commits or audit-materializes the * authority decision. */ export async function routeNativeAttention(input: { db: Db; runId: string; + assessmentId?: string; + allowSupersedingCommittedDecision?: boolean; candidate: NativeAttentionCandidate; }) { const binding = await input.db.select({ @@ -380,8 +625,13 @@ export async function routeNativeAttention(input: { .from(agents).where(eq(agents.id, input.candidate.targetAgentId)).limit(1) .then((rows) => rows[0] ?? null) : null; - const companyScopeValid = !input.candidate.targetAgentId - || targetSuggestion?.companyId === binding.companyId; + const companyScopeValid = ( + !input.candidate.targetCompanyId + || input.candidate.targetCompanyId === binding.companyId + ) && ( + !input.candidate.targetAgentId + || targetSuggestion?.companyId === binding.companyId + ); let resolvedTargetAgentId: string | null = null; if (companyScopeValid && input.candidate.route === "agent") { const eligible = companyAgents.filter((candidate) => @@ -408,8 +658,7 @@ export async function routeNativeAttention(input: { priorIssueStatus: binding.issueStatus as NativeAuthoritativeIssueStatus, agentId: binding.agentId, }); - const auditOnly = !companyScopeValid - || decision.reasonCode === "attention_duplicate_suppressed"; + const auditOnly = decision.reasonCode === "attention_duplicate_suppressed"; if (auditOnly) { const targets = await applyNativeAttentionStatusDecision({ db: input.db, @@ -422,7 +671,8 @@ export async function routeNativeAttention(input: { }); return { decision, decisionId: null, targets, resolvedTargetAgentId }; } - if (!binding.assessmentId) { + const assessmentId = input.assessmentId ?? binding.assessmentId; + if (!assessmentId) { throw new NativeInteractionBridgeError( "native_attention_assessment_missing", "Native attention requires the server-owned work assessment for this result", @@ -433,11 +683,12 @@ export async function routeNativeAttention(input: { companyId: binding.companyId, issueId: binding.issueId, runId: input.runId, - assessmentId: binding.assessmentId, + assessmentId, priorStatus: binding.issueStatus, priorStatusVersion: Number(binding.issueStatusVersion), priorDecisionId: binding.issueDecisionId, decision, + allowSupersedingCommittedDecision: input.allowSupersedingCommittedDecision, }); return { decision, diff --git a/server/src/services/native-runtime/native-run-finalizer.ts b/server/src/services/native-runtime/native-run-finalizer.ts index 036926e1dc6..20b7e1f34eb 100644 --- a/server/src/services/native-runtime/native-run-finalizer.ts +++ b/server/src/services/native-runtime/native-run-finalizer.ts @@ -10,6 +10,7 @@ import { issues, nativeRunFinalizations, nativeRunResults, + statusDecisions, } from "@paperclipai/db"; import { classifyNativeEvidence } from "./evidence-classifier.js"; import { @@ -26,6 +27,7 @@ import { } from "./status-decision-committer.js"; import { issueRecoveryActionService } from "../issue-recovery-actions.js"; import { nativeSha256 } from "./canonical.js"; +import { routePersistedNativeResultAttention } from "./native-interaction-bridge.js"; function record(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) @@ -359,6 +361,82 @@ export async function finalizeNativeRun(input: { eq(nativeRunFinalizations.runId, input.runId), eq(nativeRunFinalizations.leaseOwner, claim.leaseOwner!), )); + if (assessment.attentionRequests.length > 0) { + try { + const attentionReceipts = await routePersistedNativeResultAttention({ + db: input.db, + runId: input.runId, + }); + const [latestCoordinator, latestIssue] = await Promise.all([ + input.db.select().from(nativeRunFinalizations) + .where(eq(nativeRunFinalizations.runId, input.runId)) + .limit(1).then((rows) => rows[0] ?? null), + input.db.select({ status: issues.status, statusVersion: issues.statusVersion }) + .from(issues).where(and( + eq(issues.id, authoritativeIssue.id), + eq(issues.companyId, run.companyId), + )).limit(1).then((rows) => rows[0] ?? null), + ]); + const latestDecision = latestCoordinator?.decisionId + ? await input.db.select({ toStatus: statusDecisions.toStatus }) + .from(statusDecisions).where(and( + eq(statusDecisions.id, latestCoordinator.decisionId), + eq(statusDecisions.companyId, run.companyId), + eq(statusDecisions.issueId, authoritativeIssue.id), + )).limit(1).then((rows) => rows[0] ?? null) + : null; + if (!latestCoordinator || !latestIssue || !latestDecision) { + throw new Error("native_attention_routing_incomplete"); + } + const now = new Date(); + const attentionFailed = latestCoordinator.phase === "retryable_failure" + || latestCoordinator.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); + await input.db.update(heartbeatRuns).set({ + ...(input.projectRunStatus || attentionFailed ? { + status: attentionFailed + ? "failed" + : terminalState === "succeeded" ? "succeeded" : terminalState === "cancelled" ? "cancelled" : "failed", + finishedAt: now, + } : {}), + nativePhase: latestCoordinator.phase, + nativePhaseUpdatedAt: now, + resultJson: { + ...record(currentRun?.resultJson), + finalizationPhase: latestCoordinator.phase, + assessmentId: latestCoordinator.assessmentId, + decisionId: latestCoordinator.decisionId, + authoritativeDecision: latestDecision.toStatus, + issueStatusBefore: authoritativeIssue.status, + issueStatusAfter: latestIssue.status, + statusVersionBefore: Number(authoritativeIssue.statusVersion), + statusVersionAfter: Number(latestIssue.statusVersion), + workspaceFinalizeStatus: input.workspaceFinalizeStatus, + nativeAttentionRouting: attentionReceipts, + }, + updatedAt: now, + }).where(eq(heartbeatRuns.id, run.id)); + return { ...latestCoordinator, attentionReceipts }; + } catch (error) { + if (error instanceof NativeStatusRaceError && attempt < 2) { + supersedesAssessmentId = assessmentRow.id; + continue; + } + return recordRetryableFailure({ + db: input.db, + run, + coordinator, + failureCode: error instanceof NativeStatusRaceError ? "status_cas_exhausted" : "side_effect_planning_failed", + message: error instanceof Error ? error.message : String(error), + nextAction: error instanceof NativeStatusRaceError + ? "Reassess native attention against the latest authoritative issue status version." + : "Retry native attention routing from the persisted accepted package result.", + projectRunStatus: input.projectRunStatus, + }); + } + } try { const committed = await commitNativeStatusDecision({ db: input.db, diff --git a/server/src/services/native-runtime/runtime-mode.test.ts b/server/src/services/native-runtime/runtime-mode.test.ts index 5e154de30bf..b488ed29911 100644 --- a/server/src/services/native-runtime/runtime-mode.test.ts +++ b/server/src/services/native-runtime/runtime-mode.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { NativeRuntimeEligibilityError, resolveNativeRuntimeMode } from "./runtime-mode.js"; +import { + NativeRuntimeEligibilityError, + resolveHeartbeatNativeRuntimeMode, + resolveNativeRuntimeMode, +} from "./runtime-mode.js"; const eligible = { enabled: true, @@ -26,6 +30,27 @@ describe("resolveNativeRuntimeMode", () => { })); }); + it("keeps a persisted active run native while the global flag makes a fresh run legacy", () => { + const disabled = { ...eligible, enabled: false }; + expect(resolveHeartbeatNativeRuntimeMode({ + ...disabled, + persisted: { + runtimeMode: "native", + runtimeModeReason: "eligible_opt_in", + runtimeModeResolvedAt: new Date(), + }, + })).toEqual(expect.objectContaining({ + kind: "native", + reason: "eligible_opt_in", + authorityDecision: expect.objectContaining({ reasonCode: "live_continuation_registered" }), + })); + expect(resolveHeartbeatNativeRuntimeMode({ + ...disabled, + persisted: { runtimeMode: null, runtimeModeReason: null, runtimeModeResolvedAt: null }, + })).toEqual(expect.objectContaining({ kind: "legacy", reason: "instance_flag_disabled" })); + expect(disabled.runtimeConfig.nativeRunner.mode).toBe("native"); + }); + it("rejects an explicit native profile outside the approved boundary", () => { expect(() => resolveNativeRuntimeMode({ ...eligible, agent: { ...eligible.agent, adapterType: "claude_local" } })) .toThrow(NativeRuntimeEligibilityError); diff --git a/server/src/services/native-runtime/runtime-mode.ts b/server/src/services/native-runtime/runtime-mode.ts index 01e4c99cd39..f4c77d1db6a 100644 --- a/server/src/services/native-runtime/runtime-mode.ts +++ b/server/src/services/native-runtime/runtime-mode.ts @@ -45,13 +45,6 @@ export function resolveNativeRuntimeMode(input: { }): NativeRuntimeResolution { const nativeRunner = record(record(input.runtimeConfig).nativeRunner); const mode = nativeRunner.mode; - if (nativeRunner.disabledByNativeKillSwitch === true) { - return { - kind: "legacy", - resolverVersion: NATIVE_RUNTIME_RESOLVER_VERSION, - reason: "native_kill_switch_persisted", - }; - } if (!input.enabled) { const compatibility = resolveNativeCompatibilityStatus({ facts: { shadowApplicationDisabled: true }, @@ -101,6 +94,49 @@ export function resolveNativeRuntimeMode(input: { }; } +/** + * Production heartbeat selection seam. A resolved run keeps its persisted + * mode across configuration changes; only a fresh unresolved run consults the + * current global flag and agent profile. + */ +export function resolveHeartbeatNativeRuntimeMode(input: { + persisted: { + runtimeMode: string | null; + runtimeModeReason: string | null; + runtimeModeResolvedAt: Date | null; + }; + enabled: boolean; + runtimeConfig: unknown; + agent: { id?: string; status: string; adapterType: string | null }; + issue: { id: string; workMode: string; executionWorkspaceId?: string | null } | null; + target: { kind?: string } | null | undefined; + workspaceId: string | null; +}): NativeRuntimeResolution { + if (input.persisted.runtimeModeResolvedAt) { + if (input.persisted.runtimeMode === "native") { + return { + kind: "native", + resolverVersion: NATIVE_RUNTIME_RESOLVER_VERSION, + reason: "eligible_opt_in", + profile: { mode: "native", backend: "codex_app_server", protocolVersion: 1 }, + authorityDecision: resolveNativeMigrationStatus({ + facts: input.enabled + ? { applicationEnabled: true } + : { killSwitchActiveForNewRuns: true }, + priorIssueStatus: "in_progress", + agentId: input.agent.id ?? "00000000-0000-4000-8000-000000000000", + }), + }; + } + return { + kind: "legacy", + resolverVersion: NATIVE_RUNTIME_RESOLVER_VERSION, + reason: input.persisted.runtimeModeReason ?? "persisted_legacy_selection", + }; + } + return resolveNativeRuntimeMode(input); +} + /** Production read-model facts used by compatibility and mixed-ledger views. */ export function inspectNativeCompatibilityState(input: { resolution: NativeRuntimeResolution; @@ -267,7 +303,6 @@ export function resolveNativeMigrationStatus(input: { agentId: input.agentId, }, { kind: "finish_as_native" }, - { kind: "stop_new_native_dispatch" }, ], }; } diff --git a/server/src/services/native-runtime/status-arbiter.ts b/server/src/services/native-runtime/status-arbiter.ts index acdc4b9438d..27e17787f33 100644 --- a/server/src/services/native-runtime/status-arbiter.ts +++ b/server/src/services/native-runtime/status-arbiter.ts @@ -45,7 +45,6 @@ export type NativeStatusEffect = | { kind: "record_mode_native" } | { kind: "record_policy_version" } | { kind: "finish_as_native" } - | { kind: "stop_new_native_dispatch" } | { kind: "resume_workspace_operation" } | { kind: "record_expiry" } | { kind: "record_stale_response" } diff --git a/server/src/services/native-runtime/status-decision-committer.ts b/server/src/services/native-runtime/status-decision-committer.ts index 5f2a9f04228..f4ced6def60 100644 --- a/server/src/services/native-runtime/status-decision-committer.ts +++ b/server/src/services/native-runtime/status-decision-committer.ts @@ -26,7 +26,6 @@ import { nativeSha256 } from "./canonical.js"; import { issueService } from "../issues.js"; import { issueThreadInteractionService } from "../issue-thread-interactions.js"; import { issueRecoveryActionService } from "../issue-recovery-actions.js"; -import { agentService } from "../agents.js"; import { buildIssueBlockersResolvedWakeIdempotencyKey } from "../issue-dependency-wakeups.js"; import { persistActivity, publishActivity, type ActivityPublication } from "../activity-log.js"; @@ -881,40 +880,6 @@ async function materializeDecisionEffect(input: { .where(eq(heartbeatRuns.id, input.runId)); return { effectKind: effect.kind, targetType: "heartbeat_run", targetId, payload: { runtimeMode: "native" } }; } - if (effect.kind === "stop_new_native_dispatch") { - const run = await input.tx.select({ agentId: heartbeatRuns.agentId }).from(heartbeatRuns).where(and( - eq(heartbeatRuns.id, input.runId), - eq(heartbeatRuns.companyId, input.companyId), - )).limit(1).then((rows) => rows[0] ?? null); - if (!run) throw new Error("native_kill_switch_run_missing"); - const agent = await agentService(input.tx).getById(run.agentId); - if (!agent || agent.companyId !== input.companyId) throw new Error("native_kill_switch_agent_missing"); - const runtimeConfig = record(agent.runtimeConfig); - const nativeRunner = record(runtimeConfig.nativeRunner); - const updated = await agentService(input.tx).update(run.agentId, { - runtimeConfig: { - ...runtimeConfig, - nativeRunner: { - ...nativeRunner, - mode: "legacy", - disabledByNativeKillSwitch: true, - }, - }, - }, { - recordRevision: { - source: "native_kill_switch", - createdByAgentId: null, - createdByUserId: null, - }, - }); - if (!updated) throw new Error("native_kill_switch_not_persisted"); - return { - effectKind: effect.kind, - targetType: "agent", - targetId: updated.id, - payload: { nextDispatchMode: "legacy", runtimeConfigOwner: updated.id }, - }; - } if (effect.kind === "resume_workspace_operation") { const resumed = input.preMaterializedEffects?.get(effect.kind); if (!resumed || resumed.targetType !== "workspace_operation" || !resumed.targetId) { diff --git a/server/src/services/native-runtime/work-assessments.ts b/server/src/services/native-runtime/work-assessments.ts index 56121a55035..1d53f75e6e9 100644 --- a/server/src/services/native-runtime/work-assessments.ts +++ b/server/src/services/native-runtime/work-assessments.ts @@ -64,3 +64,69 @@ export async function recordNativeWorkAssessment(input: { if (!row) throw new Error("native_assessment_not_persisted"); return row; } + +export async function recordNativeAttentionAssessment(input: { + db: Db; + companyId: string; + issueId: string; + runId: string; + turnId: string | null; + contractId: string; + resultId: string; + requestId: string; + request: Record; + routingFacts: Record; + priorIssueStatus: string; + priorStatusVersion: number; + priorDecisionId: string | null; + supersedesAssessmentId: string | null; +}) { + const assessmentJson = { + kind: "native_attention", + requestId: input.requestId, + request: input.request, + routingFacts: input.routingFacts, + }; + const inputDigest = nativeSha256({ + companyId: input.companyId, + issueId: input.issueId, + runId: input.runId, + resultId: input.resultId, + requestId: input.requestId, + request: input.request, + routingFacts: input.routingFacts, + priorIssueStatus: input.priorIssueStatus, + priorStatusVersion: input.priorStatusVersion, + priorDecisionId: input.priorDecisionId, + policyVersion: "phase6-v2", + }); + const existing = await input.db.select().from(workAssessments).where(and( + eq(workAssessments.companyId, input.companyId), + eq(workAssessments.issueId, input.issueId), + eq(workAssessments.runId, input.runId), + eq(workAssessments.triggerKind, "native_attention"), + eq(workAssessments.triggerRef, input.requestId), + )).limit(1).then((rows) => rows[0] ?? null); + if (existing) return existing; + const [row] = await input.db.insert(workAssessments).values({ + companyId: input.companyId, + issueId: input.issueId, + runId: input.runId, + turnId: input.turnId, + contractId: input.contractId, + resultId: input.resultId, + triggerKind: "native_attention", + triggerRef: input.requestId, + triggerCapability: "server_native_attention_router", + triggerActorCompanyId: input.companyId, + priorIssueStatus: input.priorIssueStatus, + priorStatusVersion: input.priorStatusVersion, + priorDecisionId: input.priorDecisionId, + policyVersion: "phase6-v2", + assessmentJson, + inputDigest, + supersedesAssessmentId: input.supersedesAssessmentId, + }).returning(); + if (!row) throw new Error("native_attention_assessment_not_persisted"); + return row; +}