mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
fix(native-runner): wire live attention and rollback proofs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d53b7902ab
commit
c82a2142c9
18 files changed
+1038
-166
No files matched your search
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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 \
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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);
|
||||
});
|
||||
@@ -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<typeof import("../adapters/index.js")>("../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);
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
"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<NativeStatusEffect["kind"]>([
|
||||
"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<string>();
|
||||
const operationalEffects = new Set<string>();
|
||||
const priorIssueStatus = String(fixture.given.priorIssueStatus ?? "in_progress") as Parameters<typeof arbitrateNativeStatus>[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<string, unknown>;
|
||||
const persistedResult = resultEnvelope.result as Record<string, unknown>;
|
||||
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<string, unknown> | 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);
|
||||
|
||||
@@ -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<typeof buildNativeExecutionInput> | null = null;
|
||||
let nativeRunnerInstanceId: string | null = null;
|
||||
if (nativeRuntimeResolution.kind === "native") {
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
|
||||
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<string, unknown>): 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<PersistedNativeAttentionReceipt[]> {
|
||||
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<string, unknown>,
|
||||
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,
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
routingFacts: Record<string, unknown>;
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user