Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
275bde3
feat(sdk): add validated C04 terminal abort input contract
snowykr Aug 5, 2026
a0c2c43
feat(sdk): v2 reconciliation store with durable terminal scope records
snowykr Aug 5, 2026
33e353d
feat(sdk): private turn-continuation fence and origin gate
snowykr Aug 5, 2026
29fa374
feat(sdk): bind private lineage/attempt origin at tool registration s…
snowykr Aug 5, 2026
87f81f9
feat(sdk): origin-aware async-result delivery with fresh-attempt resume
snowykr Aug 5, 2026
9af3f07
feat(sdk): turn.abort terminal surface with turn-scope registration a…
snowykr Aug 5, 2026
4841d79
docs(sdk): add mandatory terminal-abort ADR and design-note gate
snowykr Aug 5, 2026
9a07436
feat(sdk): durable terminal-scope record with no-store gate
snowykr Aug 5, 2026
66a50a8
feat(sdk): owned-scope exact stop with delivery settlement and event …
snowykr Aug 5, 2026
f9637da
feat(sdk): deterministic replay, fenced-turn bounds, and gate registr…
snowykr Aug 5, 2026
2efa6e8
fix(sdk): drop denied owned deliveries and partition mixed batches
snowykr Aug 5, 2026
9497f59
feat(sdk): durable terminal marker, replay rows, and host delivery ou…
snowykr Aug 5, 2026
9bfcb7f
chore(sdk): rebase cleanup — regenerate docs index, fix dev-side send…
snowykr Aug 5, 2026
7b3b70a
fix(sdk): decide terminal success by terminalizePrompt outcome; await…
snowykr Aug 5, 2026
7cedb07
fix(sdk): normalize terminal abort input before idempotency hashing
snowykr Aug 5, 2026
15f3948
fix(sdk): fresh lineage epoch per root turn; durable conflicts as con…
snowykr Aug 5, 2026
9bd8706
fix(sdk): guard response-state transition by terminal input hash
snowykr Aug 5, 2026
851f0bd
fix(sdk): durably reserve idle terminal keys; register monitor jobs a…
snowykr Aug 5, 2026
4117879
fix(sdk): defer resume lineage to admission; strict delivery-hook inp…
snowykr Aug 5, 2026
37eca64
fix(sdk): fresh lineage at follow-up admission; preserve aborted epoc…
snowykr Aug 5, 2026
ac6bc7e
fix(sdk): session-relative resume epoch; gate hidden next-turn by the…
snowykr Aug 5, 2026
6312cc8
fix(sdk): cancel pending preflights on terminal abort; drop owned-sto…
snowykr Aug 5, 2026
4a0c76f
fix(sdk): invalidate the session preflight on terminal abort
snowykr Aug 5, 2026
a57e5c1
fix(sdk): preserve ownership for pre-abort async completions
snowykr Aug 5, 2026
50ab00a
fix(sdk): drop denied owned-completion follow-ups; discard blocked hi…
snowykr Aug 5, 2026
2091726
fix(sdk): rebind stale owned registrations; keep owned envelopes out …
snowykr Aug 5, 2026
7e8e903
fix(sdk): cancel accepted-not-started prompts; bound durable terminal…
snowykr Aug 5, 2026
57375f8
fix(sdk): finalize accepted pre-run aborts as cancelled, not fenced-u…
snowykr Aug 5, 2026
7ec0920
fix(sdk): expose terminal abort seams on ExtensionContext
snowykr Aug 5, 2026
264cde8
fix(sdk): clear hidden successors on skip/admission; reserve pre-run …
snowykr Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions docs/adr-abort-sdk-terminal-turn-owned.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# ADR: SDK terminal abort — turn-origin fence with owned-completion enablement

## Decision

**ADOPT — origin-aware `TurnContinuationFence`/`TurnContinuationGate` plus normal owned-completion delivery.**

C04 `turn.abort` gains `mode:"terminal"` with typed `scope:"turn" | "owned"` (default `"turn"`)
and a required bounded idempotency key (≤128 UTF-8 bytes). Terminal abort stops the root
worker's current turn and blocks **only** that turn's own continuation routes; exact owned
background work (Bash/task jobs, detached subagents) that the caller deliberately leaves
running keeps running, and its completion/progress is delivered through the existing
`YieldQueue -> agent.followUp`/`agent.prompt` path as a **fresh** root turn with a new
attempt/lineage/worker epoch.

## Prominent corrected design note (mandatory)

> **ADR/design note — turn abort is not owned-delivery abort.** `scope:"turn"` closes the root
> worker's current turn and its own continuation routes, while exact owned work remains
> runnable and its completion/progress results are intentionally delivered through the
> existing `YieldQueue -> AgentSession -> agent.followUp`/`agent.prompt` path. The delivery
> starts a fresh root turn with a new attempt/lineage. The earlier stage-04 no-successor fence
> that suppressed or deferred those deliveries was a misunderstanding: it defeated the reason
> to expose a leave-running option. **Do not reinstate it under another name.**

## Naming rules

- Blocked routes are **turn-origin continuations**: `TurnContinuationFence`,
`TurnContinuationGate`, `blockedContinuationIds`, `predecessorTombstones`. The gate denies
only `turn-continuation` origins after close.
- Allowed left-running feedback is **owned-completion delivery**: `ownedCompletionPolicy`,
`ownedCompletionDelivery`, `resumeFromOwnedCompletion`, `OwnedCompletionEnvelope`. A closed
turn record never invalidates or denies an allowed owned-completion entry.
- **Prohibited names** (any code, test, or review text): `TurnDeliveryGate`,
`suppressOwnedDelivery`, `closedOwnedDeliveryFence`, `selectedDeliverySuppression`,
`deferredOwnedCompletion`, or any phrasing that says "closed turn means no owned-completion
delivery". Finding any is a hard implementation blocker.

## Semantics

- `scope:"turn"` (default): `ownedWork:"left_running"`, `automaticDelivery:"enabled"`,
`resumeOnOwnedCompletion:true`. Owned work keeps running; an owned completion resumes the
root with a fresh attempt. Same-turn retry, TTSR/`agent.continue`, steering continuation,
hidden-next-turn, maintenance/worker successor, and accepted-pre-close same-attempt
continuations are blocked/tombstoned.
- `scope:"owned"`: additionally stops exact causal owned work with full quiescence proof and
foreign-work uncertainty; nothing resumes from stopped work (`automaticDelivery:"none"`,
`resumeOnOwnedCompletion:false`).
- Classification is **source/lineage-based, never timing-based**: the exact five-tuple
(endpoint generation, lineage hash, attempt epoch, job id, job generation) is recorded
before the job handle escapes; missing/mismatched metadata fails closed to ordinary.
- ultragoal/ralplan workflow stop is out of scope; ledgers/artifacts/handoffs stay untouched.
- No public surface widening: only the typed scope and bounded outcome metadata are exposed;
lineage/fence/ticket/envelope machinery is private to the SDK session layers.

## Implementation state

Committed on `feat/abort-sdk-terminal` (lore `c04-terminal-*`), base `e92a04e3`:

- `c04-terminal-lineage`: lineage/attempt origin authority — per-turn lineage minted before
model execution, `beforeToolCall` binding, task/Bash `registerOwnedIfLineaged` five-tuple
capture; bounded registries, fail-closed.
- `c04-terminal-origin-delivery`: origin-aware async-result delivery —
`classifyOwnedCompletion` before formatting/artifact allocation, `OwnedCompletionEnvelope`
carrier, `resumeFromOwnedCompletion` fresh-attempt allocation; mandated boundary comments at
`sdk/session.ts`, `yield-queue.ts`, and both `agent-session.ts` injectors.
- `c04-terminal-surface`: `turn.abort` terminal surface wired to the durable prompt
terminalization; landed-terminal verification before claiming `stopped`; no-active-turn =
`terminal_no_effect`; unfencible = `terminal_uncertain`; turn dispositions as above.
- `c04-terminal-scope-registration`: terminal scope registered + synchronously closed at abort
(session `abortPromptAndWait` terminal option), epoch advanced so the fence never leaks onto
later turns; `classifyOwnedCompletion` live end to end.
- `c04-terminal-continuation-gate`: same-turn continuations denied at the final synchronous
boundary (skip reason `terminal_turn`); fail-open without a scope.
- `c04-terminal-durable-record`: bounded `DurableTerminalScopeRecord` (selection, fence, policy,
dispositions, response state, payload hash, key hash) through the v2 store; AC 5 no-store
gate; same-key replay via dispatch + durable key-hash lookup.
- `c04-terminal-owned-stop`: `scope:"owned"` generation-verified exact cancel, fixed grace,
second quiescence proof (generation-revalidated), delivery purge, `ownedWork:"stopped"` only
after proof; `settleOwnedWork` unit-tested; event metadata on the correlated `agent_end`.
- `c04-terminal-gate-authority`: gate requires the exact registered five-tuple (forged/
unregistered denied); injectors drop denied owned-completion deliveries entirely (AC 36
zero final calls) and allocate a fresh attempt only on `allow-new-turn`.

Durable contract status (AC 6/18/19/41/42): the record persists selection, the
continuation fence (epoch + tombstones + policy), dispositions, the
normalized-input and key hashes, response state, and `terminalPublished`. Same-key
replay/conflict is deterministic across dispatch-LRU eviction and restart (the v2
store reloads terminal scopes from the single document), and response state
advances monotonic `pending -> sent` once the host writes the control response.
Not wired (tracked): a `pending -> failed` transition on host write rejection
(no surface-level host failure hook exists), a `sent -> delivered` transition
(client-acknowledgement protocol), and runtime re-hydration of the continuation
fence into the process registry. The last is architecturally bounded: lineage
registries are process-local and the per-session lineage secret regenerates on
restart, so a restarted session has NO lineage authority for a previous turn —
the plan's own AC 42 conditions fence installation on "runtime authority being
present", and missing authority failing closed (no auto-inject) is satisfied by
the durable replay/conflict gate alone.

## Reviewer / implementer checklist (mandatory)

Answer these against any change to this feature:

1. **Which origins are blocked?** Only `turn-continuation` origins of the aborted turn (same-turn
retry, TTSR/`agent.continue`, steering, hidden-next-turn, maintenance/worker successor,
accepted-pre-close same-attempt continuation). Not owned-completion, not foreign, not
ordinary.
2. **Can a left-running owned completion reach `followUp`/`prompt`?** Yes — it must, through the
normal `YieldQueue` path, after a closed `turn` record, as a fresh turn.
3. **Where is the fresh attempt allocated?** `AgentSession.#resumeFromOwnedCompletion` (fresh
`promptAttemptEpoch` + opaque lineage id) immediately before the existing
`followUp`/`prompt` call. It never reuses the aborted attempt's epoch.
4. **Is any six-path observer turn-only?** No. Any `OwnedDeliverySettlementObserver` is
owned-scope-only proof of exact settlement; it never runs for a `turn` left-running
completion and never emits `suppressed`/`deferred` turn receipts.
5. **Does any name imply suppressing owned delivery?** If yes (see prohibited names above), the
change is blocked pending a fresh intent decision.
8 changes: 8 additions & 0 deletions packages/agent/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 () => {
Expand Down
7 changes: 7 additions & 0 deletions packages/agent/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,13 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
* continues with another turn.
*/
getFollowUpMessages?: () => Promise<AgentMessage[]>;
/**
* 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
Expand Down
7 changes: 7 additions & 0 deletions packages/coding-agent/src/extensibility/extensions/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ export class ExtensionRunner {
#abortPromptAndWaitFn: NonNullable<ExtensionContextActions["abortPromptAndWait"]> = 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,
Expand Down Expand Up @@ -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 }));
Expand Down Expand Up @@ -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(),
Expand Down
14 changes: 13 additions & 1 deletion packages/coding-agent/src/extensibility/extensions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RunSettlementProof>;
/** 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). */
Expand Down Expand Up @@ -1454,7 +1458,15 @@ export interface ExtensionContextActions {
/** Stable resource ownership identifier for the active prompt run. */
getActivePromptHandle?: () => string | undefined;
abort: () => void;
abortPromptAndWait?: (handle: string, options: { graceMs: number }) => Promise<RunSettlementProof>;
abortPromptAndWait?: (
handle: string,
options: { graceMs: number; terminal?: { scope: "turn" | "owned" } },
) => Promise<RunSettlementProof>;
/** 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;
Comment thread
snowykr marked this conversation as resolved.

hasPendingMessages: () => boolean;
/** Typed pending-message counts per queue; optional for embedders without a counted queue. */
getPendingMessageCounts?: () => { steering: number; followUp: number; nextTurn: number };
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,8 @@ export class ExtensionUiController {
getActivePromptHandle: () => this.ctx.session.activePromptHandle,
abort: () => this.ctx.session.abort(),
abortPromptAndWait: (handle, options) => this.ctx.session.abortPromptAndWait(handle, options),
getTerminalTurnEpoch: () => this.ctx.session.getTerminalTurnEpoch(),
cancelPendingPreflightForTerminalAbort: () => this.ctx.session.cancelPendingPreflightForTerminalAbort(),
hasPendingMessages: () => this.ctx.session.queuedMessageCount > 0,
getPendingMessageCounts: () => this.ctx.session.pendingMessageCounts,
getTranscript: () => this.ctx.session.getTranscript(),
Expand Down Expand Up @@ -844,6 +846,8 @@ export class ExtensionUiController {
getActivePromptHandle: () => this.ctx.session.activePromptHandle,
abort: () => this.ctx.session.abort(),
abortPromptAndWait: (handle, options) => this.ctx.session.abortPromptAndWait(handle, options),
getTerminalTurnEpoch: () => this.ctx.session.getTerminalTurnEpoch(),
cancelPendingPreflightForTerminalAbort: () => this.ctx.session.cancelPendingPreflightForTerminalAbort(),
hasPendingMessages: () => this.ctx.session.queuedMessageCount > 0,
getPendingMessageCounts: () => this.ctx.session.pendingMessageCounts,
getTranscript: () => this.ctx.session.getTranscript(),
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/src/modes/runtime-init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ export async function initializeExtensions(session: AgentSession, options: Initi
getActivePromptHandle: () => session.activePromptHandle,
abort: () => session.abort(),
abortPromptAndWait: (handle, abortOptions) => session.abortPromptAndWait(handle, abortOptions),
getTerminalTurnEpoch: () => session.getTerminalTurnEpoch(),
cancelPendingPreflightForTerminalAbort: () => session.cancelPendingPreflightForTerminalAbort(),
hasPendingMessages: () => session.queuedMessageCount > 0,
getPendingMessageCounts: () => session.pendingMessageCounts,
getTranscript: () => session.getTranscript(),
Expand Down
Loading
Loading