diff --git a/docs/adr-abort-sdk-terminal-turn-owned.md b/docs/adr-abort-sdk-terminal-turn-owned.md new file mode 100644 index 000000000..6ba5c98a3 --- /dev/null +++ b/docs/adr-abort-sdk-terminal-turn-owned.md @@ -0,0 +1,117 @@ +# ADR: SDK terminal abort — turn-origin fence with owned-completion enablement + +## Decision + +**ADOPT — origin-aware `TurnContinuationFence`/`TurnContinuationGate` plus normal owned-completion delivery.** + +C04 `turn.abort` gains `mode:"terminal"` with typed `scope:"turn" | "owned"` (default `"turn"`) +and a required bounded idempotency key (≤128 UTF-8 bytes). Terminal abort stops the root +worker's current turn and blocks **only** that turn's own continuation routes; exact owned +background work (Bash/task jobs, detached subagents) that the caller deliberately leaves +running keeps running, and its completion/progress is delivered through the existing +`YieldQueue -> agent.followUp`/`agent.prompt` path as a **fresh** root turn with a new +attempt/lineage/worker epoch. + +## Prominent corrected design note (mandatory) + +> **ADR/design note — turn abort is not owned-delivery abort.** `scope:"turn"` closes the root +> worker's current turn and its own continuation routes, while exact owned work remains +> runnable and its completion/progress results are intentionally delivered through the +> existing `YieldQueue -> AgentSession -> agent.followUp`/`agent.prompt` path. The delivery +> starts a fresh root turn with a new attempt/lineage. The earlier stage-04 no-successor fence +> that suppressed or deferred those deliveries was a misunderstanding: it defeated the reason +> to expose a leave-running option. **Do not reinstate it under another name.** + +## Naming rules + +- Blocked routes are **turn-origin continuations**: `TurnContinuationFence`, + `TurnContinuationGate`, `blockedContinuationIds`, `predecessorTombstones`. The gate denies + only `turn-continuation` origins after close. +- Allowed left-running feedback is **owned-completion delivery**: `ownedCompletionPolicy`, + `ownedCompletionDelivery`, `resumeFromOwnedCompletion`, `OwnedCompletionEnvelope`. A closed + turn record never invalidates or denies an allowed owned-completion entry. +- **Prohibited names** (any code, test, or review text): `TurnDeliveryGate`, + `suppressOwnedDelivery`, `closedOwnedDeliveryFence`, `selectedDeliverySuppression`, + `deferredOwnedCompletion`, or any phrasing that says "closed turn means no owned-completion + delivery". Finding any is a hard implementation blocker. + +## Semantics + +- `scope:"turn"` (default): `ownedWork:"left_running"`, `automaticDelivery:"enabled"`, + `resumeOnOwnedCompletion:true`. Owned work keeps running; an owned completion resumes the + root with a fresh attempt. Same-turn retry, TTSR/`agent.continue`, steering continuation, + hidden-next-turn, maintenance/worker successor, and accepted-pre-close same-attempt + continuations are blocked/tombstoned. +- `scope:"owned"`: additionally stops exact causal owned work with full quiescence proof and + foreign-work uncertainty; nothing resumes from stopped work (`automaticDelivery:"none"`, + `resumeOnOwnedCompletion:false`). +- Classification is **source/lineage-based, never timing-based**: the exact five-tuple + (endpoint generation, lineage hash, attempt epoch, job id, job generation) is recorded + before the job handle escapes; missing/mismatched metadata fails closed to ordinary. +- ultragoal/ralplan workflow stop is out of scope; ledgers/artifacts/handoffs stay untouched. +- No public surface widening: only the typed scope and bounded outcome metadata are exposed; + lineage/fence/ticket/envelope machinery is private to the SDK session layers. + +## Implementation state + +Committed on `feat/abort-sdk-terminal` (lore `c04-terminal-*`), base `e92a04e3`: + +- `c04-terminal-lineage`: lineage/attempt origin authority — per-turn lineage minted before + model execution, `beforeToolCall` binding, task/Bash `registerOwnedIfLineaged` five-tuple + capture; bounded registries, fail-closed. +- `c04-terminal-origin-delivery`: origin-aware async-result delivery — + `classifyOwnedCompletion` before formatting/artifact allocation, `OwnedCompletionEnvelope` + carrier, `resumeFromOwnedCompletion` fresh-attempt allocation; mandated boundary comments at + `sdk/session.ts`, `yield-queue.ts`, and both `agent-session.ts` injectors. +- `c04-terminal-surface`: `turn.abort` terminal surface wired to the durable prompt + terminalization; landed-terminal verification before claiming `stopped`; no-active-turn = + `terminal_no_effect`; unfencible = `terminal_uncertain`; turn dispositions as above. +- `c04-terminal-scope-registration`: terminal scope registered + synchronously closed at abort + (session `abortPromptAndWait` terminal option), epoch advanced so the fence never leaks onto + later turns; `classifyOwnedCompletion` live end to end. +- `c04-terminal-continuation-gate`: same-turn continuations denied at the final synchronous + boundary (skip reason `terminal_turn`); fail-open without a scope. +- `c04-terminal-durable-record`: bounded `DurableTerminalScopeRecord` (selection, fence, policy, + dispositions, response state, payload hash, key hash) through the v2 store; AC 5 no-store + gate; same-key replay via dispatch + durable key-hash lookup. +- `c04-terminal-owned-stop`: `scope:"owned"` generation-verified exact cancel, fixed grace, + second quiescence proof (generation-revalidated), delivery purge, `ownedWork:"stopped"` only + after proof; `settleOwnedWork` unit-tested; event metadata on the correlated `agent_end`. +- `c04-terminal-gate-authority`: gate requires the exact registered five-tuple (forged/ + unregistered denied); injectors drop denied owned-completion deliveries entirely (AC 36 + zero final calls) and allocate a fresh attempt only on `allow-new-turn`. + +Durable contract status (AC 6/18/19/41/42): the record persists selection, the +continuation fence (epoch + tombstones + policy), dispositions, the +normalized-input and key hashes, response state, and `terminalPublished`. Same-key +replay/conflict is deterministic across dispatch-LRU eviction and restart (the v2 +store reloads terminal scopes from the single document), and response state +advances monotonic `pending -> sent` once the host writes the control response. +Not wired (tracked): a `pending -> failed` transition on host write rejection +(no surface-level host failure hook exists), a `sent -> delivered` transition +(client-acknowledgement protocol), and runtime re-hydration of the continuation +fence into the process registry. The last is architecturally bounded: lineage +registries are process-local and the per-session lineage secret regenerates on +restart, so a restarted session has NO lineage authority for a previous turn — +the plan's own AC 42 conditions fence installation on "runtime authority being +present", and missing authority failing closed (no auto-inject) is satisfied by +the durable replay/conflict gate alone. + +## Reviewer / implementer checklist (mandatory) + +Answer these against any change to this feature: + +1. **Which origins are blocked?** Only `turn-continuation` origins of the aborted turn (same-turn + retry, TTSR/`agent.continue`, steering, hidden-next-turn, maintenance/worker successor, + accepted-pre-close same-attempt continuation). Not owned-completion, not foreign, not + ordinary. +2. **Can a left-running owned completion reach `followUp`/`prompt`?** Yes — it must, through the + normal `YieldQueue` path, after a closed `turn` record, as a fresh turn. +3. **Where is the fresh attempt allocated?** `AgentSession.#resumeFromOwnedCompletion` (fresh + `promptAttemptEpoch` + opaque lineage id) immediately before the existing + `followUp`/`prompt` call. It never reuses the aborted attempt's epoch. +4. **Is any six-path observer turn-only?** No. Any `OwnedDeliverySettlementObserver` is + owned-scope-only proof of exact settlement; it never runs for a `turn` left-running + completion and never emits `suppressed`/`deferred` turn receipts. +5. **Does any name imply suppressing owned delivery?** If yes (see prohibited names above), the + change is blocked pending a fresh intent decision. diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 3b70f31de..0bf81b86a 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Reassignable `onFollowUpConsumed` hook on `Agent`: invoked with the follow-up messages the loop dequeues for the next turn, so consumers can attach per-turn state (e.g. a fresh owned-completion lineage) at actual resume admission. + ## [0.12.12] - 2026-08-05 ### Fixed diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 2ef4588de..bc4e7fa94 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -301,6 +301,8 @@ export interface AgentOptions { * message are emitted. See {@link AgentLoopConfig.afterToolCall} for full semantics. */ afterToolCall?: AgentLoopConfig["afterToolCall"]; + /** Invoked with the follow-up messages dequeued for the next turn (reassignable). */ + onFollowUpConsumed?: AgentLoopConfig["onFollowUpConsumed"]; /** * Opt-in OpenTelemetry instrumentation. Passing `{}` enables the loop's @@ -467,6 +469,8 @@ export class Agent { * message emission. Reassign at any time to swap the implementation. */ afterToolCall?: AgentLoopConfig["afterToolCall"]; + /** Invoked with the follow-up messages dequeued for the next turn. Reassign at any time. */ + onFollowUpConsumed?: AgentLoopConfig["onFollowUpConsumed"]; constructor(opts: AgentOptions = {}) { this.#state = { ...this.#state, ...opts.initialState }; @@ -510,6 +514,7 @@ export class Agent { this.#onHarmonyLeak = opts.onHarmonyLeak; this.#shouldPause = opts.shouldPause; this.beforeToolCall = opts.beforeToolCall; + this.onFollowUpConsumed = opts.onFollowUpConsumed; this.afterToolCall = opts.afterToolCall; this.#telemetry = opts.telemetry; this.#appendOnlyContext = opts.appendOnlyContext; @@ -1184,6 +1189,17 @@ export class Agent { return true; } + /** + * Remove ALL queued STEERING messages without touching the follow-up queue. + * Used by the terminal-abort path to purge steering queued for the aborted + * turn (the loop may exit on the abort signal without polling it); the + * follow-up queue is preserved because it may carry owned-completion + * resumes that must still deliver. + */ + clearSteeringMessages(): void { + this.#steeringQueue = []; + } + /** Remove queued steering+follow-up messages matching `predicate`, preserving order of the rest. */ removeQueuedMessages(predicate: (message: AgentMessage) => boolean): { steering: number; @@ -1683,6 +1699,9 @@ export class Agent { this.#followUpQueue = [...queued, ...this.#followUpQueue]; return []; } + if (queued.length > 0) { + await this.onFollowUpConsumed?.(queued); + } return queued; }, getSyntheticRecoveryMessage: async () => { diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 6b15415a3..6556a6f8c 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -320,6 +320,13 @@ export interface AgentLoopConfig extends SimpleStreamOptions { * continues with another turn. */ getFollowUpMessages?: () => Promise; + /** + * Invoked with the follow-up messages the loop dequeues for the next turn + * (right after {@link getFollowUpMessages}). The consumer may use this to + * attach per-turn state (e.g. a fresh owned-completion lineage) at actual + * resume admission rather than when the message was merely queued. + */ + onFollowUpConsumed?: (messages: AgentMessage[]) => void; /** * Supplies one bounded synthetic recovery instruction before the loop would * otherwise yield. Unlike a follow-up, it is sent only to the provider and diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0c74f3bd9..213f09b41 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- `turn.abort` `mode:"terminal"` with `scope:"turn" | "owned"`: stops the root turn and blocks only its own continuation routes, while left-running owned work keeps running and its completions resume the agent as a fresh turn (`scope:"turn"`) or are settled/dropped (`scope:"owned"`). Durable idempotency via the reconciliation store, deterministic replay after restart/eviction, bounded reservations, and the private turn-continuation fence. + ### Fixed - Made Telegram reference-client capability diagnostics safe for TUI embedding. diff --git a/packages/coding-agent/src/extensibility/extensions/runner.ts b/packages/coding-agent/src/extensibility/extensions/runner.ts index f09337fa4..919933783 100644 --- a/packages/coding-agent/src/extensibility/extensions/runner.ts +++ b/packages/coding-agent/src/extensibility/extensions/runner.ts @@ -204,6 +204,8 @@ export class ExtensionRunner { #abortPromptAndWaitFn: NonNullable = async () => { throw new Error("abortPromptAndWait binding is unavailable"); }; + #getTerminalTurnEpochFn: () => number | undefined = () => undefined; + #cancelPendingPreflightForTerminalAbortFn: () => void = () => {}; #hasPendingMessagesFn: () => boolean = () => false; #getPendingMessageCountsFn: () => { steering: number; followUp: number; nextTurn: number } = () => ({ steering: 0, @@ -328,6 +330,9 @@ export class ExtensionRunner { (async () => { throw new Error("abortPromptAndWait binding is unavailable"); }); + this.#getTerminalTurnEpochFn = contextActions.getTerminalTurnEpoch ?? (() => undefined); + this.#cancelPendingPreflightForTerminalAbortFn = + contextActions.cancelPendingPreflightForTerminalAbort ?? (() => {}); this.#hasPendingMessagesFn = contextActions.hasPendingMessages; this.#getPendingMessageCountsFn = contextActions.getPendingMessageCounts ?? (() => ({ steering: 0, followUp: 0, nextTurn: 0 })); @@ -607,6 +612,8 @@ export class ExtensionRunner { isIdle: () => this.#isIdleFn(), abort: () => this.#abortFn(), abortPromptAndWait: (handle, options) => this.#abortPromptAndWaitFn(handle, options), + getTerminalTurnEpoch: () => this.#getTerminalTurnEpochFn(), + cancelPendingPreflightForTerminalAbort: () => this.#cancelPendingPreflightForTerminalAbortFn(), hasPendingMessages: () => this.#hasPendingMessagesFn(), getPendingMessageCounts: () => this.#getPendingMessageCountsFn(), getTranscript: () => this.#getTranscriptFn(), diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 749e5c795..b7560fcce 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -370,6 +370,10 @@ export interface ExtensionContext { abort(): void; /** Abort and prove whether resources for a specific prompt settled. */ abortPromptAndWait?(handle: string, options: { graceMs: number }): Promise; + /** Private terminal-abort seam: current turn attempt epoch without interrupting it. */ + getTerminalTurnEpoch?(): number | undefined; + /** Private terminal-abort seam: cancel a pending (not-yet-started) prompt preflight. */ + cancelPendingPreflightForTerminalAbort?(): void; /** Whether there are queued messages waiting */ hasPendingMessages(): boolean; /** Typed pending-message counts per queue (steering, follow-up, next-turn). */ @@ -1454,7 +1458,15 @@ export interface ExtensionContextActions { /** Stable resource ownership identifier for the active prompt run. */ getActivePromptHandle?: () => string | undefined; abort: () => void; - abortPromptAndWait?: (handle: string, options: { graceMs: number }) => Promise; + abortPromptAndWait?: ( + handle: string, + options: { graceMs: number; terminal?: { scope: "turn" | "owned" } }, + ) => Promise; + /** Private terminal-abort seam: current turn attempt epoch without interrupting it. */ + getTerminalTurnEpoch?: () => number | undefined; + /** Private terminal-abort seam: cancel a pending (not-yet-started) prompt preflight. */ + cancelPendingPreflightForTerminalAbort?: () => void; + hasPendingMessages: () => boolean; /** Typed pending-message counts per queue; optional for embedders without a counted queue. */ getPendingMessageCounts?: () => { steering: number; followUp: number; nextTurn: number }; diff --git a/packages/coding-agent/src/internal-urls/docs-index.generated.ts b/packages/coding-agent/src/internal-urls/docs-index.generated.ts index c74717f3e..ff08d894d 100644 --- a/packages/coding-agent/src/internal-urls/docs-index.generated.ts +++ b/packages/coding-agent/src/internal-urls/docs-index.generated.ts @@ -1,11 +1,12 @@ // Auto-generated by scripts/generate-docs-index.ts - DO NOT EDIT Reflect.set(globalThis, Symbol.for("gjc.docs-index.generated.loaded"), true); -export const EMBEDDED_DOC_FILENAMES: readonly string[] = ["ERRATA-GPT5-HARMONY.md","REBRANDING_PLAN_260525.md","adr-inline-selection-gate.md","adr-overlay-component-seam.md","adr-sessions-dashboard.md","ai-schema-normalize.md","alibaba-token-plan-pro-profile-benchmark.md","analyze-me-with-gjc.md","aside-integration.md","auth-broker-gateway.md","bash-tool-runtime.md","blob-artifact-architecture.md","bot-integration.md","brand-assets.md","codebase-overview.md","codegraph-custom-tool.md","compaction.md","composer-codex-parity.md","computer-use/README.md","cursor-composer-profile-tiers.md","discord-onboarding.md","environment-variables.md","external-control-readiness.md","extragoal-skill-template.md","fs-scan-cache-architecture.md","geobench.md","git-daemon.md","gjc-dogfood-skill-template.md","gjc-plugins.md","gjc-session-clawhip-routing.md","gpt-5.6-codex-preset-benchmark.md","grok-build-provider-design.md","handoff-generation-pipeline.md","hermes-mcp-bridge.md","hotspot-map-successor.md","keybindings.md","lsp-config.md","memory.md","models.md","multi-vendor-profiles.md","native-ffi-optimization-policy.md","natives-addon-loader-runtime.md","natives-architecture.md","natives-binding-contract.md","natives-build-release-debugging.md","natives-media-system-utils.md","natives-package-split-plan.md","natives-rust-task-cancellation.md","natives-shell-pty-process.md","natives-text-search-pipeline.md","non-compaction-retry-policy.md","notebook-tool-runtime.md","onboarding-packet.md","onboarding-receipt.md","ooo-bridge-extension-contract.md","perf-profiling-corpus.md","porting-from-pi-mono.md","porting-to-natives.md","prompt-architect-reports/README.md","prompt-architect-reports/recovered-context/0-ToolPrompts.recovered.md","prompt-architect-reports/recovered-context/1-SystemPrompts.recovered.md","prompt-architect-reports/recovered-context/3-SkillMiscPrompts.recovered.md","prompt-architect-reports/recovery-summary.md","prompt-architect-reports/system-prompts.raw.md","prompt-architect-reports/tool-prompts.raw.md","provider-streaming-internals.md","python-repl.md","render-mermaid.md","research-plan-ledger.md","resolve-tool-runtime.md","rulebook-matching-pipeline.md","sdk-app-guide.md","sdk-embedding.md","sdk-rpc-parity-audit.md","sdk.md","secrets.md","session-operations-export-share-fork-resume.md","session-switching-and-recent-listing.md","session-tree-plan.md","session.md","slack-onboarding.md","standalone-mcp.md","telegram-onboarding.md","telegram-session-close-timeout-bug.md","theme.md","tools/ask.md","tools/ast-edit.md","tools/ast-grep.md","tools/bash.md","tools/bisect.md","tools/browser.md","tools/calc.md","tools/checkpoint.md","tools/computer.md","tools/cron.md","tools/debug.md","tools/edit.md","tools/eval.md","tools/find.md","tools/github.md","tools/irc.md","tools/job.md","tools/lsp.md","tools/monitor.md","tools/read.md","tools/recipe.md","tools/render_mermaid.md","tools/resolve.md","tools/rewind.md","tools/search.md","tools/search_tool_bm25.md","tools/ssh.md","tools/task.md","tools/todo_write.md","tools/web_search.md","tools/write.md","tree.md","ttsr-injection-lifecycle.md","tui-runtime-internals.md","ui-design-visual-qa.md"]; +export const EMBEDDED_DOC_FILENAMES: readonly string[] = ["ERRATA-GPT5-HARMONY.md","REBRANDING_PLAN_260525.md","adr-abort-sdk-terminal-turn-owned.md","adr-inline-selection-gate.md","adr-overlay-component-seam.md","adr-sessions-dashboard.md","ai-schema-normalize.md","alibaba-token-plan-pro-profile-benchmark.md","analyze-me-with-gjc.md","aside-integration.md","auth-broker-gateway.md","bash-tool-runtime.md","blob-artifact-architecture.md","bot-integration.md","brand-assets.md","codebase-overview.md","codegraph-custom-tool.md","compaction.md","composer-codex-parity.md","computer-use/README.md","cursor-composer-profile-tiers.md","discord-onboarding.md","environment-variables.md","external-control-readiness.md","extragoal-skill-template.md","fs-scan-cache-architecture.md","geobench.md","git-daemon.md","gjc-dogfood-skill-template.md","gjc-plugins.md","gjc-session-clawhip-routing.md","gpt-5.6-codex-preset-benchmark.md","grok-build-provider-design.md","handoff-generation-pipeline.md","hermes-mcp-bridge.md","hotspot-map-successor.md","keybindings.md","lsp-config.md","memory.md","models.md","multi-vendor-profiles.md","native-ffi-optimization-policy.md","natives-addon-loader-runtime.md","natives-architecture.md","natives-binding-contract.md","natives-build-release-debugging.md","natives-media-system-utils.md","natives-package-split-plan.md","natives-rust-task-cancellation.md","natives-shell-pty-process.md","natives-text-search-pipeline.md","non-compaction-retry-policy.md","notebook-tool-runtime.md","onboarding-packet.md","onboarding-receipt.md","ooo-bridge-extension-contract.md","perf-profiling-corpus.md","porting-from-pi-mono.md","porting-to-natives.md","prompt-architect-reports/README.md","prompt-architect-reports/recovered-context/0-ToolPrompts.recovered.md","prompt-architect-reports/recovered-context/1-SystemPrompts.recovered.md","prompt-architect-reports/recovered-context/3-SkillMiscPrompts.recovered.md","prompt-architect-reports/recovery-summary.md","prompt-architect-reports/system-prompts.raw.md","prompt-architect-reports/tool-prompts.raw.md","provider-streaming-internals.md","python-repl.md","render-mermaid.md","research-plan-ledger.md","resolve-tool-runtime.md","rulebook-matching-pipeline.md","sdk-app-guide.md","sdk-embedding.md","sdk-rpc-parity-audit.md","sdk.md","secrets.md","session-operations-export-share-fork-resume.md","session-switching-and-recent-listing.md","session-tree-plan.md","session.md","slack-onboarding.md","standalone-mcp.md","telegram-onboarding.md","telegram-session-close-timeout-bug.md","theme.md","tools/ask.md","tools/ast-edit.md","tools/ast-grep.md","tools/bash.md","tools/bisect.md","tools/browser.md","tools/calc.md","tools/checkpoint.md","tools/computer.md","tools/cron.md","tools/debug.md","tools/edit.md","tools/eval.md","tools/find.md","tools/github.md","tools/irc.md","tools/job.md","tools/lsp.md","tools/monitor.md","tools/read.md","tools/recipe.md","tools/render_mermaid.md","tools/resolve.md","tools/rewind.md","tools/search.md","tools/search_tool_bm25.md","tools/ssh.md","tools/task.md","tools/todo_write.md","tools/web_search.md","tools/write.md","tree.md","ttsr-injection-lifecycle.md","tui-runtime-internals.md","ui-design-visual-qa.md"]; export const EMBEDDED_DOCS: Readonly> = { "ERRATA-GPT5-HARMONY.md": "# ERRATA — GPT-5 Harmony-Header Leakage\n\n## 1. The problem\n\nOpenAI frames tool calls in the Harmony chat protocol:\n\n```\n<|start|>assistant<|channel|>commentary to=functions.<|message|>{ARGS}<|call|>\n```\n\n`<|channel|>commentary to=functions.NAME` is the **routing header** —\ncontrol tokens consumed by the runtime to dispatch the call. These\ntokens never appear as content under normal operation; the runtime\nstrips them.\n\nThe defect: gpt-5 models occasionally emit, **as ordinary content\ninside `{ARGS}`**, the **plain-text shadow** of these routing tokens —\nthe same characters without the `<|…|>` brackets — and continue\nproducing more pseudo-routing structure (channel name, body marker,\nmultilingual spam, fake tool-result framing). The contamination lives\ninside the visible tool argument and is dispatched to the tool as if it\nwere intended content.\n\n**Critical detail.** The actual `<|start|>` / `<|channel|>` /\n`<|message|>` / `<|call|>` special tokens almost never appear in tool\nargs. What leaks is the bracket-less spelling — `analysis to=functions.X\ncode …` — because OpenAI applies a logit mask suppressing the\ncontrol-token IDs inside the args region. The mass that would have gone\nto those special tokens redistributes onto the un-bracketed plain-text\nrepresentation the model also learned. This makes the leak structurally\ninvisible to the routing parser and lands it in the tool input verbatim.\n\nManifestation in tool args (real corpus example):\n\n```\n~ add_function(iso, ctx, ns, \"installSystemChangeObserver\",\n os_install_system_change_observer);】【\"】【analysis to=functions.edit\n code above เงินไทยฟรีuser to=functions.edit code …\n```\n\nThe leading code is real and intended. Everything after the first\nnon-Latin token through the next clean structural boundary is corruption.\n\n---\n\n## 2. Observed statistics & failure modes\n\nSource: `~/.gjc/stats.db` (`ss_tool_calls`, `ss_assistant_msgs`), through\n2026-05-10. 1.05M tool calls scanned.\n\n### 2.1 Rate\n\n| Model | Leaks in tool args | Calls | per million |\n|------------------|-------------------:|--------:|------------:|\n| gpt-5.4 | 37 | 226,957 | 163 |\n| gpt-5.3-openai-code | 17 | 112,243 | 151 |\n| gpt-5.5 | 2 | 80,750 | 25 |\n| gpt-5.2-openai-code | 0 | — | — |\n\nPlus 15 hits in assistant visible text / thinking blobs.\n\n### 2.2 Tool distribution\n\n| Tool | Hits |\n|---------------------|-----:|\n| `edit` | 38 |\n| `eval` | 11 |\n| `report_tool_issue` | 3 |\n| `grep`/`read`/`search`/`yield` | 1 each |\n\nConcentrated in tools with free-form (non-JSON-schema) argument formats.\n\n### 2.3 Leak shape (deterministic)\n\n```\nLEAK ::= JUNK_PREFIX MARKER CHANNEL_BODY (LEAK)?\nMARKER ::= \"to=functions.\" TOOL_NAME\nCHANNEL_BODY ::= \" code \" (SPAM | reasoning_prose | fake_tool_output)*\nJUNK_PREFIX ::= (GLITCH_TOKEN | CHANNEL_WORD | NON_LATIN_RUN | \"}\" | \"】【\")+\n```\n\n**Cascading is common.** Of 96 marker occurrences across 71 contaminated\nrecords, 39 contain ≥2 markers and 7 contain ≥3 — the model emits\nmultiple fake `to=functions.X code …` blocks back-to-back, often with\nfake `code_output\\nCell N:\\n…` framing between them. Once the\nplain-text scaffolding is in the residual stream, the prefix now *looks\nlike* a fresh tool envelope start, so the macro prior over continuations\nkeeps voting for more scaffolding. Self-amplifying.\n\n### 2.4 Glitch tokens\n\nSingle-token identifiers in `o200k_base` whose embeddings appear to be\nnear-init from underrepresentation in post-training. ASCII residue\nimmediately before the marker in the natural corpus:\n\n| Surface string | Single-token | Token ID | Hits in corpus |\n|-------------------|:-:|---------:|---:|\n| `Japgolly` | ✅ | 199,745 | 1 |\n| `Jsii` | ✅ | 114,318 | (subtoken of `Jsii_commentary`) |\n| `Jsii_commentary` | — (3 toks) | — | 2 |\n| `changedFiles` | — (2 toks) | — | 8 |\n| `RTLU` | — (2 toks) | — | 3 |\n\n`Japgolly` is in the last 0.13% of the vocabulary — the same family of\nGitHub-corpus residue that produced `SolidGoldMagikarp` in the 2023\nGPT-2 vocabulary (Rumbelow & Watkins). `SolidGoldMagikarp` itself\ntokenizes to 5 tokens in `o200k_base` — that specific token was retired,\nbut the class wasn't.\n\nFor the multi-token entries, the corpus-level signature is the surface\nstring; the underlying glitch trigger is a sub-token (e.g. `Jsii` inside\n`Jsii_commentary`). The detector list (`G` signal) keys on the surface\nstrings.\n\nStable across unrelated sessions. Treated as a high-precision detector\nsignal.\n\n### 2.5 Channel-word leakage\n\n`analysis` (5), `assistant` (5), `commentary` (3), `user` (1) appear\ndirectly preceding `to=`. Always bare words; never `<|channel|>analysis`\nor any other bracketed form. Consistent with §1 — the brackets are\nmasked, the words are not.\n\n### 2.6 Non-Latin spam residue\n\n96 marker hits, by script: CJK 40, Cyrillic 12, Telugu/Kannada/Malayalam\n18, Thai 8, Georgian 7, Armenian 7, Arabic 1. Recurring fragments are\nChinese gambling SEO (`大发时时彩`, `天天中彩票`), Georgian/Abkhaz junk,\nand Thai casino spam — well-known low-quality crawl residue.\n\nThis is the same script distribution observed in the controlled\nreproduction (§7.3), independent of the prompt's natural language.\n\n### 2.7 Failure-mode breakdown for the `edit` tool\n\nThe `edit` tool exists in two variants in the corpus:\n\n| Variant | Calls | Recovery |\n|--------------------------|------:|----------|\n| Patch-DSL (`§PATH`/anchor/`«»≔` ops) | 27 | **Recoverable** by op-truncation (§3.3) |\n| JSON-schema (`{path,edits:[…]}`) | 11 | **Not recoverable** — contamination is escaped *inside* JSON strings, parser accepts it cleanly, content would be written verbatim into source files |\n\nFor Patch-DSL leaks specifically:\n\n- 20/27 cases: contamination on the last input line; nothing follows.\n- 7/27 cases: contamination mid-input; what follows is one of: a\n duplicate replay of an earlier file/anchor, intended content for a\n *different* tool call (the model started its next call inline), or\n pure hallucination. Post-contamination content is never trustworthy.\n\n### 2.8 Mechanism (confirmed)\n\n**Prior collapse from null-embedding glitch tokens, into a\ncontrol-token-masked basin whose mass redistributes onto the\nplain-text shadow of the Harmony protocol.**\n\nStep by step:\n\n1. The model is mid-`{ARGS}` of a Harmony tool call. The runtime applies\n a logit mask suppressing structural control tokens (`<|channel|>`,\n `<|message|>`, `<|call|>`, `<|start|>`, `<|end|>`) inside the args\n region. Without this mask, normal generation would constantly\n hallucinate envelope-closes; with it, those token IDs have logit\n `-∞` in args.\n2. A glitch token `g` is sampled. By construction `g` was in the BPE\n merge corpus but barely in LM/RL training, so its **input embedding\n `e_g` ≈ near-init noise of small norm**.\n3. At position t+1, the residual update `h_{t+1} ≈ LN(h_t + e_g + Attn +\n MLP)` is dominated by the prefix-derived terms; the just-emitted-token\n signal is effectively absent. Generation diversity normally comes\n from `e_x` steering the residual into different sub-regions —\n stripped here.\n4. The next-token distribution therefore collapses onto the **conditional\n prior over continuations of the prefix, with local conditioning\n removed**. In a tool-calling rollout context, that prior is sharply\n peaked on Harmony scaffolding (control tokens + routing tokens) —\n that's what RL trained.\n5. The mask zeros the control-token IDs. Mass redistributes onto the\n **next-best continuation**: the un-bracketed surface-form spelling of\n the same protocol (`analysis`, `commentary`, ` to=functions.X`,\n ` code `). This spelling is unmasked because those characters are\n ordinary tokens.\n6. Once a few tokens of plain-text scaffolding land in the residual\n stream, the prefix now resembles a fresh envelope start. The macro\n prior keeps voting for more scaffolding. Cascading (§2.3) follows.\n7. Multilingual spam after the marker is the same prior-collapse\n continuation, drawn from the training neighborhood of the glitch\n token (often ESL/auto-generated multilingual web junk — exactly the\n crawl residue in §2.6).\n\n**Two corollaries the corpus data demanded but only the experiment\nexplained:**\n\n- **The brackets never appear** (§1, §2.5). The mask is what makes the\n leak land in plain text instead of as a real envelope-close.\n- **Counterintuitive grammar dependency** (§7.4). The leak is *worse* in\n formats closest to OpenAI's training distribution. Off-distribution\n custom grammars dampen the macro-prior basin; the official\n `*** Begin Patch` format is the strongest collapse target.\n\nThe 2023 SolidGoldMagikarp paper documented mechanism (1)+(2)+(4). The\nnew piece is (5): when constrained decoding masks the natural collapse\ntarget, the mass laundered through the un-masked plain-text shadow\nbecomes a structurally-invisible exfiltration channel.", "REBRANDING_PLAN_260525.md": "# GJC Rebranding Plan — 2026-05-26\n\n## Status\n\nApproved plan for the gajae-code/GJC rebrand and visible UI redesign. This document records the implementation contract to track in GitHub and preserve in-repo.\nGitHub tracking issue: https://github.com/Yeachan-Heo/gajae-code/issues/3\n\n## Decision\n\nRedesign the visible GJC terminal, export, and documentation surfaces around a coherent red-claw gajae-code identity while preserving clegacyatibility boundaries.\n\nThe default-visible product should read as **gajae-code / GJC**, not legacy upstream branding or a generic inherited terminal skin. Red-claw becomes the default dark visual direction for users without an explicit override. Session exports and README screenshots should show the same brand direction, while exported transcript content remains neutral and readable.\n\n## Principles\n\n1. **GJC-first visible identity** — Default-visible UI should present gajae-code/red-claw as the current product identity.\n2. **Clegacyatibility preservation** — Keep `gjc`, `gjc-stats`, `gjc-swarm`, `@gajae-code/*`, legacy runtime roots/env aliases, and explicit attribution/history.\n3. **Semantic color integrity** — Brand red/coral/shell colors must stay distinct from error, warning, and diff-removal semantics.\n4. **Readable fallbacks** — Truecolor, 256-color, Unicode, Nerd Font, ASCII, narrow terminal, and imperfect-font modes must remain usable.\n5. **Audit-friendly exports** — HTML exports and docs use GJC header/accent/metadata branding without making transcript content decorative or hard to review.\n6. **Visible workflow minimization** — Default repo-shipped visible skills/workflows remain limited to `deep-interview`, `ralplan`, `team`, and `ultragoal`.\n\n## Scope\n\n### In scope\n\n- Default dark theme and bundled red-claw palette.\n- Visible TUI surfaces: welcome, status line, footer/keybinding hints, message frames, assistant/user/custom/system messages, tool execution cards, ask/approval cards, selectors/settings, todo/plan surfaces, transcript chrome, diff/tool output styling.\n- Status-line identity cutover away from default-visible legacy/Pi/powerline styling.\n- Session HTML export header/accent/metadata branding while preserving transcript readability.\n- README screenshots/alt text and docs pages that present current GJC UI/export identity.\n- Static scans and tests for current-product brand leaks, clegacyatibility names, theme defaults, fallback readability, and export branding.\n\n### Out of scope\n\n- Renaming `gjc`, `gjc-stats`, `gjc-swarm`, or `@gajae-code/*` package surfaces.\n- Removing legacy runtime roots, env aliases, clegacyatibility internals, migration notes, generated/vendor content, or attribution/history solely because they mention legacy/Pi.\n- Copying OpenAI code provider, SST/opencode, Anthropic Code, or legacy upstream visuals verbatim.\n- Making exports decorative enough to reduce audit readability.\n- Replacing the TUI framework as part of the brand redesign.\n\n## Implementation Plan\n\n### Phase 1 — Inventory and allowlist\n\n- Search active visible UI/docs/export surfaces for old-brand and inherited UI identity markers: legacy upstream markers, `gjc`, `pi`, `powerline`, and generic export labels.\n- Classify hits as current product identity, explicit user opt-in setting labels, clegacyatibility internals, attribution/history/migration notes, or generated/vendor content.\n- Build or update verification gates so current-product visible leaks fail, but clegacyatibility and attribution do not.\n\n### Phase 2 — Theme defaults and palette semantics\n\n- Make red-claw the default dark visual direction for users without explicit theme overrides.\n- Separate brand tokens (`brandRed`, `claw`, `coral`, `shell`) from semantic tokens (`dangerRed`, `warningAmber`, `diffRemovalRed`).\n- Ensure accents, borders, markdown, status-line identity, and export header variables use brand tokens while errors, warnings, and removals use semantic tokens.\n- Add focused tests for default theme resolution and token separation.\n\n### Phase 3 — Status-line identity cutover\n\n- Remove Pi from bundled default-visible status presets or replace it with clegacyact GJC/claw identity.\n- Preserve legacy segment/symbol clegacyatibility only as explicit opt-in or internal alias behavior.\n- Change default separators away from powerline-like styling; keep powerline variants available only as explicit user choices.\n- Verify status-line overflow, narrow-width, and ASCII/minimal-symbol behavior.\n\n### Phase 4 — Coherent TUI clegacyonent pass\n\nUse existing theme tokens rather than a new UI framework abstraction.\n\n- Apply shell/ink backgrounds, coral/claw accents, clegacyact borders, and lower-noise hierarchy across visible clegacyonents.\n- Refresh welcome, status line, footer hints, message frames, tool cards, ask/approval cards, selectors/settings, todo/plan surfaces, and transcript chrome.\n- Keep high-frequency tool cards inspectable: tool name, path/args, status, diff preview, truncation/expand hints, and error states remain clearer than decoration.\n- Confirm Unicode/Nerd/ASCII fallbacks for new visible symbols.\n\n### Phase 5 — Export and docs alignment\n\n- Update HTML export title/header/metadata to present GJC session export branding.\n- Keep message bodies, code blocks, tool output, system prlegacyts, and transcript content neutral and high contrast.\n- Regenerate derived export templates if required by the repository workflow.\n- Update README screenshots/alt text and docs references so the demonstrated TUI/export direction matches the implemented default.\n\n### Phase 6 — Verification and review\n\n- Run focused theme/status/export/static-scan tests first.\n- Run package-local checks after focused tests pass.\n- Run cleanup/refactor review on changed files.\n- Rerun verification after cleanup.\n- Run final code review and resolve blockers before considering the implementation clegacylete.\n\n## Acceptance Criteria\n\n- [ ] Default dark theme resolves to red-claw/GJC for users without explicit theme override.\n- [ ] Brand/accent tokens are distinct from error, warning, and diff-removal tokens.\n- [ ] Default-visible status-line identity no longer leads with legacy/Pi-style branding.\n- [ ] Default-visible status separators no longer use powerline-style styling unless explicitly opted in.\n- [ ] Visible TUI clegacyonents share one coherent GJC language across welcome, status line, footer hints, message frames, tool execution cards, ask/approval cards, selectors/settings, and todo/plan surfaces.\n- [ ] Static scans of active UI/docs/export surfaces do not present legacy/Pi as current product identity; clegacyatibility internals, attribution/history, generated/vendor content, and migration notes remain allowlisted.\n- [ ] Full session HTML export includes GJC header/accent/metadata branding while preserving neutral readable transcript content.\n- [ ] README screenshots and alt text show the same GJC/red-claw brand direction as the TUI/export surfaces.\n- [ ] Redesign remains readable under fallback terminal modes, including ASCII/minimal-symbol operation.\n- [ ] Focused verification covers default theme, visible brand allowlist, export branding, and preserved clegacyatibility names.\n\n## Planned Evidence\n\nFocused tests/probes after implementation:\n\n```bash\nbun test packages/coding-agent/test/gjc-ui-redesign.test.ts\nbun test packages/coding-agent/test/theme-auto-detection.test.ts packages/coding-agent/test/status-line-overflow.test.ts packages/coding-agent/test/status-line-path.test.ts\nbun scripts/verify-gjc-ui-redesign.ts\nbun --cwd=packages/coding-agent run check\n```\n\nManual/render probes:\n\n1. Launch with no explicit theme config and capture welcome/status/footer/tool-card flow.\n2. Launch with explicit non-red theme config and confirm it is not overwritten.\n3. Render status line at normal and narrow widths for default, clegacyact, full, Nerd, ASCII, and preserved custom settings.\n4. Render representative tool executions: pending, success, error, diff added/removed, spilled/truncated output, and image fallback.\n5. Render selectors/settings and ask/approval cards under red-claw and ASCII/minimal-symbol mode.\n6. Generate a full session HTML export and inspect header/title/metadata/accent variables plus transcript readability.\n7. Inspect README screenshots/alt text and clegacyare them against the generated full-session export direction.\n\n## Risks and Mitigations\n\n- **Brand red becomes error/removal red** — Add token-level tests and rendered probes for brand, error, warning, and diff states.\n- **User-selected themes/status settings are overwritten** — Change defaults and bundled presets only; test explicit non-red theme/custom status preservation.\n- **Visible legacy/Pi removal breaks legacy configs** — Keep clegacyatibility aliases internally or opt-in, while removing current-product default visibility.\n- **Visual pass becomes subjective churn** — Centralize design in existing theme tokens and focused snapshots/probes; avoid framework replacement.\n- **Exports become too decorative for audits** — Brand only header/accent/metadata; keep transcript/code/tool content neutral and high contrast.\n- **Terminal fallback regressions** — Verify ASCII/minimal-symbol and narrow-width render paths.\n\n## Approval State\n\nThis plan is approved for tracking. Implementation still requires normal code review and verification before clegacyletion.\n", + "adr-abort-sdk-terminal-turn-owned.md": "# ADR: SDK terminal abort — turn-origin fence with owned-completion enablement\n\n## Decision\n\n**ADOPT — origin-aware `TurnContinuationFence`/`TurnContinuationGate` plus normal owned-completion delivery.**\n\nC04 `turn.abort` gains `mode:\"terminal\"` with typed `scope:\"turn\" | \"owned\"` (default `\"turn\"`)\nand a required bounded idempotency key (≤128 UTF-8 bytes). Terminal abort stops the root\nworker's current turn and blocks **only** that turn's own continuation routes; exact owned\nbackground work (Bash/task jobs, detached subagents) that the caller deliberately leaves\nrunning keeps running, and its completion/progress is delivered through the existing\n`YieldQueue -> agent.followUp`/`agent.prompt` path as a **fresh** root turn with a new\nattempt/lineage/worker epoch.\n\n## Prominent corrected design note (mandatory)\n\n> **ADR/design note — turn abort is not owned-delivery abort.** `scope:\"turn\"` closes the root\n> worker's current turn and its own continuation routes, while exact owned work remains\n> runnable and its completion/progress results are intentionally delivered through the\n> existing `YieldQueue -> AgentSession -> agent.followUp`/`agent.prompt` path. The delivery\n> starts a fresh root turn with a new attempt/lineage. The earlier stage-04 no-successor fence\n> that suppressed or deferred those deliveries was a misunderstanding: it defeated the reason\n> to expose a leave-running option. **Do not reinstate it under another name.**\n\n## Naming rules\n\n- Blocked routes are **turn-origin continuations**: `TurnContinuationFence`,\n `TurnContinuationGate`, `blockedContinuationIds`, `predecessorTombstones`. The gate denies\n only `turn-continuation` origins after close.\n- Allowed left-running feedback is **owned-completion delivery**: `ownedCompletionPolicy`,\n `ownedCompletionDelivery`, `resumeFromOwnedCompletion`, `OwnedCompletionEnvelope`. A closed\n turn record never invalidates or denies an allowed owned-completion entry.\n- **Prohibited names** (any code, test, or review text): `TurnDeliveryGate`,\n `suppressOwnedDelivery`, `closedOwnedDeliveryFence`, `selectedDeliverySuppression`,\n `deferredOwnedCompletion`, or any phrasing that says \"closed turn means no owned-completion\n delivery\". Finding any is a hard implementation blocker.\n\n## Semantics\n\n- `scope:\"turn\"` (default): `ownedWork:\"left_running\"`, `automaticDelivery:\"enabled\"`,\n `resumeOnOwnedCompletion:true`. Owned work keeps running; an owned completion resumes the\n root with a fresh attempt. Same-turn retry, TTSR/`agent.continue`, steering continuation,\n hidden-next-turn, maintenance/worker successor, and accepted-pre-close same-attempt\n continuations are blocked/tombstoned.\n- `scope:\"owned\"`: additionally stops exact causal owned work with full quiescence proof and\n foreign-work uncertainty; nothing resumes from stopped work (`automaticDelivery:\"none\"`,\n `resumeOnOwnedCompletion:false`).\n- Classification is **source/lineage-based, never timing-based**: the exact five-tuple\n (endpoint generation, lineage hash, attempt epoch, job id, job generation) is recorded\n before the job handle escapes; missing/mismatched metadata fails closed to ordinary.\n- ultragoal/ralplan workflow stop is out of scope; ledgers/artifacts/handoffs stay untouched.\n- No public surface widening: only the typed scope and bounded outcome metadata are exposed;\n lineage/fence/ticket/envelope machinery is private to the SDK session layers.\n\n## Implementation state\n\nCommitted on `feat/abort-sdk-terminal` (lore `c04-terminal-*`), base `e92a04e3`:\n\n- `c04-terminal-lineage`: lineage/attempt origin authority — per-turn lineage minted before\n model execution, `beforeToolCall` binding, task/Bash `registerOwnedIfLineaged` five-tuple\n capture; bounded registries, fail-closed.\n- `c04-terminal-origin-delivery`: origin-aware async-result delivery —\n `classifyOwnedCompletion` before formatting/artifact allocation, `OwnedCompletionEnvelope`\n carrier, `resumeFromOwnedCompletion` fresh-attempt allocation; mandated boundary comments at\n `sdk/session.ts`, `yield-queue.ts`, and both `agent-session.ts` injectors.\n- `c04-terminal-surface`: `turn.abort` terminal surface wired to the durable prompt\n terminalization; landed-terminal verification before claiming `stopped`; no-active-turn =\n `terminal_no_effect`; unfencible = `terminal_uncertain`; turn dispositions as above.\n- `c04-terminal-scope-registration`: terminal scope registered + synchronously closed at abort\n (session `abortPromptAndWait` terminal option), epoch advanced so the fence never leaks onto\n later turns; `classifyOwnedCompletion` live end to end.\n- `c04-terminal-continuation-gate`: same-turn continuations denied at the final synchronous\n boundary (skip reason `terminal_turn`); fail-open without a scope.\n- `c04-terminal-durable-record`: bounded `DurableTerminalScopeRecord` (selection, fence, policy,\n dispositions, response state, payload hash, key hash) through the v2 store; AC 5 no-store\n gate; same-key replay via dispatch + durable key-hash lookup.\n- `c04-terminal-owned-stop`: `scope:\"owned\"` generation-verified exact cancel, fixed grace,\n second quiescence proof (generation-revalidated), delivery purge, `ownedWork:\"stopped\"` only\n after proof; `settleOwnedWork` unit-tested; event metadata on the correlated `agent_end`.\n- `c04-terminal-gate-authority`: gate requires the exact registered five-tuple (forged/\n unregistered denied); injectors drop denied owned-completion deliveries entirely (AC 36\n zero final calls) and allocate a fresh attempt only on `allow-new-turn`.\n\nDurable contract status (AC 6/18/19/41/42): the record persists selection, the\ncontinuation fence (epoch + tombstones + policy), dispositions, the\nnormalized-input and key hashes, response state, and `terminalPublished`. Same-key\nreplay/conflict is deterministic across dispatch-LRU eviction and restart (the v2\nstore reloads terminal scopes from the single document), and response state\nadvances monotonic `pending -> sent` once the host writes the control response.\nNot wired (tracked): a `pending -> failed` transition on host write rejection\n(no surface-level host failure hook exists), a `sent -> delivered` transition\n(client-acknowledgement protocol), and runtime re-hydration of the continuation\nfence into the process registry. The last is architecturally bounded: lineage\nregistries are process-local and the per-session lineage secret regenerates on\nrestart, so a restarted session has NO lineage authority for a previous turn —\nthe plan's own AC 42 conditions fence installation on \"runtime authority being\npresent\", and missing authority failing closed (no auto-inject) is satisfied by\nthe durable replay/conflict gate alone.\n\n## Reviewer / implementer checklist (mandatory)\n\nAnswer these against any change to this feature:\n\n1. **Which origins are blocked?** Only `turn-continuation` origins of the aborted turn (same-turn\n retry, TTSR/`agent.continue`, steering, hidden-next-turn, maintenance/worker successor,\n accepted-pre-close same-attempt continuation). Not owned-completion, not foreign, not\n ordinary.\n2. **Can a left-running owned completion reach `followUp`/`prompt`?** Yes — it must, through the\n normal `YieldQueue` path, after a closed `turn` record, as a fresh turn.\n3. **Where is the fresh attempt allocated?** `AgentSession.#resumeFromOwnedCompletion` (fresh\n `promptAttemptEpoch` + opaque lineage id) immediately before the existing\n `followUp`/`prompt` call. It never reuses the aborted attempt's epoch.\n4. **Is any six-path observer turn-only?** No. Any `OwnedDeliverySettlementObserver` is\n owned-scope-only proof of exact settlement; it never runs for a `turn` left-running\n completion and never emits `suppressed`/`deferred` turn receipts.\n5. **Does any name imply suppressing owned delivery?** If yes (see prohibited names above), the\n change is blocked pending a fresh intent decision.\n", "adr-inline-selection-gate.md": "# ADR: Inline transcript selection promotion gate\n\n## Decision\n\n**HOLD — keep selection overlay-only.**\n\nThe benchmark now exercises actual `TUI.#doRender` frames rather than a copied-array microbenchmark. It shows that changing one selected row causes the real renderer to normalize and diff all 100,000 transcript rows. This violates the selection design's fundamental bounded-work requirement. No product inline-selection wiring is approved by this ADR.\n\n## Measured evidence\n\n`packages/tui/test/transcript-selection-perf.test.ts` builds a 100,000-row tree of real `Text` components, attaches it to two `TUI` instances backed by `VirtualTerminal`, and interleaves 12 navigation-equivalent control frames with 12 selected-row-change frames. Each measured frame is requested through `TUI.requestRender()` and flushed through the real render loop. The test obtains `renderTree`, total `#doRender` frame time, and `renderMetrics.snapshot().lineCounts` from that pipeline; it does not write metric values itself.\n\nThe rows reserve a two-cell gutter in both arms. The selection arm adds ANSI background/accent only to that gutter. The test explicitly verifies first, previous-selected, selected, and last rows, CJK wrapping through real `Text` and `Markdown` renderers at widths 40 and 120, content byte parity after ANSI stripping and gutter removal, and equal wrapped anchor topology between arms.\n\n### Three recorded local runs — 2026-07-16, Apple M5 Max\n\n| Run | Control renderTree | Selection renderTree | Ratio | Control total frame | Selection total frame | Ratio | Line counts (control → selection: normalized / diffed / offscreenScan) |\n| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |\n| 1 | 49.38 ms | 68.43 ms | 1.386 | 164.44 ms | 905.77 ms | 5.508 | 28 / 28 / 99,972 → 100,000 / 100,000 / 0 |\n| 2 | 55.45 ms | 56.55 ms | 1.020 | 132.11 ms | 885.57 ms | 6.703 | 28 / 28 / 99,972 → 100,000 / 100,000 / 0 |\n| 3 | 57.33 ms | 61.61 ms | 1.075 | 165.71 ms | 808.96 ms | 4.882 | 28 / 28 / 99,972 → 100,000 / 100,000 / 0 |\n\nThe advisory benchmark is enabled with `PI_TUI_PERF_GATES=1` and logs renderTree and total-frame ratios plus all line-count measurements while asserting only the stable parity and measurement-production invariants. The executable promotion evaluation is `PI_TUI_PERF_GATES=1 PI_TUI_PROMOTION_GATE=1 bun --cwd=packages/tui run test:perf`; it hard-fails when renderTree ratio > 1.15, total-frame ratio > 1.15, or selection normalized, diffed, or offscreenScan counts exceed 64. It currently fails by design, so this ADR remains HOLD: the recorded results fail all bounded-work line-count criteria and every total-frame ratio; run 1 also fails the renderTree ratio. The line-count evidence is decisive: a single-row decoration forces full-tree normalization and diffing.\n\n## Required change before reconsidering promotion\n\nA future inline implementation must make a selected-row change diff-friendly and bounded:\n\n1. Preserve the fixed reserved gutter, but memoize row decoration so unchanged rows retain identity/cache entries rather than being re-normalized.\n2. Update only the selected and previous-selected rows, with renderer invalidation/diff behavior that does not scan or normalize the whole transcript.\n3. Re-run the paired real-TUI benchmark three times with stable margins under all hard limits, including the 64-row line-count bounds, before changing this ADR to PROMOTE.\n4. Add product interaction, registry identity, viewport-anchor, and accessibility coverage only after this gate passes.\n\nThe existing overlay path remains the supported selection mechanism. CI continues to run the benchmark through `test:perf` and the `tui-perf-gates` lane; no project-wide gate or product UI wiring is introduced here.\n", "adr-overlay-component-seam.md": "# ADR: Overlay rich-rendering component seam\n\n## Decision\n\nThe transcript overlay gains narrowed rich tool rendering through **pure, width-taking line renderers**, invoked at `TranscriptViewerOverlay.#rebuild`'s `contentWidth`. It does not mount a `Component` inside `#rebuild`.\n\nThe implementation seam is a coding-agent-only rendered-lines hook whose tool implementation is:\n\n```ts\nrenderToolDisplayLines(descriptor, contentWidth, theme): string[]\n```\n\nThat function is the single owner of section identity, output validation, wrapping, result capping, and the truncation sentinel. `TranscriptViewerOverlay.#rebuild` consumes its returned `string[]` as final trusted display lines: it must not split, validate, wrap, Markdown-render, or cap those lines again.\n\nThis is deliberately narrowed fidelity, not byte-for-byte parity with the inline tool UI. The inline `ToolExecutionComponent` remains unchanged.\n\n## Drivers\n\n1. **Terminal safety.** `TranscriptViewerOverlay.#rebuild` currently routes the chosen text source through `sanitizeText` before rendering it as Markdown or raw wrapped text (`packages/coding-agent/src/modes/components/transcript-viewer-overlay.ts`). That boundary prevents terminal control sequences but also removes renderer styling. Rich output needs a replacement boundary that is auditable and no broader than SGR.\n2. **Useful width-aware rendering.** The overlay already calculates `contentWidth` in `#rebuild`. Reusing pure helpers at that width preserves useful diff, JSON-tree, status, and theme styling without constructing a live TUI component.\n3. **Bounded work without stale cache state.** The overlay rebuilds display lines repeatedly. Input budgets, selected-and-expanded rich rendering, and visible result caps bound the work without an LRU or theme/render revision invalidation scheme.\n\n## Existing seam and canonical projection\n\nThe current overlay string pipeline selects `payload.text` in raw mode, otherwise `getEntryText?.(entry, expanded)`, then `entry.getDisplayText?.(expanded)`, then `payload.text`; it trims and calls `sanitizeText`, and finally uses `wrapTextWithAnsi` for raw text or `Markdown` for expanded text. The relevant code is `TranscriptViewerOverlay.#rebuild` in `packages/coding-agent/src/modes/components/transcript-viewer-overlay.ts`.\n\nThis ADR builds on the WS5 canonical-versus-descriptor split:\n\n- `buildToolTranscriptEntry` in `packages/coding-agent/src/modes/components/tool-transcript-format.ts` keeps `canonicalPayload` as the entry `payload`, including the byte-preserving source used by copy and raw mode.\n- `createToolTranscriptRenderDescriptor` sanitizes and recursively freezes display-only fields before they are formatted. Its optional string `details` remains available for legacy text; its structured `detailsData` projection carries result details/diffs, including `perFileResults`, through the same sanitizer/freeze recursion. Both adapters supply it from the real tool result, and it is subject to the rich input budgets.\n- Rich rendering reads only that sanitized descriptor. It does not mutate canonical payload bytes.\n\nOverlay chrome continues to use `theme.fg` (as it does for the selected marker and muted entry label), and rich helper SGR is produced against the current supplied theme.\n\n## `renderToolDisplayLines` pipeline contract\n\n`renderToolDisplayLines` first composes a local typed internal shape:\n\n```ts\ntype ToolDisplaySections = {\n callLines: string[];\n statusLines: string[];\n resultLines: string[];\n};\n```\n\nThe order below is normative and is owned entirely by that function:\n\n1. Apply the input budget gate.\n2. Build `ToolDisplaySections` from the sanitized descriptor.\n3. Validate every line with the SGR-only display validator.\n4. ANSI-aware wrap every section at `contentWidth`.\n5. Cap **only wrapped `resultLines`** at 100 lines.\n6. When capped, append `... N more lines`, where `N` is the number of hidden post-wrap result lines.\n7. Flatten `callLines`, `statusLines`, and capped `resultLines` (plus sentinel) last, returning final `string[]`.\n\nCall and status lines are never charged against the 100-line result cap. The cap is post-wrap, so its count reflects what the overlay can display. The overlay may use the final lines for its collapsed presentation, but it must not re-split them or repeat any validation, wrapping, cap, or sentinel accounting.\n\nThe pure helper repertoire is intentionally limited:\n\n- `renderDiff` is the diff primitive imported by `packages/coding-agent/src/modes/components/tool-execution.ts`.\n- `renderJsonTreeLines` is the JSON tree primitive used there for structured arguments and results.\n- `renderStatusLine` is used there to produce tool status output.\n\n`renderDiff(diffText, options?: { filePath? }): string` is the diff primitive; it does **not** accept a width. `renderJsonTreeLines` likewise produces rich SGR text without owning final display width. `renderToolDisplayLines` is the width-taking owner: it invokes those helpers, validates their output, and ANSI-aware wraps every section at `contentWidth`. `renderStatusLine` produces status output; other tools fall back to plain sanitized text. `toolRenderers.renderCall` and `toolRenderers.renderResult` are not part of this seam: they return components, and `ToolExecutionComponent` is stateful (`Container`, live TUI, animation, image, and asynchronous edit-preview concerns). Neither is pure line projection.\n\n## Security contract\n\nRich display has two boundaries in this order:\n\n1. **Sanitize inputs before formatting.** Every untrusted descriptor value—arguments, result content, string details, structured `detailsData`, paths, errors, and display text—is cleaned with `sanitizeText` before interpolation into helpers. `createToolTranscriptRenderDescriptor` is the canonical display descriptor producer.\n2. **Validate outputs before terminal display.** Split rich output on newlines before validating each line. Normalize tabs to spaces, then reject or remove every remaining C0 or C1 control byte. The sole permitted control sequence is SGR, `ESC [ m`, with one-to-three-digit decimal parameters in the 0–255 range, separated by single semicolons and subject to a bounded total sequence length; this refines the prior numeric/semicolon grammar.\n\nThe validator rejects or removes all other control data, including all OSC (explicitly including OSC 8 hyperlinks), DCS, APC, PM, SOS, Kitty and Sixel/image sequences, every non-SGR CSI action such as cursor movement or erase, and every C0/C1 byte after tab normalization. The allowlist is intentionally stricter than a URI validator: hyperlink fidelity is not a v1 capability.\n\nRaw mode is different by design. It reads canonical `payload.text`, applies `sanitizeText`, then wraps ANSI-free canonical text at `contentWidth`. It bypasses the rich hook, validator, and Markdown. Copy remains exempt: `TranscriptViewerOverlay.#copy` copies `entry.payload.text` unchanged.\n\nThe rich input work limits are:\n\n| Limit | Value |\n| --- | ---: |\n| Source bytes | 1 MiB (1,048,576) |\n| Source lines | 50,000 |\n| Scalar length | 8,192 |\n| JSON depth | 32 |\n| JSON nodes | 20,000 |\n\nOn an exceeded budget, truncate before any rich helper runs, set `inputTruncated`, and prepend `... input truncated for rendering (press r for raw)`.\n\n## Alternatives rejected\n\n### Mount `ToolExecutionComponent` in `TranscriptViewerOverlay.#rebuild` (D2)\n\nRejected because it couples the transcript projection to a stateful `Container` with live TUI requests, spinner animation, image handling, and asynchronous diff preview. It also cannot expose the typed call/status/result boundaries required for a result-only cap. Revisit only when inline-to-overlay drift is a reported defect **and** renderer factories expose width-aware annotated sections.\n\n### LRU render cache (D4)\n\nRejected because a cache key must faithfully include every descriptor input and all theme state; partial fingerprints yield stale rich output. Recompute is bounded by the input budgets, selected-and-expanded rendering, and visible caps. Revisit only when a performance lane proves bounded recompute exceeds the 16 ms overlay frame budget; any replacement key must canonically fingerprint name, arguments, result, details, error/partial state, and theme through a single revision-bumping theme setter.\n\n### Lazy viewport / virtualization (D3)\n\nRejected because this overlay does not yet have stable `scrollTop`/`viewportRows` geometry or a specified virtual-line architecture. Non-tool expanded bodies retain their separate bounded post-Markdown contract instead. Revisit only when stable geometry exists and full reachability of entries beyond the cap is a hard requirement.\n\n### Validated OSC 8 hyperlinks\n\nRejected: the output allowlist is SGR only. Revisit only after a renderer needs hyperlink fidelity and fixtures prove all of: the OSC 8 grammar, an `https`/`http`/`mailto` URI allowlist, `{id}`-only parameters, mandatory paired close, and overlay-generated—not untrusted—link bytes.\n\n## Consequences\n\n- The overlay can show theme-aware diffs, JSON trees, and status lines at its actual content width while preserving the terminal trust boundary.\n- Rich rendering has no claim of parity with `ToolExecutionComponent`; custom component renderers and unsupported tools use the sanitized plain-text path.\n- Section ownership makes the result-only cap mechanically enforceable and prevents call/status output from being accidentally hidden.\n- The seam is synchronous, pure, read-only, and excludes animation, images, Kitty/Sixel, async work, and live TUI access.\n- Canonical transcript and clipboard bytes remain unchanged; only display projection is sanitized and validated.\n- Rich rendering is recomputed rather than cached, so the selected expanded entry is the only rich work candidate per rebuild.\n\n## Follow-ups and revisit criteria\n\n- **D1 — ANSI-free raw:** retain `sanitizeText` then wrap raw display. Revisit only for a demonstrated colored-raw user need with a specified and fixtured SGR-preserving raw normalizer.\n- **D2 — narrowed pure-helper fidelity:** retain the pure width-taking line renderer boundary. Revisit only for a reported inline/overlay drift defect plus width-aware annotated renderer sections.\n- **D3 — no lazy viewport:** retain bounded non-tool rendering. Revisit only with stable viewport geometry and a hard full-reachability requirement.\n- **D4 — no cache:** retain bounded recompute. Revisit only when measured performance exceeds the 16 ms frame budget and a complete canonical invalidation key exists.\n- WS5 read-group entries remain on the existing string path until their independent projection work is approved.\n- A cache is a gated WS5c follow-up, not a prerequisite for this seam.\n\nArchitect approval of this ADR is required before the rendered-lines seam or pure-helper rich rendering implementation merges.\n", "adr-sessions-dashboard.md": "# ADR: Multi-session dashboard discovery and control\n\n## Decision\n\nShip a read-only top-level sessions dashboard. It discovers sessions with `SessionManager.listAll()` (`packages/coding-agent/src/session/session-manager.ts:6070-6079`), which scans `/sessions/*/*.jsonl` and returns parsed `SessionInfo`; the current-project picker uses `SessionManager.list()` and is intentionally narrower. The dashboard displays `SessionInfo.cwd`, title (falling back to `firstMessage`), modification time, message count, and opt-in presence status.\n\nUse an **opt-in presence file** for liveness: a publisher writes an adjacent `.jsonl.presence.json` containing an `expiresAt` timestamp. A future expiry is `active`, an expired valid record is `stale`, and absent or malformed data is `unknown`. The dashboard only reads that sidecar and never treats transcript mtime as liveness.\n\n**M5.2 decision: descope dashboard-initiated dispatch and reply.** This is a deliberate product and authorization-scope decision, not a claim that no authenticated harness or coordinator transport exists. No dashboard dispatch command, transport registration, or launcher is added.\n\n## Drivers\n\n- `SessionManager.listAll()` is the established global storage inventory. It is a read-only scan; `listForResumePickerReadOnly()` is the scoped no-maintenance-write alternative for pickers that require strict read-only behavior.\n- Harness children receive `GJC_SESSION_ID` and `GJC_LIFECYCLE_REQUEST_ID` (`packages/coding-agent/src/harness-control-plane/sdk-transport.ts:376-379`), and `SessionManager` adopts the preallocated ID into the transcript header (`packages/coding-agent/src/session/session-manager.ts:592-597`, `3762-3768`). That is a real identity binding for harness-spawned sessions.\n- Harness resolves the session SDK endpoint and authenticates with its URL and token (`packages/coding-agent/src/harness-control-plane/sdk-transport.ts:135-177`). Root resolution fail-closes on a workspace mismatch (`packages/coding-agent/src/harness-control-plane/storage.ts:347-393`). That is a real authenticated transport for that harness lifecycle scope.\n- Coordinator mutations are gated: its contract exposes register, start, send, and stop (`packages/coding-agent/src/coordinator/contract.ts:4-23`); policy applies gating (`packages/coding-agent/src/coordinator-mcp/policy.ts:186-189`); and the server binds identity to an incarnation (`packages/coding-agent/src/coordinator-mcp/server.ts:2144+`). The `readOnly` field in `commands/coordinator.ts` is hardcoded and is not an authoritative statement that mutations do not exist.\n\n## Alternatives\n\n1. **Dashboard-to-harness dispatch — rejected for now.** The authenticated, transcript-bound transport is limited to sessions spawned by the harness. A global dashboard row may describe an arbitrary persisted session and has no authorization or consent UX that lets a user deliberately grant dashboard control over that runtime.\n2. **Dashboard-to-coordinator dispatch — rejected for now.** Coordinator mutations exist behind policy and incarnation-bound identity, but the dashboard has no product-level authorization/consent handoff or stable mapping from every listed transcript to an authorized coordinator runtime.\n3. **PID liveness with a staleness window — rejected.** `SessionHeader` and `SessionInfo` do not persist a PID. A PID inferred from unrelated state can be recycled and is not authenticated.\n4. **Opt-in presence file — chosen.** It is explicit, bounded by expiry, and can be read without asserting ownership. A presence protocol remains necessary for non-harness sessions; missing presence correctly remains `unknown`.\n\n## Consequences\n\nThe dashboard is an observation surface only and must make zero writes to foreign session directories. `/sessions` and the unbound `app.session.dashboard` action open the overlay; `/resume` remains the explicit mutation-capable transition. Presence publication is a future opt-in producer contract, not part of M5.1. M5.2 remains descope until the dashboard provides an explicit authorization/consent UX, a safe binding for the selected row to a target runtime beyond the harness lifecycle scope, and presence support for non-harness sessions.\n", diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index 52df8f1aa..df0107219 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -525,6 +525,8 @@ export class ExtensionUiController { getActivePromptHandle: () => this.ctx.session.activePromptHandle, abort: () => this.ctx.session.abort(), abortPromptAndWait: (handle, options) => this.ctx.session.abortPromptAndWait(handle, options), + getTerminalTurnEpoch: () => this.ctx.session.getTerminalTurnEpoch(), + cancelPendingPreflightForTerminalAbort: () => this.ctx.session.cancelPendingPreflightForTerminalAbort(), hasPendingMessages: () => this.ctx.session.queuedMessageCount > 0, getPendingMessageCounts: () => this.ctx.session.pendingMessageCounts, getTranscript: () => this.ctx.session.getTranscript(), @@ -844,6 +846,8 @@ export class ExtensionUiController { getActivePromptHandle: () => this.ctx.session.activePromptHandle, abort: () => this.ctx.session.abort(), abortPromptAndWait: (handle, options) => this.ctx.session.abortPromptAndWait(handle, options), + getTerminalTurnEpoch: () => this.ctx.session.getTerminalTurnEpoch(), + cancelPendingPreflightForTerminalAbort: () => this.ctx.session.cancelPendingPreflightForTerminalAbort(), hasPendingMessages: () => this.ctx.session.queuedMessageCount > 0, getPendingMessageCounts: () => this.ctx.session.pendingMessageCounts, getTranscript: () => this.ctx.session.getTranscript(), diff --git a/packages/coding-agent/src/modes/runtime-init.ts b/packages/coding-agent/src/modes/runtime-init.ts index f2b4a7ee7..5d61ba84c 100644 --- a/packages/coding-agent/src/modes/runtime-init.ts +++ b/packages/coding-agent/src/modes/runtime-init.ts @@ -97,6 +97,8 @@ export async function initializeExtensions(session: AgentSession, options: Initi getActivePromptHandle: () => session.activePromptHandle, abort: () => session.abort(), abortPromptAndWait: (handle, abortOptions) => session.abortPromptAndWait(handle, abortOptions), + getTerminalTurnEpoch: () => session.getTerminalTurnEpoch(), + cancelPendingPreflightForTerminalAbort: () => session.cancelPendingPreflightForTerminalAbort(), hasPendingMessages: () => session.queuedMessageCount > 0, getPendingMessageCounts: () => session.pendingMessageCounts, getTranscript: () => session.getTranscript(), diff --git a/packages/coding-agent/src/sdk/bus/index.ts b/packages/coding-agent/src/sdk/bus/index.ts index fad330541..bb1da2ba4 100644 --- a/packages/coding-agent/src/sdk/bus/index.ts +++ b/packages/coding-agent/src/sdk/bus/index.ts @@ -31,6 +31,7 @@ import { type RunSettlementProof, ThinkingLevel } from "@gajae-code/agent-core"; import type { ImageContent, TextContent, Tool } from "@gajae-code/ai"; import { NotificationServer, nativeBuildInfo } from "@gajae-code/natives"; import { $credentialEnv, logger, postmortem, VERSION } from "@gajae-code/utils"; +import { AsyncJobManager } from "../../async"; import { isModelProfileProviderAvailable, projectModelProfileCatalog } from "../../config/model-profile-contract"; import { isAuthenticated, kNoAuth } from "../../config/model-registry"; import { Settings } from "../../config/settings"; @@ -45,6 +46,11 @@ import { } from "../../modes/shared/agent-wire/workflow-gate-broker"; import type { AgentSessionEvent } from "../../session/agent-session"; import type { ClientBridge } from "../../session/client-bridge"; +import { + boundCompletedTerminalScopeRows, + findOwnedRegistrationsForTurn, + settleOwnedWork, +} from "../../session/terminal-abort"; import { parseThinkingLevel } from "../../thinking"; import type { AskAnswerRequest, @@ -62,7 +68,7 @@ import { acpFinalTextFromMessage } from "../acp/final-text"; import { ensureBroker } from "../broker/ensure"; import { SessionIndex } from "../broker/session-index"; import { SessionSdkHost, shouldHostSdk } from "../host"; -import { type ControlSurface, dispatchControl } from "../host/control"; +import { type AbortScope, type ControlSurface, dispatchControl, TypedControlError } from "../host/control"; import { CursorRegistry, QueryHandlers, RevisionStore, type SessionSurface } from "../host/query"; import { projectQ10Models } from "../models.js"; import { PROMPT_CLIENT_REF_MAX_LENGTH, type SdkPromptTerminalOutcome } from "../prompt-status"; @@ -102,7 +108,7 @@ import { createKindAwareReconciliation } from "./kind-aware-reconciliation"; import { assertNativeRuntimeCompatibility } from "./native-runtime-compatibility"; import { proposedTelegramIdentity } from "./notification-orchestration"; import { createPromptReconciliation, sanitizePromptFailure } from "./prompt-reconciliation"; -import { createReconciliationStore } from "./reconciliation-store"; +import { createReconciliationStore, type DurableTerminalScopeRecord } from "./reconciliation-store"; import { NotificationSessionController, type NotificationSessionRuntime } from "./session-control"; import type { SlackConversation } from "./slack-conversation"; import { @@ -2173,6 +2179,31 @@ function sdkControlSurface( aborted: true, disposition: "idle", }), + abortTerminalPrompt: ( + connectionId: string | undefined, + scope: AbortScope, + idempotencyKey?: string, + preflightCancel?: { + hasPending: () => boolean; + cancel: () => void; + }, + ) => Promise< + | { + ok: true; + outcome: + | "stopped" + | "stopped_owned" + | "no_active_turn" + | "already_terminal" + | "no_store" + | "no_effect" + | "pending_replay" + | "uncertain_replay" + | "no_effect_replay"; + stored?: { responseState: string; responsePayloadHash: string; terminalPublished: boolean }; + } + | { ok: false; reason: "worker_unsettled" | "owned_unsettled" | "conflict" | "reservation_failed" } + > = async () => ({ ok: true, outcome: "no_active_turn" }), skillRecon?: { admit: (clientRef?: string) => void; release: (clientRef?: string) => void; @@ -2425,6 +2456,166 @@ function sdkControlSurface( } return await abortOwnedPrompt(requesterConnectionId); }, + abortTerminal: async (input, idempotencyKey) => { + // Terminal abort (C04 mode:"terminal", approved plan): stop the root + // worker's current turn and block only its own continuation routes. + // Left-running owned work (background Bash/task jobs, detached + // subagents) keeps running and its completions are delivered normally + // through the existing followUp/prompt path as a fresh turn — owned + // delivery is intentionally NOT suppressed. + const requesterConnectionId = controlRequesterContext.getStore(); + // Preflight cancellation happens INSIDE abortTerminalPrompt, AFTER the + // durable admission/replay decision: a no-store request or a same-key + // replay/conflict must NOT cancel a pending prompt — only a newly + // admitted abort may (review thread P2). + const scope: AbortScope = input.scope === "owned" ? "owned" : "turn"; + const outcome = await abortTerminalPrompt(requesterConnectionId, scope, idempotencyKey, { + hasPending: () => + requesterConnectionId + ? [...pendingPreflightCancellations.values()].some( + entry => entry.connectionId === requesterConnectionId, + ) + : [...pendingPreflightCancellations.values()].some(entry => entry.connectionId === undefined), + cancel: () => { + if (requesterConnectionId) cancelPendingPreflightsForConnection(requesterConnectionId); + else cancelPendingPreflights(); + }, + }); + // Preflight cancellation happens ONLY for a NEWLY ADMITTED abort, + // after the durable admission/replay decision inside + // abortTerminalPrompt: a no-store request or a same-key + // replay/conflict must never cancel a pending prompt (review + // thread P2). A turn.prompt still in PREFLIGHT has no + // promptSubmissions entry, so the new no-active abort cancels the + // connection's pending preflights and invalidates the underlying + // session preflight — otherwise the prompt could start after this + // abort. + const outcomeIsNewAdmission = + outcome.ok && + outcome.outcome !== "no_store" && + outcome.outcome !== "no_effect" && + outcome.outcome !== "pending_replay" && + outcome.outcome !== "uncertain_replay" && + outcome.outcome !== "no_effect_replay"; + if (outcomeIsNewAdmission) { + // Restrict BOTH cancellations to the REQUESTER's own pending + // preflight: the SDK waiter map is per-connection, but the session + // preflight controller is session-global — aborting it when the + // requester has no preflight would fail an unrelated connection's + // prompt (review thread P1). Only a requester that actually owns a + // pending preflight triggers the session seam. + const hasPendingPreflightForRequester = requesterConnectionId + ? [...pendingPreflightCancellations.values()].some(entry => entry.connectionId === requesterConnectionId) + : [...pendingPreflightCancellations.values()].some(entry => entry.connectionId === undefined); + if (hasPendingPreflightForRequester) { + if (requesterConnectionId) cancelPendingPreflightsForConnection(requesterConnectionId); + else cancelPendingPreflights(); + // Settling the SDK waiter alone does NOT stop the underlying + // AgentSession.prompt(): invalidate the session preflight too. + const preflightSeam = ctx as typeof ctx & { + cancelPendingPreflightForTerminalAbort?: () => void; + }; + preflightSeam.cancelPendingPreflightForTerminalAbort?.(); + } + } + if (!outcome.ok && outcome.reason === "conflict") { + // Throw a typed control error instead of returning a nested result + // so dispatchControl produces a TOP-LEVEL ok:false response with + // code idempotency_conflict (the generic cache does the same for + // in-cache conflicts; this path covers the evicted/restart case). + throw new TypedControlError("idempotency_conflict", "Idempotency key was reused with different input."); + } + if (!outcome.ok) { + return { + ok: true, + selection: scope, + turn: "uncertain", + ownedWork: scope === "turn" ? "left_running" : "uncertain", + automaticDelivery: scope === "turn" ? "enabled" : "none", + resumeOnOwnedCompletion: scope === "turn", + reason: outcome.reason, + }; + } + if (outcome.outcome === "no_active_turn" || outcome.outcome === "already_terminal") { + // No active root turn to stop: process-local no-effect, no fence. + return { + ok: true, + selection: scope, + turn: "no_active_turn", + terminal: "terminal_no_effect", + ...(outcome.stored ? { replay: outcome.stored } : {}), + }; + } + if (outcome.outcome === "no_store") { + // No file-backed reconciliation owner: terminal admission is gated + // off before any fence/stop/cleanup (plan AC 5). + return { + ok: true, + selection: scope, + turn: "no_store", + terminal: "terminal_no_effect", + }; + } + if (outcome.outcome === "no_effect") { + // Initial marker could not be persisted before any destructive work + // (AC 10): process-local no-effect, no fence, no stop. + return { + ok: true, + selection: scope, + turn: "no_effect", + terminal: "terminal_no_effect", + }; + } + if (outcome.outcome === "no_effect_replay") { + // Durable no-active-turn reservation replayed: exact no-effect, so + // a same-key retry after eviction/restart never aborts a later turn. + return { + ok: true, + selection: scope, + turn: "no_active_turn", + terminal: "terminal_no_effect", + ...(outcome.stored ? { replay: outcome.stored } : {}), + }; + } + if (outcome.outcome === "pending_replay" || outcome.outcome === "uncertain_replay") { + // A crashed or restart-settled attempt left a non-stopped durable + // marker (AC 4/41): replay safe uncertainty without re-running the + // stop/cleanup/event, carrying the stored immutable row. + return { + ok: true, + selection: scope, + turn: "uncertain", + ownedWork: scope === "turn" ? "left_running" : "uncertain", + automaticDelivery: scope === "turn" ? "enabled" : "none", + resumeOnOwnedCompletion: scope === "turn", + reason: outcome.outcome === "pending_replay" ? "replay_pending" : "replay_uncertain", + ...(outcome.stored ? { replay: outcome.stored } : {}), + }; + } + if (outcome.outcome === "stopped_owned") { + // scope:"owned" stopped the exact captured owned work and proved + // quiescence (every captured generation/entry terminal); stopped + // work can never resume the agent. + return { + ok: true, + selection: "owned", + turn: "stopped", + ownedWork: "stopped", + automaticDelivery: "none", + resumeOnOwnedCompletion: false, + ...(outcome.stored ? { replay: outcome.stored } : {}), + }; + } + return { + ok: true, + selection: "turn", + turn: "stopped", + ownedWork: "left_running", + automaticDelivery: "enabled", + resumeOnOwnedCompletion: true, + ...(outcome.stored ? { replay: outcome.stored } : {}), + }; + }, abortAndPrompt: async text => { await awaitAbortReady(); return await submitPrompt(text, undefined, true, undefined, false, controlRequesterContext.getStore()); @@ -3713,6 +3904,8 @@ export function createNotificationsExtension( const PROMPT_TERMINAL_TOMBSTONE_TTL_MS = 15 * 60_000; // SDK-owned terminalization grace; injectable in tests, never a user setting. const PROMPT_TERMINALIZATION_GRACE_MS = 10_000; + // Fixed grace for exact owned-job stop before the second quiescence proof. + const OWNED_SETTLEMENT_GRACE_MS = 500; const promptSubmissionKey = (correlation: { commandId: string; turnId: string }) => `${correlation.commandId}:${correlation.turnId}`; type PromptLifecycleFrame = @@ -4043,8 +4236,17 @@ export function createNotificationsExtension( // Cleanup-initiated claims (cancel, deadline, owner disconnect) must abort the // run and prove settlement. A natural `agent_end`/`agent_failed` already unwound, // so aborting there would cancel the next turn instead of fencing this one. - options: { fence?: boolean } = {}, + options: { fence?: boolean; terminal?: { scope: AbortScope } } = {}, extra?: { finalText?: string; error?: { code: string; message: string } }, + capture?: { + proof?: RunSettlementProof & { + terminalScope?: { scopeId: string; abortedAttemptEpoch: number; lineageIdHash: string }; + }; + /** Whether the correlated agent_end event was published (AC 19). */ + published?: boolean; + /** Whether terminalization reached the durable terminal (fail-closed paths leave this unset). */ + terminalized?: boolean; + }, ) => { const submission = promptSubmissions.get(promptSubmissionKey(correlation)); if (!submission || submission.terminal || submission.phase !== "active") return; @@ -4064,7 +4266,10 @@ export function createNotificationsExtension( submission.phase = "terminalizing"; if (options.fence) { const seam = ctx as typeof ctx & { - abortPromptAndWait?: (handle: string, options: { graceMs: number }) => Promise; + abortPromptAndWait?: ( + handle: string, + options: { graceMs: number; terminal?: { scope: AbortScope } }, + ) => Promise; }; // Only the handle captured for this correlation may be fenced; a later run // must never be aborted by an older prompt's cleanup. @@ -4081,6 +4286,11 @@ export function createNotificationsExtension( try { proof = await seam.abortPromptAndWait(submission.executionHandle, { graceMs: PROMPT_TERMINALIZATION_GRACE_MS, + // Terminal abort registers the continuation fence for the + // aborted turn before the run is interrupted (see + // AgentSession.abortPromptAndWait). Ordinary cancels pass no + // terminal option and register nothing. + ...(options.terminal ? { terminal: options.terminal } : {}), }); } catch (error) { logger.warn(`sdk: prompt resource fencing failed: ${String(error)}`); @@ -4104,6 +4314,7 @@ export function createNotificationsExtension( ); return; } + if (capture) capture.proof = proof; } try { await kindReconciliation.finalizePromptOutcome(correlation, winner, extra?.error); @@ -4116,22 +4327,54 @@ export function createNotificationsExtension( } if (submission.deadlineTimer) clearTimeout(submission.deadlineTimer); if (!recordPromptTerminal(correlation) || !runtime) return; - if (winner.kind === "failed") { - emitPromptLifecycle(correlation, { - type: "agent_failed", - sessionId: runtime.id, - ...correlation, - error: extra?.error ?? { code: winner.code, message: winner.message }, - outcome: winner, - }); - } else { - emitPromptLifecycle(correlation, { - type: "agent_end", - sessionId: runtime.id, - ...correlation, - ...(extra?.finalText ? { finalText: extra.finalText } : {}), - outcome: winner, - }); + try { + if (winner.kind === "failed") { + emitPromptLifecycle(correlation, { + type: "agent_failed", + sessionId: runtime.id, + ...correlation, + error: extra?.error ?? { code: winner.code, message: winner.message }, + outcome: winner, + }); + } else { + emitPromptLifecycle(correlation, { + type: "agent_end", + sessionId: runtime.id, + ...correlation, + ...(extra?.finalText ? { finalText: extra.finalText } : {}), + outcome: winner, + // Terminal abort: one correlated existing agent_end carries bounded + // scope/turn/ownedWork/automatic metadata before the first terminal + // success. ownedWork is pre-proof here (owned cleanup settles it in + // the terminal response); later owned-completion feedback uses the + // ordinary fresh-turn event path, never a second terminal event. + ...(options.terminal + ? { + terminal: { + scope: options.terminal.scope, + turn: "stopped", + ownedWork: options.terminal.scope === "turn" ? "left_running" : "uncertain", + automaticDelivery: options.terminal.scope === "turn" ? "enabled" : "none", + resumeOnOwnedCompletion: options.terminal.scope === "turn", + }, + } + : {}), + }); + } + // The correlated event was published (AC 19): record the outcome so + // the durable terminal-scope record carries terminalPublished:true. + // terminalized marks a genuinely landed durable terminal — the + // submission may already have been finalized/deleted for an + // acknowledged prompt, so the callback must not re-derive success + // from promptSubmissions after this point (P1). + if (capture) { + capture.published = true; + capture.terminalized = true; + } + } catch (error) { + // Event publication failed: the semantic terminal stands but the + // event bit stays false (no second event is ever emitted on replay). + logger.warn(`sdk: prompt terminal event publication failed: ${String(error)}`); } }; const emitPromptFailure = (correlation: { commandId: string; turnId: string }, error: unknown) => { @@ -4228,6 +4471,350 @@ export function createNotificationsExtension( ); return { aborted: true, disposition: "cancelled" as const }; }, + async (connectionId, scope, idempotencyKey, preflightCancel) => { + // Terminal abort stops the root turn through the same durable + // terminalization as ordinary client cancel, then verifies the + // terminal actually landed before claiming "stopped". The fence + // for the aborted turn is registered by the session (via the + // terminal option on abortPromptAndWait) so a later left-running + // owned completion classifies by exact source. A fatal + // fail-closed path (no exact run handle or unsettled resources) + // reports safe uncertainty, never a fabricated stop. + if (!durableStore?.path) { + // No FILE-BACKED reconciliation owner: terminal admission is + // gated off (plan AC 5) before any fence, stop, or cleanup. A + // memory-only store (path null, e.g. an unsafe session header + // id) must not report durable success — restart would lose the + // idempotency row and a same-key retry could affect a later + // turn (review thread P2). + return { ok: true as const, outcome: "no_store" as const }; + } + // Await the startup reconciliation hydration before ANY snapshot or + // terminal-scope transaction, so a same-key retry immediately after + // a restarted endpoint becomes reachable replays the durable row + // instead of racing the still-pending store load (P2). + await reconciliationReady; + // Same-key replay/conflict: a durable terminal-scope record already + // exists for this bounded idempotency key. Same key + same + // normalized input -> return the stored dispositions exactly, never + // re-run cleanup, never a second event. Same key + different input + // (scope change) -> deterministic conflict (AC 3). + const keyHash = idempotencyKey + ? crypto.createHash("sha256").update(idempotencyKey).digest("hex") + : undefined; + const inputHash = crypto + .createHash("sha256") + .update(JSON.stringify({ mode: "terminal", scope })) + .digest("hex"); + if (keyHash) { + const existing = durableStore.snapshotTerminalScopes().find(s => s.idempotencyKeyHash === keyHash); + if (existing) { + if (existing.selection !== scope || existing.idempotencyInputHash !== inputHash) { + return { ok: false as const, reason: "conflict" as const }; + } + // Replay every persisted durable row (AC 18/19/41) WITHOUT + // re-running the stop, cleanup, or event, carrying the stored + // response state, payload hash, and publication bit so the + // client sees the exact immutable row. + const storedRow = { + responseState: existing.responseState, + responsePayloadHash: existing.responsePayloadHash, + terminalPublished: existing.terminalPublished === true, + }; + if (existing.turnDisposition === "stopped") { + return { + ok: true as const, + outcome: (existing.ownedWorkDisposition === "stopped" ? "stopped_owned" : "stopped") as + | "stopped" + | "stopped_owned", + stored: storedRow, + }; + } + if (existing.turnDisposition === "pending") { + // A crashed attempt left an incomplete marker: replay the + // plan's pending row (AC 4/41) — safe uncertainty, NO + // re-run of the stop, cleanup, or event. + return { ok: true as const, outcome: "pending_replay" as const, stored: storedRow }; + } + if (existing.turnDisposition === "no_effect") { + // A durable no-active-turn reservation: replay the exact + // no-effect row so a same-key retry after eviction/restart + // never aborts an unrelated later turn. + return { ok: true as const, outcome: "no_effect_replay" as const, stored: storedRow }; + } + // uncertain (restart-settled) or any other durable state: safe + // uncertainty replay, never a re-run (AC 41 restart row). + return { ok: true as const, outcome: "uncertain_replay" as const, stored: storedRow }; + } + } + // Durable no-effect reservations for idle/already-terminal aborts are + // bounded like the in-memory idempotency cache so a client sending + // idle aborts with unique keys cannot grow the reconciliation + // document indefinitely (review thread P2). Only the oldest + // no_effect rows beyond the cap are evicted; stopped/uncertain rows + // are untouched. + const MAX_DURABLE_TERMINAL_RESERVATIONS = 256; + const reserveTerminalNoEffect = async (): Promise<"ok" | "failed"> => { + if (!keyHash) return "ok"; + try { + await durableStore.transactTerminalScopes(scopes => { + const retained = scopes.filter( + s => !(s.idempotencyKeyHash === keyHash && s.idempotencyInputHash === inputHash), + ); + const next = [ + ...retained, + { + selection: scope, + idempotencyKeyHash: keyHash, + idempotencyInputHash: inputHash, + turnDisposition: "no_effect", + ownedWorkDisposition: "not_requested", + automaticDeliveryDisposition: scope === "turn" ? "enabled" : "none", + resumeOnOwnedCompletion: scope === "turn", + turnContinuationFence: { + state: "retained", + abortedAttemptEpoch: 0, + blockedContinuationIds: [], + predecessorTombstones: [], + ownedCompletionPolicy: scope === "turn" ? "enabled" : "disabled", + }, + responseState: "pending", + responsePayloadHash: inputHash, + acceptedAt: Date.now(), + } satisfies DurableTerminalScopeRecord, + ]; + return boundCompletedTerminalScopeRows(next, MAX_DURABLE_TERMINAL_RESERVATIONS); + }); + return "ok"; + } catch (error) { + logger.warn(`sdk: terminal no-effect reservation failed: ${String(error)}`); + return "failed"; + } + }; + let active = [...promptSubmissions.entries()].find( + ([, submission]) => submission.connectionId === connectionId && !submission.terminal, + ); + if (!active) { + // DURABLY reserve the key even for a no-active-turn abort: the + // generic idempotency cache is in-memory only, so after restart + // or eviction a same-key retry while a later prompt is active + // must replay this no-effect row instead of aborting an + // unrelated turn (review thread P2). No active turn means no + // fence epoch; the marker uses sentinel 0. The reservation is + // bounded (see reserveTerminalNoEffect). + if ((await reserveTerminalNoEffect()) === "failed") { + // Without the durable reservation a same-key retry after + // eviction/restart could abort an unrelated later turn, so a + // failed reservation must NOT report success. + return { ok: false as const, reason: "reservation_failed" as const }; + } + // RE-SCAN after the async reservation: the requester's prompt may + // have moved from preflight to accepted during the filesystem + // write. Returning no_active_turn here would leave the accepted + // prompt to start (its pending-preflight entry may already be + // removed, so the surface cleanup skips both cancellation seams) + // and the durable no-effect row would block a same-key retry from + // stopping it — fall through to the pre-run / active fencing path + // when a submission now exists (review thread P1). + active = [...promptSubmissions.entries()].find( + ([, submission]) => submission.connectionId === connectionId && !submission.terminal, + ); + if (!active) { + // Close the remaining acceptance race: cancel the requester's + // preflights HERE, in the same synchronous region as the + // rescan (no await boundary between the scan and the cancel, + // as the surface-level post-check had), so a prompt accepted + // in that window cannot start with its preflight entry + // already removed (review thread P1). + if (preflightCancel?.hasPending()) { + preflightCancel.cancel(); + const preflightSeam = ctx as typeof ctx & { + cancelPendingPreflightForTerminalAbort?: () => void; + }; + preflightSeam.cancelPendingPreflightForTerminalAbort?.(); + } + return { ok: true as const, outcome: "no_active_turn" as const }; + } + } + const [commandId, turnId] = active[0].split(":", 2); + if (!commandId || !turnId) { + if ((await reserveTerminalNoEffect()) === "failed") { + // Same durable-reservation guarantee as the no-active path. + return { ok: false as const, reason: "reservation_failed" as const }; + } + return { ok: true as const, outcome: "already_terminal" as const }; + } + // Accepted-but-not-started window: the submission exists but + // agent_start has not bound executionHandle yet, and the preflight + // cancellation entry was already removed after accept. Cancel the + // in-flight session preflight so the pending #promptWithMessage + // cannot continue into the agent, and FINALIZE the accepted prompt + // as a pre-run client cancellation WITHOUT terminalizing — there is + // no run handle to fence, and terminalizePrompt's missing-handle + // fail-closed path would wrongly fence the SDK connection and leave + // reconciliation unfinalized for a prompt that will never run + // (review thread P2). + if (!active[1].executionHandle) { + // Persist the durable no-effect reservation BEFORE cancelling the + // session preflight: a failed reservation must NOT leave the + // prompt cancelled with no durable row (a later same-key retry + // after eviction/restart could then abort an unrelated turn — + // review thread P2). + if ((await reserveTerminalNoEffect()) === "failed") { + return { ok: false as const, reason: "reservation_failed" as const }; + } + // RECHCK after the async write: agent_start may have bound the + // execution handle during the reservation, so this is no longer + // a pre-run cancellation — fall through to the ACTIVE-turn + // fencing path below (terminalizePrompt with fence) instead of + // finalizing without abortPromptAndWait (review thread P2). + if (!active[1].executionHandle) { + const preflightSeam = ctx as typeof ctx & { + cancelPendingPreflightForTerminalAbort?: () => void; + }; + preflightSeam.cancelPendingPreflightForTerminalAbort?.(); + await terminalizePrompt( + { commandId, turnId }, + { kind: "stopped", reason: "cancelled", provenance: "client_cancel" }, + {}, + ); + return { ok: true as const, outcome: "no_active_turn" as const }; + } + } + // Plan ordered step 4: write the bounded INITIAL MARKER (key/input + // hashes, pending dispositions, publication false, response pending) + // BEFORE any fence/stop/event effect, so a crash between the stop + // and the semantic CAS still leaves a same-key retry that replays + // deterministically instead of re-running effects. Marker failure is + // process-local no-effect (AC 10) — nothing destructive has run yet. + const epochSeam = ctx as typeof ctx & { getTerminalTurnEpoch?: () => number | undefined }; + const markerEpoch = epochSeam.getTerminalTurnEpoch?.(); + if (markerEpoch === undefined) return { ok: true as const, outcome: "no_effect" as const }; + try { + await durableStore.transactTerminalScopes(scopes => { + const retained = scopes.filter(s => !(keyHash && s.idempotencyKeyHash === keyHash)); + return boundCompletedTerminalScopeRows( + [ + ...retained, + { + selection: scope, + ...(keyHash ? { idempotencyKeyHash: keyHash, idempotencyInputHash: inputHash } : {}), + turnDisposition: "pending", + terminalPublished: false, + ownedWorkDisposition: "not_requested", + automaticDeliveryDisposition: scope === "turn" ? "enabled" : "none", + resumeOnOwnedCompletion: scope === "turn", + turnContinuationFence: { + state: "retained", + abortedAttemptEpoch: markerEpoch, + blockedContinuationIds: [], + predecessorTombstones: [], + ownedCompletionPolicy: scope === "turn" ? "enabled" : "disabled", + }, + responseState: "pending", + responsePayloadHash: inputHash, + acceptedAt: Date.now(), + } satisfies DurableTerminalScopeRecord, + ], + MAX_DURABLE_TERMINAL_RESERVATIONS, + ); + }); + } catch (error) { + logger.warn(`sdk: terminal initial marker persistence failed: ${String(error)}`); + return { ok: true as const, outcome: "no_effect" as const }; + } + const captured: { + proof?: RunSettlementProof & { + terminalScope?: { scopeId: string; abortedAttemptEpoch: number; lineageIdHash: string }; + }; + published?: boolean; + terminalized?: boolean; + } = {}; + await terminalizePrompt( + { commandId, turnId }, + { kind: "stopped", reason: "cancelled", provenance: "client_cancel" }, + { fence: true, terminal: { scope } }, + undefined, + captured, + ); + // Success is decided by the terminalizePrompt outcome, NOT by the + // submission record: an already-acknowledged prompt is finalized + // (deleted) during emission, so a lookup here can return undefined + // even for a landed terminal (P1). fail-closed paths leave + // terminalized unset. + if (captured.terminalized !== true) return { ok: false as const, reason: "worker_unsettled" as const }; + // For scope:"owned", stop the exact captured owned work and prove + // quiescence before claiming stopped. Exactness comes from the + // registered five-tuples of this turn's lineage+epoch; foreign or + // unclassified work is never swept and yields uncertainty. + const terminalScope = captured.proof?.terminalScope; + let ownedStopped = true; + if (scope === "owned") { + const exactJobs = terminalScope + ? findOwnedRegistrationsForTurn(terminalScope.lineageIdHash, terminalScope.abortedAttemptEpoch) + : []; + if (exactJobs.length > 0) { + const manager = AsyncJobManager.instance(); + if (!manager) { + return { ok: false as const, reason: "owned_unsettled" as const }; + } + ownedStopped = (await settleOwnedWork(manager, exactJobs, OWNED_SETTLEMENT_GRACE_MS)) === "stopped"; + if (!ownedStopped) return { ok: false as const, reason: "owned_unsettled" as const }; + } + } + // Semantic CAS: advance the INITIAL MARKER (matched by key hash, or + // by selection+epoch when keyless) to the final dispositions through + // the same full-document owner (plan step 15). The prompt terminal + // is already durable; a failed write fails closed to safe + // uncertainty — never a stopped disposition the record cannot prove. + try { + await durableStore.transactTerminalScopes(scopes => + scopes.map(scopeRecord => { + const isMarker = + (keyHash !== undefined && scopeRecord.idempotencyKeyHash === keyHash) || + (keyHash === undefined && + scopeRecord.selection === scope && + scopeRecord.turnDisposition === "pending"); + if (!isMarker) return scopeRecord; + const ownedWorkDisposition = + scope === "turn" ? "left_running" : ownedStopped ? "stopped" : "uncertain"; + const payloadHash = crypto + .createHash("sha256") + .update( + JSON.stringify({ + selection: scope, + turn: "stopped", + ownedWork: ownedWorkDisposition, + automaticDelivery: scope === "turn" ? "enabled" : "none", + resumeOnOwnedCompletion: scope === "turn", + }), + ) + .digest("hex"); + return { + ...scopeRecord, + turnDisposition: "stopped" as const, + terminalPublished: captured.published === true, + ownedWorkDisposition, + turnContinuationFence: { + ...scopeRecord.turnContinuationFence, + abortedAttemptEpoch: + terminalScope?.abortedAttemptEpoch ?? + scopeRecord.turnContinuationFence.abortedAttemptEpoch, + }, + responsePayloadHash: payloadHash, + terminalAt: Date.now(), + }; + }), + ); + } catch (error) { + logger.warn(`sdk: terminal scope persistence failed: ${String(error)}`); + return { ok: false as const, reason: "worker_unsettled" as const }; + } + if (scope === "owned") { + return { ok: true as const, outcome: "stopped_owned" as const }; + } + return { ok: true as const, outcome: "stopped" as const }; + }, { admit: (clientRef?: string) => kindReconciliation.admit("skill", clientRef), release: (clientRef?: string) => kindReconciliation.releaseAdmission("skill", clientRef), @@ -4256,10 +4843,11 @@ export function createNotificationsExtension( abandonPrompt(submission); }; - const sendSdkFrame = (connectionId: string, frame: Record) => { + const sendSdkFrame = (connectionId: string, frame: Record): "written" | "dropped" => { if (extensionShuttingDown || runtime?.stopping || runtimes.get(id) !== runtime) { + // Deliberate drop (AC 17/20): no write, no post-write hook, no fallback. abandonPromptResponse(connectionId, frame); - return; + return "dropped"; } const json = JSON.stringify(frame); if (connectionId.startsWith("seam:")) { @@ -4276,7 +4864,7 @@ export function createNotificationsExtension( abandonPromptResponse(connectionId, frame); throw error; } - return; + return "written"; } try { server.sendTo(connectionId, json); @@ -4285,6 +4873,7 @@ export function createNotificationsExtension( abandonPromptResponse(connectionId, frame); throw error; } + return "written"; }; /** @@ -4362,6 +4951,60 @@ export function createNotificationsExtension( }; }, onRequest: options.onSdkRequest, + onControlResponseDelivery: async (_connectionId, request, _response, outcome) => { + // Terminal abort: persist the monotonic response-state transition + // (AC 18) — pending -> sent on a written response, pending -> failed + // on a rejected/dropped write. A same-key retry then replays the + // stored disposition with the matching response state. + if ( + request.operation === "turn.abort" && + typeof request.input === "object" && + request.input !== null && + (request.input as { mode?: unknown }).mode === "terminal" && + typeof request.idempotencyKey === "string" && + durableStore + ) { + const keyHash = crypto.createHash("sha256").update(request.idempotencyKey).digest("hex"); + // Match the NORMALIZED terminal input hash too: a same-key + // request with a different scope (conflict after in-memory + // eviction) must never advance the ORIGINAL pending marker's + // response state — only the response for the exact input that + // produced the record may transition it (review thread P2). + // Strictly validate first: a MALFORMED retry (e.g. scope:"bogus") + // rejected by dispatch must not match a prior valid scope:"turn" + // row through the "not owned => turn" fallback. + const input = request.input as Record; + const mode = input.mode; + const rawScope = input.scope; + if (mode !== "terminal") return; + if (rawScope !== undefined && rawScope !== "turn" && rawScope !== "owned") return; + for (const key of Object.keys(input)) if (key !== "mode" && key !== "scope") return; + const scopeInput = rawScope === "owned" ? "owned" : "turn"; + const inputHash = crypto + .createHash("sha256") + .update(JSON.stringify({ mode: "terminal", scope: scopeInput })) + .digest("hex"); + try { + // Same hydration barrier as the abort path: never race a still + // pending store load with the response-state transition (P2). + await reconciliationReady; + await durableStore.transactTerminalScopes(scopes => + scopes.map(scope => + scope.idempotencyKeyHash === keyHash && + scope.idempotencyInputHash === inputHash && + scope.responseState === "pending" + ? { + ...scope, + responseState: outcome === "written" ? ("sent" as const) : ("failed" as const), + } + : scope, + ), + ); + } catch (error) { + logger.warn(`sdk: terminal response-state persistence failed: ${String(error)}`); + } + } + }, beforeControlResponse: async (_connectionId, request, response, sendTerminal) => { if (typeof request.operation !== "string" || !identityControlOperations.has(request.operation)) return; const pending = deferredIdentityRotation; diff --git a/packages/coding-agent/src/sdk/bus/reconciliation-store.ts b/packages/coding-agent/src/sdk/bus/reconciliation-store.ts index 06dcaa582..c332268ac 100644 --- a/packages/coding-agent/src/sdk/bus/reconciliation-store.ts +++ b/packages/coding-agent/src/sdk/bus/reconciliation-store.ts @@ -14,11 +14,12 @@ import * as path from "node:path"; import type { PromptReconciliationStatus, SdkPromptTerminalOutcome } from "../prompt-status"; import type { PromptCorrelation } from "./prompt-reconciliation"; -export const RECONCILIATION_STORE_VERSION = 1; +export const RECONCILIATION_STORE_VERSION = 2; +export const RECONCILIATION_STORE_VERSION_V1 = 1; export const RECONCILIATION_SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; export const RECONCILIATION_DIR_NAME = ".sdk-reconciliation"; -export type ReconciliationKind = "prompt" | "skill"; +export type ReconciliationKind = "prompt" | "skill" | "terminal"; export interface DurableReconciliationRecord extends PromptCorrelation { kind: ReconciliationKind; @@ -34,10 +35,47 @@ export interface DurableReconciliationRecord extends PromptCorrelation { skillName?: string; } +/** + * Durable terminal scope record (approved abort-SDK plan, v2 document). + * Bounded origin/fence and owned-settlement fields only; no prompt text and no + * suppressed/deferred receipts for left-running turn work. + */ +export interface DurableTerminalScopeRecord { + selection: "turn" | "owned"; + /** SHA-256 of the bounded idempotency key; the raw key is never persisted. */ + idempotencyKeyHash?: string; + /** SHA-256 of the canonicalized normalized input; raw input is never persisted. */ + idempotencyInputHash?: string; + turnDisposition: "pending" | "stopped" | "uncertain" | "no_effect"; + /** Whether the correlated agent_end event was published (AC 19). */ + terminalPublished?: boolean; + ownedWorkDisposition: "not_requested" | "left_running" | "stopped" | "uncertain"; + automaticDeliveryDisposition: "enabled" | "none"; + resumeOnOwnedCompletion: boolean; + turnContinuationFence: { + state: "retained" | "released"; + abortedAttemptEpoch: number; + blockedContinuationIds: string[]; + predecessorTombstones: string[]; + ownedCompletionPolicy: "enabled" | "disabled"; + }; + ownedDeliverySettlements?: Array<{ + keyHash: string; + entryIdHash: string; + status: "settled" | "absent" | "uncertain"; + observedAt: number; + }>; + responseState: "pending" | "sent" | "delivered" | "failed"; + responsePayloadHash: string; + acceptedAt: number; + terminalAt?: number; +} + export interface ReconciliationStoreDocument { version: typeof RECONCILIATION_STORE_VERSION; sessionId: string; records: DurableReconciliationRecord[]; + terminalScopes?: DurableTerminalScopeRecord[]; } export interface ReconciliationStoreFs { @@ -129,15 +167,103 @@ function isValidRecord(value: unknown): boolean { ); }); } +/** Terminal scope validation: bounded origin/fence/settlement fields only. */ +function isValidTerminalScope(value: unknown): boolean { + if (!isRecord(value)) return false; + const { + selection, + turnDisposition, + ownedWorkDisposition, + automaticDeliveryDisposition, + resumeOnOwnedCompletion, + turnContinuationFence, + ownedDeliverySettlements, + responseState, + responsePayloadHash, + acceptedAt, + terminalAt, + } = value; + if (selection !== "turn" && selection !== "owned") return false; + if ( + turnDisposition !== "pending" && + turnDisposition !== "stopped" && + turnDisposition !== "uncertain" && + turnDisposition !== "no_effect" + ) + return false; + if ( + ownedWorkDisposition !== "not_requested" && + ownedWorkDisposition !== "left_running" && + ownedWorkDisposition !== "stopped" && + ownedWorkDisposition !== "uncertain" + ) + return false; + if (automaticDeliveryDisposition !== "enabled" && automaticDeliveryDisposition !== "none") return false; + if (typeof resumeOnOwnedCompletion !== "boolean") return false; + if (!isRecord(turnContinuationFence)) return false; + const { state, abortedAttemptEpoch, blockedContinuationIds, predecessorTombstones, ownedCompletionPolicy } = + turnContinuationFence; + if (state !== "retained" && state !== "released") return false; + if (typeof abortedAttemptEpoch !== "number" || !Number.isFinite(abortedAttemptEpoch)) return false; + if (!Array.isArray(blockedContinuationIds) || !blockedContinuationIds.every(id => typeof id === "string")) + return false; + if (!Array.isArray(predecessorTombstones) || !predecessorTombstones.every(id => typeof id === "string")) + return false; + if (ownedCompletionPolicy !== "enabled" && ownedCompletionPolicy !== "disabled") return false; + if (ownedDeliverySettlements !== undefined) { + if (!Array.isArray(ownedDeliverySettlements) || ownedDeliverySettlements.length > 256) return false; + for (const settlement of ownedDeliverySettlements) { + if (!isRecord(settlement)) return false; + if (typeof settlement.keyHash !== "string" || !settlement.keyHash) return false; + if (typeof settlement.entryIdHash !== "string" || !settlement.entryIdHash) return false; + if (settlement.status !== "settled" && settlement.status !== "absent" && settlement.status !== "uncertain") + return false; + if (typeof settlement.observedAt !== "number" || !Number.isFinite(settlement.observedAt)) return false; + } + } + if ( + responseState !== "pending" && + responseState !== "sent" && + responseState !== "delivered" && + responseState !== "failed" + ) + return false; + if (typeof responsePayloadHash !== "string" || !responsePayloadHash) return false; + if (typeof acceptedAt !== "number" || !Number.isFinite(acceptedAt)) return false; + if (terminalAt !== undefined && (typeof terminalAt !== "number" || !Number.isFinite(terminalAt))) return false; + // An incomplete (pending) scope cannot already be terminal. + if (turnDisposition === "pending" && terminalAt !== undefined) return false; + return true; +} function parseDocument(raw: string, expectedSessionId: string): ReconciliationStoreDocument { const value = JSON.parse(raw) as unknown; - if (!isRecord(value) || value.version !== RECONCILIATION_STORE_VERSION) + if ( + !isRecord(value) || + (value.version !== RECONCILIATION_STORE_VERSION && value.version !== RECONCILIATION_STORE_VERSION_V1) + ) throw new Error("invalid reconciliation store version"); if (value.sessionId !== expectedSessionId) throw new Error("session id mismatch"); if (!Array.isArray(value.records)) throw new Error("invalid records"); if (!value.records.every(isValidRecord)) throw new Error("invalid reconciliation record"); - return value as unknown as ReconciliationStoreDocument; + // v1 documents migrate to v2 (records only; terminalScopes added later). + if (value.version === RECONCILIATION_STORE_VERSION_V1) + return { + version: RECONCILIATION_STORE_VERSION, + sessionId: expectedSessionId, + records: value.records as DurableReconciliationRecord[], + }; + const terminalScopes = value.terminalScopes; + if (terminalScopes !== undefined) { + if (!Array.isArray(terminalScopes)) throw new Error("invalid terminal scopes"); + if (!terminalScopes.every(isValidTerminalScope)) throw new Error("invalid terminal scope"); + } + return { + version: RECONCILIATION_STORE_VERSION, + sessionId: expectedSessionId, + records: value.records as DurableReconciliationRecord[], + ...(terminalScopes !== undefined ? { terminalScopes: terminalScopes as DurableTerminalScopeRecord[] } : {}), + }; } /** @@ -145,6 +271,25 @@ function parseDocument(raw: string, expectedSessionId: string): ReconciliationSt * Prompt records preserve a durable pending outcome; skills retain the existing * reconciliation-incomplete result. */ +/** + * Settle incomplete terminal scopes (turnDisposition "pending") to safe + * uncertainty after process death. A terminal scope that never finalized its + * semantic CAS replays as uncertainty, never as success. + */ +export function settleTerminalScopeRestart( + scopes: DurableTerminalScopeRecord[], + now: number, +): DurableTerminalScopeRecord[] { + return scopes.map(scope => { + if (scope.turnDisposition !== "pending" || scope.terminalAt !== undefined) return scope; + return { + ...scope, + turnDisposition: "uncertain", + ownedWorkDisposition: scope.ownedWorkDisposition === "not_requested" ? "not_requested" : "uncertain", + terminalAt: now, + }; + }); +} export function settleProcessRestart( records: DurableReconciliationRecord[], now: number, @@ -184,6 +329,13 @@ export interface ReconciliationStore { load(): Promise; /** Snapshot currently held in memory after last load/transact. */ snapshot(): DurableReconciliationRecord[]; + /** Terminal-scope mutations through the same serialized full-document owner. */ + transactTerminalScopes( + mutator: (scopes: DurableTerminalScopeRecord[]) => DurableTerminalScopeRecord[], + ): Promise; + loadTerminalScopes(): Promise; + /** Snapshot of terminal scopes currently held in memory. */ + snapshotTerminalScopes(): DurableTerminalScopeRecord[]; delete(): Promise; } @@ -202,6 +354,7 @@ export function createReconciliationStore(options: { : null; let memory: DurableReconciliationRecord[] = []; + let terminalMemory: DurableTerminalScopeRecord[] = []; let chain: Promise = Promise.resolve(); const writeAtomic = async (document: ReconciliationStoreDocument): Promise => { @@ -233,6 +386,7 @@ export function createReconciliationStore(options: { const load = async (): Promise => { if (!filePath) { memory = []; + terminalMemory = []; return memory; } let raw: string; @@ -243,6 +397,7 @@ export function createReconciliationStore(options: { // so the endpoint never becomes ready as if no prompt had been accepted. if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; memory = []; + terminalMemory = []; return memory; } let document: ReconciliationStoreDocument; @@ -256,15 +411,25 @@ export function createReconciliationStore(options: { // ignore } memory = []; + terminalMemory = []; return memory; } const settled = settleProcessRestart(document.records, now()); + const settledTerminal = settleTerminalScopeRestart(document.terminalScopes ?? [], now()); // Restart settlement must be durable before it is observable: a failed rewrite // propagates so the endpoint stays unready instead of serving empty state as if // no prompt had ever been accepted. - if (settled.some((record, index) => record !== document.records[index])) - await writeAtomic({ version: RECONCILIATION_STORE_VERSION, sessionId, records: settled }); + const recordsChanged = settled.some((record, index) => record !== document.records[index]); + const terminalChanged = settledTerminal.some((scope, index) => scope !== (document.terminalScopes ?? [])[index]); + if (recordsChanged || terminalChanged) + await writeAtomic({ + version: RECONCILIATION_STORE_VERSION, + sessionId, + records: settled, + ...(document.terminalScopes !== undefined || terminalChanged ? { terminalScopes: settledTerminal } : {}), + }); memory = settled; + terminalMemory = settledTerminal; return memory; }; @@ -273,7 +438,12 @@ export function createReconciliationStore(options: { ): Promise => { const run = async () => { const next = mutator(memory.map(r => ({ ...r }))); - await writeAtomic({ version: RECONCILIATION_STORE_VERSION, sessionId, records: next }); + await writeAtomic({ + version: RECONCILIATION_STORE_VERSION, + sessionId, + records: next, + ...(terminalMemory.length > 0 ? { terminalScopes: terminalMemory } : {}), + }); memory = next; }; const pending = chain.then(run, run); @@ -284,8 +454,30 @@ export function createReconciliationStore(options: { await pending; }; + const transactTerminalScopes = async ( + mutator: (scopes: DurableTerminalScopeRecord[]) => DurableTerminalScopeRecord[], + ): Promise => { + const run = async () => { + const next = mutator(terminalMemory.map(s => ({ ...s }))); + await writeAtomic({ + version: RECONCILIATION_STORE_VERSION, + sessionId, + records: memory, + ...(next.length > 0 ? { terminalScopes: next } : {}), + }); + terminalMemory = next; + }; + const pending = chain.then(run, run); + chain = pending.then( + () => undefined, + () => undefined, + ); + await pending; + }; + const deleteStore = async (): Promise => { memory = []; + terminalMemory = []; if (!filePath) return; await fileFs.unlink(filePath).catch(error => { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; @@ -298,6 +490,12 @@ export function createReconciliationStore(options: { transact, load, snapshot: () => memory.map(r => ({ ...r })), + transactTerminalScopes, + loadTerminalScopes: async () => { + await load(); + return terminalMemory.map(s => ({ ...s })); + }, + snapshotTerminalScopes: () => terminalMemory.map(s => ({ ...s })), delete: deleteStore, }; } diff --git a/packages/coding-agent/src/sdk/host/control/dispatch.ts b/packages/coding-agent/src/sdk/host/control/dispatch.ts index a326adde7..e50997c21 100644 --- a/packages/coding-agent/src/sdk/host/control/dispatch.ts +++ b/packages/coding-agent/src/sdk/host/control/dispatch.ts @@ -123,11 +123,64 @@ function inputHash(input: unknown): string { .update(JSON.stringify(canonicalize(input))) .digest("hex"); } +/** + * Normalize a WELL-FORMED terminal abort input for the idempotency hash: + * omitted scope defaults to "turn", so `{mode:"terminal"}` and + * `{mode:"terminal", scope:"turn"}` share one idempotency key (the durable + * terminal-scope replay hashes the same normalized payload). Malformed + * inputs (unknown fields, invalid mode/scope) are left raw so they are + * rejected downstream and never collide with a valid input's key. + */ +function normalizeTerminalAbortInputForHash(input: unknown): unknown { + if (typeof input !== "object" || input === null) return input; + const record = input as Record; + if (record.mode !== "terminal") return input; + for (const key of Object.keys(record)) if (!TERMINAL_ABORT_FIELDS.has(key)) return input; + const scope = record.scope; + if (scope !== undefined && scope !== "turn" && scope !== "owned") return input; + return { mode: "terminal", scope: scope === undefined ? "turn" : scope }; +} function text(input: ControlInput, key = "text"): string { return input[key] as string; } +const TERMINAL_ABORT_FIELDS = new Set(["mode", "scope"]); + +function invalidInput(message: string): never { + throw new TypedControlError("invalid_input", message); +} + +/** + * C04 `turn.abort` dispatch. + * + * Legacy behavior (omitted mode or `mode:"turn"`) is preserved verbatim: the + * input is dropped and the ordinary argument-less `surface.abort()` runs. + * + * Terminal mode (`mode:"terminal"`) is validated strictly and side-effect-free + * before any surface call: only `mode`/`scope` fields are accepted, `scope` + * must be `"turn"` or `"owned"` (default `"turn"`), and a nonempty idempotency + * key of at most 128 UTF-8 bytes is required on the request envelope. Terminal + * semantics (see the approved plan): stop the root worker's current turn and + * block only its own continuation routes; left-running owned work keeps + * running and its completions are delivered normally so the root worker can + * resume with a fresh attempt — owned delivery is NOT suppressed. + */ +function invokeAbort(surface: ControlSurface, input: ControlInput, idempotencyKey: string | undefined): ControlValue { + const mode = input.mode === undefined ? "turn" : input.mode; + if (mode === "turn") return surface.abort(); + if (mode !== "terminal") invalidInput('turn.abort mode must be "turn" or "terminal".'); + for (const key of Object.keys(input)) + if (!TERMINAL_ABORT_FIELDS.has(key)) invalidInput(`Unknown turn.abort terminal field: ${key}`); + const scope = input.scope === undefined ? "turn" : input.scope; + if (scope !== "turn" && scope !== "owned") invalidInput('turn.abort terminal scope must be "turn" or "owned".'); + if (typeof idempotencyKey !== "string" || idempotencyKey.length === 0) + invalidInput("terminal abort requires a nonempty idempotency key."); + if (new TextEncoder().encode(idempotencyKey).length > 128) + invalidInput("terminal abort idempotency key must be at most 128 UTF-8 bytes."); + if (!surface.abortTerminal) invalidInput("terminal abort is not supported by this surface."); + return surface.abortTerminal({ mode: "terminal", scope }, idempotencyKey); +} function invoke( surface: ControlSurface, operation: string, @@ -143,7 +196,7 @@ function invoke( case "turn.follow_up": return surface.followUp(text(input)); case "turn.abort": - return surface.abort(); + return invokeAbort(surface, input, idempotencyKey); case "turn.abort_and_prompt": return surface.abortAndPrompt(text(input)); case "ask.answer": @@ -337,7 +390,11 @@ function idempotent( const now = Date.now(); for (const [key, entry] of requests) if (entry.expiresAt <= now) requests.delete(key); const key = `${row.sdkId}\u0000${request.idempotencyKey}`; - const hash = inputHash(request.input); + // Terminal abort normalizes the omitted scope BEFORE hashing so the + // defaulted and explicit shapes share one idempotency key (and reach the + // durable terminal-scope replay on eviction); malformed inputs stay raw. + const hashInput = row.sdkId === "turn.abort" ? normalizeTerminalAbortInputForHash(request.input) : request.input; + const hash = inputHash(hashInput); const existing = requests.get(key); if (existing) { requests.delete(key); diff --git a/packages/coding-agent/src/sdk/host/control/index.ts b/packages/coding-agent/src/sdk/host/control/index.ts index 7f68ec181..4e8ac548e 100644 --- a/packages/coding-agent/src/sdk/host/control/index.ts +++ b/packages/coding-agent/src/sdk/host/control/index.ts @@ -7,4 +7,4 @@ export { dispatchControl, TypedControlError, } from "./dispatch"; -export type { ControlInput, ControlSurface, ControlValue } from "./operations"; +export type { AbortScope, ControlInput, ControlSurface, ControlValue } from "./operations"; diff --git a/packages/coding-agent/src/sdk/host/control/operations.ts b/packages/coding-agent/src/sdk/host/control/operations.ts index a68b3bbe8..40fbaea16 100644 --- a/packages/coding-agent/src/sdk/host/control/operations.ts +++ b/packages/coding-agent/src/sdk/host/control/operations.ts @@ -1,4 +1,17 @@ export type ControlValue = unknown; +export type AbortMode = "turn" | "terminal"; +export type AbortScope = "turn" | "owned"; + +/** + * Terminal-mode C04 `turn.abort` input. `scope` selects whether exact causal + * owned work (background Bash/task jobs, detached subagents) is also stopped + * (`"owned"`) or left running so its completion can resume the root worker + * (`"turn"`, the default). + */ +export interface TerminalAbortInput { + mode: "terminal"; + scope?: AbortScope; +} export type ControlInput = Record; /** @@ -10,6 +23,9 @@ export interface ControlSurface { steer(text: string): Promise | ControlValue; followUp(text: string): Promise | ControlValue; abort(): Promise | ControlValue; + /** Terminal abort: stop the current root turn (and optionally exact owned work). */ + /** Terminal abort: stop the current root turn (and optionally exact owned work). */ + abortTerminal?(input: TerminalAbortInput, idempotencyKey?: string): Promise | ControlValue; abortAndPrompt(text: string): Promise | ControlValue; answerAsk(id: string, answer: ControlValue): Promise | ControlValue; answerGate( diff --git a/packages/coding-agent/src/sdk/host/host.ts b/packages/coding-agent/src/sdk/host/host.ts index 01ed4dd39..7e6f5cb86 100644 --- a/packages/coding-agent/src/sdk/host/host.ts +++ b/packages/coding-agent/src/sdk/host/host.ts @@ -50,10 +50,22 @@ export interface SessionSdkHostOptions extends HostEndpointAdapters { connectionId: string, request: SdkFrame, response: SdkFrame, - sendTerminal: () => Promise, + sendTerminal: () => Promise, ) => void | Promise; /** Runs only after a successful control response has been sent to the client. */ afterControlResponse?: (connectionId: string, request: SdkFrame, response: SdkFrame) => void | Promise; + /** + * Classifies the awaited control-response write exactly once: `written` + * (sent), `rejected` (the write threw), or `dropped` (the send adapter + * deliberately skipped delivery). `afterControlResponse` runs only + * on `written`. Used to persist monotonic response-state transitions. + */ + onControlResponseDelivery?: ( + connectionId: string, + request: SdkFrame, + response: SdkFrame, + outcome: "written" | "rejected" | "dropped", + ) => void | Promise; installProviderDefinitions?: (capability: string, definitions: unknown) => void; onProviderDefinitionsRemoved?: (capability: string) => void; onReverseCancel?: (requestId: string, reason: "provider_disconnected" | "lease_released") => void; @@ -338,8 +350,8 @@ export class SessionSdkHost { }); } - async #send(connectionId: string, frame: SdkFrame): Promise { - await this.#options.sendFrame(connectionId, frame); + async #send(connectionId: string, frame: SdkFrame): Promise<"written" | "dropped"> { + return await this.#options.sendFrame(connectionId, frame); } /** @@ -380,14 +392,35 @@ export class SessionSdkHost { if (result !== undefined) { const response = { type: "control_response", ...(result as SdkFrame) }; let terminalSent = false; - const sendTerminal = async (): Promise => { - if (terminalSent) return; + let sendOutcome: "written" | "rejected" | "dropped" = "dropped"; + const sendTerminal = async (): Promise<"written" | "rejected" | "dropped"> => { + // Repeat calls (early hook send + fallback) return the FIRST, + // actual outcome — never a false dropped for an already + // written response (early-identity-rotation pattern). + if (terminalSent) return sendOutcome; terminalSent = true; - await this.#send(connectionId, response); + try { + const outcome = await this.#send(connectionId, response); + sendOutcome = outcome === "written" ? "written" : "dropped"; + } catch { + sendOutcome = "rejected"; + } + return sendOutcome; }; - await this.#options.beforeControlResponse?.(connectionId, frame, response, sendTerminal); - await sendTerminal(); - await this.#options.afterControlResponse?.(connectionId, frame, response); + try { + await this.#options.beforeControlResponse?.(connectionId, frame, response, sendTerminal); + // Fallback: if the before hook did not send early (identity + // rotation), the response is always sent here. + if (!terminalSent) sendOutcome = await sendTerminal(); + } finally { + // Classify exactly once, immediately after the send attempt and + // BEFORE the optional post-write hook, so a rejected/dropped + // write (or an afterControlResponse throw) never mislabels it. + await this.#options.onControlResponseDelivery?.(connectionId, frame, response, sendOutcome); + } + if (sendOutcome === "written") { + await this.#options.afterControlResponse?.(connectionId, frame, response); + } } break; } diff --git a/packages/coding-agent/src/sdk/host/reverse-leases.ts b/packages/coding-agent/src/sdk/host/reverse-leases.ts index 8bb3d9850..9638881e8 100644 --- a/packages/coding-agent/src/sdk/host/reverse-leases.ts +++ b/packages/coding-agent/src/sdk/host/reverse-leases.ts @@ -48,7 +48,7 @@ interface Outstanding { export interface ReverseLeaseOptions { now?: () => number; leaseTtlMs?: number; - sendFrame: (connectionId: string, frame: SdkFrame) => void | Promise; + sendFrame: (connectionId: string, frame: SdkFrame) => unknown; installDefinitions?: (capability: string, definitions: unknown) => void; onCancel?: (requestId: string, reason: "provider_disconnected" | "lease_released") => void; onDefinitionsRemoved?: (capability: string) => void; @@ -236,7 +236,7 @@ export class ReverseLeaseRuntime { return; } } - let delivery: void | Promise; + let delivery: unknown; try { delivery = this.#sendFrame(lease.connectionId, { type: "reverse_request", diff --git a/packages/coding-agent/src/sdk/host/types.ts b/packages/coding-agent/src/sdk/host/types.ts index d5a03a1ca..04b0344aa 100644 --- a/packages/coding-agent/src/sdk/host/types.ts +++ b/packages/coding-agent/src/sdk/host/types.ts @@ -19,7 +19,7 @@ export interface HostEndpointAdapters { sessionId: string; stateRoot: string; token: string; - sendFrame: (connectionId: string, frame: SdkFrame) => void | Promise; + sendFrame: (connectionId: string, frame: SdkFrame) => "written" | "dropped" | Promise<"written" | "dropped">; onFrame: (handler: (connectionId: string, frame: SdkFrame) => void) => undefined | (() => void); } diff --git a/packages/coding-agent/src/sdk/session.ts b/packages/coding-agent/src/sdk/session.ts index dc5b19da7..c40b849af 100644 --- a/packages/coding-agent/src/sdk/session.ts +++ b/packages/coding-agent/src/sdk/session.ts @@ -128,6 +128,11 @@ import { resolveAuthBrokerConfig } from "../session/auth-broker-config"; import { AuthBrokerClient, AuthStorage, RemoteAuthCredentialStore } from "../session/auth-storage"; import { type CustomMessage, convertToLlm } from "../session/messages"; import { createReadonlySessionManager, SessionManager } from "../session/session-manager"; +import { + isOwnedCompletionEnvelopeAllowed, + lookupOwnedRegistration, + type OwnedCompletionEnvelope, +} from "../session/terminal-abort"; import { formatNoModelsAvailableFallback } from "../setup/model-onboarding-guidance"; import { closeAllConnections } from "../ssh/connection-manager"; import { unmountAll } from "../ssh/sshfs-mount"; @@ -189,6 +194,8 @@ type AsyncResultEntry = { result: string; job: AsyncJob | undefined; durationMs: number | undefined; + /** Exact owned-completion origin when the job is registered left-running work of a terminal turn. */ + ownedCompletion?: OwnedCompletionEnvelope; }; type AsyncResultJobDetails = { @@ -200,6 +207,8 @@ type AsyncResultJobDetails = { type AsyncResultDetails = { jobs: AsyncResultJobDetails[]; + /** Private origin envelope(s); absent = ordinary delivery. Never a public field. */ + ownedCompletions?: OwnedCompletionEnvelope[]; }; type McpNotificationEntry = { @@ -209,13 +218,28 @@ type McpNotificationEntry = { function buildAsyncResultBatchMessage(entries: AsyncResultEntry[]): CustomMessage | null { if (entries.length === 0) return null; - const jobs = entries.map(entry => ({ + // Partition denied owned-completion entries out ENTIRELY before batch + // construction (AC 36 zero final calls from stopped work): a denied entry — + // owned scope, forged tuple, or vanished scope — must never reach + // followUp/prompt, even inside a mixed batch. Allowed owned-completion and + // ordinary entries are delivered normally. + const survivors = entries.filter( + entry => entry.ownedCompletion === undefined || isOwnedCompletionEnvelopeAllowed(entry.ownedCompletion), + ); + if (survivors.length === 0) return null; + const jobs = survivors.map(entry => ({ jobId: entry.jobId, result: entry.result, type: entry.job?.type, label: entry.job?.label, durationMs: entry.durationMs, })); + const ownedCompletions = survivors + .filter( + (entry): entry is AsyncResultEntry & { ownedCompletion: OwnedCompletionEnvelope } => + entry.ownedCompletion !== undefined, + ) + .map(entry => entry.ownedCompletion); const details: AsyncResultDetails = { jobs: jobs.map(job => ({ jobId: job.jobId, @@ -223,6 +247,10 @@ function buildAsyncResultBatchMessage(entries: AsyncResultEntry[]): CustomMessag label: job.label, durationMs: job.durationMs, })), + // Private origin envelope for the AgentSession injector; absent for + // ordinary deliveries. Only ALLOWED owned-completion entries survive + // partitioning, so the injector never sees a denied envelope here. + ...(ownedCompletions.length > 0 ? { ownedCompletions } : {}), }; return { role: "custom", @@ -1541,6 +1569,28 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} maxRunningJobs: asyncMaxJobs, onJobComplete: async (jobId, result, job) => { if (!session) return; + // Mandated boundary comment (corrected turn semantics): + // turn-scope abort blocks only deliveries whose origin is a + // continuation of the aborted turn. Owned-completion deliveries + // from work deliberately left running are intentionally allowed + // to resume the agent through the normal followUp/prompt path + // and receive a fresh turn attempt. Recover the immutable origin + // BEFORE formatting or artifact allocation; missing metadata + // fails closed to an ordinary delivery. + // Preserve ownership on the queued entry REGARDLESS of whether a + // terminal scope exists yet: the registration is determined at + // job-registration time, and the scope is determined at abort + // time (or later, at flush). A completion finished before the + // abort must not become an ordinary entry that owned cleanup + // cannot identify/purge (review thread P1). + const registration = job ? lookupOwnedRegistration(jobId, job.generation) : undefined; + const ownedCompletion = registration + ? { + lineageIdHash: registration.lineageIdHash, + promptAttemptEpoch: registration.promptAttemptEpoch, + registration, + } + : undefined; const formattedResult = await formatAsyncResultForFollowUp(result); if (asyncJobManager!.isDeliverySuppressed(jobId, job?.generation)) return; @@ -1551,6 +1601,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} result: formattedResult, job, durationMs, + ...(ownedCompletion + ? { + ownedCompletion: { + lineageIdHash: ownedCompletion.lineageIdHash, + promptAttemptEpoch: ownedCompletion.promptAttemptEpoch, + registration: ownedCompletion.registration, + }, + } + : {}), }); }, }) @@ -2869,6 +2928,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} if (asyncJobManager) { session.yieldQueue.register("async-result", { isStale: entry => asyncJobManager.isDeliverySuppressed(entry.jobId, entry.generation), + // Build one message per ownership origin so an owned-scope drop of + // one turn's message never suppresses other turns'/ordinary + // completions batched in the same flush (review thread P2). + groupKey: entry => + entry.ownedCompletion + ? `${entry.ownedCompletion.lineageIdHash}\u0000${entry.ownedCompletion.promptAttemptEpoch}` + : "ordinary", build: buildAsyncResultBatchMessage, }); } diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index c20106d8e..205ba8637 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -431,6 +431,15 @@ import { transferSessionMessageIdentity, } from "./session-manager"; import { getEntriesForInternalRead, getSessionContextForInternalRead } from "./session-manager-internal"; +import { + bindToolLineage, + classifyOwnedEnvelope, + lookupOwnedRegistration, + lookupTerminalScope, + mintTurnLineageIdHash, + type OwnedCompletionEnvelope, + registerTerminalTurnScope, +} from "./terminal-abort"; import { ToolChoiceQueue } from "./tool-choice-queue"; import { pruneSupersededMaintenanceReminders, pruneSupersededVolatileProjectContext } from "./volatile-context-pruning"; @@ -459,6 +468,32 @@ function appendCompactionStateContext(summary: string, stateContext: string[]): if (stateContext.length === 0) return summary; return `${summary}\n\n\n${stateContext.join("\n")}\n`; } +/** + * Classify an async-result delivery against terminal-abort ownership: + * - "ordinary": no owned-completion envelope — deliver as before. + * - "fresh": an exact registered owned-completion the owning scope's gate + * authorizes as a fresh-turn resume (scope:"turn", policy enabled). + * - "drop": a recognized owned-completion the gate denies — scope:"owned" + * (policy disabled, stopped work must never call followUp/prompt), a + * forged/unregistered tuple, or an envelope whose terminal scope no longer + * exists. Dropped entries never reach the agent (AC 36 zero final calls + * from stopped work), even if a delivery races the settlement purge. + */ +export function ownedCompletionResumeAction(message: AgentMessage): "ordinary" | "fresh" | "drop" { + const details = (message as { details?: { ownedCompletions?: OwnedCompletionEnvelope[] } }).details; + const envelopes = details?.ownedCompletions; + if (!envelopes || envelopes.length === 0) return "ordinary"; + // Three states per envelope: no terminal scope (no abort) is ORDINARY, + // turn-scope enabled is FRESH, owned-scope disabled is DROP. ANY drop in a + // mixed batch drops the whole delivery (defense in depth). + let anyFresh = false; + for (const envelope of envelopes) { + const action = classifyOwnedEnvelope(envelope); + if (action === "drop") return "drop"; + if (action === "fresh") anyFresh = true; + } + return anyFresh ? "fresh" : "ordinary"; +} const PRUNED_ARTIFACT_REF_MAX_CHARS = 64; @@ -2170,6 +2205,15 @@ export class AgentSession { #pendingRewindReport: string | undefined = undefined; #lastSuccessfulYieldToolCallId: string | undefined = undefined; + // Private terminal-abort machinery (C04 mode:"terminal"). The lineage id is + // minted per prompt turn before the model runs; tool-call bindings attach + // the attempt epoch so background registrations can later be classified as + // exact owned work (turn-continuation vs owned-completion) by source, never + // by timing. Endpoint generation is 0 for local/non-SDK sessions and is + // bound by the SDK host layer when a terminal endpoint is known. + #terminalEndpointGeneration = 0; + #turnLineageIdHash: string | undefined; + #terminalLineageSecret = crypto.randomUUID(); #promptGeneration = 0; #promptPreflightAbortController = new AbortController(); @@ -2373,6 +2417,50 @@ export class AgentSession { // in #refreshTeamWorkerHeartbeat(), including internally dispatched turns. } } + /** + * Allocate a FRESH prompt attempt/lineage for an allowed owned-completion + * delivery (corrected turn semantics). The new turn gets a new attempt epoch + * and an opaque lineage id; it never reuses the aborted attempt's epoch and + * is not a retry/TTSR/steering/successor of the aborted turn. The caller + * then invokes the existing followUp/prompt path. + */ + #resumeFromOwnedCompletion(): void { + // Allocate the fresh epoch from the SESSION prompt epoch, not the + // module-global counter (root admissions never advance it, so after a + // normal root prompt at epoch N the counter could still be <= N and mint + // the SAME lineage as the aborted turn — review thread P1). A + // session-relative +1 is always distinct from the current turn's epoch. + const freshEpoch = this.#promptGeneration + 1; + this.#promptGeneration = freshEpoch; + this.#turnLineageIdHash = mintTurnLineageIdHash( + this.sessionManager.getSessionId?.() ?? "local", + freshEpoch, + this.#terminalLineageSecret, + ); + } + /** + * Whether a same-turn continuation of the current turn is blocked by a + * terminal-abort fence. Fails open (false) when no terminal scope exists for + * the current lineage+epoch, so ordinary sessions never consult a gate. + * Post-close continuations (retry/TTSR/steering/hidden-next-turn/ + * maintenance/worker successor) are denied at the final synchronous boundary + * before method entry; a continuation already linearized as a predecessor + * before close stays allowed to finish. + */ + #isTurnContinuationBlocked(): boolean { + const lineageIdHash = this.#turnLineageIdHash; + if (!lineageIdHash) return false; + const scope = lookupTerminalScope(lineageIdHash, this.#promptGeneration); + if (!scope) return false; + return ( + scope.gate.authorizeContinuation({ + kind: "turn-continuation", + lineageIdHash, + attemptEpoch: this.#promptGeneration, + continuationId: crypto.randomUUID(), + }) === "deny" + ); + } #isPromptPreflightCancelled(generation: number, signal: AbortSignal): boolean { return signal.aborted || this.#promptGeneration !== generation; @@ -2678,16 +2766,54 @@ export class AgentSession { this.#bindWorkflowGateEmitter(); this.yieldQueue = new YieldQueue({ isStreaming: () => this.isStreaming || this.#handoffTransitionActive, - injectStreaming: message => this.agent.followUp(message), + injectStreaming: message => { + // Mandated boundary comment (corrected turn semantics): turn-scope + // abort blocks only deliveries whose origin is a continuation of the + // aborted turn. Owned-completion deliveries from work deliberately + // left running are intentionally allowed to resume the agent through + // the normal followUp/prompt path and receive a fresh turn attempt. + // A denied owned-completion entry (owned scope, forged tuple, or + // missing scope) is DROPPED here — it must never call followUp/prompt. + const action = ownedCompletionResumeAction(message); + if (action === "drop") return; + // Defer the fresh lineage allocation to the ACTUAL resume admission: + // while another prompt is streaming, followUp only queues, so + // mutating the session-wide epoch/lineage here would corrupt the + // ACTIVE turn's lineage if it is terminal-aborted meanwhile (review + // thread P2). The queued resume is admitted through + // #promptWithMessage (resetRetryReplaySafety), which allocates the + // fresh attempt epoch at turn start. The idle injector, which calls + // agent.prompt directly, allocates right before admission. + this.agent.followUp(message); + }, injectIdle: async messages => { - const first = messages[0]; + // Mandated boundary comment (corrected turn semantics): same origin + // split as the streaming injector — an allowed owned-completion + // delivery starts a fresh turn attempt/lineage and is not a + // continuation of the aborted turn. Denied owned-completion entries + // are dropped (mixed batches split before injection). + const survivors = messages.filter(message => ownedCompletionResumeAction(message) !== "drop"); + const first = survivors[0]; if (!first) return; await this.#awaitStartupTurnBarrier(); if (this.#isDisposed) return; - if (messages.length === 1) { + // A user prompt may have started during the barrier/scheduling + // delay: if the session is now streaming, mutating the epoch and + // lineage here would corrupt the ACTIVE user turn (and + // agent.prompt would then reject as busy, losing the drained + // completion). Route the survivors through followUp — the + // streaming injector's path — which allocates the fresh resume + // lineage at actual admission (review thread P1). + if (this.isStreaming) { + for (const message of survivors) this.agent.followUp(message); + return; + } + if (survivors.some(message => ownedCompletionResumeAction(message) === "fresh")) + this.#resumeFromOwnedCompletion(); + if (survivors.length === 1) { await this.agent.prompt(first, this.#managedFallbackPromptOptions()); } else { - await this.agent.prompt(messages, this.#managedFallbackPromptOptions()); + await this.agent.prompt(survivors, this.#managedFallbackPromptOptions()); } }, scheduleIdleFlush: run => { @@ -2779,6 +2905,73 @@ export class AgentSession { this.#providerCacheSessionId = config.providerCacheSessionId; // Per-tool TTSR reminders are folded into the matched tool's result via this hook. this.agent.afterToolCall = ctx => this.#ttsrAfterToolCall(ctx); + // Bind immutable lineage/attempt metadata to each tool call id before the + // tool executes. Background registrations made inside the tool (task, Bash) + // read this binding synchronously so their completion can later be + // classified as exact owned work instead of a turn continuation. Bindings + // intentionally survive the tool call: resumed registrations re-use the + // original tool call id and must retain the same owned-completion origin. + // They are superseded by a rebind on the same id or by bounded eviction. + this.agent.beforeToolCall = ctx => { + const lineageIdHash = this.#turnLineageIdHash; + if (lineageIdHash) { + bindToolLineage(ctx.toolCall.id, { + lineageIdHash, + promptAttemptEpoch: this.#promptGeneration, + endpointGeneration: this.#terminalEndpointGeneration, + }); + } + return undefined; + }; + // A queued owned-completion follow-up is consumed by the agent loop + // DIRECTLY (getFollowUpMessages), never through #promptWithMessage, so + // the fresh attempt/lineage promised for the resume is allocated HERE at + // actual resume admission — when the loop dequeues the follow-up for the + // next turn, the previously streaming turn has ended, so mutating the + // session-wide epoch/lineage is safe and its tools bind the fresh lineage. + this.agent.onFollowUpConsumed = messages => { + // A follow-up whose owned-completion origin is DENIED — an owned + // scope landed after the result was queued, or the tuple is + // forged/vanished-disabled — must NOT resume the agent: remove it + // from the dequeued batch before the loop processes it (review + // thread P1). This is the final consumption boundary for + // follow-up-delivered owned completions. + for (let i = messages.length - 1; i >= 0; i--) { + if (ownedCompletionResumeAction(messages[i]) === "drop") { + messages.splice(i, 1); + } + } + // An allowed owned-completion resume allocates the fresh lineage at + // actual admission (see the comment above). + if (messages.some(message => ownedCompletionResumeAction(message) === "fresh")) { + this.#resumeFromOwnedCompletion(); + } + // A monitor task-notification follow-up from a job stopped by + // scope:"owned" (ownedCompletionPolicy disabled) must NOT resume the + // agent: drop it at admission, mirroring the async-result drop + // semantics for the notification side channel (review thread P2). + // The notification is a follow-up, not an async-result, so it is not + // covered by the injectors' owned-drop path. + const manager = AsyncJobManager.instance(); + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + const details = (message as { details?: { taskId?: unknown; jobGeneration?: unknown } }).details; + const taskId = details?.taskId; + if (typeof taskId !== "string") continue; + // Prefer the generation carried on the notification (survives job + // eviction); fall back to the live manager record. + const carriedGeneration = typeof details?.jobGeneration === "string" ? details.jobGeneration : undefined; + const job = carriedGeneration ? undefined : manager?.getJob(taskId); + const generation = carriedGeneration ?? job?.generation; + if (!generation) continue; + const registration = lookupOwnedRegistration(taskId, generation); + if (!registration) continue; + const scope = lookupTerminalScope(registration.lineageIdHash, registration.promptAttemptEpoch); + if (scope && scope.fence.ownedCompletionPolicy === "disabled") { + messages.splice(i, 1); + } + } + }; this.agent.providerSessionState = this.#providerSessionState; this.#syncAgentSessionId(); this.#removeEphemeralCustomMessages(); @@ -4690,7 +4883,9 @@ export class AgentSession { skipCompactionCheck?: boolean; suppressPredecessorAgentEnd?: boolean; shouldContinue?: () => boolean; - onSkip?: (reason: "generation_changed" | "aborted_signal" | "queue_drained" | "handoff_in_progress") => void; + onSkip?: ( + reason: "generation_changed" | "aborted_signal" | "queue_drained" | "handoff_in_progress" | "terminal_turn", + ) => void; allowDuringCancelAndSubmit?: boolean; rescheduleOnBusy?: boolean; onError?: (error: unknown) => void; @@ -4701,7 +4896,9 @@ export class AgentSession { ? this.#reserveDeferredAgentEndForContinuation() : undefined; let terminalized = false; - const skip = (reason: "generation_changed" | "aborted_signal" | "queue_drained" | "handoff_in_progress") => { + const skip = ( + reason: "generation_changed" | "aborted_signal" | "queue_drained" | "handoff_in_progress" | "terminal_turn", + ) => { if (terminalized) return; terminalized = true; this.#releaseDeferredAgentEndContinuation(predecessorAgentEndHold); @@ -4767,6 +4964,14 @@ export class AgentSession { skip("queue_drained"); return; } + // A scheduled same-turn continuation of a terminally aborted turn is + // denied at the final synchronous boundary before agent.continue + // entry; no await intervenes between this check and method entry. + // Owned-completion deliveries are NOT affected (they use followUp). + if (this.#isTurnContinuationBlocked()) { + skip("terminal_turn"); + return; + } // A continuation scheduled before a handoff engaged must not start a // turn against the session being handed off (or the restored // predecessor). rearmIdle / normal delivery resumes after the fence. @@ -4934,6 +5139,13 @@ export class AgentSession { ); return false; } + // A same-turn auto-continue of a terminally aborted turn is denied + // (corrected turn semantics); owned-completion deliveries are not + // affected — they flow through followUp as fresh turns. + if (this.#isTurnContinuationBlocked()) { + this.#logCompactionContinuationSkipped("auto_continue_prompt", "terminal_turn"); + return false; + } const authorized = requireUnfinishedWork ? this.#hasUnfinishedWork(snapshot) || hasPendingNextTurnMessages : snapshot.queuedMessages || @@ -8622,9 +8834,38 @@ export class AgentSession { // session being handed off. this.#assertNoHandoffTransition(); this.#beginInFlight(); + // Discard hidden next-turn successors queued by a PREVIOUS turn that a + // terminal abort closed. This must run BEFORE the admission bump below: + // once the new root turn advances the epoch, the fence lookup can no + // longer find the aborted turn's scope, and the pending messages would + // otherwise be injected into this new prompt (review thread P2). + if (this.#pendingNextTurnMessages.length > 0 && this.#isTurnContinuationBlocked()) { + this.#pendingNextTurnMessages = []; + } + // NEW ROOT TURN: advance the attempt epoch before minting the lineage so + // consecutive non-aborted turns never share (lineageIdHash, epoch). A + // terminal abort of turn B must never capture turn A's left-running + // owned work, and turn A's completion must never classify as a resume of + // B. Same-turn continuations (auto-continue/retry, resetRetryReplaySafety + // unset) keep the turn's epoch and lineage. + if (options?.resetRetryReplaySafety) { + this.#promptGeneration++; + this.#promptPreflightAbortController.abort(); + this.#promptPreflightAbortController = new AbortController(); + } const predecessorAgentEndHold = options?.predecessorAgentEndHold ?? this.#reserveDeferredAgentEndForContinuation(); const generation = this.#promptGeneration; + // Mint the immutable lineage identity for this prompt turn before the + // model runs; beforeToolCall attaches this lineage + attempt epoch to + // each tool call id so background registrations can be classified by + // source later (terminal-abort owned-completion vs turn-continuation). + this.#turnLineageIdHash = mintTurnLineageIdHash( + this.sessionManager.getSessionId?.() ?? "local", + generation, + this.#terminalLineageSecret, + ); + const preflightSignal = this.#promptPreflightAbortController.signal; const rosterClaim = this.#claimIrcRosterCandidate(); let hasPendingNextTurnMessages = false; @@ -9259,6 +9500,18 @@ export class AgentSession { if (this.#pendingNextTurnMessages.length === 0) { return; } + // Terminal abort closed this turn's continuation fence: a hidden + // next-turn successor queued by the aborted turn must NOT start, + // even though the scheduler's generation check still passes + // (the abort preserves the epoch so the gate can find the scope). + if (this.#isTurnContinuationBlocked()) { + // Terminal abort closed this turn's continuation fence: + // DISCARD the hidden next-turn successors queued by the + // aborted turn so they cannot be drained into a later explicit + // prompt (review thread P2). + this.#pendingNextTurnMessages = []; + return; + } try { await this.#promptQueuedHiddenNextTurnMessages(); } catch { @@ -10022,20 +10275,101 @@ export class AgentSession { if (outcome.kind === "error") throw outcome.cause; } /** - * Abort a specific active prompt and prove whether its tracked resources settled. + * Private terminal-abort seam: read the CURRENT turn's attempt epoch WITHOUT + * interrupting it. Used to write the durable initial terminal marker BEFORE + * any fence/stop effect (plan ordered step 4). Only the epoch is exposed — + * never the opaque lineage handle — so no private origin metadata leaves the + * session. Fails closed (undefined) when no active turn lineage exists. */ - async abortPromptAndWait(handle: string, options: { graceMs: number }): Promise { + /** + * Private terminal-abort seam: cancel a PENDING (not-yet-started) prompt + * preflight. Aborting the preflight controller fires the captured admission + * signal so #throwIfPromptPreflightCancelled throws and the pending prompt + * never starts even if its SDK waiter was already settled; the controller is + * reset for the next admission. No run handle exists for a preflight prompt. + */ + cancelPendingPreflightForTerminalAbort(): void { + this.#promptPreflightAbortController.abort(); + this.#promptPreflightAbortController = new AbortController(); + } + + getTerminalTurnEpoch(): number | undefined { + const lineageIdHash = this.#turnLineageIdHash; + if (!lineageIdHash) return undefined; + return this.#promptGeneration; + } + async abortPromptAndWait( + handle: string, + options: { graceMs: number; terminal?: { scope: "turn" | "owned" } }, + ): Promise< + RunSettlementProof & { + terminalScope?: { scopeId: string; abortedAttemptEpoch: number; lineageIdHash: string }; + } + > { + let registeredScope: { scopeId: string; abortedAttemptEpoch: number; lineageIdHash: string } | undefined; + if (options.terminal) { + // Terminal abort (C04 mode:"terminal"): register and synchronously + // close the continuation fence for the current turn BEFORE the run is + // interrupted, so a later left-running owned completion classifies as + // owned-completion by exact source (lineage + attempt epoch). The + // owned-completion policy stays enabled for scope:"turn" — delivery + // intentionally resumes the agent — and disabled for scope:"owned". + // A missing lineage (no active turn) fails closed: no scope is + // registered, so nothing is attributed. + const lineageIdHash = this.#turnLineageIdHash; + if (lineageIdHash) { + const scope = registerTerminalTurnScope({ + lineageIdHash, + promptAttemptEpoch: this.#promptGeneration, + ownedCompletionPolicy: options.terminal.scope === "owned" ? "disabled" : "enabled", + }); + registeredScope = { + scopeId: scope.scopeId, + abortedAttemptEpoch: scope.promptAttemptEpoch, + lineageIdHash: scope.lineageIdHash, + }; + // Terminal abort blocks STEERING continuations of the aborted turn: + // purge steering queued just before the abort won (the loop can + // exit on the abort signal without polling it) so it cannot alter + // the next user turn (review thread P2). The follow-up queue is + // preserved — owned-completion resumes must still deliver. + this.agent.clearSteeringMessages(); + this.#steeringMessages = []; + } + // Do NOT advance the attempt epoch here: the terminal scope must stay + // keyed to the aborted epoch so #isTurnContinuationBlocked (which + // looks up by the CURRENT #promptGeneration) still finds the closed + // fence for hidden-next-turn and other non-generation-guarded + // continuation paths. Fresh-turn allocation is the per-admission + // responsibility: every NEW ROOT TURN (#promptWithMessage with + // resetRetryReplaySafety) advances the epoch before minting, so the + // next turn after the abort gets a distinct (lineage, epoch) and is + // never captured by this scope (review thread P2). + this.#promptPreflightAbortController.abort(); + this.#promptPreflightAbortController = new AbortController(); + } const aborted = this.#runCancellationDomains.abort(handle); if (!aborted.ok) { if (aborted.reason === "quarantined") { - return await this.agent.resourceLedger.waitForSettlement(handle, { graceMs: 0 }); + return { + ...(await this.agent.resourceLedger.waitForSettlement(handle, { graceMs: 0 })), + ...(registeredScope ? { terminalScope: registeredScope } : {}), + }; } - return { status: "unfenced", reason: "unknown_run", pending: [] }; + return { + status: "unfenced", + reason: "unknown_run", + pending: [], + ...(registeredScope ? { terminalScope: registeredScope } : {}), + }; } if (handle === this.agent.activeResourceRunId) this.agent.abort(); const proof = await this.agent.resourceLedger.waitForSettlement(handle, { graceMs: options.graceMs }); if (proof.status === "unfenced") this.agent.resourceLedger.quarantine(handle); - return proof; + return { + ...proof, + ...(registeredScope ? { terminalScope: registeredScope } : {}), + }; } /** Atomically interrupt the active run and make text the next prompt. */ diff --git a/packages/coding-agent/src/session/messages.ts b/packages/coding-agent/src/session/messages.ts index 169d56a86..404424a1d 100644 --- a/packages/coding-agent/src/session/messages.ts +++ b/packages/coding-agent/src/session/messages.ts @@ -122,7 +122,7 @@ export function readPendingDisplayTag(details: unknown): string | undefined { * the CustomMessageEntry to disk. Scoped intentionally narrow: only fields * declared here are stripped. Adding a new entry is a deliberate, reviewed * change — unrelated future payload fields are never silently dropped. */ -export const INTERNAL_DETAILS_FIELDS = ["__pendingDisplayTag"] as const; +export const INTERNAL_DETAILS_FIELDS = ["__pendingDisplayTag", "ownedCompletions"] as const; /** Return a `details` copy with every key in `INTERNAL_DETAILS_FIELDS` * removed. Returns the input unchanged when there is nothing to strip diff --git a/packages/coding-agent/src/session/terminal-abort.ts b/packages/coding-agent/src/session/terminal-abort.ts new file mode 100644 index 000000000..fde06c427 --- /dev/null +++ b/packages/coding-agent/src/session/terminal-abort.ts @@ -0,0 +1,624 @@ +/** + * Private terminal-abort machinery for C04 `turn.abort` `mode:"terminal"`. + * + * Corrected semantics (approved plan, user-directed; see the plan's prominent + * design note): `scope:"turn"` stops the ROOT WORKER's current turn and blocks + * ONLY its own continuation routes (same-turn retry, TTSR/`agent.continue`, + * steering continuation, hidden-next-turn, maintenance/worker successor, + * accepted-pre-close same-attempt continuation). Left-running owned work + * (background Bash/task jobs, detached subagents) keeps running and its + * completions are DELIVERED NORMALLY through the existing + * YieldQueue -> `agent.followUp`/`agent.prompt` path so the root worker can + * resume with a fresh attempt. Owned delivery is intentionally NOT suppressed. + * + * The earlier stage-04 no-successor delivery fence was a misunderstanding and + * must not be reinstated under any name. + */ +import { createHash, randomUUID } from "node:crypto"; + +/** Origin class assigned to every causal callback/queue entry before escape. */ +export type DeliveryOrigin = + | Readonly<{ + kind: "turn-continuation"; + lineageIdHash: string; + attemptEpoch: number; + continuationId: string; + }> + | Readonly<{ + kind: "owned-completion"; + lineageIdHash: string; + attemptEpoch: number; + registration: TurnRegistrationKey; + }> + | Readonly<{ kind: "ordinary"; source: string }>; + +/** Exact causal registration key bound before a job/subagent handle escapes. */ +export interface TurnRegistrationKey { + endpointGeneration: number; + lineageIdHash: string; + promptAttemptEpoch: number; + jobId: string; + jobGeneration: string; +} + +/** Per-completion delivery key: registration tuple plus entry identity. */ +export type TurnDeliveryKey = TurnRegistrationKey & { + entryId: string; + progressSeq?: number; +}; +/** Private origin envelope carried through the plain AgentMessage boundary. */ +export interface OwnedCompletionEnvelope { + lineageIdHash: string; + promptAttemptEpoch: number; + /** Exact registered five-tuple so the final gate can validate source authority. */ + registration: TurnRegistrationKey; +} + +export type TurnContinuationFenceState = "open" | "closing" | "closed" | "retained" | "released"; + +export type OwnedCompletionPolicy = "enabled" | "disabled"; + +/** + * Continuation fence lifecycle: `open -> closing -> closed` happens + * synchronously before the first await that interrupts the root turn. Closing + * records exact continuation tombstones and invalidates ONLY continuation + * tokens; it never invalidates an owned-completion token, cancels a manager + * job, or creates a turn delivery receipt. `retained` keeps tombstones for + * restart/later-owned binding; `released` requires exact tokens gone, teardown + * with no live continuation, or bounded durable retention. Host response + * success/replay/retry never releases it. + */ +export interface TurnContinuationFence { + state: TurnContinuationFenceState; + lineageIdHash: string; + abortedAttemptEpoch: number; + terminalScopeId: string; + blockedContinuationIds: ReadonlySet; + predecessorTombstones: ReadonlySet; + ownedCompletionPolicy: OwnedCompletionPolicy; +} + +/** + * The one gate consulted immediately before turn-origin continuation calls and + * owned-completion admission. + * + * `authorizeContinuation` denies any post-close same-turn continuation and + * allows only a call already linearized as a predecessor before close. + * `authorizeOwnedCompletion` does NOT consult the closed continuation state as + * a suppression flag; it validates exact source metadata and, when allowed, + * AgentSession allocates a FRESH attempt/lineage for the new turn. + */ +export interface TurnContinuationGate { + close(reason: "terminal-turn"): void; + authorizeContinuation(origin: DeliveryOrigin): "deny" | "allow-predecessor"; + authorizeOwnedCompletion(origin: DeliveryOrigin): "allow-new-turn" | "deny"; +} + +export type OwnedDeliverySettlementPath = + | "enqueue-acknowledged-return" + | "acknowledgeDeliveries-queue-purge" + | "delivery-loop-acknowledged-skip" + | "deliverDelivery-acknowledged-return" + | "terminal-wait-acknowledge-suppression-purge" + | "filtered-drain-post-selection-suppression"; + +/** Owned-scope-only settlement observer (never installed for turn scope). */ +export type OwnedDeliverySettlementObserver = (event: { + key: TurnDeliveryKey; + path: OwnedDeliverySettlementPath; + action: "owned_settled" | "owned_absent"; +}) => void; + +/** Safe, bounded reasons surfaced on `terminal_uncertain` responses. */ +export const TERMINAL_UNCERTAIN_REASONS = [ + "persistence_unavailable", + "publication_failed", + "delivery_failed", + "owned_unsettled", + "worker_unsettled", + "unknown_origin", + "registration_authority_unavailable", +] as const; +export type TerminalUncertainReason = (typeof TERMINAL_UNCERTAIN_REASONS)[number]; + +export interface TerminalScopeDispositions { + selection: "turn" | "owned"; + turnDisposition: "pending" | "stopped" | "uncertain"; + ownedWorkDisposition: "not_requested" | "left_running" | "stopped" | "uncertain"; + automaticDeliveryDisposition: "enabled" | "none"; + resumeOnOwnedCompletion: boolean; +} +export interface ActiveTerminalScope { + scopeId: string; + lineageIdHash: string; + abortedAttemptEpoch: number; + gate: TurnContinuationGate; + fence: TurnContinuationFence; +} + +const MAX_ACTIVE_TERMINAL_SCOPES = 1024; +const MAX_OWNED_REGISTRATIONS = 8192; +const MAX_RETAINED_ATTEMPT_POLICIES = 2048; +const activeScopes = new Map(); +const activeScopeByAttempt = new Map(); +const ownedRegistrations = new Map(); +/** + * Compact attempt-policy tombstones for scopes evicted by the cap: when a + * scope is evicted, its ownedCompletionPolicy is retained so a still-running + * owned completion from that attempt keeps classifying correctly (fresh + * resume for scope:"turn", drop for scope:"owned") instead of degrading to + * ordinary (review thread P2). Bounded; oldest tombstone evicted first. + */ +const retainedAttemptPolicies = new Map(); + +/** Register one active terminal scope (scopeId -> seam). Bounded; evicts oldest. */ +export function registerTerminalScope(scope: ActiveTerminalScope): void { + if (activeScopes.size >= MAX_ACTIVE_TERMINAL_SCOPES) { + const oldest = activeScopes.keys().next().value; + if (oldest !== undefined) { + const evicted = activeScopes.get(oldest); + if (evicted) { + retainedAttemptPolicies.set(`${evicted.lineageIdHash}\u0000${evicted.abortedAttemptEpoch}`, { + ownedCompletionPolicy: evicted.fence.ownedCompletionPolicy, + }); + if (retainedAttemptPolicies.size > MAX_RETAINED_ATTEMPT_POLICIES) { + const oldestPolicy = retainedAttemptPolicies.keys().next().value; + if (oldestPolicy !== undefined) retainedAttemptPolicies.delete(oldestPolicy); + } + } + unregisterTerminalScope(oldest); + } + } + activeScopes.set(scope.scopeId, scope); + activeScopeByAttempt.set(`${scope.lineageIdHash}\u0000${scope.abortedAttemptEpoch}`, scope.scopeId); +} + +/** Look up the active terminal scope for an aborted attempt (exact lineage+epoch). */ +export function lookupTerminalScope(lineageIdHash: string, attemptEpoch: number): ActiveTerminalScope | undefined { + const scopeId = activeScopeByAttempt.get(`${lineageIdHash}\u0000${attemptEpoch}`); + return scopeId === undefined ? undefined : activeScopes.get(scopeId); +} + +export function unregisterTerminalScope(scopeId: string): void { + const scope = activeScopes.get(scopeId); + if (!scope) return; + activeScopes.delete(scopeId); + activeScopeByAttempt.delete(`${scope.lineageIdHash}\u0000${scope.abortedAttemptEpoch}`); +} + +/** Record an exact owned registration before its handle escapes (bounded). */ +export function registerOwnedRegistration(key: TurnRegistrationKey): void { + const mapKey = `${key.jobId}\u0000${key.jobGeneration}`; + const existing = ownedRegistrations.get(mapKey); + // Idempotent re-registration of the SAME turn is a no-op, but a reused + // (jobId, jobGeneration) tuple with a DIFFERENT lineage/epoch is a stale + // entry from a replaced session or fresh manager (job ids restart at + // bg_1/job:1) and must OVERWRITE so the new job binds to its own turn + // (review thread P1). + if ( + existing && + existing.lineageIdHash === key.lineageIdHash && + existing.promptAttemptEpoch === key.promptAttemptEpoch + ) { + return; + } + if (ownedRegistrations.size >= MAX_OWNED_REGISTRATIONS) { + const oldest = ownedRegistrations.keys().next().value; + if (oldest !== undefined) ownedRegistrations.delete(oldest); + } + ownedRegistrations.set(mapKey, key); +} + +/** Exact (jobId, jobGeneration) lookup for completion-origin classification. */ +export function lookupOwnedRegistration(jobId: string, jobGeneration: string): TurnRegistrationKey | undefined { + return ownedRegistrations.get(`${jobId}\u0000${jobGeneration}`); +} + +export function unregisterOwnedRegistration(key: TurnRegistrationKey): void { + ownedRegistrations.delete(`${key.jobId}\u0000${key.jobGeneration}`); +} +/** + * Enumerate every exact owned registration belonging to one aborted turn + * (matching lineage + attempt epoch). Used by `scope:"owned"` cleanup to + * capture the exact causal job set; foreign/unclassified work is never + * returned and is never swept. + */ +export function findOwnedRegistrationsForTurn(lineageIdHash: string, attemptEpoch: number): TurnRegistrationKey[] { + const matches: TurnRegistrationKey[] = []; + for (const key of ownedRegistrations.values()) { + if (key.lineageIdHash === lineageIdHash && key.promptAttemptEpoch === attemptEpoch) { + matches.push(key); + } + } + return matches; +} +export interface OwnedCompletionClassification { + lineageIdHash: string; + promptAttemptEpoch: number; + registration: TurnRegistrationKey; + terminalScopeId: string; +} + +/** + * Classify a manager completion/progress delivery against the terminal-abort + * registries. Returns an exact owned-completion classification ONLY when the + * job carries an exact registered five-tuple AND a terminal scope exists for + * that turn. Missing or mismatched metadata fails closed (undefined) and the + * delivery is then ordinary. Classification is source/lineage-based, never + * timing-based; a closed terminal record does NOT suppress an exact + * left-running owned completion (corrected turn semantics). + */ +export function classifyOwnedCompletion( + jobId: string, + jobGeneration: string | undefined, +): OwnedCompletionClassification | undefined { + if (!jobGeneration) return undefined; + const registration = lookupOwnedRegistration(jobId, jobGeneration); + if (!registration) return undefined; + const scope = lookupTerminalScope(registration.lineageIdHash, registration.promptAttemptEpoch); + if (!scope) return undefined; + return { + lineageIdHash: registration.lineageIdHash, + promptAttemptEpoch: registration.promptAttemptEpoch, + registration, + terminalScopeId: scope.scopeId, + }; +} +/** + * Whether an owned-completion envelope is authorized by its owning terminal + * scope as a fresh-turn resume. Used at batch build (sdk/session.ts) and the + * final injection boundary (agent-session.ts): a denied envelope — owned scope + * (policy disabled), forged/unregistered tuple, or vanished scope — must be + * dropped/partitioned out so stopped work can never call followUp/prompt. + */ +/** + * Classify an owned-completion envelope into three states. A registration + * without a terminal scope (no abort yet) is ORDINARY — normal delivery; + * only a scope with the owned policy disabled DROPS, and a turn-scope + * enabled policy is FRESH (new-turn resume). This lets the batch keep + * ownership on the entry and reclassify at flush/abort time (review + * thread P1: a completion finished before the abort must not become an + * unpurgeable ordinary entry that can still resume the agent). + */ +export function classifyOwnedEnvelope(envelope: OwnedCompletionEnvelope): "ordinary" | "fresh" | "drop" { + const scope = lookupTerminalScope(envelope.lineageIdHash, envelope.promptAttemptEpoch); + if (scope) { + return scope.gate.authorizeOwnedCompletion({ + kind: "owned-completion", + lineageIdHash: envelope.lineageIdHash, + attemptEpoch: envelope.promptAttemptEpoch, + registration: envelope.registration, + }) === "allow-new-turn" + ? "fresh" + : "drop"; + } + // The scope was evicted by the cap but its attempt-policy tombstone + // survives: classify by the retained policy while the registration is + // still exact (review thread P2). + const retained = retainedAttemptPolicies.get(`${envelope.lineageIdHash}\u0000${envelope.promptAttemptEpoch}`); + if (!retained) return "ordinary"; + if (retained.ownedCompletionPolicy === "disabled") return "drop"; + const registered = lookupOwnedRegistration(envelope.registration.jobId, envelope.registration.jobGeneration); + if (!registered) return "ordinary"; + return "fresh"; +} + +/** Whether an envelope must be kept in the batch (not an owned-scope drop). */ +export function isOwnedCompletionEnvelopeAllowed(envelope: OwnedCompletionEnvelope): boolean { + return classifyOwnedEnvelope(envelope) !== "drop"; +} +/** Structural subset of AsyncJobManager used by owned-stop settlement (avoids an import cycle). */ +export interface OwnedStopManager { + cancel(jobId: string): boolean; + getJob(jobId: string): { generation?: string; status?: string } | undefined; + acknowledgeDeliveries(jobIds: string[]): number; +} + +/** + * Settle exact owned work for `scope:"owned"`: generation-verified cancel, a + * fixed grace, a second quiescence proof, then a delivery purge. Returns + * "stopped" only when every captured job is terminal after the grace; a reused + * job id with a new generation, a missing/evicted record, or still-running/ + * paused work fails closed to "unsettled" (AC 16/36 — foreign work is never + * swept and unprovable quiescence never claims stopped). + */ +export async function settleOwnedWork( + manager: OwnedStopManager, + exactJobs: TurnRegistrationKey[], + graceMs: number, +): Promise<"stopped" | "unsettled"> { + const generationExact = exactJobs.every(reg => { + const live = manager.getJob(reg.jobId); + if (live !== undefined && live.generation !== reg.jobGeneration) return false; + manager.cancel(reg.jobId); + return true; + }); + if (!generationExact) return "unsettled"; + await Bun.sleep(graceMs); + // Second proof: every captured job must still be the EXACT captured + // generation and terminal (cancelled/completed/failed). A reused job id + // with a NEW generation during the grace, a missing/evicted record, or a + // still-running/paused job fails closed — foreign work is never swept and + // unprovable quiescence never claims stopped. + const quiescent = exactJobs.every(reg => { + const job = manager.getJob(reg.jobId); + return ( + job !== undefined && + job.generation === reg.jobGeneration && + job.status !== "running" && + job.status !== "paused" + ); + }); + if (!quiescent) return "unsettled"; + manager.acknowledgeDeliveries(exactJobs.map(reg => reg.jobId)); + return "stopped"; +} +export interface LineageBinding { + lineageIdHash: string; + promptAttemptEpoch: number; + endpointGeneration: number; +} + +const MAX_LINEAGE_BINDINGS = 8192; +const lineageByToolCall = new Map(); + +/** + * Bind immutable lineage/attempt metadata to an attempt-scoped tool call + * identity (toolCallId). The binding is set once at prompt admission and must + * never be mutated from a session-current fallback; missing/mismatched + * context fails closed (resolve returns undefined). + */ +export function bindToolLineage(toolCallId: string, binding: LineageBinding): void { + if (lineageByToolCall.size >= MAX_LINEAGE_BINDINGS) { + const oldest = lineageByToolCall.keys().next().value; + if (oldest !== undefined) lineageByToolCall.delete(oldest); + } + lineageByToolCall.set(toolCallId, binding); +} + +export function resolveToolLineage(toolCallId: string | undefined): LineageBinding | undefined { + return toolCallId === undefined ? undefined : lineageByToolCall.get(toolCallId); +} + +export function unbindToolLineage(toolCallId: string): void { + lineageByToolCall.delete(toolCallId); +} + +/** + * Mint an unforgeable opaque lineage id for one prompt turn. The hash binds + * session id, attempt epoch, and a per-session secret; it never contains + * prompt body and cannot be re-derived from public session data. It is + * created before model/tool execution and must never be mutated from a + * session-current fallback. + */ +export function mintTurnLineageIdHash(sessionId: string, promptAttemptEpoch: number, sessionSecret: string): string { + return createHash("sha256") + .update(`turn-lineage-v1:${sessionId}\u0000${promptAttemptEpoch}\u0000${sessionSecret}`) + .digest("hex"); +} +/** + * Register an exact owned registration when the tool call carries immutable + * lineage metadata. The generation is read synchronously from the manager's + * job record; a missing generation fails closed (no ownership claim). A + * registry failure never breaks ordinary registration. + */ +export function registerOwnedIfLineaged( + manager: { getJob?(id: string): { generation?: string } | undefined }, + toolCallId: string | undefined, + jobId: string, +): void { + try { + const lineage = resolveToolLineage(toolCallId); + if (!lineage) return; + const jobGeneration = manager.getJob?.(jobId)?.generation; + if (!jobGeneration) return; + registerOwnedRegistration({ + endpointGeneration: lineage.endpointGeneration, + lineageIdHash: lineage.lineageIdHash, + promptAttemptEpoch: lineage.promptAttemptEpoch, + jobId, + jobGeneration, + }); + } catch { + // ignore: never break ordinary registration + } +} + +let attemptEpochCounter = 0; + +/** Monotonic fresh-attempt epoch for `resumeFromOwnedCompletion` allocation. */ +export function nextPromptAttemptEpoch(): number { + return ++attemptEpochCounter; +} + +/** Mint a fresh terminal scope id (opaque, never persisted raw). */ +export function newTerminalScopeId(): string { + return randomUUID(); +} + +export interface TurnContinuationSeam { + fence: TurnContinuationFence; + gate: TurnContinuationGate; +} + +/** + * Create a continuation fence + gate for one terminal scope. The fence starts + * `open` and is closed synchronously via `gate.close()` before the root turn is + * interrupted. Continuation authorization is source-based (lineageIdHash + + * attemptEpoch + continuationId); timing alone never authorizes. + */ +export function createTurnContinuationSeam(options: { + lineageIdHash: string; + abortedAttemptEpoch: number; + terminalScopeId: string; + ownedCompletionPolicy?: OwnedCompletionPolicy; + blockedContinuationIds?: readonly string[]; +}): TurnContinuationSeam { + const blocked = new Set(options.blockedContinuationIds ?? []); + const predecessors = new Set(); + let state: TurnContinuationFenceState = "open"; + + const fence: TurnContinuationFence = { + state: "open", + lineageIdHash: options.lineageIdHash, + abortedAttemptEpoch: options.abortedAttemptEpoch, + terminalScopeId: options.terminalScopeId, + blockedContinuationIds: blocked, + predecessorTombstones: predecessors, + ownedCompletionPolicy: options.ownedCompletionPolicy ?? "enabled", + }; + + const gate: TurnContinuationGate = { + close(_reason: "terminal-turn") { + if (state === "closing" || state === "closed") return; + state = "closing"; + state = "closed"; + fence.state = state; + }, + authorizeContinuation(origin) { + if (origin.kind !== "turn-continuation") return "deny"; + if (origin.lineageIdHash !== fence.lineageIdHash || origin.attemptEpoch !== fence.abortedAttemptEpoch) + return "deny"; + // A call linearized BEFORE close is a predecessor: record it once and + // allow it to finish its already-started work; it must never start a + // successor. After close, only recorded predecessors pass; every other + // same-turn continuation (retry/TTSR/steering/hidden/maintenance) is + // denied. + if (state === "open" || state === "closing") { + predecessors.add(origin.continuationId); + return "allow-predecessor"; + } + return predecessors.has(origin.continuationId) ? "allow-predecessor" : "deny"; + }, + authorizeOwnedCompletion(origin) { + // Owned completion is intentionally NOT suppressed by a closed turn + // record. Validate exact source metadata and fail closed otherwise. + if (origin.kind !== "owned-completion") return "deny"; + if (!origin.registration || typeof origin.registration !== "object") return "deny"; + if (origin.lineageIdHash !== fence.lineageIdHash) return "deny"; + if (origin.attemptEpoch !== fence.abortedAttemptEpoch) return "deny"; + const { endpointGeneration, promptAttemptEpoch, jobId, jobGeneration } = origin.registration; + if (promptAttemptEpoch !== fence.abortedAttemptEpoch) return "deny"; + if (fence.ownedCompletionPolicy === "disabled") return "deny"; + if ( + !Number.isFinite(endpointGeneration) || + typeof jobId !== "string" || + !jobId || + typeof jobGeneration !== "string" || + !jobGeneration + ) + return "deny"; + // The tuple must be an EXACT registered five-tuple: an unregistered, + // forged, or mutated registration fails closed even when the outer + // lineage/epoch match the aborted turn (AC 25 — missing/copied/ + // mismatched origin never authorizes an automatic call). + const registered = lookupOwnedRegistration(jobId, jobGeneration); + if (!registered) return "deny"; + if ( + registered.lineageIdHash !== origin.lineageIdHash || + registered.promptAttemptEpoch !== promptAttemptEpoch || + registered.endpointGeneration !== endpointGeneration + ) + return "deny"; + return "allow-new-turn"; + }, + }; + + return { fence, gate }; +} +export interface RegisteredTerminalScope { + scopeId: string; + lineageIdHash: string; + promptAttemptEpoch: number; + seam: TurnContinuationSeam; +} + +/** + * Create, register, and synchronously close a terminal scope for one aborted + * turn. The fence closes before the first await that interrupts the root turn; + * owned-completion policy is enabled for `scope:"turn"` (left-running owned + * delivery intentionally resumes the agent as a fresh turn) and disabled for + * `scope:"owned"`. Registered scopes are process-local and bounded; the exact + * (lineageIdHash, attemptEpoch) key makes later owned-completion classification + * source-exact and fail-closed. + */ +export function registerTerminalTurnScope(options: { + lineageIdHash: string; + promptAttemptEpoch: number; + terminalScopeId?: string; + ownedCompletionPolicy?: OwnedCompletionPolicy; + blockedContinuationIds?: readonly string[]; +}): RegisteredTerminalScope { + const terminalScopeId = options.terminalScopeId ?? newTerminalScopeId(); + const seam = createTurnContinuationSeam({ + lineageIdHash: options.lineageIdHash, + abortedAttemptEpoch: options.promptAttemptEpoch, + terminalScopeId, + ownedCompletionPolicy: options.ownedCompletionPolicy, + blockedContinuationIds: options.blockedContinuationIds, + }); + seam.gate.close("terminal-turn"); + registerTerminalScope({ + scopeId: terminalScopeId, + lineageIdHash: options.lineageIdHash, + abortedAttemptEpoch: options.promptAttemptEpoch, + gate: seam.gate, + fence: seam.fence, + }); + return { + scopeId: terminalScopeId, + lineageIdHash: options.lineageIdHash, + promptAttemptEpoch: options.promptAttemptEpoch, + seam, + }; +} + +/** + * TEST-ONLY: clear the module-global terminal-abort registries so tests get + * isolated lineage/binding/scope state. Never call from production code — + * the registries are intentionally process-lifetime in the runtime. + */ +export function resetTerminalAbortRegistriesForTests(): void { + activeScopes.clear(); + activeScopeByAttempt.clear(); + ownedRegistrations.clear(); + lineageByToolCall.clear(); +} + +/** + * Structural subset of a durable terminal-scope row needed for bounding + * retention (review thread P2). Only rows with a COMPLETED disposition are + * evictable; pending markers are never touched. + */ +export interface DurableScopeRetentionRow { + idempotencyKeyHash?: string; + idempotencyInputHash?: string; + turnDisposition: "pending" | "no_effect" | (string & {}); + acceptedAt?: number; +} + +/** + * Evict the OLDEST COMPLETED terminal-scope rows beyond `cap`, mirroring the + * bounded in-memory idempotency cache so a long-lived session cannot grow + * the durable reconciliation document indefinitely. Completed dispositions + * (stopped/uncertain/no_effect) are evicted oldest-first; pending markers + * (an in-flight abort) are never evicted. Returns a new array. + */ +export function boundCompletedTerminalScopeRows(rows: T[], cap: number): T[] { + const completed = rows.filter(s => s.turnDisposition !== "pending"); + if (completed.length <= cap) return rows; + const overflow = completed.length - cap; + const evict = new Set( + [...completed] + .sort((a, b) => (a.acceptedAt ?? 0) - (b.acceptedAt ?? 0)) + .slice(0, overflow) + .map(s => `${s.idempotencyKeyHash ?? ""}\u0000${s.idempotencyInputHash ?? ""}`), + ); + return rows.filter( + s => + !( + s.turnDisposition !== "pending" && + evict.has(`${s.idempotencyKeyHash ?? ""}\u0000${s.idempotencyInputHash ?? ""}`) + ), + ); +} diff --git a/packages/coding-agent/src/session/yield-queue.ts b/packages/coding-agent/src/session/yield-queue.ts index 964e139fd..f1dfd08a5 100644 --- a/packages/coding-agent/src/session/yield-queue.ts +++ b/packages/coding-agent/src/session/yield-queue.ts @@ -4,6 +4,13 @@ import { logger } from "@gajae-code/utils"; export interface YieldDispatcher

{ /** Drop entries already delivered through another path. Called per-entry at flush time. */ isStale?(entry: P): boolean; + /** + * Optional ownership-origin key: when provided, the flush builds ONE + * message per distinct key instead of one message for the whole batch, so + * a later scope:"owned" drop of one origin never suppresses entries of + * another origin (review thread P2). + */ + groupKey?(entry: P): string; /** Produce one batched AgentMessage from non-stale entries. Return null to skip. */ build(survivors: P[]): AgentMessage | null; } @@ -19,6 +26,7 @@ type YieldFlushMode = "streaming" | "idle"; interface StoredDispatcher { isStale?: (entry: unknown) => boolean; + groupKey?: (entry: unknown) => string; build: (survivors: unknown[]) => AgentMessage | null; } @@ -39,6 +47,7 @@ export class YieldQueue { register

(kind: string, dispatcher: YieldDispatcher

): () => void { const stored: StoredDispatcher = { ...(dispatcher.isStale ? { isStale: entry => dispatcher.isStale?.(entry as P) ?? false } : {}), + ...(dispatcher.groupKey ? { groupKey: entry => dispatcher.groupKey?.(entry as P) ?? "default" } : {}), build: survivors => dispatcher.build(survivors as P[]), }; this.#dispatchers.set(kind, stored); @@ -81,16 +90,17 @@ export class YieldQueue { for (const [kind, dispatcher] of this.#dispatchers) { const entries = this.#drain(kind); if (entries.length === 0) continue; - const message = this.#build(kind, dispatcher, entries); - if (!message) continue; - if (mode === "streaming") { - try { - this.#options.injectStreaming(message); - } catch (error) { - logger.warn("Yield queue streaming dispatch failed", { kind, error: formatError(error) }); + const messages = this.#build(kind, dispatcher, entries) ?? []; + for (const message of messages) { + if (mode === "streaming") { + try { + this.#options.injectStreaming(message); + } catch (error) { + logger.warn("Yield queue streaming dispatch failed", { kind, error: formatError(error) }); + } + } else { + idleMessages.push(message); } - } else { - idleMessages.push(message); } } if (mode === "idle" && idleMessages.length > 0) { @@ -149,7 +159,16 @@ export class YieldQueue { return entries; } - #build(kind: string, dispatcher: StoredDispatcher, entries: unknown[]): AgentMessage | null { + #build(kind: string, dispatcher: StoredDispatcher, entries: unknown[]): AgentMessage[] | null { + // Corrected turn semantics (terminal abort): turn-scope abort blocks only + // deliveries whose origin is a continuation of the aborted turn. + // Owned-completion deliveries from work deliberately left running are + // intentionally allowed to resume the agent through the normal + // followUp/prompt path and receive a fresh turn attempt. A closed + // terminal record must never make an allowed owned-completion entry + // stale merely because it is closed; stale filtering below applies only + // to ordinary manager state (e.g. isDeliverySuppressed) or explicit + // blocked-continuation/owned-cleanup entries. const survivors: unknown[] = []; for (const entry of entries) { if (dispatcher.isStale) { @@ -165,11 +184,25 @@ export class YieldQueue { survivors.push(entry); } if (survivors.length === 0) return null; - try { - return dispatcher.build(survivors); - } catch (error) { - logger.warn("Yield queue build failed", { kind, error: formatError(error) }); - return null; + // Build one message per ownership-origin group (when the dispatcher + // declares a groupKey) so a later owned-scope drop of one group never + // suppresses another group's entries. + const groups = new Map(); + for (const entry of survivors) { + const key = dispatcher.groupKey ? dispatcher.groupKey(entry) : "default"; + const group = groups.get(key); + if (group) group.push(entry); + else groups.set(key, [entry]); + } + const messages: AgentMessage[] = []; + for (const group of groups.values()) { + try { + const message = dispatcher.build(group); + if (message) messages.push(message); + } catch (error) { + logger.warn("Yield queue build failed", { kind, error: formatError(error) }); + } } + return messages.length > 0 ? messages : null; } } diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 45dfcb8da..f52666fb5 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -1856,6 +1856,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise session.activePromptHandle, abort: () => session.abort(), abortPromptAndWait: (handle, options) => session.abortPromptAndWait(handle, options), + getTerminalTurnEpoch: () => session.getTerminalTurnEpoch(), + cancelPendingPreflightForTerminalAbort: () => session.cancelPendingPreflightForTerminalAbort(), hasPendingMessages: () => session.queuedMessageCount > 0, getPendingMessageCounts: () => session.pendingMessageCounts, getTranscript: () => session.getTranscript(), diff --git a/packages/coding-agent/src/task/index.ts b/packages/coding-agent/src/task/index.ts index 1b1a6128b..128c29f91 100644 --- a/packages/coding-agent/src/task/index.ts +++ b/packages/coding-agent/src/task/index.ts @@ -55,6 +55,7 @@ import { } from "../gjc-runtime/repository-binding"; import { initializeLocalRoot, type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls"; import { ArtifactManager } from "../session/artifacts"; +import { registerOwnedIfLineaged } from "../session/terminal-abort"; import { generateCommitMessage } from "../utils/commit-message-generator"; import * as git from "../utils/git"; import { discoverAgents, filterVisibleAgents, getAgent } from "./discovery"; @@ -65,7 +66,6 @@ import { getTaskIdValidationError, validateAllocatedTaskId } from "./id"; import { AgentOutputManager } from "./output-manager"; import { mapWithConcurrencyLimit, Semaphore } from "./parallel"; import { assertNoRawTaskFields, buildTaskReceipt, buildTaskRoiSummary, type TaskResultReceipt } from "./receipt"; - import { renderResult, renderCall as renderTaskCall } from "./render"; import { reconcileSpawnRoi } from "./roi-reconciliation"; import { getTaskSimpleModeCapabilities, type TaskSimpleMode } from "./simple-mode"; @@ -1065,7 +1065,7 @@ export class TaskTool implements AgentTool { @@ -1127,6 +1127,8 @@ export class TaskTool implements AgentTool { resolvedEnv?: Record; onUpdate?: AgentToolUpdateCallback; startBackgrounded: boolean; + /** Immutable attempt-scoped tool call id, when executed via a tool call. */ + toolCallId?: string; }): ManagedBashJobHandle { const manager = AsyncJobManager.instance(); if (!manager) { @@ -839,6 +842,7 @@ export class BashTool implements AgentTool { }, }, ); + registerOwnedIfLineaged(manager, options.toolCallId, jobId); return { jobId, @@ -1075,6 +1079,7 @@ export class BashTool implements AgentTool { ownerId?: string; label?: string; ctx?: AgentToolContext; + toolCallId?: string; onRawLine?: (line: string, jobId: string) => void; shouldAcceptRawLine?: (jobId: string) => boolean; lifecycle?: import("../async").AsyncJobLifecycleCleanup; @@ -1178,12 +1183,16 @@ export class BashTool implements AgentTool { }, { ownerId, metadata: { monitor: true }, lifecycle: opts.lifecycle }, ); + // Monitor jobs are exact owned background work of the turn that started + // them: register the five-tuple so scope:"owned" terminal abort stops the + // monitor too (review thread P2). + registerOwnedIfLineaged(manager, opts.toolCallId, jobId); currentJobId = jobId; return { jobId, label, commandCwd: prepared.commandCwd }; } async execute( - _toolCallId: string, + toolCallId: string, { command: rawCommand, env: rawEnv, @@ -1233,6 +1242,7 @@ export class BashTool implements AgentTool { resolvedEnv, onUpdate, startBackgrounded: true, + toolCallId, }); return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, { requestedTimeoutSec, @@ -1268,6 +1278,7 @@ export class BashTool implements AgentTool { resolvedEnv, onUpdate, startBackgrounded, + toolCallId, }); if (startBackgrounded) { return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, { diff --git a/packages/coding-agent/src/tools/monitor.ts b/packages/coding-agent/src/tools/monitor.ts index 81666d799..008f7d9e8 100644 --- a/packages/coding-agent/src/tools/monitor.ts +++ b/packages/coding-agent/src/tools/monitor.ts @@ -106,7 +106,7 @@ export class MonitorTool implements AgentTool, @@ -171,6 +171,10 @@ export class MonitorTool implements AgentTool\nMonitor task ${jobId} (${params.kind}: ${params.description}) emitted latest state:\n${notificationLine.content}${suffix}\n`; const details = { taskId: jobId, + // Carry the job generation so the owned-scope admission filter can + // attribute the notification even after the job record is evicted + // from the manager (review thread P2). + ...(manager.getJob(jobId)?.generation ? { jobGeneration: manager.getJob(jobId)!.generation } : {}), kind: params.kind, description: params.description, monitor: true, @@ -219,6 +223,7 @@ export class MonitorTool implements AgentTool !controller.closed, lifecycle: { onCancel: () => closeMonitor("purge"), diff --git a/packages/coding-agent/test/agent-session-terminal-abort-chain.test.ts b/packages/coding-agent/test/agent-session-terminal-abort-chain.test.ts new file mode 100644 index 000000000..e984c2c50 --- /dev/null +++ b/packages/coding-agent/test/agent-session-terminal-abort-chain.test.ts @@ -0,0 +1,333 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Agent, type AgentTool } from "@gajae-code/agent-core"; +import { getBundledModel } from "@gajae-code/ai"; +import { createMockModel, type MockResponse } from "@gajae-code/ai/providers/mock"; +import { ModelRegistry } from "@gajae-code/coding-agent/config/model-registry"; +import { resetSettingsForTest, Settings } from "@gajae-code/coding-agent/config/settings"; +import { AgentSession } from "@gajae-code/coding-agent/session/agent-session"; +import { AuthStorage } from "@gajae-code/coding-agent/session/auth-storage"; +import { convertToLlm } from "@gajae-code/coding-agent/session/messages"; +import { SessionManager } from "@gajae-code/coding-agent/session/session-manager"; +import { + bindToolLineage, + classifyOwnedCompletion, + lookupOwnedRegistration, + resetTerminalAbortRegistriesForTests, +} from "@gajae-code/coding-agent/session/terminal-abort"; +import { BashTool, type ToolSession } from "@gajae-code/coding-agent/tools"; +import { Snowflake } from "@gajae-code/utils"; +import { AsyncJobManager } from "../src/async"; + +/** Scripted assistant turn that issues a single `bash` tool call. */ +function bashCall(command: string, callId: string): MockResponse { + return { + content: [{ type: "toolCall", id: callId, name: "bash", arguments: { command, timeout: 10 } }], + stopReason: "toolUse", + }; +} + +/** Scripted plain-text assistant turn with `stopReason: "stop"`. */ +function stopReply(text: string): MockResponse { + return { + content: [{ type: "text", text }], + stopReason: "stop", + }; +} + +async function waitFor(predicate: () => boolean, label: string, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > deadline) throw new Error(`Timed out waiting for ${label}`); + await Bun.sleep(10); + } +} + +describe("terminal abort registers a turn scope so left-running owned work classifies by source", () => { + let session: AgentSession; + let tempDir: string; + let authStorage: AuthStorage | undefined; + let scriptedResponses: MockResponse[]; + let manager: AsyncJobManager; + let bashToolRef: BashTool; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `pi-terminal-abort-chain-${Snowflake.next()}`); + fs.mkdirSync(tempDir, { recursive: true }); + + resetSettingsForTest(); + await Settings.init({ inMemory: true, cwd: tempDir }); + + authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db")); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("expected claude-sonnet-4-5 to be bundled"); + + const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); + const settings = Settings.isolated({ + "compaction.enabled": false, + "todo.enabled": false, + "todo.eager": false, + "todo.reminders": false, + // The managed async-job path must be live so BashTool registers jobs + // and the terminal-abort lineage binding is captured. + "async.enabled": true, + "bash.autoBackground.enabled": true, + }); + const sessionManager = SessionManager.inMemory(tempDir); + + const toolSession: ToolSession = { + cwd: tempDir, + hasUI: false, + settings, + getSessionFile: () => sessionManager.getSessionFile() ?? null, + getSessionId: () => sessionManager.getSessionId?.() ?? null, + getSessionSpawns: () => "*", + }; + const bashTool = new BashTool(toolSession); + bashToolRef = bashTool; + + scriptedResponses = []; + + const mock = createMockModel({ + handler: () => scriptedResponses.shift() ?? stopReply("done"), + }); + + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { + model, + systemPrompt: ["Test"], + tools: [bashTool as unknown as AgentTool], + messages: [], + }, + convertToLlm, + streamFn: mock.stream, + }); + + manager = new AsyncJobManager({ maxRunningJobs: 2, onJobComplete: () => {} }); + AsyncJobManager.setInstance(manager); + // Isolate the module-global terminal-abort registries per test: job ids + // (bg_N) and generations (job:N) collide across fresh managers, and the + // registries are process-lifetime by design. + resetTerminalAbortRegistriesForTests(); + + session = new AgentSession({ + agent, + sessionManager, + settings, + modelRegistry, + toolRegistry: new Map([[bashTool.name, bashTool as unknown as AgentTool]]), + }); + session.setSdkPermissionMode("allow"); + }); + + afterEach(async () => { + AsyncJobManager.setInstance(undefined); + await session?.dispose(); + authStorage?.close(); + authStorage = undefined; + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("terminal abort registers the scope so the left-running owned job classifies as owned-completion", async () => { + const callId = "call_terminal_owned"; + scriptedResponses = [bashCall("echo left-running", callId), stopReply("ok")]; + + const promptPromise = session.prompt("run owned work").catch(() => { + // The turn may be interrupted by the terminal abort; that is expected. + }); + await waitFor(() => manager.getAllJobs().length > 0, "bash job registered"); + const job = manager.getAllJobs()[0]!; + + const handle = session.agent.activeResourceRunId; + const proof = await session.abortPromptAndWait(handle ?? job.id, { + graceMs: 2_000, + terminal: { scope: "turn" }, + }); + // The abort may or may not fence (run handle availability varies), but the + // terminal scope MUST be registered for the aborted turn either way. + expect(proof).toBeDefined(); + + // The left-running owned job now classifies by exact source lineage. + const classified = classifyOwnedCompletion(job.id, job.generation); + expect(classified).toBeDefined(); + expect(classified?.registration.jobId).toBe(job.id); + expect(classified?.registration.jobGeneration).toBe(job.generation); + + await promptPromise; + }, 20_000); + + it("owned scope registers a scope with owned-completion delivery disabled", async () => { + const callId = "call_terminal_owned_disabled"; + scriptedResponses = [bashCall("echo stopped", callId), stopReply("ok")]; + + const promptPromise = session.prompt("run capturable work").catch(() => { + // Interruption by the terminal abort is expected. + }); + await waitFor(() => manager.getAllJobs().length > 0, "bash job registered"); + const job = manager.getAllJobs()[0]!; + + await session.abortPromptAndWait(session.agent.activeResourceRunId ?? job.id, { + graceMs: 2_000, + terminal: { scope: "owned" }, + }); + + // The job still classifies as owned (exact tuple), but the scope's + // owned-completion policy is disabled — no resume from stopped work. + const classified = classifyOwnedCompletion(job.id, job.generation); + expect(classified).toBeDefined(); + expect(classified?.registration.promptAttemptEpoch).toBeGreaterThanOrEqual(0); + + await promptPromise; + }, 20_000); + + it("terminal abort advances the epoch so a later turn's work never binds the aborted scope", async () => { + // Turn A spawns a job; terminal abort fences turn A's lineage+epoch. + scriptedResponses = [bashCall("echo first", "call-a"), stopReply("ok")]; + const firstPrompt = session.prompt("first turn").catch(() => {}); + await waitFor(() => manager.getAllJobs().length > 0, "first job registered"); + const firstJob = manager.getAllJobs()[0]!; + await session.abortPromptAndWait(session.agent.activeResourceRunId ?? firstJob.id, { + graceMs: 2_000, + terminal: { scope: "turn" }, + }); + await firstPrompt; + expect(classifyOwnedCompletion(firstJob.id, firstJob.generation)).toBeDefined(); + + // Turn B (fresh user prompt) spawns a job in a NEW turn: the epoch + // advanced, so its lineage is distinct and the aborted scope must NOT + // claim it (AC 27/28 — the fence bounds only the aborted turn). + const jobCountBefore = manager.getAllJobs().length; + scriptedResponses = [bashCall("echo second", "call-b"), stopReply("ok")]; + const secondPrompt = session.prompt("second turn").catch(() => {}); + await waitFor(() => manager.getAllJobs().length > jobCountBefore, "second job registered"); + const secondJob = manager.getAllJobs().find(job => job.id !== firstJob.id)!; + expect(classifyOwnedCompletion(secondJob.id, secondJob.generation)).toBeUndefined(); + await secondPrompt; + }, 20_000); + + it("consecutive normal turns get distinct lineage epochs; owned abort of turn B never captures turn A's job", async () => { + // Turn A completes normally (no abort), leaving a registered job. + scriptedResponses = [bashCall("echo a", "call-distinct-a"), stopReply("ok")]; + await session.prompt("first turn"); + await waitFor(() => manager.getAllJobs().length >= 1, "first job registered"); + const jobA = manager.getAllJobs()[0]!; + + // Turn B also completes normally; the lineage epoch must NOT be reused, + // otherwise both turns share (lineageIdHash, epoch) and turn A's job + // would look owned by turn B (review thread P1). + scriptedResponses = [bashCall("echo b", "call-distinct-b"), stopReply("ok")]; + await session.prompt("second turn"); + await waitFor(() => manager.getAllJobs().length >= 2, "second job registered"); + const jobB = manager.getAllJobs().find(job => job.id !== jobA.id)!; + + // Terminal owned abort of the CURRENT turn (B): its scope captures only + // B's exact registered work; turn A's left-running job stays foreign. + await session.abortPromptAndWait(session.agent.activeResourceRunId ?? jobB.id, { + graceMs: 2_000, + terminal: { scope: "owned" }, + }); + expect(classifyOwnedCompletion(jobA.id, jobA.generation)).toBeUndefined(); + expect(classifyOwnedCompletion(jobB.id, jobB.generation)).toBeDefined(); + }, 20_000); + + it("monitor jobs are registered as exact owned work of the turn (scope:owned can stop them)", async () => { + // Bind a lineage to the monitor tool call id as beforeToolCall would. + bindToolLineage("call-monitor", { + lineageIdHash: "monitor-lineage", + promptAttemptEpoch: 41, + endpointGeneration: 0, + }); + const monitorJob = await bashToolRef.startMonitorJob( + { command: "echo monitor", timeout: 10 }, + { toolCallId: "call-monitor" }, + ); + const registration = lookupOwnedRegistration( + monitorJob.jobId, + manager.getJob(monitorJob.jobId)?.generation ?? "", + ); + expect(registration).toBeDefined(); + expect(registration?.lineageIdHash).toBe("monitor-lineage"); + expect(registration?.promptAttemptEpoch).toBe(41); + }, 20_000); + + it("terminal abort discards hidden next-turn messages queued by the aborted turn", async () => { + // A hidden next-turn successor is scheduled for the current generation; + // the terminal abort closes the fence BEFORE the scheduled drain runs, + // so the drain is blocked and must discard the queued messages instead + // of leaving them for a later explicit prompt (review thread P2). + session.queueDeferredMessageForTests( + { + role: "custom", + customType: "test-hidden-next-turn", + content: [{ type: "text", text: "hidden successor" }], + display: true, + details: {}, + timestamp: Date.now(), + }, + true, + ); + await session.abortPromptAndWait(session.agent.activeResourceRunId ?? "run", { + graceMs: 2_000, + terminal: { scope: "turn" }, + }); + expect(session.getPendingNextTurnMessagesForTests()).toHaveLength(0); + }, 20_000); + + it("terminal abort + new prompt discards hidden next-turn successors before injection", async () => { + scriptedResponses = [stopReply("ok")]; + await session.prompt("first turn"); + // A hidden successor is queued for the current (first) turn's generation. + session.queueDeferredMessageForTests( + { + role: "custom", + customType: "test-hidden-skip", + content: [{ type: "text", text: "hidden successor" }], + display: true, + details: {}, + timestamp: Date.now(), + }, + true, + ); + // Terminal abort closes the first turn's fence. + await session.abortPromptAndWait(session.agent.activeResourceRunId ?? "run", { + graceMs: 2_000, + terminal: { scope: "turn" }, + }); + // A NEW prompt advances the generation: the scheduled drain is skipped, + // and the explicit-prompt admission must discard the aborted turn's + // hidden successors instead of injecting them into this new turn + // (review thread P2). + scriptedResponses = [stopReply("ok")]; + await session.prompt("new user turn"); + expect(session.getPendingNextTurnMessagesForTests()).toHaveLength(0); + }, 20_000); + + it("terminal abort purges steering queued for the aborted turn but keeps owned-completion follow-ups", async () => { + scriptedResponses = [stopReply("ok")]; + await session.prompt("first turn"); + // A steer is queued just before the terminal abort wins. + session.agent.steer({ + role: "custom", + customType: "steer-test", + content: [{ type: "text", text: "stale steer" }], + display: true, + details: {}, + timestamp: Date.now(), + }); + await session.abortPromptAndWait(session.agent.activeResourceRunId ?? "run", { + graceMs: 2_000, + terminal: { scope: "turn" }, + }); + // The queued steering is purged so it cannot alter the next user turn; + // the follow-up queue is untouched (owned-completion resumes still deliver). + expect(session.agent.hasQueuedSteering()).toBe(false); + expect(session.agent.snapshotQueues().followUp).toHaveLength(0); + }, 20_000); +}); diff --git a/packages/coding-agent/test/async-yield-queue.test.ts b/packages/coding-agent/test/async-yield-queue.test.ts index a3bd927f5..36e6d259a 100644 --- a/packages/coding-agent/test/async-yield-queue.test.ts +++ b/packages/coding-agent/test/async-yield-queue.test.ts @@ -173,3 +173,56 @@ describe("async result yield queue delivery", () => { expect(asyncDetails(harness.prompts[0]![0]!).jobs.map(job => job.jobId)).toEqual([jobId]); }); }); + +test("flush builds one message per groupKey origin so owned drops cannot suppress other origins", async () => { + const { queue, followUps } = createHarness(false); + // Two entries from DIFFERENT ownership origins plus one ordinary entry. + const g1 = { jobId: "j-1", result: "one", kind: "g1" }; + const g2 = { jobId: "j-2", result: "two", kind: "g2" }; + const ordinary = { jobId: "j-3", result: "three", kind: "ordinary" }; + queue.register("test-grouped", { + groupKey: entry => entry.kind, + build: (survivors: Array<{ jobId: string; result: string; kind: string }>) => ({ + role: "custom", + customType: "async-result", + content: survivors.map(s => s.result).join("+"), + display: true, + attribution: "agent", + details: { jobs: survivors.map(s => s.jobId) }, + timestamp: 1, + }), + }); + queue.enqueue("test-grouped", g1); + queue.enqueue("test-grouped", g2); + queue.enqueue("test-grouped", ordinary); + await queue.flush("streaming"); + // One message per origin (3 groups), each carrying only its own entries. + const grouped = followUps as CustomMessage<{ jobs: string[] }>[]; + expect(grouped).toHaveLength(3); + expect(grouped.map(m => m.content).sort()).toEqual(["one", "three", "two"]); + expect(grouped.find(m => m.content === "one")?.details?.jobs).toEqual(["j-1"]); + expect(grouped.find(m => m.content === "two")?.details?.jobs).toEqual(["j-2"]); + expect(grouped.find(m => m.content === "three")?.details?.jobs).toEqual(["j-3"]); + expect(grouped[0]?.details?.jobs).toBeDefined(); +}); + +test("flush without a groupKey keeps the single-batch behavior", async () => { + const { queue, followUps } = createHarness(false); + queue.register("test-plain", { + build: (survivors: string[]) => ({ + role: "custom", + customType: "async-result", + content: survivors.join("+"), + display: true, + attribution: "agent", + details: {}, + timestamp: 1, + }), + }); + queue.enqueue("test-plain", "a"); + queue.enqueue("test-plain", "b"); + await queue.flush("streaming"); + const plain = followUps as CustomMessage>[]; + expect(plain).toHaveLength(1); + expect(plain[0]?.content).toBe("a+b"); +}); diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index fa088f939..4244f1dd5 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -1032,6 +1032,36 @@ describe("ExtensionRunner", () => { expect(wired.createContext().getPendingMessageCounts()).toEqual({ steering: 0, followUp: 0, nextTurn: 0 }); }); + it("surfaces the terminal-abort session seams on the created context after initialize", async () => { + // The SDK host reads these seams off the ExtensionContext (not + // ExtensionContextActions): without them a terminal abort in + // production hits markerEpoch === undefined and cannot cancel the + // underlying session preflight (review thread P1). + const wired = new ExtensionRunner( + [], + { flagValues: new Map(), pendingProviderRegistrations: [] } as never, + tempDir.path(), + sessionManager, + modelRegistry, + ); + const early = wired.createContext(); + expect(early.getTerminalTurnEpoch?.()).toBeUndefined(); + let preflightCancels = 0; + wired.initialize( + {} as never, + { + getTerminalTurnEpoch: () => 42, + cancelPendingPreflightForTerminalAbort: () => { + preflightCancels += 1; + }, + } as never, + ); + const ctx = wired.createContext(); + expect(ctx.getTerminalTurnEpoch?.()).toBe(42); + ctx.cancelPendingPreflightForTerminalAbort?.(); + expect(preflightCancels).toBe(1); + }); + it("keeps session naming unavailable during extension load", async () => { const extCode = ` export default function(pi) { diff --git a/packages/coding-agent/test/monitor-redteam.test.ts b/packages/coding-agent/test/monitor-redteam.test.ts index 9a30575f5..080d24782 100644 --- a/packages/coding-agent/test/monitor-redteam.test.ts +++ b/packages/coding-agent/test/monitor-redteam.test.ts @@ -8,7 +8,12 @@ import { MonitorTool } from "../src/tools/monitor"; type QueuedMessage = { customType: string; content: string; details?: unknown }; -function detailsOf(entry: QueuedMessage): { taskId?: string; notificationId?: string; coalescedCount?: number } { +function detailsOf(entry: QueuedMessage): { + taskId?: string; + notificationId?: string; + coalescedCount?: number; + jobGeneration?: string; +} { return (entry.details ?? {}) as { taskId?: string; notificationId?: string; coalescedCount?: number }; } @@ -176,6 +181,9 @@ describe("monitor backlog red-team public surfaces", () => { expect(entries).toHaveLength(1); expect(entries[0]?.content).toContain("first"); expect(entries[0]?.content).not.toContain("second"); + // The notification carries the job generation so the owned-scope + // admission filter can attribute it even after job eviction (P2). + expect(detailsOf(entries[0]!).jobGeneration).toBe(manager.getJob(taskId)?.generation); expect(manager.getJob(taskId)?.status).toBe("cancelled"); expect(queue.filter(entry => detailsOf(entry).taskId === taskId)).toHaveLength(1); }); diff --git a/packages/coding-agent/test/notifications-tool-activity.test.ts b/packages/coding-agent/test/notifications-tool-activity.test.ts index 895586185..246a64e39 100644 --- a/packages/coding-agent/test/notifications-tool-activity.test.ts +++ b/packages/coding-agent/test/notifications-tool-activity.test.ts @@ -163,6 +163,7 @@ describe("SDK replay capability filter", () => { : undefined, sendFrame: (connectionId, frame) => { sent.push({ connectionId, frame }); + return "written"; }, onFrame: handler => { receive = handler; diff --git a/packages/coding-agent/test/sdk-acp-two-client-race.test.ts b/packages/coding-agent/test/sdk-acp-two-client-race.test.ts index 4faba0415..d42de8880 100644 --- a/packages/coding-agent/test/sdk-acp-two-client-race.test.ts +++ b/packages/coding-agent/test/sdk-acp-two-client-race.test.ts @@ -22,7 +22,10 @@ test("SDK-RPC-provider-conflict: real ACP clients race atomically for one provid sessionId: "s", stateRoot: "/tmp", token: "token", - sendFrame: (connectionId, frame) => server.sendTo(connectionId, JSON.stringify(frame)), + sendFrame: (connectionId, frame) => { + server.sendTo(connectionId, JSON.stringify(frame)); + return "written"; + }, onFrame: handler => { onFrame = handler; return () => { diff --git a/packages/coding-agent/test/sdk-control-dispatch.test.ts b/packages/coding-agent/test/sdk-control-dispatch.test.ts index 77a888856..aa2e79e67 100644 --- a/packages/coding-agent/test/sdk-control-dispatch.test.ts +++ b/packages/coding-agent/test/sdk-control-dispatch.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { type ControlRequest, type ControlSurface, dispatchControl } from "../src/sdk/host/control"; +import { type ControlRequest, type ControlSurface, dispatchControl, TypedControlError } from "../src/sdk/host/control"; import { OPERATIONS } from "../src/sdk/protocol/operation-registry"; const methodByOperation: Record = { @@ -59,37 +59,44 @@ const methodByOperation: Record = { }; function request(row: (typeof OPERATIONS)[number]): ControlRequest { + // turn.abort validates strictly: the generic kitchen-sink input carries + // `mode: "all"`, which is an invalid mode. Legacy C04 sends `{}` (omitted + // mode), so the broad fixture uses `{}` for turn.abort. + const input = + row.sdkId === "turn.abort" + ? {} + : { + text: "text", + images: [], + id: "id", + answer: "answer", + response: "response", + choice: "choice", + name: "name", + args: [], + on: true, + op: "create", + objective: "goal", + items: [], + level: "high", + mode: "all", + cmd: "echo hi", + entryId: "entry", + target: "target", + patch: {}, + components: [], + provider: "provider", + defs: [], + tier: "pro", + names: [], + before: "before", + after: "after", + path: "/tmp", + }; return { id: row.id, operation: row.sdkId, - input: { - text: "text", - images: [], - id: "id", - answer: "answer", - response: "response", - choice: "choice", - name: "name", - args: [], - on: true, - op: "create", - objective: "goal", - items: [], - level: "high", - mode: "all", - cmd: "echo hi", - entryId: "entry", - target: "target", - patch: {}, - components: [], - provider: "provider", - defs: [], - tier: "pro", - names: [], - before: "before", - after: "after", - path: "/tmp", - }, + input, confirm: row.sdkId === "context.clear" || row.sdkId === "session.delete", }; } @@ -465,3 +472,163 @@ test("replays matching idempotency requests, rejects conflicts, and evicts LRU e }); expect(calls).toBe(258); }); +test("turn.abort terminal mode validates strictly and forwards normalized input", async () => { + const abort = OPERATIONS.find(row => row.sdkId === "turn.abort")!; + const calls: Array> = []; + const surface = { + abort: () => "legacy", + abortTerminal: (input: unknown, idempotencyKey?: string) => { + calls.push({ ...(input as Record), idempotencyKey }); + return "terminal"; + }, + } as unknown as ControlSurface; + const terminal = (input: Record, idempotencyKey?: string) => + dispatchControl(surface, abort, { + id: "t", + operation: abort.sdkId, + input, + idempotencyKey, + }); + + expect(await terminal({ mode: "terminal" }, "key-1")).toEqual({ id: "t", ok: true, result: "terminal" }); + expect(calls).toEqual([{ mode: "terminal", scope: "turn", idempotencyKey: "key-1" }]); + expect(await terminal({ mode: "terminal", scope: "owned" }, "key-2")).toEqual({ + id: "t", + ok: true, + result: "terminal", + }); + expect(calls).toEqual([ + { mode: "terminal", scope: "turn", idempotencyKey: "key-1" }, + { mode: "terminal", scope: "owned", idempotencyKey: "key-2" }, + ]); + // Same-key same-input retry replays at the dispatch layer without invoking + // the surface again (the durable record covers the evicted/restart window). + const replay = await terminal({ mode: "terminal" }, "key-1"); + expect(replay).toEqual({ id: "t", ok: true, result: "terminal" }); + expect(calls).toHaveLength(2); +}); +test("turn.abort terminal normalizes omitted scope before idempotency hashing", async () => { + const abort = OPERATIONS.find(row => row.sdkId === "turn.abort")!; + let calls = 0; + const surface = { + abort: () => "legacy", + abortTerminal: () => { + calls++; + return "terminal"; + }, + } as unknown as ControlSurface; + const terminal = (input: Record, idempotencyKey: string) => + dispatchControl(surface, abort, { + id: "t", + operation: abort.sdkId, + input, + idempotencyKey, + }); + + // The defaulted shape and the explicit `scope:"turn"` are the SAME input: + // the retry must replay (not idempotency_conflict), so it never re-runs the + // surface and stays eligible for the durable terminal-scope replay. + expect(await terminal({ mode: "terminal" }, "key-norm")).toEqual({ id: "t", ok: true, result: "terminal" }); + expect(await terminal({ mode: "terminal", scope: "turn" }, "key-norm")).toEqual({ + id: "t", + ok: true, + result: "terminal", + }); + expect(calls).toBe(1); + // A genuinely different scope with the same key still conflicts. + const conflict = await terminal({ mode: "terminal", scope: "owned" }, "key-norm"); + expect(conflict).toMatchObject({ ok: false, error: { code: "idempotency_conflict" } }); + expect(calls).toBe(1); + // A malformed input (extra field) does NOT normalize: with a FRESH key it + // is rejected downstream (invalid_input), never replayed against a valid + // input's key. + const malformed = await terminal({ mode: "terminal", force: true }, "key-malformed"); + expect(malformed).toMatchObject({ ok: false, error: { code: "invalid_input" } }); +}); + +test("turn.abort terminal mode rejects missing/oversized key, invalid mode/scope, and unknown fields", async () => { + const abort = OPERATIONS.find(row => row.sdkId === "turn.abort")!; + let terminalCalls = 0; + const surface = { + abort: () => "legacy", + abortTerminal: () => { + terminalCalls++; + return "terminal"; + }, + } as unknown as ControlSurface; + const terminal = (input: Record, idempotencyKey?: string) => + dispatchControl(surface, abort, { + id: "t", + operation: abort.sdkId, + input, + idempotencyKey, + }); + const rejection = async (input: Record, idempotencyKey?: string) => { + const response = await terminal(input, idempotencyKey); + expect(response.ok).toBe(false); + expect((response.error as { code?: string }).code).toBe("invalid_input"); + }; + + await rejection({ mode: "terminal" }); // keyless + await rejection({ mode: "terminal" }, ""); // empty key + await rejection({ mode: "terminal" }, "x".repeat(129)); // oversized + await rejection({ mode: "terminal", force: true }, "k-force"); + await rejection({ mode: "unknown" }, "k-mode"); + await rejection({ mode: "terminal", scope: "all" }, "k-scope"); + await rejection({ mode: "terminal", foo: 1 }, "k-field"); + expect(terminalCalls).toBe(0); +}); + +test("turn.abort terminal mode is rejected when the surface does not implement abortTerminal", async () => { + const abort = OPERATIONS.find(row => row.sdkId === "turn.abort")!; + const surface = { abort: () => "legacy" } as unknown as ControlSurface; + const response = await dispatchControl(surface, abort, { + id: "t", + operation: abort.sdkId, + input: { mode: "terminal" }, + idempotencyKey: "key", + }); + expect(response).toMatchObject({ ok: false, error: { code: "invalid_input" } }); +}); + +test("turn.abort legacy mode keeps dropping input and calling the argument-less abort", async () => { + const abort = OPERATIONS.find(row => row.sdkId === "turn.abort")!; + const calls: unknown[] = []; + const surface = { + abort: (...args: unknown[]) => { + calls.push(args); + return "legacy"; + }, + } as unknown as ControlSurface; + for (const input of [{}, { mode: "turn" }, { mode: "turn", scope: "owned", extra: 1 }]) { + const response = await dispatchControl(surface, abort, { + id: "t", + operation: abort.sdkId, + input, + }); + expect(response).toEqual({ id: "t", ok: true, result: "legacy" }); + } + expect(calls).toEqual([[], [], []]); +}); + +test("turn.abort terminal conflict surfaces as a top-level control error", async () => { + const abort = OPERATIONS.find(row => row.sdkId === "turn.abort")!; + // A surface that throws a typed idempotency_conflict (the durable + // terminal-scope replay conflict path after in-memory eviction) must + // produce a TOP-LEVEL ok:false response — not a nested result inside a + // successful control_response. + const surface = { + abort: () => "legacy", + abortTerminal: () => { + throw new TypedControlError("idempotency_conflict", "Idempotency key was reused with different input."); + }, + } as unknown as ControlSurface; + const response = await dispatchControl(surface, abort, { + id: "t-conflict", + operation: abort.sdkId, + input: { mode: "terminal", scope: "turn" }, + idempotencyKey: "k", + }); + expect(response.ok).toBe(false); + expect(response.error).toMatchObject({ code: "idempotency_conflict" }); +}); diff --git a/packages/coding-agent/test/sdk-host-wiring.test.ts b/packages/coding-agent/test/sdk-host-wiring.test.ts index d3bd39068..f80bb2051 100644 --- a/packages/coding-agent/test/sdk-host-wiring.test.ts +++ b/packages/coding-agent/test/sdk-host-wiring.test.ts @@ -2314,6 +2314,75 @@ test("SDK host terminalizes a never-resolving preflight on abort and fences late await handlers.get("session_shutdown")?.({ type: "session_shutdown" }, context(cwd, sessionId)); }); +test("terminal abort cancels a pending prompt preflight (never accepts)", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "gjc-sdk-terminal-preflight-")); + dirs.push(cwd); + const sessionId = `sdk-terminal-preflight-${Date.now()}`; + const live = { idle: true }; + const neverPreflight = Promise.withResolvers(); + const deliveries: Parameters[] = []; + const sessionContext = { + ...context(cwd, sessionId, "main", live), + sessionManager: { + ...(context(cwd, sessionId, "main", live).sessionManager as Record), + getSessionFile: () => path.join(cwd, ".gjc", "state", "sdk", `${sessionId}.jsonl`), + }, + getTerminalTurnEpoch: () => 1, + }; + const handlers = start( + sessionContext, + undefined, + async (content, options) => { + deliveries.push([content, options]); + if (content === "never resolve") { + await neverPreflight.promise; + } + await firePreflightAccept(options); + }, + true, + ); + const endpointFile = path.join(cwd, ".gjc", "state", "sdk", `${sessionId}.json`); + await waitFor(() => fs.existsSync(endpointFile), "SDK endpoint"); + const endpoint = JSON.parse(fs.readFileSync(endpointFile, "utf8")) as { url: string; token: string }; + const frames: Record[] = []; + const socket = new WebSocket(`${endpoint.url}/?token=${encodeURIComponent(endpoint.token)}`); + sockets.push(socket); + socket.addEventListener("message", event => frames.push(JSON.parse(String(event.data)))); + await new Promise((resolve, reject) => { + socket.addEventListener("open", () => resolve(), { once: true }); + socket.addEventListener("error", () => reject(new Error("socket error")), { once: true }); + }); + socket.send( + JSON.stringify({ + type: "control_request", + id: "term-prompt", + operation: "turn.prompt", + input: { text: "never resolve", images: [] }, + }), + ); + await waitFor(() => deliveries.length > 0, "prompt preflight started"); + socket.send( + JSON.stringify({ + type: "control_request", + id: "term-abort", + operation: "turn.abort", + input: { mode: "terminal" }, + idempotencyKey: "term-abort-key-1", + }), + ); + await waitFor( + () => + frames.some(frame => frame.type === "control_response" && frame.id === "term-abort") && + frames.some(frame => frame.type === "control_response" && frame.id === "term-prompt"), + "terminal abort + cancelled preflight responses", + ); + // The preflight is cancelled (never accepted), so the prompt never starts. + const promptResponse = frames.find(frame => frame.type === "control_response" && frame.id === "term-prompt"); + expect(promptResponse).toMatchObject({ ok: false }); + expect(frames.some(frame => frame.type === "agent_failed" || frame.type === "agent_start")).toBe(false); + await handlers.get("session_shutdown")?.({ type: "session_shutdown" }, context(cwd, sessionId)); +}); + test("SDK host abort-and-prompt cancels a never-resolving preflight before replacement submission", async () => { const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "gjc-sdk-abort-prompt-never-preflight-")); dirs.push(cwd); @@ -2465,6 +2534,133 @@ test("SDK host waits for asynchronous abort unwind before delivering an abort-an }); await handlers.get("session_shutdown")?.({ type: "session_shutdown" }, sessionContext); }); +test("SDK host turn.abort terminal mode returns no-effect with no active turn", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "gjc-sdk-terminal-noop-")); + dirs.push(cwd); + const sessionId = `sdk-terminal-noop-${Date.now()}`; + const sessionContext = { + ...context(cwd, sessionId), + // Provide a file-backed session so the terminal abort has a reconciliation + // owner (the no-store gate only fires for genuinely store-less sessions). + sessionManager: { + ...(context(cwd, sessionId).sessionManager as Record), + getSessionFile: () => path.join(cwd, ".gjc", "state", "sdk", `${sessionId}.jsonl`), + }, + }; + const handlers = start(sessionContext, undefined, () => {}, true); + const endpointFile = path.join(cwd, ".gjc", "state", "sdk", `${sessionId}.json`); + await waitFor(() => fs.existsSync(endpointFile), "SDK endpoint"); + const endpoint = JSON.parse(fs.readFileSync(endpointFile, "utf8")) as { url: string; token: string }; + const frames: Record[] = []; + const socket = new WebSocket(`${endpoint.url}/?token=${encodeURIComponent(endpoint.token)}`); + sockets.push(socket); + socket.addEventListener("message", event => frames.push(JSON.parse(String(event.data)))); + await new Promise((resolve, reject) => { + socket.addEventListener("open", () => resolve(), { once: true }); + socket.addEventListener("error", () => reject(new Error("WS error")), { once: true }); + }); + socket.send( + JSON.stringify({ + type: "control_request", + id: "terminal-noop", + operation: "turn.abort", + input: { mode: "terminal" }, + idempotencyKey: "terminal-noop-key", + }), + ); + await waitFor( + () => frames.some(frame => frame.type === "control_response" && frame.id === "terminal-noop"), + "terminal abort no-effect response", + ); + expect(frames.find(frame => frame.type === "control_response" && frame.id === "terminal-noop")).toMatchObject({ + ok: true, + result: { + selection: "turn", + turn: "no_active_turn", + terminal: "terminal_no_effect", + }, + }); + // No agent turn ever started. + expect(frames.some(frame => frame.type === "agent_start")).toBe(false); + await handlers.get("session_shutdown")?.({ type: "session_shutdown" }, sessionContext); +}); + +test("SDK host turn.abort terminal mode finalizes an accepted-but-not-started prompt as cancelled", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "gjc-sdk-terminal-fence-")); + dirs.push(cwd); + const sessionId = `sdk-terminal-fence-${Date.now()}`; + const live = { idle: true }; + const deliveries: Parameters[] = []; + const sessionContext = { + ...context(cwd, sessionId, "main", live), + // File-backed reconciliation owner so the terminal abort reaches the + // fence path (and fails closed there) instead of the no-store gate. + sessionManager: { + ...(context(cwd, sessionId, "main", live).sessionManager as Record), + getSessionFile: () => path.join(cwd, ".gjc", "state", "sdk", `${sessionId}.jsonl`), + }, + // The initial-marker seam: a stable epoch so the marker is written + // before the fence attempts (and fails closed) on the missing seam. + getTerminalTurnEpoch: () => 1, + }; + const handlers = start( + sessionContext, + undefined, + async (content, options) => { + deliveries.push([content, options]); + await firePreflightAccept(options); + }, + true, + ); + const endpointFile = path.join(cwd, ".gjc", "state", "sdk", `${sessionId}.json`); + await waitFor(() => fs.existsSync(endpointFile), "SDK endpoint"); + const endpoint = JSON.parse(fs.readFileSync(endpointFile, "utf8")) as { url: string; token: string }; + const frames: Record[] = []; + const socket = new WebSocket(`${endpoint.url}/?token=${encodeURIComponent(endpoint.token)}`); + sockets.push(socket); + socket.addEventListener("message", event => frames.push(JSON.parse(String(event.data)))); + await new Promise((resolve, reject) => { + socket.addEventListener("open", () => resolve(), { once: true }); + socket.addEventListener("error", () => reject(new Error("WS error")), { once: true }); + }); + socket.send( + JSON.stringify({ + type: "control_request", + id: "terminal-prompt", + operation: "turn.prompt", + input: { text: "terminalize me" }, + }), + ); + await waitFor(() => deliveries.length === 1, "terminal prompt accepted"); + void handlers.get("agent_start")?.({ type: "agent_start" }, sessionContext); + // The fixture harness fires agent_start without binding an exact run handle, + // so the prompt is accepted-but-not-started: terminal abort must cancel the + // in-flight session preflight and FINALIZE the accepted prompt as a pre-run + // cancellation (no_active_turn / terminal_no_effect) instead of terminalizing + // with no run handle (which would wrongly fence the connection). + socket.send( + JSON.stringify({ + type: "control_request", + id: "terminal-abort", + operation: "turn.abort", + input: { mode: "terminal" }, + idempotencyKey: "terminal-abort-key", + }), + ); + await waitFor( + () => frames.some(frame => frame.type === "control_response" && frame.id === "terminal-abort"), + "terminal abort uncertainty response", + ); + expect(frames.find(frame => frame.type === "control_response" && frame.id === "terminal-abort")).toMatchObject({ + ok: true, + result: { + selection: "turn", + turn: "no_active_turn", + terminal: "terminal_no_effect", + }, + }); + await handlers.get("session_shutdown")?.({ type: "session_shutdown" }, sessionContext); +}); test("SDK session switches rotate endpoint authority before publishing the replacement host", async () => { const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "gjc-sdk-host-switch-")); @@ -3079,6 +3275,7 @@ test("SDK host replay gaps are generation-scoped and sequence gaps remain cohere token: "test-token", sendFrame: (_connectionId, frame) => { sent.push(frame); + return "written"; }, onFrame: handler => { receive = handler; diff --git a/packages/coding-agent/test/sdk-host.test.ts b/packages/coding-agent/test/sdk-host.test.ts index 0faf2c3f0..4c63d259c 100644 --- a/packages/coding-agent/test/sdk-host.test.ts +++ b/packages/coding-agent/test/sdk-host.test.ts @@ -35,6 +35,7 @@ describe("SessionSdkHost", () => { : new Set(), sendFrame: (connectionId, frame) => { sent.push({ connectionId, frame }); + return "written"; }, onFrame: handler => { receive = handler; @@ -85,7 +86,9 @@ describe("SessionSdkHost", () => { sessionId: "s", stateRoot: "/tmp/s", token: "t", - sendFrame: () => {}, + sendFrame: () => { + return "written"; + }, onFrame: value => { handler = value; return () => { @@ -115,7 +118,9 @@ describe("SessionSdkHost", () => { sessionId: "retry-stop", stateRoot: "/tmp/retry-stop", token: "t", - sendFrame: () => {}, + sendFrame: () => { + return "written"; + }, onFrame: () => () => { unsubscribeAttempts++; }, @@ -150,7 +155,9 @@ describe("SessionSdkHost", () => { sessionId: "concurrent-stop", stateRoot: "/tmp/concurrent-stop", token: "t", - sendFrame: () => {}, + sendFrame: () => { + return "written"; + }, onFrame: () => () => { unsubscribeAttempts++; }, @@ -192,6 +199,7 @@ describe("SessionSdkHost", () => { token: "t", sendFrame: (connectionId, frame) => { sent.push({ connectionId, frame }); + return "written"; }, onFrame: handler => { receive = handler; @@ -276,6 +284,7 @@ describe("SessionSdkHost", () => { failSends += 1; throw new Error("connection closed"); } + return "written"; }, onFrame: handler => { receive = handler; @@ -304,6 +313,8 @@ describe("SessionSdkHost", () => { const sent: Array> = []; const successorReady = Promise.withResolvers(); const order: string[] = []; + const deliveries: string[] = []; + let afterRan = false; const host = new SessionSdkHost({ sessionId: "control-drain-order", stateRoot: "/tmp/control-drain-order", @@ -311,6 +322,7 @@ describe("SessionSdkHost", () => { sendFrame: (_connectionId, frame) => { order.push("send"); sent.push(frame); + return "written"; }, onFrame: handler => { receive = handler; @@ -323,6 +335,12 @@ describe("SessionSdkHost", () => { order.push("ready"); await sendTerminal(); }, + onControlResponseDelivery: async (_connectionId, _request, _response, outcome) => { + deliveries.push(outcome); + }, + afterControlResponse: async () => { + afterRan = true; + }, }); await host.start(); receive("client", { type: "control_request", id: "c1", operation: "session.switch", input: {} }); @@ -334,6 +352,11 @@ describe("SessionSdkHost", () => { await new Promise(resolve => setTimeout(resolve, 0)); expect(sent).toEqual([expect.objectContaining({ type: "control_response", id: "c1", ok: true })]); expect(order).toEqual(["before", "ready", "send"]); + // The EARLY send (inside beforeControlResponse) is classified as written — + // the fallback repeat call must not report a false dropped — and the + // post-write hook runs for the written response. + expect(deliveries).toEqual(["written"]); + expect(afterRan).toBe(true); await host.stop(); }); }); diff --git a/packages/coding-agent/test/sdk-prompt-terminal-arbiter.test.ts b/packages/coding-agent/test/sdk-prompt-terminal-arbiter.test.ts index 31b73e391..af32173c3 100644 --- a/packages/coding-agent/test/sdk-prompt-terminal-arbiter.test.ts +++ b/packages/coding-agent/test/sdk-prompt-terminal-arbiter.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { createKindAwareReconciliation } from "../src/sdk/bus/kind-aware-reconciliation"; import { type DurableReconciliationRecord, + type DurableTerminalScopeRecord, type ReconciliationStore, settleProcessRestart, } from "../src/sdk/bus/reconciliation-store"; @@ -11,6 +12,7 @@ class MemoryStore implements ReconciliationStore { readonly path = null; readonly sessionId = "test-session"; #records: DurableReconciliationRecord[] = []; + #terminalScopes: DurableTerminalScopeRecord[] = []; #failNext = false; #holdNext?: Promise; #onHeld?: () => void; @@ -39,6 +41,19 @@ class MemoryStore implements ReconciliationStore { } this.#records = next; } + async transactTerminalScopes( + mutator: (scopes: DurableTerminalScopeRecord[]) => DurableTerminalScopeRecord[], + ): Promise { + this.#terminalScopes = mutator(this.snapshotTerminalScopes()); + } + + async loadTerminalScopes(): Promise { + return this.snapshotTerminalScopes(); + } + + snapshotTerminalScopes(): DurableTerminalScopeRecord[] { + return this.#terminalScopes.map(scope => ({ ...scope })); + } async load(): Promise { return this.snapshot(); diff --git a/packages/coding-agent/test/sdk-protocol-conformance.test.ts b/packages/coding-agent/test/sdk-protocol-conformance.test.ts index 80ee0d3ca..e2ecff007 100644 --- a/packages/coding-agent/test/sdk-protocol-conformance.test.ts +++ b/packages/coding-agent/test/sdk-protocol-conformance.test.ts @@ -138,6 +138,7 @@ describe("SDK v3 TypeScript/Rust wire conformance", () => { token: "token", sendFrame: (_connectionId, value) => { sent.push(value); + return "written"; }, onFrame: handler => { receive = handler; diff --git a/packages/coding-agent/test/sdk-reconciliation-store.test.ts b/packages/coding-agent/test/sdk-reconciliation-store.test.ts index de30e5fd2..2eeae9177 100644 --- a/packages/coding-agent/test/sdk-reconciliation-store.test.ts +++ b/packages/coding-agent/test/sdk-reconciliation-store.test.ts @@ -5,9 +5,13 @@ import * as path from "node:path"; import { createReconciliationStore, type DurableReconciliationRecord, + type DurableTerminalScopeRecord, isSafeReconciliationSessionId, + RECONCILIATION_STORE_VERSION, + RECONCILIATION_STORE_VERSION_V1, reconciliationStorePath, settleProcessRestart, + settleTerminalScopeRestart, } from "../src/sdk/bus/reconciliation-store"; describe("reconciliation-store", () => { @@ -225,4 +229,298 @@ describe("reconciliation-store", () => { await store.delete(); expect(store.snapshot()).toHaveLength(0); }); + test("v1 documents migrate to v2 on load and are rewritten durably", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "sdk-recon-v1-")); + const sessionFile = path.join(root, "s.jsonl"); + await fs.writeFile(sessionFile, ""); + const storePath = reconciliationStorePath(sessionFile, "s1"); + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile( + storePath, + JSON.stringify({ + version: RECONCILIATION_STORE_VERSION_V1, + sessionId: "s1", + records: [{ kind: "prompt", commandId: "c1", turnId: "t1", status: "accepted", acceptedAt: 1 }], + }), + ); + const store = createReconciliationStore({ sessionFile, sessionId: "s1" }); + await store.load(); + const rewritten = JSON.parse(await fs.readFile(storePath, "utf8")); + expect(rewritten.version).toBe(RECONCILIATION_STORE_VERSION); + expect(rewritten.records).toHaveLength(1); + expect(await store.loadTerminalScopes()).toEqual([]); + await fs.rm(root, { recursive: true, force: true }); + }); + + test("terminal scope records round-trip through the shared document", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "sdk-recon-term-")); + const sessionFile = path.join(root, "s.jsonl"); + await fs.writeFile(sessionFile, ""); + const store = createReconciliationStore({ sessionFile, sessionId: "s1" }); + const scope: DurableTerminalScopeRecord = { + selection: "turn", + idempotencyKeyHash: "k-hash-1", + idempotencyInputHash: "input-hash-1", + turnDisposition: "stopped", + ownedWorkDisposition: "left_running", + automaticDeliveryDisposition: "enabled", + resumeOnOwnedCompletion: true, + turnContinuationFence: { + state: "retained", + abortedAttemptEpoch: 3, + blockedContinuationIds: ["c-a"], + predecessorTombstones: ["p-1"], + ownedCompletionPolicy: "enabled", + }, + responseState: "delivered", + responsePayloadHash: "hash-1", + acceptedAt: 10, + terminalAt: 20, + }; + await store.transactTerminalScopes(() => [scope]); + await store.transact(() => [ + { kind: "prompt", commandId: "c1", turnId: "t1", status: "accepted", acceptedAt: 1 }, + ]); + expect(store.snapshotTerminalScopes()).toEqual([scope]); + expect(store.snapshot()).toHaveLength(1); + + // A fresh store instance reloads both records and terminal scopes from one document. + const reloaded = createReconciliationStore({ sessionFile, sessionId: "s1" }); + await reloaded.load(); + expect(reloaded.snapshotTerminalScopes()).toEqual([scope]); + expect(reloaded.snapshot()).toHaveLength(1); + await fs.rm(root, { recursive: true, force: true }); + }); + + test("invalid terminal scope documents are quarantined on load", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "sdk-recon-bad-")); + const sessionFile = path.join(root, "s.jsonl"); + await fs.writeFile(sessionFile, ""); + const storePath = reconciliationStorePath(sessionFile, "s1"); + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile( + storePath, + JSON.stringify({ + version: RECONCILIATION_STORE_VERSION, + sessionId: "s1", + records: [], + terminalScopes: [{ selection: "bogus", turnDisposition: "stopped" }], + }), + ); + const store = createReconciliationStore({ sessionFile, sessionId: "s1" }); + expect(await store.loadTerminalScopes()).toEqual([]); + const entries = await fs.readdir(path.dirname(storePath)); + expect(entries.some(name => name.includes("corrupt"))).toBe(true); + await fs.rm(root, { recursive: true, force: true }); + }); + + test("settleTerminalScopeRestart maps pending to uncertain and never invents success", () => { + const now = 5_000; + const pending: DurableTerminalScopeRecord = { + selection: "turn", + turnDisposition: "pending", + ownedWorkDisposition: "left_running", + automaticDeliveryDisposition: "enabled", + resumeOnOwnedCompletion: true, + turnContinuationFence: { + state: "retained", + abortedAttemptEpoch: 1, + blockedContinuationIds: [], + predecessorTombstones: [], + ownedCompletionPolicy: "enabled", + }, + responseState: "pending", + responsePayloadHash: "h", + acceptedAt: 1, + }; + const settled = settleTerminalScopeRestart([pending], now)[0]; + expect(settled.turnDisposition).toBe("uncertain"); + expect(settled.ownedWorkDisposition).toBe("uncertain"); + expect(settled.terminalAt).toBe(now); + // A durable stopped scope is left untouched. + const stopped: DurableTerminalScopeRecord = { ...pending, turnDisposition: "stopped", terminalAt: 2 }; + expect(settleTerminalScopeRestart([stopped], now)[0]).toBe(stopped); + }); +}); + +test("terminal scope response state advances pending -> sent through the shared owner", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "sdk-recon-resp-")); + const sessionFile = path.join(root, "s.jsonl"); + await fs.writeFile(sessionFile, ""); + const store = createReconciliationStore({ sessionFile, sessionId: "s1" }); + const scope: DurableTerminalScopeRecord = { + selection: "turn", + idempotencyKeyHash: "k-hash-1", + idempotencyInputHash: "input-hash-1", + turnDisposition: "stopped", + ownedWorkDisposition: "left_running", + automaticDeliveryDisposition: "enabled", + resumeOnOwnedCompletion: true, + turnContinuationFence: { + state: "retained", + abortedAttemptEpoch: 3, + blockedContinuationIds: [], + predecessorTombstones: [], + ownedCompletionPolicy: "enabled", + }, + responseState: "pending", + responsePayloadHash: "hash-1", + acceptedAt: 10, + terminalAt: 20, + }; + await store.transactTerminalScopes(() => [scope]); + expect(store.snapshotTerminalScopes()[0]!.responseState).toBe("pending"); + // The afterControlResponse hook advances only the matching key from + // pending to sent (AC 18 monotonic) and persists through reload. + await store.transactTerminalScopes(scopes => + scopes.map(s => + s.idempotencyKeyHash === "k-hash-1" && s.responseState === "pending" + ? { ...s, responseState: "sent" as const } + : s, + ), + ); + expect(store.snapshotTerminalScopes()[0]!.responseState).toBe("sent"); + const reloaded = createReconciliationStore({ sessionFile, sessionId: "s1" }); + await reloaded.load(); + expect(reloaded.snapshotTerminalScopes()[0]!.responseState).toBe("sent"); + await fs.rm(root, { recursive: true, force: true }); +}); + +test("initial pending marker CASes to stopped through the same owner", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "sdk-recon-marker-")); + const sessionFile = path.join(root, "s.jsonl"); + await fs.writeFile(sessionFile, ""); + const store = createReconciliationStore({ sessionFile, sessionId: "s1" }); + // Initial marker (plan step 4): pending, publication false, response pending. + await store.transactTerminalScopes(() => [ + { + selection: "turn", + idempotencyKeyHash: "k1", + idempotencyInputHash: "i1", + turnDisposition: "pending", + terminalPublished: false, + ownedWorkDisposition: "not_requested", + automaticDeliveryDisposition: "enabled", + resumeOnOwnedCompletion: true, + turnContinuationFence: { + state: "retained", + abortedAttemptEpoch: 3, + blockedContinuationIds: [], + predecessorTombstones: [], + ownedCompletionPolicy: "enabled", + }, + responseState: "pending", + responsePayloadHash: "i1", + acceptedAt: 1, + }, + ]); + const marker = store.snapshotTerminalScopes()[0]!; + expect(marker.turnDisposition).toBe("pending"); + expect(marker.terminalPublished).toBe(false); + // Semantic CAS (plan step 15): advance the same marker. + await store.transactTerminalScopes(scopes => + scopes.map(s => + s.idempotencyKeyHash === "k1" + ? { + ...s, + turnDisposition: "stopped" as const, + terminalPublished: true, + ownedWorkDisposition: "left_running" as const, + terminalAt: 2, + } + : s, + ), + ); + const cas = store.snapshotTerminalScopes()[0]!; + expect(cas.turnDisposition).toBe("stopped"); + expect(cas.terminalPublished).toBe(true); + expect(cas.ownedWorkDisposition).toBe("left_running"); + // Reload keeps the CASed state; restart settlement leaves a stopped scope untouched. + const reloaded = createReconciliationStore({ sessionFile, sessionId: "s1" }); + await reloaded.load(); + expect(reloaded.snapshotTerminalScopes()[0]!.turnDisposition).toBe("stopped"); + expect(settleTerminalScopeRestart(reloaded.snapshotTerminalScopes(), 9)[0]).toEqual( + reloaded.snapshotTerminalScopes()[0], + ); + await fs.rm(root, { recursive: true, force: true }); +}); + +test("response-state transition is guarded by the normalized input hash", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "sdk-recon-inputhash-")); + const sessionFile = path.join(root, "s.jsonl"); + await fs.writeFile(sessionFile, ""); + const store = createReconciliationStore({ sessionFile, sessionId: "s1" }); + const base = { + selection: "turn" as const, + idempotencyKeyHash: "k1", + ownedWorkDisposition: "left_running" as const, + automaticDeliveryDisposition: "enabled" as const, + resumeOnOwnedCompletion: true, + turnContinuationFence: { + state: "retained" as const, + abortedAttemptEpoch: 3, + blockedContinuationIds: [], + predecessorTombstones: [], + ownedCompletionPolicy: "enabled" as const, + }, + responseState: "pending" as const, + responsePayloadHash: "p", + acceptedAt: 1, + }; + await store.transactTerminalScopes(() => [ + { ...base, idempotencyInputHash: "input-turn", turnDisposition: "stopped" as const }, + { ...base, idempotencyInputHash: "input-owned", turnDisposition: "stopped" as const }, + ]); + // A response for the TURN input (matching key + input) advances only the + // turn record; the owned record (same key, different input) stays pending + // — a conflict/invalid response for a different input must never advance + // the original marker (review thread P2). + await store.transactTerminalScopes(scopes => + scopes.map(scope => + scope.idempotencyKeyHash === "k1" && + scope.idempotencyInputHash === "input-turn" && + scope.responseState === "pending" + ? { ...scope, responseState: "sent" as const } + : scope, + ), + ); + const after = store.snapshotTerminalScopes(); + expect(after.find(s => s.idempotencyInputHash === "input-turn")?.responseState).toBe("sent"); + expect(after.find(s => s.idempotencyInputHash === "input-owned")?.responseState).toBe("pending"); + await fs.rm(root, { recursive: true, force: true }); +}); + +test("no-effect terminal reservations persist and survive restart settlement", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "sdk-recon-noeffect-")); + const sessionFile = path.join(root, "s.jsonl"); + await fs.writeFile(sessionFile, ""); + const store = createReconciliationStore({ sessionFile, sessionId: "s1" }); + await store.transactTerminalScopes(() => [ + { + selection: "turn", + idempotencyKeyHash: "k1", + idempotencyInputHash: "i1", + turnDisposition: "no_effect", + ownedWorkDisposition: "not_requested", + automaticDeliveryDisposition: "enabled", + resumeOnOwnedCompletion: true, + turnContinuationFence: { + state: "retained", + abortedAttemptEpoch: 0, + blockedContinuationIds: [], + predecessorTombstones: [], + ownedCompletionPolicy: "enabled", + }, + responseState: "pending", + responsePayloadHash: "i1", + acceptedAt: 1, + }, + ]); + // Validator accepts it and restart settlement leaves a no-effect row + // untouched (only pending rows settle to uncertainty). + const reloaded = createReconciliationStore({ sessionFile, sessionId: "s1" }); + await reloaded.load(); + expect(reloaded.snapshotTerminalScopes()[0]!.turnDisposition).toBe("no_effect"); + expect(settleTerminalScopeRestart(reloaded.snapshotTerminalScopes(), 9)[0]!.turnDisposition).toBe("no_effect"); + await fs.rm(root, { recursive: true, force: true }); }); diff --git a/packages/coding-agent/test/sdk-reverse-transport-e2e.test.ts b/packages/coding-agent/test/sdk-reverse-transport-e2e.test.ts index 3b4537360..cc0c36488 100644 --- a/packages/coding-agent/test/sdk-reverse-transport-e2e.test.ts +++ b/packages/coding-agent/test/sdk-reverse-transport-e2e.test.ts @@ -20,7 +20,10 @@ test("reverse transport keeps typed lease frames isolated across reconnect and h sessionId: "s", stateRoot: "/tmp", token: "token", - sendFrame: (connectionId, frame) => server.sendTo(connectionId, JSON.stringify(frame)), + sendFrame: (connectionId, frame) => { + server.sendTo(connectionId, JSON.stringify(frame)); + return "written"; + }, onFrame: handler => { onFrame = handler; return () => { diff --git a/packages/coding-agent/test/sdk-session-readiness-lifecycle.test.ts b/packages/coding-agent/test/sdk-session-readiness-lifecycle.test.ts index e1e2425b8..f91f922aa 100644 --- a/packages/coding-agent/test/sdk-session-readiness-lifecycle.test.ts +++ b/packages/coding-agent/test/sdk-session-readiness-lifecycle.test.ts @@ -30,6 +30,7 @@ async function withHost( token: "not-persisted", sendFrame: (_connectionId, frame) => { sent.push(frame as Record); + return "written"; }, onFrame: handler => { inbound = handler; diff --git a/packages/coding-agent/test/session-manager-internal-details.test.ts b/packages/coding-agent/test/session-manager-internal-details.test.ts index 25d9b7526..c41e8ae13 100644 --- a/packages/coding-agent/test/session-manager-internal-details.test.ts +++ b/packages/coding-agent/test/session-manager-internal-details.test.ts @@ -124,6 +124,29 @@ describe("SessionManager.appendCustomMessageEntry (allowlist strip + persistence }); }); + it("F6: strips ownedCompletions (private terminal origin envelope) from persisted details", () => { + const details = { + jobs: [{ jobId: "bg_1" }], + ownedCompletions: [ + { + lineageIdHash: "private-hash", + promptAttemptEpoch: 7, + registration: { + endpointGeneration: 0, + lineageIdHash: "private-hash", + promptAttemptEpoch: 7, + jobId: "bg_1", + jobGeneration: "job:1", + }, + }, + ], + }; + const result = stripInternalDetailsFields(details); + expect(result?.ownedCompletions).toBeUndefined(); + // Public delivery fields survive the strip. + expect(result?.jobs).toEqual([{ jobId: "bg_1" }]); + }); + it("F4: stripInternalDetailsFields treats undefined / null / non-object details as identity", () => { expect(stripInternalDetailsFields(undefined)).toBeUndefined(); // `null as never` here only because the public signature is `T | undefined`, diff --git a/packages/coding-agent/test/session/terminal-abort.test.ts b/packages/coding-agent/test/session/terminal-abort.test.ts new file mode 100644 index 000000000..da40419fb --- /dev/null +++ b/packages/coding-agent/test/session/terminal-abort.test.ts @@ -0,0 +1,860 @@ +import { beforeEach, expect, test } from "bun:test"; +import { ownedCompletionResumeAction } from "../../src/session/agent-session"; +import { + bindToolLineage, + boundCompletedTerminalScopeRows, + classifyOwnedCompletion, + classifyOwnedEnvelope, + createTurnContinuationSeam, + type DeliveryOrigin, + findOwnedRegistrationsForTurn, + isOwnedCompletionEnvelopeAllowed, + lookupOwnedRegistration, + lookupTerminalScope, + mintTurnLineageIdHash, + newTerminalScopeId, + nextPromptAttemptEpoch, + registerOwnedIfLineaged, + registerOwnedRegistration, + registerTerminalScope, + registerTerminalTurnScope, + resetTerminalAbortRegistriesForTests, + resolveToolLineage, + settleOwnedWork, + type TurnRegistrationKey, + unbindToolLineage, + unregisterOwnedRegistration, + unregisterTerminalScope, +} from "../../src/session/terminal-abort"; + +beforeEach(() => { + // Isolate the process-lifetime registries per test (job ids/generations + // collide across tests; bindings/scopes persist otherwise). + resetTerminalAbortRegistriesForTests(); +}); + +const registration: TurnRegistrationKey = { + endpointGeneration: 1, + lineageIdHash: "lineage-a", + promptAttemptEpoch: 7, + jobId: "job-1", + jobGeneration: "gen-1", +}; + +const continuation = (id: string): DeliveryOrigin => ({ + kind: "turn-continuation", + lineageIdHash: "lineage-a", + attemptEpoch: 7, + continuationId: id, +}); + +const owned = ( + originOverrides: Partial< + Pick, "lineageIdHash" | "attemptEpoch"> + > = {}, + registrationOverrides: Partial = {}, +): DeliveryOrigin => ({ + kind: "owned-completion", + lineageIdHash: "lineage-a", + attemptEpoch: 7, + ...originOverrides, + registration: { ...registration, ...registrationOverrides }, +}); + +test("fence starts open and closes synchronously", () => { + const { fence, gate } = createTurnContinuationSeam({ + lineageIdHash: "lineage-a", + abortedAttemptEpoch: 7, + terminalScopeId: "scope-1", + }); + expect(fence.state).toBe("open"); + gate.close("terminal-turn"); + expect(fence.state).toBe("closed"); +}); + +test("post-close same-turn continuations are denied; pre-close predecessors allowed", () => { + const { gate } = createTurnContinuationSeam({ + lineageIdHash: "lineage-a", + abortedAttemptEpoch: 7, + terminalScopeId: "scope-1", + }); + // Linearize a predecessor before close. + expect(gate.authorizeContinuation(continuation("pre-1"))).toBe("allow-predecessor"); + gate.close("terminal-turn"); + // A different continuation after close is denied. + expect(gate.authorizeContinuation(continuation("retry-1"))).toBe("deny"); + // The pre-close predecessor remains allowed to finish its linearized work. + expect(gate.authorizeContinuation(continuation("pre-1"))).toBe("allow-predecessor"); +}); + +test("owned completions stay allowed after close (corrected semantics)", () => { + registerOwnedRegistration(registration); + const { gate } = createTurnContinuationSeam({ + lineageIdHash: "lineage-a", + abortedAttemptEpoch: 7, + terminalScopeId: "scope-1", + }); + gate.close("terminal-turn"); + // Left-running owned completion is intentionally delivered as a fresh turn. + expect(gate.authorizeOwnedCompletion(owned())).toBe("allow-new-turn"); + // Before close it is allowed too. + const open = createTurnContinuationSeam({ + lineageIdHash: "lineage-a", + abortedAttemptEpoch: 7, + terminalScopeId: "scope-2", + }); + expect(open.gate.authorizeOwnedCompletion(owned())).toBe("allow-new-turn"); + unregisterOwnedRegistration(registration); +}); + +test("owned completion fails closed on mismatched or missing metadata", () => { + registerOwnedRegistration(registration); + const { gate } = createTurnContinuationSeam({ + lineageIdHash: "lineage-a", + abortedAttemptEpoch: 7, + terminalScopeId: "scope-1", + }); + gate.close("terminal-turn"); + expect(gate.authorizeOwnedCompletion(owned({ lineageIdHash: "other" }))).toBe("deny"); + expect(gate.authorizeOwnedCompletion(owned({}, { promptAttemptEpoch: 8 }))).toBe("deny"); + expect(gate.authorizeOwnedCompletion(owned({}, { jobId: "" }))).toBe("deny"); + expect(gate.authorizeOwnedCompletion(owned({}, { jobGeneration: "" }))).toBe("deny"); + expect(gate.authorizeOwnedCompletion(owned({}, { endpointGeneration: Number.NaN }))).toBe("deny"); + // A non-owned origin is never admitted as a new turn. + expect(gate.authorizeOwnedCompletion({ kind: "ordinary", source: "monitor" })).toBe("deny"); + expect(gate.authorizeOwnedCompletion(continuation("x"))).toBe("deny"); + unregisterOwnedRegistration(registration); +}); + +test("owned completion gate denies forged or unregistered registration tuples", () => { + const { gate } = createTurnContinuationSeam({ + lineageIdHash: "lineage-a", + abortedAttemptEpoch: 7, + terminalScopeId: "scope-1", + }); + gate.close("terminal-turn"); + // The tuple is NOT registered: even with a matching lineage/epoch the gate + // must fail closed (AC 25 — missing/copied/mismatched origin never + // authorizes an automatic call). + expect(gate.authorizeOwnedCompletion(owned())).toBe("deny"); + // A registered tuple with a FORGED job generation is denied. + registerOwnedRegistration(registration); + expect(gate.authorizeOwnedCompletion(owned({}, { jobGeneration: "forged-gen" }))).toBe("deny"); + // A registered tuple with a FORGED endpoint generation is denied. + expect(gate.authorizeOwnedCompletion(owned({}, { endpointGeneration: 99 }))).toBe("deny"); + // The exact registered tuple is allowed. + expect(gate.authorizeOwnedCompletion(owned())).toBe("allow-new-turn"); + unregisterOwnedRegistration(registration); +}); + +test("disabled owned completion policy blocks new turns", () => { + const { gate } = createTurnContinuationSeam({ + lineageIdHash: "lineage-a", + abortedAttemptEpoch: 7, + terminalScopeId: "scope-1", + ownedCompletionPolicy: "disabled", + }); + gate.close("terminal-turn"); + expect(gate.authorizeOwnedCompletion(owned())).toBe("deny"); +}); + +test("fresh attempt epochs are monotonic and scope ids are unique", () => { + const a = nextPromptAttemptEpoch(); + const b = nextPromptAttemptEpoch(); + expect(b).toBeGreaterThan(a); + expect(newTerminalScopeId()).not.toBe(newTerminalScopeId()); +}); +test("lineage bindings round-trip and fail closed", () => { + const binding = { + lineageIdHash: mintTurnLineageIdHash("session-1", 3, "secret-1"), + promptAttemptEpoch: 3, + endpointGeneration: 0, + }; + expect(resolveToolLineage("call-1")).toBeUndefined(); + bindToolLineage("call-1", binding); + expect(resolveToolLineage("call-1")).toEqual(binding); + expect(resolveToolLineage(undefined)).toBeUndefined(); + unbindToolLineage("call-1"); + expect(resolveToolLineage("call-1")).toBeUndefined(); + // A rebind supersedes the prior binding on the same id. + bindToolLineage("call-1", { ...binding, promptAttemptEpoch: 4 }); + expect(resolveToolLineage("call-1")?.promptAttemptEpoch).toBe(4); +}); + +test("mintTurnLineageIdHash is deterministic per inputs and opaque across epochs/secrets", () => { + const a = mintTurnLineageIdHash("session-1", 3, "secret-1"); + expect(a).toBe(mintTurnLineageIdHash("session-1", 3, "secret-1")); + expect(a).not.toBe(mintTurnLineageIdHash("session-1", 4, "secret-1")); + expect(a).not.toBe(mintTurnLineageIdHash("session-2", 3, "secret-1")); + expect(a).not.toBe(mintTurnLineageIdHash("session-1", 3, "secret-2")); + // The hash is opaque: it never embeds the raw inputs. + expect(a).not.toContain("session-1"); + expect(a).not.toContain("secret-1"); +}); + +test("owned registrations round-trip, dedupe, and unregister", () => { + expect(lookupOwnedRegistration("job-1", "gen-1")).toBeUndefined(); + registerOwnedRegistration(registration); + expect(lookupOwnedRegistration("job-1", "gen-1")).toEqual(registration); + // Same exact key is deduplicated, not re-inserted. + registerOwnedRegistration(registration); + unregisterOwnedRegistration(registration); + expect(lookupOwnedRegistration("job-1", "gen-1")).toBeUndefined(); + // A different generation is a distinct registration. + registerOwnedRegistration(registration); + registerOwnedRegistration({ ...registration, jobGeneration: "gen-2" }); + expect(lookupOwnedRegistration("job-1", "gen-2")).toBeDefined(); + expect(lookupOwnedRegistration("job-1", "gen-1")).toBeDefined(); + unregisterOwnedRegistration(registration); + unregisterOwnedRegistration({ ...registration, jobGeneration: "gen-2" }); +}); + +test("terminal scopes round-trip by exact lineage+epoch and unregister", () => { + const { fence, gate } = createTurnContinuationSeam({ + lineageIdHash: "lineage-a", + abortedAttemptEpoch: 7, + terminalScopeId: "scope-1", + }); + expect(lookupTerminalScope("lineage-a", 7)).toBeUndefined(); + registerTerminalScope({ scopeId: "scope-1", lineageIdHash: "lineage-a", abortedAttemptEpoch: 7, gate, fence }); + expect(lookupTerminalScope("lineage-a", 7)?.scopeId).toBe("scope-1"); + // Different epoch/lineage does not resolve to this scope. + expect(lookupTerminalScope("lineage-a", 8)).toBeUndefined(); + expect(lookupTerminalScope("lineage-other", 7)).toBeUndefined(); + unregisterTerminalScope("scope-1"); + expect(lookupTerminalScope("lineage-a", 7)).toBeUndefined(); +}); + +test("registerOwnedIfLineaged records the exact five-tuple when lineage is bound", () => { + bindToolLineage("call-t", { + lineageIdHash: "lineage-a", + promptAttemptEpoch: 7, + endpointGeneration: 4, + }); + const manager = { getJob: () => ({ generation: "gen-9" }) }; + registerOwnedIfLineaged(manager, "call-t", "job-9"); + expect(lookupOwnedRegistration("job-9", "gen-9")).toEqual({ + endpointGeneration: 4, + lineageIdHash: "lineage-a", + promptAttemptEpoch: 7, + jobId: "job-9", + jobGeneration: "gen-9", + }); + unregisterOwnedRegistration({ ...registration, jobId: "job-9", jobGeneration: "gen-9" }); +}); + +test("registerOwnedIfLineaged fails closed on missing lineage, generation, or manager", () => { + const manager = { getJob: () => ({ generation: "gen-1" }) }; + // No bound lineage for this tool call -> no ownership claim. + registerOwnedIfLineaged(manager, "unbound-call", "job-1"); + expect(lookupOwnedRegistration("job-1", "gen-1")).toBeUndefined(); + // Bound lineage but missing job generation -> fails closed. + bindToolLineage("call-2", { + lineageIdHash: "lineage-a", + promptAttemptEpoch: 7, + endpointGeneration: 4, + }); + registerOwnedIfLineaged({}, "call-2", "job-2"); + expect(lookupOwnedRegistration("job-2", "gen-1")).toBeUndefined(); + // A throwing manager never breaks ordinary registration. + bindToolLineage("call-3", { + lineageIdHash: "lineage-a", + promptAttemptEpoch: 7, + endpointGeneration: 4, + }); + expect(() => + registerOwnedIfLineaged( + { + getJob: () => { + throw new Error("boom"); + }, + }, + "call-3", + "job-3", + ), + ).not.toThrow(); + expect(lookupOwnedRegistration("job-3", "never-registered")).toBeUndefined(); +}); + +test("terminal scope registry evicts oldest beyond its bound", () => { + for (let i = 0; i < 1025; i++) { + registerTerminalScope({ + scopeId: `scope-evict-${i}`, + lineageIdHash: `lineage-evict-${i}`, + abortedAttemptEpoch: i, + gate: { close() {}, authorizeContinuation: () => "deny", authorizeOwnedCompletion: () => "deny" }, + fence: { + state: "open", + lineageIdHash: `lineage-evict-${i}`, + abortedAttemptEpoch: i, + terminalScopeId: `scope-evict-${i}`, + blockedContinuationIds: new Set(), + predecessorTombstones: new Set(), + ownedCompletionPolicy: "enabled", + }, + }); + } + // The oldest registration was evicted; the newest survives. + expect(lookupTerminalScope("lineage-evict-0", 0)).toBeUndefined(); + expect(lookupTerminalScope("lineage-evict-1024", 1024)).toBeDefined(); + unregisterTerminalScope("scope-evict-1024"); +}); +test("classifyOwnedCompletion resolves only for exact registration plus terminal scope", () => { + // No registration -> ordinary. + expect(classifyOwnedCompletion("job-x", "gen-x")).toBeUndefined(); + // Registered but no terminal scope for its turn -> ordinary (fail closed). + registerOwnedRegistration(registration); + expect(classifyOwnedCompletion("job-1", "gen-1")).toBeUndefined(); + // Missing generation -> ordinary. + expect(classifyOwnedCompletion("job-1", undefined)).toBeUndefined(); + // Terminal scope for the exact lineage+epoch -> owned-completion. + const { fence, gate } = createTurnContinuationSeam({ + lineageIdHash: "lineage-a", + abortedAttemptEpoch: 7, + terminalScopeId: "scope-1", + }); + registerTerminalScope({ scopeId: "scope-1", lineageIdHash: "lineage-a", abortedAttemptEpoch: 7, gate, fence }); + const classified = classifyOwnedCompletion("job-1", "gen-1"); + expect(classified).toEqual({ + lineageIdHash: "lineage-a", + promptAttemptEpoch: 7, + registration, + terminalScopeId: "scope-1", + }); + // A different generation of the same job id is NOT owned (exact tuple). + expect(classifyOwnedCompletion("job-1", "gen-other")).toBeUndefined(); + unregisterTerminalScope("scope-1"); + unregisterOwnedRegistration(registration); +}); + +test("classifyOwnedCompletion fails closed when the scope is removed or epoch mismatches", () => { + registerOwnedRegistration(registration); + const { fence, gate } = createTurnContinuationSeam({ + lineageIdHash: "lineage-a", + abortedAttemptEpoch: 7, + terminalScopeId: "scope-1", + }); + registerTerminalScope({ scopeId: "scope-1", lineageIdHash: "lineage-a", abortedAttemptEpoch: 7, gate, fence }); + expect(classifyOwnedCompletion("job-1", "gen-1")).toBeDefined(); + unregisterTerminalScope("scope-1"); + // After the scope is gone, the same delivery is ordinary again. + expect(classifyOwnedCompletion("job-1", "gen-1")).toBeUndefined(); + unregisterOwnedRegistration(registration); +}); +test("registerTerminalTurnScope registers a synchronously closed scope for the turn", () => { + const { scopeId, lineageIdHash, promptAttemptEpoch, seam } = registerTerminalTurnScope({ + lineageIdHash: "lineage-turn-1", + promptAttemptEpoch: 9, + }); + expect(seam.fence.state).toBe("closed"); + expect(seam.fence.ownedCompletionPolicy).toBe("enabled"); + expect(seam.fence.abortedAttemptEpoch).toBe(9); + // The scope is lookup-able by the exact lineage+epoch. + const found = lookupTerminalScope("lineage-turn-1", 9); + expect(found?.scopeId).toBe(scopeId); + expect(found?.lineageIdHash).toBe(lineageIdHash); + expect(found?.abortedAttemptEpoch).toBe(promptAttemptEpoch); + // Post-close same-turn continuations are denied; owned completions allowed. + expect(seam.gate.authorizeContinuation(continuation("retry-x"))).toBe("deny"); + unregisterTerminalScope(scopeId); + expect(lookupTerminalScope("lineage-turn-1", 9)).toBeUndefined(); +}); + +test("registerTerminalTurnScope with owned policy disables owned-completion delivery", () => { + const { seam } = registerTerminalTurnScope({ + lineageIdHash: "lineage-turn-2", + promptAttemptEpoch: 11, + ownedCompletionPolicy: "disabled", + }); + expect(seam.fence.ownedCompletionPolicy).toBe("disabled"); + expect( + seam.gate.authorizeOwnedCompletion( + owned( + { lineageIdHash: "lineage-turn-2", attemptEpoch: 11 }, + { ...registration, lineageIdHash: "lineage-turn-2", promptAttemptEpoch: 11 }, + ), + ), + ).toBe("deny"); + unregisterTerminalScope(seam.fence.terminalScopeId); +}); + +test("a registered terminal turn scope makes a matching owned job classify as owned-completion", () => { + registerTerminalTurnScope({ lineageIdHash: "lineage-chain", promptAttemptEpoch: 13 }); + registerOwnedRegistration({ ...registration, lineageIdHash: "lineage-chain", promptAttemptEpoch: 13 }); + const classified = classifyOwnedCompletion("job-1", "gen-1"); + expect(classified).toEqual({ + lineageIdHash: "lineage-chain", + promptAttemptEpoch: 13, + registration: { ...registration, lineageIdHash: "lineage-chain", promptAttemptEpoch: 13 }, + terminalScopeId: expect.any(String), + }); + unregisterOwnedRegistration({ ...registration, lineageIdHash: "lineage-chain", promptAttemptEpoch: 13 }); +}); +test("findOwnedRegistrationsForTurn returns only exact lineage+epoch registrations", () => { + registerOwnedRegistration({ ...registration, lineageIdHash: "lineage-a", promptAttemptEpoch: 7 }); + registerOwnedRegistration({ + ...registration, + jobId: "job-2", + lineageIdHash: "lineage-a", + promptAttemptEpoch: 7, + }); + registerOwnedRegistration({ + ...registration, + jobId: "job-foreign", + lineageIdHash: "lineage-other", + promptAttemptEpoch: 7, + }); + registerOwnedRegistration({ + ...registration, + jobId: "job-later", + lineageIdHash: "lineage-a", + promptAttemptEpoch: 8, + }); + const exact = findOwnedRegistrationsForTurn("lineage-a", 7); + expect(exact.map(key => key.jobId).sort()).toEqual(["job-1", "job-2"]); + // Foreign lineage and a different epoch are never captured. + expect(findOwnedRegistrationsForTurn("lineage-other", 7).map(key => key.jobId)).toEqual(["job-foreign"]); + expect(findOwnedRegistrationsForTurn("lineage-a", 8).map(key => key.jobId)).toEqual(["job-later"]); + expect(findOwnedRegistrationsForTurn("lineage-none", 7)).toEqual([]); + unregisterOwnedRegistration({ ...registration, lineageIdHash: "lineage-a", promptAttemptEpoch: 7 }); + unregisterOwnedRegistration({ + ...registration, + jobId: "job-2", + lineageIdHash: "lineage-a", + promptAttemptEpoch: 7, + }); + unregisterOwnedRegistration({ + ...registration, + jobId: "job-foreign", + lineageIdHash: "lineage-other", + promptAttemptEpoch: 7, + }); + unregisterOwnedRegistration({ + ...registration, + jobId: "job-later", + lineageIdHash: "lineage-a", + promptAttemptEpoch: 8, + }); +}); + +test("settleOwnedWork stops exact jobs, purges deliveries, and returns stopped", async () => { + const cancelled: string[] = []; + const purged: string[][] = []; + const jobs = new Map([ + ["job-1", { generation: "gen-1", status: "running" }], + ["job-2", { generation: "gen-1", status: "running" }], + ]); + const manager = { + cancel: (jobId: string) => { + cancelled.push(jobId); + const job = jobs.get(jobId); + if (job && job.status !== "paused") job.status = "cancelled"; + return true; + }, + getJob: (jobId: string) => jobs.get(jobId), + acknowledgeDeliveries: (jobIds: string[]) => { + purged.push(jobIds); + return jobIds.length; + }, + }; + const outcome = await settleOwnedWork( + manager, + [ + { ...registration, jobId: "job-1", jobGeneration: "gen-1" }, + { ...registration, jobId: "job-2", jobGeneration: "gen-1" }, + ], + 5, + ); + expect(outcome).toBe("stopped"); + expect(cancelled.sort()).toEqual(["job-1", "job-2"]); + expect(purged).toEqual([["job-1", "job-2"]]); +}); + +test("settleOwnedWork fails closed on a reused id with a new generation (no foreign sweep)", async () => { + const cancelled: string[] = []; + const purged: string[][] = []; + const manager = { + cancel: (jobId: string) => { + cancelled.push(jobId); + return true; + }, + getJob: (jobId: string) => (jobId === "job-1" ? { generation: "gen-2", status: "running" } : undefined), + acknowledgeDeliveries: (jobIds: string[]) => { + purged.push(jobIds); + return jobIds.length; + }, + }; + const outcome = await settleOwnedWork(manager, [{ ...registration, jobId: "job-1", jobGeneration: "gen-1" }], 5); + expect(outcome).toBe("unsettled"); + // The foreign (reused) job must NOT be cancelled or purged. + expect(cancelled).toEqual([]); + expect(purged).toEqual([]); +}); + +test("settleOwnedWork fails closed when a captured job is still running or missing after grace", async () => { + const running = { + cancel: () => true, + getJob: () => ({ generation: "gen-1", status: "running" }), + acknowledgeDeliveries: () => 0, + }; + expect(await settleOwnedWork(running, [registration], 2)).toBe("unsettled"); + + const missing = { + cancel: () => true, + getJob: () => undefined, + acknowledgeDeliveries: () => 0, + }; + expect(await settleOwnedWork(missing, [registration], 2)).toBe("unsettled"); + + const paused = { + cancel: (_jobId: string) => true, + getJob: () => ({ generation: "gen-1", status: "paused" }), + acknowledgeDeliveries: () => 0, + }; + expect(await settleOwnedWork(paused, [registration], 2)).toBe("unsettled"); +}); + +test("settleOwnedWork fails closed when the job id is reused with a new generation during grace", async () => { + const cancelled: string[] = []; + const purged: string[][] = []; + let generation = "gen-1"; + const manager = { + cancel: (jobId: string) => { + cancelled.push(jobId); + return true; + }, + getJob: () => ({ generation, status: "cancelled" }), + acknowledgeDeliveries: (jobIds: string[]) => { + purged.push(jobIds); + return jobIds.length; + }, + }; + // The job id is reused with a NEW generation between the cancel and the + // second proof: the foreign job must not be claimed or purged. + const settling = settleOwnedWork(manager, [registration], 20); + generation = "gen-2"; + const outcome = await settling; + expect(outcome).toBe("unsettled"); + expect(purged).toEqual([]); + // Only the exact captured generation was cancelled; the foreign job record + // was left untouched (no post-grace claim of it). + expect(cancelled).toEqual(["job-1"]); +}); + +test("ownedCompletionResumeAction drops denied owned deliveries at the injector boundary", async () => { + // An ordinary async-result message (no envelope) delivers as before. + expect(ownedCompletionResumeAction({ role: "custom", customType: "async-result" } as never)).toBe("ordinary"); + // A scope:"turn" envelope with the exact registered tuple resumes fresh. + const turnScope = registerTerminalTurnScope({ lineageIdHash: "lineage-drop", promptAttemptEpoch: 21 }); + registerOwnedRegistration({ + ...registration, + jobId: "job-drop", + jobGeneration: "gen-drop", + lineageIdHash: "lineage-drop", + promptAttemptEpoch: 21, + }); + const freshMessage = { + details: { + ownedCompletions: [ + { + lineageIdHash: "lineage-drop", + promptAttemptEpoch: 21, + registration: { + ...registration, + jobId: "job-drop", + jobGeneration: "gen-drop", + lineageIdHash: "lineage-drop", + promptAttemptEpoch: 21, + }, + }, + ], + }, + } as never; + expect(ownedCompletionResumeAction(freshMessage)).toBe("fresh"); + // A scope:"owned" envelope (policy disabled) is DROPPED — stopped work must + // never call followUp/prompt even if a delivery races the purge. + const ownedScope = registerTerminalTurnScope({ + lineageIdHash: "lineage-owned", + promptAttemptEpoch: 22, + ownedCompletionPolicy: "disabled", + }); + registerOwnedRegistration({ + ...registration, + jobId: "job-owned", + jobGeneration: "gen-owned", + lineageIdHash: "lineage-owned", + promptAttemptEpoch: 22, + }); + const ownedMessage = { + details: { + ownedCompletions: [ + { + lineageIdHash: "lineage-owned", + promptAttemptEpoch: 22, + registration: { + ...registration, + jobId: "job-owned", + jobGeneration: "gen-owned", + lineageIdHash: "lineage-owned", + promptAttemptEpoch: 22, + }, + }, + ], + }, + } as never; + expect(ownedCompletionResumeAction(ownedMessage)).toBe("drop"); + // A forged/unregistered tuple is dropped. + expect(ownedCompletionResumeAction(freshMessage)).toBe("fresh"); + expect( + ownedCompletionResumeAction({ + details: { + ownedCompletions: [ + { + lineageIdHash: "lineage-drop", + promptAttemptEpoch: 21, + registration: { + ...registration, + jobId: "job-drop", + lineageIdHash: "lineage-drop", + promptAttemptEpoch: 21, + jobGeneration: "forged", + }, + }, + ], + }, + } as never), + ).toBe("drop"); + // An envelope whose scope no longer exists is ORDINARY: ownership is kept + // on the entry regardless of scope (P1), and no active scope means normal + // delivery — the owned-drop applies only to an existing disabled scope. + unregisterTerminalScope(turnScope.scopeId); + expect(ownedCompletionResumeAction(freshMessage)).toBe("ordinary"); + unregisterTerminalScope(ownedScope.scopeId); + unregisterOwnedRegistration({ + ...registration, + jobId: "job-drop", + jobGeneration: "gen-drop", + lineageIdHash: "lineage-drop", + promptAttemptEpoch: 21, + }); + unregisterOwnedRegistration({ + ...registration, + jobId: "job-owned", + jobGeneration: "gen-owned", + lineageIdHash: "lineage-owned", + promptAttemptEpoch: 22, + }); +}); + +test("mixed owned-completion batches drop when ANY envelope is denied", async () => { + // Allowed turn-scope envelope (distinct job key from the denied fixture: + // the registry overwrites reused (jobId, generation) tuples). + const turnScope = registerTerminalTurnScope({ lineageIdHash: "lineage-mix-a", promptAttemptEpoch: 31 }); + registerOwnedRegistration({ + ...registration, + jobId: "job-mix-a", + jobGeneration: "gen-mix-a", + lineageIdHash: "lineage-mix-a", + promptAttemptEpoch: 31, + }); + const allowed = { + lineageIdHash: "lineage-mix-a", + promptAttemptEpoch: 31, + registration: { + ...registration, + jobId: "job-mix-a", + jobGeneration: "gen-mix-a", + lineageIdHash: "lineage-mix-a", + promptAttemptEpoch: 31, + }, + }; + // Denied owned-scope envelope (policy disabled). + const ownedScope = registerTerminalTurnScope({ + lineageIdHash: "lineage-mix-b", + promptAttemptEpoch: 32, + ownedCompletionPolicy: "disabled", + }); + registerOwnedRegistration({ + ...registration, + jobId: "job-mix-b", + jobGeneration: "gen-mix-b", + lineageIdHash: "lineage-mix-b", + promptAttemptEpoch: 32, + }); + const denied = { + lineageIdHash: "lineage-mix-b", + promptAttemptEpoch: 32, + registration: { + ...registration, + jobId: "job-mix-b", + jobGeneration: "gen-mix-b", + lineageIdHash: "lineage-mix-b", + promptAttemptEpoch: 32, + }, + }; + const message = (ownedCompletions: unknown[]) => ({ details: { ownedCompletions } }) as never; + // Allowed-then-denied and denied-then-allowed orderings both drop. + expect(ownedCompletionResumeAction(message([allowed, denied]))).toBe("drop"); + expect(ownedCompletionResumeAction(message([denied, allowed]))).toBe("drop"); + // All-allowed stays fresh; no envelope is ordinary. + expect(ownedCompletionResumeAction(message([allowed, allowed]))).toBe("fresh"); + expect(ownedCompletionResumeAction(message([]))).toBe("ordinary"); + // Build-time partitioning predicate: a denied envelope is never allowed. + expect(isOwnedCompletionEnvelopeAllowed(denied)).toBe(false); + expect(isOwnedCompletionEnvelopeAllowed(allowed)).toBe(true); + unregisterTerminalScope(turnScope.scopeId); + unregisterTerminalScope(ownedScope.scopeId); + unregisterOwnedRegistration({ + ...registration, + jobId: "job-mix-a", + jobGeneration: "gen-mix-a", + lineageIdHash: "lineage-mix-a", + promptAttemptEpoch: 31, + }); + unregisterOwnedRegistration({ + ...registration, + jobId: "job-mix-b", + jobGeneration: "gen-mix-b", + lineageIdHash: "lineage-mix-b", + promptAttemptEpoch: 32, + }); +}); + +test("pre-abort completion keeps ownership and is dropped once an owned scope lands", async () => { + // A job completes BEFORE any terminal abort: registered but no scope yet. + registerOwnedRegistration({ ...registration, lineageIdHash: "lineage-p1", promptAttemptEpoch: 51 }); + const envelope = { + lineageIdHash: "lineage-p1", + promptAttemptEpoch: 51, + registration: { ...registration, lineageIdHash: "lineage-p1", promptAttemptEpoch: 51 }, + }; + const message = { details: { ownedCompletions: [envelope] } } as never; + // No scope -> ordinary (normal delivery; ownership preserved on the entry). + expect(ownedCompletionResumeAction(message)).toBe("ordinary"); + expect(isOwnedCompletionEnvelopeAllowed(envelope)).toBe(true); + // The owned scope lands AFTER the completion was queued: the same entry is + // now classified as owned-stopped work and must be dropped, so the queued + // async result can never resume the agent (review thread P1). + registerTerminalTurnScope({ + lineageIdHash: "lineage-p1", + promptAttemptEpoch: 51, + ownedCompletionPolicy: "disabled", + }); + expect(ownedCompletionResumeAction(message)).toBe("drop"); + expect(isOwnedCompletionEnvelopeAllowed(envelope)).toBe(false); +}); + +test("registerOwnedRegistration overwrites a reused tuple from a different turn", () => { + registerOwnedRegistration(registration); + // Same tuple, same lineage -> idempotent no-op. + registerOwnedRegistration(registration); + expect(lookupOwnedRegistration("job-1", "gen-1")).toEqual(registration); + // Reused (jobId, generation) with a DIFFERENT lineage (fresh manager after + // session replacement restarts ids at bg_1/job:1) must OVERWRITE so the new + // job binds to its own turn (review thread P1). + const fresh = { ...registration, lineageIdHash: "lineage-new-session", promptAttemptEpoch: 99 }; + registerOwnedRegistration(fresh); + expect(lookupOwnedRegistration("job-1", "gen-1")).toEqual(fresh); + unregisterOwnedRegistration(fresh); +}); + +test("boundCompletedTerminalScopeRows evicts the oldest completed rows but never pending markers", () => { + const rows: Array<{ + idempotencyKeyHash: string; + idempotencyInputHash: string; + turnDisposition: string; + acceptedAt: number; + }> = []; + for (let i = 0; i < 300; i++) { + rows.push({ + idempotencyKeyHash: `k${i}`, + idempotencyInputHash: `i${i}`, + turnDisposition: "no_effect", + acceptedAt: i, + }); + } + rows.push({ + idempotencyKeyHash: "stopped-key", + idempotencyInputHash: "i", + turnDisposition: "stopped", + acceptedAt: 0, + }); + rows.push({ + idempotencyKeyHash: "pending-key", + idempotencyInputHash: "i", + turnDisposition: "pending", + acceptedAt: 0, + }); + const bounded = boundCompletedTerminalScopeRows(rows, 256); + // 300 completed + 1 stopped -> 256 completed kept (45 oldest evicted); the + // pending marker is NEVER evicted. + expect(bounded.filter((r: { turnDisposition: string }) => r.turnDisposition !== "pending")).toHaveLength(256); + expect(bounded.some((r: { idempotencyKeyHash: string }) => r.idempotencyKeyHash === "k0")).toBe(false); + expect(bounded.some((r: { idempotencyKeyHash: string }) => r.idempotencyKeyHash === "k43")).toBe(false); + expect(bounded.some((r: { idempotencyKeyHash: string }) => r.idempotencyKeyHash === "k44")).toBe(true); + expect(bounded.some((r: { idempotencyKeyHash: string }) => r.idempotencyKeyHash === "k299")).toBe(true); + expect(bounded.some((r: { idempotencyKeyHash: string }) => r.idempotencyKeyHash === "stopped-key")).toBe(false); + expect(bounded.some((r: { idempotencyKeyHash: string }) => r.idempotencyKeyHash === "pending-key")).toBe(true); +}); + +test("evicted terminal scopes retain their attempt policy via a compact tombstone", () => { + // Register a turn-scope attempt, then overflow the scope cap so it is evicted. + registerTerminalTurnScope({ lineageIdHash: "lineage-tomb", promptAttemptEpoch: 5001 }); + registerOwnedRegistration({ + ...registration, + jobId: "job-tomb", + jobGeneration: "gen-tomb", + lineageIdHash: "lineage-tomb", + promptAttemptEpoch: 5001, + }); + // The runnable cap check: register MAX_ACTIVE_TERMINAL_SCOPES more scopes to + // force eviction of the first (FIFO). The oldest scope is the tomb one. + for (let i = 0; i < 1024; i++) { + registerTerminalTurnScope({ lineageIdHash: `lineage-fill-${i}`, promptAttemptEpoch: 10000 + i }); + } + expect(lookupTerminalScope("lineage-tomb", 5001)).toBeUndefined(); + // A still-running turn-scope owned completion from the evicted attempt must + // STILL classify as fresh (resume), not degrade to ordinary. + const turnEnvelope = { + lineageIdHash: "lineage-tomb", + promptAttemptEpoch: 5001, + registration: { + ...registration, + jobId: "job-tomb", + jobGeneration: "gen-tomb", + lineageIdHash: "lineage-tomb", + promptAttemptEpoch: 5001, + }, + }; + expect(classifyOwnedEnvelope(turnEnvelope)).toBe("fresh"); + // An owned-scope evicted policy drops. + registerOwnedRegistration({ + ...registration, + jobId: "job-tomb-owned", + jobGeneration: "gen-tomb-owned", + lineageIdHash: "lineage-tomb-owned", + promptAttemptEpoch: 6001, + }); + registerTerminalTurnScope({ + lineageIdHash: "lineage-tomb-owned", + promptAttemptEpoch: 6001, + ownedCompletionPolicy: "disabled", + }); + for (let i = 0; i < 1024; i++) { + registerTerminalTurnScope({ lineageIdHash: `lineage-fill2-${i}`, promptAttemptEpoch: 20000 + i }); + } + expect(lookupTerminalScope("lineage-tomb-owned", 6001)).toBeUndefined(); + expect( + classifyOwnedEnvelope({ + lineageIdHash: "lineage-tomb-owned", + promptAttemptEpoch: 6001, + registration: { + ...registration, + jobId: "job-tomb-owned", + jobGeneration: "gen-tomb-owned", + lineageIdHash: "lineage-tomb-owned", + promptAttemptEpoch: 6001, + }, + }), + ).toBe("drop"); +});