diff --git a/.agents/notes/implemented/bug-fix/2026-09-12-native-compaction-cancellation.md b/.agents/notes/implemented/bug-fix/2026-09-12-native-compaction-cancellation.md new file mode 100644 index 000000000..346633363 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-12-native-compaction-cancellation.md @@ -0,0 +1,106 @@ +# Cancel compaction through the native turn + +Status: implemented +Translation: current + +[中文](2026-09-12-native-compaction-cancellation.zh.md) + +## Abstract + +Manual Codex `/compact` was treated as a command without a native turn, so Stop +could return an ACP cancellation while Codex kept compacting. The adapter now +captures the native turn and interrupts it through the ordinary cancellation +path, retaining ownership until terminal confirmation. Lody sends provider cancel +without interrupting an in-flight prompt's owner fiber, so normal cancellation +finalizes history only after ACP returns. A shared five-second drain terminates +an unresponsive provider; failed termination keeps ownership. PR #618 removes its +automatic historical reconciliation protocol and fixes these execution boundaries. +Existing stale histories are not migrated by opening a conversation. + +## Decision + +Codex 0.153.4 emits standard turn and item notifications for manual compaction. +`thread/compact/start` acknowledges submission with an empty result; the turn id +arrives through `turn/started`. The previous assumption that compaction had no +interruptible turn confused an empty acknowledgement with an absent lifecycle. + +The app-server client registers an owner before submission and resolves only on +the matching `turn/completed`. Start failure and connection closure reject the +owner. The command routes the native id and terminal result through the same ACP +command lifecycle used by other native turns. Stop and request cancellation both +interrupt the captured turn. A cancellation before its id arrives stays pending +until the turn starts; the ACP prompt remains occupied while cancellation drains. + +Lody records `runtime.cancelRequested` and sends provider cancel while the prompt +is in flight. It retains the existing owner fiber and runtime until ACP returns; +the scope then runs cancellation finalization and releases ownership. A new user +message remains pending and cannot reach ACP during that interval. Steer admission +also checks the existing cancellation flag after asynchronous preparation and after +the provider's acceptance ACK. A successful ACK after Stop cannot replace the source +invocation, force its settlement to handled, or transfer ownership. It marks that exact +steer user turn `canceled` without changing dispatch pointers, then returns `stale-turn` +and releases the application lease. The accepted steer cannot remain `pending_apply` +or be requeued; the existing owner continues to its cancelled terminal outcome. Neither the +daemon nor the client treats that disposition as permission to replay the steer. +Cancellation before prompt submission and finalization teardown retain their existing paths. + +The earlier CLI code already retained the runtime through `pendingPromptCompletion` +inside its scope finalizer; it did not unconditionally release ownership at Stop. +It nevertheless interrupted the owner fiber and finalized history before draining +the provider. Normal Stop now waits in the prompt itself and starts the existing +five-second raw-request drain independently of the cancel acknowledgement. If the +prompt remains pending at the deadline, Lody terminates that session; connection +closure rejects the prompt and lets the owner enter its finalizer naturally. +Failed termination waits for raw ACP completion without releasing ownership. +Stop and external-interruption finalization share one drain promise on the runtime, +so repeated cancellation neither resets the deadline nor terminates twice. +This restores #571's recovery policy, which the earlier in-flight Stop change had +unintentionally restricted to external interruption. + +After ACP completion, the existing CLI finalization from +[provider-failure settlement](2026-09-10-context-compaction-terminal-state.md) +then persists unresolved compaction as failed before releasing execution ownership. +Explicit provider terminal item updates remain authoritative. + +The Session view does not initiate data repair. The branch's new reconciliation +capability, both transport methods, renderer retry hook, and daemon history repair +are removed. Historical unresolved records are a separate maintenance concern; +neither hiding progress nor rewriting history can interrupt native execution. + +## Evidence and verification + +- [Pinned native protocol](https://github.com/openai/codex/blob/rust-v0.153.4/codex-rs/app-server/README.md#example-trigger-thread-compaction). +- Adapter owner: `packages/acp-extension-codex/src/CodexAppServerClient.ts`; + command and cancel routing: `CodexCommands.ts` and `CodexAcpServer.ts` in that directory. +- Deterministic tests cover Stop and request abort before/after native start, + rejection of a second prompt while draining, successful continuation after + interruption, failure, terminal events before the start ACK, start rejection, + unrelated turn completion, and process exit before/after native start. +- The Lody execution suite uses the real `AgentClient` with a controlled ACP + transport: cancel ACK leaves the prompt signal live and history unfinished, + a second dispatch stays pending, and native terminal evidence enables the next + prompt. Fake timers cover normal Stop's five-second termination, a withheld cancel + acknowledgement, repeated Stop without deadline reset, and failed termination + retaining unfinished history and ownership until ACP ends. External-interruption + coverage retains raw completion, process termination, and failed-termination cases. +- Steer coverage retains undelivered history and its dispatch pointer when Stop + precedes the request or arrives while prompt blocks are being built. A controlled + acceptance ACK arriving after Stop preserves the source invocation, user-turn and + dispatch owner, leaves source history unfinished, and never reports handled or + replays the steer. The accepted steer is `canceled` both before and after provider + terminal, which settles the source as cancelled and releases ownership. The terminal + steer assertion failed on `ae24a723` with the entry stranded in `pending_apply`. +- Contract: [Session history writes](../../../../specs/session-history-writes.md). +- Pull request: [Lody #618](https://github.com/LodyAI/Lody/pull/618). +- Adapter implementation: [Codex adapter #41](https://github.com/LodyAI/acp-extension-codex/pull/41). + +Adapter typechecks and all 617 enabled adapter tests passed; 27 tests are skipped +by their existing environment gates. Real Codex smoke checks completed manual +compaction normally and confirmed that Stop produces native `interrupted` before +the ACP prompt returns `cancelled`. + +Root typechecks, lint, formatting, i18n, documentation and boundary checks passed. +The targeted Lody execution, dispatch-watcher and AgentClient suites pass 207 tests. +The full `pnpm check` reached Electron tests: 103 passed, while the relay suite +could not load because this checkout lacks the installed Electron binary. All +preceding workspace test suites passed. diff --git a/.agents/notes/implemented/bug-fix/2026-09-12-native-compaction-cancellation.zh.md b/.agents/notes/implemented/bug-fix/2026-09-12-native-compaction-cancellation.zh.md new file mode 100644 index 000000000..400ad5ed7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-12-native-compaction-cancellation.zh.md @@ -0,0 +1,78 @@ +# 通过 native turn 取消压缩 + +Status: implemented +Translation: current + +[English](2026-09-12-native-compaction-cancellation.md) + +## 摘要 + +手动 Codex `/compact` 原本被当作没有 native turn 的命令,Stop 可以返回 ACP 取消, +但 Codex 仍在压缩。适配器现在捕获 native turn,通过常规取消路径中断它,并持有 ownership +直到 terminal 确认。Lody 发送 provider cancel,不中断 in-flight prompt 的 owner fiber, +因此正常取消在 ACP 返回后才完成历史收尾;共用的五秒 drain 会终止无响应的 provider, +终止失败则继续持有 owner。PR #618 删除自动历史 reconciliation 协议,改为修复这些执行边界; +打开会话不会迁移已有的陈旧历史。 + +## 决策 + +Codex 0.153.4 的手动压缩会发出标准 turn 和 item 通知。`thread/compact/start` 用空结果确认 +提交,turn id 随 `turn/started` 到达。此前认为压缩没有可中断 turn,是把空 ACK 与不存在 +生命周期混为一谈。 + +app-server client 在提交前注册 owner,只在匹配的 `turn/completed` 到来时 resolve。 +启动失败和连接关闭会 reject owner。命令通过普通 native turn 的 ACP 命令生命周期传递 +native id 和 terminal 结果。Stop 与请求取消均中断捕获的 turn;若取消早于 id 到达, +则等待 turn 启动后中断。在取消 drain 期间,ACP prompt 一直被占用。 + +Lody 在 prompt in-flight 时记录 `runtime.cancelRequested` 并发送 provider cancel, +保留原有 owner fiber 和 runtime,直到 ACP 返回,再由 scope 执行取消收尾并释放 ownership。 +期间新用户消息保持 pending,不能到达 ACP。steer 在异步准备后及 provider 接受 ACK 后都 +检查取消标记。Stop 后到达的成功 ACK 不得替换 source invocation、将 source 强制结算为 +handled 或转移 ownership。它将该 exact steer 用户轮次标为 `canceled`,不改变 dispatch pointer, +然后返回 `stale-turn` 并释放 application lease。已接受的 steer 不能停留在 `pending_apply`, +也不能重新排队;现有 owner 继续等待 cancelled terminal。daemon 与客户端均不会把这个 +disposition 当作重放 steer 的许可。提交 prompt 前的取消及 finalization teardown 保留原路径。 + +此前 CLI 的 scope finalizer 已通过 `pendingPromptCompletion` 保留 runtime,并非在 Stop 时 +无条件释放 ownership;但它会先中断 owner fiber、完成历史收尾,然后才 drain provider。 +正常 Stop 现在等待 prompt 本身,同时启动原有五秒 raw-request drain,不等待 cancel ACK。 +到期时 prompt 若仍 pending,则终止该 session;连接关闭 reject prompt,让 owner 自然进入 +finalizer。终止失败时继续等待 raw ACP 完成,不释放 ownership。Stop 与外部中断后的 +finalizer 共用 runtime 上的一个 drain promise,重复取消不会重置期限或重复终止。 +这恢复了 #571 的恢复策略;此前保留 in-flight owner 的修改误将该策略限制在外部中断路径。 + +ACP 完成后,既有的 [provider 失败收尾](2026-09-10-context-compaction-terminal-state.md) +会在释放执行 ownership 前,将尚未结束的压缩持久化为 failed。provider 明确发出的 terminal +item 更新仍是权威结果。 + +Session 视图不主动修复数据。本分支新增的 reconciliation capability、两种传输方法、 +renderer retry hook 和 daemon 历史修复均已移除。历史上的未完成记录是独立维护问题; +隐藏进度或改写历史都不能中断 native 执行。 + +## 证据与验证 + +- [固定版本的 native 协议](https://github.com/openai/codex/blob/rust-v0.153.4/codex-rs/app-server/README.md#example-trigger-thread-compaction)。 +- 适配器 owner 位于 `packages/acp-extension-codex/src/CodexAppServerClient.ts`; + 同目录的 `CodexCommands.ts` 和 `CodexAcpServer.ts` 负责命令及取消路由。 +- 确定性测试覆盖 native start 前后的 Stop/abort、drain 期间拒绝第二个 prompt、 + 中断后继续执行、失败、start ACK 前的 terminal、启动拒绝、无关 turn 完成及进程退出。 +- Lody execution 测试使用真实 `AgentClient` 与受控 ACP transport:cancel ACK 后 signal + 仍存活、历史未完成、第二次 dispatch 保持 pending,native terminal 后下一次 prompt 才能运行。 + fake timer 覆盖五秒终止、cancel ACK 不返回、重复 Stop 不重置期限,以及终止失败后继续 + 保留未完成历史和 ownership。外部中断测试仍覆盖 raw 完成、进程终止及终止失败。 +- steer 测试覆盖请求前和异步准备期间的 Stop,验证未投递历史及其 dispatch pointer 保留。 + 受控的接受 ACK 在 Stop 后到达时,source invocation、用户轮次和 dispatch owner 均不改变, + source 历史不提前完成、不报告 handled,也不重放 steer。已接受的 steer 在 provider terminal + 前后均为 `canceled`;terminal 将 source 结算为 cancelled 并释放 owner。steer 终态断言在 + `ae24a723` 上失败,当时条目会停留在 `pending_apply`。 +- 契约:[会话历史写入](../../../../specs/session-history-writes.zh.md)。 +- PR:[Lody #618](https://github.com/LodyAI/Lody/pull/618)。 +- 适配器实现:[Codex adapter #41](https://github.com/LodyAI/acp-extension-codex/pull/41)。 + +适配器类型检查及 617 个启用的测试通过;27 个测试由原有环境条件跳过。真实 Codex smoke +验证手动压缩正常完成,并确认 Stop 后 native `interrupted` 先于 ACP prompt 的 `cancelled`。 + +根仓库类型、lint、格式、i18n、文档和边界检查通过。Lody execution、dispatch-watcher 和 +AgentClient 三组相关测试共 207 个通过。完整 `pnpm check` 到达 Electron 测试时,103 个通过, +relay suite 因本地缺少已安装的 Electron 二进制无法加载;此前的 workspace 测试均通过。 diff --git a/apps/cli/src/session/AGENTS.md b/apps/cli/src/session/AGENTS.md index da502aa6d..0c01fa3a0 100644 --- a/apps/cli/src/session/AGENTS.md +++ b/apps/cli/src/session/AGENTS.md @@ -37,8 +37,8 @@ Contract: specs/session-orchestration.md. - Never re-dispatch a late-arriving history entry; recovery is a fresh send. - `hasPendingUserTurnActivation` is the ONLY pending-turn predicate; never compare those two pointers in a consumer. -- Session metadata is the activation index: never inspect historical Session documents to infer - work, and never publish or clear active presence here (`../lib/loro/session-active-presence.ts`). +- Never inspect historical Session documents to infer work, or publish or clear active presence + here (`../lib/loro/session-active-presence.ts`). - Keep bootstrap and live reconciliation bounded as README describes; add no per-trigger scan or extra throttle. @@ -47,26 +47,27 @@ Contract: specs/session-orchestration.md. - Gate turn-scoped history LIST writes on user-entry sync (`turn-history-gate.ts`, 20s); never gate status or meta writes. - Goals obey [this contract](../../../../specs/session-goal-control.md). -- Keep `TurnRuntimeState` until raw ACP completion or confirmed termination after cancel; no - second visible turn. Assistant ids use `userTurnId`. `invocation` atomically - owns source Turn, requester and config; steer replaces it before tools. +- In-flight Stop cancels ACP, never its owner fiber. Keep `TurnRuntimeState` until raw ACP + completion or confirmed termination; no second turn. Assistant ids use `userTurnId`. + `invocation` owns source Turn, requester and config atomically; steer replaces it before tools. - Publish `latestUserMsgId` in the SAME write as the history append (`appendUserTurn`). Only dispatch producers publish it. Renderer sends and queue promotion retain the missing-history tombstone; CLI dispatch producers keep their own marker policy. - Ordinary turn execution writes only `processingUserMsgId` and `lastHandledUserMsgId`; no start or terminal path may read-await-rewrite the other slots. -- INVARIANT: a steer the agent never accepted must not stay parked in `pending_apply`. Requeue it - through the pointer, not the entry status, only for pre-submission rejections or - `AgentSteerNotDeliveredError`; skip active or already-handled entries. +- Never submit steer after Stop. A late accepted ACK cancels that exact steer entry without + transferring ownership, changing dispatch pointers or requeueing it. + Requeue unaccepted steer via its pointer, not entry status, only before submission or on + `AgentSteerNotDeliveredError`; skip active or handled entries. - Resume must REOPEN the in-progress assistant entry, clearing `finished`/`endedAt`/`permissionWaitMs` there only; never write `finished=false` from teardown. - Keep JSON-RPC/transport matching in `acp-error-classification.ts`: disposed/stale `-32603` is `agent_disconnected`, Harness compression mismatch is `acp_session_storage_incompatible`. - Continue-session recovery may restore the ACP session and retry the same prompt once, only while that turn has no ACP output. -- INVARIANT: a resolved prompt is not proof of success. A turn that emitted no ACP update takes - `recordSilentTurnFailure`, not `setDispatchHandled` (read `turnProducedVisibleOutput` before - `finalizeTurn` clears it); it still finalizes, still ADVANCES the pointer, and fails open. +- A turn with no ACP updates takes `recordSilentTurnFailure`, not `setDispatchHandled`. + Read `turnProducedVisibleOutput` before `finalizeTurn` clears it; still finalize, advance + the pointer, and fail open. - Diff content comes only from the CLI-local ACP evidence store; GitHub `diffStats` use PR compare semantics, and `session-diff-stats-target.ts` skips rather than overwrites a good total. diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 227fa4ed0..2d59b3e44 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -278,6 +278,8 @@ type TurnRuntimeState = { prePromptFailureRecorded: boolean; cancelRequested: boolean; cancelFinalized: boolean; + /** One drain deadline shared by Stop and the cancellation finalizer. */ + cancellationDrain?: Promise; interruptRequested: boolean; terminateSessionOnCancel: boolean; settlement?: { @@ -1463,7 +1465,7 @@ export class SessionExecutionService { if (runtime.turnId !== options.expectedTurnId) { return await rejectUndelivered('stale-turn'); } - if (!runtime.promptInFlight) { + if (!runtime.promptInFlight || runtime.cancelRequested) { return await rejectUndelivered('no-active-turn'); } if (runtime.userTurnId === options.userTurnId) { @@ -1498,7 +1500,7 @@ export class SessionExecutionService { } // No provider request has been submitted yet, so this guide is still // ours to run as an ordinary follow-up turn. - if (!runtime.promptInFlight) { + if (!runtime.promptInFlight || runtime.cancelRequested) { return await rejectUndelivered('no-active-turn'); } return null; @@ -1535,10 +1537,10 @@ export class SessionExecutionService { return preSubmitRejection; } const ownedPromptRun = runtime.activePromptRun; - if (!ownedPromptRun || ownedPromptRun.turnId !== runtime.turnId) { + if (runtime.cancelRequested || !ownedPromptRun || ownedPromptRun.turnId !== runtime.turnId) { return await rejectUndelivered( 'busy', - 'Prompt owner was transitioning between logical turns' + 'Prompt owner is cancelling or transitioning between logical turns' ); } @@ -1549,12 +1551,25 @@ export class SessionExecutionService { const application = await steerRun.applied; try { if ( + runtime.cancelRequested || this.turnRuntimeBySession.get(options.sessionId) !== runtime || !runtime.promptInFlight || runtime.turnId !== previousTurnId || runtime.activePromptRun !== ownedPromptRun ) { - return reject('stale-turn', 'Steer application arrived after ownership changed'); + // Provider acceptance forbids replay; Stop keeps the source cancellation owner. + if (runtime.cancelRequested) { + await this.setTerminalUserTurnStatus( + options.sessionId, + sessionDoc, + options.userTurnId, + 'canceled' + ); + } + return reject( + 'stale-turn', + 'Steer application arrived after cancellation or ownership changed' + ); } // The provider has accepted this steer and may execute tools before @@ -2028,6 +2043,34 @@ export class SessionExecutionService { }); } + private drainCancelledPrompt(session: ISession, runtime?: TurnRuntimeState): Promise { + if (runtime?.cancellationDrain) return runtime.cancellationDrain; + const pendingPrompt = session.agentClient?.pendingPromptCompletion; + if (!pendingPrompt) return Promise.resolve(); + + const drain = withTimeout(pendingPrompt, 5_000, 'ACP prompt cancellation timed out').catch( + async () => { + // A terminal response may have won just after the timeout fired. + if (!session.agentClient?.pendingPromptCompletion) return; + if (runtime && this.getTurnRuntime(runtime.sessionId, runtime.turnId) !== runtime) return; + this.deps.logger.warn( + `[${session.sessionId}] ACP prompt did not finish after cancellation; terminating session before reuse` + ); + try { + await session.terminate(true); + } catch (error) { + this.deps.logger.warn( + `[${session.sessionId}] Failed to terminate cancelled session; waiting for ACP completion: ${formatErrorMessage(error)}` + ); + // Failed termination is not permission to reuse a busy agent. + await pendingPrompt; + } + } + ); + if (runtime) runtime.cancellationDrain = drain; + return drain; + } + private createAcpReplaySuppressionResource(sessionId: SessionId): { acquire: Effect.Effect; release: Effect.Effect; @@ -2155,30 +2198,10 @@ export class SessionExecutionService { ); const sessionToDrain = options.session; - const pendingPrompt = sessionToDrain?.agentClient?.pendingPromptCompletion; - if (pendingPrompt && sessionToDrain && !options.terminateSession) { + if (sessionToDrain && !options.terminateSession) { // Keep the execution owner until ACP has actually finished. Otherwise // the next queued turn can reach the still-busy adapter after local abort. - yield* self - .tryPromise(() => withTimeout(pendingPrompt, 5_000, 'ACP prompt cancellation timed out')) - .pipe( - Effect.catchAll(() => - self.tryPromise(async () => { - self.deps.logger.warn( - `[${options.sessionId}] ACP prompt did not finish after cancellation; terminating session before reuse` - ); - try { - await sessionToDrain.terminate(true); - } catch (error) { - self.deps.logger.warn( - `[${options.sessionId}] Failed to terminate cancelled session; waiting for ACP completion: ${formatErrorMessage(error)}` - ); - // Failed termination is not permission to reuse a busy agent. - await pendingPrompt; - } - }) - ) - ); + yield* self.tryPromise(() => self.drainCancelledPrompt(sessionToDrain, runtime)); } if (runtime?.promptStarted) { @@ -5402,8 +5425,13 @@ export class SessionExecutionService { this.requestTurnInterrupt(runtime); return { success: true }; } - this.requestTurnInterrupt(runtime); + // Keep the owner alive until ACP returns; cancel acknowledgement is not prompt completion. this.requestAgentCancelInBackground(runtime, 'active'); + void this.drainCancelledPrompt(runtimeSession, runtime).catch((error: unknown) => { + this.deps.logger.warn( + `[${sessionId}] Failed to drain cancelled prompt: ${formatErrorMessage(error)}` + ); + }); return { success: true }; } diff --git a/apps/cli/tests/session-execution-service.test.ts b/apps/cli/tests/session-execution-service.test.ts index 9e5efb924..02c3975ca 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -3,7 +3,13 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; -import { RequestError, type ContentBlock } from '@agentclientprotocol/sdk'; +import { Effect, Fiber } from 'effect'; +import { + RequestError, + type ContentBlock, + type PromptRequest, + type PromptResponse, +} from '@agentclientprotocol/sdk'; import type { Logger } from '../src/utils/logger'; import { SessionExecutionService, @@ -30,6 +36,7 @@ import type { SessionManager } from '../src/session/session-manager'; import type { LoroDocumentManager } from '../src/lib/loro/doc'; import { AcpAuthenticationRequiredError, + AgentClient, AgentSteerNotDeliveredError, } from '../src/agent/agent-client'; import { AcpAuthenticationManager } from '../src/agent/acp-authentication'; @@ -810,98 +817,106 @@ describe('SessionExecutionService', () => { ).toEqual(['user-2', 'user-3']); }); - it('queues a steer the agent refused as the next ordinary turn', async () => { - let history: SessionHistoryInput[] = [ - { id: 'user-1', role: 'user', status: 'handled', read: true } as SessionHistoryInput, - { - id: 'user-2', - role: 'user', - status: 'pending_apply', - read: false, - inputConfig: { prompt: 'do it differently' }, - } as SessionHistoryInput, - ]; - const sessionDoc = { - updateHistory: vi.fn( - async (update: (entries: SessionHistoryInput[]) => SessionHistoryInput[]) => { - history = update(history); - } - ), - }; - const upsertDocMeta = vi.fn(async () => {}); - const deps = createBaseDeps({ - workspaceDocument: { - repo: { upsertDocMeta, getDocMeta: vi.fn(async () => undefined) }, - getOrCreateSessionDoc: vi.fn(async () => sessionDoc), - } as unknown as LoroDocumentManager, - }); - const service = new SessionExecutionService(deps); - const sessionId = 'session-steer-refused' as SessionId; - // The agent answered the acknowledged steer request with a refusal, which - // is proof the prompt never joined the live turn. - const steerPrompt = vi.fn(() => ({ - completion: new Promise(() => {}), - applied: Promise.reject( - new AgentSteerNotDeliveredError( - 'Agent refused the acknowledged steer request _session/steering: No active Codex turn to steer' - ) - ), - })); - const runtime = { - sessionId, - turnId: 'assistant:user-1', - userTurnId: 'user-1', - session: { - agentClient: { - getAcknowledgedSteerCapability: vi.fn(() => ({ - provider: 'codex', - appliedNotificationMethod: 'codex/steerApplied', - upstreamTurn: 'same', - configPolicy: 'active', - })), - findSteerConfigMismatch: vi.fn(() => null), - steerPrompt, + it.each(['none', 'before', 'during-build'] as const)( + 'queues an undelivered steer as the next ordinary turn (Stop: %s)', + async (cancelStage) => { + let history: SessionHistoryInput[] = [ + { id: 'user-1', role: 'user', status: 'handled', read: true } as SessionHistoryInput, + { + id: 'user-2', + role: 'user', + status: 'pending_apply', + read: false, + inputConfig: { prompt: 'do it differently' }, + } as SessionHistoryInput, + ]; + const sessionDoc = { + updateHistory: vi.fn( + async (update: (entries: SessionHistoryInput[]) => SessionHistoryInput[]) => { + history = update(history); + } + ), + }; + const upsertDocMeta = vi.fn(async () => {}); + const deps = createBaseDeps({ + workspaceDocument: { + repo: { upsertDocMeta, getDocMeta: vi.fn(async () => undefined) }, + getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + } as unknown as LoroDocumentManager, + }); + const service = new SessionExecutionService(deps); + const sessionId = 'session-steer-refused' as SessionId; + // The agent answered the acknowledged steer request with a refusal, which + // is proof the prompt never joined the live turn. + const steerPrompt = vi.fn(() => ({ + completion: new Promise(() => {}), + applied: Promise.reject( + new AgentSteerNotDeliveredError( + 'Agent refused the acknowledged steer request _session/steering: No active Codex turn to steer' + ) + ), + })); + const runtime = { + sessionId, + turnId: 'assistant:user-1', + userTurnId: 'user-1', + session: { + agentClient: { + getAcknowledgedSteerCapability: vi.fn(() => ({ + provider: 'codex', + appliedNotificationMethod: 'codex/steerApplied', + upstreamTurn: 'same', + configPolicy: 'active', + })), + findSteerConfigMismatch: vi.fn(() => null), + steerPrompt, + }, + acpSessionId: 'acp-steer-refused' as ACPSessionId, }, - acpSessionId: 'acp-steer-refused' as ACPSessionId, - }, - promptInFlight: true, - activePromptRun: { turnId: 'assistant:user-1' }, - }; - ( - service as unknown as { - turnRuntimeBySession: Map; - } - ).turnRuntimeBySession.set(sessionId, runtime); + promptInFlight: true, + activePromptRun: { turnId: 'assistant:user-1' }, + cancelRequested: cancelStage === 'before', + }; + vi.mocked(deps.buildAcpPromptBlocks).mockImplementation(async () => { + if (cancelStage === 'during-build') runtime.cancelRequested = true; + return [{ type: 'text', text: 'do it differently' }]; + }); + ( + service as unknown as { + turnRuntimeBySession: Map; + } + ).turnRuntimeBySession.set(sessionId, runtime); - await expect( - service.steerSession({ - sessionId, - expectedTurnId: 'assistant:user-1', - userTurnId: 'user-2', - userId: 'user-1', - timestamp: '2026-07-19T00:00:00.000Z', - inputConfig: { prompt: 'do it differently' }, - }) - ).resolves.toMatchObject({ applied: false, disposition: 'no-active-turn' }); + await expect( + service.steerSession({ + sessionId, + expectedTurnId: 'assistant:user-1', + userTurnId: 'user-2', + userId: 'user-1', + timestamp: '2026-07-19T00:00:00.000Z', + inputConfig: { prompt: 'do it differently' }, + }) + ).resolves.toMatchObject({ applied: false, disposition: 'no-active-turn' }); - expect(steerPrompt).toHaveBeenCalledOnce(); - // Durable source: the entry becomes dispatchable, so the watcher runs it - // once the active turn ends (and again after a daemon restart). - expect(history.find((entry) => entry.id === 'user-2')).toMatchObject({ - status: 'pending', - read: false, - }); - expect(history.find((entry) => entry.id === 'user-1')).toMatchObject({ status: 'handled' }); - // The load-bearing half: `sessionNeedsActiveWatch` reads meta only, so a - // history-only entry would be dropped the moment the session goes idle. - expect(upsertDocMeta).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ latestUserMsgId: 'user-2' }) - ); - // Ownership never moved: the refused steer must not seal the running turn. - expect(deps.turnFinalization.finalizeACPState).not.toHaveBeenCalled(); - expect(deps.beginConversationTurn).not.toHaveBeenCalled(); - }); + expect(steerPrompt).toHaveBeenCalledTimes(cancelStage === 'none' ? 1 : 0); + // Durable source: the entry becomes dispatchable, so the watcher runs it + // once the active turn ends (and again after a daemon restart). + expect(history.find((entry) => entry.id === 'user-2')).toMatchObject({ + status: 'pending', + read: false, + }); + expect(history.find((entry) => entry.id === 'user-1')).toMatchObject({ status: 'handled' }); + // The load-bearing half: `sessionNeedsActiveWatch` reads meta only, so a + // history-only entry would be dropped the moment the session goes idle. + expect(upsertDocMeta).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ latestUserMsgId: 'user-2' }) + ); + // Ownership never moved: the refused steer must not seal the running turn. + expect(deps.turnFinalization.finalizeACPState).not.toHaveBeenCalled(); + expect(deps.beginConversationTurn).not.toHaveBeenCalled(); + } + ); it('does not requeue an undelivered steer whose turn already left the pending state', async () => { let history: SessionHistoryInput[] = [ @@ -5272,13 +5287,22 @@ describe('SessionExecutionService', () => { expect(deps.turnFinalization.finalizeACPState).toHaveBeenCalledTimes(1); }); - it('routes prompt-in-flight cancellation through the turn owner finalizer', async () => { + const cancelCompletions = [ + 'native-terminal', + 'late-steer-ack', + 'terminated', + 'termination-failed', + 'cancel-unacknowledged', + ] as const; + it.each(cancelCompletions)('retains cancelled ownership (%s)', async (completion) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const nativeCompletion = completion === 'native-terminal' || completion === 'late-steer-ack'; let meta: Record = {}; let history: Array> = [ { id: 'turn-prompt-cancel', role: 'user', - items: [{ type: 'text', text: 'hello' }], + items: [{ type: 'text', text: '/compact' }], status: 'pending', read: false, }, @@ -5311,23 +5335,68 @@ describe('SessionExecutionService', () => { }), }; let activeTurnId: string | undefined; - let service: SessionExecutionService; - const agentClient = { - isCreated: vi.fn(() => true), - cancel: vi.fn(async () => {}), - prompt: vi.fn(async () => { - const result = await service.cancelSession({ - type: 'session/cancel', - sessionId: 'session-prompt-cancel' as SessionId, - machineId: 'machine-1', - workspaceId: 'workspace-1' as WorkspaceId, - turnId: 'assistant-prompt-cancel', - }); - expect(result).toEqual({ success: true }); - throw new Error('agent cancelled prompt'); - }), - currentModel: undefined, + const promptStarted = createDeferred(); + const cancelSubmitted = createDeferred(); + const cancelAck = createDeferred(); + const nativeTerminal = createDeferred(); + const steerSubmitted = createDeferred(); + const steerApplied = createDeferred<{ release: () => void }>(); + const steerReleased = createDeferred(); + const deliveredSteers: ContentBlock[][] = []; + let steering: ReturnType | undefined; + const termination = createDeferred(); + let terminationRequested = false; + let nativePending = false; + let promptSignal: AbortSignal | undefined; + const delivered: ContentBlock[][] = []; + const agentClient = new AgentClient({ + sessionId: 'session-prompt-cancel' as SessionId, + logger: createSilentLogger(), + terminalManager: {} as never, + agentConfig: { cliType: 'builtin', agentType: 'codex' }, + onUpdateMessage: () => {}, + onRequestPermission: async () => ({ outcome: { outcome: 'cancelled' } }), + }); + // Keep the real AgentClient's local abort and raw ACP tracking behavior. + // @ts-expect-error - inject an already-created ACP session at the transport boundary + agentClient.acpSessionId = 'acp-prompt-cancel' as ACPSessionId; + // @ts-expect-error - only prompt and cancel transport methods are needed here + agentClient.connection = { + cancel: async () => { + cancelSubmitted.resolve(); + await cancelAck.promise; + }, + prompt: async ({ prompt }: PromptRequest): Promise => { + if (nativePending) throw new RequestError(-32600, 'A Codex prompt is already active'); + delivered.push(prompt); + if (delivered.length > 1) return { stopReason: 'end_turn' }; + nativePending = true; + promptStarted.resolve(); + try { + return await nativeTerminal.promise; + } finally { + nativePending = false; + } + }, }; + const sendPrompt = agentClient.prompt.bind(agentClient); + vi.spyOn(agentClient, 'prompt').mockImplementation((id, blocks, options) => { + promptSignal = options?.signal; + return sendPrompt(id, blocks, options); + }); + if (completion === 'late-steer-ack') { + vi.spyOn(agentClient, 'getAcknowledgedSteerCapability').mockReturnValue({ + provider: 'codex', + appliedNotificationMethod: 'codex/steerApplied', + upstreamTurn: 'same', + configPolicy: 'active', + }); + vi.spyOn(agentClient, 'steerPrompt').mockImplementation((_id, blocks) => { + deliveredSteers.push(blocks); + steerSubmitted.resolve(); + return { applied: steerApplied.promise, completion: nativeTerminal.promise }; + }); + } const session = { sessionId: 'session-prompt-cancel' as SessionId, acpSessionId: 'acp-prompt-cancel' as ACPSessionId, @@ -5337,7 +5406,12 @@ describe('SessionExecutionService', () => { getHostWorkdir: () => '/tmp', getParentSessionId: () => undefined, exec: vi.fn(async () => ''), - terminate: vi.fn(async () => {}), + terminate: vi.fn(async () => { + terminationRequested = true; + await termination.promise; + if (completion === 'termination-failed') throw new Error('Synthetic termination failure'); + nativeTerminal.reject(new Error('Synthetic ACP connection closed')); + }), updateGitIdentity: vi.fn(), createAgent: vi.fn(async () => 'acp-prompt-cancel'), applyExecutionPlaneLimits: vi.fn(async () => {}), @@ -5352,8 +5426,11 @@ describe('SessionExecutionService', () => { } as unknown as SessionManager; const deps = createBaseDeps({ sessionManager, - beginConversationTurn: vi.fn(() => { - activeTurnId = 'assistant-prompt-cancel'; + beginConversationTurn: vi.fn((_id, userTurnId) => { + activeTurnId = + userTurnId === 'turn-prompt-cancel' + ? 'assistant-prompt-cancel' + : `assistant:${userTurnId}`; return activeTurnId; }), getActiveTurnId: vi.fn(() => activeTurnId), @@ -5372,7 +5449,7 @@ describe('SessionExecutionService', () => { getOrCreateSessionDoc: vi.fn(async () => sessionDoc), updateAcpCapabilities: vi.fn(async () => {}), } as unknown as LoroDocumentManager, - buildAcpPromptBlocks: vi.fn(async () => [{ type: 'text', text: 'hello' }] as any), + buildAcpPromptBlocks: async ({ inputBlocks }) => inputBlocks as ContentBlock[], processMessageQueue: vi.fn(async () => {}), }); vi.mocked(deps.turnFinalization.finalizeACPState).mockImplementation( @@ -5387,25 +5464,146 @@ describe('SessionExecutionService', () => { ); const onTurnSettled = vi.fn(async () => {}); - service = new SessionExecutionService(deps); - await service.continueSession( - { - type: 'session/chat', - sessionId: 'session-prompt-cancel' as SessionId, - machineId: 'machine-1', - workspaceId: 'workspace-1' as WorkspaceId, - project: { kind: 'github', repoFullName: 'owner/repo', branch: 'main' }, - acpSessionConfig: { prompt: 'hello', cliType: 'builtin', agentType: 'codex' }, - userTurnId: 'turn-prompt-cancel', - userId: 'user-1', - userName: 'User', - userEmail: 'user@example.com', - }, - { onTurnSettled } - ); + const service = new SessionExecutionService(deps); + const message: Parameters[0] = { + type: 'session/chat', + sessionId: 'session-prompt-cancel' as SessionId, + machineId: 'machine-1', + workspaceId: 'workspace-1' as WorkspaceId, + project: { kind: 'github', repoFullName: 'owner/repo', branch: 'main' }, + acpSessionConfig: { prompt: '/compact', cliType: 'builtin', agentType: 'codex' }, + userTurnId: 'turn-prompt-cancel', + userId: 'user-1', + userName: 'User', + userEmail: 'user@example.com', + }; + const nextMessage = { + ...message, + userTurnId: 'next-user-turn', + acpSessionConfig: { ...message.acpSessionConfig, prompt: 'continue' }, + }; + const running = service.continueSession(message, { onTurnSettled }); + try { + await promptStarted.promise; + const sourceInvocation = service.getActiveInvocationContext(message.sessionId); + if (completion === 'late-steer-ack') { + history.push({ + id: 'steer-user-turn', + role: 'user', + status: 'pending_apply', + inputConfig: { prompt: 'change direction' }, + }); + steering = service.steerSession({ + sessionId: message.sessionId, + expectedTurnId: 'assistant-prompt-cancel', + userTurnId: 'steer-user-turn', + userId: 'steer-requester', + timestamp: '2026-09-13T00:00:00.000Z', + inputConfig: { prompt: 'change direction' }, + }); + await steerSubmitted.promise; + } + await expect( + service.cancelSession({ + type: 'session/cancel', + sessionId: message.sessionId, + machineId: message.machineId, + workspaceId: message.workspaceId, + turnId: 'assistant-prompt-cancel', + }) + ).resolves.toEqual({ success: true }); + await cancelSubmitted.promise; + if (completion !== 'cancel-unacknowledged') cancelAck.resolve(); + await vi.advanceTimersByTimeAsync(0); + if (steering) { + steerApplied.resolve({ release: () => steerReleased.resolve() }); + await expect(steering).resolves.toMatchObject({ + applied: false, + disposition: 'stale-turn', + }); + await steerReleased.promise; + expect(onTurnSettled).not.toHaveBeenCalled(); + expect(service.getActiveInvocationContext(message.sessionId)).toEqual(sourceInvocation); + expect(service.getActiveUserTurnId(message.sessionId)).toBe(message.userTurnId); + expect(meta.processingUserMsgId).toBe(message.userTurnId); + expect(meta.latestUserMsgId).not.toBe('steer-user-turn'); + expect(history.find((entry) => entry.id === 'steer-user-turn')).toMatchObject({ + status: 'canceled', + }); + } + expect(agentClient.pendingPromptCompletion).not.toBeNull(); + expect(service.getExecutionSnapshot(message.sessionId)).toMatchObject({ + hasActiveTurn: true, + activeTurnId: 'assistant-prompt-cancel', + }); + expect(promptSignal?.aborted).toBe(false); + expect(history[0]).toMatchObject({ status: 'processing' }); + expect(history[1]).not.toHaveProperty('finished', true); + history.push({ + id: nextMessage.userTurnId, + role: 'user', + status: 'pending', + items: [{ type: 'text', text: 'continue' }], + }); + meta.latestUserMsgId = nextMessage.userTurnId; + await service.continueSession(nextMessage); + expect(delivered).toEqual([[{ type: 'text', text: '/compact' }]]); + expect(history.find((entry) => entry.id === nextMessage.userTurnId)).toMatchObject({ + status: 'pending', + }); + expect(deps.recordChatFailure).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(4_999); + expect(terminationRequested).toBe(false); + // A repeated Stop must neither abort the owner nor restart its deadline. + await service.cancelSession({ + type: 'session/cancel', + sessionId: message.sessionId, + machineId: message.machineId, + workspaceId: message.workspaceId, + turnId: 'assistant-prompt-cancel', + }); + if (nativeCompletion) { + nativeTerminal.resolve({ stopReason: 'cancelled' }); + } else { + await vi.advanceTimersByTimeAsync(1); + expect(terminationRequested).toBe(true); + expect(promptSignal?.aborted).toBe(false); + expect(history[1]).not.toHaveProperty('finished', true); + expect(service.getExecutionSnapshot(message.sessionId)).toMatchObject({ + hasActiveTurn: true, + }); + await service.continueSession(nextMessage); + expect(delivered).toEqual([[{ type: 'text', text: '/compact' }]]); + termination.resolve(); + await vi.advanceTimersByTimeAsync(0); + if (completion === 'termination-failed') { + await vi.advanceTimersByTimeAsync(5_000); + expect(agentClient.pendingPromptCompletion).not.toBeNull(); + expect(promptSignal?.aborted).toBe(false); + expect(history[1]).not.toHaveProperty('finished', true); + expect(service.getExecutionSnapshot(message.sessionId)).toMatchObject({ + hasActiveTurn: true, + }); + await service.continueSession(nextMessage); + expect(delivered).toEqual([[{ type: 'text', text: '/compact' }]]); + nativeTerminal.resolve({ stopReason: 'cancelled' }); + } + } + await running; + await vi.advanceTimersByTimeAsync(5_000); + expect(terminationRequested).toBe(!nativeCompletion); + } finally { + steerApplied.resolve({ release: () => steerReleased.resolve() }); + cancelAck.resolve(); + termination.resolve(); + nativeTerminal.resolve({ stopReason: 'cancelled' }); + await running; + await steering; + vi.useRealTimers(); + } - expect(agentClient.cancel).toHaveBeenCalledWith('acp-prompt-cancel'); - expect(deps.turnFinalization.finalizeACPState).toHaveBeenCalledTimes(2); + expect(agentClient.pendingPromptCompletion).toBeNull(); + expect(service.getExecutionSnapshot(message.sessionId)).toMatchObject({ hasActiveTurn: false }); expect(deps.processMessageQueue).not.toHaveBeenCalled(); expect(sessionDoc.setStatus).toHaveBeenCalledWith(SessionStatusFactory.idle()); expect(history[0]).toMatchObject({ id: 'turn-prompt-cancel', status: 'canceled' }); @@ -5424,10 +5622,31 @@ describe('SessionExecutionService', () => { processingUserMsgId: undefined, }); expect(onTurnSettled).toHaveBeenCalledWith('cancelled'); + expect(onTurnSettled).not.toHaveBeenCalledWith('handled'); + await service.continueSession(nextMessage); + expect(delivered).toEqual([ + [{ type: 'text', text: '/compact' }], + [{ type: 'text', text: 'continue' }], + ]); + expect(history.find((entry) => entry.id === nextMessage.userTurnId)).toMatchObject({ + status: 'handled', + }); + if (steering) { + expect(deliveredSteers).toEqual([[{ type: 'text', text: 'change direction' }]]); + expect(history.find((entry) => entry.id === 'steer-user-turn')).toMatchObject({ + status: 'canceled', + }); + } + expect(meta).toMatchObject({ + latestUserMsgId: nextMessage.userTurnId, + lastHandledUserMsgId: nextMessage.userTurnId, + }); + expect(deps.recordChatFailure).not.toHaveBeenCalled(); + expect(service.getExecutionSnapshot(message.sessionId)).toMatchObject({ hasActiveTurn: false }); }); it.each(['resolved', 'rejected', 'timeout', 'termination-failed'] as const)( - 'keeps cancelled prompt ownership until raw ACP completion or termination (%s)', + 'keeps ownership through raw ACP drain after external owner interruption (%s)', async (completion) => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); const rawPrompt = createDeferred(); @@ -5606,7 +5825,16 @@ describe('SessionExecutionService', () => { turnId: `assistant:${userTurnId}`, }) ).resolves.toEqual({ success: true }); + const owner = ( + service as unknown as { + turnRuntimeBySession: Map }>; + } + ).turnRuntimeBySession.get(sessionId); + if (!owner?.fiber) throw new Error('Expected the running turn owner'); + // External teardown can still interrupt the owner; normal Stop no longer does. + void Effect.runPromise(Fiber.interrupt(owner.fiber)); await Promise.race([drainStarted.promise, running]); + await vi.advanceTimersByTimeAsync(0); expect(promptSignal?.aborted).toBe(true); expect(status).toEqual(SessionStatusFactory.idle()); expect(history[0]).toMatchObject({ id: userTurnId, status: 'canceled' }); diff --git a/packages/acp-extension-codex b/packages/acp-extension-codex index 314b38237..61ed84ce8 160000 --- a/packages/acp-extension-codex +++ b/packages/acp-extension-codex @@ -1 +1 @@ -Subproject commit 314b38237273f3b71335d5193498b60755665769 +Subproject commit 61ed84ce884c43d57670c4795d2cdbd1b2b4b70c diff --git a/specs/session-history-writes.md b/specs/session-history-writes.md index 969efa220..555d99fef 100644 --- a/specs/session-history-writes.md +++ b/specs/session-history-writes.md @@ -1,7 +1,7 @@ # Session history writes Status: draft -Translation: stale +Translation: current [中文](session-history-writes.zh.md) @@ -46,6 +46,25 @@ That tolerance must not authorize creating new malformed items locally. - New history accepts existing legacy built-in CLI selector normalization without rewriting stored history. Steer config and same-identity task-proposal edits parse only changed fields. - Queue promotion removes its queued row only after history acceptance; failed writes retain it. +- Manual Codex compaction owns its native turn through completion. Stop interrupts + that turn and retains the ACP prompt until `turn/completed` confirms its outcome + or the provider connection closes. For an in-flight prompt with a ready ACP session, + Lody records cancellation and sends provider cancel without interrupting its owner + fiber. The owner and unfinished history remain until ACP returns; new dispatch and + undelivered steer stay pending. If the raw prompt remains pending five seconds after + Stop, Lody terminates the old session so connection closure can end the prompt. + This deadline does not wait for cancel acknowledgement or restart on repeated Stop. + Failed termination retains ownership until ACP ends. Start and interrupt + acknowledgements, like compaction-item completion, do not release execution ownership. + The CLI persists unresolved compaction as failed after confirmed cancellation, + before accepting another turn. + Opening a Session does not trigger a history-repair RPC or rewrite old outcomes. +- If Stop wins while a submitted steer awaits acceptance, a later successful ACK must + not transfer ownership, change the source invocation or settle the source as handled. + Mark that exact steer user turn `canceled` before returning `stale-turn`, without + changing dispatch pointers. Keep the current cancellation owner until provider completion. + Do not requeue the accepted steer: rejection of the local ownership transfer is not + proof of non-delivery. - Accepted steer provenance survives both writing and read normalization. Editing and resending must not reinterpret a steer as an independently replayable user turn. - External imports retain their source hashes and derived ids. A separate versioned diff --git a/specs/session-history-writes.zh.md b/specs/session-history-writes.zh.md index 738824286..868bc7b14 100644 --- a/specs/session-history-writes.zh.md +++ b/specs/session-history-writes.zh.md @@ -1,7 +1,7 @@ # 会话历史写入 Status: draft -Translation: stale +Translation: current [English](session-history-writes.md) @@ -24,14 +24,29 @@ Translation: stale 复制轮次插在目标已有轮次之前,拒绝 id 冲突,保留目标容器。 调用者构造的 JSON 不能冒充这种来源。复制不修改源文档。 - 编辑后重发失败时,可恢复捕获的旧历史,不将其当新输入重验。一次性的本地回滚凭据 - 只恢复本次变化的区间,保留未涉及轮次的当前内容。期间若轮次身份/顺序变化,或区间内 + 只恢复本次变化的区间,保留未涉及轮次的当前内容及随后追加的轮次。期间若轮次身份/顺序变化,或区间内 内容变化,则拒绝覆盖;区间内唯一例外是本次新插入的 pending 用户轮次 变成 seen/read,且其他字段完全不变;它不是崩溃恢复或分布式事务。 外部 provider 导入仍是新输入,不能借用已存历史复制权限。 - 这里的接受表示本地 CRDT 写入。持久化、权限和远端同步仍由原有 repo 和传输层负责。 -- 工具状态、权限请求与描述元数据(title/kind/locations)只解析本次变化的字段, - 不重验未修改的工具内容;只修改 outcome 时保留已有请求信息。 - 修改工具身份或内容仍需完整 item 解析;新增元数据非法时,整条命令在写入前拒绝。 +- 除 type/toolCallId 外,工具字段只解析本次变化的值,不重验未修改的工具内容; + 只修改 outcome 时保留已有请求信息。修改工具身份需完整 item 解析;变化的 content block + 单独解析。新增字段非法时,整条命令在写入前拒绝。 +- 新历史接受原有内置 CLI selector 的归一化,不重写旧历史。steer 配置和保持身份的 + task proposal 编辑也只校验变化的字段。 +- 队列提升必须在历史接受后才删除队列行;写入失败保留队列行。 +- 手动 Codex 压缩持有 native turn 直到完成。Stop 中断该 turn,并保留 ACP prompt, + 直到 `turn/completed` 确认结果或 provider 连接关闭。对已 in-flight 且 ACP session 就绪的 + prompt,Lody 记录取消并发送 provider cancel,不中断 owner fiber。ACP 返回前保留 owner + 和未完成的历史;新 dispatch 及未投递的 steer 保持 pending。Stop 后五秒 raw prompt + 仍未结束时,Lody 终止旧 session,让连接关闭结束 prompt。计时不等待 cancel ACK, + 也不因重复 Stop 重置。终止失败则继续持有 owner,直到 ACP 结束。start/interrupt ACK + 和压缩 item 的完成均不能释放执行 ownership。CLI 在取消确认后、接受下一轮前, + 将尚未结束的压缩标记为 failed。打开 Session 不触发历史修复 RPC,也不改写旧结果。 +- 已提交的 steer 等待接受时,若 Stop 先发生,则后到的成功 ACK 不得转移 ownership、 + 改变 source invocation 或将 source 结算为 handled。返回 `stale-turn` 前,将该 exact steer + 用户轮次标为 `canceled`,不改变 dispatch pointer。保持当前 cancellation owner, + 直到 provider 完成。不得重排该已接受的 steer:拒绝本地 ownership 转移不代表消息未投递。 - 已接受的 steer 标记在写入和读取归一化后都必须保留;编辑重发不能把 steer 当作可独立重放的普通用户轮次。