From 275bde38e56995cdcb076ef96b41d7a108ffe591 Mon Sep 17 00:00:00 2001 From: snowykr Date: Wed, 5 Aug 2026 21:37:46 +0900 Subject: [PATCH 01/30] feat(sdk): add validated C04 terminal abort input contract The approved abort-SDK plan extends turn.abort with optional terminal mode and a caller-selected scope (turn|owned). Dispatch previously dropped C04 input entirely; this lands the strict, side-effect-free validation layer: terminal mode accepts only mode/scope fields, requires a nonempty envelope idempotency key (<=128 UTF-8 bytes), rejects force/timeout/unknown fields with invalid_input, and forwards normalized input to a new private abortTerminal surface hook. Legacy {} and mode:"turn" keep the argument-less abort path byte-for-byte compatible. Terminal semantics per the reconciled plan: stop the root worker's current turn and block only its own continuation routes; left-running owned work's completions are delivered normally so the root worker can resume with a fresh attempt (owned delivery is intentionally NOT suppressed). Lore-id: c04-terminal-contract Constraint: legacy C04 {} / ACP cancel() must stay key-optional and unchanged Constraint: terminal validation must run before any surface side effect Rejected: reusing watchJobs/acknowledgeDeliveries as a turn fence | broad identity, no origin Tested: 19/19 sdk-control-dispatch tests incl. keyless/oversized-key/invalid-mode/scope/unknown-field rejections and legacy passthrough Not-tested: bus-level terminal transaction, lineage/fence, owned settlement, queue/gate --- .../src/sdk/host/control/dispatch.ts | 38 ++++- .../src/sdk/host/control/operations.ts | 15 ++ .../test/sdk-control-dispatch.test.ts | 158 ++++++++++++++---- 3 files changed, 182 insertions(+), 29 deletions(-) diff --git a/packages/coding-agent/src/sdk/host/control/dispatch.ts b/packages/coding-agent/src/sdk/host/control/dispatch.ts index a326adde7b..8a9fb8d690 100644 --- a/packages/coding-agent/src/sdk/host/control/dispatch.ts +++ b/packages/coding-agent/src/sdk/host/control/dispatch.ts @@ -128,6 +128,42 @@ 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 }); +} function invoke( surface: ControlSurface, operation: string, @@ -143,7 +179,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": diff --git a/packages/coding-agent/src/sdk/host/control/operations.ts b/packages/coding-agent/src/sdk/host/control/operations.ts index a68b3bbe88..4809c7d268 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,8 @@ 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). */ + abortTerminal?(input: TerminalAbortInput): Promise | ControlValue; abortAndPrompt(text: string): Promise | ControlValue; answerAsk(id: string, answer: ControlValue): Promise | ControlValue; answerGate( diff --git a/packages/coding-agent/test/sdk-control-dispatch.test.ts b/packages/coding-agent/test/sdk-control-dispatch.test.ts index 77a8888562..10d117d1e3 100644 --- a/packages/coding-agent/test/sdk-control-dispatch.test.ts +++ b/packages/coding-agent/test/sdk-control-dispatch.test.ts @@ -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,98 @@ 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) => { + calls.push(input as Record); + 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" }]); + expect(await terminal({ mode: "terminal", scope: "owned" }, "key-2")).toEqual({ + id: "t", + ok: true, + result: "terminal", + }); + expect(calls).toEqual([ + { mode: "terminal", scope: "turn" }, + { mode: "terminal", scope: "owned" }, + ]); +}); + +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([[], [], []]); +}); From a0c2c43c6c43a3f157a03d3fe205a69ef6f47fc3 Mon Sep 17 00:00:00 2001 From: snowykr Date: Wed, 5 Aug 2026 22:33:29 +0900 Subject: [PATCH 02/30] feat(sdk): v2 reconciliation store with durable terminal scope records Terminal abort needs a durable owner before any fence/stop/event effect. The v2 full-document store persists bounded terminal-scope records (selection, continuation fence, policy, dispositions, response state) through the single serialized owner alongside prompt/skill records; v1 documents migrate on load, malformed terminal scopes are quarantined, and incomplete pending scopes settle to safe uncertainty on restart. Covers the C04 terminal dispatch input contract (strict validation, canonical scope, required bounded key) and store persistence with migration tests. Lore-id: c04-terminal-persistence --- .../src/sdk/bus/reconciliation-store.ts | 200 +++++++++++++++++- .../test/sdk-prompt-terminal-arbiter.test.ts | 15 ++ .../test/sdk-reconciliation-store.test.ts | 114 ++++++++++ 3 files changed, 322 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/sdk/bus/reconciliation-store.ts b/packages/coding-agent/src/sdk/bus/reconciliation-store.ts index 06dcaa582b..c1549f98ed 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,41 @@ 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"; + turnDisposition: "pending" | "stopped" | "uncertain"; + 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 +161,97 @@ 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") 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 +259,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 +317,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 +342,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 +374,7 @@ export function createReconciliationStore(options: { const load = async (): Promise => { if (!filePath) { memory = []; + terminalMemory = []; return memory; } let raw: string; @@ -243,6 +385,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 +399,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 +426,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 +442,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 +478,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/test/sdk-prompt-terminal-arbiter.test.ts b/packages/coding-agent/test/sdk-prompt-terminal-arbiter.test.ts index 31b73e3912..af32173c34 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-reconciliation-store.test.ts b/packages/coding-agent/test/sdk-reconciliation-store.test.ts index de30e5fd28..507a2ad00a 100644 --- a/packages/coding-agent/test/sdk-reconciliation-store.test.ts +++ b/packages/coding-agent/test/sdk-reconciliation-store.test.ts @@ -3,11 +3,15 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { + RECONCILIATION_STORE_VERSION, + RECONCILIATION_STORE_VERSION_V1, createReconciliationStore, type DurableReconciliationRecord, + type DurableTerminalScopeRecord, isSafeReconciliationSessionId, reconciliationStorePath, settleProcessRestart, + settleTerminalScopeRestart, } from "../src/sdk/bus/reconciliation-store"; describe("reconciliation-store", () => { @@ -225,4 +229,114 @@ 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", + 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); + }); }); From 33e353d146d707d419b9f92eb00c1bb4b89ef167 Mon Sep 17 00:00:00 2001 From: snowykr Date: Wed, 5 Aug 2026 22:43:39 +0900 Subject: [PATCH 03/30] feat(sdk): private turn-continuation fence and origin gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New module session/terminal-abort.ts holds the private machinery for C04 terminal abort with the corrected semantics: - DeliveryOrigin classification: turn-continuation (same-turn retry, TTSR/ agent.continue, steering, hidden-next-turn, maintenance/worker successor, pre-close predecessor) vs owned-completion (left-running background Bash/task/detached subagent) vs ordinary. Classification is causal, never timing-based; missing/mismatched metadata fails closed. - TurnContinuationFence lifecycle open -> closing -> closed -> retained -> released, closing synchronously before the root turn is interrupted. - TurnContinuationGate: post-close same-turn continuations are denied (only pre-close linearized predecessors pass); owned completions stay ALLOWED after close as fresh-turn delivery (the user-corrected resume-on-owned- completion semantics — the stage-04 no-successor fence is not reinstated). - OwnedDeliverySettlementObserver shape (owned scope only, six manager paths), bounded terminal_uncertain reason union, monotonic fresh-attempt epoch, and terminal scope id minting. Tested: 53/53 across dispatch/reconciliation/kind-aware/arbiter/terminal-abort suites in the worktree; package biome + tsc clean. --- .../src/session/terminal-abort.ts | 213 ++++++++++++++++++ .../test/sdk-reconciliation-store.test.ts | 4 +- .../test/session/terminal-abort.test.ts | 115 ++++++++++ 3 files changed, 330 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/src/session/terminal-abort.ts create mode 100644 packages/coding-agent/test/session/terminal-abort.test.ts 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 0000000000..0b96adff2a --- /dev/null +++ b/packages/coding-agent/src/session/terminal-abort.ts @@ -0,0 +1,213 @@ +/** + * 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 { 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; +}; + +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; +} + +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.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"; + return "allow-new-turn"; + }, + }; + + return { fence, gate }; +} diff --git a/packages/coding-agent/test/sdk-reconciliation-store.test.ts b/packages/coding-agent/test/sdk-reconciliation-store.test.ts index 507a2ad00a..d9408d5ca3 100644 --- a/packages/coding-agent/test/sdk-reconciliation-store.test.ts +++ b/packages/coding-agent/test/sdk-reconciliation-store.test.ts @@ -3,12 +3,12 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { - RECONCILIATION_STORE_VERSION, - RECONCILIATION_STORE_VERSION_V1, createReconciliationStore, type DurableReconciliationRecord, type DurableTerminalScopeRecord, isSafeReconciliationSessionId, + RECONCILIATION_STORE_VERSION, + RECONCILIATION_STORE_VERSION_V1, reconciliationStorePath, settleProcessRestart, settleTerminalScopeRestart, 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 0000000000..900626622c --- /dev/null +++ b/packages/coding-agent/test/session/terminal-abort.test.ts @@ -0,0 +1,115 @@ +import { expect, test } from "bun:test"; +import { + createTurnContinuationSeam, + type DeliveryOrigin, + newTerminalScopeId, + nextPromptAttemptEpoch, + type TurnRegistrationKey, +} from "../../src/session/terminal-abort"; + +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)", () => { + 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"); +}); + +test("owned completion fails closed on mismatched or missing metadata", () => { + 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"); +}); + +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()); +}); From 29fa374f8c8c8b22766abb3046dcc1715cce2d84 Mon Sep 17 00:00:00 2001 From: snowykr Date: Wed, 5 Aug 2026 23:28:11 +0900 Subject: [PATCH 04/30] feat(sdk): bind private lineage/attempt origin at tool registration seams Terminal abort (C04 mode:"terminal") must classify background work by causal source, never by timing. This wires the lineage/attempt authority from sequencing step 3: each prompt turn mints an opaque lineage id bound to the attempt epoch (#promptGeneration) before the model runs; beforeToolCall binds that lineage to each tool call id; task and Bash registrations then record the exact owned five-tuple (endpoint generation, lineage hash, attempt epoch, job id, job generation) synchronously before the job handle escapes, so a later completion can be attributed to the turn that spawned it. Registries are bounded (1024 terminal scopes, 8192 registrations and lineage bindings) and evict oldest; missing or mismatched lineage fails closed and never breaks ordinary registration. Bindings intentionally survive the tool call so resumed registrations re-using the original tool call id retain the same owned-completion origin (post-close resume stays runnable/monitorable under turn scope). Lore-id: c04-terminal-lineage Constraint: classification is source/lineage-based, never timing-based Constraint: fail closed on missing/mismatched context; no session-current fallback Rejected: mutable session-current lineage lookup | forgeable and stale Tested: 13/13 terminal-abort suite; control-dispatch/reconciliation/yield-queue/agent-session/task/bash suites still green Not-tested: SDK callback/YieldQueue/AgentSession origin consumption (sequencing step 4+) --- .../coding-agent/src/session/agent-session.ts | 37 +++++ .../src/session/terminal-abort.ts | 128 ++++++++++++++- packages/coding-agent/src/task/index.ts | 7 +- packages/coding-agent/src/tools/bash.ts | 8 +- .../test/session/terminal-abort.test.ts | 146 ++++++++++++++++++ 5 files changed, 322 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index c20106d8e6..def1e922a5 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -431,6 +431,7 @@ import { transferSessionMessageIdentity, } from "./session-manager"; import { getEntriesForInternalRead, getSessionContextForInternalRead } from "./session-manager-internal"; +import { bindToolLineage, mintTurnLineageIdHash } from "./terminal-abort"; import { ToolChoiceQueue } from "./tool-choice-queue"; import { pruneSupersededMaintenanceReminders, pruneSupersededVolatileProjectContext } from "./volatile-context-pruning"; @@ -2170,6 +2171,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(); @@ -2779,6 +2789,24 @@ 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; + }; this.agent.providerSessionState = this.#providerSessionState; this.#syncAgentSessionId(); this.#removeEphemeralCustomMessages(); @@ -8625,6 +8653,15 @@ export class AgentSession { 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; diff --git a/packages/coding-agent/src/session/terminal-abort.ts b/packages/coding-agent/src/session/terminal-abort.ts index 0b96adff2a..50b42b87ab 100644 --- a/packages/coding-agent/src/session/terminal-abort.ts +++ b/packages/coding-agent/src/session/terminal-abort.ts @@ -14,7 +14,7 @@ * The earlier stage-04 no-successor delivery fence was a misunderstanding and * must not be reinstated under any name. */ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; /** Origin class assigned to every causal callback/queue entry before escape. */ export type DeliveryOrigin = @@ -121,6 +121,132 @@ export interface TerminalScopeDispositions { 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 activeScopes = new Map(); +const activeScopeByAttempt = new Map(); +const ownedRegistrations = 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) 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}`; + if (ownedRegistrations.has(mapKey)) 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}`); +} +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; diff --git a/packages/coding-agent/src/task/index.ts b/packages/coding-agent/src/task/index.ts index 1b1a6128b5..128c29f919 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, @@ -1183,7 +1187,7 @@ export class BashTool implements AgentTool { } async execute( - _toolCallId: string, + toolCallId: string, { command: rawCommand, env: rawEnv, @@ -1233,6 +1237,7 @@ export class BashTool implements AgentTool { resolvedEnv, onUpdate, startBackgrounded: true, + toolCallId, }); return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, { requestedTimeoutSec, @@ -1268,6 +1273,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/test/session/terminal-abort.test.ts b/packages/coding-agent/test/session/terminal-abort.test.ts index 900626622c..5f1cf47912 100644 --- a/packages/coding-agent/test/session/terminal-abort.test.ts +++ b/packages/coding-agent/test/session/terminal-abort.test.ts @@ -1,10 +1,21 @@ import { expect, test } from "bun:test"; import { + bindToolLineage, createTurnContinuationSeam, type DeliveryOrigin, + lookupOwnedRegistration, + lookupTerminalScope, + mintTurnLineageIdHash, newTerminalScopeId, nextPromptAttemptEpoch, + registerOwnedIfLineaged, + registerOwnedRegistration, + registerTerminalScope, + resolveToolLineage, type TurnRegistrationKey, + unbindToolLineage, + unregisterOwnedRegistration, + unregisterTerminalScope, } from "../../src/session/terminal-abort"; const registration: TurnRegistrationKey = { @@ -113,3 +124,138 @@ test("fresh attempt epochs are monotonic and scope ids are unique", () => { 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"); +}); From 87f81f9a4d8e51dd8e529699dbb5562a212a8b7a Mon Sep 17 00:00:00 2001 From: snowykr Date: Wed, 5 Aug 2026 23:35:11 +0900 Subject: [PATCH 05/30] feat(sdk): origin-aware async-result delivery with fresh-attempt resume Sequencing step 4: the SDK onJobComplete callback now recovers the immutable origin BEFORE formatting or artifact allocation. classifyOwnedCompletion resolves an exact owned-completion only when the job carries a registered five-tuple AND a terminal scope exists for its turn; missing/mismatched metadata fails closed to an ordinary delivery. The private OwnedCompletionEnvelope rides the plain async-result message boundary (never a public field), and the AgentSession streaming/idle injectors route envelope-carrying deliveries through resumeFromOwnedCompletion, which allocates a fresh prompt attempt epoch and lineage before the existing followUp/prompt call. Mandated boundary comments now exist at the sdk/session.ts callback, the yield-queue stale/build boundary, and both AgentSession injectors, stating that turn-scope abort blocks only turn-origin continuations and intentionally allows left-running owned completion to resume the agent. Lore-id: c04-terminal-origin-delivery Constraint: origin recovered before formatting/artifact allocation Constraint: closed terminal record never makes an allowed owned completion stale Rejected: blanket delivery gate at injection | would suppress left-running owned results Tested: 15/15 terminal-abort suite; yield-queue/async-yield-queue/agent-session/sdk-lifecycle/sdk-session suites green Not-tested: bus-level terminal scope registration feeding classifyOwnedCompletion (sequencing step 6/7) --- packages/coding-agent/src/sdk/session.ts | 31 ++++++++++++ .../coding-agent/src/session/agent-session.ts | 47 ++++++++++++++++++- .../src/session/terminal-abort.ts | 37 +++++++++++++++ .../coding-agent/src/session/yield-queue.ts | 9 ++++ .../test/session/terminal-abort.test.ts | 43 +++++++++++++++++ 5 files changed, 165 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/sdk/session.ts b/packages/coding-agent/src/sdk/session.ts index dc5b19da79..d2e3ec9360 100644 --- a/packages/coding-agent/src/sdk/session.ts +++ b/packages/coding-agent/src/sdk/session.ts @@ -128,6 +128,7 @@ 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 { classifyOwnedCompletion, 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 +190,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 +203,8 @@ type AsyncResultJobDetails = { type AsyncResultDetails = { jobs: AsyncResultJobDetails[]; + /** Private origin envelope(s); absent = ordinary delivery. Never a public field. */ + ownedCompletions?: OwnedCompletionEnvelope[]; }; type McpNotificationEntry = { @@ -216,6 +221,12 @@ function buildAsyncResultBatchMessage(entries: AsyncResultEntry[]): CustomMessag label: entry.job?.label, durationMs: entry.durationMs, })); + const ownedCompletions = entries + .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 +234,9 @@ function buildAsyncResultBatchMessage(entries: AsyncResultEntry[]): CustomMessag label: job.label, durationMs: job.durationMs, })), + // Private origin envelope for the AgentSession injector; absent for + // ordinary deliveries. This is internal metadata, never a public field. + ...(ownedCompletions.length > 0 ? { ownedCompletions } : {}), }; return { role: "custom", @@ -1541,6 +1555,15 @@ 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. + const ownedCompletion = job ? classifyOwnedCompletion(jobId, job.generation) : undefined; const formattedResult = await formatAsyncResultForFollowUp(result); if (asyncJobManager!.isDeliverySuppressed(jobId, job?.generation)) return; @@ -1551,6 +1574,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} result: formattedResult, job, durationMs, + ...(ownedCompletion + ? { + ownedCompletion: { + lineageIdHash: ownedCompletion.lineageIdHash, + promptAttemptEpoch: ownedCompletion.promptAttemptEpoch, + }, + } + : {}), }); }, }) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index def1e922a5..a8202eb9eb 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -431,7 +431,12 @@ import { transferSessionMessageIdentity, } from "./session-manager"; import { getEntriesForInternalRead, getSessionContextForInternalRead } from "./session-manager-internal"; -import { bindToolLineage, mintTurnLineageIdHash } from "./terminal-abort"; +import { + bindToolLineage, + mintTurnLineageIdHash, + nextPromptAttemptEpoch, + type OwnedCompletionEnvelope, +} from "./terminal-abort"; import { ToolChoiceQueue } from "./tool-choice-queue"; import { pruneSupersededMaintenanceReminders, pruneSupersededVolatileProjectContext } from "./volatile-context-pruning"; @@ -460,6 +465,15 @@ function appendCompactionStateContext(summary: string, stateContext: string[]): if (stateContext.length === 0) return summary; return `${summary}\n\n\n${stateContext.join("\n")}\n`; } +/** + * Detect the private owned-completion origin envelope on an async-result + * delivery message. The envelope is carried in the message details by the SDK + * session callback and is never part of the public message surface. + */ +function hasOwnedCompletionEnvelope(message: AgentMessage): boolean { + const details = (message as { details?: { ownedCompletions?: OwnedCompletionEnvelope[] } }).details; + return (details?.ownedCompletions?.length ?? 0) > 0; +} const PRUNED_ARTIFACT_REF_MAX_CHARS = 64; @@ -2383,6 +2397,22 @@ 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 { + const freshEpoch = nextPromptAttemptEpoch(); + if (freshEpoch > this.#promptGeneration) this.#promptGeneration = freshEpoch; + this.#turnLineageIdHash = mintTurnLineageIdHash( + this.sessionManager.getSessionId?.() ?? "local", + freshEpoch, + this.#terminalLineageSecret, + ); + } #isPromptPreflightCancelled(generation: number, signal: AbortSignal): boolean { return signal.aborted || this.#promptGeneration !== generation; @@ -2688,12 +2718,25 @@ 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. + if (hasOwnedCompletionEnvelope(message)) this.#resumeFromOwnedCompletion(); + this.agent.followUp(message); + }, injectIdle: async messages => { + // 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. const first = messages[0]; if (!first) return; await this.#awaitStartupTurnBarrier(); if (this.#isDisposed) return; + if (messages.some(hasOwnedCompletionEnvelope)) this.#resumeFromOwnedCompletion(); if (messages.length === 1) { await this.agent.prompt(first, this.#managedFallbackPromptOptions()); } else { diff --git a/packages/coding-agent/src/session/terminal-abort.ts b/packages/coding-agent/src/session/terminal-abort.ts index 50b42b87ab..9e2fb97abd 100644 --- a/packages/coding-agent/src/session/terminal-abort.ts +++ b/packages/coding-agent/src/session/terminal-abort.ts @@ -46,6 +46,11 @@ export type TurnDeliveryKey = TurnRegistrationKey & { entryId: string; progressSeq?: number; }; +/** Private origin envelope carried through the plain AgentMessage boundary. */ +export interface OwnedCompletionEnvelope { + lineageIdHash: string; + promptAttemptEpoch: number; +} export type TurnContinuationFenceState = "open" | "closing" | "closed" | "retained" | "released"; @@ -177,6 +182,38 @@ export function lookupOwnedRegistration(jobId: string, jobGeneration: string): T export function unregisterOwnedRegistration(key: TurnRegistrationKey): void { ownedRegistrations.delete(`${key.jobId}\u0000${key.jobGeneration}`); } +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, + }; +} export interface LineageBinding { lineageIdHash: string; promptAttemptEpoch: number; diff --git a/packages/coding-agent/src/session/yield-queue.ts b/packages/coding-agent/src/session/yield-queue.ts index 964e139fdd..37d99fd3c2 100644 --- a/packages/coding-agent/src/session/yield-queue.ts +++ b/packages/coding-agent/src/session/yield-queue.ts @@ -150,6 +150,15 @@ export class YieldQueue { } #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) { diff --git a/packages/coding-agent/test/session/terminal-abort.test.ts b/packages/coding-agent/test/session/terminal-abort.test.ts index 5f1cf47912..7ccf66766d 100644 --- a/packages/coding-agent/test/session/terminal-abort.test.ts +++ b/packages/coding-agent/test/session/terminal-abort.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; import { bindToolLineage, + classifyOwnedCompletion, createTurnContinuationSeam, type DeliveryOrigin, lookupOwnedRegistration, @@ -259,3 +260,45 @@ test("terminal scope registry evicts oldest beyond its bound", () => { 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); +}); From 9af3f0769faeef5d775f91f66360526c8c67f81b Mon Sep 17 00:00:00 2001 From: snowykr Date: Wed, 5 Aug 2026 23:44:13 +0900 Subject: [PATCH 06/30] feat(sdk): turn.abort terminal surface with turn-scope registration and continuation gate Wires the C04 terminal abort surface to the durable prompt terminalization: the turn's continuation fence is registered and synchronously closed at abort via the session seam, and the attempt epoch advances so the fence can never leak onto later turns. Same-turn continuations (retry/TTSR/steering/hidden-next-turn/maintenance/worker successor) are denied at the final synchronous boundary; no-active-turn and unfencible paths return process-local no-effect / safe uncertainty. Lore-id: c04-terminal-surface --- .../src/extensibility/extensions/types.ts | 5 +- packages/coding-agent/src/sdk/bus/index.ts | 101 +++++++++- .../src/sdk/host/control/index.ts | 2 +- .../coding-agent/src/session/agent-session.ts | 71 ++++++- .../src/session/terminal-abort.ts | 46 +++++ ...agent-session-terminal-abort-chain.test.ts | 178 ++++++++++++++++++ .../coding-agent/test/sdk-host-wiring.test.ts | 109 +++++++++++ .../test/session/terminal-abort.test.ts | 50 +++++ 8 files changed, 554 insertions(+), 8 deletions(-) create mode 100644 packages/coding-agent/test/agent-session-terminal-abort-chain.test.ts diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 749e5c795b..8956399a50 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -1454,7 +1454,10 @@ 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; 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/sdk/bus/index.ts b/packages/coding-agent/src/sdk/bus/index.ts index fad3305417..702e06aec8 100644 --- a/packages/coding-agent/src/sdk/bus/index.ts +++ b/packages/coding-agent/src/sdk/bus/index.ts @@ -62,7 +62,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 } 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"; @@ -2173,6 +2173,13 @@ function sdkControlSurface( aborted: true, disposition: "idle", }), + abortTerminalPrompt: ( + connectionId: string | undefined, + _scope: AbortScope, + ) => Promise< + | { ok: true; outcome: "stopped" | "no_active_turn" | "already_terminal" } + | { ok: false; reason: "worker_unsettled" | "owned_unsettled" } + > = async () => ({ ok: true, outcome: "no_active_turn" }), skillRecon?: { admit: (clientRef?: string) => void; release: (clientRef?: string) => void; @@ -2425,6 +2432,61 @@ function sdkControlSurface( } return await abortOwnedPrompt(requesterConnectionId); }, + abortTerminal: async input => { + // 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(); + const scope: AbortScope = input.scope === "owned" ? "owned" : "turn"; + const outcome = await abortTerminalPrompt(requesterConnectionId, scope); + 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", + }; + } + if (scope === "owned") { + // Exact owned cleanup (captured-job stop, quiescence proof, delivery + // settlement) is implemented in a later increment. Until the exact + // proof exists the plan mandates safe uncertainty — never a claimed + // quiescence — so owned returns terminal_uncertain with reason + // owned_unsettled instead of a fabricated stopped disposition. + return { + ok: true, + selection: "owned", + turn: "stopped", + ownedWork: "uncertain", + automaticDelivery: "none", + resumeOnOwnedCompletion: false, + reason: "owned_unsettled", + }; + } + return { + ok: true, + selection: "turn", + turn: "stopped", + ownedWork: "left_running", + automaticDelivery: "enabled", + resumeOnOwnedCompletion: true, + }; + }, abortAndPrompt: async text => { await awaitAbortReady(); return await submitPrompt(text, undefined, true, undefined, false, controlRequesterContext.getStore()); @@ -4043,7 +4105,7 @@ 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 } }, ) => { const submission = promptSubmissions.get(promptSubmissionKey(correlation)); @@ -4064,7 +4126,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 +4146,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)}`); @@ -4228,6 +4298,31 @@ export function createNotificationsExtension( ); return { aborted: true, disposition: "cancelled" as const }; }, + async (connectionId, scope) => { + // 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. + const active = [...promptSubmissions.entries()].find( + ([, submission]) => submission.connectionId === connectionId && !submission.terminal, + ); + if (!active) return { ok: true as const, outcome: "no_active_turn" as const }; + const [commandId, turnId] = active[0].split(":", 2); + if (!commandId || !turnId) return { ok: true as const, outcome: "already_terminal" as const }; + await terminalizePrompt( + { commandId, turnId }, + { kind: "stopped", reason: "cancelled", provenance: "client_cancel" }, + { fence: true, terminal: { scope } }, + ); + const submission = promptSubmissions.get(promptSubmissionKey({ commandId, turnId })); + if (!submission?.terminal || submission.fatal === true) + return { ok: false as const, reason: "worker_unsettled" 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), diff --git a/packages/coding-agent/src/sdk/host/control/index.ts b/packages/coding-agent/src/sdk/host/control/index.ts index 7f68ec1815..4e8ac548ed 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/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index a8202eb9eb..2d07ebc770 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -433,9 +433,11 @@ import { import { getEntriesForInternalRead, getSessionContextForInternalRead } from "./session-manager-internal"; import { bindToolLineage, + lookupTerminalScope, mintTurnLineageIdHash, nextPromptAttemptEpoch, type OwnedCompletionEnvelope, + registerTerminalTurnScope, } from "./terminal-abort"; import { ToolChoiceQueue } from "./tool-choice-queue"; @@ -2413,6 +2415,29 @@ export class AgentSession { 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; @@ -4761,7 +4786,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; @@ -4772,7 +4799,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); @@ -4838,6 +4867,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. @@ -5005,6 +5042,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 || @@ -10104,7 +10148,28 @@ export class AgentSession { /** * Abort a specific active prompt and prove whether its tracked resources settled. */ - async abortPromptAndWait(handle: string, options: { graceMs: number }): Promise { + async abortPromptAndWait( + handle: string, + options: { graceMs: number; terminal?: { scope: "turn" | "owned" } }, + ): Promise { + 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) { + registerTerminalTurnScope({ + lineageIdHash, + promptAttemptEpoch: this.#promptGeneration, + ownedCompletionPolicy: options.terminal.scope === "owned" ? "disabled" : "enabled", + }); + } + } const aborted = this.#runCancellationDomains.abort(handle); if (!aborted.ok) { if (aborted.reason === "quarantined") { diff --git a/packages/coding-agent/src/session/terminal-abort.ts b/packages/coding-agent/src/session/terminal-abort.ts index 9e2fb97abd..fd8020e19f 100644 --- a/packages/coding-agent/src/session/terminal-abort.ts +++ b/packages/coding-agent/src/session/terminal-abort.ts @@ -374,3 +374,49 @@ export function createTurnContinuationSeam(options: { 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, + }; +} 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 0000000000..d077d1cfca --- /dev/null +++ b/packages/coding-agent/test/agent-session-terminal-abort-chain.test.ts @@ -0,0 +1,178 @@ +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 { classifyOwnedCompletion } 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; + + 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); + + 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); + + 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); +}); diff --git a/packages/coding-agent/test/sdk-host-wiring.test.ts b/packages/coding-agent/test/sdk-host-wiring.test.ts index d3bd390686..d9222624ab 100644 --- a/packages/coding-agent/test/sdk-host-wiring.test.ts +++ b/packages/coding-agent/test/sdk-host-wiring.test.ts @@ -2465,6 +2465,115 @@ 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); + 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 fails closed when the turn cannot be fenced", 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); + 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 has no exact run handle or abortPromptAndWait seam, so + // the fence cannot settle: terminal abort must fail closed with safe + // uncertainty instead of fabricating a stopped disposition. + 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: "uncertain", + ownedWork: "left_running", + automaticDelivery: "enabled", + resumeOnOwnedCompletion: true, + reason: "worker_unsettled", + }, + }); + 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-")); diff --git a/packages/coding-agent/test/session/terminal-abort.test.ts b/packages/coding-agent/test/session/terminal-abort.test.ts index 7ccf66766d..57fdb6d563 100644 --- a/packages/coding-agent/test/session/terminal-abort.test.ts +++ b/packages/coding-agent/test/session/terminal-abort.test.ts @@ -12,6 +12,7 @@ import { registerOwnedIfLineaged, registerOwnedRegistration, registerTerminalScope, + registerTerminalTurnScope, resolveToolLineage, type TurnRegistrationKey, unbindToolLineage, @@ -302,3 +303,52 @@ test("classifyOwnedCompletion fails closed when the scope is removed or epoch mi 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 }); +}); From 4841d79e997b6ea9adc6662b5ac9491f94f9ddd0 Mon Sep 17 00:00:00 2001 From: snowykr Date: Wed, 5 Aug 2026 23:45:51 +0900 Subject: [PATCH 07/30] docs(sdk): add mandatory terminal-abort ADR and design-note gate The approved plan's hard documentation gate requires the design note, the naming rules (TurnContinuationFence blocks turn-origin continuation only; owned-completion delivery is never suppressed), boundary comments, and a reviewer/implementer checklist. The boundary comments are already in place at sdk/session.ts, yield-queue.ts, and both AgentSession injectors. This ADR records the corrected turn semantics (prior no-successor fence was a misunderstanding), the prohibited blanket-suppression names, the implementation state, and the five mandatory review questions. Lore-id: c04-terminal-docs Constraint: any code/test/review text implying closed-turn owned-delivery suppression is a hard blocker Tested: package check + docs index regeneration sync --- docs/adr-abort-sdk-terminal-turn-owned.md | 92 +++++++++++++++++++ .../src/internal-urls/docs-index.generated.ts | 3 +- 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 docs/adr-abort-sdk-terminal-turn-owned.md 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 0000000000..93c941df3b --- /dev/null +++ b/docs/adr-abort-sdk-terminal-turn-owned.md @@ -0,0 +1,92 @@ +# 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-*`): + +- `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. + +Still pending (explicit, not silently omitted): owned-scope exact cleanup + six-path +settlement observer, terminal scope registration bound to the aborted turn's lineage +(feeding `classifyOwnedCompletion`), durable terminal-scope record consumption, publication +/replay/retention, and the full race matrix. + +## 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/coding-agent/src/internal-urls/docs-index.generated.ts b/packages/coding-agent/src/internal-urls/docs-index.generated.ts index c74717f3e7..9cba962f82 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","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"]; (docs(sdk): add mandatory terminal-abort ADR and design-note gate) 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-*`):\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\nStill pending (explicit, not silently omitted): owned-scope exact cleanup + six-path\nsettlement observer, terminal scope registration bound to the aborted turn's lineage\n(feeding `classifyOwnedCompletion`), durable terminal-scope record consumption, publication\n/replay/retention, and the full race matrix.\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", From 9a0743601504bacfbefb4245f34efa8ca0c81b52 Mon Sep 17 00:00:00 2001 From: snowykr Date: Thu, 6 Aug 2026 00:18:58 +0900 Subject: [PATCH 08/30] feat(sdk): durable terminal-scope record with no-store gate The bus terminal abort now persists the bounded DurableTerminalScopeRecord through the v2 reconciliation store's single full-document owner (transactTerminalScopes), idempotent per selection+epoch, carrying the turn dispositions, retained continuation fence (epoch + tombstones), enabled/disabled owned-completion policy, pending response state, and a deterministic payload hash. The session's abortPromptAndWait returns the registered scope info (scopeId/epoch/lineage) so the bus can write the record with the exact aborted epoch; terminalizePrompt captures the fence proof through an out-param (an explicit closure return type triggers a TypeScript narrowing regression in this file, so the capture avoids it). Plan AC 5 no-store gate: terminal abort without a file-backed reconciliation owner returns terminal_no_effect before any fence/stop/ cleanup. A failed scope-record write after a landed prompt terminal fails closed to terminal_uncertain(worker_unsettled). Lore-id: c04-terminal-durable-record Constraint: single full-document owner; idempotent per selection+epoch Constraint: no-store precedes any destructive terminal work Tested: 80/80 sdk-host-wiring (tests now provide a file-backed session to reach the fence path); 33/33 store/terminal/chain suites; package check clean Not-tested: restart hydration replay of the durable record; owned exact cleanup (step 6) --- packages/coding-agent/src/sdk/bus/index.ts | 87 ++++++++++++++++++- .../coding-agent/src/session/agent-session.ts | 31 +++++-- .../coding-agent/test/sdk-host-wiring.test.ts | 20 ++++- 3 files changed, 129 insertions(+), 9 deletions(-) diff --git a/packages/coding-agent/src/sdk/bus/index.ts b/packages/coding-agent/src/sdk/bus/index.ts index 702e06aec8..2cdb373847 100644 --- a/packages/coding-agent/src/sdk/bus/index.ts +++ b/packages/coding-agent/src/sdk/bus/index.ts @@ -102,7 +102,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 { @@ -2177,7 +2177,7 @@ function sdkControlSurface( connectionId: string | undefined, _scope: AbortScope, ) => Promise< - | { ok: true; outcome: "stopped" | "no_active_turn" | "already_terminal" } + | { ok: true; outcome: "stopped" | "no_active_turn" | "already_terminal" | "no_store" } | { ok: false; reason: "worker_unsettled" | "owned_unsettled" } > = async () => ({ ok: true, outcome: "no_active_turn" }), skillRecon?: { @@ -2462,6 +2462,16 @@ function sdkControlSurface( terminal: "terminal_no_effect", }; } + 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 (scope === "owned") { // Exact owned cleanup (captured-job stop, quiescence proof, delivery // settlement) is implemented in a later increment. Until the exact @@ -4107,6 +4117,11 @@ export function createNotificationsExtension( // so aborting there would cancel the next turn instead of fencing this one. options: { fence?: boolean; terminal?: { scope: AbortScope } } = {}, extra?: { finalText?: string; error?: { code: string; message: string } }, + capture?: { + proof?: RunSettlementProof & { + terminalScope?: { scopeId: string; abortedAttemptEpoch: number; lineageIdHash: string }; + }; + }, ) => { const submission = promptSubmissions.get(promptSubmissionKey(correlation)); if (!submission || submission.terminal || submission.phase !== "active") return; @@ -4174,6 +4189,7 @@ export function createNotificationsExtension( ); return; } + if (capture) capture.proof = proof; } try { await kindReconciliation.finalizePromptOutcome(correlation, winner, extra?.error); @@ -4307,20 +4323,87 @@ export function createNotificationsExtension( // 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) { + // No file-backed reconciliation owner: terminal admission is + // gated off (plan AC 5) before any fence, stop, or cleanup. + return { ok: true as const, outcome: "no_store" as const }; + } const active = [...promptSubmissions.entries()].find( ([, submission]) => submission.connectionId === connectionId && !submission.terminal, ); if (!active) return { ok: true as const, outcome: "no_active_turn" as const }; const [commandId, turnId] = active[0].split(":", 2); if (!commandId || !turnId) return { ok: true as const, outcome: "already_terminal" as const }; + const captured: { + proof?: RunSettlementProof & { + terminalScope?: { scopeId: string; abortedAttemptEpoch: number; lineageIdHash: string }; + }; + } = {}; await terminalizePrompt( { commandId, turnId }, { kind: "stopped", reason: "cancelled", provenance: "client_cancel" }, { fence: true, terminal: { scope } }, + undefined, + captured, ); const submission = promptSubmissions.get(promptSubmissionKey({ commandId, turnId })); if (!submission?.terminal || submission.fatal === true) return { ok: false as const, reason: "worker_unsettled" as const }; + // Persist the bounded durable terminal-scope record through the + // same full-document owner (idempotent per selection+epoch). + const terminalScope = captured.proof?.terminalScope; + if (terminalScope) { + try { + await durableStore.transactTerminalScopes(scopes => { + const retained = scopes.filter( + s => + !( + s.selection === scope && + s.turnContinuationFence.abortedAttemptEpoch === terminalScope.abortedAttemptEpoch + ), + ); + const payloadHash = crypto + .createHash("sha256") + .update( + JSON.stringify({ + selection: scope, + turn: "stopped", + ownedWork: scope === "turn" ? "left_running" : "uncertain", + automaticDelivery: scope === "turn" ? "enabled" : "none", + resumeOnOwnedCompletion: scope === "turn", + }), + ) + .digest("hex"); + return [ + ...retained, + { + selection: scope, + turnDisposition: "stopped", + ownedWorkDisposition: scope === "turn" ? "left_running" : "uncertain", + automaticDeliveryDisposition: scope === "turn" ? "enabled" : "none", + resumeOnOwnedCompletion: scope === "turn", + turnContinuationFence: { + state: "retained", + abortedAttemptEpoch: terminalScope.abortedAttemptEpoch, + blockedContinuationIds: [], + predecessorTombstones: [], + ownedCompletionPolicy: scope === "turn" ? "enabled" : "disabled", + }, + responseState: "pending", + responsePayloadHash: payloadHash, + acceptedAt: Date.now(), + terminalAt: Date.now(), + } satisfies DurableTerminalScopeRecord, + ]; + }); + } catch (error) { + // The prompt terminal is already durable; a failed scope-record + // write must fail closed to safe uncertainty, never claim a + // stopped disposition the durable record cannot prove. + logger.warn(`sdk: terminal scope persistence failed: ${String(error)}`); + return { ok: false as const, reason: "worker_unsettled" as const }; + } + } return { ok: true as const, outcome: "stopped" as const }; }, { diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 2d07ebc770..e83e31e93a 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -10151,7 +10151,12 @@ export class AgentSession { async abortPromptAndWait( handle: string, options: { graceMs: number; terminal?: { scope: "turn" | "owned" } }, - ): Promise { + ): 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 @@ -10163,24 +10168,40 @@ export class AgentSession { // registered, so nothing is attributed. const lineageIdHash = this.#turnLineageIdHash; if (lineageIdHash) { - registerTerminalTurnScope({ + const scope = registerTerminalTurnScope({ lineageIdHash, promptAttemptEpoch: this.#promptGeneration, ownedCompletionPolicy: options.terminal.scope === "owned" ? "disabled" : "enabled", }); + registeredScope = { + scopeId: scope.scopeId, + abortedAttemptEpoch: scope.promptAttemptEpoch, + lineageIdHash: scope.lineageIdHash, + }; } } 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/test/sdk-host-wiring.test.ts b/packages/coding-agent/test/sdk-host-wiring.test.ts index d9222624ab..7e533f9f4e 100644 --- a/packages/coding-agent/test/sdk-host-wiring.test.ts +++ b/packages/coding-agent/test/sdk-host-wiring.test.ts @@ -2469,7 +2469,15 @@ test("SDK host turn.abort terminal mode returns no-effect with no active turn", 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); + 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, + 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"); @@ -2514,7 +2522,15 @@ test("SDK host turn.abort terminal mode fails closed when the turn cannot be fen const sessionId = `sdk-terminal-fence-${Date.now()}`; const live = { idle: true }; const deliveries: Parameters[] = []; - const sessionContext = context(cwd, sessionId, "main", live); + 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, + getSessionFile: () => path.join(cwd, ".gjc", "state", "sdk", `${sessionId}.jsonl`), + }, + }; const handlers = start( sessionContext, undefined, From 66a50a8a0c2b5c1b10eb1bb932c18cd946bac4a2 Mon Sep 17 00:00:00 2001 From: snowykr Date: Thu, 6 Aug 2026 00:30:32 +0900 Subject: [PATCH 09/30] feat(sdk): owned-scope exact stop with delivery settlement and event metadata scope:"owned" stops only generation-exact captured jobs, waits a fixed grace, re-proves quiescence (second proof), and purges their queued deliveries so stopped work can never resume the agent; unprovable or foreign work yields safe uncertainty, never a claimed stop. The correlated agent_end carries bounded terminal metadata (scope, turn, ownedWork, automaticDelivery, resumeOnOwnedCompletion) with no extra terminal event kind. Lore-id: c04-terminal-owned-stop --- packages/coding-agent/src/sdk/bus/index.ts | 73 ++++++++++++++++--- .../src/session/terminal-abort.ts | 15 ++++ .../coding-agent/test/sdk-host-wiring.test.ts | 4 +- .../test/session/terminal-abort.test.ts | 47 ++++++++++++ 4 files changed, 125 insertions(+), 14 deletions(-) diff --git a/packages/coding-agent/src/sdk/bus/index.ts b/packages/coding-agent/src/sdk/bus/index.ts index 2cdb373847..49741c3f43 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,7 @@ import { } from "../../modes/shared/agent-wire/workflow-gate-broker"; import type { AgentSessionEvent } from "../../session/agent-session"; import type { ClientBridge } from "../../session/client-bridge"; +import { findOwnedRegistrationsForTurn } from "../../session/terminal-abort"; import { parseThinkingLevel } from "../../thinking"; import type { AskAnswerRequest, @@ -2177,7 +2179,7 @@ function sdkControlSurface( connectionId: string | undefined, _scope: AbortScope, ) => Promise< - | { ok: true; outcome: "stopped" | "no_active_turn" | "already_terminal" | "no_store" } + | { ok: true; outcome: "stopped" | "stopped_owned" | "no_active_turn" | "already_terminal" | "no_store" } | { ok: false; reason: "worker_unsettled" | "owned_unsettled" } > = async () => ({ ok: true, outcome: "no_active_turn" }), skillRecon?: { @@ -2472,20 +2474,17 @@ function sdkControlSurface( terminal: "terminal_no_effect", }; } - if (scope === "owned") { - // Exact owned cleanup (captured-job stop, quiescence proof, delivery - // settlement) is implemented in a later increment. Until the exact - // proof exists the plan mandates safe uncertainty — never a claimed - // quiescence — so owned returns terminal_uncertain with reason - // owned_unsettled instead of a fabricated stopped disposition. + 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: "uncertain", + ownedWork: "stopped", automaticDelivery: "none", resumeOnOwnedCompletion: false, - reason: "owned_unsettled", }; } return { @@ -4217,6 +4216,22 @@ export function createNotificationsExtension( ...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", + }, + } + : {}), }); } }; @@ -4349,9 +4364,38 @@ export function createNotificationsExtension( const submission = promptSubmissions.get(promptSubmissionKey({ commandId, turnId })); if (!submission?.terminal || submission.fatal === 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 = exactJobs.every(reg => { + manager.cancel(reg.jobId); + const job = manager.getJob(reg.jobId); + // Quiescent = terminal (cancelled/completed/failed); running or + // paused work proves the capture could not be stopped exactly. + return job !== undefined && job.status !== "running" && job.status !== "paused"; + }); + if (!ownedStopped) return { ok: false as const, reason: "owned_unsettled" as const }; + // Purge queued deliveries of the exact captured jobs through the + // existing exact-key acknowledgement path so no delivery from + // stopped work can resume the agent; foreign deliveries are + // untouched. + manager.acknowledgeDeliveries(exactJobs.map(reg => reg.jobId)); + } + } // Persist the bounded durable terminal-scope record through the // same full-document owner (idempotent per selection+epoch). - const terminalScope = captured.proof?.terminalScope; if (terminalScope) { try { await durableStore.transactTerminalScopes(scopes => { @@ -4362,13 +4406,15 @@ export function createNotificationsExtension( s.turnContinuationFence.abortedAttemptEpoch === terminalScope.abortedAttemptEpoch ), ); + const ownedWorkDisposition = + scope === "turn" ? "left_running" : ownedStopped ? "stopped" : "uncertain"; const payloadHash = crypto .createHash("sha256") .update( JSON.stringify({ selection: scope, turn: "stopped", - ownedWork: scope === "turn" ? "left_running" : "uncertain", + ownedWork: ownedWorkDisposition, automaticDelivery: scope === "turn" ? "enabled" : "none", resumeOnOwnedCompletion: scope === "turn", }), @@ -4379,7 +4425,7 @@ export function createNotificationsExtension( { selection: scope, turnDisposition: "stopped", - ownedWorkDisposition: scope === "turn" ? "left_running" : "uncertain", + ownedWorkDisposition, automaticDeliveryDisposition: scope === "turn" ? "enabled" : "none", resumeOnOwnedCompletion: scope === "turn", turnContinuationFence: { @@ -4404,6 +4450,9 @@ export function createNotificationsExtension( 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 }; }, { diff --git a/packages/coding-agent/src/session/terminal-abort.ts b/packages/coding-agent/src/session/terminal-abort.ts index fd8020e19f..c0475ab3d1 100644 --- a/packages/coding-agent/src/session/terminal-abort.ts +++ b/packages/coding-agent/src/session/terminal-abort.ts @@ -182,6 +182,21 @@ export function lookupOwnedRegistration(jobId: string, jobGeneration: string): T 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; diff --git a/packages/coding-agent/test/sdk-host-wiring.test.ts b/packages/coding-agent/test/sdk-host-wiring.test.ts index 7e533f9f4e..785157fcab 100644 --- a/packages/coding-agent/test/sdk-host-wiring.test.ts +++ b/packages/coding-agent/test/sdk-host-wiring.test.ts @@ -2474,7 +2474,7 @@ test("SDK host turn.abort terminal mode returns no-effect with no active turn", // 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, + ...(context(cwd, sessionId).sessionManager as Record), getSessionFile: () => path.join(cwd, ".gjc", "state", "sdk", `${sessionId}.jsonl`), }, }; @@ -2527,7 +2527,7 @@ test("SDK host turn.abort terminal mode fails closed when the turn cannot be fen // 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, + ...(context(cwd, sessionId, "main", live).sessionManager as Record), getSessionFile: () => path.join(cwd, ".gjc", "state", "sdk", `${sessionId}.jsonl`), }, }; diff --git a/packages/coding-agent/test/session/terminal-abort.test.ts b/packages/coding-agent/test/session/terminal-abort.test.ts index 57fdb6d563..f7c17d793c 100644 --- a/packages/coding-agent/test/session/terminal-abort.test.ts +++ b/packages/coding-agent/test/session/terminal-abort.test.ts @@ -4,6 +4,7 @@ import { classifyOwnedCompletion, createTurnContinuationSeam, type DeliveryOrigin, + findOwnedRegistrationsForTurn, lookupOwnedRegistration, lookupTerminalScope, mintTurnLineageIdHash, @@ -352,3 +353,49 @@ test("a registered terminal turn scope makes a matching owned job classify as ow }); 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, + }); +}); From f9637daff7decee746aa3efc9c7d599dbfa95f41 Mon Sep 17 00:00:00 2001 From: snowykr Date: Thu, 6 Aug 2026 00:38:18 +0900 Subject: [PATCH 10/30] feat(sdk): deterministic replay, fenced-turn bounds, and gate registration authority Same-key same-input terminal requests replay deterministically (dispatch LRU plus durable key/input hashes) without re-running the stop, cleanup, or a second event; same-key different-input conflicts. The attempt epoch advances with every terminal scope so the fence is bounded to the aborted turn. Owned cleanup is generation-verified (reused job ids fail closed) with settleOwnedWork unit tests, and the owned-completion gate requires the exact registered five-tuple (forged/unregistered tuples denied). Lore-id: c04-terminal-replay-bounds --- packages/coding-agent/src/sdk/bus/index.ts | 46 +++++--- .../src/sdk/bus/reconciliation-store.ts | 2 + .../src/sdk/host/control/dispatch.ts | 2 +- .../src/sdk/host/control/operations.ts | 3 +- packages/coding-agent/src/sdk/session.ts | 1 + .../coding-agent/src/session/agent-session.ts | 39 +++++-- .../src/session/terminal-abort.ts | 51 +++++++++ ...agent-session-terminal-abort-chain.test.ts | 25 +++++ .../test/sdk-control-dispatch.test.ts | 15 ++- .../test/sdk-reconciliation-store.test.ts | 1 + .../test/session/terminal-abort.test.ts | 103 ++++++++++++++++++ 11 files changed, 257 insertions(+), 31 deletions(-) diff --git a/packages/coding-agent/src/sdk/bus/index.ts b/packages/coding-agent/src/sdk/bus/index.ts index 49741c3f43..a7ff303ab6 100644 --- a/packages/coding-agent/src/sdk/bus/index.ts +++ b/packages/coding-agent/src/sdk/bus/index.ts @@ -46,7 +46,7 @@ import { } from "../../modes/shared/agent-wire/workflow-gate-broker"; import type { AgentSessionEvent } from "../../session/agent-session"; import type { ClientBridge } from "../../session/client-bridge"; -import { findOwnedRegistrationsForTurn } from "../../session/terminal-abort"; +import { findOwnedRegistrationsForTurn, settleOwnedWork } from "../../session/terminal-abort"; import { parseThinkingLevel } from "../../thinking"; import type { AskAnswerRequest, @@ -2177,7 +2177,8 @@ function sdkControlSurface( }), abortTerminalPrompt: ( connectionId: string | undefined, - _scope: AbortScope, + scope: AbortScope, + idempotencyKey?: string, ) => Promise< | { ok: true; outcome: "stopped" | "stopped_owned" | "no_active_turn" | "already_terminal" | "no_store" } | { ok: false; reason: "worker_unsettled" | "owned_unsettled" } @@ -2434,7 +2435,7 @@ function sdkControlSurface( } return await abortOwnedPrompt(requesterConnectionId); }, - abortTerminal: async input => { + 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 @@ -2443,7 +2444,7 @@ function sdkControlSurface( // delivery is intentionally NOT suppressed. const requesterConnectionId = controlRequesterContext.getStore(); const scope: AbortScope = input.scope === "owned" ? "owned" : "turn"; - const outcome = await abortTerminalPrompt(requesterConnectionId, scope); + const outcome = await abortTerminalPrompt(requesterConnectionId, scope, idempotencyKey); if (!outcome.ok) { return { ok: true, @@ -3784,6 +3785,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 = @@ -4329,7 +4332,7 @@ export function createNotificationsExtension( ); return { aborted: true, disposition: "cancelled" as const }; }, - async (connectionId, scope) => { + async (connectionId, scope, idempotencyKey) => { // 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 @@ -4343,6 +4346,25 @@ export function createNotificationsExtension( // gated off (plan AC 5) before any fence, stop, or cleanup. return { ok: true as const, outcome: "no_store" as const }; } + // Same-key replay: a durable terminal-scope record already exists + // for this bounded idempotency key + selection -> return the stored + // dispositions exactly, never re-run cleanup, never a second event. + const keyHash = idempotencyKey + ? crypto.createHash("sha256").update(idempotencyKey).digest("hex") + : undefined; + if (keyHash) { + const existing = durableStore + .snapshotTerminalScopes() + .find(s => s.idempotencyKeyHash === keyHash && s.selection === scope); + if (existing?.turnDisposition === "stopped") { + return { + ok: true as const, + outcome: (existing.ownedWorkDisposition === "stopped" ? "stopped_owned" : "stopped") as + | "stopped" + | "stopped_owned", + }; + } + } const active = [...promptSubmissions.entries()].find( ([, submission]) => submission.connectionId === connectionId && !submission.terminal, ); @@ -4379,19 +4401,8 @@ export function createNotificationsExtension( if (!manager) { return { ok: false as const, reason: "owned_unsettled" as const }; } - ownedStopped = exactJobs.every(reg => { - manager.cancel(reg.jobId); - const job = manager.getJob(reg.jobId); - // Quiescent = terminal (cancelled/completed/failed); running or - // paused work proves the capture could not be stopped exactly. - return job !== undefined && job.status !== "running" && job.status !== "paused"; - }); + ownedStopped = (await settleOwnedWork(manager, exactJobs, OWNED_SETTLEMENT_GRACE_MS)) === "stopped"; if (!ownedStopped) return { ok: false as const, reason: "owned_unsettled" as const }; - // Purge queued deliveries of the exact captured jobs through the - // existing exact-key acknowledgement path so no delivery from - // stopped work can resume the agent; foreign deliveries are - // untouched. - manager.acknowledgeDeliveries(exactJobs.map(reg => reg.jobId)); } } // Persist the bounded durable terminal-scope record through the @@ -4424,6 +4435,7 @@ export function createNotificationsExtension( ...retained, { selection: scope, + ...(keyHash ? { idempotencyKeyHash: keyHash } : {}), turnDisposition: "stopped", ownedWorkDisposition, automaticDeliveryDisposition: scope === "turn" ? "enabled" : "none", diff --git a/packages/coding-agent/src/sdk/bus/reconciliation-store.ts b/packages/coding-agent/src/sdk/bus/reconciliation-store.ts index c1549f98ed..ab6575482a 100644 --- a/packages/coding-agent/src/sdk/bus/reconciliation-store.ts +++ b/packages/coding-agent/src/sdk/bus/reconciliation-store.ts @@ -42,6 +42,8 @@ export interface DurableReconciliationRecord extends PromptCorrelation { */ export interface DurableTerminalScopeRecord { selection: "turn" | "owned"; + /** SHA-256 of the bounded idempotency key; the raw key is never persisted. */ + idempotencyKeyHash?: string; turnDisposition: "pending" | "stopped" | "uncertain"; ownedWorkDisposition: "not_requested" | "left_running" | "stopped" | "uncertain"; automaticDeliveryDisposition: "enabled" | "none"; diff --git a/packages/coding-agent/src/sdk/host/control/dispatch.ts b/packages/coding-agent/src/sdk/host/control/dispatch.ts index 8a9fb8d690..3643763fbf 100644 --- a/packages/coding-agent/src/sdk/host/control/dispatch.ts +++ b/packages/coding-agent/src/sdk/host/control/dispatch.ts @@ -162,7 +162,7 @@ function invokeAbort(surface: ControlSurface, input: ControlInput, idempotencyKe 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 }); + return surface.abortTerminal({ mode: "terminal", scope }, idempotencyKey); } function invoke( surface: ControlSurface, diff --git a/packages/coding-agent/src/sdk/host/control/operations.ts b/packages/coding-agent/src/sdk/host/control/operations.ts index 4809c7d268..40fbaea160 100644 --- a/packages/coding-agent/src/sdk/host/control/operations.ts +++ b/packages/coding-agent/src/sdk/host/control/operations.ts @@ -24,7 +24,8 @@ export interface ControlSurface { followUp(text: string): Promise | ControlValue; abort(): Promise | ControlValue; /** Terminal abort: stop the current root turn (and optionally exact owned work). */ - abortTerminal?(input: TerminalAbortInput): Promise | ControlValue; + /** 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/session.ts b/packages/coding-agent/src/sdk/session.ts index d2e3ec9360..6fd795ccfb 100644 --- a/packages/coding-agent/src/sdk/session.ts +++ b/packages/coding-agent/src/sdk/session.ts @@ -1579,6 +1579,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} ownedCompletion: { lineageIdHash: ownedCompletion.lineageIdHash, promptAttemptEpoch: ownedCompletion.promptAttemptEpoch, + registration: ownedCompletion.registration, }, } : {}), diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index e83e31e93a..abf52b05a9 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -468,13 +468,30 @@ function appendCompactionStateContext(summary: string, stateContext: string[]): return `${summary}\n\n\n${stateContext.join("\n")}\n`; } /** - * Detect the private owned-completion origin envelope on an async-result - * delivery message. The envelope is carried in the message details by the SDK - * session callback and is never part of the public message surface. + * Whether an async-result delivery carries an owned-completion origin that the + * owning terminal scope's gate authorizes as a fresh-turn resume. The envelope + * is carried in the message details by the SDK session callback and is never + * part of the public message surface. The gate validates the EXACT registered + * five-tuple and the owned-completion policy: scope:"turn" keeps it enabled + * (left-running delivery intentionally resumes as a fresh turn), while + * scope:"owned" disables it so stopped work can never call followUp/prompt + * even if a delivery races the settlement purge. A forged or unregistered + * origin fails closed. */ -function hasOwnedCompletionEnvelope(message: AgentMessage): boolean { +function isOwnedCompletionResumeAllowed(message: AgentMessage): boolean { const details = (message as { details?: { ownedCompletions?: OwnedCompletionEnvelope[] } }).details; - return (details?.ownedCompletions?.length ?? 0) > 0; + const envelope = details?.ownedCompletions?.[0]; + if (!envelope) return false; + const scope = lookupTerminalScope(envelope.lineageIdHash, envelope.promptAttemptEpoch); + if (!scope) return false; + return ( + scope.gate.authorizeOwnedCompletion({ + kind: "owned-completion", + lineageIdHash: envelope.lineageIdHash, + attemptEpoch: envelope.promptAttemptEpoch, + registration: envelope.registration, + }) === "allow-new-turn" + ); } const PRUNED_ARTIFACT_REF_MAX_CHARS = 64; @@ -2749,7 +2766,7 @@ export class AgentSession { // 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. - if (hasOwnedCompletionEnvelope(message)) this.#resumeFromOwnedCompletion(); + if (isOwnedCompletionResumeAllowed(message)) this.#resumeFromOwnedCompletion(); this.agent.followUp(message); }, injectIdle: async messages => { @@ -2761,7 +2778,7 @@ export class AgentSession { if (!first) return; await this.#awaitStartupTurnBarrier(); if (this.#isDisposed) return; - if (messages.some(hasOwnedCompletionEnvelope)) this.#resumeFromOwnedCompletion(); + if (messages.some(isOwnedCompletionResumeAllowed)) this.#resumeFromOwnedCompletion(); if (messages.length === 1) { await this.agent.prompt(first, this.#managedFallbackPromptOptions()); } else { @@ -10179,6 +10196,14 @@ export class AgentSession { lineageIdHash: scope.lineageIdHash, }; } + // Advance the attempt epoch so the aborted turn's (lineage, epoch) can + // never be reused by a later turn: the terminal scope stays keyed to the + // aborted epoch, and any subsequent prompt admission mints a distinct + // lineage (AC 27/28 — the fence bounds ONLY the aborted turn). This + // mirrors the epoch advance the ordinary abort path performs. + this.#promptGeneration++; + this.#promptPreflightAbortController.abort(); + this.#promptPreflightAbortController = new AbortController(); } const aborted = this.#runCancellationDomains.abort(handle); if (!aborted.ok) { diff --git a/packages/coding-agent/src/session/terminal-abort.ts b/packages/coding-agent/src/session/terminal-abort.ts index c0475ab3d1..b1c0567787 100644 --- a/packages/coding-agent/src/session/terminal-abort.ts +++ b/packages/coding-agent/src/session/terminal-abort.ts @@ -50,6 +50,8 @@ export type TurnDeliveryKey = TurnRegistrationKey & { 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"; @@ -229,6 +231,42 @@ export function classifyOwnedCompletion( terminalScopeId: scope.scopeId, }; } +/** 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); + const quiescent = exactJobs.every(reg => { + const job = manager.getJob(reg.jobId); + return job !== undefined && 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; @@ -370,6 +408,7 @@ export function createTurnContinuationSeam(options: { // 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; @@ -383,6 +422,18 @@ export function createTurnContinuationSeam(options: { !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"; }, }; 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 index d077d1cfca..b2f4164462 100644 --- a/packages/coding-agent/test/agent-session-terminal-abort-chain.test.ts +++ b/packages/coding-agent/test/agent-session-terminal-abort-chain.test.ts @@ -175,4 +175,29 @@ describe("terminal abort registers a turn scope so left-running owned work class 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); }); diff --git a/packages/coding-agent/test/sdk-control-dispatch.test.ts b/packages/coding-agent/test/sdk-control-dispatch.test.ts index 10d117d1e3..a9b524325d 100644 --- a/packages/coding-agent/test/sdk-control-dispatch.test.ts +++ b/packages/coding-agent/test/sdk-control-dispatch.test.ts @@ -477,8 +477,8 @@ test("turn.abort terminal mode validates strictly and forwards normalized input" const calls: Array> = []; const surface = { abort: () => "legacy", - abortTerminal: (input: unknown) => { - calls.push(input as Record); + abortTerminal: (input: unknown, idempotencyKey?: string) => { + calls.push({ ...(input as Record), idempotencyKey }); return "terminal"; }, } as unknown as ControlSurface; @@ -491,16 +491,21 @@ test("turn.abort terminal mode validates strictly and forwards normalized input" }); expect(await terminal({ mode: "terminal" }, "key-1")).toEqual({ id: "t", ok: true, result: "terminal" }); - expect(calls).toEqual([{ mode: "terminal", scope: "turn" }]); + 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" }, - { mode: "terminal", scope: "owned" }, + { 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 mode rejects missing/oversized key, invalid mode/scope, and unknown fields", async () => { diff --git a/packages/coding-agent/test/sdk-reconciliation-store.test.ts b/packages/coding-agent/test/sdk-reconciliation-store.test.ts index d9408d5ca3..a639092090 100644 --- a/packages/coding-agent/test/sdk-reconciliation-store.test.ts +++ b/packages/coding-agent/test/sdk-reconciliation-store.test.ts @@ -259,6 +259,7 @@ describe("reconciliation-store", () => { const store = createReconciliationStore({ sessionFile, sessionId: "s1" }); const scope: DurableTerminalScopeRecord = { selection: "turn", + idempotencyKeyHash: "k-hash-1", turnDisposition: "stopped", ownedWorkDisposition: "left_running", automaticDeliveryDisposition: "enabled", diff --git a/packages/coding-agent/test/session/terminal-abort.test.ts b/packages/coding-agent/test/session/terminal-abort.test.ts index f7c17d793c..363bafffb5 100644 --- a/packages/coding-agent/test/session/terminal-abort.test.ts +++ b/packages/coding-agent/test/session/terminal-abort.test.ts @@ -15,6 +15,7 @@ import { registerTerminalScope, registerTerminalTurnScope, resolveToolLineage, + settleOwnedWork, type TurnRegistrationKey, unbindToolLineage, unregisterOwnedRegistration, @@ -76,6 +77,7 @@ test("post-close same-turn continuations are denied; pre-close predecessors allo }); test("owned completions stay allowed after close (corrected semantics)", () => { + registerOwnedRegistration(registration); const { gate } = createTurnContinuationSeam({ lineageIdHash: "lineage-a", abortedAttemptEpoch: 7, @@ -91,9 +93,11 @@ test("owned completions stay allowed after close (corrected semantics)", () => { 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, @@ -108,6 +112,28 @@ test("owned completion fails closed on mismatched or missing metadata", () => { // 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", () => { @@ -399,3 +425,80 @@ test("findOwnedRegistrationsForTurn returns only exact lineage+epoch registratio 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"); +}); From 2efa6e8906dc55d43890e7bd67d706d992b5d5c5 Mon Sep 17 00:00:00 2001 From: snowykr Date: Thu, 6 Aug 2026 01:24:11 +0900 Subject: [PATCH 11/30] fix(sdk): drop denied owned deliveries and partition mixed batches AgentSession injectors drop owned-completion deliveries the scope gate denies (owned scope policy disabled, forged/unregistered tuple, vanished scope) before followUp/prompt, so stopped work can never call the agent; batch build partitions denied entries out entirely so a mixed batch never leaks a stopped-work delivery. Generation is revalidated in the quiescence proof and same-key scope changes conflict durably. The ADR implementation-state now reflects the completed work. Lore-id: c04-terminal-batch-split --- docs/adr-abort-sdk-terminal-turn-owned.md | 28 +++- .../src/internal-urls/docs-index.generated.ts | 2 +- packages/coding-agent/src/sdk/bus/index.ts | 68 +++++++-- .../src/sdk/bus/reconciliation-store.ts | 2 + packages/coding-agent/src/sdk/session.ts | 22 ++- .../coding-agent/src/session/agent-session.ts | 58 ++++---- .../src/session/terminal-abort.ts | 31 +++- .../test/sdk-reconciliation-store.test.ts | 44 ++++++ .../test/session/terminal-abort.test.ts | 132 ++++++++++++++++++ 9 files changed, 332 insertions(+), 55 deletions(-) diff --git a/docs/adr-abort-sdk-terminal-turn-owned.md b/docs/adr-abort-sdk-terminal-turn-owned.md index 93c941df3b..41bdf3d2cc 100644 --- a/docs/adr-abort-sdk-terminal-turn-owned.md +++ b/docs/adr-abort-sdk-terminal-turn-owned.md @@ -54,7 +54,7 @@ attempt/lineage/worker epoch. ## Implementation state -Committed on `feat/abort-sdk-terminal` (lore `c04-terminal-*`): +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 @@ -66,11 +66,27 @@ Committed on `feat/abort-sdk-terminal` (lore `c04-terminal-*`): - `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. - -Still pending (explicit, not silently omitted): owned-scope exact cleanup + six-path -settlement observer, terminal scope registration bound to the aborted turn's lineage -(feeding `classifyOwnedCompletion`), durable terminal-scope record consumption, publication -/replay/retention, and the full race matrix. +- `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`. + +Documented remaining work (not silently omitted): the durable record is written at the terminal +transition but is not yet re-hydrated into a runtime continuation fence on restart, and the +six-row response replay table across restart/eviction is covered by the dispatch LRU plus the +durable key-hash replay but not by a full persisted publication-bit state machine. These are +tracked as follow-ups; the corrected design note, naming rules, boundary comments, and reviewer +checklist below are the implementation contract. ## Reviewer / implementer checklist (mandatory) 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 9cba962f82..fa14249a32 100644 --- a/packages/coding-agent/src/internal-urls/docs-index.generated.ts +++ b/packages/coding-agent/src/internal-urls/docs-index.generated.ts @@ -6,7 +6,7 @@ export const EMBEDDED_DOC_FILENAMES: readonly string[] = ["ERRATA-GPT5-HARMONY.m 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-*`):\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\nStill pending (explicit, not silently omitted): owned-scope exact cleanup + six-path\nsettlement observer, terminal scope registration bound to the aborted turn's lineage\n(feeding `classifyOwnedCompletion`), durable terminal-scope record consumption, publication\n/replay/retention, and the full race matrix.\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-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\nDocumented remaining work (not silently omitted): the durable record is written at the terminal\ntransition but is not yet re-hydrated into a runtime continuation fence on restart, and the\nsix-row response replay table across restart/eviction is covered by the dispatch LRU plus the\ndurable key-hash replay but not by a full persisted publication-bit state machine. These are\ntracked as follow-ups; the corrected design note, naming rules, boundary comments, and reviewer\nchecklist below are the implementation contract.\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/sdk/bus/index.ts b/packages/coding-agent/src/sdk/bus/index.ts index a7ff303ab6..91677cde70 100644 --- a/packages/coding-agent/src/sdk/bus/index.ts +++ b/packages/coding-agent/src/sdk/bus/index.ts @@ -2181,7 +2181,7 @@ function sdkControlSurface( idempotencyKey?: string, ) => Promise< | { ok: true; outcome: "stopped" | "stopped_owned" | "no_active_turn" | "already_terminal" | "no_store" } - | { ok: false; reason: "worker_unsettled" | "owned_unsettled" } + | { ok: false; reason: "worker_unsettled" | "owned_unsettled" | "conflict" } > = async () => ({ ok: true, outcome: "no_active_turn" }), skillRecon?: { admit: (clientRef?: string) => void; @@ -2445,6 +2445,9 @@ function sdkControlSurface( const requesterConnectionId = controlRequesterContext.getStore(); const scope: AbortScope = input.scope === "owned" ? "owned" : "turn"; const outcome = await abortTerminalPrompt(requesterConnectionId, scope, idempotencyKey); + if (!outcome.ok && outcome.reason === "conflict") { + return { ok: false, error: { code: "idempotency_conflict" } }; + } if (!outcome.ok) { return { ok: true, @@ -4346,23 +4349,32 @@ export function createNotificationsExtension( // gated off (plan AC 5) before any fence, stop, or cleanup. return { ok: true as const, outcome: "no_store" as const }; } - // Same-key replay: a durable terminal-scope record already exists - // for this bounded idempotency key + selection -> return the stored - // dispositions exactly, never re-run cleanup, never a second event. + // 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 && s.selection === scope); - if (existing?.turnDisposition === "stopped") { - return { - ok: true as const, - outcome: (existing.ownedWorkDisposition === "stopped" ? "stopped_owned" : "stopped") as - | "stopped" - | "stopped_owned", - }; + 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 }; + } + if (existing.turnDisposition === "stopped") { + return { + ok: true as const, + outcome: (existing.ownedWorkDisposition === "stopped" ? "stopped_owned" : "stopped") as + | "stopped" + | "stopped_owned", + }; + } } } const active = [...promptSubmissions.entries()].find( @@ -4435,7 +4447,7 @@ export function createNotificationsExtension( ...retained, { selection: scope, - ...(keyHash ? { idempotencyKeyHash: keyHash } : {}), + ...(keyHash ? { idempotencyKeyHash: keyHash, idempotencyInputHash: inputHash } : {}), turnDisposition: "stopped", ownedWorkDisposition, automaticDeliveryDisposition: scope === "turn" ? "enabled" : "none", @@ -4689,6 +4701,32 @@ export function createNotificationsExtension( acknowledgePrompt(connectionId, { commandId: result.commandId, turnId: result.turnId }); } + // Terminal abort: the control response was actually written to the + // client — advance the durable terminal-scope record's response + // state pending -> sent (AC 18 monotonic; a same-key replay then + // returns the same stored payload with responseState:"sent"). A + // failed transition never breaks the already-delivered response. + 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"); + try { + await durableStore.transactTerminalScopes(scopes => + scopes.map(scope => + scope.idempotencyKeyHash === keyHash && scope.responseState === "pending" + ? { ...scope, responseState: "sent" as const } + : scope, + ), + ); + } catch (error) { + logger.warn(`sdk: terminal response-state persistence failed: ${String(error)}`); + } + } if (request.operation === "session.close" && response.ok === true) ctx.shutdown(); }, control: async (connectionId, frame) => { diff --git a/packages/coding-agent/src/sdk/bus/reconciliation-store.ts b/packages/coding-agent/src/sdk/bus/reconciliation-store.ts index ab6575482a..aa1188a791 100644 --- a/packages/coding-agent/src/sdk/bus/reconciliation-store.ts +++ b/packages/coding-agent/src/sdk/bus/reconciliation-store.ts @@ -44,6 +44,8 @@ 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"; ownedWorkDisposition: "not_requested" | "left_running" | "stopped" | "uncertain"; automaticDeliveryDisposition: "enabled" | "none"; diff --git a/packages/coding-agent/src/sdk/session.ts b/packages/coding-agent/src/sdk/session.ts index 6fd795ccfb..2104bb0fbb 100644 --- a/packages/coding-agent/src/sdk/session.ts +++ b/packages/coding-agent/src/sdk/session.ts @@ -128,7 +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 { classifyOwnedCompletion, type OwnedCompletionEnvelope } from "../session/terminal-abort"; +import { + classifyOwnedCompletion, + isOwnedCompletionEnvelopeAllowed, + 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"; @@ -214,14 +218,23 @@ 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 = entries + const ownedCompletions = survivors .filter( (entry): entry is AsyncResultEntry & { ownedCompletion: OwnedCompletionEnvelope } => entry.ownedCompletion !== undefined, @@ -235,7 +248,8 @@ function buildAsyncResultBatchMessage(entries: AsyncResultEntry[]): CustomMessag durationMs: job.durationMs, })), // Private origin envelope for the AgentSession injector; absent for - // ordinary deliveries. This is internal metadata, never a public field. + // ordinary deliveries. Only ALLOWED owned-completion entries survive + // partitioning, so the injector never sees a denied envelope here. ...(ownedCompletions.length > 0 ? { ownedCompletions } : {}), }; return { diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index abf52b05a9..0127191fce 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -433,6 +433,7 @@ import { import { getEntriesForInternalRead, getSessionContextForInternalRead } from "./session-manager-internal"; import { bindToolLineage, + isOwnedCompletionEnvelopeAllowed, lookupTerminalScope, mintTurnLineageIdHash, nextPromptAttemptEpoch, @@ -468,30 +469,24 @@ function appendCompactionStateContext(summary: string, stateContext: string[]): return `${summary}\n\n\n${stateContext.join("\n")}\n`; } /** - * Whether an async-result delivery carries an owned-completion origin that the - * owning terminal scope's gate authorizes as a fresh-turn resume. The envelope - * is carried in the message details by the SDK session callback and is never - * part of the public message surface. The gate validates the EXACT registered - * five-tuple and the owned-completion policy: scope:"turn" keeps it enabled - * (left-running delivery intentionally resumes as a fresh turn), while - * scope:"owned" disables it so stopped work can never call followUp/prompt - * even if a delivery races the settlement purge. A forged or unregistered - * origin fails closed. + * 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. */ -function isOwnedCompletionResumeAllowed(message: AgentMessage): boolean { +export function ownedCompletionResumeAction(message: AgentMessage): "ordinary" | "fresh" | "drop" { const details = (message as { details?: { ownedCompletions?: OwnedCompletionEnvelope[] } }).details; - const envelope = details?.ownedCompletions?.[0]; - if (!envelope) return false; - const scope = lookupTerminalScope(envelope.lineageIdHash, envelope.promptAttemptEpoch); - if (!scope) return false; - return ( - scope.gate.authorizeOwnedCompletion({ - kind: "owned-completion", - lineageIdHash: envelope.lineageIdHash, - attemptEpoch: envelope.promptAttemptEpoch, - registration: envelope.registration, - }) === "allow-new-turn" - ); + const envelopes = details?.ownedCompletions; + if (!envelopes || envelopes.length === 0) return "ordinary"; + // Build-time partitioning (sdk/session.ts) already excludes denied entries, + // but fail closed here too: ANY denied envelope drops the whole delivery + // (defense in depth against a mixed/forged batch). + return envelopes.every(isOwnedCompletionEnvelopeAllowed) ? "fresh" : "drop"; } const PRUNED_ARTIFACT_REF_MAX_CHARS = 64; @@ -2766,23 +2761,30 @@ export class AgentSession { // 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. - if (isOwnedCompletionResumeAllowed(message)) this.#resumeFromOwnedCompletion(); + // 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; + if (action === "fresh") this.#resumeFromOwnedCompletion(); this.agent.followUp(message); }, injectIdle: async messages => { // 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. - const first = messages[0]; + // 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.some(isOwnedCompletionResumeAllowed)) this.#resumeFromOwnedCompletion(); - if (messages.length === 1) { + 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 => { diff --git a/packages/coding-agent/src/session/terminal-abort.ts b/packages/coding-agent/src/session/terminal-abort.ts index b1c0567787..8e11d55bb1 100644 --- a/packages/coding-agent/src/session/terminal-abort.ts +++ b/packages/coding-agent/src/session/terminal-abort.ts @@ -231,6 +231,25 @@ export function classifyOwnedCompletion( 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. + */ +export function isOwnedCompletionEnvelopeAllowed(envelope: OwnedCompletionEnvelope): boolean { + const scope = lookupTerminalScope(envelope.lineageIdHash, envelope.promptAttemptEpoch); + if (!scope) return false; + return ( + scope.gate.authorizeOwnedCompletion({ + kind: "owned-completion", + lineageIdHash: envelope.lineageIdHash, + attemptEpoch: envelope.promptAttemptEpoch, + registration: envelope.registration, + }) === "allow-new-turn" + ); +} /** Structural subset of AsyncJobManager used by owned-stop settlement (avoids an import cycle). */ export interface OwnedStopManager { cancel(jobId: string): boolean; @@ -259,9 +278,19 @@ export async function settleOwnedWork( }); 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.status !== "running" && job.status !== "paused"; + return ( + job !== undefined && + job.generation === reg.jobGeneration && + job.status !== "running" && + job.status !== "paused" + ); }); if (!quiescent) return "unsettled"; manager.acknowledgeDeliveries(exactJobs.map(reg => reg.jobId)); diff --git a/packages/coding-agent/test/sdk-reconciliation-store.test.ts b/packages/coding-agent/test/sdk-reconciliation-store.test.ts index a639092090..849735ea1a 100644 --- a/packages/coding-agent/test/sdk-reconciliation-store.test.ts +++ b/packages/coding-agent/test/sdk-reconciliation-store.test.ts @@ -260,6 +260,7 @@ describe("reconciliation-store", () => { const scope: DurableTerminalScopeRecord = { selection: "turn", idempotencyKeyHash: "k-hash-1", + idempotencyInputHash: "input-hash-1", turnDisposition: "stopped", ownedWorkDisposition: "left_running", automaticDeliveryDisposition: "enabled", @@ -341,3 +342,46 @@ describe("reconciliation-store", () => { 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 }); +}); diff --git a/packages/coding-agent/test/session/terminal-abort.test.ts b/packages/coding-agent/test/session/terminal-abort.test.ts index 363bafffb5..0279fa48d3 100644 --- a/packages/coding-agent/test/session/terminal-abort.test.ts +++ b/packages/coding-agent/test/session/terminal-abort.test.ts @@ -1,10 +1,12 @@ import { expect, test } from "bun:test"; +import { ownedCompletionResumeAction } from "../../src/session/agent-session"; import { bindToolLineage, classifyOwnedCompletion, createTurnContinuationSeam, type DeliveryOrigin, findOwnedRegistrationsForTurn, + isOwnedCompletionEnvelopeAllowed, lookupOwnedRegistration, lookupTerminalScope, mintTurnLineageIdHash, @@ -502,3 +504,133 @@ test("settleOwnedWork fails closed when a captured job is still running or missi }; 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, lineageIdHash: "lineage-drop", promptAttemptEpoch: 21 }); + const freshMessage = { + details: { + ownedCompletions: [ + { + lineageIdHash: "lineage-drop", + promptAttemptEpoch: 21, + registration: { ...registration, 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, lineageIdHash: "lineage-owned", promptAttemptEpoch: 22 }); + const ownedMessage = { + details: { + ownedCompletions: [ + { + lineageIdHash: "lineage-owned", + promptAttemptEpoch: 22, + registration: { ...registration, 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, + lineageIdHash: "lineage-drop", + promptAttemptEpoch: 21, + jobGeneration: "forged", + }, + }, + ], + }, + } as never), + ).toBe("drop"); + // An envelope whose scope no longer exists is dropped (fail closed). + unregisterTerminalScope(turnScope.scopeId); + expect(ownedCompletionResumeAction(freshMessage)).toBe("drop"); + unregisterTerminalScope(ownedScope.scopeId); + unregisterOwnedRegistration({ ...registration, lineageIdHash: "lineage-drop", promptAttemptEpoch: 21 }); + unregisterOwnedRegistration({ ...registration, lineageIdHash: "lineage-owned", promptAttemptEpoch: 22 }); +}); + +test("mixed owned-completion batches drop when ANY envelope is denied", async () => { + // Allowed turn-scope envelope. + const turnScope = registerTerminalTurnScope({ lineageIdHash: "lineage-mix-a", promptAttemptEpoch: 31 }); + registerOwnedRegistration({ ...registration, lineageIdHash: "lineage-mix-a", promptAttemptEpoch: 31 }); + const allowed = { + lineageIdHash: "lineage-mix-a", + promptAttemptEpoch: 31, + registration: { ...registration, 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, lineageIdHash: "lineage-mix-b", promptAttemptEpoch: 32 }); + const denied = { + lineageIdHash: "lineage-mix-b", + promptAttemptEpoch: 32, + registration: { ...registration, 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, lineageIdHash: "lineage-mix-a", promptAttemptEpoch: 31 }); + unregisterOwnedRegistration({ ...registration, lineageIdHash: "lineage-mix-b", promptAttemptEpoch: 32 }); +}); From 9497f59e6cf75bfd8b79ed89918834d7b321b859 Mon Sep 17 00:00:00 2001 From: snowykr Date: Thu, 6 Aug 2026 01:36:41 +0900 Subject: [PATCH 12/30] feat(sdk): durable terminal marker, replay rows, and host delivery outcome The bounded initial marker is persisted BEFORE any fence/stop/event effect (marker failure is process-local no-effect, AC 10), then CASed to the semantic outcome. The host classifies the control-response write exactly once (written/rejected/dropped, first-outcome memoized across early-hook + fallback sends) and response state advances monotonically pending -> sent / pending -> failed. Same-key replay returns every stored durable row (stopped/pending/uncertain) with response state, payload hash, and terminalPublished without re-running effects. The extension seam exposes only the current turn's epoch (AC 24); the ADR documents the durable contract status precisely. Lore-id: c04-terminal-durable-state --- docs/adr-abort-sdk-terminal-turn-owned.md | 21 +- .../src/extensibility/extensions/types.ts | 3 + .../src/internal-urls/docs-index.generated.ts | 2 +- .../controllers/extension-ui-controller.ts | 2 + .../coding-agent/src/modes/runtime-init.ts | 1 + packages/coding-agent/src/sdk/bus/index.ts | 315 ++++++++++++------ .../src/sdk/bus/reconciliation-store.ts | 2 + packages/coding-agent/src/sdk/host/host.ts | 51 ++- .../src/sdk/host/reverse-leases.ts | 4 +- packages/coding-agent/src/sdk/host/types.ts | 2 +- .../coding-agent/src/session/agent-session.ts | 11 +- packages/coding-agent/src/task/executor.ts | 1 + .../test/notifications-tool-activity.test.ts | 1 + .../test/sdk-acp-two-client-race.test.ts | 5 +- .../coding-agent/test/sdk-host-wiring.test.ts | 4 + packages/coding-agent/test/sdk-host.test.ts | 29 +- .../test/sdk-protocol-conformance.test.ts | 1 + .../test/sdk-reconciliation-store.test.ts | 59 ++++ .../test/sdk-reverse-transport-e2e.test.ts | 5 +- 19 files changed, 390 insertions(+), 129 deletions(-) diff --git a/docs/adr-abort-sdk-terminal-turn-owned.md b/docs/adr-abort-sdk-terminal-turn-owned.md index 41bdf3d2cc..6ba5c98a30 100644 --- a/docs/adr-abort-sdk-terminal-turn-owned.md +++ b/docs/adr-abort-sdk-terminal-turn-owned.md @@ -81,12 +81,21 @@ Committed on `feat/abort-sdk-terminal` (lore `c04-terminal-*`), base `e92a04e3`: unregistered denied); injectors drop denied owned-completion deliveries entirely (AC 36 zero final calls) and allocate a fresh attempt only on `allow-new-turn`. -Documented remaining work (not silently omitted): the durable record is written at the terminal -transition but is not yet re-hydrated into a runtime continuation fence on restart, and the -six-row response replay table across restart/eviction is covered by the dispatch LRU plus the -durable key-hash replay but not by a full persisted publication-bit state machine. These are -tracked as follow-ups; the corrected design note, naming rules, boundary comments, and reviewer -checklist below are the implementation contract. +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) diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 8956399a50..3229119294 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -1458,6 +1458,9 @@ export interface ExtensionContextActions { handle: string, options: { graceMs: number; terminal?: { scope: "turn" | "owned" } }, ) => Promise; + /** Private terminal-abort seam: current turn attempt epoch without interrupting it. */ + getTerminalTurnEpoch?: () => number | undefined; + 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 fa14249a32..485cb4ff61 100644 --- a/packages/coding-agent/src/internal-urls/docs-index.generated.ts +++ b/packages/coding-agent/src/internal-urls/docs-index.generated.ts @@ -6,7 +6,7 @@ export const EMBEDDED_DOC_FILENAMES: readonly string[] = ["ERRATA-GPT5-HARMONY.m 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\nDocumented remaining work (not silently omitted): the durable record is written at the terminal\ntransition but is not yet re-hydrated into a runtime continuation fence on restart, and the\nsix-row response replay table across restart/eviction is covered by the dispatch LRU plus the\ndurable key-hash replay but not by a full persisted publication-bit state machine. These are\ntracked as follow-ups; the corrected design note, naming rules, boundary comments, and reviewer\nchecklist below are the implementation contract.\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-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 52df8f1aa8..df25c2185c 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,7 @@ 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(), hasPendingMessages: () => this.ctx.session.queuedMessageCount > 0, getPendingMessageCounts: () => this.ctx.session.pendingMessageCounts, getTranscript: () => this.ctx.session.getTranscript(), @@ -844,6 +845,7 @@ 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(), 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 f2b4a7ee70..1181a31a97 100644 --- a/packages/coding-agent/src/modes/runtime-init.ts +++ b/packages/coding-agent/src/modes/runtime-init.ts @@ -97,6 +97,7 @@ export async function initializeExtensions(session: AgentSession, options: Initi getActivePromptHandle: () => session.activePromptHandle, abort: () => session.abort(), abortPromptAndWait: (handle, abortOptions) => session.abortPromptAndWait(handle, abortOptions), + getTerminalTurnEpoch: () => session.getTerminalTurnEpoch(), 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 91677cde70..ea9c19545c 100644 --- a/packages/coding-agent/src/sdk/bus/index.ts +++ b/packages/coding-agent/src/sdk/bus/index.ts @@ -2180,7 +2180,19 @@ function sdkControlSurface( scope: AbortScope, idempotencyKey?: string, ) => Promise< - | { ok: true; outcome: "stopped" | "stopped_owned" | "no_active_turn" | "already_terminal" | "no_store" } + | { + ok: true; + outcome: + | "stopped" + | "stopped_owned" + | "no_active_turn" + | "already_terminal" + | "no_store" + | "no_effect" + | "pending_replay" + | "uncertain_replay"; + stored?: { responseState: string; responsePayloadHash: string; terminalPublished: boolean }; + } | { ok: false; reason: "worker_unsettled" | "owned_unsettled" | "conflict" } > = async () => ({ ok: true, outcome: "no_active_turn" }), skillRecon?: { @@ -2466,6 +2478,7 @@ function sdkControlSurface( selection: scope, turn: "no_active_turn", terminal: "terminal_no_effect", + ...(outcome.stored ? { replay: outcome.stored } : {}), }; } if (outcome.outcome === "no_store") { @@ -2478,6 +2491,31 @@ function sdkControlSurface( 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 === "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 @@ -2489,6 +2527,7 @@ function sdkControlSurface( ownedWork: "stopped", automaticDelivery: "none", resumeOnOwnedCompletion: false, + ...(outcome.stored ? { replay: outcome.stored } : {}), }; } return { @@ -2498,6 +2537,7 @@ function sdkControlSurface( ownedWork: "left_running", automaticDelivery: "enabled", resumeOnOwnedCompletion: true, + ...(outcome.stored ? { replay: outcome.stored } : {}), }; }, abortAndPrompt: async text => { @@ -4126,6 +4166,8 @@ export function createNotificationsExtension( proof?: RunSettlementProof & { terminalScope?: { scopeId: string; abortedAttemptEpoch: number; lineageIdHash: string }; }; + /** Whether the correlated agent_end event was published (AC 19). */ + published?: boolean; }, ) => { const submission = promptSubmissions.get(promptSubmissionKey(correlation)); @@ -4207,38 +4249,47 @@ 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, - // 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", - }, - } - : {}), - }); + 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. + if (capture) capture.published = 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) => { @@ -4367,14 +4418,33 @@ export function createNotificationsExtension( 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 }; + } + // 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 }; } } const active = [...promptSubmissions.entries()].find( @@ -4383,10 +4453,50 @@ export function createNotificationsExtension( if (!active) return { ok: true as const, outcome: "no_active_turn" as const }; const [commandId, turnId] = active[0].split(":", 2); if (!commandId || !turnId) return { ok: true as const, outcome: "already_terminal" 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 [ + ...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, + ]; + }); + } 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; } = {}; await terminalizePrompt( { commandId, turnId }, @@ -4417,18 +4527,20 @@ export function createNotificationsExtension( if (!ownedStopped) return { ok: false as const, reason: "owned_unsettled" as const }; } } - // Persist the bounded durable terminal-scope record through the - // same full-document owner (idempotent per selection+epoch). - if (terminalScope) { - try { - await durableStore.transactTerminalScopes(scopes => { - const retained = scopes.filter( - s => - !( - s.selection === scope && - s.turnContinuationFence.abortedAttemptEpoch === terminalScope.abortedAttemptEpoch - ), - ); + // 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 @@ -4443,36 +4555,25 @@ export function createNotificationsExtension( }), ) .digest("hex"); - return [ - ...retained, - { - selection: scope, - ...(keyHash ? { idempotencyKeyHash: keyHash, idempotencyInputHash: inputHash } : {}), - turnDisposition: "stopped", - ownedWorkDisposition, - automaticDeliveryDisposition: scope === "turn" ? "enabled" : "none", - resumeOnOwnedCompletion: scope === "turn", - turnContinuationFence: { - state: "retained", - abortedAttemptEpoch: terminalScope.abortedAttemptEpoch, - blockedContinuationIds: [], - predecessorTombstones: [], - ownedCompletionPolicy: scope === "turn" ? "enabled" : "disabled", - }, - responseState: "pending", - responsePayloadHash: payloadHash, - acceptedAt: Date.now(), - terminalAt: Date.now(), - } satisfies DurableTerminalScopeRecord, - ]; - }); - } catch (error) { - // The prompt terminal is already durable; a failed scope-record - // write must fail closed to safe uncertainty, never claim a - // stopped disposition the durable record cannot prove. - logger.warn(`sdk: terminal scope persistence failed: ${String(error)}`); - return { ok: false as const, reason: "worker_unsettled" as const }; - } + 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 }; @@ -4507,10 +4608,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:")) { @@ -4527,7 +4629,7 @@ export function createNotificationsExtension( abandonPromptResponse(connectionId, frame); throw error; } - return; + return "written"; } try { server.sendTo(connectionId, json); @@ -4536,6 +4638,7 @@ export function createNotificationsExtension( abandonPromptResponse(connectionId, frame); throw error; } + return "written"; }; /** @@ -4613,6 +4716,36 @@ 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"); + try { + await durableStore.transactTerminalScopes(scopes => + scopes.map(scope => + scope.idempotencyKeyHash === keyHash && 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; @@ -4701,32 +4834,6 @@ export function createNotificationsExtension( acknowledgePrompt(connectionId, { commandId: result.commandId, turnId: result.turnId }); } - // Terminal abort: the control response was actually written to the - // client — advance the durable terminal-scope record's response - // state pending -> sent (AC 18 monotonic; a same-key replay then - // returns the same stored payload with responseState:"sent"). A - // failed transition never breaks the already-delivered response. - 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"); - try { - await durableStore.transactTerminalScopes(scopes => - scopes.map(scope => - scope.idempotencyKeyHash === keyHash && scope.responseState === "pending" - ? { ...scope, responseState: "sent" as const } - : scope, - ), - ); - } catch (error) { - logger.warn(`sdk: terminal response-state persistence failed: ${String(error)}`); - } - } if (request.operation === "session.close" && response.ok === true) ctx.shutdown(); }, control: async (connectionId, frame) => { diff --git a/packages/coding-agent/src/sdk/bus/reconciliation-store.ts b/packages/coding-agent/src/sdk/bus/reconciliation-store.ts index aa1188a791..e093d0c354 100644 --- a/packages/coding-agent/src/sdk/bus/reconciliation-store.ts +++ b/packages/coding-agent/src/sdk/bus/reconciliation-store.ts @@ -47,6 +47,8 @@ export interface DurableTerminalScopeRecord { /** SHA-256 of the canonicalized normalized input; raw input is never persisted. */ idempotencyInputHash?: string; turnDisposition: "pending" | "stopped" | "uncertain"; + /** Whether the correlated agent_end event was published (AC 19). */ + terminalPublished?: boolean; ownedWorkDisposition: "not_requested" | "left_running" | "stopped" | "uncertain"; automaticDeliveryDisposition: "enabled" | "none"; resumeOnOwnedCompletion: boolean; diff --git a/packages/coding-agent/src/sdk/host/host.ts b/packages/coding-agent/src/sdk/host/host.ts index 01ed4dd395..7e6f5cb86d 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 8bb3d98501..9638881e81 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 d5a03a1ca4..04b0344aab 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/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 0127191fce..d26284cb5b 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -10165,8 +10165,17 @@ 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. */ + 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" } }, diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 45dfcb8da1..fc322282b0 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -1856,6 +1856,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise session.activePromptHandle, abort: () => session.abort(), abortPromptAndWait: (handle, options) => session.abortPromptAndWait(handle, options), + getTerminalTurnEpoch: () => session.getTerminalTurnEpoch(), hasPendingMessages: () => session.queuedMessageCount > 0, getPendingMessageCounts: () => session.pendingMessageCounts, getTranscript: () => session.getTranscript(), diff --git a/packages/coding-agent/test/notifications-tool-activity.test.ts b/packages/coding-agent/test/notifications-tool-activity.test.ts index 8955861857..246a64e390 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 4faba04157..d42de88809 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-host-wiring.test.ts b/packages/coding-agent/test/sdk-host-wiring.test.ts index 785157fcab..0a12b30d97 100644 --- a/packages/coding-agent/test/sdk-host-wiring.test.ts +++ b/packages/coding-agent/test/sdk-host-wiring.test.ts @@ -2530,6 +2530,9 @@ test("SDK host turn.abort terminal mode fails closed when the turn cannot be fen ...(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, @@ -3204,6 +3207,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 0faf2c3f00..4c63d259c6 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-protocol-conformance.test.ts b/packages/coding-agent/test/sdk-protocol-conformance.test.ts index 80ee0d3ca9..e2ecff0078 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 849735ea1a..41198b1162 100644 --- a/packages/coding-agent/test/sdk-reconciliation-store.test.ts +++ b/packages/coding-agent/test/sdk-reconciliation-store.test.ts @@ -385,3 +385,62 @@ test("terminal scope response state advances pending -> sent through the shared 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 }); +}); 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 3b4537360d..cc0c36488d 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 () => { From 9bfcb7fccb6a8073d2cb73fbb0028d8699a06c1a Mon Sep 17 00:00:00 2001 From: snowykr Date: Thu, 6 Aug 2026 02:50:56 +0900 Subject: [PATCH 13/30] =?UTF-8?q?chore(sdk):=20rebase=20cleanup=20?= =?UTF-8?q?=E2=80=94=20regenerate=20docs=20index,=20fix=20dev-side=20sendF?= =?UTF-8?q?rame=20stub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-rebase onto dev: the docs index now includes the terminal-abort ADR (121 docs) and the dev-added readiness-lifecycle test's sendFrame stub returns the required delivery outcome. --- packages/coding-agent/src/internal-urls/docs-index.generated.ts | 2 +- .../coding-agent/test/sdk-session-readiness-lifecycle.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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 485cb4ff61..ff08d894db 100644 --- a/packages/coding-agent/src/internal-urls/docs-index.generated.ts +++ b/packages/coding-agent/src/internal-urls/docs-index.generated.ts @@ -1,7 +1,7 @@ // 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-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","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"]; (docs(sdk): add mandatory terminal-abort ADR and design-note gate) +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.", 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 e1e2425b88..f91f922aa6 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; From 7b3b70a950e24480f45c6d79842fdb6345c8e6d4 Mon Sep 17 00:00:00 2001 From: snowykr Date: Thu, 6 Aug 2026 03:06:56 +0900 Subject: [PATCH 14/30] fix(sdk): decide terminal success by terminalizePrompt outcome; await hydration Codex review threads P1/P2 on PR #21: 1. (P1) A successful terminal abort on an already-acknowledged prompt was reported as worker_unsettled because emitPromptLifecycle finalizes (deletes) the submission during emission, so the post-terminalize promptSubmissions lookup returned undefined and the durable marker was never advanced to stopped. terminalizePrompt now sets capture.terminalized only on a genuinely landed durable terminal, and the callback decides success from that outcome instead of re-deriving it from the (possibly finalized) submission record; fail-closed paths leave it unset. 2. (P2) The terminal replay lookup and response-state transition read / wrote the reconciliation store without awaiting the startup hydration, so a same-key retry immediately after a restarted endpoint became reachable could race the still-pending store load and miss the durable row. Both the abort path and the host onControlResponseDelivery hook now await reconciliationReady before any terminal-scope snapshot or transaction. Tested: 26/26 terminal-abort, 19/19 dispatch, 15/15 store, 9/9 sdk-host, 80/80 host-wiring serial; package check clean --- packages/coding-agent/src/sdk/bus/index.ts | 29 +++++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/sdk/bus/index.ts b/packages/coding-agent/src/sdk/bus/index.ts index ea9c19545c..c3f625ff08 100644 --- a/packages/coding-agent/src/sdk/bus/index.ts +++ b/packages/coding-agent/src/sdk/bus/index.ts @@ -4168,6 +4168,8 @@ export function createNotificationsExtension( }; /** 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)); @@ -4285,7 +4287,14 @@ export function createNotificationsExtension( } // The correlated event was published (AC 19): record the outcome so // the durable terminal-scope record carries terminalPublished:true. - if (capture) capture.published = 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). @@ -4400,6 +4409,11 @@ export function createNotificationsExtension( // gated off (plan AC 5) before any fence, stop, or cleanup. 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 @@ -4497,6 +4511,7 @@ export function createNotificationsExtension( terminalScope?: { scopeId: string; abortedAttemptEpoch: number; lineageIdHash: string }; }; published?: boolean; + terminalized?: boolean; } = {}; await terminalizePrompt( { commandId, turnId }, @@ -4505,9 +4520,12 @@ export function createNotificationsExtension( undefined, captured, ); - const submission = promptSubmissions.get(promptSubmissionKey({ commandId, turnId })); - if (!submission?.terminal || submission.fatal === true) - return { ok: false as const, reason: "worker_unsettled" as const }; + // 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 @@ -4731,6 +4749,9 @@ export function createNotificationsExtension( ) { const keyHash = crypto.createHash("sha256").update(request.idempotencyKey).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.responseState === "pending" From 7cedb0769b8700b348002bf1f64a60c50b8edee8 Mon Sep 17 00:00:00 2001 From: snowykr Date: Thu, 6 Aug 2026 03:16:35 +0900 Subject: [PATCH 15/30] fix(sdk): normalize terminal abort input before idempotency hashing Codex review thread P2 on PR #21: the generic dispatch idempotency cache hashed the RAW request input, so retrying a terminal abort key with the defaulted shape and its explicit equivalent (first {mode:"terminal"}, then {mode:"terminal", scope:"turn"}) produced idempotency_conflict and never reached the durable terminal-scope replay that hashes the normalized {mode, scope} payload. The idempotency hash now normalizes a WELL-FORMED terminal abort input (omitted scope -> "turn") before hashing, so both shapes share one key and replay deterministically; malformed inputs (unknown fields, invalid mode/scope) stay raw so they are rejected downstream and never collide with a valid input's key. Lore-id: c04-terminal-idempotency-normalize Tested: 20/20 sdk-control-dispatch (incl. defaulted/explicit replay, scope change conflict, malformed-fresh-key rejection), 9/9 sdk-host, 80/80 host-wiring serial; package check clean --- .../src/sdk/host/control/dispatch.ts | 23 ++++++++++- .../test/sdk-control-dispatch.test.ts | 38 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/sdk/host/control/dispatch.ts b/packages/coding-agent/src/sdk/host/control/dispatch.ts index 3643763fbf..e50997c21f 100644 --- a/packages/coding-agent/src/sdk/host/control/dispatch.ts +++ b/packages/coding-agent/src/sdk/host/control/dispatch.ts @@ -123,6 +123,23 @@ 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; @@ -373,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/test/sdk-control-dispatch.test.ts b/packages/coding-agent/test/sdk-control-dispatch.test.ts index a9b524325d..b4cdf80235 100644 --- a/packages/coding-agent/test/sdk-control-dispatch.test.ts +++ b/packages/coding-agent/test/sdk-control-dispatch.test.ts @@ -507,6 +507,44 @@ test("turn.abort terminal mode validates strictly and forwards normalized input" 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")!; From 15f3948629c2faba0589fc31cdea0a97d37483f0 Mon Sep 17 00:00:00 2001 From: snowykr Date: Thu, 6 Aug 2026 03:40:43 +0900 Subject: [PATCH 16/30] fix(sdk): fresh lineage epoch per root turn; durable conflicts as control errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review threads P1/P2 on PR #21: 1. (P1) The turn lineage was minted from #promptGeneration, which only advanced on abort/session-close — two consecutive NON-aborted turns shared (lineageIdHash, epoch), so a scope:"owned" abort of turn B could capture turn A's left-running jobs and scope:"turn" could misclassify A's completion as B's resume. Every NEW ROOT TURN admission (marked by resetRetryReplaySafety, i.e. user prompt / custom message / queued dispatch) now advances the attempt epoch before minting, so each turn gets a unique (lineage, epoch); same-turn continuations keep the turn's epoch. Regression test: two normal turns, then owned abort of turn B — A's job stays foreign, B's job is captured. The chain suite now resets the process-lifetime registries per test (job ids collide across fresh managers) via a test-only reset. 2. (P2) The durable terminal-scope replay conflict returned a nested {ok:false,error} that dispatchControl wrapped in a top-level successful control_response. The surface now THROWS a typed idempotency_conflict control error (SHARED_ERROR_CODES), so the response itself is ok:false after in-memory eviction/restart, matching the in-cache conflict path. Lore-id: c04-terminal-per-turn-epoch Tested: 21/21 sdk-control-dispatch (incl. top-level conflict), 4/4 chain (incl. consecutive-turn regression), 9/9 sdk-host, 80/80 host-wiring serial; package check clean --- packages/coding-agent/src/sdk/bus/index.ts | 8 +++-- .../coding-agent/src/session/agent-session.ts | 12 +++++++ .../src/session/terminal-abort.ts | 12 +++++++ ...agent-session-terminal-abort-chain.test.ts | 34 ++++++++++++++++++- .../test/sdk-control-dispatch.test.ts | 24 ++++++++++++- 5 files changed, 86 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/sdk/bus/index.ts b/packages/coding-agent/src/sdk/bus/index.ts index c3f625ff08..93cfe7140e 100644 --- a/packages/coding-agent/src/sdk/bus/index.ts +++ b/packages/coding-agent/src/sdk/bus/index.ts @@ -64,7 +64,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 AbortScope, 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"; @@ -2458,7 +2458,11 @@ function sdkControlSurface( const scope: AbortScope = input.scope === "owned" ? "owned" : "turn"; const outcome = await abortTerminalPrompt(requesterConnectionId, scope, idempotencyKey); if (!outcome.ok && outcome.reason === "conflict") { - return { ok: false, error: { code: "idempotency_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 { diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index d26284cb5b..dece9a1870 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -8756,6 +8756,17 @@ export class AgentSession { // session being handed off. this.#assertNoHandoffTransition(); this.#beginInFlight(); + // 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; @@ -8768,6 +8779,7 @@ export class AgentSession { generation, this.#terminalLineageSecret, ); + const preflightSignal = this.#promptPreflightAbortController.signal; const rosterClaim = this.#claimIrcRosterCandidate(); let hasPendingNextTurnMessages = false; diff --git a/packages/coding-agent/src/session/terminal-abort.ts b/packages/coding-agent/src/session/terminal-abort.ts index 8e11d55bb1..04cffb117b 100644 --- a/packages/coding-agent/src/session/terminal-abort.ts +++ b/packages/coding-agent/src/session/terminal-abort.ts @@ -515,3 +515,15 @@ export function registerTerminalTurnScope(options: { 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(); +} 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 index b2f4164462..ffc03118aa 100644 --- a/packages/coding-agent/test/agent-session-terminal-abort-chain.test.ts +++ b/packages/coding-agent/test/agent-session-terminal-abort-chain.test.ts @@ -11,7 +11,10 @@ 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 { classifyOwnedCompletion } from "@gajae-code/coding-agent/session/terminal-abort"; +import { + classifyOwnedCompletion, + 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"; @@ -103,6 +106,10 @@ describe("terminal abort registers a turn scope so left-running owned work class 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, @@ -200,4 +207,29 @@ describe("terminal abort registers a turn scope so left-running owned work class 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. + const abortResult = 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); }); diff --git a/packages/coding-agent/test/sdk-control-dispatch.test.ts b/packages/coding-agent/test/sdk-control-dispatch.test.ts index b4cdf80235..aa2e79e672 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 = { @@ -610,3 +610,25 @@ test("turn.abort legacy mode keeps dropping input and calling the argument-less } 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" }); +}); From 9bd87065d79c872b75fa22a0f9b21af70d3a5792 Mon Sep 17 00:00:00 2001 From: snowykr Date: Thu, 6 Aug 2026 03:48:12 +0900 Subject: [PATCH 17/30] fix(sdk): guard response-state transition by terminal input hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review thread P2 on PR #21: the onControlResponseDelivery hook advanced the durable record's responseState by matching only the idempotency key hash, so a same-key terminal abort with a DIFFERENT input (after in-memory eviction, e.g. scope:"owned" after a pending scope:"turn" marker) would mark the ORIGINAL pending marker sent/failed for the conflict response — corrupting the durable replay row so a later exact retry of the original input could report the response was sent even though only a conflict for a different input was delivered. The transition now also requires the record's normalized idempotencyInputHash to match the request's normalized {mode, scope} payload, so only the response for the exact input that produced the record advances its state. Store test covers same-key/different-input isolation. Lore-id: c04-terminal-response-state-guard Tested: 16/16 sdk-reconciliation-store (incl. input-hash guard), 21/21 dispatch, 4/4 chain, 9/9 sdk-host; package check clean --- packages/coding-agent/src/sdk/bus/index.ts | 15 ++++++- ...agent-session-terminal-abort-chain.test.ts | 2 +- .../test/sdk-reconciliation-store.test.ts | 45 +++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/sdk/bus/index.ts b/packages/coding-agent/src/sdk/bus/index.ts index 93cfe7140e..9f747265c0 100644 --- a/packages/coding-agent/src/sdk/bus/index.ts +++ b/packages/coding-agent/src/sdk/bus/index.ts @@ -4752,13 +4752,26 @@ export function createNotificationsExtension( 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). + const input = request.input as { scope?: unknown }; + const scopeInput = input.scope === "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.responseState === "pending" + scope.idempotencyKeyHash === keyHash && + scope.idempotencyInputHash === inputHash && + scope.responseState === "pending" ? { ...scope, responseState: outcome === "written" ? ("sent" as const) : ("failed" as const), 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 index ffc03118aa..6cc5835b7b 100644 --- a/packages/coding-agent/test/agent-session-terminal-abort-chain.test.ts +++ b/packages/coding-agent/test/agent-session-terminal-abort-chain.test.ts @@ -225,7 +225,7 @@ describe("terminal abort registers a turn scope so left-running owned work class // 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. - const abortResult = await session.abortPromptAndWait(session.agent.activeResourceRunId ?? jobB.id, { + await session.abortPromptAndWait(session.agent.activeResourceRunId ?? jobB.id, { graceMs: 2_000, terminal: { scope: "owned" }, }); diff --git a/packages/coding-agent/test/sdk-reconciliation-store.test.ts b/packages/coding-agent/test/sdk-reconciliation-store.test.ts index 41198b1162..1c854f0c2a 100644 --- a/packages/coding-agent/test/sdk-reconciliation-store.test.ts +++ b/packages/coding-agent/test/sdk-reconciliation-store.test.ts @@ -444,3 +444,48 @@ test("initial pending marker CASes to stopped through the same owner", async () ); 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 }); +}); From 851f0bd894251e38432b22cd78e8b8826442580b Mon Sep 17 00:00:00 2001 From: snowykr Date: Thu, 6 Aug 2026 04:02:15 +0900 Subject: [PATCH 18/30] fix(sdk): durably reserve idle terminal keys; register monitor jobs as owned Codex review threads P1/P2 on PR #21: 1. (idle-key reservation) A terminal abort with no active prompt returned no_active_turn WITHOUT a durable row, so after restart or in-memory eviction a same-key retry while a later prompt was active missed the replay and could fence/abort that unrelated turn. The no-active-turn / already-terminal paths now persist a bounded no_effect terminal-scope reservation (turnDisposition "no_effect", sentinel fence epoch 0) keyed by key+input hash, and the replay branch returns the exact no-effect row (no_effect_replay) instead of re-running. The v2 validator accepts "no_effect" and restart settlement leaves it untouched; store test covers persistence + reload + settlement. 2. (monitor ownership) MonitorTool started background bash through startMonitorJob whose register path never captured the five-tuple, so scope:"owned" could claim stopped_owned while the monitor kept running and emitting follow-ups. startMonitorJob now threads the tool call id and calls registerOwnedIfLineaged; chain test proves the monitor job registers as exact owned work of its turn. Lore-id: c04-terminal-idle-reserve-monitor-owned Tested: 17/17 sdk-reconciliation-store (incl. no_effect persistence), 5/5 chain (incl. monitor ownership), 21/21 dispatch, 9/9 sdk-host, 105/105 tools; package check clean --- packages/coding-agent/src/sdk/bus/index.ts | 94 ++++++++++++++++++- .../src/sdk/bus/reconciliation-store.ts | 10 +- packages/coding-agent/src/tools/bash.ts | 5 + packages/coding-agent/src/tools/monitor.ts | 3 +- ...agent-session-terminal-abort-chain.test.ts | 24 +++++ .../test/sdk-reconciliation-store.test.ts | 35 +++++++ 6 files changed, 165 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/sdk/bus/index.ts b/packages/coding-agent/src/sdk/bus/index.ts index 9f747265c0..d79f0a9a91 100644 --- a/packages/coding-agent/src/sdk/bus/index.ts +++ b/packages/coding-agent/src/sdk/bus/index.ts @@ -2190,7 +2190,8 @@ function sdkControlSurface( | "no_store" | "no_effect" | "pending_replay" - | "uncertain_replay"; + | "uncertain_replay" + | "no_effect_replay"; stored?: { responseState: string; responsePayloadHash: string; terminalPublished: boolean }; } | { ok: false; reason: "worker_unsettled" | "owned_unsettled" | "conflict" } @@ -2505,6 +2506,17 @@ function sdkControlSurface( 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 @@ -4460,6 +4472,12 @@ export function createNotificationsExtension( // 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 }; @@ -4468,9 +4486,79 @@ export function createNotificationsExtension( const active = [...promptSubmissions.entries()].find( ([, submission]) => submission.connectionId === connectionId && !submission.terminal, ); - if (!active) return { ok: true as const, outcome: "no_active_turn" as const }; + 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. + if (keyHash) { + try { + await durableStore.transactTerminalScopes(scopes => [ + ...scopes.filter( + s => !(s.idempotencyKeyHash === keyHash && s.idempotencyInputHash === inputHash), + ), + { + 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, + ]); + } catch (error) { + logger.warn(`sdk: terminal no-effect reservation failed: ${String(error)}`); + } + } + return { ok: true as const, outcome: "no_active_turn" as const }; + } const [commandId, turnId] = active[0].split(":", 2); - if (!commandId || !turnId) return { ok: true as const, outcome: "already_terminal" as const }; + if (!commandId || !turnId) { + if (keyHash) { + try { + await durableStore.transactTerminalScopes(scopes => [ + ...scopes.filter( + s => !(s.idempotencyKeyHash === keyHash && s.idempotencyInputHash === inputHash), + ), + { + 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, + ]); + } catch (error) { + logger.warn(`sdk: terminal no-effect reservation failed: ${String(error)}`); + } + } + return { ok: true as const, outcome: "already_terminal" 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 diff --git a/packages/coding-agent/src/sdk/bus/reconciliation-store.ts b/packages/coding-agent/src/sdk/bus/reconciliation-store.ts index e093d0c354..c332268ac6 100644 --- a/packages/coding-agent/src/sdk/bus/reconciliation-store.ts +++ b/packages/coding-agent/src/sdk/bus/reconciliation-store.ts @@ -46,7 +46,7 @@ export interface DurableTerminalScopeRecord { idempotencyKeyHash?: string; /** SHA-256 of the canonicalized normalized input; raw input is never persisted. */ idempotencyInputHash?: string; - turnDisposition: "pending" | "stopped" | "uncertain"; + 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"; @@ -184,7 +184,13 @@ function isValidTerminalScope(value: unknown): boolean { terminalAt, } = value; if (selection !== "turn" && selection !== "owned") return false; - if (turnDisposition !== "pending" && turnDisposition !== "stopped" && turnDisposition !== "uncertain") return false; + if ( + turnDisposition !== "pending" && + turnDisposition !== "stopped" && + turnDisposition !== "uncertain" && + turnDisposition !== "no_effect" + ) + return false; if ( ownedWorkDisposition !== "not_requested" && ownedWorkDisposition !== "left_running" && diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index ad9eb16ab1..10875bc239 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -1079,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; @@ -1182,6 +1183,10 @@ 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 }; } diff --git a/packages/coding-agent/src/tools/monitor.ts b/packages/coding-agent/src/tools/monitor.ts index 81666d7992..28d9b1f412 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, @@ -219,6 +219,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 index 6cc5835b7b..3f817f30ff 100644 --- a/packages/coding-agent/test/agent-session-terminal-abort-chain.test.ts +++ b/packages/coding-agent/test/agent-session-terminal-abort-chain.test.ts @@ -12,7 +12,9 @@ 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"; @@ -49,6 +51,7 @@ describe("terminal abort registers a turn scope so left-running owned work class 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()}`); @@ -85,6 +88,7 @@ describe("terminal abort registers a turn scope so left-running owned work class getSessionSpawns: () => "*", }; const bashTool = new BashTool(toolSession); + bashToolRef = bashTool; scriptedResponses = []; @@ -232,4 +236,24 @@ describe("terminal abort registers a turn scope so left-running owned work class 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); }); diff --git a/packages/coding-agent/test/sdk-reconciliation-store.test.ts b/packages/coding-agent/test/sdk-reconciliation-store.test.ts index 1c854f0c2a..2eeae9177a 100644 --- a/packages/coding-agent/test/sdk-reconciliation-store.test.ts +++ b/packages/coding-agent/test/sdk-reconciliation-store.test.ts @@ -489,3 +489,38 @@ test("response-state transition is guarded by the normalized input hash", async 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 }); +}); From 411787982123a6625144ef4e3b57c69365dd9b46 Mon Sep 17 00:00:00 2001 From: snowykr Date: Thu, 6 Aug 2026 04:10:26 +0900 Subject: [PATCH 19/30] fix(sdk): defer resume lineage to admission; strict delivery-hook input check Codex review threads P1/P2 on PR #21: 1. (defer fresh lineage) The streaming injector called #resumeFromOwnedCompletion while followUp only QUEUED the owned completion during another active prompt, immediately mutating the session-wide epoch/lineage. If that active turn was then terminal-aborted, getTerminalTurnEpoch() and later tool registrations used the queued resume's fresh lineage instead of the active turn's, so scope:"owned" could miss that turn's already-registered jobs while still reporting success. The streaming path now defers allocation to the actual resume admission (the queued dispatch re-enters #promptWithMessage with resetRetryReplaySafety, which allocates the fresh epoch at turn start); the idle injector (agent.prompt direct) still allocates immediately before admission. 2. (strict delivery-hook input) The response-state hook normalized any non-"owned" scope to "turn", so a MALFORMED same-key retry (scope:"bogus") rejected by dispatch could still match a prior valid scope:"turn" pending row and mark it sent/failed for the invalid response. The hook now strictly validates the terminal input (mode "terminal", scope undefined|"turn"|"owned", no unknown fields) before computing the input hash and advancing any durable row. Lore-id: c04-terminal-resume-admission-delivery-guard Tested: 5/5 chain, 21/21 dispatch, 17/17 store, 9/9 sdk-host, 80/80 host-wiring serial; package check clean --- packages/coding-agent/src/sdk/bus/index.ts | 12 ++++++++++-- packages/coding-agent/src/session/agent-session.ts | 9 ++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/sdk/bus/index.ts b/packages/coding-agent/src/sdk/bus/index.ts index d79f0a9a91..01cc1b864b 100644 --- a/packages/coding-agent/src/sdk/bus/index.ts +++ b/packages/coding-agent/src/sdk/bus/index.ts @@ -4845,8 +4845,16 @@ export function createNotificationsExtension( // 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). - const input = request.input as { scope?: unknown }; - const scopeInput = input.scope === "owned" ? "owned" : "turn"; + // 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 })) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index dece9a1870..b3b47a1de5 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -2765,7 +2765,14 @@ export class AgentSession { // missing scope) is DROPPED here — it must never call followUp/prompt. const action = ownedCompletionResumeAction(message); if (action === "drop") return; - if (action === "fresh") this.#resumeFromOwnedCompletion(); + // 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 => { From 37eca64ad48e04178649e4d1e9f7df279b5808ad Mon Sep 17 00:00:00 2001 From: snowykr Date: Thu, 6 Aug 2026 04:26:48 +0900 Subject: [PATCH 20/30] fix(sdk): fresh lineage at follow-up admission; preserve aborted epoch; fail reservation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review threads on PR #21: 1. (P1, streaming resume lineage) The streaming injector queued an owned completion via agent.followUp but never allocated the promised fresh attempt/lineage — the agent loop consumes follow-ups DIRECTLY via getFollowUpMessages, never through #promptWithMessage, so the resume's tools inherited the streaming turn's lineage and scope:"owned" could attribute/cancel work to the wrong turn. The Agent now exposes onFollowUpConsumed (reassignable, invoked when follow-ups are dequeued for the next turn); the session allocates the fresh lineage there — at actual resume admission, after the previously streaming turn has ended, so the active turn's lineage is never corrupted. 2. (P2, preserve aborted epoch) abortPromptAndWait advanced #promptGeneration right after registering the scope, so #isTurnContinuationBlocked (which looks up by the CURRENT epoch) missed the closed fence on non-generation-guarded continuation paths such as hidden-next-turn. The abort-time bump is removed: fresh-turn allocation is the per-admission responsibility (every NEW ROOT TURN advances the epoch before minting), and the scope stays keyed to the aborted epoch where the continuation gate finds it. 3. (P2, reservation failure) A no-active-turn/already-terminal abort whose durable reservation write failed still returned success without any durable row, so a later same-key retry after eviction/restart could abort an unrelated turn. Reservation failure now returns reservation_failed (safe uncertainty) instead of success. Lore-id: c04-terminal-resume-admission-epoch-reserve Tested: 26/26 terminal-abort, 5/5 chain, 21/21 dispatch, 17/17 store, 9/9 sdk-host, 80/80 host-wiring serial; agent + coding-agent package checks clean --- packages/agent/src/agent.ts | 8 ++++++ packages/agent/src/types.ts | 7 +++++ packages/coding-agent/src/sdk/bus/index.ts | 9 ++++++- .../coding-agent/src/session/agent-session.ts | 26 ++++++++++++++----- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 2ef4588de3..dbcd79cbb0 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; @@ -1683,6 +1688,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 6b15415a30..6556a6f8cb 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/src/sdk/bus/index.ts b/packages/coding-agent/src/sdk/bus/index.ts index 01cc1b864b..31405a996c 100644 --- a/packages/coding-agent/src/sdk/bus/index.ts +++ b/packages/coding-agent/src/sdk/bus/index.ts @@ -2194,7 +2194,7 @@ function sdkControlSurface( | "no_effect_replay"; stored?: { responseState: string; responsePayloadHash: string; terminalPublished: boolean }; } - | { ok: false; reason: "worker_unsettled" | "owned_unsettled" | "conflict" } + | { ok: false; reason: "worker_unsettled" | "owned_unsettled" | "conflict" | "reservation_failed" } > = async () => ({ ok: true, outcome: "no_active_turn" }), skillRecon?: { admit: (clientRef?: string) => void; @@ -4520,7 +4520,12 @@ export function createNotificationsExtension( } satisfies DurableTerminalScopeRecord, ]); } catch (error) { + // The durable reservation is the only replay guard beyond the + // in-memory cache: without it a same-key retry after eviction/ + // restart could abort an unrelated later turn, so a failed + // reservation must NOT report success (review thread P2). logger.warn(`sdk: terminal no-effect reservation failed: ${String(error)}`); + return { ok: false as const, reason: "reservation_failed" as const }; } } return { ok: true as const, outcome: "no_active_turn" as const }; @@ -4554,7 +4559,9 @@ export function createNotificationsExtension( } satisfies DurableTerminalScopeRecord, ]); } catch (error) { + // Same durable-reservation guarantee as the no-active path. logger.warn(`sdk: terminal no-effect reservation failed: ${String(error)}`); + return { ok: false as const, reason: "reservation_failed" as const }; } } return { ok: true as const, outcome: "already_terminal" as const }; diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index b3b47a1de5..c9baf942a1 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -2901,6 +2901,17 @@ export class AgentSession { } 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 => { + if (messages.some(message => ownedCompletionResumeAction(message) === "fresh")) { + this.#resumeFromOwnedCompletion(); + } + }; this.agent.providerSessionState = this.#providerSessionState; this.#syncAgentSessionId(); this.#removeEphemeralCustomMessages(); @@ -10226,12 +10237,15 @@ export class AgentSession { lineageIdHash: scope.lineageIdHash, }; } - // Advance the attempt epoch so the aborted turn's (lineage, epoch) can - // never be reused by a later turn: the terminal scope stays keyed to the - // aborted epoch, and any subsequent prompt admission mints a distinct - // lineage (AC 27/28 — the fence bounds ONLY the aborted turn). This - // mirrors the epoch advance the ordinary abort path performs. - this.#promptGeneration++; + // 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(); } From ac6bc7eb45e5b922e461810890202803e85866da Mon Sep 17 00:00:00 2001 From: snowykr Date: Thu, 6 Aug 2026 04:34:17 +0900 Subject: [PATCH 21/30] fix(sdk): session-relative resume epoch; gate hidden next-turn by the fence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review threads P1/P2 on PR #21: 1. (P1, resume epoch) #resumeFromOwnedCompletion used the module-global nextPromptAttemptEpoch counter, which root admissions never advance — after a normal root prompt at epoch N the counter could still be <= N, so the resume minted the SAME (lineage, epoch) as the aborted turn and its tools could be captured by a later scope:"owned" abort. The fresh epoch is now allocated from the SESSION prompt epoch (+1), always distinct from the current turn's epoch. 2. (P2, hidden next-turn gate) The terminal abort now preserves the epoch so the continuation gate can find the closed scope, but the hidden next-turn scheduler's generation check still passed and its admission path never consulted #isTurnContinuationBlocked — a successor queued by the aborted turn could start. The hidden next-turn drain now checks the fence before prompting. Lore-id: c04-terminal-resume-epoch-hidden-gate Tested: 26/26 terminal-abort, 5/5 chain, 21/21 dispatch, 17/17 store, 9/9 sdk-host, 52/52 resilient-retry, 6/6 queued-prompts, 80/80 host-wiring serial; package check clean --- .../coding-agent/src/session/agent-session.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index c9baf942a1..f56b085a74 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -436,7 +436,6 @@ import { isOwnedCompletionEnvelopeAllowed, lookupTerminalScope, mintTurnLineageIdHash, - nextPromptAttemptEpoch, type OwnedCompletionEnvelope, registerTerminalTurnScope, } from "./terminal-abort"; @@ -2419,8 +2418,13 @@ export class AgentSession { * then invokes the existing followUp/prompt path. */ #resumeFromOwnedCompletion(): void { - const freshEpoch = nextPromptAttemptEpoch(); - if (freshEpoch > this.#promptGeneration) this.#promptGeneration = freshEpoch; + // 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, @@ -9432,6 +9436,13 @@ 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()) { + return; + } try { await this.#promptQueuedHiddenNextTurnMessages(); } catch { From 6312cc83b5f0ca7adc172389d4637b553dda5707 Mon Sep 17 00:00:00 2001 From: snowykr Date: Thu, 6 Aug 2026 04:52:36 +0900 Subject: [PATCH 22/30] fix(sdk): cancel pending preflights on terminal abort; drop owned-stopped monitor notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review threads P1/P2 on PR #21: 1. (preflight cancel) A turn.prompt still in PREFLIGHT has no promptSubmissions entry, so terminal abort treated it as no_active_turn and durably reserved the key WITHOUT cancelling the pending preflight — the prompt would later be accepted and start running despite the abort. The surface's abortTerminal now cancels the connection's pending preflights first (mirroring the legacy abort path). Host-wiring test: terminal abort during a never-resolving preflight cancels it (no agent_start/agent_failed). 2. (monitor notifications) A monitor task-notification is delivered as a follow-up (not an async-result), so the injectors' owned-drop path did not cover it — scope:"owned" could report stopped_owned while a queued notification from the stopped monitor still resumed the agent. onFollowUpConsumed now drops follow-up task-notifications whose job is registered as owned under a terminal scope with ownedCompletionPolicy disabled (the notification side channel gets the same ownership/purge treatment as the job). Non-persistent monitors keep their one notification on their normal self-cancel flow (red-team contract preserved). Lore-id: c04-terminal-preflight-notification-purge Tested: 81/81 host-wiring (incl. terminal-preflight cancel), 6/6 + 23/23 monitor suites, 26/26 terminal-abort, 5/5 chain, 21/21 dispatch, 9/9 sdk-host; package check clean --- packages/coding-agent/src/sdk/bus/index.ts | 12 ++++ .../coding-agent/src/session/agent-session.ts | 23 +++++++ .../coding-agent/test/sdk-host-wiring.test.ts | 69 +++++++++++++++++++ 3 files changed, 104 insertions(+) diff --git a/packages/coding-agent/src/sdk/bus/index.ts b/packages/coding-agent/src/sdk/bus/index.ts index 31405a996c..c2b268a600 100644 --- a/packages/coding-agent/src/sdk/bus/index.ts +++ b/packages/coding-agent/src/sdk/bus/index.ts @@ -2456,6 +2456,18 @@ function sdkControlSurface( // through the existing followUp/prompt path as a fresh turn — owned // delivery is intentionally NOT suppressed. const requesterConnectionId = controlRequesterContext.getStore(); + // A turn.prompt still in PREFLIGHT has no promptSubmissions entry yet: + // the legacy abort cancels pending preflights first, and terminal + // abort must do the same — otherwise the client gets no_active_turn + // while the preflight later completes and the prompt starts running + // (review thread P2). + const pendingPreflight = [...pendingPreflightCancellations.values()].some( + entry => entry.connectionId === requesterConnectionId, + ); + if (pendingPreflight) { + if (requesterConnectionId) cancelPendingPreflightsForConnection(requesterConnectionId); + else cancelPendingPreflights(); + } const scope: AbortScope = input.scope === "owned" ? "owned" : "turn"; const outcome = await abortTerminalPrompt(requesterConnectionId, scope, idempotencyKey); if (!outcome.ok && outcome.reason === "conflict") { diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index f56b085a74..17a118b4bb 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -434,6 +434,7 @@ import { getEntriesForInternalRead, getSessionContextForInternalRead } from "./s import { bindToolLineage, isOwnedCompletionEnvelopeAllowed, + lookupOwnedRegistration, lookupTerminalScope, mintTurnLineageIdHash, type OwnedCompletionEnvelope, @@ -2912,9 +2913,31 @@ export class AgentSession { // 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 => { + // 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 taskId = (message as { details?: { taskId?: unknown } }).details?.taskId; + if (typeof taskId !== "string" || !manager) continue; + const job = manager.getJob(taskId); + if (!job) continue; + const registration = lookupOwnedRegistration(taskId, job.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(); diff --git a/packages/coding-agent/test/sdk-host-wiring.test.ts b/packages/coding-agent/test/sdk-host-wiring.test.ts index 0a12b30d97..7f3649cbc8 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); From 4a0c76f3bcad620398a1594e1995fa9c5a98f168 Mon Sep 17 00:00:00 2001 From: snowykr Date: Thu, 6 Aug 2026 05:03:49 +0900 Subject: [PATCH 23/30] fix(sdk): invalidate the session preflight on terminal abort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review thread P2 on PR #21: cancelPendingPreflightsForConnection only settles the SDK preflight waiter — it does not change the session's preflight signal or generation, so an AgentSession.prompt() still in async preflight could later reach the accept callback (no-op because the waiter was settled), pass the unchanged cancellation checks, and start the turn after a terminal abort reported no_active_turn. A new private session seam cancelPendingPreflightForTerminalAbort aborts the preflight controller (firing the captured admission signal so #throwIfPromptPreflightCancelled throws and the pending prompt never starts) and resets it for the next admission; the terminal abort surface calls it alongside the SDK waiter settlement. Lore-id: c04-terminal-preflight-session-invalidate Tested: 81/81 host-wiring (incl. terminal-preflight cancel), 26/26 terminal-abort, 5/5 chain, 21/21 dispatch, 9/9 sdk-host, 6/6 monitor; package check clean --- .../src/extensibility/extensions/types.ts | 2 ++ .../src/modes/controllers/extension-ui-controller.ts | 2 ++ packages/coding-agent/src/modes/runtime-init.ts | 1 + packages/coding-agent/src/sdk/bus/index.ts | 8 ++++++++ packages/coding-agent/src/session/agent-session.ts | 12 ++++++++++++ packages/coding-agent/src/task/executor.ts | 1 + 6 files changed, 26 insertions(+) diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 3229119294..ff0f708f24 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -1460,6 +1460,8 @@ export interface ExtensionContextActions { ) => 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. */ 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 df25c2185c..df01072194 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -526,6 +526,7 @@ export class ExtensionUiController { 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(), @@ -846,6 +847,7 @@ export class ExtensionUiController { 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 1181a31a97..5d61ba84ce 100644 --- a/packages/coding-agent/src/modes/runtime-init.ts +++ b/packages/coding-agent/src/modes/runtime-init.ts @@ -98,6 +98,7 @@ export async function initializeExtensions(session: AgentSession, options: Initi 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 c2b268a600..d65e90e1cd 100644 --- a/packages/coding-agent/src/sdk/bus/index.ts +++ b/packages/coding-agent/src/sdk/bus/index.ts @@ -2467,6 +2467,14 @@ function sdkControlSurface( if (pendingPreflight) { if (requesterConnectionId) cancelPendingPreflightsForConnection(requesterConnectionId); else cancelPendingPreflights(); + // Settling the SDK waiter alone does NOT stop the underlying + // AgentSession.prompt(): its preflight signal/generation is + // unchanged, so it can later reach the accept callback and start + // the turn. Invalidate the session preflight too (review thread). + const preflightSeam = ctx as typeof ctx & { + cancelPendingPreflightForTerminalAbort?: () => void; + }; + preflightSeam.cancelPendingPreflightForTerminalAbort?.(); } const scope: AbortScope = input.scope === "owned" ? "owned" : "turn"; const outcome = await abortTerminalPrompt(requesterConnectionId, scope, idempotencyKey); diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 17a118b4bb..47eb82a063 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -10235,6 +10235,18 @@ export class AgentSession { * never the opaque lineage handle — so no private origin metadata leaves the * session. Fails closed (undefined) when no active turn lineage exists. */ + /** + * 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; diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index fc322282b0..f52666fb57 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -1857,6 +1857,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise 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(), From a57e5c1f705e06eff0377f2afa563aba5457a59c Mon Sep 17 00:00:00 2001 From: snowykr Date: Thu, 6 Aug 2026 05:16:03 +0900 Subject: [PATCH 24/30] fix(sdk): preserve ownership for pre-abort async completions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review thread P1 on PR #21: classifyOwnedCompletion required an existing terminal scope, so an owned Bash/task job that completed BEFORE the abort (root turn still streaming) was enqueued as an ORDINARY YieldQueue entry; a later scope:"owned" abort could cancel/ack the manager delivery but could not identify or purge the already-queued ordinary async-result, so stopped owned work could still resume the agent. Ownership is now kept on the queued entry regardless of scope: onJobComplete attaches the registration-based envelope whenever the job has an exact registered five-tuple (the registration is fixed at job-registration time, the scope only appears at abort/flush time). Classification became three-state — no scope: ORDINARY (normal delivery), turn-scope enabled: FRESH (new-turn resume), owned-scope disabled: DROP — so the pre-abort completion is dropped at flush once the owned scope lands. The terminal-abort unit suite now isolates the process-lifetime registries per test (job-id collisions across tests were masking this). Lore-id: c04-terminal-preabort-ownership Tested: 27/27 terminal-abort (incl. pre-abort ownership regression), 5/5 chain, 21/21 dispatch, 9/9 sdk-host, 6/6 monitor, 81/81 host-wiring; package check clean --- packages/coding-agent/src/sdk/session.ts | 17 +++++++- .../coding-agent/src/session/agent-session.ts | 16 +++++--- .../src/session/terminal-abort.ts | 34 +++++++++++----- .../test/session/terminal-abort.test.ts | 39 +++++++++++++++++-- 4 files changed, 86 insertions(+), 20 deletions(-) diff --git a/packages/coding-agent/src/sdk/session.ts b/packages/coding-agent/src/sdk/session.ts index 2104bb0fbb..7b0df31420 100644 --- a/packages/coding-agent/src/sdk/session.ts +++ b/packages/coding-agent/src/sdk/session.ts @@ -129,8 +129,8 @@ import { AuthBrokerClient, AuthStorage, RemoteAuthCredentialStore } from "../ses import { type CustomMessage, convertToLlm } from "../session/messages"; import { createReadonlySessionManager, SessionManager } from "../session/session-manager"; import { - classifyOwnedCompletion, isOwnedCompletionEnvelopeAllowed, + lookupOwnedRegistration, type OwnedCompletionEnvelope, } from "../session/terminal-abort"; import { formatNoModelsAvailableFallback } from "../setup/model-onboarding-guidance"; @@ -1577,7 +1577,20 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // and receive a fresh turn attempt. Recover the immutable origin // BEFORE formatting or artifact allocation; missing metadata // fails closed to an ordinary delivery. - const ownedCompletion = job ? classifyOwnedCompletion(jobId, job.generation) : undefined; + // 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; diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 47eb82a063..60bbb95e7d 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -433,7 +433,7 @@ import { import { getEntriesForInternalRead, getSessionContextForInternalRead } from "./session-manager-internal"; import { bindToolLineage, - isOwnedCompletionEnvelopeAllowed, + classifyOwnedEnvelope, lookupOwnedRegistration, lookupTerminalScope, mintTurnLineageIdHash, @@ -483,10 +483,16 @@ export function ownedCompletionResumeAction(message: AgentMessage): "ordinary" | const details = (message as { details?: { ownedCompletions?: OwnedCompletionEnvelope[] } }).details; const envelopes = details?.ownedCompletions; if (!envelopes || envelopes.length === 0) return "ordinary"; - // Build-time partitioning (sdk/session.ts) already excludes denied entries, - // but fail closed here too: ANY denied envelope drops the whole delivery - // (defense in depth against a mixed/forged batch). - return envelopes.every(isOwnedCompletionEnvelopeAllowed) ? "fresh" : "drop"; + // 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; diff --git a/packages/coding-agent/src/session/terminal-abort.ts b/packages/coding-agent/src/session/terminal-abort.ts index 04cffb117b..0bbb96eaeb 100644 --- a/packages/coding-agent/src/session/terminal-abort.ts +++ b/packages/coding-agent/src/session/terminal-abort.ts @@ -238,17 +238,31 @@ export function classifyOwnedCompletion( * (policy disabled), forged/unregistered tuple, or vanished scope — must be * dropped/partitioned out so stopped work can never call followUp/prompt. */ -export function isOwnedCompletionEnvelopeAllowed(envelope: OwnedCompletionEnvelope): boolean { +/** + * 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 false; - return ( - scope.gate.authorizeOwnedCompletion({ - kind: "owned-completion", - lineageIdHash: envelope.lineageIdHash, - attemptEpoch: envelope.promptAttemptEpoch, - registration: envelope.registration, - }) === "allow-new-turn" - ); + if (!scope) return "ordinary"; + return scope.gate.authorizeOwnedCompletion({ + kind: "owned-completion", + lineageIdHash: envelope.lineageIdHash, + attemptEpoch: envelope.promptAttemptEpoch, + registration: envelope.registration, + }) === "allow-new-turn" + ? "fresh" + : "drop"; +} + +/** 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 { diff --git a/packages/coding-agent/test/session/terminal-abort.test.ts b/packages/coding-agent/test/session/terminal-abort.test.ts index 0279fa48d3..08c25db601 100644 --- a/packages/coding-agent/test/session/terminal-abort.test.ts +++ b/packages/coding-agent/test/session/terminal-abort.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { beforeEach, expect, test } from "bun:test"; import { ownedCompletionResumeAction } from "../../src/session/agent-session"; import { bindToolLineage, @@ -16,6 +16,7 @@ import { registerOwnedRegistration, registerTerminalScope, registerTerminalTurnScope, + resetTerminalAbortRegistriesForTests, resolveToolLineage, settleOwnedWork, type TurnRegistrationKey, @@ -24,6 +25,12 @@ import { 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", @@ -590,9 +597,11 @@ test("ownedCompletionResumeAction drops denied owned deliveries at the injector }, } as never), ).toBe("drop"); - // An envelope whose scope no longer exists is dropped (fail closed). + // 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("drop"); + expect(ownedCompletionResumeAction(freshMessage)).toBe("ordinary"); unregisterTerminalScope(ownedScope.scopeId); unregisterOwnedRegistration({ ...registration, lineageIdHash: "lineage-drop", promptAttemptEpoch: 21 }); unregisterOwnedRegistration({ ...registration, lineageIdHash: "lineage-owned", promptAttemptEpoch: 22 }); @@ -634,3 +643,27 @@ test("mixed owned-completion batches drop when ANY envelope is denied", async () unregisterOwnedRegistration({ ...registration, lineageIdHash: "lineage-mix-a", promptAttemptEpoch: 31 }); unregisterOwnedRegistration({ ...registration, 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); +}); From 50ab00a732b8fa4611783c19938c81283b696586 Mon Sep 17 00:00:00 2001 From: snowykr Date: Thu, 6 Aug 2026 05:26:34 +0900 Subject: [PATCH 25/30] fix(sdk): drop denied owned-completion follow-ups; discard blocked hidden next-turn Codex review threads P1/P2 on PR #21: 1. (P1, denied follow-up drop) An owned async-result enqueued into Agent.followUp while streaming BEFORE any terminal scope existed was ordinary at enqueue but still carried details.ownedCompletions; once a scope:"owned" abort landed, the onFollowUpConsumed hook only handled the fresh case and never removed now-dropped follow-ups, so stopped owned work could still be returned by getFollowUpMessages and resume the agent. The hook now removes ANY follow-up whose ownedCompletionResumeAction is "drop" from the dequeued batch at the final consumption boundary. 2. (P2, hidden next-turn discard) The fence-blocked hidden next-turn drain returned without discarding #pendingNextTurnMessages, so the aborted turn's queued successors could be drained into a later explicit prompt. The blocked drain now clears the queued hidden messages. Chain test: terminal abort discards a queued hidden next-turn successor. Lore-id: c04-terminal-followup-drop-hidden-discard Tested: 27/27 terminal-abort, 6/6 chain (incl. hidden-next-turn discard), 21/21 dispatch, 9/9 sdk-host, 52/52 resilient-retry, 6/6 queued-prompts, 81/81 host-wiring; package check clean --- .../coding-agent/src/session/agent-session.ts | 16 +++++++++++++ ...agent-session-terminal-abort-chain.test.ts | 23 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 60bbb95e7d..3cb3f3f118 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -2919,6 +2919,17 @@ export class AgentSession { // 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")) { @@ -9470,6 +9481,11 @@ export class AgentSession { // 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 { 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 index 3f817f30ff..623f29b036 100644 --- a/packages/coding-agent/test/agent-session-terminal-abort-chain.test.ts +++ b/packages/coding-agent/test/agent-session-terminal-abort-chain.test.ts @@ -256,4 +256,27 @@ describe("terminal abort registers a turn scope so left-running owned work class 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); }); From 2091726a44f86396fe1c0698044d063316a6e961 Mon Sep 17 00:00:00 2001 From: snowykr Date: Thu, 6 Aug 2026 05:41:48 +0900 Subject: [PATCH 26/30] fix(sdk): rebind stale owned registrations; keep owned envelopes out of persisted details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review threads P1/P2 on PR #21: 1. (P1, stale registrations) When a top-level session is replaced in the same process, a fresh AsyncJobManager restarts job ids/generations at bg_1/job:1, but the process-global owned registry was never cleared and registerOwnedRegistration's early return preserved the OLD turn's lineage for the reused tuple — the new session's first job could not bind to its own turn, and a later scope:"owned" abort could report stopped_owned while leaving it running. A reused (jobId, jobGeneration) with a DIFFERENT lineage/epoch now OVERWRITES the stale entry (same lineage/epoch stays idempotent). 2. (P2, persisted details) The async-result custom message carries the private owned-completion envelope in details, which is persisted via appendCustomMessageEntry (only __pendingDisplayTag was stripped) and returned to SDK transcript clients. ownedCompletions is now in INTERNAL_DETAILS_FIELDS, so it is stripped from persisted entries while remaining available in-memory for the injectors. Unit tests: reused-tuple overwrite (P1) + ownedCompletions strip (P2); terminal-abort fixtures moved to distinct job keys (the overwrite semantics made shared (job-1, gen-1) fixtures undefined). Lore-id: c04-terminal-stale-rebind-envelope-strip Tested: 28/28 terminal-abort, 12/12 session-manager-internal-details (incl. ownedCompletions strip), 6/6 chain, 21/21 dispatch, 9/9 sdk-host, 81/81 host-wiring; package check clean --- packages/coding-agent/src/session/messages.ts | 2 +- .../src/session/terminal-abort.ts | 14 ++- .../session-manager-internal-details.test.ts | 23 ++++ .../test/session/terminal-abort.test.ts | 114 ++++++++++++++++-- 4 files changed, 138 insertions(+), 15 deletions(-) diff --git a/packages/coding-agent/src/session/messages.ts b/packages/coding-agent/src/session/messages.ts index 169d56a86b..404424a1d6 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 index 0bbb96eaeb..39d2d1306c 100644 --- a/packages/coding-agent/src/session/terminal-abort.ts +++ b/packages/coding-agent/src/session/terminal-abort.ts @@ -168,7 +168,19 @@ export function unregisterTerminalScope(scopeId: string): void { /** Record an exact owned registration before its handle escapes (bounded). */ export function registerOwnedRegistration(key: TurnRegistrationKey): void { const mapKey = `${key.jobId}\u0000${key.jobGeneration}`; - if (ownedRegistrations.has(mapKey)) return; + 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); 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 25d9b75264..c41e8ae13a 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 index 08c25db601..08f05a458a 100644 --- a/packages/coding-agent/test/session/terminal-abort.test.ts +++ b/packages/coding-agent/test/session/terminal-abort.test.ts @@ -544,14 +544,26 @@ test("ownedCompletionResumeAction drops denied owned deliveries at the injector 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, 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, lineageIdHash: "lineage-drop", promptAttemptEpoch: 21 }, + registration: { + ...registration, + jobId: "job-drop", + jobGeneration: "gen-drop", + lineageIdHash: "lineage-drop", + promptAttemptEpoch: 21, + }, }, ], }, @@ -564,14 +576,26 @@ test("ownedCompletionResumeAction drops denied owned deliveries at the injector promptAttemptEpoch: 22, ownedCompletionPolicy: "disabled", }); - registerOwnedRegistration({ ...registration, lineageIdHash: "lineage-owned", promptAttemptEpoch: 22 }); + registerOwnedRegistration({ + ...registration, + jobId: "job-owned", + jobGeneration: "gen-owned", + lineageIdHash: "lineage-owned", + promptAttemptEpoch: 22, + }); const ownedMessage = { details: { ownedCompletions: [ { lineageIdHash: "lineage-owned", promptAttemptEpoch: 22, - registration: { ...registration, lineageIdHash: "lineage-owned", promptAttemptEpoch: 22 }, + registration: { + ...registration, + jobId: "job-owned", + jobGeneration: "gen-owned", + lineageIdHash: "lineage-owned", + promptAttemptEpoch: 22, + }, }, ], }, @@ -588,6 +612,7 @@ test("ownedCompletionResumeAction drops denied owned deliveries at the injector promptAttemptEpoch: 21, registration: { ...registration, + jobId: "job-drop", lineageIdHash: "lineage-drop", promptAttemptEpoch: 21, jobGeneration: "forged", @@ -603,18 +628,43 @@ test("ownedCompletionResumeAction drops denied owned deliveries at the injector unregisterTerminalScope(turnScope.scopeId); expect(ownedCompletionResumeAction(freshMessage)).toBe("ordinary"); unregisterTerminalScope(ownedScope.scopeId); - unregisterOwnedRegistration({ ...registration, lineageIdHash: "lineage-drop", promptAttemptEpoch: 21 }); - unregisterOwnedRegistration({ ...registration, lineageIdHash: "lineage-owned", promptAttemptEpoch: 22 }); + 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. + // 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, 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, 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({ @@ -622,11 +672,23 @@ test("mixed owned-completion batches drop when ANY envelope is denied", async () promptAttemptEpoch: 32, ownedCompletionPolicy: "disabled", }); - registerOwnedRegistration({ ...registration, lineageIdHash: "lineage-mix-b", promptAttemptEpoch: 32 }); + 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, 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. @@ -640,8 +702,20 @@ test("mixed owned-completion batches drop when ANY envelope is denied", async () expect(isOwnedCompletionEnvelopeAllowed(allowed)).toBe(true); unregisterTerminalScope(turnScope.scopeId); unregisterTerminalScope(ownedScope.scopeId); - unregisterOwnedRegistration({ ...registration, lineageIdHash: "lineage-mix-a", promptAttemptEpoch: 31 }); - unregisterOwnedRegistration({ ...registration, lineageIdHash: "lineage-mix-b", promptAttemptEpoch: 32 }); + 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 () => { @@ -667,3 +741,17 @@ test("pre-abort completion keeps ownership and is dropped once an owned scope la 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); +}); From 7e8e9038b5d7d4da99b3eb1f58f7fcf61589763d Mon Sep 17 00:00:00 2001 From: snowykr Date: Thu, 6 Aug 2026 05:53:53 +0900 Subject: [PATCH 27/30] fix(sdk): cancel accepted-not-started prompts; bound durable terminal reservations Codex review threads P1/P2 on PR #21: 1. (accepted-not-started cancel) A terminal abort landing after onPreflightAcceptCommit accepted the prompt but before agent_start bound submission.executionHandle hit terminalizePrompt's missing-handle fail-closed branch; the preflight cancellation entry was already removed after accept, so cancelPendingPreflightForTerminalAbort was not called and the pending #promptWithMessage could continue into the agent. The abort now cancels the in-flight session preflight whenever the active submission has no executionHandle yet, so the accepted-but-not-started prompt cannot start after the abort response. 2. (bounded reservations) Idle/already-terminal aborts with unique keys appended durable no_effect rows without a count/age cap, so a client could grow the reconciliation document indefinitely. The reservation now trims the OLDEST no_effect rows beyond a 256 cap (bounded like the in-memory idempotency cache) via the pure boundNoEffectReservations helper; stopped/uncertain rows are never evicted. Unit test covers the eviction. Lore-id: c04-terminal-accepted-cancel-reservation-bound Tested: 29/29 terminal-abort (incl. reservation bound), 17/17 store, 6/6 chain, 21/21 dispatch, 9/9 sdk-host, 81/81 host-wiring; package check clean --- packages/coding-agent/src/sdk/bus/index.ts | 117 +++++++++--------- .../src/session/terminal-abort.ts | 37 ++++++ .../test/session/terminal-abort.test.ts | 32 +++++ 3 files changed, 129 insertions(+), 57 deletions(-) diff --git a/packages/coding-agent/src/sdk/bus/index.ts b/packages/coding-agent/src/sdk/bus/index.ts index d65e90e1cd..2ed06ca0ce 100644 --- a/packages/coding-agent/src/sdk/bus/index.ts +++ b/packages/coding-agent/src/sdk/bus/index.ts @@ -46,7 +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 { findOwnedRegistrationsForTurn, settleOwnedWork } from "../../session/terminal-abort"; +import { + boundNoEffectReservations, + findOwnedRegistrationsForTurn, + settleOwnedWork, +} from "../../session/terminal-abort"; import { parseThinkingLevel } from "../../thinking"; import type { AskAnswerRequest, @@ -4503,22 +4507,22 @@ export function createNotificationsExtension( return { ok: true as const, outcome: "uncertain_replay" as const, stored: storedRow }; } } - const 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. - if (keyHash) { - try { - await durableStore.transactTerminalScopes(scopes => [ - ...scopes.filter( - s => !(s.idempotencyKeyHash === keyHash && s.idempotencyInputHash === inputHash), - ), + // 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, @@ -4538,51 +4542,50 @@ export function createNotificationsExtension( responsePayloadHash: inputHash, acceptedAt: Date.now(), } satisfies DurableTerminalScopeRecord, - ]); - } catch (error) { - // The durable reservation is the only replay guard beyond the - // in-memory cache: without it a same-key retry after eviction/ - // restart could abort an unrelated later turn, so a failed - // reservation must NOT report success (review thread P2). - logger.warn(`sdk: terminal no-effect reservation failed: ${String(error)}`); - return { ok: false as const, reason: "reservation_failed" as const }; - } + ]; + return boundNoEffectReservations(next, MAX_DURABLE_TERMINAL_RESERVATIONS); + }); + return "ok"; + } catch (error) { + logger.warn(`sdk: terminal no-effect reservation failed: ${String(error)}`); + return "failed"; + } + }; + const 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 }; } return { ok: true as const, outcome: "no_active_turn" as const }; } const [commandId, turnId] = active[0].split(":", 2); + // 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 after the abort (review thread P2). + if (!active[1].executionHandle) { + const preflightSeam = ctx as typeof ctx & { + cancelPendingPreflightForTerminalAbort?: () => void; + }; + preflightSeam.cancelPendingPreflightForTerminalAbort?.(); + } if (!commandId || !turnId) { - if (keyHash) { - try { - await durableStore.transactTerminalScopes(scopes => [ - ...scopes.filter( - s => !(s.idempotencyKeyHash === keyHash && s.idempotencyInputHash === inputHash), - ), - { - 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, - ]); - } catch (error) { - // Same durable-reservation guarantee as the no-active path. - logger.warn(`sdk: terminal no-effect reservation failed: ${String(error)}`); - return { ok: false as const, reason: "reservation_failed" as const }; - } + 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 }; } diff --git a/packages/coding-agent/src/session/terminal-abort.ts b/packages/coding-agent/src/session/terminal-abort.ts index 39d2d1306c..7f284a9d05 100644 --- a/packages/coding-agent/src/session/terminal-abort.ts +++ b/packages/coding-agent/src/session/terminal-abort.ts @@ -553,3 +553,40 @@ export function resetTerminalAbortRegistriesForTests(): void { ownedRegistrations.clear(); lineageByToolCall.clear(); } + +/** + * Structural subset of a durable terminal-scope row needed for bounding + * no-effect reservations (review thread P2). Only the oldest no_effect rows + * beyond the cap are evicted; stopped/uncertain rows are untouched. + */ +export interface NoEffectReservationRow { + idempotencyKeyHash?: string; + idempotencyInputHash?: string; + turnDisposition: "no_effect" | (string & {}); + acceptedAt?: number; +} + +/** + * Evict the OLDEST no_effect rows beyond `cap`, mirroring the bounded + * in-memory idempotency cache so idle terminal aborts with unique keys + * cannot grow the durable reconciliation document indefinitely. Non-no_effect + * rows are never evicted. Returns a new array. + */ +export function boundNoEffectReservations(rows: T[], cap: number): T[] { + const noEffect = rows.filter(s => s.turnDisposition === "no_effect"); + if (noEffect.length <= cap) return rows; + const overflow = noEffect.length - cap; + const evict = new Set( + [...noEffect] + .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 === "no_effect" && + evict.has(`${s.idempotencyKeyHash ?? ""}\u0000${s.idempotencyInputHash ?? ""}`) + ), + ); +} diff --git a/packages/coding-agent/test/session/terminal-abort.test.ts b/packages/coding-agent/test/session/terminal-abort.test.ts index 08f05a458a..8e8375fe0c 100644 --- a/packages/coding-agent/test/session/terminal-abort.test.ts +++ b/packages/coding-agent/test/session/terminal-abort.test.ts @@ -2,6 +2,7 @@ import { beforeEach, expect, test } from "bun:test"; import { ownedCompletionResumeAction } from "../../src/session/agent-session"; import { bindToolLineage, + boundNoEffectReservations, classifyOwnedCompletion, createTurnContinuationSeam, type DeliveryOrigin, @@ -755,3 +756,34 @@ test("registerOwnedRegistration overwrites a reused tuple from a different turn" expect(lookupOwnedRegistration("job-1", "gen-1")).toEqual(fresh); unregisterOwnedRegistration(fresh); }); + +test("boundNoEffectReservations evicts only the oldest no_effect rows beyond the cap", () => { + 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, + }); + const bounded = boundNoEffectReservations(rows, 256); + // 300 no_effect -> 256 kept (44 oldest evicted); the stopped row is untouched. + expect(bounded.filter(r => r.turnDisposition === "no_effect")).toHaveLength(256); + expect(bounded.some(r => r.idempotencyKeyHash === "k0")).toBe(false); + expect(bounded.some(r => r.idempotencyKeyHash === "k43")).toBe(false); + expect(bounded.some(r => r.idempotencyKeyHash === "k44")).toBe(true); + expect(bounded.some(r => r.idempotencyKeyHash === "k299")).toBe(true); + expect(bounded.some(r => r.idempotencyKeyHash === "stopped-key")).toBe(true); +}); From 57375f841d68f03ff79d3df9f16ab8be763ada30 Mon Sep 17 00:00:00 2001 From: snowykr Date: Thu, 6 Aug 2026 06:07:10 +0900 Subject: [PATCH 28/30] fix(sdk): finalize accepted pre-run aborts as cancelled, not fenced-uncertain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review thread P2 on PR #21: an accepted-but-not-started prompt (submission exists, agent_start not yet bound) had the session preflight cancelled but then continued into terminalizePrompt, whose missing-handle branch ran failPromptClosed — claiming a pending prompt outcome, fencing the SDK connection, and leaving reconciliation unfinalized until restart for a prompt that will never run. The accepted-but-not-started case now FINALIZES the prompt as a pre-run client cancellation (terminalizePrompt without fence, outcome stopped/ cancelled/client_cancel), reserves the key durably, and returns no_active_turn / terminal_no_effect — no fenced-uncertain claim, no connection fence. The harness test that simulated "cannot be fenced" (via an unbound handle) now asserts the finalized cancellation. Lore-id: c04-terminal-pre-run-finalize Tested: 81/81 host-wiring (incl. accepted-but-not-started finalize), 29/29 terminal-abort, 6/6 chain, 21/21 dispatch, 9/9 sdk-host, 17/17 store; package check clean --- packages/coding-agent/src/sdk/bus/index.ts | 26 ++++++++++++++----- .../coding-agent/test/sdk-host-wiring.test.ts | 17 ++++++------ 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/packages/coding-agent/src/sdk/bus/index.ts b/packages/coding-agent/src/sdk/bus/index.ts index 2ed06ca0ce..3708734987 100644 --- a/packages/coding-agent/src/sdk/bus/index.ts +++ b/packages/coding-agent/src/sdk/bus/index.ts @@ -4571,23 +4571,37 @@ export function createNotificationsExtension( 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 + // cancellation entry was already removed after accept. Cancel the // in-flight session preflight so the pending #promptWithMessage - // cannot continue into the agent after the abort (review thread P2). + // 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) { const preflightSeam = ctx as typeof ctx & { cancelPendingPreflightForTerminalAbort?: () => void; }; preflightSeam.cancelPendingPreflightForTerminalAbort?.(); - } - if (!commandId || !turnId) { + await terminalizePrompt( + { commandId, turnId }, + { kind: "stopped", reason: "cancelled", provenance: "client_cancel" }, + {}, + ); 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 }; + 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) diff --git a/packages/coding-agent/test/sdk-host-wiring.test.ts b/packages/coding-agent/test/sdk-host-wiring.test.ts index 7f3649cbc8..f80bb20518 100644 --- a/packages/coding-agent/test/sdk-host-wiring.test.ts +++ b/packages/coding-agent/test/sdk-host-wiring.test.ts @@ -2585,7 +2585,7 @@ test("SDK host turn.abort terminal mode returns no-effect with no active turn", await handlers.get("session_shutdown")?.({ type: "session_shutdown" }, sessionContext); }); -test("SDK host turn.abort terminal mode fails closed when the turn cannot be fenced", async () => { +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()}`; @@ -2633,9 +2633,11 @@ test("SDK host turn.abort terminal mode fails closed when the turn cannot be fen ); await waitFor(() => deliveries.length === 1, "terminal prompt accepted"); void handlers.get("agent_start")?.({ type: "agent_start" }, sessionContext); - // The fixture harness has no exact run handle or abortPromptAndWait seam, so - // the fence cannot settle: terminal abort must fail closed with safe - // uncertainty instead of fabricating a stopped disposition. + // 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", @@ -2653,11 +2655,8 @@ test("SDK host turn.abort terminal mode fails closed when the turn cannot be fen ok: true, result: { selection: "turn", - turn: "uncertain", - ownedWork: "left_running", - automaticDelivery: "enabled", - resumeOnOwnedCompletion: true, - reason: "worker_unsettled", + turn: "no_active_turn", + terminal: "terminal_no_effect", }, }); await handlers.get("session_shutdown")?.({ type: "session_shutdown" }, sessionContext); From 7ec092092bde869b42764979cee9a982e4423413 Mon Sep 17 00:00:00 2001 From: snowykr Date: Thu, 6 Aug 2026 06:21:20 +0900 Subject: [PATCH 29/30] fix(sdk): expose terminal abort seams on ExtensionContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review thread P1 on PR #21: getTerminalTurnEpoch and cancelPendingPreflightForTerminalAbort existed only on ExtensionContextActions; ExtensionRunner.createContext() never returned them, so the ctx passed to the SDK host in PRODUCTION lacked both seams — active terminal aborts hit markerEpoch === undefined and returned terminal_no_effect WITHOUT stopping the turn, and preflight terminal aborts only settled the SDK waiter without cancelling the underlying AgentSession preflight. (The host-wiring harness provided the seams manually, masking the gap.) The ExtensionRunner now binds the seams from contextActions during initialize (with safe zero fallbacks) and returns them from createContext(), and the ExtensionContext interface declares them. Runner test: the created context surfaces getTerminalTurnEpoch and cancelPendingPreflightForTerminalAbort after initialize. Lore-id: c04-terminal-context-seams Tested: 34/34 extensions-runner (incl. seam surfacing), 81/81 host-wiring, 29/29 terminal-abort, 6/6 chain, 21/21 dispatch, 9/9 sdk-host; package check clean --- .../src/extensibility/extensions/runner.ts | 7 +++++ .../src/extensibility/extensions/types.ts | 4 +++ .../test/extensions-runner.test.ts | 30 +++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/packages/coding-agent/src/extensibility/extensions/runner.ts b/packages/coding-agent/src/extensibility/extensions/runner.ts index f09337fa48..9199337832 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 ff0f708f24..b7560fcce7 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). */ diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index fa088f9399..4244f1dd52 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) { From 264cde88857a6ecbcea12f412b9641e353d649c8 Mon Sep 17 00:00:00 2001 From: snowykr Date: Thu, 6 Aug 2026 06:31:57 +0900 Subject: [PATCH 30/30] fix(sdk): clear hidden successors on skip/admission; reserve pre-run keys first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review threads P1/P2 on PR #21: 1. (hidden successors on skip/admission) The hidden-next-turn discard only ran inside the scheduled drain callback. If a new prompt advanced the generation before the drain ran, the task was skipped by the generation guard and the clear never executed — and once the new prompt bumped the epoch, the fence lookup could not find the aborted turn's scope, so #promptWithMessage would inject the aborted turn's hidden successors into the new user prompt. #promptWithMessage now discards pending hidden next-turn messages BEFORE the admission bump when the previous turn's fence is closed (covering scheduled-skip, explicit-drain, and no-drain paths alike). Chain test: terminal abort + new prompt discards the hidden successor. 2. (pre-run reservation order) The accepted-but-not-started branch finalized the accepted prompt BEFORE writing the durable no-effect reservation; a failed write or a crash between the awaits left the key unreserved while the prompt was already cancelled. The reservation is now persisted before terminalizePrompt in the pre-run path. Lore-id: c04-terminal-hidden-skip-pre-run-reserve Tested: 7/7 chain (incl. skip-path discard), 29/29 terminal-abort, 21/21 dispatch, 17/17 store, 9/9 sdk-host, 52/52 resilient-retry, 6/6 queued-prompts, 81/81 host-wiring; package check clean --- packages/coding-agent/src/sdk/bus/index.ts | 11 +++++-- .../coding-agent/src/session/agent-session.ts | 8 +++++ ...agent-session-terminal-abort-chain.test.ts | 29 +++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/sdk/bus/index.ts b/packages/coding-agent/src/sdk/bus/index.ts index 3708734987..d325f1faee 100644 --- a/packages/coding-agent/src/sdk/bus/index.ts +++ b/packages/coding-agent/src/sdk/bus/index.ts @@ -4593,14 +4593,19 @@ export function createNotificationsExtension( cancelPendingPreflightForTerminalAbort?: () => void; }; preflightSeam.cancelPendingPreflightForTerminalAbort?.(); + // Persist the durable no-effect reservation BEFORE finalizing the + // accepted prompt: a failure/crash between the awaits must never + // leave the key unreserved while the prompt is already cancelled + // (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 }; + } await terminalizePrompt( { commandId, turnId }, { kind: "stopped", reason: "cancelled", provenance: "client_cancel" }, {}, ); - if ((await reserveTerminalNoEffect()) === "failed") { - return { ok: false as const, reason: "reservation_failed" as const }; - } return { ok: true as const, outcome: "no_active_turn" as const }; } // Plan ordered step 4: write the bounded INITIAL MARKER (key/input diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 3cb3f3f118..9eda4c14e0 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -8818,6 +8818,14 @@ 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 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 index 623f29b036..27b0c40c2a 100644 --- a/packages/coding-agent/test/agent-session-terminal-abort-chain.test.ts +++ b/packages/coding-agent/test/agent-session-terminal-abort-chain.test.ts @@ -279,4 +279,33 @@ describe("terminal abort registers a turn scope so left-running owned work class }); 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); });