From 8a48169adae0827aa6880ec54f3e49adce1964e1 Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:42:40 +0800 Subject: [PATCH 01/14] fix: reconcile stale context compaction Model: gpt-5 --- .agents/docs/sessions-live-status.md | 8 ++ ...09-10-context-compaction-terminal-state.md | 4 +- ...10-context-compaction-terminal-state.zh.md | 4 +- ...stale-context-compaction-reconciliation.md | 61 +++++++++ ...le-context-compaction-reconciliation.zh.md | 51 ++++++++ .../src/lib/assistant-turn-finalize.test.ts | 76 ++++++++++- apps/cli/src/lib/assistant-turn-finalize.ts | 25 ++++ apps/cli/src/lib/message-handler.ts | 79 ++++++++++- .../message-handler-acp-batching.test.ts | 123 ++++++++++++++++++ ...ssage-handler-machine-registration.test.ts | 1 + packages/components/src/atoms/runtime.ts | 5 + .../sessions/session-chat-interface.tsx | 37 +++++- .../src/lib/session-context-compaction.ts | 31 ++++- .../src/providers/create-workspace-runtime.ts | 2 + .../providers/workspace-machine-rpc-facade.ts | 39 ++++++ .../tests/session-context-compaction.test.ts | 18 ++- .../workspace-machine-rpc-facade.test.ts | 77 +++++++++++ packages/loro-streams-rpc/AGENTS.md | 13 +- .../src/machine-rpc-server.ts | 24 ++++ packages/loro-streams-rpc/src/rpc.ts | 71 +++++++++- .../tests/machine-rpc-server.test.ts | 67 ++++++++++ packages/shared/src/local-machine-rpc.ts | 12 ++ .../src/machine-protocol-capabilities.ts | 15 +++ packages/shared/src/message-schemas.ts | 16 +++ .../shared/tests/local-machine-rpc.test.ts | 8 ++ .../machine-protocol-capabilities.test.ts | 16 +++ specs/session-history-writes.md | 12 +- specs/session-history-writes.zh.md | 10 +- 28 files changed, 878 insertions(+), 27 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md diff --git a/.agents/docs/sessions-live-status.md b/.agents/docs/sessions-live-status.md index 0b00bcbd9..c03745777 100644 --- a/.agents/docs/sessions-live-status.md +++ b/.agents/docs/sessions-live-status.md @@ -51,3 +51,11 @@ null`: browser offline, machine removed or offline) — the chip owns that story (Stop visibility, busy-send queue routing) shares the SAME time-bounded pre-start signal, so a stalled dispatch no longer holds the composer in a busy state either. +- Context compaction is different from live working status: its spinner follows + the latest durable `context_compaction` tool-call status. When that item is still + `pending` or `in_progress` inside a finished assistant turn, opening the Session + asks a capability-compatible owner daemon to reconcile the exact turn/tool ids. + The renderer does not convert `finished` into a provider terminal signal. The + daemon writes `failed` only when its live execution state proves the addressed + turn no longer owns work; an unsupported, offline, non-owner, active, or + indeterminate daemon leaves the durable item unchanged. diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md index d40aafd60..27759d8e4 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md +++ b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md @@ -39,7 +39,9 @@ provider terminal states remain unchanged, and a late provider update for the sa This fixes future error paths and histories that receive a later failure-aware finalization. It does not migrate already persisted stale histories, because those histories contain no durable evidence that distinguishes #570 from an interrupted -but still-active provider prompt. +but still-active provider prompt. A later, separate decision adds +[owner-evidence reconciliation](2026-09-11-stale-context-compaction-reconciliation.md) +without weakening that constraint. ## Scope and verification diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md index 0fd2be839..e990994e0 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md @@ -32,7 +32,9 @@ raw completion。只有 drain 完成以后,host 才收敛未完成 compaction 该设计修复未来的 error path,并处理之后再次经过 failure-aware finalization 的历史。 它不会迁移已经持久化的陈旧历史,因为这些历史里没有 durable evidence 能区分 #570 和 -“已中断但 provider prompt 仍活跃”的情况。 +“已中断但 provider prompt 仍活跃”的情况。后续的独立决策通过 +[owner 证据协调](2026-09-11-stale-context-compaction-reconciliation.zh.md)补上旧历史修复, +但不放宽这一约束。 ## 范围与验证 diff --git a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md new file mode 100644 index 000000000..d86d8827a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md @@ -0,0 +1,61 @@ +# Reconcile stale context compaction with live ownership evidence + +Status: implemented +Translation: current + +[中文](2026-09-11-stale-context-compaction-reconciliation.zh.md) + +## Abstract + +Sessions written before the provider-failure fix can retain a `pending` or +`in_progress` context-compaction item after the Agent has stopped, so reopening or +upgrading keeps the durable spinner visible. Lody now treats a finished owning turn +as a repair candidate and asks the Session's current owner daemon to reconcile the +exact turn and tool-call ids. The daemon writes `failed` only when live execution +ownership proves that turn is inactive; absent or ambiguous evidence leaves history +unchanged. + +## Decision + +The renderer remains a reader of durable compaction status. It does not reinterpret +`SessionHistory.finished`, a timeout, a restart, or missing presence as provider +termination. When the latest compaction is unresolved and its assistant turn is +finished, the renderer makes one capability-gated reconciliation request while the +Session is open. A transient `unknown` outcome may be retried after later history +activity; unsupported or unreachable daemons do not create a write fallback. + +The request names `sessionId`, `turnId`, and `toolCallId`. The target daemon first +verifies that current Session metadata assigns ownership to its own machine. It then +holds the existing Session history rewrite barrier while checking execution-service +turn ownership, blocking Session creation, active presence, and pending dispatch. +The addressed turn remains unchanged when it is active or when unassigned work makes +the result indeterminate. Once the daemon can prove the turn is not its live owner, +it changes only the named unresolved `context_compaction` item to `failed`; terminal, +mismatched, unknown, and unrelated stored items are preserved. + +This is lazy per opened Session rather than a startup migration. A global history +scan would activate arbitrarily many old documents and still could not establish +provider ownership. Versioned Machine capability negotiation also keeps mixed-version +clients from sending a write request to daemons that do not implement this evidence +contract. + +## Alternatives and limits + +Age-based cleanup was rejected because elapsed time does not prove the provider has +released the turn. UI-only suppression was rejected because it would make the +transcript disagree with durable history and other readers. Treating `finished` as +sufficient was rejected for the same reason recorded in the +[provider-failure decision](2026-09-10-context-compaction-terminal-state.md): host +finalization can precede provider termination. + +An offline or old owner daemon cannot repair the item. The Session continues to show +its durable state until a compatible owner can provide evidence. This change does not +claim that every historical unresolved compaction is stale, and it does not rewrite +more than the exact activity requested by the current view. + +## Verification + +Behavioral coverage exercises exact-item mutation, active and indeterminate ownership +outcomes, owner-daemon persistence, local capability gating, and Loro Streams RPC +dispatch. Shared schemas validate the request and response shapes. No startup scan, +storage migration, or end-to-end provider fixture was added. diff --git a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md new file mode 100644 index 000000000..ec1ba9d01 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md @@ -0,0 +1,51 @@ +# 使用实时所有权证据协调陈旧的上下文压缩状态 + +Status: implemented +Translation: current + +[English](2026-09-11-stale-context-compaction-reconciliation.md) + +## 摘要 + +Provider failure 修复之前写入的 Session,可能在 Agent 已停止后仍保留 `pending` 或 +`in_progress` 的 context-compaction item,导致重新打开或升级后仍显示持久化的旋转状态。 +现在,Lody 会把 owner turn 已 finished 的记录视为修复候选,并请求 Session 当前的 owner +daemon 协调精确的 turn 与 tool-call id。只有实时执行所有权证明该 turn 已不活跃时,daemon +才会写入 `failed`;证据缺失或存在歧义时,历史保持不变。 + +## 决策 + +Renderer 仍只读取持久化的 compaction 状态。它不会把 `SessionHistory.finished`、超时、 +重启或 presence 缺失重新解释成 provider 已终止。最新 compaction 未完成且所属 assistant +turn 已 finished 时,renderer 会在 Session 打开期间发起一次经过 capability gate 的协调请求。 +Transient `unknown` 可以在后续历史活动后重试;不支持该能力或不可访问的 daemon 不会触发 +其他写入 fallback。 + +请求携带 `sessionId`、`turnId` 和 `toolCallId`。目标 daemon 先验证当前 Session metadata +确实把所有权分配给本机,然后在现有 Session history rewrite barrier 内检查 execution-service +turn ownership、阻塞中的 Session 创建、active presence 和 pending dispatch。目标 turn 仍 +active,或存在未归属工作导致结论不确定时,历史不变。当 daemon 能证明该 turn 已不是实时 +owner 时,它只把指定且未完成的 `context_compaction` item 改成 `failed`;已有终态、不匹配、 +未知和无关的持久化 item 均被保留。 + +该修复针对打开的 Session 惰性执行,而不是启动时迁移。全局扫描会激活数量不受控的旧文档, +却仍无法建立 provider 所有权。版本化的 Machine capability negotiation 也确保混合版本客户端 +不会向未实现这套证据契约的 daemon 发送写请求。 + +## 替代方案与边界 + +基于记录年龄的清理被否决,因为经过多长时间不能证明 provider 已释放 turn。只在 UI 隐藏 +spinner 也被否决,因为这会让 transcript 与持久化历史及其他 reader 不一致。把 `finished` +视为充分条件同样被否决;正如 +[provider failure 决策](2026-09-10-context-compaction-terminal-state.zh.md)所记录,host +finalization 可能早于 provider termination。 + +Owner daemon 离线或版本过旧时无法修复该 item;Session 会继续显示其持久化状态,直到兼容的 +owner 能提供证据。本次变更不假定每一条历史未完成 compaction 都已陈旧,也不会改写当前视图 +请求之外的活动。 + +## 验证 + +行为测试覆盖精确 item 修改、active 与 indeterminate 所有权结果、owner-daemon 持久化、本地 +capability gating 和 Loro Streams RPC 分发。共享 schema 会校验请求与响应 shape。本次没有 +加入启动扫描、存储迁移或端到端 provider fixture。 diff --git a/apps/cli/src/lib/assistant-turn-finalize.test.ts b/apps/cli/src/lib/assistant-turn-finalize.test.ts index 59bee68a7..97f824e89 100644 --- a/apps/cli/src/lib/assistant-turn-finalize.test.ts +++ b/apps/cli/src/lib/assistant-turn-finalize.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest'; import type { SessionHistoryInput } from '@lody/shared'; -import { markAssistantTurnFinished } from './assistant-turn-finalize'; +import { + markAssistantTurnFinished, + settleContextCompactionItemAsFailed, +} from './assistant-turn-finalize'; const OPENED_AT = Date.parse('2026-01-01T00:00:00.000Z'); const TURN_ENDED_AT = OPENED_AT + 12_000; @@ -136,3 +139,74 @@ describe('markAssistantTurnFinished', () => { expect(history[1]?.finished).toBeUndefined(); }); }); + +describe('settleContextCompactionItemAsFailed', () => { + it('settles only the exact unresolved activity owned by the requested turn', () => { + const history = [ + assistantEntry({ + id: 'assistant:u1', + items: [ + { + type: 'tool_call', + toolCallId: 'compact-old', + title: 'Context compacting', + status: 'in_progress', + activityKind: 'context_compaction', + }, + ], + }), + assistantEntry({ + id: 'assistant:u2', + items: [ + { + type: 'tool_call', + toolCallId: 'compact-current', + title: 'Context compacting', + status: 'in_progress', + activityKind: 'context_compaction', + }, + ], + }), + ]; + + expect( + settleContextCompactionItemAsFailed(history, { + turnId: 'assistant:u1', + toolCallId: 'compact-old', + }) + ).toBe(true); + expect(history[0]?.items?.[0]).toMatchObject({ status: 'failed' }); + expect(history[1]?.items?.[0]).toMatchObject({ status: 'in_progress' }); + }); + + it('preserves terminal and mismatched activities', () => { + const history = [ + assistantEntry({ + id: 'assistant:u1', + items: [ + { + type: 'tool_call', + toolCallId: 'compact-1', + title: 'Context compacting', + status: 'completed', + activityKind: 'context_compaction', + }, + ], + }), + ]; + + expect( + settleContextCompactionItemAsFailed(history, { + turnId: 'assistant:u1', + toolCallId: 'compact-other', + }) + ).toBe(false); + expect( + settleContextCompactionItemAsFailed(history, { + turnId: 'assistant:u1', + toolCallId: 'compact-1', + }) + ).toBe(false); + expect(history[0]?.items?.[0]).toMatchObject({ status: 'completed' }); + }); +}); diff --git a/apps/cli/src/lib/assistant-turn-finalize.ts b/apps/cli/src/lib/assistant-turn-finalize.ts index e1c3e60f8..ffe2afb98 100644 --- a/apps/cli/src/lib/assistant-turn-finalize.ts +++ b/apps/cli/src/lib/assistant-turn-finalize.ts @@ -1,5 +1,30 @@ import type { SessionHistoryInput } from '@lody/shared'; +export const settleContextCompactionItemAsFailed = ( + history: SessionHistoryInput[], + options: { turnId: string; toolCallId: string } +): boolean => { + const entry = history.find( + (candidate) => candidate.role === 'assistant' && candidate.id === options.turnId + ); + if (!entry?.items) return false; + + let changed = false; + entry.items = entry.items.map((item) => { + if ( + item.type !== 'tool_call' || + item.activityKind !== 'context_compaction' || + item.toolCallId !== options.toolCallId || + (item.status !== 'pending' && item.status !== 'in_progress') + ) { + return item; + } + changed = true; + return { ...item, status: 'failed' as const }; + }); + return changed; +}; + /** * Stamp the terminal footprint (`finished`/`endedAt`/`permissionWaitMs`) on the * assistant entry a finalize call owns. Extracted from `finalizeACPState` so the diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index 3fc61500e..8e6eed502 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -61,6 +61,7 @@ import { getCodeCollabFileIndexSignalFlockDocId, type SessionContextWindowUsage, type SessionHistoryInput, + type SessionContextCompactionReconcileResponse, type SessionLegacyMetaFields, PERMISSION_REQUEST_TIMEOUT_MS, type ChatFailedCode, @@ -264,7 +265,10 @@ import { resolveImageGenerationStatusWrite, shouldRestoreRunningAfterPermission, } from './session-activity-status'; -import { markAssistantTurnFinished } from './assistant-turn-finalize'; +import { + markAssistantTurnFinished, + settleContextCompactionItemAsFailed, +} from './assistant-turn-finalize'; import type { RepoWatchHandle } from 'loro-repo'; import { AgentClient, @@ -3401,6 +3405,8 @@ export class MessageHandler { observedAtMs: getServerNow(), }; }, + reconcileSessionContextCompaction: async (args) => + await this.reconcileSessionContextCompaction(args), steerSession: async (args) => await this.steerSessionWithAccessCheck(args), controlSessionGoal: async (args) => await this.controlSessionGoalWithAccessCheck(args), terminateSession: async ({ sessionId }) => await this.terminateAcpSession(sessionId), @@ -6669,6 +6675,8 @@ export class MessageHandler { error: result.error, }; } + case 'session/reconcile-context-compaction': + return await this.reconcileSessionContextCompaction(request.params); case 'session/dispatch-turn': { // Mirrors the Loro Streams Machine RPC path: normalize the opaque // transport-level input config, then offer the turn to the dispatch @@ -6775,6 +6783,75 @@ export class MessageHandler { } } + private async reconcileSessionContextCompaction(args: { + sessionId: SessionId; + turnId: string; + toolCallId: string; + }): Promise { + const response = ( + outcome: SessionContextCompactionReconcileResponse['outcome'], + options: { activeTurnId?: string; error?: string } = {} + ): SessionContextCompactionReconcileResponse => ({ + type: 'session/reconcile-context-compaction_response', + ...args, + outcome, + ...options, + }); + + let releaseBarrier: (() => void) | undefined; + try { + const metaRecord = await this.workspaceDocument.repo.getDocMeta( + getSessionRoomId(args.sessionId) + ); + const meta = + metaRecord?.meta && !isLoroRepoDocDeleted(metaRecord) + ? (metaRecord.meta as SessionMeta) + : undefined; + if (!meta || meta.machineId !== this.machineId) { + return response('unknown', { error: 'The target daemon does not own this Session.' }); + } + + releaseBarrier = + this.executionService.tryAcquireSessionRewriteBarrier(args.sessionId) ?? undefined; + if (!releaseBarrier) { + return response('unknown', { error: 'Session ownership is changing.' }); + } + + const execution = this.executionService.getExecutionSnapshot(args.sessionId); + if (execution.activeTurnId === args.turnId) { + return response('active', { activeTurnId: execution.activeTurnId }); + } + + if ( + !execution.activeTurnId && + (execution.hasActiveTurn || + execution.hasBlockingPendingCreate || + execution.hasActiveAutomation || + this.sessionActivePresence.has(args.sessionId) || + this.sessionDispatchWatcher.hasPendingDispatch(args.sessionId)) + ) { + return response('unknown', { error: 'The daemon still has unassigned Session work.' }); + } + + const sessionDoc = await this.workspaceDocument.getOrCreateSessionDoc(args.sessionId); + let reconciled = false; + await sessionDoc.updateHistory((history) => { + reconciled = settleContextCompactionItemAsFailed(history, args); + return history; + }); + if (reconciled) { + await sessionDoc.waitUntilSynced(); + } + return response(reconciled ? 'reconciled' : 'unchanged', { + ...(execution.activeTurnId ? { activeTurnId: execution.activeTurnId } : {}), + }); + } catch (error) { + return response('unknown', { error: formatErrorMessage(error) }); + } finally { + releaseBarrier?.(); + } + } + private async respondToLegacyCodeCollabHostStart( message: SessionCodeCollabHostStartRequestValidated ): Promise { diff --git a/apps/cli/tests/message-handler-acp-batching.test.ts b/apps/cli/tests/message-handler-acp-batching.test.ts index 43dd637df..54ee2ce9c 100644 --- a/apps/cli/tests/message-handler-acp-batching.test.ts +++ b/apps/cli/tests/message-handler-acp-batching.test.ts @@ -96,6 +96,7 @@ const createHandlerHarness = async (sessionIds: SessionId[]) => { watch: vi.fn(() => ({ unsubscribe: vi.fn() })), getDocMeta: vi.fn(async () => ({ meta: { + machineId: 'm-1', needToArchiveSessions: {}, needToDeleteSessions: {}, }, @@ -466,6 +467,128 @@ describe('MessageHandler ACP batching', () => { } }); + it('reconciles stale compaction only after the owner daemon proves its turn is inactive', async () => { + vi.useRealTimers(); + const sessionId = 'stale-compaction-reconciliation' as SessionId; + const { repo, docs, workspaceDocument, handler } = await createHandlerHarness([sessionId]); + const doc = docs.get(sessionId); + if (!doc) throw new Error(`Missing session doc for ${sessionId}`); + const host = handler as unknown as { + beginConversationTurn(sessionId: SessionId): string; + enqueueACPUpdate(sessionId: SessionId, update: AcpSessionNotification): void; + flushACPUpdatesNow(sessionId: SessionId): Promise; + finalizeACPState(sessionId: SessionId, turnId?: string): Promise; + reconcileSessionContextCompaction(args: { + sessionId: SessionId; + turnId: string; + toolCallId: string; + }): Promise<{ outcome: string }>; + executionService: { + getExecutionSnapshot(sessionId: SessionId): { + activeTurnId?: string; + hasActiveTurn: boolean; + hasBlockingPendingCreate: boolean; + hasReusableSession: boolean; + hasRewriteBarrier: boolean; + hasActiveAutomation: boolean; + }; + }; + sessionActivePresence: { has(sessionId: SessionId): boolean }; + }; + const findCompaction = (history: SessionHistoryInput[]) => + history + .flatMap((entry) => readItems(entry)) + .find( + (item) => + item.type === 'tool_call' && + item.activityKind === 'context_compaction' && + item.toolCallId === 'compact-stale' + ); + + try { + const turnId = host.beginConversationTurn(sessionId); + host.enqueueACPUpdate(sessionId, { + sessionId, + update: { + sessionUpdate: 'tool_call', + toolCallId: 'compact-stale', + title: 'Context compacting', + status: 'in_progress', + _meta: { contextCompaction: true }, + }, + }); + await host.flushACPUpdatesNow(sessionId); + await host.finalizeACPState(sessionId, turnId); + + workspaceDocument.repo.getDocMeta.mockResolvedValueOnce({ + meta: { + machineId: 'm-other', + needToArchiveSessions: {}, + needToDeleteSessions: {}, + }, + }); + await expect( + host.reconcileSessionContextCompaction({ + sessionId, + turnId, + toolCallId: 'compact-stale', + }) + ).resolves.toMatchObject({ outcome: 'unknown' }); + expect(findCompaction(await doc.getHistory())).toMatchObject({ status: 'in_progress' }); + + const getExecutionSnapshot = vi.spyOn(host.executionService, 'getExecutionSnapshot'); + getExecutionSnapshot.mockReturnValue({ + activeTurnId: turnId, + hasActiveTurn: true, + hasBlockingPendingCreate: false, + hasReusableSession: true, + hasRewriteBarrier: false, + hasActiveAutomation: false, + }); + await expect( + host.reconcileSessionContextCompaction({ + sessionId, + turnId, + toolCallId: 'compact-stale', + }) + ).resolves.toMatchObject({ outcome: 'active' }); + expect(findCompaction(await doc.getHistory())).toMatchObject({ status: 'in_progress' }); + + getExecutionSnapshot.mockReturnValue({ + hasActiveTurn: false, + hasBlockingPendingCreate: false, + hasReusableSession: true, + hasRewriteBarrier: false, + hasActiveAutomation: false, + }); + const hasActivePresence = vi + .spyOn(host.sessionActivePresence, 'has') + .mockReturnValueOnce(true) + .mockReturnValue(false); + await expect( + host.reconcileSessionContextCompaction({ + sessionId, + turnId, + toolCallId: 'compact-stale', + }) + ).resolves.toMatchObject({ outcome: 'unknown' }); + expect(findCompaction(await doc.getHistory())).toMatchObject({ status: 'in_progress' }); + + await expect( + host.reconcileSessionContextCompaction({ + sessionId, + turnId, + toolCallId: 'compact-stale', + }) + ).resolves.toMatchObject({ outcome: 'reconciled' }); + expect(hasActivePresence).toHaveBeenCalled(); + expect(findCompaction(await doc.getHistory())).toMatchObject({ status: 'failed' }); + expect(isSessionContextCompacting(await doc.getHistory())).toBe(false); + } finally { + await destroyRepoOnRealTimers(repo); + } + }); + it('persists updates buffered during the finalization tail instead of dropping them', async () => { const sessionId = 's-1' as SessionId; const { repo, docs, handler } = await createHandlerHarness([sessionId]); diff --git a/apps/cli/tests/message-handler-machine-registration.test.ts b/apps/cli/tests/message-handler-machine-registration.test.ts index f9401161b..a2b8dabbc 100644 --- a/apps/cli/tests/message-handler-machine-registration.test.ts +++ b/apps/cli/tests/message-handler-machine-registration.test.ts @@ -187,6 +187,7 @@ describe('MessageHandler machine registration', () => { localFileResources: 1, providerSetup: 1, acpProtocolAuthentication: 2, + contextCompactionReconciliation: 1, }); await handler.cleanup(); diff --git a/packages/components/src/atoms/runtime.ts b/packages/components/src/atoms/runtime.ts index 1316a03f9..148feead9 100644 --- a/packages/components/src/atoms/runtime.ts +++ b/packages/components/src/atoms/runtime.ts @@ -290,6 +290,11 @@ export type WorkspaceRuntime = { turnId: string, options?: { timeoutMs?: number } ) => Promise; + requestSessionContextCompactionReconciliation: ( + machineId: MachineId, + args: { sessionId: SessionId; turnId: string; toolCallId: string }, + options?: { timeoutMs?: number } + ) => Promise; requestSessionSteer: ( machineId: MachineId, args: { diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index e6629465c..c4536cb91 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -55,7 +55,7 @@ import { Button } from '@/ui/button'; import { isMacOSElectronRenderer, useElectronFullscreen } from '@/lib/electron'; import { getIpcServices } from '@/lib/electron-ipc-client'; import { matchesKeyboardEvent } from '@/lib/commands/key-matcher'; -import { isSessionContextCompacting } from '@/lib/session-context-compaction'; +import { findActiveSessionContextCompaction } from '@/lib/session-context-compaction'; import { hasFileTransfer, readDroppedTransfer } from '@/lib/file-drop'; import { resolveProgrammaticTurnAgentRole } from '@/lib/composer-agent-roles'; import { mergeDropZoneHandlers, useDropZone } from '@/hooks/use-drop-zone'; @@ -114,6 +114,7 @@ import { hasReportedPreviewTarget, isSessionGoalCleared, isSessionGoalActive, + machineSupportsContextCompactionReconciliationProtocol, normalizeSessionInputBlocks, normalizeSessionTurnInputConfig, resolveSessionAcpRuntimeConfig, @@ -2394,6 +2395,7 @@ export const SessionChatInterface = memo( const deferredSearchQuery = useDeferredValue(searchQuery); const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0); const lastSearchAnalyticsKeyRef = useRef(null); + const compactionReconciliationAttemptsRef = useRef(new Set()); const runtime = useAtomValue(activeWorkspaceRuntimeAtom); const queuedMessageBehavior = useAtomValue(queuedMessageBehaviorAtom); @@ -2699,10 +2701,39 @@ export const SessionChatInterface = memo( const canForkFromMenu = Boolean( onForkSessionExternal || (lastCompletedAssistantMessageId && onForkLastAssistant) ); - const isContextCompacting = useMemo( - () => isSessionContextCompacting(sessionHistory), + const activeContextCompaction = useMemo( + () => findActiveSessionContextCompaction(sessionHistory), [sessionHistory] ); + const isContextCompacting = activeContextCompaction !== null; + const canReconcileContextCompaction = + sessionMachineOnlineStatus === 'online' && + machineSupportsContextCompactionReconciliationProtocol(sessionMachine); + useEffect(() => { + if (!runtime || !canReconcileContextCompaction || !activeContextCompaction?.turnFinished) { + return; + } + const attemptKey = `${session.id}:${activeContextCompaction.turnId}:${activeContextCompaction.toolCallId}`; + if (compactionReconciliationAttemptsRef.current.has(attemptKey)) return; + compactionReconciliationAttemptsRef.current.add(attemptKey); + void runtime + .requestSessionContextCompactionReconciliation(session.machineId, { + sessionId: session.id, + turnId: activeContextCompaction.turnId, + toolCallId: activeContextCompaction.toolCallId, + }) + .then((result) => { + if (!result || result.outcome === 'unknown') { + compactionReconciliationAttemptsRef.current.delete(attemptKey); + } + }); + }, [ + activeContextCompaction, + canReconcileContextCompaction, + runtime, + session.id, + session.machineId, + ]); // Pending scheduled tasks (cron / wakeup) are derived on the fly from the // Cron*/ScheduleWakeup tool_call items already in history — nothing extra is // persisted. Serialize to a key so the input area only re-renders when the diff --git a/packages/components/src/lib/session-context-compaction.ts b/packages/components/src/lib/session-context-compaction.ts index e7d16dc21..ac9593a0e 100644 --- a/packages/components/src/lib/session-context-compaction.ts +++ b/packages/components/src/lib/session-context-compaction.ts @@ -1,15 +1,34 @@ import type { SessionHistory } from '@lody/shared'; -export const isSessionContextCompacting = ( - history: readonly Pick[] -): boolean => { +export type ActiveSessionContextCompaction = { + turnId: string; + toolCallId: string; + status: 'pending' | 'in_progress'; + turnFinished: boolean; +}; + +export const findActiveSessionContextCompaction = ( + history: readonly Pick[] +): ActiveSessionContextCompaction | null => { for (let entryIndex = history.length - 1; entryIndex >= 0; entryIndex -= 1) { - const items = history[entryIndex]?.items ?? []; + const entry = history[entryIndex]; + if (!entry || entry.role !== 'assistant') continue; + const items = entry.items ?? []; for (let itemIndex = items.length - 1; itemIndex >= 0; itemIndex -= 1) { const item = items[itemIndex]; if (item?.type !== 'tool_call' || item.activityKind !== 'context_compaction') continue; - return item.status === 'pending' || item.status === 'in_progress'; + if (item.status !== 'pending' && item.status !== 'in_progress') return null; + return { + turnId: entry.id, + toolCallId: item.toolCallId, + status: item.status, + turnFinished: entry.finished === true, + }; } } - return false; + return null; }; + +export const isSessionContextCompacting = ( + history: readonly Pick[] +): boolean => findActiveSessionContextCompaction(history) !== null; diff --git a/packages/components/src/providers/create-workspace-runtime.ts b/packages/components/src/providers/create-workspace-runtime.ts index 660886950..10de6aed8 100644 --- a/packages/components/src/providers/create-workspace-runtime.ts +++ b/packages/components/src/providers/create-workspace-runtime.ts @@ -1662,6 +1662,7 @@ export async function createWorkspaceRuntime(deps: RuntimeDeps): Promise => { + try { + const protocolCapabilities = await deps.getMachineProtocolCapabilities(machineId); + if ( + !machineSupportsContextCompactionReconciliationProtocol({ protocolCapabilities }) + ) { + return null; + } + if (await canUseLocalMachineRpc(machineId)) { + const response = await getLocalMachineRpcSender()?.({ + method: 'session/reconcile-context-compaction', + machineId, + workspaceId, + params: args, + timeoutMs: options?.timeoutMs ?? 10_000, + }); + if (response?.ok) { + return response.result as SessionContextCompactionReconcileResponse; + } + return null; + } + return await ( + await getMachineRpcClient(machineId) + ).requestSessionContextCompactionReconciliation({ + ...args, + timeoutMs: options?.timeoutMs ?? 10_000, + }); + } catch { + return null; + } + }; + const requestSessionTerminate = async ( machineId: MachineId, sessionId: SessionId, @@ -1163,6 +1201,7 @@ export function createWorkspaceMachineRpcFacade(deps: WorkspaceMachineRpcFacadeD return { requestSessionCancel, + requestSessionContextCompactionReconciliation, requestSessionSteer, requestSessionGoal, requestSessionTerminate, diff --git a/packages/components/tests/session-context-compaction.test.ts b/packages/components/tests/session-context-compaction.test.ts index dac623cb4..cfac1ed10 100644 --- a/packages/components/tests/session-context-compaction.test.ts +++ b/packages/components/tests/session-context-compaction.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest'; import type { SessionHistory } from '@lody/shared'; -import { isSessionContextCompacting } from '../src/lib/session-context-compaction'; +import { + findActiveSessionContextCompaction, + isSessionContextCompacting, +} from '../src/lib/session-context-compaction'; const historyWithStatus = ( status: 'pending' | 'in_progress' | 'completed' | 'failed', @@ -9,6 +12,8 @@ const historyWithStatus = ( ) => [ { + id: 'assistant:turn-1', + role: 'assistant' as const, finished, items: [ { @@ -20,7 +25,7 @@ const historyWithStatus = ( }, ], }, - ] as Pick[]; + ] as Pick[]; describe('isSessionContextCompacting', () => { it('tracks pending and in-progress compaction tool calls', () => { @@ -28,6 +33,15 @@ describe('isSessionContextCompacting', () => { expect(isSessionContextCompacting(historyWithStatus('in_progress'))).toBe(true); }); + it('returns the exact active activity owner for reconciliation', () => { + expect(findActiveSessionContextCompaction(historyWithStatus('in_progress'))).toEqual({ + turnId: 'assistant:turn-1', + toolCallId: 'context-compaction-1', + status: 'in_progress', + turnFinished: false, + }); + }); + it('stops loading after compaction completes or fails', () => { expect(isSessionContextCompacting(historyWithStatus('completed'))).toBe(false); expect(isSessionContextCompacting(historyWithStatus('failed'))).toBe(false); diff --git a/packages/components/tests/workspace-machine-rpc-facade.test.ts b/packages/components/tests/workspace-machine-rpc-facade.test.ts index 2ef8f926e..96e85b064 100644 --- a/packages/components/tests/workspace-machine-rpc-facade.test.ts +++ b/packages/components/tests/workspace-machine-rpc-facade.test.ts @@ -208,6 +208,83 @@ describe('createWorkspaceMachineRpcFacade', () => { expect(getMachineRpcClient).not.toHaveBeenCalled(); }); + it('uses the local owner daemon to reconcile an exact context compaction activity', async () => { + const invoke = vi.fn(async () => ({ + ok: true as const, + result: { + type: 'session/reconcile-context-compaction_response' as const, + sessionId, + turnId: 'assistant:turn-1', + toolCallId: 'compact-1', + outcome: 'reconciled' as const, + }, + })); + vi.stubGlobal('window', { + __LODY_ELECTRON__: true, + ipc: { invoke }, + }); + const getMachineRpcClient = vi.fn(); + const facade = createWorkspaceMachineRpcFacade({ + workspaceId, + getMachineProtocolCapabilities: async () => CURRENT_MACHINE_PROTOCOL_CAPABILITIES, + targetRouter: { + getPlaneForMachine: () => 'local', + resolvePlaneForMachine: vi.fn(async () => 'local'), + }, + getMachineRpcClient, + }); + + await expect( + facade.requestSessionContextCompactionReconciliation(localMachineId, { + sessionId, + turnId: 'assistant:turn-1', + toolCallId: 'compact-1', + }) + ).resolves.toMatchObject({ outcome: 'reconciled' }); + expect(invoke).toHaveBeenCalledWith( + 'machineRpc.send', + expect.objectContaining({ + machineId: localMachineId, + workspaceId, + method: 'session/reconcile-context-compaction', + params: { + sessionId, + turnId: 'assistant:turn-1', + toolCallId: 'compact-1', + }, + }) + ); + expect(getMachineRpcClient).not.toHaveBeenCalled(); + }); + + it('does not reconcile compaction through a daemon without the capability', async () => { + const invoke = vi.fn(); + vi.stubGlobal('window', { + __LODY_ELECTRON__: true, + ipc: { invoke }, + }); + const getMachineRpcClient = vi.fn(); + const facade = createWorkspaceMachineRpcFacade({ + workspaceId, + getMachineProtocolCapabilities: async () => undefined, + targetRouter: { + getPlaneForMachine: () => 'local', + resolvePlaneForMachine: vi.fn(async () => 'local'), + }, + getMachineRpcClient, + }); + + await expect( + facade.requestSessionContextCompactionReconciliation(localMachineId, { + sessionId, + turnId: 'assistant:turn-1', + toolCallId: 'compact-1', + }) + ).resolves.toBeNull(); + expect(invoke).not.toHaveBeenCalled(); + expect(getMachineRpcClient).not.toHaveBeenCalled(); + }); + it('uses the cloud Machine RPC client for a remote machine', async () => { const invoke = vi.fn(); vi.stubGlobal('window', { diff --git a/packages/loro-streams-rpc/AGENTS.md b/packages/loro-streams-rpc/AGENTS.md index c64b4508a..c2602fb98 100644 --- a/packages/loro-streams-rpc/AGENTS.md +++ b/packages/loro-streams-rpc/AGENTS.md @@ -46,8 +46,8 @@ and the `file/preview` namespace are in needs read-check-write atomicity serializes in its own service layer (Code Collab `save-text` per absolute path in `code-collab-v2-service.ts`), not in the request loop. - Control-plane methods (`machine/status`, `machine/ping`, `session/cancel`, - `session/live-status`, `session/steer`, `session/terminate`, `machine/restart`, - `machine/upgrade`, `session/dispatch-turn`) + `session/live-status`, `session/reconcile-context-compaction`, `session/steer`, + `session/terminate`, `machine/restart`, `machine/upgrade`, `session/dispatch-turn`) bypass the shared semaphore and run on a small dedicated lane (`CONTROL_METHODS` in `machine-rpc-server.ts`) so saturated code-collab handlers cannot delay them at intake. Control handlers must stay fast @@ -55,11 +55,10 @@ and the `file/preview` namespace are in dispatch watcher — never run the agent turn inside the handler. Its `expiresAt` is deliberately short (== client timeout, ~15s) because a server restart replays the request stream from offset `'-1'`. -- Session orchestration authorization is checked source-side with the source CLI - token because workspace RPC cannot authenticate a claimed requester identity. - `session/live-status` reads the target - daemon's active-presence controller and must not infer liveness from durable - `SessionMeta.status` or message pointers. +- Session RPC authorization is source-side because workspace RPC cannot authenticate + a claimed requester. Live status and compaction repair use target-daemon runtime, + never durable meta, to judge liveness. Repair is capability-gated and may change + only the exact inactive turn/tool ids supplied by the client. - Remote lifecycle: [ACK contract](../../specs/machine-lifecycle-ack.md). RPC handlers never run installers inline. Accepted work exits via the CLI process boundary after a bounded ACK attempt, including delivery failure or timeout. diff --git a/packages/loro-streams-rpc/src/machine-rpc-server.ts b/packages/loro-streams-rpc/src/machine-rpc-server.ts index 5579d0dea..db8a717af 100644 --- a/packages/loro-streams-rpc/src/machine-rpc-server.ts +++ b/packages/loro-streams-rpc/src/machine-rpc-server.ts @@ -34,6 +34,7 @@ import type { PreviewTarget, PreviewTargetApproval, SessionCancelResponse, + SessionContextCompactionReconcileResponse, SessionPreparationCancelSpec, SessionPreparationSpec, SessionPrepareCancelResponse, @@ -118,6 +119,7 @@ const CONTROL_METHODS: ReadonlySet = new Set([ 'machine/acp-capabilities-refresh-cancel', 'session/cancel', 'session/live-status', + 'session/reconcile-context-compaction', 'session/steer', 'session/goal', 'session/terminate', @@ -349,6 +351,11 @@ type RpcServerDeps = { getSessionLiveStatus?: (args: { sessionId: SessionId; }) => Promise; + reconcileSessionContextCompaction?: (args: { + sessionId: SessionId; + turnId: string; + toolCallId: string; + }) => Promise; steerSession?: (args: { sessionId: SessionId; expectedTurnId: string; @@ -1078,6 +1085,22 @@ export class LoroStreamsMachineRpcServer { await this.appendResultResponse(request.replyTo, request.id, request.method, response); return; } + case 'session/reconcile-context-compaction': { + if (!this.deps.reconcileSessionContextCompaction) { + await this.appendErrorResponse(request.replyTo, request.id, request.method, { + code: LORO_STREAMS_RPC_ERROR_CODES.methodUnavailable, + message: 'Context compaction reconciliation is not available on this machine.', + }); + return; + } + const response = await this.deps.reconcileSessionContextCompaction({ + sessionId: request.params.sessionId as SessionId, + turnId: request.params.turnId, + toolCallId: request.params.toolCallId, + }); + await this.appendResultResponse(request.replyTo, request.id, request.method, response); + return; + } case 'session/steer': { if (!this.deps.steerSession) { await this.appendErrorResponse(request.replyTo, request.id, request.method, { @@ -1613,6 +1636,7 @@ export class LoroStreamsMachineRpcServer { | MachineBugReportResponse | SessionCancelResponse | LoroSessionLiveStatusRpcResponse + | SessionContextCompactionReconcileResponse | SessionSteerResponse | SessionGoalResponse | SessionTerminateResponse diff --git a/packages/loro-streams-rpc/src/rpc.ts b/packages/loro-streams-rpc/src/rpc.ts index c9013a958..506ca2da3 100644 --- a/packages/loro-streams-rpc/src/rpc.ts +++ b/packages/loro-streams-rpc/src/rpc.ts @@ -45,6 +45,7 @@ import type { MachineStatusResponse, MachineUpgradeResponse, SessionCancelResponse, + SessionContextCompactionReconcileResponse, SessionGoalAction, SessionGoalResponse, SessionPreparationCancelSpec, @@ -94,6 +95,7 @@ import { MachineStatusResponseSchema, MachineUpgradeResponseSchema, SessionCancelResponseSchema, + SessionContextCompactionReconcileResponseSchema, SessionPreparationCancelSpecSchema, SessionPreparationSpecSchema, SessionPrepareCancelResponseSchema, @@ -192,6 +194,7 @@ export const LoroStreamsRpcMethodSchema = z.enum([ 'file/preview', 'session/cancel', 'session/live-status', + 'session/reconcile-context-compaction', 'session/steer', 'session/goal', 'session/terminate', @@ -445,6 +448,17 @@ export const LoroSessionLiveStatusRpcRequestSchema = BaseRpcRequestSchema.extend .strict(), }).strict(); +export const LoroSessionContextCompactionReconcileRpcRequestSchema = BaseRpcRequestSchema.extend({ + method: z.literal('session/reconcile-context-compaction'), + params: z + .object({ + sessionId: SessionIdSchema, + turnId: z.string().trim().min(1), + toolCallId: z.string().trim().min(1), + }) + .strict(), +}).strict(); + export const LoroSessionGoalRpcRequestSchema = BaseRpcRequestSchema.extend({ method: z.literal('session/goal'), params: z @@ -598,6 +612,7 @@ export const LoroStreamsRpcRequestSchema = z.discriminatedUnion('method', [ LoroFilePreviewRpcRequestSchema, LoroSessionCancelRpcRequestSchema, LoroSessionLiveStatusRpcRequestSchema, + LoroSessionContextCompactionReconcileRpcRequestSchema, LoroSessionSteerRpcRequestSchema, LoroSessionGoalRpcRequestSchema, LoroSessionTerminateRpcRequestSchema, @@ -735,6 +750,9 @@ export type LoroCodeCollabV2LspReferencesRpcRequest = z.infer< export type LoroFilePreviewRpcRequest = z.infer; export type LoroSessionCancelRpcRequest = z.infer; export type LoroSessionLiveStatusRpcRequest = z.infer; +export type LoroSessionContextCompactionReconcileRpcRequest = z.infer< + typeof LoroSessionContextCompactionReconcileRpcRequestSchema +>; export type LoroSessionTerminateRpcRequest = z.infer; export type LoroSessionForkRpcRequest = z.infer; export type LoroSessionEditAndResendRpcRequest = z.infer< @@ -1441,6 +1459,7 @@ export type LoroMachineRpcResult = | MachineBugReportResponse | SessionCancelResponse | LoroSessionLiveStatusRpcResponse + | SessionContextCompactionReconcileResponse | SessionSteerResponse | SessionGoalResponse | SessionTerminateResponse @@ -1466,7 +1485,7 @@ const toLegacyRpcErrorResponse = ( pingContext?: { requestId: string }, lifecycleContext?: { requestId: string; targetVersion?: string }, binaryContext?: { agentType: string; requestId?: string }, - cancelContext?: { sessionId: string }, + cancelContext?: { sessionId: string; turnId?: string; toolCallId?: string }, forkContext?: { sourceSessionId: string; targetSessionId: string }, editAndResendContext?: { sessionId: string; replacementUserTurnId: string }, steerContext?: { sessionId: string; userTurnId: string }, @@ -1598,6 +1617,17 @@ const toLegacyRpcErrorResponse = ( }; } + if (method === 'session/reconcile-context-compaction') { + return { + type: 'session/reconcile-context-compaction_response', + sessionId: (cancelContext?.sessionId ?? '') as SessionId, + turnId: cancelContext?.turnId ?? '', + toolCallId: cancelContext?.toolCallId ?? '', + outcome: 'unknown', + error: `${error.code}: ${error.message}`, + }; + } + if (method === 'session/fork') { return sessionForkFailure( { @@ -1815,6 +1845,10 @@ const parseRpcSuccessResult = async ( const parsed = LoroSessionLiveStatusRpcResponseSchema.safeParse(response.result); return parsed.success ? parsed.data : null; } + if (response.method === 'session/reconcile-context-compaction') { + const parsed = SessionContextCompactionReconcileResponseSchema.safeParse(response.result); + return parsed.success ? (parsed.data as SessionContextCompactionReconcileResponse) : null; + } if (response.method === 'session/steer') { const parsed = SessionSteerResponseSchema.safeParse(response.result); return parsed.success ? (parsed.data as SessionSteerResponse) : null; @@ -1892,7 +1926,7 @@ export type LoroStreamsRpcPendingRegistration = { pingContext?: { requestId: string }; lifecycleContext?: { requestId: string; targetVersion?: string }; binaryContext?: { agentType: string; requestId?: string }; - cancelContext?: { sessionId: string }; + cancelContext?: { sessionId: string; turnId?: string; toolCallId?: string }; forkContext?: { sourceSessionId: string; targetSessionId: string }; editAndResendContext?: { sessionId: string; replacementUserTurnId: string }; steerContext?: { sessionId: string; userTurnId: string }; @@ -2666,6 +2700,23 @@ export class LoroStreamsMachineRpcClient { })) as LoroSessionLiveStatusRpcResponse | null; } + async requestSessionContextCompactionReconciliation(options: { + sessionId: SessionId; + turnId: string; + toolCallId: string; + timeoutMs?: number; + }): Promise { + return (await this.sendRequest({ + method: 'session/reconcile-context-compaction', + timeoutMs: options.timeoutMs ?? 10_000, + params: { + sessionId: options.sessionId, + turnId: options.turnId, + toolCallId: options.toolCallId, + }, + })) as SessionContextCompactionReconcileResponse | null; + } + async requestSessionSteer(options: { sessionId: string; expectedTurnId: string; @@ -3165,6 +3216,15 @@ export class LoroStreamsMachineRpcClient { sessionId: string; }; } + | { + method: 'session/reconcile-context-compaction'; + timeoutMs: number; + params: { + sessionId: SessionId; + turnId: string; + toolCallId: string; + }; + } | { method: 'session/steer'; timeoutMs: number; @@ -3380,7 +3440,9 @@ export class LoroStreamsMachineRpcClient { args.method === 'session/live-status' || args.method === 'session/terminate' ? { sessionId: args.params.sessionId } - : undefined, + : args.method === 'session/reconcile-context-compaction' + ? args.params + : undefined, forkContext: args.method === 'session/fork' ? { @@ -3508,6 +3570,9 @@ export class LoroStreamsMachineRpcClient { case 'session/live-status': request = { ...envelope, method: args.method, params: args.params }; break; + case 'session/reconcile-context-compaction': + request = { ...envelope, method: args.method, params: args.params }; + break; case 'session/steer': request = { ...envelope, method: args.method, params: args.params }; break; diff --git a/packages/loro-streams-rpc/tests/machine-rpc-server.test.ts b/packages/loro-streams-rpc/tests/machine-rpc-server.test.ts index cdec9bbed..e26525e00 100644 --- a/packages/loro-streams-rpc/tests/machine-rpc-server.test.ts +++ b/packages/loro-streams-rpc/tests/machine-rpc-server.test.ts @@ -863,6 +863,73 @@ describe('LoroStreamsMachineRpcServer', () => { server.stop(); }); + it('dispatches context compaction reconciliation with the exact activity owner', async () => { + const workspaceId = 'workspace-1' as WorkspaceId; + const machineId = 'machine-1' as MachineId; + const sessionId = 'session-1' as SessionId; + const fake = createFakeStreamClient(); + const reconcileSessionContextCompaction = vi.fn( + async (args: { sessionId: SessionId; turnId: string; toolCallId: string }) => ({ + type: 'session/reconcile-context-compaction_response' as const, + ...args, + outcome: 'reconciled' as const, + }) + ); + const server = new LoroStreamsMachineRpcServer({ + logger: createSilentLogger(), + workspaceId, + machineId, + streamClient: fake.streamClient, + getMachineStatus: vi.fn(), + refreshMachineAcpCapabilities: vi.fn(), + reconcileSessionContextCompaction, + }); + fake.pushBatch({ + messages: [ + { + jsonrpc: '2.0', + id: 'reconcile-1', + method: 'session/reconcile-context-compaction', + rpcVersion: '1', + machineId, + workspaceId, + replyTo: 'workspace-1:rpc:res:client-1', + sentAt: Date.now(), + expiresAt: Date.now() + 5000, + params: { + sessionId, + turnId: 'assistant:turn-1', + toolCallId: 'compact-1', + }, + }, + ], + nextOffset: '1', + cursor: 'cursor-1', + upToDate: true, + }); + + await server.start(); + await fake.waitForAppendedCount(1); + + expect(reconcileSessionContextCompaction).toHaveBeenCalledWith({ + sessionId, + turnId: 'assistant:turn-1', + toolCallId: 'compact-1', + }); + expect(fake.appended[0]?.value).toEqual( + expect.objectContaining({ + id: 'reconcile-1', + result: expect.objectContaining({ + outcome: 'reconciled', + turnId: 'assistant:turn-1', + toolCallId: 'compact-1', + }), + }) + ); + + server.stop(); + }); + it('appends responses to workspace-level replyTo streams for new clients', async () => { const workspaceId = 'workspace-1' as WorkspaceId; const machineId = 'machine-1' as MachineId; diff --git a/packages/shared/src/local-machine-rpc.ts b/packages/shared/src/local-machine-rpc.ts index d6d3912bb..fd7436b53 100644 --- a/packages/shared/src/local-machine-rpc.ts +++ b/packages/shared/src/local-machine-rpc.ts @@ -24,6 +24,7 @@ import { import { FilePreviewV3RequestSchema, FilePreviewV3ResponseSchema } from './file-preview'; import { SessionCancelResponseSchema, + SessionContextCompactionReconcileResponseSchema, SessionDispatchTurnResponseSchema, SessionEditAndResendResponseSchema, SessionEditAndResendSpecSchema, @@ -161,6 +162,16 @@ export const LocalMachineRpcRequestSchema = z.discriminatedUnion('method', [ }) .strict(), }).strict(), + BaseLocalMachineRpcRequestSchema.extend({ + method: z.literal('session/reconcile-context-compaction'), + params: z + .object({ + sessionId: SessionIdSchema, + turnId: z.string().trim().min(1), + toolCallId: z.string().trim().min(1), + }) + .strict(), + }).strict(), BaseLocalMachineRpcRequestSchema.extend({ method: z.literal('session/fork'), params: SessionForkSpecSchema, @@ -263,6 +274,7 @@ export const LocalMachineRpcResultSchema = z.union([ FilePreviewV3ResponseSchema, LocalFileResolutionSchema, SessionCancelResponseSchema, + SessionContextCompactionReconcileResponseSchema, SessionDispatchTurnResponseSchema, SessionEditAndResendResponseSchema, SessionForkResponseSchema, diff --git a/packages/shared/src/machine-protocol-capabilities.ts b/packages/shared/src/machine-protocol-capabilities.ts index f81e8dde5..d9151eaab 100644 --- a/packages/shared/src/machine-protocol-capabilities.ts +++ b/packages/shared/src/machine-protocol-capabilities.ts @@ -13,6 +13,7 @@ export const MACHINE_PROTOCOL_CAPABILITIES = { providerSetup: 'providerSetup', localFileResources: 'localFileResources', acpProtocolAuthentication: 'acpProtocolAuthentication', + contextCompactionReconciliation: 'contextCompactionReconciliation', } as const; export const ACP_AUTHENTICATION_INTERACTIONS_PROTOCOL_VERSION = 2; @@ -20,6 +21,7 @@ export const LOCAL_PROJECT_REMOVAL_PROTOCOL_VERSION = 1; export const PROVIDER_SETUP_PROTOCOL_VERSION = 1; export const LOCAL_FILE_RESOURCES_PROTOCOL_VERSION = 1; export const ACP_PROTOCOL_AUTHENTICATION_VERSION = 2; +export const CONTEXT_COMPACTION_RECONCILIATION_PROTOCOL_VERSION = 1; type MachineProtocolCapabilityCarrier = { protocolCapabilities?: MachineProtocolCapabilities; @@ -55,6 +57,8 @@ export const CURRENT_MACHINE_PROTOCOL_CAPABILITIES: MachineProtocolCapabilities [MACHINE_PROTOCOL_CAPABILITIES.providerSetup]: PROVIDER_SETUP_PROTOCOL_VERSION, [MACHINE_PROTOCOL_CAPABILITIES.localFileResources]: LOCAL_FILE_RESOURCES_PROTOCOL_VERSION, [MACHINE_PROTOCOL_CAPABILITIES.acpProtocolAuthentication]: ACP_PROTOCOL_AUTHENTICATION_VERSION, + [MACHINE_PROTOCOL_CAPABILITIES.contextCompactionReconciliation]: + CONTEXT_COMPACTION_RECONCILIATION_PROTOCOL_VERSION, }; /** Whether the target daemon supports interactive Custom/Registry ACP authentication. */ @@ -115,3 +119,14 @@ export function machineSupportsLocalFileResourcesProtocol( LOCAL_FILE_RESOURCES_PROTOCOL_VERSION ); } + +/** Whether the target daemon can settle legacy compaction state from live turn ownership. */ +export function machineSupportsContextCompactionReconciliationProtocol( + machine: MachineProtocolCapabilityCarrier | null | undefined +): boolean { + return machineSupportsProtocolCapability( + machine, + MACHINE_PROTOCOL_CAPABILITIES.contextCompactionReconciliation, + CONTEXT_COMPACTION_RECONCILIATION_PROTOCOL_VERSION + ); +} diff --git a/packages/shared/src/message-schemas.ts b/packages/shared/src/message-schemas.ts index 35fc17bb2..eabf4b95c 100644 --- a/packages/shared/src/message-schemas.ts +++ b/packages/shared/src/message-schemas.ts @@ -643,6 +643,22 @@ export const SessionCancelResponseSchema = z }) .strict(); +export const SessionContextCompactionReconcileResponseSchema = z + .object({ + type: z.literal('session/reconcile-context-compaction_response'), + sessionId: SessionIdSchema, + turnId: z.string().trim().min(1), + toolCallId: z.string().trim().min(1), + outcome: z.enum(['reconciled', 'unchanged', 'active', 'unknown']), + activeTurnId: z.string().trim().min(1).optional(), + error: z.string().trim().min(1).optional(), + }) + .strict(); + +export type SessionContextCompactionReconcileResponse = z.infer< + typeof SessionContextCompactionReconcileResponseSchema +>; + export const SessionSteerRequestSchema = z .object({ type: z.literal('session/steer'), diff --git a/packages/shared/tests/local-machine-rpc.test.ts b/packages/shared/tests/local-machine-rpc.test.ts index c3c8b7f93..a1978bb0a 100644 --- a/packages/shared/tests/local-machine-rpc.test.ts +++ b/packages/shared/tests/local-machine-rpc.test.ts @@ -10,6 +10,14 @@ describe('local Machine RPC', () => { method: 'session/get-active-invocation-context', params: { sessionId: 'session-1' }, }, + { + method: 'session/reconcile-context-compaction', + params: { + sessionId: 'session-1', + turnId: 'assistant:turn-1', + toolCallId: 'context-compaction-1', + }, + }, { method: 'session/fork', params: { diff --git a/packages/shared/tests/machine-protocol-capabilities.test.ts b/packages/shared/tests/machine-protocol-capabilities.test.ts index 83f64112b..b2fef9f34 100644 --- a/packages/shared/tests/machine-protocol-capabilities.test.ts +++ b/packages/shared/tests/machine-protocol-capabilities.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest'; import { ACP_AUTHENTICATION_INTERACTIONS_PROTOCOL_VERSION, + CONTEXT_COMPACTION_RECONCILIATION_PROTOCOL_VERSION, CURRENT_MACHINE_PROTOCOL_CAPABILITIES, MACHINE_PROTOCOL_CAPABILITIES, machineSupportsAcpAuthenticationInteractionsProtocol, + machineSupportsContextCompactionReconciliationProtocol, machineSupportsLocalFileResourcesProtocol, } from '../src/machine-protocol-capabilities'; @@ -35,6 +37,20 @@ describe('ACP authentication interaction protocol capability', () => { }); }); +it('binds legacy compaction reconciliation to its advertised protocol version', () => { + expect( + CURRENT_MACHINE_PROTOCOL_CAPABILITIES[ + MACHINE_PROTOCOL_CAPABILITIES.contextCompactionReconciliation + ] + ).toBe(CONTEXT_COMPACTION_RECONCILIATION_PROTOCOL_VERSION); + expect(machineSupportsContextCompactionReconciliationProtocol(undefined)).toBe(false); + expect( + machineSupportsContextCompactionReconciliationProtocol({ + protocolCapabilities: CURRENT_MACHINE_PROTOCOL_CAPABILITIES, + }) + ).toBe(true); +}); + it('requires an advertised local file resource protocol, independent of release version', () => { expect(machineSupportsLocalFileResourcesProtocol(undefined)).toBe(false); expect( diff --git a/specs/session-history-writes.md b/specs/session-history-writes.md index 969efa220..8caa4a1be 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,13 @@ 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. +- A finished assistant turn may contain a legacy context-compaction activity whose + provider terminal state was never persisted. Opening that Session may request a + repair, but only its owning daemon may change the exact addressed turn and tool + item, and only after live execution ownership proves that turn is no longer + active. Turn `finished`, durable Session status, elapsed time, daemon restart, + or missing live evidence is insufficient. An offline, unsupported, non-owning, + or indeterminate daemon leaves history unchanged. - 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 @@ -77,7 +84,10 @@ Non-history control-field validation remains outside this HistoryWriter contract - `packages/shared/src/{history-writer,history-write-schema,session-mirror}.ts` - `packages/shared/tests/history-writer.test.ts` and `history-writer.contract.ts` +- `apps/cli/src/lib/assistant-turn-finalize.ts` and the capability-gated + `session/reconcile-context-compaction` Machine RPC - [Decision](../.agents/notes/implemented/architecture/2026-09-07-single-history-writer.md) +- [Legacy compaction repair decision](../.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md) - [Business-field repair and pending hash decision](../.agents/notes/implemented/architecture/2026-09-07-single-history-writer.md) - [Imported-history baseline repair](../.agents/notes/implemented/architecture/2026-09-07-single-history-writer.md) diff --git a/specs/session-history-writes.zh.md b/specs/session-history-writes.zh.md index 738824286..1188e1543 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) @@ -32,6 +32,11 @@ Translation: stale - 工具状态、权限请求与描述元数据(title/kind/locations)只解析本次变化的字段, 不重验未修改的工具内容;只修改 outcome 时保留已有请求信息。 修改工具身份或内容仍需完整 item 解析;新增元数据非法时,整条命令在写入前拒绝。 +- 已 finished 的 assistant turn 可能包含未持久化 provider 终态的旧 context-compaction + activity。打开该 Session 时可以请求修复,但只有 owner daemon 能修改指定 turn 和 tool + item,且必须先由实时执行所有权证明该 turn 已不活跃。Turn `finished`、持久化 Session + status、经过时间、daemon 重启或缺少 live evidence 均不充分。Daemon 离线、不支持该能力、 + 不持有 Session 或无法确定时,历史保持不变。 - 已接受的 steer 标记在写入和读取归一化后都必须保留;编辑重发不能把 steer 当作可独立重放的普通用户轮次。 @@ -59,7 +64,10 @@ Translation: stale - `packages/shared/src/{history-writer,history-write-schema,session-mirror}.ts` - `packages/shared/tests/history-writer.test.ts` 与 `history-writer.contract.ts` +- `apps/cli/src/lib/assistant-turn-finalize.ts` 与经过 capability gate 的 + `session/reconcile-context-compaction` Machine RPC - [决策记录](../.agents/notes/implemented/architecture/2026-09-07-single-history-writer.zh.md) +- [旧 compaction 修复决策](../.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md) - [业务字段修复与待定 hash 决策](../.agents/notes/implemented/architecture/2026-09-07-single-history-writer.zh.md) - [外部历史基线修复](../.agents/notes/implemented/architecture/2026-09-07-single-history-writer.zh.md) From 7e2680aeddcff0874d0f1d84292e38c1f73a761d Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:44:26 +0800 Subject: [PATCH 02/14] fix: make compaction reconciliation retry-safe Model: gpt-5 --- .agents/docs/sessions-live-status.md | 4 +- ...stale-context-compaction-reconciliation.md | 29 +++-- ...le-context-compaction-reconciliation.zh.md | 28 +++-- apps/cli/src/lib/message-handler.ts | 114 ++++++++++++------ .../message-handler-acp-batching.test.ts | 58 ++++++++- .../tests/session-dispatch-watcher.test.ts | 72 +++++++++++ packages/components/src/atoms/presence.ts | 13 ++ .../sessions/session-chat-interface.tsx | 21 +++- .../src/hooks/use-machine-online-status.ts | 8 ++ .../src/lib/session-context-compaction.ts | 13 +- .../tests/doc-meta-presence.test.ts | 2 + .../tests/session-context-compaction.test.ts | 31 +++++ .../src/machine-rpc-server.ts | 1 - .../tests/machine-rpc-server.test.ts | 81 +++++++++++++ specs/session-history-writes.md | 6 +- specs/session-history-writes.zh.md | 5 +- 16 files changed, 419 insertions(+), 67 deletions(-) diff --git a/.agents/docs/sessions-live-status.md b/.agents/docs/sessions-live-status.md index c03745777..69e5f4665 100644 --- a/.agents/docs/sessions-live-status.md +++ b/.agents/docs/sessions-live-status.md @@ -58,4 +58,6 @@ null`: browser offline, machine removed or offline) — the chip owns that story The renderer does not convert `finished` into a provider terminal signal. The daemon writes `failed` only when its live execution state proves the addressed turn no longer owns work; an unsupported, offline, non-owner, active, or - indeterminate daemon leaves the durable item unchanged. + indeterminate daemon leaves the durable item unchanged. Active, ambiguous, + unchanged, and unreachable attempts remain retryable after history activity, + reconnect, or daemon replacement; only a remotely confirmed repair is terminal. diff --git a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md index d86d8827a..507c04813 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md +++ b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md @@ -20,14 +20,23 @@ unchanged. The renderer remains a reader of durable compaction status. It does not reinterpret `SessionHistory.finished`, a timeout, a restart, or missing presence as provider termination. When the latest compaction is unresolved and its assistant turn is -finished, the renderer makes one capability-gated reconciliation request while the -Session is open. A transient `unknown` outcome may be retried after later history -activity; unsupported or unreachable daemons do not create a write fallback. +finished, the renderer makes a capability-gated reconciliation request. Only a +`reconciled` result whose write was confirmed is permanently deduplicated. `active`, +`unknown`, and `unchanged` may be retried after later history activity, an +offline-to-online transition, or a new daemon presence instance. Unsupported or +unreachable daemons do not create a write fallback. The request names `sessionId`, `turnId`, and `toolCallId`. The target daemon first -verifies that current Session metadata assigns ownership to its own machine. It then -holds the existing Session history rewrite barrier while checking execution-service -turn ownership, blocking Session creation, active presence, and pending dispatch. +verifies that current Session metadata assigns ownership to its own machine and waits +for the Session document's initial remote state before drawing a conclusion from its +history. It acquires the existing Session history rewrite barrier only for a second +ownership/liveness check and the local history mutation. Releasing that barrier +explicitly enqueues a dispatch recheck, so a turn accepted while repair was running +cannot remain stranded. Remote write confirmation happens after the barrier is +released, and a failed or unavailable confirmation returns `unknown`, never durable +success. The reconciliation RPC uses the ordinary request lane because document sync +and history writes are not fast control-plane work. + The addressed turn remains unchanged when it is active or when unassigned work makes the result indeterminate. Once the daemon can prove the turn is not its live owner, it changes only the named unresolved `context_compaction` item to `failed`; terminal, @@ -56,6 +65,8 @@ more than the exact activity requested by the current view. ## Verification Behavioral coverage exercises exact-item mutation, active and indeterminate ownership -outcomes, owner-daemon persistence, local capability gating, and Loro Streams RPC -dispatch. Shared schemas validate the request and response shapes. No startup scan, -storage migration, or end-to-end provider fixture was added. +outcomes, cold/unsynced documents, write-confirmation failure, dispatch wakeup after a +rewrite barrier, owner-daemon generations, control-lane isolation, local capability +gating, and Loro Streams RPC dispatch. Shared schemas validate the request and +response shapes. No startup scan, storage migration, or end-to-end provider fixture +was added. diff --git a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md index ec1ba9d01..7dc1ac8ea 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md @@ -17,16 +17,21 @@ daemon 协调精确的 turn 与 tool-call id。只有实时执行所有权证明 Renderer 仍只读取持久化的 compaction 状态。它不会把 `SessionHistory.finished`、超时、 重启或 presence 缺失重新解释成 provider 已终止。最新 compaction 未完成且所属 assistant -turn 已 finished 时,renderer 会在 Session 打开期间发起一次经过 capability gate 的协调请求。 -Transient `unknown` 可以在后续历史活动后重试;不支持该能力或不可访问的 daemon 不会触发 -其他写入 fallback。 +turn 已 finished 时,renderer 会发起经过 capability gate 的协调请求。只有写入已确认的 +`reconciled` 结果会被永久去重;`active`、`unknown` 和 `unchanged` 可以在后续历史活动、 +offline-to-online 转换或新的 daemon presence instance 出现后重试。不支持该能力或不可访问的 +daemon 不会触发其他写入 fallback。 请求携带 `sessionId`、`turnId` 和 `toolCallId`。目标 daemon 先验证当前 Session metadata -确实把所有权分配给本机,然后在现有 Session history rewrite barrier 内检查 execution-service -turn ownership、阻塞中的 Session 创建、active presence 和 pending dispatch。目标 turn 仍 -active,或存在未归属工作导致结论不确定时,历史不变。当 daemon 能证明该 turn 已不是实时 -owner 时,它只把指定且未完成的 `context_compaction` item 改成 `failed`;已有终态、不匹配、 -未知和无关的持久化 item 均被保留。 +确实把所有权分配给本机,并等待 Session document 的初始远端状态完成后再根据 history 下结论。 +现有 Session history rewrite barrier 只覆盖第二次 ownership/liveness 检查和本地 history 修改。 +barrier 释放后会显式 enqueue 一次 dispatch recheck,确保 repair 期间已接受的 turn 不会被搁置。 +远端写入确认在 barrier 外进行;确认失败或不可用时返回 `unknown`,不能报告持久化成功。 +reconciliation RPC 使用普通 request lane,因为 document sync 和 history write 不是快速控制面工作。 + +目标 turn 仍 active,或存在未归属工作导致结论不确定时,历史不变。当 daemon 能证明该 turn +已不是实时 owner 时,它只把指定且未完成的 `context_compaction` item 改成 `failed`;已有终态、 +不匹配、未知和无关的持久化 item 均被保留。 该修复针对打开的 Session 惰性执行,而不是启动时迁移。全局扫描会激活数量不受控的旧文档, 却仍无法建立 provider 所有权。版本化的 Machine capability negotiation 也确保混合版本客户端 @@ -46,6 +51,7 @@ owner 能提供证据。本次变更不假定每一条历史未完成 compaction ## 验证 -行为测试覆盖精确 item 修改、active 与 indeterminate 所有权结果、owner-daemon 持久化、本地 -capability gating 和 Loro Streams RPC 分发。共享 schema 会校验请求与响应 shape。本次没有 -加入启动扫描、存储迁移或端到端 provider fixture。 +行为测试覆盖精确 item 修改、active 与 indeterminate 所有权结果、cold/unsynced document、 +写入确认失败、rewrite barrier 释放后的 dispatch 唤醒、owner-daemon generation、control lane +隔离、本地 capability gating 和 Loro Streams RPC 分发。共享 schema 会校验请求与响应 shape。 +本次没有加入启动扫描、存储迁移或端到端 provider fixture。 diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index 8e6eed502..780faa368 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -6798,57 +6798,99 @@ export class MessageHandler { ...options, }); - let releaseBarrier: (() => void) | undefined; try { - const metaRecord = await this.workspaceDocument.repo.getDocMeta( - getSessionRoomId(args.sessionId) - ); - const meta = - metaRecord?.meta && !isLoroRepoDocDeleted(metaRecord) - ? (metaRecord.meta as SessionMeta) - : undefined; - if (!meta || meta.machineId !== this.machineId) { + const verifyOwnership = async (): Promise => { + const metaRecord = await this.workspaceDocument.repo.getDocMeta( + getSessionRoomId(args.sessionId) + ); + const meta = + metaRecord?.meta && !isLoroRepoDocDeleted(metaRecord) + ? (metaRecord.meta as SessionMeta) + : undefined; + return meta?.machineId === this.machineId; + }; + const inspectLiveWork = (): { + activeTurnId?: string; + outcome: SessionContextCompactionReconcileResponse | null; + } => { + const execution = this.executionService.getExecutionSnapshot(args.sessionId); + if (execution.activeTurnId === args.turnId) { + return { + activeTurnId: execution.activeTurnId, + outcome: response('active', { activeTurnId: execution.activeTurnId }), + }; + } + if ( + !execution.activeTurnId && + (execution.hasActiveTurn || + execution.hasBlockingPendingCreate || + execution.hasActiveAutomation || + this.sessionActivePresence.has(args.sessionId) || + this.sessionDispatchWatcher.hasPendingDispatch(args.sessionId)) + ) { + return { + outcome: response('unknown', { + error: 'The daemon still has unassigned Session work.', + }), + }; + } + return { + ...(execution.activeTurnId ? { activeTurnId: execution.activeTurnId } : {}), + outcome: null, + }; + }; + + if (!(await verifyOwnership())) { return response('unknown', { error: 'The target daemon does not own this Session.' }); } - - releaseBarrier = - this.executionService.tryAcquireSessionRewriteBarrier(args.sessionId) ?? undefined; - if (!releaseBarrier) { - return response('unknown', { error: 'Session ownership is changing.' }); + const initialLiveWork = inspectLiveWork(); + if (initialLiveWork.outcome) { + return initialLiveWork.outcome; } - const execution = this.executionService.getExecutionSnapshot(args.sessionId); - if (execution.activeTurnId === args.turnId) { - return response('active', { activeTurnId: execution.activeTurnId }); + const sessionDoc = await this.workspaceDocument.getOrCreateSessionDoc(args.sessionId); + await sessionDoc.ensureDocRoomJoined(); + if (!(await sessionDoc.waitUntilSynced())) { + return response('unknown', { error: 'Session history is not synced with its owner.' }); } - if ( - !execution.activeTurnId && - (execution.hasActiveTurn || - execution.hasBlockingPendingCreate || - execution.hasActiveAutomation || - this.sessionActivePresence.has(args.sessionId) || - this.sessionDispatchWatcher.hasPendingDispatch(args.sessionId)) - ) { - return response('unknown', { error: 'The daemon still has unassigned Session work.' }); + const releaseBarrier = this.executionService.tryAcquireSessionRewriteBarrier(args.sessionId); + if (!releaseBarrier) { + return response('unknown', { error: 'Session ownership is changing.' }); } - const sessionDoc = await this.workspaceDocument.getOrCreateSessionDoc(args.sessionId); let reconciled = false; - await sessionDoc.updateHistory((history) => { - reconciled = settleContextCompactionItemAsFailed(history, args); - return history; - }); - if (reconciled) { - await sessionDoc.waitUntilSynced(); + let observedActiveTurnId: string | undefined; + try { + if (!(await verifyOwnership())) { + return response('unknown', { error: 'The target daemon no longer owns this Session.' }); + } + const liveWork = inspectLiveWork(); + if (liveWork.outcome) { + return liveWork.outcome; + } + observedActiveTurnId = liveWork.activeTurnId; + await sessionDoc.updateHistory((history) => { + reconciled = settleContextCompactionItemAsFailed(history, args); + return history; + }); + } finally { + releaseBarrier(); + // A turn offer or History update may have been checked while the barrier + // was held. Queue a follow-up after release so ACKed work cannot be stranded. + void this.sessionDispatchWatcher.enqueueSessionCheck(args.sessionId); + } + + if (reconciled && !(await sessionDoc.waitUntilSynced())) { + return response('unknown', { + error: 'The reconciled Session history was not confirmed by its owner.', + }); } return response(reconciled ? 'reconciled' : 'unchanged', { - ...(execution.activeTurnId ? { activeTurnId: execution.activeTurnId } : {}), + ...(observedActiveTurnId ? { activeTurnId: observedActiveTurnId } : {}), }); } catch (error) { return response('unknown', { error: formatErrorMessage(error) }); - } finally { - releaseBarrier?.(); } } diff --git a/apps/cli/tests/message-handler-acp-batching.test.ts b/apps/cli/tests/message-handler-acp-batching.test.ts index 54ee2ce9c..061e8ee63 100644 --- a/apps/cli/tests/message-handler-acp-batching.test.ts +++ b/apps/cli/tests/message-handler-acp-batching.test.ts @@ -494,15 +494,18 @@ describe('MessageHandler ACP batching', () => { }; }; sessionActivePresence: { has(sessionId: SessionId): boolean }; + sessionDispatchWatcher: { + enqueueSessionCheck(sessionId: SessionId): Promise; + }; }; - const findCompaction = (history: SessionHistoryInput[]) => + const findCompaction = (history: SessionHistoryInput[], toolCallId = 'compact-stale') => history .flatMap((entry) => readItems(entry)) .find( (item) => item.type === 'tool_call' && item.activityKind === 'context_compaction' && - item.toolCallId === 'compact-stale' + item.toolCallId === toolCallId ); try { @@ -574,16 +577,65 @@ describe('MessageHandler ACP batching', () => { ).resolves.toMatchObject({ outcome: 'unknown' }); expect(findCompaction(await doc.getHistory())).toMatchObject({ status: 'in_progress' }); + const ensureDocRoomJoined = vi.spyOn(doc, 'ensureDocRoomJoined').mockResolvedValue(undefined); + const waitUntilSynced = vi.spyOn(doc, 'waitUntilSynced').mockResolvedValueOnce(false); + const updateHistory = vi.spyOn(doc, 'updateHistory'); + const enqueueSessionCheck = vi + .spyOn(host.sessionDispatchWatcher, 'enqueueSessionCheck') + .mockResolvedValue(undefined); await expect( host.reconcileSessionContextCompaction({ sessionId, turnId, toolCallId: 'compact-stale', }) - ).resolves.toMatchObject({ outcome: 'reconciled' }); + ).resolves.toMatchObject({ outcome: 'unknown' }); + expect(ensureDocRoomJoined).toHaveBeenCalled(); + expect(updateHistory).not.toHaveBeenCalled(); + expect(findCompaction(await doc.getHistory())).toMatchObject({ status: 'in_progress' }); + + let confirmDurableWrite!: (synced: boolean) => void; + const durableWrite = new Promise((resolve) => { + confirmDurableWrite = resolve; + }); + waitUntilSynced.mockResolvedValueOnce(true).mockReturnValueOnce(durableWrite); + const reconciliation = host.reconcileSessionContextCompaction({ + sessionId, + turnId, + toolCallId: 'compact-stale', + }); + await vi.waitFor(() => expect(enqueueSessionCheck).toHaveBeenCalledWith(sessionId)); + expect(host.executionService.getExecutionSnapshot(sessionId).hasRewriteBarrier).toBe(false); + confirmDurableWrite(true); + await expect(reconciliation).resolves.toMatchObject({ outcome: 'reconciled' }); expect(hasActivePresence).toHaveBeenCalled(); expect(findCompaction(await doc.getHistory())).toMatchObject({ status: 'failed' }); expect(isSessionContextCompacting(await doc.getHistory())).toBe(false); + + const unsyncedTurnId = host.beginConversationTurn(sessionId); + host.enqueueACPUpdate(sessionId, { + sessionId, + update: { + sessionUpdate: 'tool_call', + toolCallId: 'compact-unsynced', + title: 'Context compacting', + status: 'in_progress', + _meta: { contextCompaction: true }, + }, + }); + await host.flushACPUpdatesNow(sessionId); + await host.finalizeACPState(sessionId, unsyncedTurnId); + waitUntilSynced.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + await expect( + host.reconcileSessionContextCompaction({ + sessionId, + turnId: unsyncedTurnId, + toolCallId: 'compact-unsynced', + }) + ).resolves.toMatchObject({ outcome: 'unknown' }); + expect(findCompaction(await doc.getHistory(), 'compact-unsynced')).toMatchObject({ + status: 'failed', + }); } finally { await destroyRepoOnRealTimers(repo); } diff --git a/apps/cli/tests/session-dispatch-watcher.test.ts b/apps/cli/tests/session-dispatch-watcher.test.ts index 44b208cd4..54610b165 100644 --- a/apps/cli/tests/session-dispatch-watcher.test.ts +++ b/apps/cli/tests/session-dispatch-watcher.test.ts @@ -389,6 +389,78 @@ describe('SessionDispatchWatcher', () => { ); }); + it('dispatches an ACKed RPC turn when a rewrite barrier release requests a recheck', async () => { + const startSession = vi.fn(async () => {}); + const sessionId = 'session-rpc-rewrite-barrier' as SessionId; + const sessionMeta = { + id: sessionId, + machineId: 'machine-1', + userId: 'user-1', + createdAt: new Date().toISOString(), + cliType: 'builtin', + agentType: 'codex', + status: { type: 'idle' as const }, + }; + const sessionDoc = { + mirror: { subscribe: vi.fn(() => vi.fn()) }, + getMetaState: vi.fn(async () => sessionMeta), + getHistory: vi.fn(async () => []), + updateHistory: vi.fn(async () => {}), + setStatus: vi.fn(async () => {}), + waitForRemoteSync: vi.fn(async () => {}), + }; + const workspaceDocument = { + repo: { + getDocMeta: vi.fn(async () => ({ meta: sessionMeta })), + upsertDocMeta: vi.fn(async () => {}), + watch: vi.fn(() => ({ unsubscribe: vi.fn() })), + }, + getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + onMetaRoomSynced: vi.fn(() => vi.fn()), + } as unknown as LoroDocumentManager; + let barrierHeld = true; + const getExecutionSnapshot = vi.fn(() => ({ + hasActiveTurn: false, + hasBlockingPendingCreate: false, + hasReusableSession: false, + hasRewriteBarrier: barrierHeld, + })); + const watcher = createWatcher({ + logger: createSilentLogger(), + machineId: 'machine-1', + workspaceId: 'workspace-1' as WorkspaceId, + workspaceDocument, + executionService: { + getExecutionSnapshot, + continueSession: vi.fn(async () => {}), + startSession, + cancelSession: vi.fn(async () => ({ success: true })), + } as unknown as SessionExecutionService, + canUseMachine: createAllowMachineAccess(), + }); + + await expect( + watcher.offerRpcTurn({ + sessionId, + userTurnId: 'rpc-turn-barrier', + userId: 'user-1', + timestamp: new Date().toISOString(), + inputConfig: { prompt: 'send during reconciliation' }, + }) + ).resolves.toBe('accepted'); + await vi.waitFor(() => expect(getExecutionSnapshot).toHaveBeenCalled()); + expect(startSession).not.toHaveBeenCalled(); + + barrierHeld = false; + await watcher.enqueueSessionCheck(sessionId); + + expect(startSession).toHaveBeenCalledTimes(1); + expect(startSession).toHaveBeenCalledWith( + expect.objectContaining({ sessionId, userTurnId: 'rpc-turn-barrier' }), + { dispatchSource: 'rpc' } + ); + }); + it('keeps a stashed RPC turn while session meta is unknown and dispatches once meta syncs', async () => { const startSession = vi.fn(async () => {}); const sessionId = 'session-rpc-before-meta' as SessionId; diff --git a/packages/components/src/atoms/presence.ts b/packages/components/src/atoms/presence.ts index b900624e4..a2cff2f97 100644 --- a/packages/components/src/atoms/presence.ts +++ b/packages/components/src/atoms/presence.ts @@ -2,6 +2,7 @@ import { atom } from 'jotai'; import { atomFamily } from 'jotai/utils'; import { collectOnlineMachineIdsFromPresence, + findFreshMachinePresenceState, findFreshSessionPresenceState, getServerNow, type LodyPresenceStateMap, @@ -36,6 +37,18 @@ export const sessionLiveStatusAtomFamily = atomFamily((sessionId: SessionId) => atom((get) => get(sessionLivePresenceAtomFamily(sessionId))?.status ?? null) ); +export const machineLivePresenceAtomFamily = atomFamily((machineId?: MachineId) => + atom((get) => + machineId + ? (findFreshMachinePresenceState( + get(lodyPresenceStatesAtom), + machineId, + get(lodyPresenceNowMsAtom) + ) ?? null) + : null + ) +); + const setsEqual = (a: ReadonlySet, b: ReadonlySet): boolean => { if (a.size !== b.size) return false; for (const id of a) { diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index 1cfef608e..748a9a938 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -58,6 +58,8 @@ import { matchesKeyboardEvent } from '@/lib/commands/key-matcher'; import { canStopAgentEnabled, findActiveSessionContextCompaction, + getContextCompactionReconciliationAttemptKey, + isDurableContextCompactionReconciliation, } from '@/lib/session-context-compaction'; import { hasFileTransfer, readDroppedTransfer } from '@/lib/file-drop'; import { resolveProgrammaticTurnAgentRole } from '@/lib/composer-agent-roles'; @@ -149,7 +151,10 @@ import { openedSessionsAtomFamily, sessionMetaAtomFamily, } from '@/atoms/doc-meta'; -import { useMachineOnlineStatus } from '@/hooks/use-machine-online-status'; +import { + useMachineOnlineStatus, + useMachinePresenceInstanceId, +} from '@/hooks/use-machine-online-status'; import { useDelayedFlag } from '@/hooks/use-delayed-flag'; import { resolveSessionStatusStripState } from './session-status-strip'; import { isSyncingRoomSyncState } from '@/lib/room-sync-state'; @@ -2250,6 +2255,7 @@ export const SessionChatInterface = memo( const docMetaCacheReady = useAtomValue(docMetaCacheReadyAtom); const isMachineRemoved = !sessionMachine && docMetaCacheReady; const sessionMachineOnlineStatus = useMachineOnlineStatus(session.machineId); + const sessionMachinePresenceInstanceId = useMachinePresenceInstanceId(session.machineId); const browserOnline = useAtomValue(browserOnlineAtom); const externalHistorySyncLabel = isExternalHistoryRefreshing ? t('sessions.externalHistorySyncing', { @@ -2716,7 +2722,12 @@ export const SessionChatInterface = memo( if (!runtime || !canReconcileContextCompaction || !activeContextCompaction?.turnFinished) { return; } - const attemptKey = `${session.id}:${activeContextCompaction.turnId}:${activeContextCompaction.toolCallId}`; + const attemptKey = getContextCompactionReconciliationAttemptKey({ + sessionId: session.id, + turnId: activeContextCompaction.turnId, + toolCallId: activeContextCompaction.toolCallId, + ownerInstanceId: sessionMachinePresenceInstanceId, + }); if (compactionReconciliationAttemptsRef.current.has(attemptKey)) return; compactionReconciliationAttemptsRef.current.add(attemptKey); void runtime @@ -2726,9 +2737,12 @@ export const SessionChatInterface = memo( toolCallId: activeContextCompaction.toolCallId, }) .then((result) => { - if (!result || result.outcome === 'unknown') { + if (!isDurableContextCompactionReconciliation(result)) { compactionReconciliationAttemptsRef.current.delete(attemptKey); } + }) + .catch(() => { + compactionReconciliationAttemptsRef.current.delete(attemptKey); }); }, [ activeContextCompaction, @@ -2736,6 +2750,7 @@ export const SessionChatInterface = memo( runtime, session.id, session.machineId, + sessionMachinePresenceInstanceId, ]); // Pending scheduled tasks (cron / wakeup) are derived on the fly from the // Cron*/ScheduleWakeup tool_call items already in history — nothing extra is diff --git a/packages/components/src/hooks/use-machine-online-status.ts b/packages/components/src/hooks/use-machine-online-status.ts index 31601c290..7d11c1ef5 100644 --- a/packages/components/src/hooks/use-machine-online-status.ts +++ b/packages/components/src/hooks/use-machine-online-status.ts @@ -2,6 +2,7 @@ import { useAtomValue } from 'jotai'; import type { MachineId } from '@lody/shared'; import { machineOnlineStatusAtomFamily, + machineLivePresenceAtomFamily, onlineMachineIdsAtom, type MachineOnlineStatus, } from '@/atoms/presence'; @@ -17,6 +18,13 @@ export function useMachineOnlineStatus( return useAtomValue(machineOnlineStatusAtomFamily(machineId ?? undefined)); } +/** Instance identity of the currently fresh daemon generation, when online. */ +export function useMachinePresenceInstanceId( + machineId: MachineId | null | undefined +): string | null { + return useAtomValue(machineLivePresenceAtomFamily(machineId ?? undefined))?.instanceId ?? null; +} + /** Machine ids with a fresh presence heartbeat. Stable reference between recomputes. */ export function useOnlineMachineIds(): ReadonlySet { return useAtomValue(onlineMachineIdsAtom); diff --git a/packages/components/src/lib/session-context-compaction.ts b/packages/components/src/lib/session-context-compaction.ts index 85fea3fcc..33ef00aac 100644 --- a/packages/components/src/lib/session-context-compaction.ts +++ b/packages/components/src/lib/session-context-compaction.ts @@ -1,4 +1,4 @@ -import type { SessionHistory } from '@lody/shared'; +import type { SessionContextCompactionReconcileResponse, SessionHistory } from '@lody/shared'; export type ActiveSessionContextCompaction = { turnId: string; @@ -7,6 +7,17 @@ export type ActiveSessionContextCompaction = { turnFinished: boolean; }; +export const getContextCompactionReconciliationAttemptKey = (args: { + sessionId: string; + turnId: string; + toolCallId: string; + ownerInstanceId: string | null; +}): string => JSON.stringify([args.sessionId, args.turnId, args.toolCallId, args.ownerInstanceId]); + +export const isDurableContextCompactionReconciliation = ( + result: SessionContextCompactionReconcileResponse | null +): boolean => result?.outcome === 'reconciled'; + export const findActiveSessionContextCompaction = ( history: readonly Pick[] ): ActiveSessionContextCompaction | null => { diff --git a/packages/components/tests/doc-meta-presence.test.ts b/packages/components/tests/doc-meta-presence.test.ts index 621f33f10..9a963c3b5 100644 --- a/packages/components/tests/doc-meta-presence.test.ts +++ b/packages/components/tests/doc-meta-presence.test.ts @@ -28,6 +28,7 @@ import { import { lodyPresenceStatesAtom, lodyPresenceSyncStateAtom, + machineLivePresenceAtomFamily, machineOnlineStatusAtomFamily, onlineMachineIdsAtom, sessionLiveStatusAtomFamily, @@ -104,6 +105,7 @@ describe('doc meta presence overlay', () => { // liveness is exposed through the presence atoms instead. expect(store.get(machineMetaAtomFamily(machineRoomId))?.lastSeen).toBe(now - 240_000); expect(store.get(onlineMachineIdsAtom).has(machineId)).toBe(true); + expect(store.get(machineLivePresenceAtomFamily(machineId))?.instanceId).toBe(instanceId); store.set(lodyPresenceSyncStateAtom, 'synced'); expect(store.get(machineOnlineStatusAtomFamily(machineId))).toBe('online'); expect(store.get(machineOnlineStatusAtomFamily('missing-machine' as MachineId))).toBe( diff --git a/packages/components/tests/session-context-compaction.test.ts b/packages/components/tests/session-context-compaction.test.ts index f9eb1bb0a..ddeb4d95e 100644 --- a/packages/components/tests/session-context-compaction.test.ts +++ b/packages/components/tests/session-context-compaction.test.ts @@ -4,6 +4,8 @@ import type { SessionHistory } from '@lody/shared'; import { canStopAgentEnabled, findActiveSessionContextCompaction, + getContextCompactionReconciliationAttemptKey, + isDurableContextCompactionReconciliation, isSessionContextCompacting, } from '../src/lib/session-context-compaction'; @@ -52,6 +54,35 @@ describe('isSessionContextCompacting', () => { expect(isSessionContextCompacting(historyWithStatus('pending', true))).toBe(true); expect(isSessionContextCompacting(historyWithStatus('in_progress', true))).toBe(true); }); + + it('retries non-durable outcomes and isolates attempts by daemon generation', () => { + const base = { + sessionId: 'session-1', + turnId: 'assistant:turn-1', + toolCallId: 'context-compaction-1', + }; + expect( + getContextCompactionReconciliationAttemptKey({ ...base, ownerInstanceId: 'daemon-1' }) + ).not.toBe( + getContextCompactionReconciliationAttemptKey({ ...base, ownerInstanceId: 'daemon-2' }) + ); + for (const outcome of ['active', 'unknown', 'unchanged'] as const) { + expect( + isDurableContextCompactionReconciliation({ + type: 'session/reconcile-context-compaction_response', + ...base, + outcome, + }) + ).toBe(false); + } + expect( + isDurableContextCompactionReconciliation({ + type: 'session/reconcile-context-compaction_response', + ...base, + outcome: 'reconciled', + }) + ).toBe(true); + }); }); describe('canStopAgentEnabled', () => { diff --git a/packages/loro-streams-rpc/src/machine-rpc-server.ts b/packages/loro-streams-rpc/src/machine-rpc-server.ts index db8a717af..e329bde57 100644 --- a/packages/loro-streams-rpc/src/machine-rpc-server.ts +++ b/packages/loro-streams-rpc/src/machine-rpc-server.ts @@ -119,7 +119,6 @@ const CONTROL_METHODS: ReadonlySet = new Set([ 'machine/acp-capabilities-refresh-cancel', 'session/cancel', 'session/live-status', - 'session/reconcile-context-compaction', 'session/steer', 'session/goal', 'session/terminate', diff --git a/packages/loro-streams-rpc/tests/machine-rpc-server.test.ts b/packages/loro-streams-rpc/tests/machine-rpc-server.test.ts index e26525e00..2d839dac1 100644 --- a/packages/loro-streams-rpc/tests/machine-rpc-server.test.ts +++ b/packages/loro-streams-rpc/tests/machine-rpc-server.test.ts @@ -930,6 +930,87 @@ describe('LoroStreamsMachineRpcServer', () => { server.stop(); }); + it('keeps control requests responsive while four reconciliations are running', async () => { + const workspaceId = 'workspace-1' as WorkspaceId; + const machineId = 'machine-1' as MachineId; + const fake = createFakeStreamClient(); + let releaseReconciliations!: () => void; + const reconciliationGate = new Promise((resolve) => { + releaseReconciliations = resolve; + }); + const reconcileSessionContextCompaction = vi.fn( + async (args: { sessionId: SessionId; turnId: string; toolCallId: string }) => { + await reconciliationGate; + return { + type: 'session/reconcile-context-compaction_response' as const, + ...args, + outcome: 'unchanged' as const, + }; + } + ); + const cancelSession = vi.fn(async (args: { sessionId: SessionId; turnId: string }) => ({ + type: 'session/cancel_response' as const, + sessionId: args.sessionId, + success: true, + })); + const server = new LoroStreamsMachineRpcServer({ + logger: createSilentLogger(), + workspaceId, + machineId, + streamClient: fake.streamClient, + getMachineStatus: vi.fn(), + refreshMachineAcpCapabilities: vi.fn(), + reconcileSessionContextCompaction, + cancelSession, + }); + const base = { + jsonrpc: '2.0' as const, + rpcVersion: '1', + machineId, + workspaceId, + replyTo: 'workspace-1:rpc:res:client-1', + sentAt: Date.now(), + expiresAt: Date.now() + 5_000, + }; + fake.pushBatch({ + messages: [ + ...Array.from({ length: 4 }, (_, index) => ({ + ...base, + id: `reconcile-${index}`, + method: 'session/reconcile-context-compaction', + params: { + sessionId: `session-${index}`, + turnId: `assistant:turn-${index}`, + toolCallId: `compact-${index}`, + }, + })), + { + ...base, + id: 'cancel-after-reconciliations', + method: 'session/cancel', + params: { sessionId: 'session-cancel', turnId: 'assistant:turn-cancel' }, + }, + ], + nextOffset: '1', + cursor: 'cursor-1', + upToDate: true, + }); + + try { + await server.start(); + await vi.waitFor(() => expect(reconcileSessionContextCompaction).toHaveBeenCalledTimes(4)); + await vi.waitFor(() => expect(cancelSession).toHaveBeenCalledTimes(1)); + expect( + fake.appended.some( + (entry) => (entry.value as { id?: string }).id === 'cancel-after-reconciliations' + ) + ).toBe(true); + } finally { + releaseReconciliations(); + server.stop(); + } + }); + it('appends responses to workspace-level replyTo streams for new clients', async () => { const workspaceId = 'workspace-1' as WorkspaceId; const machineId = 'machine-1' as MachineId; diff --git a/specs/session-history-writes.md b/specs/session-history-writes.md index 8caa4a1be..8646be73d 100644 --- a/specs/session-history-writes.md +++ b/specs/session-history-writes.md @@ -52,7 +52,11 @@ That tolerance must not authorize creating new malformed items locally. item, and only after live execution ownership proves that turn is no longer active. Turn `finished`, durable Session status, elapsed time, daemon restart, or missing live evidence is insufficient. An offline, unsupported, non-owning, - or indeterminate daemon leaves history unchanged. + or indeterminate daemon leaves history unchanged. Repair must inspect a remotely + synced Session document, hold the rewrite barrier only across the final live-state + check and local mutation, wake dispatch after release, and report `reconciled` only + after the write is confirmed. Non-terminal results remain retryable across later + history, connectivity, or daemon-generation evidence. - 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 1188e1543..014550d09 100644 --- a/specs/session-history-writes.zh.md +++ b/specs/session-history-writes.zh.md @@ -36,7 +36,10 @@ Translation: current activity。打开该 Session 时可以请求修复,但只有 owner daemon 能修改指定 turn 和 tool item,且必须先由实时执行所有权证明该 turn 已不活跃。Turn `finished`、持久化 Session status、经过时间、daemon 重启或缺少 live evidence 均不充分。Daemon 离线、不支持该能力、 - 不持有 Session 或无法确定时,历史保持不变。 + 不持有 Session 或无法确定时,历史保持不变。Repair 必须读取已完成远端同步的 Session + document;rewrite barrier 仅覆盖最终 live-state 检查和本地修改,释放后必须唤醒 dispatch; + 只有写入确认后才能返回 `reconciled`。非终态结果可在后续 history、连接或 daemon generation + 证据变化时重试。 - 已接受的 steer 标记在写入和读取归一化后都必须保留;编辑重发不能把 steer 当作可独立重放的普通用户轮次。 From 7fe6689323e67bb1d028d24e8602cdb317ac27f0 Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Sat, 12 Sep 2026 02:01:35 +0800 Subject: [PATCH 03/14] fix: preserve goal turns across history rewrites Model: gpt-5 --- ...stale-context-compaction-reconciliation.md | 18 +++--- ...le-context-compaction-reconciliation.zh.md | 14 ++-- .../src/session/session-execution-service.ts | 64 ++++++++++++++++--- .../tests/session-execution-service.test.ts | 42 +++++++++++- specs/session-goal-control.md | 6 +- specs/session-history-writes.md | 6 +- specs/session-history-writes.zh.md | 6 +- 7 files changed, 124 insertions(+), 32 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md index 507c04813..c0b83a855 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md +++ b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md @@ -31,9 +31,11 @@ verifies that current Session metadata assigns ownership to its own machine and for the Session document's initial remote state before drawing a conclusion from its history. It acquires the existing Session history rewrite barrier only for a second ownership/liveness check and the local history mutation. Releasing that barrier -explicitly enqueues a dispatch recheck, so a turn accepted while repair was running -cannot remain stranded. Remote write confirmation happens after the barrier is -released, and a failed or unavailable confirmation returns `unknown`, never durable +explicitly enqueues an ordinary dispatch recheck and resolves the execution service's +barrier waiters. Goal actions retain their process-local pending request while waiting, +so neither a user turn nor a Goal turn accepted during repair can remain stranded or be +misreported as a startup failure. Remote write confirmation happens after the barrier +is released, and a failed or unavailable confirmation returns `unknown`, never durable success. The reconciliation RPC uses the ordinary request lane because document sync and history writes are not fast control-plane work. @@ -65,8 +67,8 @@ more than the exact activity requested by the current view. ## Verification Behavioral coverage exercises exact-item mutation, active and indeterminate ownership -outcomes, cold/unsynced documents, write-confirmation failure, dispatch wakeup after a -rewrite barrier, owner-daemon generations, control-lane isolation, local capability -gating, and Loro Streams RPC dispatch. Shared schemas validate the request and -response shapes. No startup scan, storage migration, or end-to-end provider fixture -was added. +outcomes, cold/unsynced documents, write-confirmation failure, ordinary and Goal turn +dispatch after a rewrite barrier, owner-daemon generations, control-lane isolation, +local capability gating, and Loro Streams RPC dispatch. Shared schemas validate the +request and response shapes. No startup scan, storage migration, or end-to-end provider +fixture was added. diff --git a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md index 7dc1ac8ea..8f1b26fb4 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md @@ -25,9 +25,11 @@ daemon 不会触发其他写入 fallback。 请求携带 `sessionId`、`turnId` 和 `toolCallId`。目标 daemon 先验证当前 Session metadata 确实把所有权分配给本机,并等待 Session document 的初始远端状态完成后再根据 history 下结论。 现有 Session history rewrite barrier 只覆盖第二次 ownership/liveness 检查和本地 history 修改。 -barrier 释放后会显式 enqueue 一次 dispatch recheck,确保 repair 期间已接受的 turn 不会被搁置。 -远端写入确认在 barrier 外进行;确认失败或不可用时返回 `unknown`,不能报告持久化成功。 -reconciliation RPC 使用普通 request lane,因为 document sync 和 history write 不是快速控制面工作。 +barrier 释放后会显式 enqueue 一次普通 dispatch recheck,并解析 execution service 的 barrier +waiter。Goal action 会在等待期间保留其进程内 pending request,因此 repair 期间已接受的普通 turn +和 Goal turn 都不会被搁置或误报为启动失败。远端写入确认在 barrier 外进行;确认失败或不可用时 +返回 `unknown`,不能报告持久化成功。reconciliation RPC 使用普通 request lane,因为 document +sync 和 history write 不是快速控制面工作。 目标 turn 仍 active,或存在未归属工作导致结论不确定时,历史不变。当 daemon 能证明该 turn 已不是实时 owner 时,它只把指定且未完成的 `context_compaction` item 改成 `failed`;已有终态、 @@ -52,6 +54,6 @@ owner 能提供证据。本次变更不假定每一条历史未完成 compaction ## 验证 行为测试覆盖精确 item 修改、active 与 indeterminate 所有权结果、cold/unsynced document、 -写入确认失败、rewrite barrier 释放后的 dispatch 唤醒、owner-daemon generation、control lane -隔离、本地 capability gating 和 Loro Streams RPC 分发。共享 schema 会校验请求与响应 shape。 -本次没有加入启动扫描、存储迁移或端到端 provider fixture。 +写入确认失败、rewrite barrier 释放后的普通 turn 与 Goal turn dispatch、owner-daemon generation、 +control lane 隔离、本地 capability gating 和 Loro Streams RPC 分发。共享 schema 会校验请求与 +响应 shape。本次没有加入启动扫描、存储迁移或端到端 provider fixture。 diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 227fa4ed0..a59405bab 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -251,6 +251,8 @@ type SessionGoalTurnRequest = { userEmail: string; }; +type GoalTurnStartResult = 'claimed' | 'rewrite-barrier' | 'not-claimed'; + type TurnInvocation = { /** Causal input Turn for authorization and durable provenance. */ sourceTurnId: string; @@ -739,6 +741,7 @@ export class SessionExecutionService { private readonly turnRuntimeBySession = new Map(); private readonly rewriteBarrierSessions = new Set(); private readonly rewriteConflictLeaseSessions = new Set(); + private readonly rewriteBarrierReleaseWaiters = new Map void>>(); private readonly turnReleaseWaiters = new Map void>>>(); /** At most one goal action waits per session; a newer action replaces it. */ private readonly pendingGoalTurnBySession = new Map(); @@ -1147,9 +1150,38 @@ export class SessionExecutionService { } released = true; this.rewriteBarrierSessions.delete(sessionId); + this.resolveRewriteBarrierReleaseWaiters(sessionId); }; } + private async waitForSessionRewriteBarrierRelease(sessionId: SessionId): Promise { + if (!this.rewriteBarrierSessions.has(sessionId)) { + return; + } + await new Promise((resolve) => { + let waiters = this.rewriteBarrierReleaseWaiters.get(sessionId); + if (!waiters) { + waiters = new Set(); + this.rewriteBarrierReleaseWaiters.set(sessionId, waiters); + } + waiters.add(resolve); + if (!this.rewriteBarrierSessions.has(sessionId)) { + this.resolveRewriteBarrierReleaseWaiters(sessionId); + } + }); + } + + private resolveRewriteBarrierReleaseWaiters(sessionId: SessionId): void { + const waiters = this.rewriteBarrierReleaseWaiters.get(sessionId); + if (!waiters) { + return; + } + this.rewriteBarrierReleaseWaiters.delete(sessionId); + for (const resolve of waiters) { + resolve(); + } + } + /** * Claims a short operation that must not overlap a durable history rewrite: * visible-turn ownership, acknowledged steer transfer, stale repair, or queue promotion. @@ -1297,12 +1329,18 @@ export class SessionExecutionService { continue; } try { - const claimed = await this.startGoalTurn(pending); + const result = await this.startGoalTurn(pending); if (this.pendingGoalTurnBySession.get(sessionId) !== pending) continue; + if (result === 'rewrite-barrier') { + await this.waitForSessionRewriteBarrierRelease(sessionId); + continue; + } // Another dispatch may win while metadata is loading. Retain the // accepted request and wait for its owner instead of reporting success. - if (!claimed && this.getExecutionSnapshot(sessionId).hasActiveTurn) continue; - if (!claimed) throw new Error('Goal turn could not acquire session ownership'); + if (result === 'not-claimed' && this.getExecutionSnapshot(sessionId).hasActiveTurn) + continue; + if (result === 'not-claimed') + throw new Error('Goal turn could not acquire session ownership'); this.pendingGoalTurnBySession.delete(sessionId); // A claimed turn that never submitted its prompt already records its // startup/cancellation outcome through the ordinary turn lifecycle. @@ -1330,7 +1368,7 @@ export class SessionExecutionService { }); } - private async startGoalTurn(request: SessionGoalTurnRequest): Promise { + private async startGoalTurn(request: SessionGoalTurnRequest): Promise { const { sessionId, control } = request; const sessionDoc = await this.deps.workspaceDocument.getOrCreateSessionDoc(sessionId); const meta = await sessionDoc.getMetaState(); @@ -1345,7 +1383,7 @@ export class SessionExecutionService { } const resumeAcpSessionId = resolveDispatchAcpSessionId(meta); let claimed = false; - await this.continueSession( + const outcome = await this.continueSessionWithOutcome( { type: 'session/chat', sessionId, @@ -1385,7 +1423,10 @@ export class SessionExecutionService { }, } ); - return claimed; + if (claimed) { + return 'claimed'; + } + return outcome === 'rewrite-barrier' ? 'rewrite-barrier' : 'not-claimed'; } async steerSession(options: { @@ -3825,6 +3866,13 @@ export class SessionExecutionService { message: SessionChatRequestValidated, dispatchOptions?: SessionDispatchOptions ): Promise { + await this.continueSessionWithOutcome(message, dispatchOptions); + } + + private async continueSessionWithOutcome( + message: SessionChatRequestValidated, + dispatchOptions?: SessionDispatchOptions + ): Promise { const turn = await this.prepareContinueSessionTurn(message, dispatchOptions); if ( dispatchOptions?.dispatchSource !== 'delivery' && @@ -3834,7 +3882,7 @@ export class SessionExecutionService { userTurnId: message.userTurnId, })) ) { - return; + return 'cancelled-before-owner'; } const body = dispatchOptions?.onTurnClaimed ? (ctx: VisibleSessionTurnContext) => @@ -3851,7 +3899,7 @@ export class SessionExecutionService { ) ) : turn.body; - await this.runVisibleSessionTurn(turn.options, body); + return await this.runVisibleSessionTurn(turn.options, body); } private async prepareContinueSessionTurn( diff --git a/apps/cli/tests/session-execution-service.test.ts b/apps/cli/tests/session-execution-service.test.ts index 9e5efb924..e358bd43b 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -7211,6 +7211,46 @@ describe('SessionExecutionService goal control', () => { expect(service.getExecutionSnapshot(goalSessionId).hasActiveTurn).toBe(false); }); + it('retains an accepted goal action until a rewrite barrier releases', async () => { + const { service, submitted, completion, delivered, failures } = createGoalService({ + transport: 'promptMeta', + }); + const internals = service as unknown as { + pendingGoalTurnBySession: Map; + waitForSessionRewriteBarrierRelease: (sessionId: SessionId) => Promise; + }; + const waitingForBarrier = createDeferred(); + const originalWait = internals.waitForSessionRewriteBarrierRelease.bind(service); + vi.spyOn(internals, 'waitForSessionRewriteBarrierRelease').mockImplementation( + async (sessionId) => { + waitingForBarrier.resolve(); + await originalWait(sessionId); + } + ); + const releaseBarrier = service.tryAcquireSessionRewriteBarrier(goalSessionId); + expect(releaseBarrier).not.toBeNull(); + + try { + const response = await service.controlSessionGoal({ ...goalArgs, action: 'resume' }); + expect(response).toMatchObject({ accepted: true, disposition: 'queued' }); + await waitingForBarrier.promise; + expect(internals.pendingGoalTurnBySession.has(goalSessionId)).toBe(true); + expect(delivered).toEqual([]); + expect(failures).toEqual([]); + + releaseBarrier?.(); + await submitted.promise; + expect(delivered).toEqual([expect.objectContaining({ goalControl: { action: 'resume' } })]); + expect(failures).toEqual([]); + const released = service.waitForTurnRelease(goalSessionId, 'turn-1'); + completion.resolve(); + await released; + } finally { + releaseBarrier?.(); + completion.resolve(); + } + }); + it('retains an accepted goal across more than three competing turns', async () => { const { service, submitted, completion, delivered } = createGoalService({ transport: 'promptMeta', @@ -7262,7 +7302,7 @@ describe('SessionExecutionService goal control', () => { return originalMeta(); }; const internals = service as unknown as { - startGoalTurn: (request: unknown) => Promise; + startGoalTurn: (request: unknown) => Promise<'claimed' | 'rewrite-barrier' | 'not-claimed'>; }; const originalStart = internals.startGoalTurn.bind(service); const settled = createDeferred(); diff --git a/specs/session-goal-control.md b/specs/session-goal-control.md index 8e35e54ec..4779ec2ce 100644 --- a/specs/session-goal-control.md +++ b/specs/session-goal-control.md @@ -103,6 +103,6 @@ Executed validation: adapter tests for the prompt-metadata transport, CLI tests for transport selection and for queueing a resume behind a draining turn, and component tests for capability-driven command availability. Host regressions cover prompt-lifetime-independent acceptance, more than three competing turns, -supersession before provider submission, and visible startup failures. Agent-client -wire tests cover cold pause/clear and unchanged manual `/goal xxx` prompts. No live Codex goal -was exercised end to end. +supersession before provider submission, rewrite-barrier release, and visible startup +failures. Agent-client wire tests cover cold pause/clear and unchanged manual `/goal xxx` +prompts. No live Codex goal was exercised end to end. diff --git a/specs/session-history-writes.md b/specs/session-history-writes.md index 8646be73d..5337f60f2 100644 --- a/specs/session-history-writes.md +++ b/specs/session-history-writes.md @@ -54,9 +54,9 @@ That tolerance must not authorize creating new malformed items locally. or missing live evidence is insufficient. An offline, unsupported, non-owning, or indeterminate daemon leaves history unchanged. Repair must inspect a remotely synced Session document, hold the rewrite barrier only across the final live-state - check and local mutation, wake dispatch after release, and report `reconciled` only - after the write is confirmed. Non-terminal results remain retryable across later - history, connectivity, or daemon-generation evidence. + check and local mutation, wake both ordinary and Goal turn dispatch after release, + and report `reconciled` only after the write is confirmed. Non-terminal results + remain retryable across later history, connectivity, or daemon-generation evidence. - 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 014550d09..8a67e1bdd 100644 --- a/specs/session-history-writes.zh.md +++ b/specs/session-history-writes.zh.md @@ -37,9 +37,9 @@ Translation: current item,且必须先由实时执行所有权证明该 turn 已不活跃。Turn `finished`、持久化 Session status、经过时间、daemon 重启或缺少 live evidence 均不充分。Daemon 离线、不支持该能力、 不持有 Session 或无法确定时,历史保持不变。Repair 必须读取已完成远端同步的 Session - document;rewrite barrier 仅覆盖最终 live-state 检查和本地修改,释放后必须唤醒 dispatch; - 只有写入确认后才能返回 `reconciled`。非终态结果可在后续 history、连接或 daemon generation - 证据变化时重试。 + document;rewrite barrier 仅覆盖最终 live-state 检查和本地修改,释放后必须同时唤醒普通 + turn 与 Goal turn dispatch;只有写入确认后才能返回 `reconciled`。非终态结果可在后续 + history、连接或 daemon generation 证据变化时重试。 - 已接受的 steer 标记在写入和读取归一化后都必须保留;编辑重发不能把 steer 当作可独立重放的普通用户轮次。 From 3a9a062fc2039dba189772026184d92975745e91 Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:01:18 +0800 Subject: [PATCH 04/14] fix: retry compaction repair after activity ends Model: gpt-5 --- ...stale-context-compaction-reconciliation.md | 22 ++--- ...le-context-compaction-reconciliation.zh.md | 15 ++-- .../src/components/sessions/README.md | 1 + .../sessions/session-chat-interface.tsx | 48 +++------- ...ssion-context-compaction-reconciliation.ts | 71 +++++++++++++++ .../src/lib/session-context-compaction.ts | 10 ++- .../tests/session-context-compaction.test.ts | 87 ++++++++++++++++++- specs/session-history-writes.md | 3 +- specs/session-history-writes.zh.md | 3 +- 9 files changed, 198 insertions(+), 62 deletions(-) create mode 100644 packages/components/src/components/sessions/use-session-context-compaction-reconciliation.ts diff --git a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md index c0b83a855..e12f74573 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md +++ b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md @@ -23,8 +23,8 @@ termination. When the latest compaction is unresolved and its assistant turn is finished, the renderer makes a capability-gated reconciliation request. Only a `reconciled` result whose write was confirmed is permanently deduplicated. `active`, `unknown`, and `unchanged` may be retried after later history activity, an -offline-to-online transition, or a new daemon presence instance. Unsupported or -unreachable daemons do not create a write fallback. +offline-to-online transition, a stable Session activity transition, or a new daemon +presence instance. Unsupported or unreachable daemons do not create a write fallback. The request names `sessionId`, `turnId`, and `toolCallId`. The target daemon first verifies that current Session metadata assigns ownership to its own machine and waits @@ -34,10 +34,12 @@ ownership/liveness check and the local history mutation. Releasing that barrier explicitly enqueues an ordinary dispatch recheck and resolves the execution service's barrier waiters. Goal actions retain their process-local pending request while waiting, so neither a user turn nor a Goal turn accepted during repair can remain stranded or be -misreported as a startup failure. Remote write confirmation happens after the barrier -is released, and a failed or unavailable confirmation returns `unknown`, never durable -success. The reconciliation RPC uses the ordinary request lane because document sync -and history writes are not fast control-plane work. +misreported as a startup failure. A same-daemon Session activity transition from active +to inactive starts a new bounded reconciliation attempt; heartbeat-only presence updates +do not. Remote write confirmation happens after the barrier is released, and a failed or +unavailable confirmation returns `unknown`, never durable success. The reconciliation +RPC uses the ordinary request lane because document sync and history writes are not fast +control-plane work. The addressed turn remains unchanged when it is active or when unassigned work makes the result indeterminate. Once the daemon can prove the turn is not its live owner, @@ -68,7 +70,7 @@ more than the exact activity requested by the current view. Behavioral coverage exercises exact-item mutation, active and indeterminate ownership outcomes, cold/unsynced documents, write-confirmation failure, ordinary and Goal turn -dispatch after a rewrite barrier, owner-daemon generations, control-lane isolation, -local capability gating, and Loro Streams RPC dispatch. Shared schemas validate the -request and response shapes. No startup scan, storage migration, or end-to-end provider -fixture was added. +dispatch after a rewrite barrier, owner-daemon generations, same-daemon activity release, +control-lane isolation, local capability gating, and Loro Streams RPC dispatch. Shared +schemas validate the request and response shapes. No startup scan, storage migration, or +end-to-end provider fixture was added. diff --git a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md index 8f1b26fb4..31a3cd09d 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md @@ -19,17 +19,18 @@ Renderer 仍只读取持久化的 compaction 状态。它不会把 `SessionHisto 重启或 presence 缺失重新解释成 provider 已终止。最新 compaction 未完成且所属 assistant turn 已 finished 时,renderer 会发起经过 capability gate 的协调请求。只有写入已确认的 `reconciled` 结果会被永久去重;`active`、`unknown` 和 `unchanged` 可以在后续历史活动、 -offline-to-online 转换或新的 daemon presence instance 出现后重试。不支持该能力或不可访问的 -daemon 不会触发其他写入 fallback。 +offline-to-online 转换、稳定的 Session activity 状态切换或新的 daemon presence instance 出现后 +重试。不支持该能力或不可访问的 daemon 不会触发其他写入 fallback。 请求携带 `sessionId`、`turnId` 和 `toolCallId`。目标 daemon 先验证当前 Session metadata 确实把所有权分配给本机,并等待 Session document 的初始远端状态完成后再根据 history 下结论。 现有 Session history rewrite barrier 只覆盖第二次 ownership/liveness 检查和本地 history 修改。 barrier 释放后会显式 enqueue 一次普通 dispatch recheck,并解析 execution service 的 barrier waiter。Goal action 会在等待期间保留其进程内 pending request,因此 repair 期间已接受的普通 turn -和 Goal turn 都不会被搁置或误报为启动失败。远端写入确认在 barrier 外进行;确认失败或不可用时 -返回 `unknown`,不能报告持久化成功。reconciliation RPC 使用普通 request lane,因为 document -sync 和 history write 不是快速控制面工作。 +和 Goal turn 都不会被搁置或误报为启动失败。同一 daemon 的 Session activity 从 active 变为 +inactive 时会触发一次新的有界 reconciliation;单纯 heartbeat 更新不会。远端写入确认在 +barrier 外进行;确认失败或不可用时返回 `unknown`,不能报告持久化成功。reconciliation RPC +使用普通 request lane,因为 document sync 和 history write 不是快速控制面工作。 目标 turn 仍 active,或存在未归属工作导致结论不确定时,历史不变。当 daemon 能证明该 turn 已不是实时 owner 时,它只把指定且未完成的 `context_compaction` item 改成 `failed`;已有终态、 @@ -55,5 +56,5 @@ owner 能提供证据。本次变更不假定每一条历史未完成 compaction 行为测试覆盖精确 item 修改、active 与 indeterminate 所有权结果、cold/unsynced document、 写入确认失败、rewrite barrier 释放后的普通 turn 与 Goal turn dispatch、owner-daemon generation、 -control lane 隔离、本地 capability gating 和 Loro Streams RPC 分发。共享 schema 会校验请求与 -响应 shape。本次没有加入启动扫描、存储迁移或端到端 provider fixture。 +同 daemon activity 释放、control lane 隔离、本地 capability gating 和 Loro Streams RPC 分发。 +共享 schema 会校验请求与响应 shape。本次没有加入启动扫描、存储迁移或端到端 provider fixture。 diff --git a/packages/components/src/components/sessions/README.md b/packages/components/src/components/sessions/README.md index 3ef99ef1a..876c43231 100644 --- a/packages/components/src/components/sessions/README.md +++ b/packages/components/src/components/sessions/README.md @@ -27,6 +27,7 @@ What each file in this directory is responsible for. Binding rules live in | File | Responsibility | | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `session-chat-interface.tsx` | Conversation surface: stream, header variants, read receipts, launchers | +| `use-session-context-compaction-reconciliation.ts` | Bounded stale-compaction repair attempts across daemon and Session activity transitions | | [`chat-share-image-dialog.tsx`](chat-share-image-dialog.tsx) | Selected-message card styling, historical metadata, PNG export, and local image copying; [draft contract](../../../../../specs/chat-share-image.md) | | `draft-session-chat-interface.tsx` | Draft variant of the conversation surface | | `session-chat-input-area.tsx` | Composer: attachments, run-config footer, submit | diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index 748a9a938..d889caf61 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -58,8 +58,6 @@ import { matchesKeyboardEvent } from '@/lib/commands/key-matcher'; import { canStopAgentEnabled, findActiveSessionContextCompaction, - getContextCompactionReconciliationAttemptKey, - isDurableContextCompactionReconciliation, } from '@/lib/session-context-compaction'; import { hasFileTransfer, readDroppedTransfer } from '@/lib/file-drop'; import { resolveProgrammaticTurnAgentRole } from '@/lib/composer-agent-roles'; @@ -156,6 +154,7 @@ import { useMachinePresenceInstanceId, } from '@/hooks/use-machine-online-status'; import { useDelayedFlag } from '@/hooks/use-delayed-flag'; +import { useSessionContextCompactionReconciliation } from './use-session-context-compaction-reconciliation'; import { resolveSessionStatusStripState } from './session-status-strip'; import { isSyncingRoomSyncState } from '@/lib/room-sync-state'; import { @@ -2117,6 +2116,7 @@ export const SessionChatInterface = memo( const localHomeDir = useAtomValue(localHomeDirAtom); const liveSessionPresence = useAtomValue(sessionLivePresenceAtomFamily(session.id)); const liveSessionStatus = liveSessionPresence?.status ?? null; + const isSessionActive = liveSessionStatus != null; const isLocalSession = !!localMachineId && session.machineId === localMachineId; const [pendingRemoteHtmlFileName, setPendingRemoteHtmlFileName] = useState(null); const { @@ -2404,8 +2404,6 @@ export const SessionChatInterface = memo( const deferredSearchQuery = useDeferredValue(searchQuery); const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0); const lastSearchAnalyticsKeyRef = useRef(null); - const compactionReconciliationAttemptsRef = useRef(new Set()); - const runtime = useAtomValue(activeWorkspaceRuntimeAtom); const queuedMessageBehavior = useAtomValue(queuedMessageBehaviorAtom); const { @@ -2718,40 +2716,15 @@ export const SessionChatInterface = memo( const canReconcileContextCompaction = sessionMachineOnlineStatus === 'online' && machineSupportsContextCompactionReconciliationProtocol(sessionMachine); - useEffect(() => { - if (!runtime || !canReconcileContextCompaction || !activeContextCompaction?.turnFinished) { - return; - } - const attemptKey = getContextCompactionReconciliationAttemptKey({ - sessionId: session.id, - turnId: activeContextCompaction.turnId, - toolCallId: activeContextCompaction.toolCallId, - ownerInstanceId: sessionMachinePresenceInstanceId, - }); - if (compactionReconciliationAttemptsRef.current.has(attemptKey)) return; - compactionReconciliationAttemptsRef.current.add(attemptKey); - void runtime - .requestSessionContextCompactionReconciliation(session.machineId, { - sessionId: session.id, - turnId: activeContextCompaction.turnId, - toolCallId: activeContextCompaction.toolCallId, - }) - .then((result) => { - if (!isDurableContextCompactionReconciliation(result)) { - compactionReconciliationAttemptsRef.current.delete(attemptKey); - } - }) - .catch(() => { - compactionReconciliationAttemptsRef.current.delete(attemptKey); - }); - }, [ - activeContextCompaction, - canReconcileContextCompaction, + useSessionContextCompactionReconciliation({ + activeCompaction: activeContextCompaction, + canReconcile: canReconcileContextCompaction, + isSessionActive, + machineId: session.machineId, + ownerInstanceId: sessionMachinePresenceInstanceId, runtime, - session.id, - session.machineId, - sessionMachinePresenceInstanceId, - ]); + sessionId: session.id, + }); // Pending scheduled tasks (cron / wakeup) are derived on the fly from the // Cron*/ScheduleWakeup tool_call items already in history — nothing extra is // persisted. Serialize to a key so the input area only re-renders when the @@ -2827,7 +2800,6 @@ export const SessionChatInterface = memo( return () => clearTimeout(timer); }, [pendingGoalCommand]); - const isSessionActive = liveSessionStatus != null; // CLI-reported presence is the fact source for "working now". The only // frontend-derived state is the dispatched-but-not-started window, read // from the trailing pending user turn in history — never from meta diff --git a/packages/components/src/components/sessions/use-session-context-compaction-reconciliation.ts b/packages/components/src/components/sessions/use-session-context-compaction-reconciliation.ts new file mode 100644 index 000000000..4a5e2b46f --- /dev/null +++ b/packages/components/src/components/sessions/use-session-context-compaction-reconciliation.ts @@ -0,0 +1,71 @@ +import { useEffect, useRef } from 'react'; +import type { MachineId, SessionId } from '@lody/shared'; + +import type { WorkspaceRuntime } from '@/atoms/runtime'; +import { + type ActiveSessionContextCompaction, + getContextCompactionReconciliationAttemptKey, + isDurableContextCompactionReconciliation, +} from '@/lib/session-context-compaction'; + +type ReconciliationRuntime = Pick< + WorkspaceRuntime, + 'requestSessionContextCompactionReconciliation' +>; + +export const useSessionContextCompactionReconciliation = ({ + activeCompaction, + canReconcile, + isSessionActive, + machineId, + ownerInstanceId, + runtime, + sessionId, +}: { + activeCompaction: ActiveSessionContextCompaction | null; + canReconcile: boolean; + isSessionActive: boolean; + machineId: MachineId; + ownerInstanceId: string | null; + runtime: ReconciliationRuntime | null; + sessionId: SessionId; +}): void => { + const attemptsRef = useRef(new Set()); + + useEffect(() => { + if (!runtime || !canReconcile || !activeCompaction?.turnFinished) { + return; + } + const attemptKey = getContextCompactionReconciliationAttemptKey({ + sessionId, + turnId: activeCompaction.turnId, + toolCallId: activeCompaction.toolCallId, + ownerInstanceId, + isSessionActive, + }); + if (attemptsRef.current.has(attemptKey)) return; + attemptsRef.current.add(attemptKey); + void runtime + .requestSessionContextCompactionReconciliation(machineId, { + sessionId, + turnId: activeCompaction.turnId, + toolCallId: activeCompaction.toolCallId, + }) + .then((result) => { + if (!isDurableContextCompactionReconciliation(result)) { + attemptsRef.current.delete(attemptKey); + } + }) + .catch(() => { + attemptsRef.current.delete(attemptKey); + }); + }, [ + activeCompaction, + canReconcile, + isSessionActive, + machineId, + ownerInstanceId, + runtime, + sessionId, + ]); +}; diff --git a/packages/components/src/lib/session-context-compaction.ts b/packages/components/src/lib/session-context-compaction.ts index 33ef00aac..42bcd5755 100644 --- a/packages/components/src/lib/session-context-compaction.ts +++ b/packages/components/src/lib/session-context-compaction.ts @@ -12,7 +12,15 @@ export const getContextCompactionReconciliationAttemptKey = (args: { turnId: string; toolCallId: string; ownerInstanceId: string | null; -}): string => JSON.stringify([args.sessionId, args.turnId, args.toolCallId, args.ownerInstanceId]); + isSessionActive: boolean; +}): string => + JSON.stringify([ + args.sessionId, + args.turnId, + args.toolCallId, + args.ownerInstanceId, + args.isSessionActive, + ]); export const isDurableContextCompactionReconciliation = ( result: SessionContextCompactionReconcileResponse | null diff --git a/packages/components/tests/session-context-compaction.test.ts b/packages/components/tests/session-context-compaction.test.ts index ddeb4d95e..ab05b597f 100644 --- a/packages/components/tests/session-context-compaction.test.ts +++ b/packages/components/tests/session-context-compaction.test.ts @@ -1,5 +1,14 @@ -import { describe, expect, it } from 'vitest'; -import type { SessionHistory } from '@lody/shared'; +// @vitest-environment jsdom + +import { act, createElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { describe, expect, it, vi } from 'vitest'; +import type { + MachineId, + SessionContextCompactionReconcileResponse, + SessionHistory, + SessionId, +} from '@lody/shared'; import { canStopAgentEnabled, @@ -8,6 +17,7 @@ import { isDurableContextCompactionReconciliation, isSessionContextCompacting, } from '../src/lib/session-context-compaction'; +import { useSessionContextCompactionReconciliation } from '../src/components/sessions/use-session-context-compaction-reconciliation'; const historyWithStatus = ( status: 'pending' | 'in_progress' | 'completed' | 'failed', @@ -62,9 +72,30 @@ describe('isSessionContextCompacting', () => { toolCallId: 'context-compaction-1', }; expect( - getContextCompactionReconciliationAttemptKey({ ...base, ownerInstanceId: 'daemon-1' }) + getContextCompactionReconciliationAttemptKey({ + ...base, + ownerInstanceId: 'daemon-1', + isSessionActive: true, + }) ).not.toBe( - getContextCompactionReconciliationAttemptKey({ ...base, ownerInstanceId: 'daemon-2' }) + getContextCompactionReconciliationAttemptKey({ + ...base, + ownerInstanceId: 'daemon-2', + isSessionActive: true, + }) + ); + expect( + getContextCompactionReconciliationAttemptKey({ + ...base, + ownerInstanceId: 'daemon-1', + isSessionActive: true, + }) + ).not.toBe( + getContextCompactionReconciliationAttemptKey({ + ...base, + ownerInstanceId: 'daemon-1', + isSessionActive: false, + }) ); for (const outcome of ['active', 'unknown', 'unchanged'] as const) { expect( @@ -85,6 +116,54 @@ describe('isSessionContextCompacting', () => { }); }); +describe('context compaction reconciliation activity transitions', () => { + it('retries after the same daemon releases active Session presence', async () => { + const sessionId = 'session-1' as SessionId; + const machineId = 'machine-1' as MachineId; + const activeCompaction = findActiveSessionContextCompaction( + historyWithStatus('in_progress', true) + ); + const request = vi.fn().mockResolvedValue({ + type: 'session/reconcile-context-compaction_response', + sessionId, + turnId: 'assistant:turn-1', + toolCallId: 'context-compaction-1', + outcome: 'active', + } satisfies SessionContextCompactionReconcileResponse); + const runtime = { requestSessionContextCompactionReconciliation: request }; + + const Probe = ({ isSessionActive }: { isSessionActive: boolean }) => { + useSessionContextCompactionReconciliation({ + activeCompaction, + canReconcile: true, + isSessionActive, + machineId, + ownerInstanceId: 'daemon-1', + runtime, + sessionId, + }); + return null; + }; + + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement('div')); + try { + await act(async () => root.render(createElement(Probe, { isSessionActive: true }))); + expect(request).toHaveBeenCalledTimes(1); + + await act(async () => root.render(createElement(Probe, { isSessionActive: true }))); + expect(request).toHaveBeenCalledTimes(1); + + await act(async () => root.render(createElement(Probe, { isSessionActive: false }))); + expect(request).toHaveBeenCalledTimes(2); + expect(request.mock.calls[1]).toEqual(request.mock.calls[0]); + } finally { + act(() => root.unmount()); + delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; + } + }); +}); + describe('canStopAgentEnabled', () => { const base = { isContextCompacting: false, diff --git a/specs/session-history-writes.md b/specs/session-history-writes.md index 5337f60f2..2a269cce9 100644 --- a/specs/session-history-writes.md +++ b/specs/session-history-writes.md @@ -56,7 +56,8 @@ That tolerance must not authorize creating new malformed items locally. synced Session document, hold the rewrite barrier only across the final live-state check and local mutation, wake both ordinary and Goal turn dispatch after release, and report `reconciled` only after the write is confirmed. Non-terminal results - remain retryable across later history, connectivity, or daemon-generation evidence. + remain retryable across later history, connectivity, daemon-generation, or stable + Session activity-transition evidence; presence heartbeats must not become polling. - 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 8a67e1bdd..22060ba5b 100644 --- a/specs/session-history-writes.zh.md +++ b/specs/session-history-writes.zh.md @@ -39,7 +39,8 @@ Translation: current 不持有 Session 或无法确定时,历史保持不变。Repair 必须读取已完成远端同步的 Session document;rewrite barrier 仅覆盖最终 live-state 检查和本地修改,释放后必须同时唤醒普通 turn 与 Goal turn dispatch;只有写入确认后才能返回 `reconciled`。非终态结果可在后续 - history、连接或 daemon generation 证据变化时重试。 + history、连接、daemon generation 或稳定的 Session activity 状态切换证据变化时重试; + presence heartbeat 不得退化成轮询。 - 已接受的 steer 标记在写入和读取归一化后都必须保留;编辑重发不能把 steer 当作可独立重放的普通用户轮次。 From 3b2e51301f663fb1667721ae214c6b7e7db7bcc4 Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:37:51 +0800 Subject: [PATCH 05/14] fix: retry compaction repair after reconnect Model: gpt-5 --- ...stale-context-compaction-reconciliation.md | 6 ++- ...le-context-compaction-reconciliation.zh.md | 10 ++-- .../src/components/sessions/README.md | 2 +- .../sessions/session-chat-interface.tsx | 1 + ...ssion-context-compaction-reconciliation.ts | 4 ++ .../src/lib/session-context-compaction.ts | 2 + .../tests/session-context-compaction.test.ts | 54 ++++++++++++++++++- specs/session-history-writes.md | 5 +- specs/session-history-writes.zh.md | 4 +- 9 files changed, 76 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md index e12f74573..f280b6b82 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md +++ b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md @@ -35,8 +35,9 @@ explicitly enqueues an ordinary dispatch recheck and resolves the execution serv barrier waiters. Goal actions retain their process-local pending request while waiting, so neither a user turn nor a Goal turn accepted during repair can remain stranded or be misreported as a startup failure. A same-daemon Session activity transition from active -to inactive starts a new bounded reconciliation attempt; heartbeat-only presence updates -do not. Remote write confirmation happens after the barrier is released, and a failed or +to inactive or a browser connectivity transition from offline to online starts a new +bounded reconciliation attempt; heartbeat-only presence updates do not. Remote write +confirmation happens after the barrier is released, and a failed or unavailable confirmation returns `unknown`, never durable success. The reconciliation RPC uses the ordinary request lane because document sync and history writes are not fast control-plane work. @@ -71,6 +72,7 @@ more than the exact activity requested by the current view. Behavioral coverage exercises exact-item mutation, active and indeterminate ownership outcomes, cold/unsynced documents, write-confirmation failure, ordinary and Goal turn dispatch after a rewrite barrier, owner-daemon generations, same-daemon activity release, +local-first browser connectivity restoration while Machine presence remains online, control-lane isolation, local capability gating, and Loro Streams RPC dispatch. Shared schemas validate the request and response shapes. No startup scan, storage migration, or end-to-end provider fixture was added. diff --git a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md index 31a3cd09d..0826a15db 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md @@ -28,8 +28,9 @@ offline-to-online 转换、稳定的 Session activity 状态切换或新的 daem barrier 释放后会显式 enqueue 一次普通 dispatch recheck,并解析 execution service 的 barrier waiter。Goal action 会在等待期间保留其进程内 pending request,因此 repair 期间已接受的普通 turn 和 Goal turn 都不会被搁置或误报为启动失败。同一 daemon 的 Session activity 从 active 变为 -inactive 时会触发一次新的有界 reconciliation;单纯 heartbeat 更新不会。远端写入确认在 -barrier 外进行;确认失败或不可用时返回 `unknown`,不能报告持久化成功。reconciliation RPC +inactive,或 browser connectivity 从 offline 变为 online 时,会触发一次新的有界 +reconciliation;单纯 heartbeat 更新不会。远端写入确认在 barrier 外进行;确认失败或不可用时 +返回 `unknown`,不能报告持久化成功。reconciliation RPC 使用普通 request lane,因为 document sync 和 history write 不是快速控制面工作。 目标 turn 仍 active,或存在未归属工作导致结论不确定时,历史不变。当 daemon 能证明该 turn @@ -56,5 +57,6 @@ owner 能提供证据。本次变更不假定每一条历史未完成 compaction 行为测试覆盖精确 item 修改、active 与 indeterminate 所有权结果、cold/unsynced document、 写入确认失败、rewrite barrier 释放后的普通 turn 与 Goal turn dispatch、owner-daemon generation、 -同 daemon activity 释放、control lane 隔离、本地 capability gating 和 Loro Streams RPC 分发。 -共享 schema 会校验请求与响应 shape。本次没有加入启动扫描、存储迁移或端到端 provider fixture。 +同 daemon activity 释放、Machine presence 保持 online 时的 local-first browser connectivity +恢复、control lane 隔离、本地 capability gating 和 Loro Streams RPC 分发。共享 schema 会校验 +请求与响应 shape。本次没有加入启动扫描、存储迁移或端到端 provider fixture。 diff --git a/packages/components/src/components/sessions/README.md b/packages/components/src/components/sessions/README.md index 876c43231..fa77884c3 100644 --- a/packages/components/src/components/sessions/README.md +++ b/packages/components/src/components/sessions/README.md @@ -27,7 +27,7 @@ What each file in this directory is responsible for. Binding rules live in | File | Responsibility | | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `session-chat-interface.tsx` | Conversation surface: stream, header variants, read receipts, launchers | -| `use-session-context-compaction-reconciliation.ts` | Bounded stale-compaction repair attempts across daemon and Session activity transitions | +| `use-session-context-compaction-reconciliation.ts` | Bounded stale-compaction repair attempts across daemon, browser connectivity, and Session activity transitions | | [`chat-share-image-dialog.tsx`](chat-share-image-dialog.tsx) | Selected-message card styling, historical metadata, PNG export, and local image copying; [draft contract](../../../../../specs/chat-share-image.md) | | `draft-session-chat-interface.tsx` | Draft variant of the conversation surface | | `session-chat-input-area.tsx` | Composer: attachments, run-config footer, submit | diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index d889caf61..7a48dca75 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -2719,6 +2719,7 @@ export const SessionChatInterface = memo( useSessionContextCompactionReconciliation({ activeCompaction: activeContextCompaction, canReconcile: canReconcileContextCompaction, + isConnectivityOnline: browserOnline, isSessionActive, machineId: session.machineId, ownerInstanceId: sessionMachinePresenceInstanceId, diff --git a/packages/components/src/components/sessions/use-session-context-compaction-reconciliation.ts b/packages/components/src/components/sessions/use-session-context-compaction-reconciliation.ts index 4a5e2b46f..7d180b97a 100644 --- a/packages/components/src/components/sessions/use-session-context-compaction-reconciliation.ts +++ b/packages/components/src/components/sessions/use-session-context-compaction-reconciliation.ts @@ -16,6 +16,7 @@ type ReconciliationRuntime = Pick< export const useSessionContextCompactionReconciliation = ({ activeCompaction, canReconcile, + isConnectivityOnline, isSessionActive, machineId, ownerInstanceId, @@ -24,6 +25,7 @@ export const useSessionContextCompactionReconciliation = ({ }: { activeCompaction: ActiveSessionContextCompaction | null; canReconcile: boolean; + isConnectivityOnline: boolean; isSessionActive: boolean; machineId: MachineId; ownerInstanceId: string | null; @@ -42,6 +44,7 @@ export const useSessionContextCompactionReconciliation = ({ toolCallId: activeCompaction.toolCallId, ownerInstanceId, isSessionActive, + isConnectivityOnline, }); if (attemptsRef.current.has(attemptKey)) return; attemptsRef.current.add(attemptKey); @@ -62,6 +65,7 @@ export const useSessionContextCompactionReconciliation = ({ }, [ activeCompaction, canReconcile, + isConnectivityOnline, isSessionActive, machineId, ownerInstanceId, diff --git a/packages/components/src/lib/session-context-compaction.ts b/packages/components/src/lib/session-context-compaction.ts index 42bcd5755..dd7f93dc3 100644 --- a/packages/components/src/lib/session-context-compaction.ts +++ b/packages/components/src/lib/session-context-compaction.ts @@ -13,6 +13,7 @@ export const getContextCompactionReconciliationAttemptKey = (args: { toolCallId: string; ownerInstanceId: string | null; isSessionActive: boolean; + isConnectivityOnline: boolean; }): string => JSON.stringify([ args.sessionId, @@ -20,6 +21,7 @@ export const getContextCompactionReconciliationAttemptKey = (args: { args.toolCallId, args.ownerInstanceId, args.isSessionActive, + args.isConnectivityOnline, ]); export const isDurableContextCompactionReconciliation = ( diff --git a/packages/components/tests/session-context-compaction.test.ts b/packages/components/tests/session-context-compaction.test.ts index ab05b597f..05408aafa 100644 --- a/packages/components/tests/session-context-compaction.test.ts +++ b/packages/components/tests/session-context-compaction.test.ts @@ -76,12 +76,14 @@ describe('isSessionContextCompacting', () => { ...base, ownerInstanceId: 'daemon-1', isSessionActive: true, + isConnectivityOnline: true, }) ).not.toBe( getContextCompactionReconciliationAttemptKey({ ...base, ownerInstanceId: 'daemon-2', isSessionActive: true, + isConnectivityOnline: true, }) ); expect( @@ -89,12 +91,14 @@ describe('isSessionContextCompacting', () => { ...base, ownerInstanceId: 'daemon-1', isSessionActive: true, + isConnectivityOnline: true, }) ).not.toBe( getContextCompactionReconciliationAttemptKey({ ...base, ownerInstanceId: 'daemon-1', isSessionActive: false, + isConnectivityOnline: true, }) ); for (const outcome of ['active', 'unknown', 'unchanged'] as const) { @@ -116,7 +120,7 @@ describe('isSessionContextCompacting', () => { }); }); -describe('context compaction reconciliation activity transitions', () => { +describe('context compaction reconciliation retry transitions', () => { it('retries after the same daemon releases active Session presence', async () => { const sessionId = 'session-1' as SessionId; const machineId = 'machine-1' as MachineId; @@ -136,6 +140,7 @@ describe('context compaction reconciliation activity transitions', () => { useSessionContextCompactionReconciliation({ activeCompaction, canReconcile: true, + isConnectivityOnline: true, isSessionActive, machineId, ownerInstanceId: 'daemon-1', @@ -162,6 +167,53 @@ describe('context compaction reconciliation activity transitions', () => { delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; } }); + + it('retries after browser connectivity returns while the local machine stays online', async () => { + const sessionId = 'session-1' as SessionId; + const machineId = 'machine-1' as MachineId; + const activeCompaction = findActiveSessionContextCompaction( + historyWithStatus('in_progress', true) + ); + const request = vi.fn().mockResolvedValue({ + type: 'session/reconcile-context-compaction_response', + sessionId, + turnId: 'assistant:turn-1', + toolCallId: 'context-compaction-1', + outcome: 'unknown', + } satisfies SessionContextCompactionReconcileResponse); + const runtime = { requestSessionContextCompactionReconciliation: request }; + + const Probe = ({ isConnectivityOnline }: { isConnectivityOnline: boolean }) => { + useSessionContextCompactionReconciliation({ + activeCompaction, + canReconcile: true, + isConnectivityOnline, + isSessionActive: false, + machineId, + ownerInstanceId: 'daemon-1', + runtime, + sessionId, + }); + return null; + }; + + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement('div')); + try { + await act(async () => root.render(createElement(Probe, { isConnectivityOnline: false }))); + expect(request).toHaveBeenCalledTimes(1); + + await act(async () => root.render(createElement(Probe, { isConnectivityOnline: false }))); + expect(request).toHaveBeenCalledTimes(1); + + await act(async () => root.render(createElement(Probe, { isConnectivityOnline: true }))); + expect(request).toHaveBeenCalledTimes(2); + expect(request.mock.calls[1]).toEqual(request.mock.calls[0]); + } finally { + act(() => root.unmount()); + delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; + } + }); }); describe('canStopAgentEnabled', () => { diff --git a/specs/session-history-writes.md b/specs/session-history-writes.md index 2a269cce9..805d5c90f 100644 --- a/specs/session-history-writes.md +++ b/specs/session-history-writes.md @@ -56,8 +56,9 @@ That tolerance must not authorize creating new malformed items locally. synced Session document, hold the rewrite barrier only across the final live-state check and local mutation, wake both ordinary and Goal turn dispatch after release, and report `reconciled` only after the write is confirmed. Non-terminal results - remain retryable across later history, connectivity, daemon-generation, or stable - Session activity-transition evidence; presence heartbeats must not become polling. + remain retryable across later history, browser connectivity restoration even while + Machine presence stays online, daemon-generation, or stable Session + activity-transition evidence; presence heartbeats must not become polling. - 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 22060ba5b..520e501c0 100644 --- a/specs/session-history-writes.zh.md +++ b/specs/session-history-writes.zh.md @@ -39,8 +39,8 @@ Translation: current 不持有 Session 或无法确定时,历史保持不变。Repair 必须读取已完成远端同步的 Session document;rewrite barrier 仅覆盖最终 live-state 检查和本地修改,释放后必须同时唤醒普通 turn 与 Goal turn dispatch;只有写入确认后才能返回 `reconciled`。非终态结果可在后续 - history、连接、daemon generation 或稳定的 Session activity 状态切换证据变化时重试; - presence heartbeat 不得退化成轮询。 + history、browser connectivity 恢复(即使 Machine presence 始终 online)、daemon generation + 或稳定的 Session activity 状态切换证据变化时重试;presence heartbeat 不得退化成轮询。 - 已接受的 steer 标记在写入和读取归一化后都必须保留;编辑重发不能把 steer 当作可独立重放的普通用户轮次。 From 5faa4d9f2785c7af690a673c0303925507071b8d Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:02:08 +0800 Subject: [PATCH 06/14] fix: simplify stale compaction repair Model: gpt-5 --- .agents/docs/sessions-live-status.md | 18 ++-- ...stale-context-compaction-reconciliation.md | 54 ++++++------ ...le-context-compaction-reconciliation.zh.md | 40 +++++---- apps/cli/src/lib/message-handler.ts | 86 ++++++------------- .../src/session/session-execution-service.ts | 66 ++------------ .../message-handler-acp-batching.test.ts | 76 +++++++++++----- .../tests/session-dispatch-watcher.test.ts | 72 ---------------- .../tests/session-execution-service.test.ts | 42 +-------- .../src/components/sessions/README.md | 2 +- ...ssion-context-compaction-reconciliation.ts | 39 ++++----- .../src/lib/session-context-compaction.ts | 25 +----- .../tests/session-context-compaction.test.ts | 78 ++++------------- packages/loro-streams-rpc/AGENTS.md | 4 +- packages/loro-streams-rpc/src/rpc.ts | 2 +- .../tests/machine-rpc-server.test.ts | 2 +- packages/shared/src/message-schemas.ts | 3 +- specs/session-goal-control.md | 6 +- specs/session-history-writes.md | 17 ++-- specs/session-history-writes.zh.md | 13 +-- 19 files changed, 209 insertions(+), 436 deletions(-) diff --git a/.agents/docs/sessions-live-status.md b/.agents/docs/sessions-live-status.md index 69e5f4665..b40a23fda 100644 --- a/.agents/docs/sessions-live-status.md +++ b/.agents/docs/sessions-live-status.md @@ -53,11 +53,13 @@ null`: browser offline, machine removed or offline) — the chip owns that story state either. - Context compaction is different from live working status: its spinner follows the latest durable `context_compaction` tool-call status. When that item is still - `pending` or `in_progress` inside a finished assistant turn, opening the Session - asks a capability-compatible owner daemon to reconcile the exact turn/tool ids. - The renderer does not convert `finished` into a provider terminal signal. The - daemon writes `failed` only when its live execution state proves the addressed - turn no longer owns work; an unsupported, offline, non-owner, active, or - indeterminate daemon leaves the durable item unchanged. Active, ambiguous, - unchanged, and unreachable attempts remain retryable after history activity, - reconnect, or daemon replacement; only a remotely confirmed repair is terminal. + `pending` or `in_progress` inside a finished assistant turn, an inactive Session + with ready connectivity asks a capability-compatible owner daemon to reconcile the + exact turn/tool ids. The renderer does not convert `finished` into a provider + terminal signal. The daemon writes `failed` only after its live execution state + shows no Session work, and it updates only the named history entry. Unsupported, + offline, non-owner, active, indeterminate, mismatched, and unreachable cases return + `retry` without changing the durable item. The renderer attempts each candidate, + daemon generation, and connectivity evidence once; leaving eligibility clears that + evidence so a later inactive/reconnected transition can retry. Presence heartbeats + never become polling, and only a remotely confirmed repair is terminal. diff --git a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md index f280b6b82..50c3a2da8 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md +++ b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md @@ -19,27 +19,29 @@ unchanged. The renderer remains a reader of durable compaction status. It does not reinterpret `SessionHistory.finished`, a timeout, a restart, or missing presence as provider -termination. When the latest compaction is unresolved and its assistant turn is -finished, the renderer makes a capability-gated reconciliation request. Only a -`reconciled` result whose write was confirmed is permanently deduplicated. `active`, -`unknown`, and `unchanged` may be retried after later history activity, an -offline-to-online transition, a stable Session activity transition, or a new daemon -presence instance. Unsupported or unreachable daemons do not create a write fallback. +termination. When the latest compaction is unresolved, its assistant turn is +finished, the Session is inactive, browser connectivity is ready, and the owner +daemon advertises the capability, the renderer requests reconciliation. A single +`lastAttemptEvidenceRef` bounds requests by the exact candidate, owner-daemon +instance, and connectivity state. Leaving eligibility clears that evidence, so a +later inactive or reconnected transition can try again. RPC results do not drive a +parallel renderer state machine, and heartbeat-only presence updates do not poll. The request names `sessionId`, `turnId`, and `toolCallId`. The target daemon first -verifies that current Session metadata assigns ownership to its own machine and waits -for the Session document's initial remote state before drawing a conclusion from its -history. It acquires the existing Session history rewrite barrier only for a second -ownership/liveness check and the local history mutation. Releasing that barrier -explicitly enqueues an ordinary dispatch recheck and resolves the execution service's -barrier waiters. Goal actions retain their process-local pending request while waiting, -so neither a user turn nor a Goal turn accepted during repair can remain stranded or be -misreported as a startup failure. A same-daemon Session activity transition from active -to inactive or a browser connectivity transition from offline to online starts a new -bounded reconciliation attempt; heartbeat-only presence updates do not. Remote write -confirmation happens after the barrier is released, and a failed or -unavailable confirmation returns `unknown`, never durable success. The reconciliation -RPC uses the ordinary request lane because document sync and history writes are not fast +verifies that current Session metadata assigns ownership to its own machine, joins the +Session document, and waits for its remote state before drawing a conclusion. It then +rejects any active turn, pending create, automation, active Session presence, or +pending dispatch, and verifies ownership again before writing. The mutation uses +`SessionDocument.updateHistory(..., { onlyEntryId: turnId })`, so a new turn that +starts after the liveness check has a different entry identity and remains untouched. +Reconciliation therefore does not acquire the Session history rewrite barrier and +does not need barrier-release wakeups for ordinary or Goal dispatch. + +The daemon returns only `reconciled` or `retry`. It reports `reconciled` only when the +named unresolved item changed to `failed` and the remote write was confirmed. A +non-owner, unsynced document, live work, mismatch, already-settled item, failed write +confirmation, or handler error returns `retry`. The reconciliation RPC uses the +ordinary request lane because document sync and history writes are not fast control-plane work. The addressed turn remains unchanged when it is active or when unassigned work makes @@ -70,9 +72,11 @@ more than the exact activity requested by the current view. ## Verification Behavioral coverage exercises exact-item mutation, active and indeterminate ownership -outcomes, cold/unsynced documents, write-confirmation failure, ordinary and Goal turn -dispatch after a rewrite barrier, owner-daemon generations, same-daemon activity release, -local-first browser connectivity restoration while Machine presence remains online, -control-lane isolation, local capability gating, and Loro Streams RPC dispatch. Shared -schemas validate the request and response shapes. No startup scan, storage migration, or -end-to-end provider fixture was added. +outcomes, cold/unsynced documents, write-confirmation failure, and a turn that starts +between the final liveness check and the targeted history mutation. Renderer coverage +exercises owner-daemon generations, inactive eligibility, local-first browser +connectivity restoration while Machine presence remains online, and bounded attempts +for unchanged evidence. RPC coverage exercises ordinary-lane isolation, local +capability gating, both local and Loro transports, and shared request/response schemas. +No startup scan, storage migration, rewrite-barrier compensation, or end-to-end +provider fixture was added. diff --git a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md index 0826a15db..213275953 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md @@ -16,22 +16,25 @@ daemon 协调精确的 turn 与 tool-call id。只有实时执行所有权证明 ## 决策 Renderer 仍只读取持久化的 compaction 状态。它不会把 `SessionHistory.finished`、超时、 -重启或 presence 缺失重新解释成 provider 已终止。最新 compaction 未完成且所属 assistant -turn 已 finished 时,renderer 会发起经过 capability gate 的协调请求。只有写入已确认的 -`reconciled` 结果会被永久去重;`active`、`unknown` 和 `unchanged` 可以在后续历史活动、 -offline-to-online 转换、稳定的 Session activity 状态切换或新的 daemon presence instance 出现后 -重试。不支持该能力或不可访问的 daemon 不会触发其他写入 fallback。 +重启或 presence 缺失重新解释成 provider 已终止。仅当最新 compaction 未完成、所属 +assistant turn 已 finished、Session inactive、browser connectivity ready,且 owner daemon +声明支持该能力时,renderer 才请求 reconciliation。一个 +`lastAttemptEvidenceRef` 按 exact candidate、owner-daemon instance 和 connectivity state +限定请求。离开 eligibility 会清除该 evidence,因此后续 inactive 或重连转换可以 +再试。RPC 结果不驱动另一套 renderer state machine,单纯 heartbeat 更新也不会退化成轮询。 请求携带 `sessionId`、`turnId` 和 `toolCallId`。目标 daemon 先验证当前 Session metadata -确实把所有权分配给本机,并等待 Session document 的初始远端状态完成后再根据 history 下结论。 -现有 Session history rewrite barrier 只覆盖第二次 ownership/liveness 检查和本地 history 修改。 -barrier 释放后会显式 enqueue 一次普通 dispatch recheck,并解析 execution service 的 barrier -waiter。Goal action 会在等待期间保留其进程内 pending request,因此 repair 期间已接受的普通 turn -和 Goal turn 都不会被搁置或误报为启动失败。同一 daemon 的 Session activity 从 active 变为 -inactive,或 browser connectivity 从 offline 变为 online 时,会触发一次新的有界 -reconciliation;单纯 heartbeat 更新不会。远端写入确认在 barrier 外进行;确认失败或不可用时 -返回 `unknown`,不能报告持久化成功。reconciliation RPC -使用普通 request lane,因为 document sync 和 history write 不是快速控制面工作。 +确实把所有权分配给本机,加入 Session document,并等待其远端状态完成。然后它会拒绝 +任何 active turn、pending create、automation、active Session presence 或 pending dispatch,并在写入前 +再次确认 owner。修改使用 `SessionDocument.updateHistory(..., { onlyEntryId: turnId })`, +因此在 liveness 检查后启动的新 turn 具有不同 entry identity,不会被修改。Reconciliation +不再获取 Session history rewrite barrier,也不需要为普通 dispatch 或 Goal dispatch 增加 +barrier-release wakeup。 + +Daemon 只返回 `reconciled` 或 `retry`。只有指定的未完成 item 变为 `failed`,且远端 +写入已确认时,才返回 `reconciled`。非 owner、document 未同步、存在 live work、不匹配、 +已终态 item、写入确认失败或 handler error 都返回 `retry`。Reconciliation RPC 使用普通 +request lane,因为 document sync 和 history write 不是快速控制面工作。 目标 turn 仍 active,或存在未归属工作导致结论不确定时,历史不变。当 daemon 能证明该 turn 已不是实时 owner 时,它只把指定且未完成的 `context_compaction` item 改成 `failed`;已有终态、 @@ -56,7 +59,8 @@ owner 能提供证据。本次变更不假定每一条历史未完成 compaction ## 验证 行为测试覆盖精确 item 修改、active 与 indeterminate 所有权结果、cold/unsynced document、 -写入确认失败、rewrite barrier 释放后的普通 turn 与 Goal turn dispatch、owner-daemon generation、 -同 daemon activity 释放、Machine presence 保持 online 时的 local-first browser connectivity -恢复、control lane 隔离、本地 capability gating 和 Loro Streams RPC 分发。共享 schema 会校验 -请求与响应 shape。本次没有加入启动扫描、存储迁移或端到端 provider fixture。 +写入确认失败,以及最终 liveness 检查与 targeted history mutation 之间启动的新 turn。 +Renderer 测试覆盖 owner-daemon generation、inactive eligibility、Machine presence 保持 online 时的 +local-first browser connectivity 恢复,以及 evidence 未变时的有界尝试。RPC 测试覆盖普通 lane +隔离、本地 capability gating、local 与 Loro transport,以及共享 request/response schema。本次没有 +加入启动扫描、存储迁移、rewrite-barrier compensation 或端到端 provider fixture。 diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index 780faa368..d35c90c79 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -6790,7 +6790,7 @@ export class MessageHandler { }): Promise { const response = ( outcome: SessionContextCompactionReconcileResponse['outcome'], - options: { activeTurnId?: string; error?: string } = {} + options: { error?: string } = {} ): SessionContextCompactionReconcileResponse => ({ type: 'session/reconcile-context-compaction_response', ...args, @@ -6809,88 +6809,52 @@ export class MessageHandler { : undefined; return meta?.machineId === this.machineId; }; - const inspectLiveWork = (): { - activeTurnId?: string; - outcome: SessionContextCompactionReconcileResponse | null; - } => { + const hasLiveSessionWork = (): boolean => { const execution = this.executionService.getExecutionSnapshot(args.sessionId); - if (execution.activeTurnId === args.turnId) { - return { - activeTurnId: execution.activeTurnId, - outcome: response('active', { activeTurnId: execution.activeTurnId }), - }; - } - if ( - !execution.activeTurnId && - (execution.hasActiveTurn || - execution.hasBlockingPendingCreate || - execution.hasActiveAutomation || - this.sessionActivePresence.has(args.sessionId) || - this.sessionDispatchWatcher.hasPendingDispatch(args.sessionId)) - ) { - return { - outcome: response('unknown', { - error: 'The daemon still has unassigned Session work.', - }), - }; - } - return { - ...(execution.activeTurnId ? { activeTurnId: execution.activeTurnId } : {}), - outcome: null, - }; + return ( + execution.hasActiveTurn || + execution.hasBlockingPendingCreate || + execution.hasActiveAutomation || + this.sessionActivePresence.has(args.sessionId) || + this.sessionDispatchWatcher.hasPendingDispatch(args.sessionId) + ); }; if (!(await verifyOwnership())) { - return response('unknown', { error: 'The target daemon does not own this Session.' }); - } - const initialLiveWork = inspectLiveWork(); - if (initialLiveWork.outcome) { - return initialLiveWork.outcome; + return response('retry', { error: 'The target daemon does not own this Session.' }); } const sessionDoc = await this.workspaceDocument.getOrCreateSessionDoc(args.sessionId); await sessionDoc.ensureDocRoomJoined(); if (!(await sessionDoc.waitUntilSynced())) { - return response('unknown', { error: 'Session history is not synced with its owner.' }); + return response('retry', { error: 'Session history is not synced with its owner.' }); } - const releaseBarrier = this.executionService.tryAcquireSessionRewriteBarrier(args.sessionId); - if (!releaseBarrier) { - return response('unknown', { error: 'Session ownership is changing.' }); + if (hasLiveSessionWork()) { + return response('retry', { error: 'The daemon still has live Session work.' }); + } + + if (!(await verifyOwnership())) { + return response('retry', { error: 'The target daemon no longer owns this Session.' }); } let reconciled = false; - let observedActiveTurnId: string | undefined; - try { - if (!(await verifyOwnership())) { - return response('unknown', { error: 'The target daemon no longer owns this Session.' }); - } - const liveWork = inspectLiveWork(); - if (liveWork.outcome) { - return liveWork.outcome; - } - observedActiveTurnId = liveWork.activeTurnId; - await sessionDoc.updateHistory((history) => { + await sessionDoc.updateHistory( + (history) => { reconciled = settleContextCompactionItemAsFailed(history, args); return history; - }); - } finally { - releaseBarrier(); - // A turn offer or History update may have been checked while the barrier - // was held. Queue a follow-up after release so ACKed work cannot be stranded. - void this.sessionDispatchWatcher.enqueueSessionCheck(args.sessionId); - } + }, + { onlyEntryId: args.turnId } + ); if (reconciled && !(await sessionDoc.waitUntilSynced())) { - return response('unknown', { + return response('retry', { error: 'The reconciled Session history was not confirmed by its owner.', }); } - return response(reconciled ? 'reconciled' : 'unchanged', { - ...(observedActiveTurnId ? { activeTurnId: observedActiveTurnId } : {}), - }); + return response(reconciled ? 'reconciled' : 'retry'); } catch (error) { - return response('unknown', { error: formatErrorMessage(error) }); + return response('retry', { error: formatErrorMessage(error) }); } } diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index a59405bab..59bb88ad1 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -251,8 +251,6 @@ type SessionGoalTurnRequest = { userEmail: string; }; -type GoalTurnStartResult = 'claimed' | 'rewrite-barrier' | 'not-claimed'; - type TurnInvocation = { /** Causal input Turn for authorization and durable provenance. */ sourceTurnId: string; @@ -741,7 +739,6 @@ export class SessionExecutionService { private readonly turnRuntimeBySession = new Map(); private readonly rewriteBarrierSessions = new Set(); private readonly rewriteConflictLeaseSessions = new Set(); - private readonly rewriteBarrierReleaseWaiters = new Map void>>(); private readonly turnReleaseWaiters = new Map void>>>(); /** At most one goal action waits per session; a newer action replaces it. */ private readonly pendingGoalTurnBySession = new Map(); @@ -1150,41 +1147,12 @@ export class SessionExecutionService { } released = true; this.rewriteBarrierSessions.delete(sessionId); - this.resolveRewriteBarrierReleaseWaiters(sessionId); }; } - private async waitForSessionRewriteBarrierRelease(sessionId: SessionId): Promise { - if (!this.rewriteBarrierSessions.has(sessionId)) { - return; - } - await new Promise((resolve) => { - let waiters = this.rewriteBarrierReleaseWaiters.get(sessionId); - if (!waiters) { - waiters = new Set(); - this.rewriteBarrierReleaseWaiters.set(sessionId, waiters); - } - waiters.add(resolve); - if (!this.rewriteBarrierSessions.has(sessionId)) { - this.resolveRewriteBarrierReleaseWaiters(sessionId); - } - }); - } - - private resolveRewriteBarrierReleaseWaiters(sessionId: SessionId): void { - const waiters = this.rewriteBarrierReleaseWaiters.get(sessionId); - if (!waiters) { - return; - } - this.rewriteBarrierReleaseWaiters.delete(sessionId); - for (const resolve of waiters) { - resolve(); - } - } - /** * Claims a short operation that must not overlap a durable history rewrite: - * visible-turn ownership, acknowledged steer transfer, stale repair, or queue promotion. + * visible-turn ownership, acknowledged steer transfer, or queue promotion. */ tryAcquireSessionRewriteConflictLease(sessionId: SessionId): (() => void) | null { if ( @@ -1329,18 +1297,12 @@ export class SessionExecutionService { continue; } try { - const result = await this.startGoalTurn(pending); + const claimed = await this.startGoalTurn(pending); if (this.pendingGoalTurnBySession.get(sessionId) !== pending) continue; - if (result === 'rewrite-barrier') { - await this.waitForSessionRewriteBarrierRelease(sessionId); - continue; - } // Another dispatch may win while metadata is loading. Retain the // accepted request and wait for its owner instead of reporting success. - if (result === 'not-claimed' && this.getExecutionSnapshot(sessionId).hasActiveTurn) - continue; - if (result === 'not-claimed') - throw new Error('Goal turn could not acquire session ownership'); + if (!claimed && this.getExecutionSnapshot(sessionId).hasActiveTurn) continue; + if (!claimed) throw new Error('Goal turn could not acquire session ownership'); this.pendingGoalTurnBySession.delete(sessionId); // A claimed turn that never submitted its prompt already records its // startup/cancellation outcome through the ordinary turn lifecycle. @@ -1368,7 +1330,7 @@ export class SessionExecutionService { }); } - private async startGoalTurn(request: SessionGoalTurnRequest): Promise { + private async startGoalTurn(request: SessionGoalTurnRequest): Promise { const { sessionId, control } = request; const sessionDoc = await this.deps.workspaceDocument.getOrCreateSessionDoc(sessionId); const meta = await sessionDoc.getMetaState(); @@ -1383,7 +1345,7 @@ export class SessionExecutionService { } const resumeAcpSessionId = resolveDispatchAcpSessionId(meta); let claimed = false; - const outcome = await this.continueSessionWithOutcome( + await this.continueSession( { type: 'session/chat', sessionId, @@ -1423,10 +1385,7 @@ export class SessionExecutionService { }, } ); - if (claimed) { - return 'claimed'; - } - return outcome === 'rewrite-barrier' ? 'rewrite-barrier' : 'not-claimed'; + return claimed; } async steerSession(options: { @@ -3866,13 +3825,6 @@ export class SessionExecutionService { message: SessionChatRequestValidated, dispatchOptions?: SessionDispatchOptions ): Promise { - await this.continueSessionWithOutcome(message, dispatchOptions); - } - - private async continueSessionWithOutcome( - message: SessionChatRequestValidated, - dispatchOptions?: SessionDispatchOptions - ): Promise { const turn = await this.prepareContinueSessionTurn(message, dispatchOptions); if ( dispatchOptions?.dispatchSource !== 'delivery' && @@ -3882,7 +3834,7 @@ export class SessionExecutionService { userTurnId: message.userTurnId, })) ) { - return 'cancelled-before-owner'; + return; } const body = dispatchOptions?.onTurnClaimed ? (ctx: VisibleSessionTurnContext) => @@ -3899,7 +3851,7 @@ export class SessionExecutionService { ) ) : turn.body; - return await this.runVisibleSessionTurn(turn.options, body); + await this.runVisibleSessionTurn(turn.options, body); } private async prepareContinueSessionTurn( diff --git a/apps/cli/tests/message-handler-acp-batching.test.ts b/apps/cli/tests/message-handler-acp-batching.test.ts index 061e8ee63..a0a797682 100644 --- a/apps/cli/tests/message-handler-acp-batching.test.ts +++ b/apps/cli/tests/message-handler-acp-batching.test.ts @@ -482,7 +482,7 @@ describe('MessageHandler ACP batching', () => { sessionId: SessionId; turnId: string; toolCallId: string; - }): Promise<{ outcome: string }>; + }): Promise<{ outcome: string; error?: string }>; executionService: { getExecutionSnapshot(sessionId: SessionId): { activeTurnId?: string; @@ -494,9 +494,7 @@ describe('MessageHandler ACP batching', () => { }; }; sessionActivePresence: { has(sessionId: SessionId): boolean }; - sessionDispatchWatcher: { - enqueueSessionCheck(sessionId: SessionId): Promise; - }; + sessionDispatchWatcher: { hasPendingDispatch(sessionId: SessionId): boolean }; }; const findCompaction = (history: SessionHistoryInput[], toolCallId = 'compact-stale') => history @@ -536,7 +534,7 @@ describe('MessageHandler ACP batching', () => { turnId, toolCallId: 'compact-stale', }) - ).resolves.toMatchObject({ outcome: 'unknown' }); + ).resolves.toMatchObject({ outcome: 'retry' }); expect(findCompaction(await doc.getHistory())).toMatchObject({ status: 'in_progress' }); const getExecutionSnapshot = vi.spyOn(host.executionService, 'getExecutionSnapshot'); @@ -554,7 +552,7 @@ describe('MessageHandler ACP batching', () => { turnId, toolCallId: 'compact-stale', }) - ).resolves.toMatchObject({ outcome: 'active' }); + ).resolves.toMatchObject({ outcome: 'retry' }); expect(findCompaction(await doc.getHistory())).toMatchObject({ status: 'in_progress' }); getExecutionSnapshot.mockReturnValue({ @@ -574,44 +572,80 @@ describe('MessageHandler ACP batching', () => { turnId, toolCallId: 'compact-stale', }) - ).resolves.toMatchObject({ outcome: 'unknown' }); + ).resolves.toMatchObject({ outcome: 'retry' }); expect(findCompaction(await doc.getHistory())).toMatchObject({ status: 'in_progress' }); const ensureDocRoomJoined = vi.spyOn(doc, 'ensureDocRoomJoined').mockResolvedValue(undefined); const waitUntilSynced = vi.spyOn(doc, 'waitUntilSynced').mockResolvedValueOnce(false); + const originalUpdateHistory = doc.updateHistory.bind(doc); const updateHistory = vi.spyOn(doc, 'updateHistory'); - const enqueueSessionCheck = vi - .spyOn(host.sessionDispatchWatcher, 'enqueueSessionCheck') - .mockResolvedValue(undefined); await expect( host.reconcileSessionContextCompaction({ sessionId, turnId, toolCallId: 'compact-stale', }) - ).resolves.toMatchObject({ outcome: 'unknown' }); + ).resolves.toMatchObject({ outcome: 'retry' }); expect(ensureDocRoomJoined).toHaveBeenCalled(); expect(updateHistory).not.toHaveBeenCalled(); expect(findCompaction(await doc.getHistory())).toMatchObject({ status: 'in_progress' }); - let confirmDurableWrite!: (synced: boolean) => void; - const durableWrite = new Promise((resolve) => { - confirmDurableWrite = resolve; + getExecutionSnapshot.mockReturnValue({ + hasActiveTurn: false, + hasBlockingPendingCreate: false, + hasReusableSession: true, + hasRewriteBarrier: false, + hasActiveAutomation: false, }); - waitUntilSynced.mockResolvedValueOnce(true).mockReturnValueOnce(durableWrite); - const reconciliation = host.reconcileSessionContextCompaction({ + hasActivePresence.mockReset().mockReturnValue(false); + vi.spyOn(host.sessionDispatchWatcher, 'hasPendingDispatch').mockReturnValue(false); + const concurrentTurnId = 'assistant:concurrent-turn'; + updateHistory.mockImplementationOnce(async (updateFn, options) => { + await originalUpdateHistory((history) => { + history.push({ + id: concurrentTurnId, + role: 'assistant', + timestamp: '2026-09-12T00:00:00.000Z', + items: [{ type: 'text', text: 'concurrent turn' }], + }); + return history; + }); + getExecutionSnapshot.mockReturnValue({ + activeTurnId: concurrentTurnId, + hasActiveTurn: true, + hasBlockingPendingCreate: false, + hasReusableSession: true, + hasRewriteBarrier: false, + hasActiveAutomation: false, + }); + await originalUpdateHistory(updateFn, options); + }); + waitUntilSynced.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + const reconciliation = await host.reconcileSessionContextCompaction({ sessionId, turnId, toolCallId: 'compact-stale', }); - await vi.waitFor(() => expect(enqueueSessionCheck).toHaveBeenCalledWith(sessionId)); - expect(host.executionService.getExecutionSnapshot(sessionId).hasRewriteBarrier).toBe(false); - confirmDurableWrite(true); - await expect(reconciliation).resolves.toMatchObject({ outcome: 'reconciled' }); + expect(reconciliation.error).toBeUndefined(); + expect(reconciliation.outcome).toBe('reconciled'); + expect(updateHistory).toHaveBeenCalledWith(expect.any(Function), { onlyEntryId: turnId }); + expect((await doc.getHistory()).find((entry) => entry.id === concurrentTurnId)).toMatchObject( + { + items: [{ type: 'text', text: 'concurrent turn' }], + } + ); expect(hasActivePresence).toHaveBeenCalled(); expect(findCompaction(await doc.getHistory())).toMatchObject({ status: 'failed' }); expect(isSessionContextCompacting(await doc.getHistory())).toBe(false); + getExecutionSnapshot.mockReturnValue({ + hasActiveTurn: false, + hasBlockingPendingCreate: false, + hasReusableSession: true, + hasRewriteBarrier: false, + hasActiveAutomation: false, + }); + const unsyncedTurnId = host.beginConversationTurn(sessionId); host.enqueueACPUpdate(sessionId, { sessionId, @@ -632,7 +666,7 @@ describe('MessageHandler ACP batching', () => { turnId: unsyncedTurnId, toolCallId: 'compact-unsynced', }) - ).resolves.toMatchObject({ outcome: 'unknown' }); + ).resolves.toMatchObject({ outcome: 'retry' }); expect(findCompaction(await doc.getHistory(), 'compact-unsynced')).toMatchObject({ status: 'failed', }); diff --git a/apps/cli/tests/session-dispatch-watcher.test.ts b/apps/cli/tests/session-dispatch-watcher.test.ts index 54610b165..44b208cd4 100644 --- a/apps/cli/tests/session-dispatch-watcher.test.ts +++ b/apps/cli/tests/session-dispatch-watcher.test.ts @@ -389,78 +389,6 @@ describe('SessionDispatchWatcher', () => { ); }); - it('dispatches an ACKed RPC turn when a rewrite barrier release requests a recheck', async () => { - const startSession = vi.fn(async () => {}); - const sessionId = 'session-rpc-rewrite-barrier' as SessionId; - const sessionMeta = { - id: sessionId, - machineId: 'machine-1', - userId: 'user-1', - createdAt: new Date().toISOString(), - cliType: 'builtin', - agentType: 'codex', - status: { type: 'idle' as const }, - }; - const sessionDoc = { - mirror: { subscribe: vi.fn(() => vi.fn()) }, - getMetaState: vi.fn(async () => sessionMeta), - getHistory: vi.fn(async () => []), - updateHistory: vi.fn(async () => {}), - setStatus: vi.fn(async () => {}), - waitForRemoteSync: vi.fn(async () => {}), - }; - const workspaceDocument = { - repo: { - getDocMeta: vi.fn(async () => ({ meta: sessionMeta })), - upsertDocMeta: vi.fn(async () => {}), - watch: vi.fn(() => ({ unsubscribe: vi.fn() })), - }, - getOrCreateSessionDoc: vi.fn(async () => sessionDoc), - onMetaRoomSynced: vi.fn(() => vi.fn()), - } as unknown as LoroDocumentManager; - let barrierHeld = true; - const getExecutionSnapshot = vi.fn(() => ({ - hasActiveTurn: false, - hasBlockingPendingCreate: false, - hasReusableSession: false, - hasRewriteBarrier: barrierHeld, - })); - const watcher = createWatcher({ - logger: createSilentLogger(), - machineId: 'machine-1', - workspaceId: 'workspace-1' as WorkspaceId, - workspaceDocument, - executionService: { - getExecutionSnapshot, - continueSession: vi.fn(async () => {}), - startSession, - cancelSession: vi.fn(async () => ({ success: true })), - } as unknown as SessionExecutionService, - canUseMachine: createAllowMachineAccess(), - }); - - await expect( - watcher.offerRpcTurn({ - sessionId, - userTurnId: 'rpc-turn-barrier', - userId: 'user-1', - timestamp: new Date().toISOString(), - inputConfig: { prompt: 'send during reconciliation' }, - }) - ).resolves.toBe('accepted'); - await vi.waitFor(() => expect(getExecutionSnapshot).toHaveBeenCalled()); - expect(startSession).not.toHaveBeenCalled(); - - barrierHeld = false; - await watcher.enqueueSessionCheck(sessionId); - - expect(startSession).toHaveBeenCalledTimes(1); - expect(startSession).toHaveBeenCalledWith( - expect.objectContaining({ sessionId, userTurnId: 'rpc-turn-barrier' }), - { dispatchSource: 'rpc' } - ); - }); - it('keeps a stashed RPC turn while session meta is unknown and dispatches once meta syncs', async () => { const startSession = vi.fn(async () => {}); const sessionId = 'session-rpc-before-meta' as SessionId; diff --git a/apps/cli/tests/session-execution-service.test.ts b/apps/cli/tests/session-execution-service.test.ts index e358bd43b..9e5efb924 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -7211,46 +7211,6 @@ describe('SessionExecutionService goal control', () => { expect(service.getExecutionSnapshot(goalSessionId).hasActiveTurn).toBe(false); }); - it('retains an accepted goal action until a rewrite barrier releases', async () => { - const { service, submitted, completion, delivered, failures } = createGoalService({ - transport: 'promptMeta', - }); - const internals = service as unknown as { - pendingGoalTurnBySession: Map; - waitForSessionRewriteBarrierRelease: (sessionId: SessionId) => Promise; - }; - const waitingForBarrier = createDeferred(); - const originalWait = internals.waitForSessionRewriteBarrierRelease.bind(service); - vi.spyOn(internals, 'waitForSessionRewriteBarrierRelease').mockImplementation( - async (sessionId) => { - waitingForBarrier.resolve(); - await originalWait(sessionId); - } - ); - const releaseBarrier = service.tryAcquireSessionRewriteBarrier(goalSessionId); - expect(releaseBarrier).not.toBeNull(); - - try { - const response = await service.controlSessionGoal({ ...goalArgs, action: 'resume' }); - expect(response).toMatchObject({ accepted: true, disposition: 'queued' }); - await waitingForBarrier.promise; - expect(internals.pendingGoalTurnBySession.has(goalSessionId)).toBe(true); - expect(delivered).toEqual([]); - expect(failures).toEqual([]); - - releaseBarrier?.(); - await submitted.promise; - expect(delivered).toEqual([expect.objectContaining({ goalControl: { action: 'resume' } })]); - expect(failures).toEqual([]); - const released = service.waitForTurnRelease(goalSessionId, 'turn-1'); - completion.resolve(); - await released; - } finally { - releaseBarrier?.(); - completion.resolve(); - } - }); - it('retains an accepted goal across more than three competing turns', async () => { const { service, submitted, completion, delivered } = createGoalService({ transport: 'promptMeta', @@ -7302,7 +7262,7 @@ describe('SessionExecutionService goal control', () => { return originalMeta(); }; const internals = service as unknown as { - startGoalTurn: (request: unknown) => Promise<'claimed' | 'rewrite-barrier' | 'not-claimed'>; + startGoalTurn: (request: unknown) => Promise; }; const originalStart = internals.startGoalTurn.bind(service); const settled = createDeferred(); diff --git a/packages/components/src/components/sessions/README.md b/packages/components/src/components/sessions/README.md index fa77884c3..36b407cd5 100644 --- a/packages/components/src/components/sessions/README.md +++ b/packages/components/src/components/sessions/README.md @@ -27,7 +27,7 @@ What each file in this directory is responsible for. Binding rules live in | File | Responsibility | | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `session-chat-interface.tsx` | Conversation surface: stream, header variants, read receipts, launchers | -| `use-session-context-compaction-reconciliation.ts` | Bounded stale-compaction repair attempts across daemon, browser connectivity, and Session activity transitions | +| `use-session-context-compaction-reconciliation.ts` | One stale-compaction repair attempt per inactive candidate, daemon generation, and ready-connectivity evidence | | [`chat-share-image-dialog.tsx`](chat-share-image-dialog.tsx) | Selected-message card styling, historical metadata, PNG export, and local image copying; [draft contract](../../../../../specs/chat-share-image.md) | | `draft-session-chat-interface.tsx` | Draft variant of the conversation surface | | `session-chat-input-area.tsx` | Composer: attachments, run-config footer, submit | diff --git a/packages/components/src/components/sessions/use-session-context-compaction-reconciliation.ts b/packages/components/src/components/sessions/use-session-context-compaction-reconciliation.ts index 7d180b97a..237b04a8a 100644 --- a/packages/components/src/components/sessions/use-session-context-compaction-reconciliation.ts +++ b/packages/components/src/components/sessions/use-session-context-compaction-reconciliation.ts @@ -2,11 +2,7 @@ import { useEffect, useRef } from 'react'; import type { MachineId, SessionId } from '@lody/shared'; import type { WorkspaceRuntime } from '@/atoms/runtime'; -import { - type ActiveSessionContextCompaction, - getContextCompactionReconciliationAttemptKey, - isDurableContextCompactionReconciliation, -} from '@/lib/session-context-compaction'; +import type { ActiveSessionContextCompaction } from '@/lib/session-context-compaction'; type ReconciliationRuntime = Pick< WorkspaceRuntime, @@ -32,36 +28,35 @@ export const useSessionContextCompactionReconciliation = ({ runtime: ReconciliationRuntime | null; sessionId: SessionId; }): void => { - const attemptsRef = useRef(new Set()); + const lastAttemptEvidenceRef = useRef(null); useEffect(() => { - if (!runtime || !canReconcile || !activeCompaction?.turnFinished) { + if ( + !runtime || + !canReconcile || + !isConnectivityOnline || + isSessionActive || + !activeCompaction?.turnFinished + ) { + lastAttemptEvidenceRef.current = null; return; } - const attemptKey = getContextCompactionReconciliationAttemptKey({ + const evidence = JSON.stringify([ sessionId, - turnId: activeCompaction.turnId, - toolCallId: activeCompaction.toolCallId, + activeCompaction.turnId, + activeCompaction.toolCallId, ownerInstanceId, - isSessionActive, isConnectivityOnline, - }); - if (attemptsRef.current.has(attemptKey)) return; - attemptsRef.current.add(attemptKey); + ]); + if (lastAttemptEvidenceRef.current === evidence) return; + lastAttemptEvidenceRef.current = evidence; void runtime .requestSessionContextCompactionReconciliation(machineId, { sessionId, turnId: activeCompaction.turnId, toolCallId: activeCompaction.toolCallId, }) - .then((result) => { - if (!isDurableContextCompactionReconciliation(result)) { - attemptsRef.current.delete(attemptKey); - } - }) - .catch(() => { - attemptsRef.current.delete(attemptKey); - }); + .catch(() => undefined); }, [ activeCompaction, canReconcile, diff --git a/packages/components/src/lib/session-context-compaction.ts b/packages/components/src/lib/session-context-compaction.ts index dd7f93dc3..a0475972b 100644 --- a/packages/components/src/lib/session-context-compaction.ts +++ b/packages/components/src/lib/session-context-compaction.ts @@ -1,33 +1,11 @@ -import type { SessionContextCompactionReconcileResponse, SessionHistory } from '@lody/shared'; +import type { SessionHistory } from '@lody/shared'; export type ActiveSessionContextCompaction = { turnId: string; toolCallId: string; - status: 'pending' | 'in_progress'; turnFinished: boolean; }; -export const getContextCompactionReconciliationAttemptKey = (args: { - sessionId: string; - turnId: string; - toolCallId: string; - ownerInstanceId: string | null; - isSessionActive: boolean; - isConnectivityOnline: boolean; -}): string => - JSON.stringify([ - args.sessionId, - args.turnId, - args.toolCallId, - args.ownerInstanceId, - args.isSessionActive, - args.isConnectivityOnline, - ]); - -export const isDurableContextCompactionReconciliation = ( - result: SessionContextCompactionReconcileResponse | null -): boolean => result?.outcome === 'reconciled'; - export const findActiveSessionContextCompaction = ( history: readonly Pick[] ): ActiveSessionContextCompaction | null => { @@ -42,7 +20,6 @@ export const findActiveSessionContextCompaction = ( return { turnId: entry.id, toolCallId: item.toolCallId, - status: item.status, turnFinished: entry.finished === true, }; } diff --git a/packages/components/tests/session-context-compaction.test.ts b/packages/components/tests/session-context-compaction.test.ts index 05408aafa..a0b4f4d59 100644 --- a/packages/components/tests/session-context-compaction.test.ts +++ b/packages/components/tests/session-context-compaction.test.ts @@ -13,8 +13,6 @@ import type { import { canStopAgentEnabled, findActiveSessionContextCompaction, - getContextCompactionReconciliationAttemptKey, - isDurableContextCompactionReconciliation, isSessionContextCompacting, } from '../src/lib/session-context-compaction'; import { useSessionContextCompactionReconciliation } from '../src/components/sessions/use-session-context-compaction-reconciliation'; @@ -50,7 +48,6 @@ describe('isSessionContextCompacting', () => { expect(findActiveSessionContextCompaction(historyWithStatus('in_progress'))).toEqual({ turnId: 'assistant:turn-1', toolCallId: 'context-compaction-1', - status: 'in_progress', turnFinished: false, }); }); @@ -64,60 +61,6 @@ describe('isSessionContextCompacting', () => { expect(isSessionContextCompacting(historyWithStatus('pending', true))).toBe(true); expect(isSessionContextCompacting(historyWithStatus('in_progress', true))).toBe(true); }); - - it('retries non-durable outcomes and isolates attempts by daemon generation', () => { - const base = { - sessionId: 'session-1', - turnId: 'assistant:turn-1', - toolCallId: 'context-compaction-1', - }; - expect( - getContextCompactionReconciliationAttemptKey({ - ...base, - ownerInstanceId: 'daemon-1', - isSessionActive: true, - isConnectivityOnline: true, - }) - ).not.toBe( - getContextCompactionReconciliationAttemptKey({ - ...base, - ownerInstanceId: 'daemon-2', - isSessionActive: true, - isConnectivityOnline: true, - }) - ); - expect( - getContextCompactionReconciliationAttemptKey({ - ...base, - ownerInstanceId: 'daemon-1', - isSessionActive: true, - isConnectivityOnline: true, - }) - ).not.toBe( - getContextCompactionReconciliationAttemptKey({ - ...base, - ownerInstanceId: 'daemon-1', - isSessionActive: false, - isConnectivityOnline: true, - }) - ); - for (const outcome of ['active', 'unknown', 'unchanged'] as const) { - expect( - isDurableContextCompactionReconciliation({ - type: 'session/reconcile-context-compaction_response', - ...base, - outcome, - }) - ).toBe(false); - } - expect( - isDurableContextCompactionReconciliation({ - type: 'session/reconcile-context-compaction_response', - ...base, - outcome: 'reconciled', - }) - ).toBe(true); - }); }); describe('context compaction reconciliation retry transitions', () => { @@ -132,7 +75,7 @@ describe('context compaction reconciliation retry transitions', () => { sessionId, turnId: 'assistant:turn-1', toolCallId: 'context-compaction-1', - outcome: 'active', + outcome: 'retry', } satisfies SessionContextCompactionReconcileResponse); const runtime = { requestSessionContextCompactionReconciliation: request }; @@ -154,14 +97,17 @@ describe('context compaction reconciliation retry transitions', () => { const root = createRoot(document.createElement('div')); try { await act(async () => root.render(createElement(Probe, { isSessionActive: true }))); + expect(request).not.toHaveBeenCalled(); + + await act(async () => root.render(createElement(Probe, { isSessionActive: false }))); expect(request).toHaveBeenCalledTimes(1); - await act(async () => root.render(createElement(Probe, { isSessionActive: true }))); + await act(async () => root.render(createElement(Probe, { isSessionActive: false }))); expect(request).toHaveBeenCalledTimes(1); + await act(async () => root.render(createElement(Probe, { isSessionActive: true }))); await act(async () => root.render(createElement(Probe, { isSessionActive: false }))); expect(request).toHaveBeenCalledTimes(2); - expect(request.mock.calls[1]).toEqual(request.mock.calls[0]); } finally { act(() => root.unmount()); delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; @@ -179,7 +125,7 @@ describe('context compaction reconciliation retry transitions', () => { sessionId, turnId: 'assistant:turn-1', toolCallId: 'context-compaction-1', - outcome: 'unknown', + outcome: 'retry', } satisfies SessionContextCompactionReconcileResponse); const runtime = { requestSessionContextCompactionReconciliation: request }; @@ -201,14 +147,20 @@ describe('context compaction reconciliation retry transitions', () => { const root = createRoot(document.createElement('div')); try { await act(async () => root.render(createElement(Probe, { isConnectivityOnline: false }))); - expect(request).toHaveBeenCalledTimes(1); + expect(request).not.toHaveBeenCalled(); await act(async () => root.render(createElement(Probe, { isConnectivityOnline: false }))); + expect(request).not.toHaveBeenCalled(); + + await act(async () => root.render(createElement(Probe, { isConnectivityOnline: true }))); expect(request).toHaveBeenCalledTimes(1); + await act(async () => root.render(createElement(Probe, { isConnectivityOnline: true }))); + expect(request).toHaveBeenCalledTimes(1); + + await act(async () => root.render(createElement(Probe, { isConnectivityOnline: false }))); await act(async () => root.render(createElement(Probe, { isConnectivityOnline: true }))); expect(request).toHaveBeenCalledTimes(2); - expect(request.mock.calls[1]).toEqual(request.mock.calls[0]); } finally { act(() => root.unmount()); delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; diff --git a/packages/loro-streams-rpc/AGENTS.md b/packages/loro-streams-rpc/AGENTS.md index c2602fb98..3ad60ffac 100644 --- a/packages/loro-streams-rpc/AGENTS.md +++ b/packages/loro-streams-rpc/AGENTS.md @@ -46,8 +46,8 @@ and the `file/preview` namespace are in needs read-check-write atomicity serializes in its own service layer (Code Collab `save-text` per absolute path in `code-collab-v2-service.ts`), not in the request loop. - Control-plane methods (`machine/status`, `machine/ping`, `session/cancel`, - `session/live-status`, `session/reconcile-context-compaction`, `session/steer`, - `session/terminate`, `machine/restart`, `machine/upgrade`, `session/dispatch-turn`) + `session/live-status`, `session/steer`, `session/terminate`, `machine/restart`, + `machine/upgrade`, `session/dispatch-turn`) bypass the shared semaphore and run on a small dedicated lane (`CONTROL_METHODS` in `machine-rpc-server.ts`) so saturated code-collab handlers cannot delay them at intake. Control handlers must stay fast diff --git a/packages/loro-streams-rpc/src/rpc.ts b/packages/loro-streams-rpc/src/rpc.ts index 506ca2da3..b64f0980e 100644 --- a/packages/loro-streams-rpc/src/rpc.ts +++ b/packages/loro-streams-rpc/src/rpc.ts @@ -1623,7 +1623,7 @@ const toLegacyRpcErrorResponse = ( sessionId: (cancelContext?.sessionId ?? '') as SessionId, turnId: cancelContext?.turnId ?? '', toolCallId: cancelContext?.toolCallId ?? '', - outcome: 'unknown', + outcome: 'retry', error: `${error.code}: ${error.message}`, }; } diff --git a/packages/loro-streams-rpc/tests/machine-rpc-server.test.ts b/packages/loro-streams-rpc/tests/machine-rpc-server.test.ts index 2d839dac1..54443e322 100644 --- a/packages/loro-streams-rpc/tests/machine-rpc-server.test.ts +++ b/packages/loro-streams-rpc/tests/machine-rpc-server.test.ts @@ -944,7 +944,7 @@ describe('LoroStreamsMachineRpcServer', () => { return { type: 'session/reconcile-context-compaction_response' as const, ...args, - outcome: 'unchanged' as const, + outcome: 'retry' as const, }; } ); diff --git a/packages/shared/src/message-schemas.ts b/packages/shared/src/message-schemas.ts index eabf4b95c..52e67235e 100644 --- a/packages/shared/src/message-schemas.ts +++ b/packages/shared/src/message-schemas.ts @@ -649,8 +649,7 @@ export const SessionContextCompactionReconcileResponseSchema = z sessionId: SessionIdSchema, turnId: z.string().trim().min(1), toolCallId: z.string().trim().min(1), - outcome: z.enum(['reconciled', 'unchanged', 'active', 'unknown']), - activeTurnId: z.string().trim().min(1).optional(), + outcome: z.enum(['reconciled', 'retry']), error: z.string().trim().min(1).optional(), }) .strict(); diff --git a/specs/session-goal-control.md b/specs/session-goal-control.md index 4779ec2ce..8e35e54ec 100644 --- a/specs/session-goal-control.md +++ b/specs/session-goal-control.md @@ -103,6 +103,6 @@ Executed validation: adapter tests for the prompt-metadata transport, CLI tests for transport selection and for queueing a resume behind a draining turn, and component tests for capability-driven command availability. Host regressions cover prompt-lifetime-independent acceptance, more than three competing turns, -supersession before provider submission, rewrite-barrier release, and visible startup -failures. Agent-client wire tests cover cold pause/clear and unchanged manual `/goal xxx` -prompts. No live Codex goal was exercised end to end. +supersession before provider submission, and visible startup failures. Agent-client +wire tests cover cold pause/clear and unchanged manual `/goal xxx` prompts. No live Codex goal +was exercised end to end. diff --git a/specs/session-history-writes.md b/specs/session-history-writes.md index 805d5c90f..c33ffa160 100644 --- a/specs/session-history-writes.md +++ b/specs/session-history-writes.md @@ -51,14 +51,15 @@ That tolerance must not authorize creating new malformed items locally. repair, but only its owning daemon may change the exact addressed turn and tool item, and only after live execution ownership proves that turn is no longer active. Turn `finished`, durable Session status, elapsed time, daemon restart, - or missing live evidence is insufficient. An offline, unsupported, non-owning, - or indeterminate daemon leaves history unchanged. Repair must inspect a remotely - synced Session document, hold the rewrite barrier only across the final live-state - check and local mutation, wake both ordinary and Goal turn dispatch after release, - and report `reconciled` only after the write is confirmed. Non-terminal results - remain retryable across later history, browser connectivity restoration even while - Machine presence stays online, daemon-generation, or stable Session - activity-transition evidence; presence heartbeats must not become polling. + or missing live evidence is insufficient. The renderer requests repair only while + the Session is inactive and connectivity is ready. An offline, unsupported, + non-owning, active, or indeterminate daemon leaves history unchanged. Repair must + inspect a remotely synced Session document, reject any live Session work, recheck + ownership, and update only the named entry through `onlyEntryId`; a concurrent new + turn has a different identity and remains untouched. Report `reconciled` only after + the write is confirmed; every other result is `retry`. Retry evidence is bounded by + the exact candidate, daemon generation, connectivity, and stable Session activity + transitions; presence heartbeats must not become polling. - 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 520e501c0..b66d7938b 100644 --- a/specs/session-history-writes.zh.md +++ b/specs/session-history-writes.zh.md @@ -35,12 +35,13 @@ Translation: current - 已 finished 的 assistant turn 可能包含未持久化 provider 终态的旧 context-compaction activity。打开该 Session 时可以请求修复,但只有 owner daemon 能修改指定 turn 和 tool item,且必须先由实时执行所有权证明该 turn 已不活跃。Turn `finished`、持久化 Session - status、经过时间、daemon 重启或缺少 live evidence 均不充分。Daemon 离线、不支持该能力、 - 不持有 Session 或无法确定时,历史保持不变。Repair 必须读取已完成远端同步的 Session - document;rewrite barrier 仅覆盖最终 live-state 检查和本地修改,释放后必须同时唤醒普通 - turn 与 Goal turn dispatch;只有写入确认后才能返回 `reconciled`。非终态结果可在后续 - history、browser connectivity 恢复(即使 Machine presence 始终 online)、daemon generation - 或稳定的 Session activity 状态切换证据变化时重试;presence heartbeat 不得退化成轮询。 + status、经过时间、daemon 重启或缺少 live evidence 均不充分。Renderer 只在 Session inactive + 且 connectivity ready 时请求修复。Daemon 离线、不支持该能力、不持有 Session、仍有 live + work 或无法确定时,历史保持不变。Repair 必须读取已完成远端同步的 Session document,拒绝 + 任何 live Session work,再次确认 owner,并通过 `onlyEntryId` 只更新指定 entry;并发的新 turn + 使用不同 identity,必须保持不变。只有写入确认后才能返回 `reconciled`,其他结果统一返回 + `retry`。Retry evidence 只由 exact candidate、daemon generation、connectivity 和稳定的 Session + activity 状态切换界定;presence heartbeat 不得退化成轮询。 - 已接受的 steer 标记在写入和读取归一化后都必须保留;编辑重发不能把 steer 当作可独立重放的普通用户轮次。 From e00f69cfc0e793050c9c6afa1e08f5e04faf8fcf Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:42:03 +0800 Subject: [PATCH 07/14] fix: retry compaction repair after room reconnect Model: gpt-5 --- .agents/docs/sessions-live-status.md | 8 +-- ...stale-context-compaction-reconciliation.md | 18 ++++--- ...le-context-compaction-reconciliation.zh.md | 16 +++--- .../src/components/sessions/README.md | 2 +- .../sessions/session-chat-interface.tsx | 1 + ...ssion-context-compaction-reconciliation.ts | 5 ++ .../tests/session-context-compaction.test.ts | 49 +++++++++++++++++++ specs/session-history-writes.md | 7 +-- specs/session-history-writes.zh.md | 10 ++-- 9 files changed, 89 insertions(+), 27 deletions(-) diff --git a/.agents/docs/sessions-live-status.md b/.agents/docs/sessions-live-status.md index b40a23fda..f5d56c618 100644 --- a/.agents/docs/sessions-live-status.md +++ b/.agents/docs/sessions-live-status.md @@ -54,12 +54,14 @@ null`: browser offline, machine removed or offline) — the chip owns that story - Context compaction is different from live working status: its spinner follows the latest durable `context_compaction` tool-call status. When that item is still `pending` or `in_progress` inside a finished assistant turn, an inactive Session - with ready connectivity asks a capability-compatible owner daemon to reconcile the + with browser connectivity online and its Session room synced asks a + capability-compatible owner daemon to reconcile the exact turn/tool ids. The renderer does not convert `finished` into a provider terminal signal. The daemon writes `failed` only after its live execution state shows no Session work, and it updates only the named history entry. Unsupported, offline, non-owner, active, indeterminate, mismatched, and unreachable cases return `retry` without changing the durable item. The renderer attempts each candidate, daemon generation, and connectivity evidence once; leaving eligibility clears that - evidence so a later inactive/reconnected transition can retry. Presence heartbeats - never become polling, and only a remotely confirmed repair is terminal. + evidence so a later inactive, browser-online, or Session-room-reconnected transition + can retry. Presence heartbeats never become polling, and only a remotely confirmed + repair is terminal. diff --git a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md index 50c3a2da8..bf1bc6b2d 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md +++ b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md @@ -20,12 +20,13 @@ unchanged. The renderer remains a reader of durable compaction status. It does not reinterpret `SessionHistory.finished`, a timeout, a restart, or missing presence as provider termination. When the latest compaction is unresolved, its assistant turn is -finished, the Session is inactive, browser connectivity is ready, and the owner -daemon advertises the capability, the renderer requests reconciliation. A single -`lastAttemptEvidenceRef` bounds requests by the exact candidate, owner-daemon -instance, and connectivity state. Leaving eligibility clears that evidence, so a -later inactive or reconnected transition can try again. RPC results do not drive a -parallel renderer state machine, and heartbeat-only presence updates do not poll. +finished, the Session is inactive, browser connectivity is online, the Session room +is synced, and the owner daemon advertises the capability, the renderer requests +reconciliation. A single `lastAttemptEvidenceRef` bounds requests by the exact +candidate, owner-daemon instance, and connectivity state. Leaving eligibility clears +that evidence, so a later inactive, browser-online, or Session-room-reconnected +transition can try again. RPC results do not drive a parallel renderer state machine, +and heartbeat-only presence updates do not poll. The request names `sessionId`, `turnId`, and `toolCallId`. The target daemon first verifies that current Session metadata assigns ownership to its own machine, joins the @@ -75,8 +76,9 @@ Behavioral coverage exercises exact-item mutation, active and indeterminate owne outcomes, cold/unsynced documents, write-confirmation failure, and a turn that starts between the final liveness check and the targeted history mutation. Renderer coverage exercises owner-daemon generations, inactive eligibility, local-first browser -connectivity restoration while Machine presence remains online, and bounded attempts -for unchanged evidence. RPC coverage exercises ordinary-lane isolation, local +connectivity restoration while Machine presence remains online, Session-room +reconnection while browser connectivity remains online, and bounded attempts for +unchanged evidence. RPC coverage exercises ordinary-lane isolation, local capability gating, both local and Loro transports, and shared request/response schemas. No startup scan, storage migration, rewrite-barrier compensation, or end-to-end provider fixture was added. diff --git a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md index 213275953..b4064a607 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md @@ -17,11 +17,12 @@ daemon 协调精确的 turn 与 tool-call id。只有实时执行所有权证明 Renderer 仍只读取持久化的 compaction 状态。它不会把 `SessionHistory.finished`、超时、 重启或 presence 缺失重新解释成 provider 已终止。仅当最新 compaction 未完成、所属 -assistant turn 已 finished、Session inactive、browser connectivity ready,且 owner daemon -声明支持该能力时,renderer 才请求 reconciliation。一个 +assistant turn 已 finished、Session inactive、browser connectivity online、Session room +已 synced,且 owner daemon 声明支持该能力时,renderer 才请求 reconciliation。一个 `lastAttemptEvidenceRef` 按 exact candidate、owner-daemon instance 和 connectivity state -限定请求。离开 eligibility 会清除该 evidence,因此后续 inactive 或重连转换可以 -再试。RPC 结果不驱动另一套 renderer state machine,单纯 heartbeat 更新也不会退化成轮询。 +限定请求。离开 eligibility 会清除该 evidence,因此后续 inactive、browser online 或 +Session room 重连转换可以再试。RPC 结果不驱动另一套 renderer state machine,单纯 +heartbeat 更新也不会退化成轮询。 请求携带 `sessionId`、`turnId` 和 `toolCallId`。目标 daemon 先验证当前 Session metadata 确实把所有权分配给本机,加入 Session document,并等待其远端状态完成。然后它会拒绝 @@ -61,6 +62,7 @@ owner 能提供证据。本次变更不假定每一条历史未完成 compaction 行为测试覆盖精确 item 修改、active 与 indeterminate 所有权结果、cold/unsynced document、 写入确认失败,以及最终 liveness 检查与 targeted history mutation 之间启动的新 turn。 Renderer 测试覆盖 owner-daemon generation、inactive eligibility、Machine presence 保持 online 时的 -local-first browser connectivity 恢复,以及 evidence 未变时的有界尝试。RPC 测试覆盖普通 lane -隔离、本地 capability gating、local 与 Loro transport,以及共享 request/response schema。本次没有 -加入启动扫描、存储迁移、rewrite-barrier compensation 或端到端 provider fixture。 +local-first browser connectivity 恢复、browser connectivity 保持 online 时的 Session room 重连, +以及 evidence 未变时的有界尝试。RPC 测试覆盖普通 lane 隔离、本地 capability gating、local 与 +Loro transport,以及共享 request/response schema。本次没有加入启动扫描、存储迁移、 +rewrite-barrier compensation 或端到端 provider fixture。 diff --git a/packages/components/src/components/sessions/README.md b/packages/components/src/components/sessions/README.md index 36b407cd5..406ace023 100644 --- a/packages/components/src/components/sessions/README.md +++ b/packages/components/src/components/sessions/README.md @@ -27,7 +27,7 @@ What each file in this directory is responsible for. Binding rules live in | File | Responsibility | | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `session-chat-interface.tsx` | Conversation surface: stream, header variants, read receipts, launchers | -| `use-session-context-compaction-reconciliation.ts` | One stale-compaction repair attempt per inactive candidate, daemon generation, and ready-connectivity evidence | +| `use-session-context-compaction-reconciliation.ts` | One stale-compaction repair attempt per inactive candidate, daemon generation, and browser/Session-room connectivity evidence | | [`chat-share-image-dialog.tsx`](chat-share-image-dialog.tsx) | Selected-message card styling, historical metadata, PNG export, and local image copying; [draft contract](../../../../../specs/chat-share-image.md) | | `draft-session-chat-interface.tsx` | Draft variant of the conversation surface | | `session-chat-input-area.tsx` | Composer: attachments, run-config footer, submit | diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index 7a48dca75..72283bdb5 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -2720,6 +2720,7 @@ export const SessionChatInterface = memo( activeCompaction: activeContextCompaction, canReconcile: canReconcileContextCompaction, isConnectivityOnline: browserOnline, + isSessionRoomSynced: sessionDocSyncState === 'synced', isSessionActive, machineId: session.machineId, ownerInstanceId: sessionMachinePresenceInstanceId, diff --git a/packages/components/src/components/sessions/use-session-context-compaction-reconciliation.ts b/packages/components/src/components/sessions/use-session-context-compaction-reconciliation.ts index 237b04a8a..2b90b4d4f 100644 --- a/packages/components/src/components/sessions/use-session-context-compaction-reconciliation.ts +++ b/packages/components/src/components/sessions/use-session-context-compaction-reconciliation.ts @@ -13,6 +13,7 @@ export const useSessionContextCompactionReconciliation = ({ activeCompaction, canReconcile, isConnectivityOnline, + isSessionRoomSynced, isSessionActive, machineId, ownerInstanceId, @@ -22,6 +23,7 @@ export const useSessionContextCompactionReconciliation = ({ activeCompaction: ActiveSessionContextCompaction | null; canReconcile: boolean; isConnectivityOnline: boolean; + isSessionRoomSynced: boolean; isSessionActive: boolean; machineId: MachineId; ownerInstanceId: string | null; @@ -35,6 +37,7 @@ export const useSessionContextCompactionReconciliation = ({ !runtime || !canReconcile || !isConnectivityOnline || + !isSessionRoomSynced || isSessionActive || !activeCompaction?.turnFinished ) { @@ -47,6 +50,7 @@ export const useSessionContextCompactionReconciliation = ({ activeCompaction.toolCallId, ownerInstanceId, isConnectivityOnline, + isSessionRoomSynced, ]); if (lastAttemptEvidenceRef.current === evidence) return; lastAttemptEvidenceRef.current = evidence; @@ -61,6 +65,7 @@ export const useSessionContextCompactionReconciliation = ({ activeCompaction, canReconcile, isConnectivityOnline, + isSessionRoomSynced, isSessionActive, machineId, ownerInstanceId, diff --git a/packages/components/tests/session-context-compaction.test.ts b/packages/components/tests/session-context-compaction.test.ts index a0b4f4d59..5ad99f732 100644 --- a/packages/components/tests/session-context-compaction.test.ts +++ b/packages/components/tests/session-context-compaction.test.ts @@ -84,6 +84,7 @@ describe('context compaction reconciliation retry transitions', () => { activeCompaction, canReconcile: true, isConnectivityOnline: true, + isSessionRoomSynced: true, isSessionActive, machineId, ownerInstanceId: 'daemon-1', @@ -134,6 +135,7 @@ describe('context compaction reconciliation retry transitions', () => { activeCompaction, canReconcile: true, isConnectivityOnline, + isSessionRoomSynced: true, isSessionActive: false, machineId, ownerInstanceId: 'daemon-1', @@ -166,6 +168,53 @@ describe('context compaction reconciliation retry transitions', () => { delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; } }); + + it('retries after the Session room reconnects while browser connectivity stays online', async () => { + const sessionId = 'session-1' as SessionId; + const machineId = 'machine-1' as MachineId; + const activeCompaction = findActiveSessionContextCompaction( + historyWithStatus('in_progress', true) + ); + const request = vi.fn().mockResolvedValue({ + type: 'session/reconcile-context-compaction_response', + sessionId, + turnId: 'assistant:turn-1', + toolCallId: 'context-compaction-1', + outcome: 'retry', + } satisfies SessionContextCompactionReconcileResponse); + const runtime = { requestSessionContextCompactionReconciliation: request }; + + const Probe = ({ syncState }: { syncState: 'synced' | 'reconnecting' }) => { + useSessionContextCompactionReconciliation({ + activeCompaction, + canReconcile: true, + isConnectivityOnline: true, + isSessionRoomSynced: syncState === 'synced', + isSessionActive: false, + machineId, + ownerInstanceId: 'daemon-1', + runtime, + sessionId, + }); + return null; + }; + + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement('div')); + try { + await act(async () => root.render(createElement(Probe, { syncState: 'synced' }))); + expect(request).toHaveBeenCalledTimes(1); + + await act(async () => root.render(createElement(Probe, { syncState: 'reconnecting' }))); + expect(request).toHaveBeenCalledTimes(1); + + await act(async () => root.render(createElement(Probe, { syncState: 'synced' }))); + expect(request).toHaveBeenCalledTimes(2); + } finally { + act(() => root.unmount()); + delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; + } + }); }); describe('canStopAgentEnabled', () => { diff --git a/specs/session-history-writes.md b/specs/session-history-writes.md index c33ffa160..dc3a8bbb2 100644 --- a/specs/session-history-writes.md +++ b/specs/session-history-writes.md @@ -52,14 +52,15 @@ That tolerance must not authorize creating new malformed items locally. item, and only after live execution ownership proves that turn is no longer active. Turn `finished`, durable Session status, elapsed time, daemon restart, or missing live evidence is insufficient. The renderer requests repair only while - the Session is inactive and connectivity is ready. An offline, unsupported, + the Session is inactive, browser connectivity is online, and the Session room is + synced. An offline, reconnecting, unsupported, non-owning, active, or indeterminate daemon leaves history unchanged. Repair must inspect a remotely synced Session document, reject any live Session work, recheck ownership, and update only the named entry through `onlyEntryId`; a concurrent new turn has a different identity and remains untouched. Report `reconciled` only after the write is confirmed; every other result is `retry`. Retry evidence is bounded by - the exact candidate, daemon generation, connectivity, and stable Session activity - transitions; presence heartbeats must not become polling. + the exact candidate, daemon generation, browser and Session-room connectivity, and + stable Session activity transitions; presence heartbeats must not become polling. - 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 b66d7938b..7cad66686 100644 --- a/specs/session-history-writes.zh.md +++ b/specs/session-history-writes.zh.md @@ -35,13 +35,13 @@ Translation: current - 已 finished 的 assistant turn 可能包含未持久化 provider 终态的旧 context-compaction activity。打开该 Session 时可以请求修复,但只有 owner daemon 能修改指定 turn 和 tool item,且必须先由实时执行所有权证明该 turn 已不活跃。Turn `finished`、持久化 Session - status、经过时间、daemon 重启或缺少 live evidence 均不充分。Renderer 只在 Session inactive - 且 connectivity ready 时请求修复。Daemon 离线、不支持该能力、不持有 Session、仍有 live - work 或无法确定时,历史保持不变。Repair 必须读取已完成远端同步的 Session document,拒绝 + status、经过时间、daemon 重启或缺少 live evidence 均不充分。Renderer 只在 Session inactive、 + browser connectivity online 且 Session room 已 synced 时请求修复。Daemon 离线、正在重连、 + 不支持该能力、不持有 Session、仍有 live work 或无法确定时,历史保持不变。Repair 必须读取已完成远端同步的 Session document,拒绝 任何 live Session work,再次确认 owner,并通过 `onlyEntryId` 只更新指定 entry;并发的新 turn 使用不同 identity,必须保持不变。只有写入确认后才能返回 `reconciled`,其他结果统一返回 - `retry`。Retry evidence 只由 exact candidate、daemon generation、connectivity 和稳定的 Session - activity 状态切换界定;presence heartbeat 不得退化成轮询。 + `retry`。Retry evidence 只由 exact candidate、daemon generation、browser 与 Session-room + connectivity 和稳定的 Session activity 状态切换界定;presence heartbeat 不得退化成轮询。 - 已接受的 steer 标记在写入和读取归一化后都必须保留;编辑重发不能把 steer 当作可独立重放的普通用户轮次。 From f814adecda65e47c132ee092746ae5058a9061b3 Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:11:00 +0800 Subject: [PATCH 08/14] fix: cancel native compaction instead of reconciling history Model: gpt-6 --- .agents/docs/sessions-live-status.md | 14 -- ...09-10-context-compaction-terminal-state.md | 4 +- ...10-context-compaction-terminal-state.zh.md | 4 +- ...stale-context-compaction-reconciliation.md | 84 ------- ...le-context-compaction-reconciliation.zh.md | 68 ------ ...26-09-12-native-compaction-cancellation.md | 60 +++++ .../src/lib/assistant-turn-finalize.test.ts | 76 +------ apps/cli/src/lib/assistant-turn-finalize.ts | 25 --- apps/cli/src/lib/message-handler.ts | 85 +------ .../src/session/session-execution-service.ts | 2 +- .../message-handler-acp-batching.test.ts | 209 ------------------ ...ssage-handler-machine-registration.test.ts | 1 - packages/acp-extension-codex | 2 +- packages/components/src/atoms/presence.ts | 13 -- packages/components/src/atoms/runtime.ts | 5 - .../src/components/sessions/README.md | 1 - .../sessions/session-chat-interface.tsx | 35 +-- ...ssion-context-compaction-reconciliation.ts | 75 ------- .../src/hooks/use-machine-online-status.ts | 8 - .../src/lib/session-context-compaction.ts | 29 +-- .../src/providers/create-workspace-runtime.ts | 2 - .../providers/workspace-machine-rpc-facade.ts | 39 ---- .../tests/doc-meta-presence.test.ts | 2 - .../tests/session-context-compaction.test.ts | 186 +--------------- .../workspace-machine-rpc-facade.test.ts | 77 ------- packages/loro-streams-rpc/AGENTS.md | 9 +- .../src/machine-rpc-server.ts | 23 -- packages/loro-streams-rpc/src/rpc.ts | 71 +----- .../tests/machine-rpc-server.test.ts | 148 ------------- packages/shared/src/local-machine-rpc.ts | 12 - .../src/machine-protocol-capabilities.ts | 15 -- packages/shared/src/message-schemas.ts | 15 -- .../shared/tests/local-machine-rpc.test.ts | 8 - .../machine-protocol-capabilities.test.ts | 16 -- specs/session-history-writes.md | 26 +-- specs/session-history-writes.zh.md | 15 +- 36 files changed, 98 insertions(+), 1366 deletions(-) delete mode 100644 .agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md delete mode 100644 .agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-12-native-compaction-cancellation.md delete mode 100644 packages/components/src/components/sessions/use-session-context-compaction-reconciliation.ts diff --git a/.agents/docs/sessions-live-status.md b/.agents/docs/sessions-live-status.md index f5d56c618..0b00bcbd9 100644 --- a/.agents/docs/sessions-live-status.md +++ b/.agents/docs/sessions-live-status.md @@ -51,17 +51,3 @@ null`: browser offline, machine removed or offline) — the chip owns that story (Stop visibility, busy-send queue routing) shares the SAME time-bounded pre-start signal, so a stalled dispatch no longer holds the composer in a busy state either. -- Context compaction is different from live working status: its spinner follows - the latest durable `context_compaction` tool-call status. When that item is still - `pending` or `in_progress` inside a finished assistant turn, an inactive Session - with browser connectivity online and its Session room synced asks a - capability-compatible owner daemon to reconcile the - exact turn/tool ids. The renderer does not convert `finished` into a provider - terminal signal. The daemon writes `failed` only after its live execution state - shows no Session work, and it updates only the named history entry. Unsupported, - offline, non-owner, active, indeterminate, mismatched, and unreachable cases return - `retry` without changing the durable item. The renderer attempts each candidate, - daemon generation, and connectivity evidence once; leaving eligibility clears that - evidence so a later inactive, browser-online, or Session-room-reconnected transition - can retry. Presence heartbeats never become polling, and only a remotely confirmed - repair is terminal. diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md index 27759d8e4..d40aafd60 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md +++ b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md @@ -39,9 +39,7 @@ provider terminal states remain unchanged, and a late provider update for the sa This fixes future error paths and histories that receive a later failure-aware finalization. It does not migrate already persisted stale histories, because those histories contain no durable evidence that distinguishes #570 from an interrupted -but still-active provider prompt. A later, separate decision adds -[owner-evidence reconciliation](2026-09-11-stale-context-compaction-reconciliation.md) -without weakening that constraint. +but still-active provider prompt. ## Scope and verification diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md index e990994e0..0fd2be839 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md @@ -32,9 +32,7 @@ raw completion。只有 drain 完成以后,host 才收敛未完成 compaction 该设计修复未来的 error path,并处理之后再次经过 failure-aware finalization 的历史。 它不会迁移已经持久化的陈旧历史,因为这些历史里没有 durable evidence 能区分 #570 和 -“已中断但 provider prompt 仍活跃”的情况。后续的独立决策通过 -[owner 证据协调](2026-09-11-stale-context-compaction-reconciliation.zh.md)补上旧历史修复, -但不放宽这一约束。 +“已中断但 provider prompt 仍活跃”的情况。 ## 范围与验证 diff --git a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md deleted file mode 100644 index bf1bc6b2d..000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md +++ /dev/null @@ -1,84 +0,0 @@ -# Reconcile stale context compaction with live ownership evidence - -Status: implemented -Translation: current - -[中文](2026-09-11-stale-context-compaction-reconciliation.zh.md) - -## Abstract - -Sessions written before the provider-failure fix can retain a `pending` or -`in_progress` context-compaction item after the Agent has stopped, so reopening or -upgrading keeps the durable spinner visible. Lody now treats a finished owning turn -as a repair candidate and asks the Session's current owner daemon to reconcile the -exact turn and tool-call ids. The daemon writes `failed` only when live execution -ownership proves that turn is inactive; absent or ambiguous evidence leaves history -unchanged. - -## Decision - -The renderer remains a reader of durable compaction status. It does not reinterpret -`SessionHistory.finished`, a timeout, a restart, or missing presence as provider -termination. When the latest compaction is unresolved, its assistant turn is -finished, the Session is inactive, browser connectivity is online, the Session room -is synced, and the owner daemon advertises the capability, the renderer requests -reconciliation. A single `lastAttemptEvidenceRef` bounds requests by the exact -candidate, owner-daemon instance, and connectivity state. Leaving eligibility clears -that evidence, so a later inactive, browser-online, or Session-room-reconnected -transition can try again. RPC results do not drive a parallel renderer state machine, -and heartbeat-only presence updates do not poll. - -The request names `sessionId`, `turnId`, and `toolCallId`. The target daemon first -verifies that current Session metadata assigns ownership to its own machine, joins the -Session document, and waits for its remote state before drawing a conclusion. It then -rejects any active turn, pending create, automation, active Session presence, or -pending dispatch, and verifies ownership again before writing. The mutation uses -`SessionDocument.updateHistory(..., { onlyEntryId: turnId })`, so a new turn that -starts after the liveness check has a different entry identity and remains untouched. -Reconciliation therefore does not acquire the Session history rewrite barrier and -does not need barrier-release wakeups for ordinary or Goal dispatch. - -The daemon returns only `reconciled` or `retry`. It reports `reconciled` only when the -named unresolved item changed to `failed` and the remote write was confirmed. A -non-owner, unsynced document, live work, mismatch, already-settled item, failed write -confirmation, or handler error returns `retry`. The reconciliation RPC uses the -ordinary request lane because document sync and history writes are not fast -control-plane work. - -The addressed turn remains unchanged when it is active or when unassigned work makes -the result indeterminate. Once the daemon can prove the turn is not its live owner, -it changes only the named unresolved `context_compaction` item to `failed`; terminal, -mismatched, unknown, and unrelated stored items are preserved. - -This is lazy per opened Session rather than a startup migration. A global history -scan would activate arbitrarily many old documents and still could not establish -provider ownership. Versioned Machine capability negotiation also keeps mixed-version -clients from sending a write request to daemons that do not implement this evidence -contract. - -## Alternatives and limits - -Age-based cleanup was rejected because elapsed time does not prove the provider has -released the turn. UI-only suppression was rejected because it would make the -transcript disagree with durable history and other readers. Treating `finished` as -sufficient was rejected for the same reason recorded in the -[provider-failure decision](2026-09-10-context-compaction-terminal-state.md): host -finalization can precede provider termination. - -An offline or old owner daemon cannot repair the item. The Session continues to show -its durable state until a compatible owner can provide evidence. This change does not -claim that every historical unresolved compaction is stale, and it does not rewrite -more than the exact activity requested by the current view. - -## Verification - -Behavioral coverage exercises exact-item mutation, active and indeterminate ownership -outcomes, cold/unsynced documents, write-confirmation failure, and a turn that starts -between the final liveness check and the targeted history mutation. Renderer coverage -exercises owner-daemon generations, inactive eligibility, local-first browser -connectivity restoration while Machine presence remains online, Session-room -reconnection while browser connectivity remains online, and bounded attempts for -unchanged evidence. RPC coverage exercises ordinary-lane isolation, local -capability gating, both local and Loro transports, and shared request/response schemas. -No startup scan, storage migration, rewrite-barrier compensation, or end-to-end -provider fixture was added. diff --git a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md b/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md deleted file mode 100644 index b4064a607..000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md +++ /dev/null @@ -1,68 +0,0 @@ -# 使用实时所有权证据协调陈旧的上下文压缩状态 - -Status: implemented -Translation: current - -[English](2026-09-11-stale-context-compaction-reconciliation.md) - -## 摘要 - -Provider failure 修复之前写入的 Session,可能在 Agent 已停止后仍保留 `pending` 或 -`in_progress` 的 context-compaction item,导致重新打开或升级后仍显示持久化的旋转状态。 -现在,Lody 会把 owner turn 已 finished 的记录视为修复候选,并请求 Session 当前的 owner -daemon 协调精确的 turn 与 tool-call id。只有实时执行所有权证明该 turn 已不活跃时,daemon -才会写入 `failed`;证据缺失或存在歧义时,历史保持不变。 - -## 决策 - -Renderer 仍只读取持久化的 compaction 状态。它不会把 `SessionHistory.finished`、超时、 -重启或 presence 缺失重新解释成 provider 已终止。仅当最新 compaction 未完成、所属 -assistant turn 已 finished、Session inactive、browser connectivity online、Session room -已 synced,且 owner daemon 声明支持该能力时,renderer 才请求 reconciliation。一个 -`lastAttemptEvidenceRef` 按 exact candidate、owner-daemon instance 和 connectivity state -限定请求。离开 eligibility 会清除该 evidence,因此后续 inactive、browser online 或 -Session room 重连转换可以再试。RPC 结果不驱动另一套 renderer state machine,单纯 -heartbeat 更新也不会退化成轮询。 - -请求携带 `sessionId`、`turnId` 和 `toolCallId`。目标 daemon 先验证当前 Session metadata -确实把所有权分配给本机,加入 Session document,并等待其远端状态完成。然后它会拒绝 -任何 active turn、pending create、automation、active Session presence 或 pending dispatch,并在写入前 -再次确认 owner。修改使用 `SessionDocument.updateHistory(..., { onlyEntryId: turnId })`, -因此在 liveness 检查后启动的新 turn 具有不同 entry identity,不会被修改。Reconciliation -不再获取 Session history rewrite barrier,也不需要为普通 dispatch 或 Goal dispatch 增加 -barrier-release wakeup。 - -Daemon 只返回 `reconciled` 或 `retry`。只有指定的未完成 item 变为 `failed`,且远端 -写入已确认时,才返回 `reconciled`。非 owner、document 未同步、存在 live work、不匹配、 -已终态 item、写入确认失败或 handler error 都返回 `retry`。Reconciliation RPC 使用普通 -request lane,因为 document sync 和 history write 不是快速控制面工作。 - -目标 turn 仍 active,或存在未归属工作导致结论不确定时,历史不变。当 daemon 能证明该 turn -已不是实时 owner 时,它只把指定且未完成的 `context_compaction` item 改成 `failed`;已有终态、 -不匹配、未知和无关的持久化 item 均被保留。 - -该修复针对打开的 Session 惰性执行,而不是启动时迁移。全局扫描会激活数量不受控的旧文档, -却仍无法建立 provider 所有权。版本化的 Machine capability negotiation 也确保混合版本客户端 -不会向未实现这套证据契约的 daemon 发送写请求。 - -## 替代方案与边界 - -基于记录年龄的清理被否决,因为经过多长时间不能证明 provider 已释放 turn。只在 UI 隐藏 -spinner 也被否决,因为这会让 transcript 与持久化历史及其他 reader 不一致。把 `finished` -视为充分条件同样被否决;正如 -[provider failure 决策](2026-09-10-context-compaction-terminal-state.zh.md)所记录,host -finalization 可能早于 provider termination。 - -Owner daemon 离线或版本过旧时无法修复该 item;Session 会继续显示其持久化状态,直到兼容的 -owner 能提供证据。本次变更不假定每一条历史未完成 compaction 都已陈旧,也不会改写当前视图 -请求之外的活动。 - -## 验证 - -行为测试覆盖精确 item 修改、active 与 indeterminate 所有权结果、cold/unsynced document、 -写入确认失败,以及最终 liveness 检查与 targeted history mutation 之间启动的新 turn。 -Renderer 测试覆盖 owner-daemon generation、inactive eligibility、Machine presence 保持 online 时的 -local-first browser connectivity 恢复、browser connectivity 保持 online 时的 Session room 重连, -以及 evidence 未变时的有界尝试。RPC 测试覆盖普通 lane 隔离、本地 capability gating、local 与 -Loro transport,以及共享 request/response schema。本次没有加入启动扫描、存储迁移、 -rewrite-barrier compensation 或端到端 provider fixture。 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..fa27c2ad3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-12-native-compaction-cancellation.md @@ -0,0 +1,60 @@ +# Cancel compaction through the native turn + +Status: implemented +Translation: pending + +## 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. PR #618 removes its +automatic historical reconciliation protocol and fixes this execution boundary. +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. + +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. +- 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 616 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 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. Documentation translation remains pending. diff --git a/apps/cli/src/lib/assistant-turn-finalize.test.ts b/apps/cli/src/lib/assistant-turn-finalize.test.ts index 97f824e89..59bee68a7 100644 --- a/apps/cli/src/lib/assistant-turn-finalize.test.ts +++ b/apps/cli/src/lib/assistant-turn-finalize.test.ts @@ -1,10 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { SessionHistoryInput } from '@lody/shared'; -import { - markAssistantTurnFinished, - settleContextCompactionItemAsFailed, -} from './assistant-turn-finalize'; +import { markAssistantTurnFinished } from './assistant-turn-finalize'; const OPENED_AT = Date.parse('2026-01-01T00:00:00.000Z'); const TURN_ENDED_AT = OPENED_AT + 12_000; @@ -139,74 +136,3 @@ describe('markAssistantTurnFinished', () => { expect(history[1]?.finished).toBeUndefined(); }); }); - -describe('settleContextCompactionItemAsFailed', () => { - it('settles only the exact unresolved activity owned by the requested turn', () => { - const history = [ - assistantEntry({ - id: 'assistant:u1', - items: [ - { - type: 'tool_call', - toolCallId: 'compact-old', - title: 'Context compacting', - status: 'in_progress', - activityKind: 'context_compaction', - }, - ], - }), - assistantEntry({ - id: 'assistant:u2', - items: [ - { - type: 'tool_call', - toolCallId: 'compact-current', - title: 'Context compacting', - status: 'in_progress', - activityKind: 'context_compaction', - }, - ], - }), - ]; - - expect( - settleContextCompactionItemAsFailed(history, { - turnId: 'assistant:u1', - toolCallId: 'compact-old', - }) - ).toBe(true); - expect(history[0]?.items?.[0]).toMatchObject({ status: 'failed' }); - expect(history[1]?.items?.[0]).toMatchObject({ status: 'in_progress' }); - }); - - it('preserves terminal and mismatched activities', () => { - const history = [ - assistantEntry({ - id: 'assistant:u1', - items: [ - { - type: 'tool_call', - toolCallId: 'compact-1', - title: 'Context compacting', - status: 'completed', - activityKind: 'context_compaction', - }, - ], - }), - ]; - - expect( - settleContextCompactionItemAsFailed(history, { - turnId: 'assistant:u1', - toolCallId: 'compact-other', - }) - ).toBe(false); - expect( - settleContextCompactionItemAsFailed(history, { - turnId: 'assistant:u1', - toolCallId: 'compact-1', - }) - ).toBe(false); - expect(history[0]?.items?.[0]).toMatchObject({ status: 'completed' }); - }); -}); diff --git a/apps/cli/src/lib/assistant-turn-finalize.ts b/apps/cli/src/lib/assistant-turn-finalize.ts index ffe2afb98..e1c3e60f8 100644 --- a/apps/cli/src/lib/assistant-turn-finalize.ts +++ b/apps/cli/src/lib/assistant-turn-finalize.ts @@ -1,30 +1,5 @@ import type { SessionHistoryInput } from '@lody/shared'; -export const settleContextCompactionItemAsFailed = ( - history: SessionHistoryInput[], - options: { turnId: string; toolCallId: string } -): boolean => { - const entry = history.find( - (candidate) => candidate.role === 'assistant' && candidate.id === options.turnId - ); - if (!entry?.items) return false; - - let changed = false; - entry.items = entry.items.map((item) => { - if ( - item.type !== 'tool_call' || - item.activityKind !== 'context_compaction' || - item.toolCallId !== options.toolCallId || - (item.status !== 'pending' && item.status !== 'in_progress') - ) { - return item; - } - changed = true; - return { ...item, status: 'failed' as const }; - }); - return changed; -}; - /** * Stamp the terminal footprint (`finished`/`endedAt`/`permissionWaitMs`) on the * assistant entry a finalize call owns. Extracted from `finalizeACPState` so the diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index d35c90c79..3fc61500e 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -61,7 +61,6 @@ import { getCodeCollabFileIndexSignalFlockDocId, type SessionContextWindowUsage, type SessionHistoryInput, - type SessionContextCompactionReconcileResponse, type SessionLegacyMetaFields, PERMISSION_REQUEST_TIMEOUT_MS, type ChatFailedCode, @@ -265,10 +264,7 @@ import { resolveImageGenerationStatusWrite, shouldRestoreRunningAfterPermission, } from './session-activity-status'; -import { - markAssistantTurnFinished, - settleContextCompactionItemAsFailed, -} from './assistant-turn-finalize'; +import { markAssistantTurnFinished } from './assistant-turn-finalize'; import type { RepoWatchHandle } from 'loro-repo'; import { AgentClient, @@ -3405,8 +3401,6 @@ export class MessageHandler { observedAtMs: getServerNow(), }; }, - reconcileSessionContextCompaction: async (args) => - await this.reconcileSessionContextCompaction(args), steerSession: async (args) => await this.steerSessionWithAccessCheck(args), controlSessionGoal: async (args) => await this.controlSessionGoalWithAccessCheck(args), terminateSession: async ({ sessionId }) => await this.terminateAcpSession(sessionId), @@ -6675,8 +6669,6 @@ export class MessageHandler { error: result.error, }; } - case 'session/reconcile-context-compaction': - return await this.reconcileSessionContextCompaction(request.params); case 'session/dispatch-turn': { // Mirrors the Loro Streams Machine RPC path: normalize the opaque // transport-level input config, then offer the turn to the dispatch @@ -6783,81 +6775,6 @@ export class MessageHandler { } } - private async reconcileSessionContextCompaction(args: { - sessionId: SessionId; - turnId: string; - toolCallId: string; - }): Promise { - const response = ( - outcome: SessionContextCompactionReconcileResponse['outcome'], - options: { error?: string } = {} - ): SessionContextCompactionReconcileResponse => ({ - type: 'session/reconcile-context-compaction_response', - ...args, - outcome, - ...options, - }); - - try { - const verifyOwnership = async (): Promise => { - const metaRecord = await this.workspaceDocument.repo.getDocMeta( - getSessionRoomId(args.sessionId) - ); - const meta = - metaRecord?.meta && !isLoroRepoDocDeleted(metaRecord) - ? (metaRecord.meta as SessionMeta) - : undefined; - return meta?.machineId === this.machineId; - }; - const hasLiveSessionWork = (): boolean => { - const execution = this.executionService.getExecutionSnapshot(args.sessionId); - return ( - execution.hasActiveTurn || - execution.hasBlockingPendingCreate || - execution.hasActiveAutomation || - this.sessionActivePresence.has(args.sessionId) || - this.sessionDispatchWatcher.hasPendingDispatch(args.sessionId) - ); - }; - - if (!(await verifyOwnership())) { - return response('retry', { error: 'The target daemon does not own this Session.' }); - } - - const sessionDoc = await this.workspaceDocument.getOrCreateSessionDoc(args.sessionId); - await sessionDoc.ensureDocRoomJoined(); - if (!(await sessionDoc.waitUntilSynced())) { - return response('retry', { error: 'Session history is not synced with its owner.' }); - } - - if (hasLiveSessionWork()) { - return response('retry', { error: 'The daemon still has live Session work.' }); - } - - if (!(await verifyOwnership())) { - return response('retry', { error: 'The target daemon no longer owns this Session.' }); - } - - let reconciled = false; - await sessionDoc.updateHistory( - (history) => { - reconciled = settleContextCompactionItemAsFailed(history, args); - return history; - }, - { onlyEntryId: args.turnId } - ); - - if (reconciled && !(await sessionDoc.waitUntilSynced())) { - return response('retry', { - error: 'The reconciled Session history was not confirmed by its owner.', - }); - } - return response(reconciled ? 'reconciled' : 'retry'); - } catch (error) { - return response('retry', { error: formatErrorMessage(error) }); - } - } - private async respondToLegacyCodeCollabHostStart( message: SessionCodeCollabHostStartRequestValidated ): Promise { diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 59bb88ad1..227fa4ed0 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -1152,7 +1152,7 @@ export class SessionExecutionService { /** * Claims a short operation that must not overlap a durable history rewrite: - * visible-turn ownership, acknowledged steer transfer, or queue promotion. + * visible-turn ownership, acknowledged steer transfer, stale repair, or queue promotion. */ tryAcquireSessionRewriteConflictLease(sessionId: SessionId): (() => void) | null { if ( diff --git a/apps/cli/tests/message-handler-acp-batching.test.ts b/apps/cli/tests/message-handler-acp-batching.test.ts index a0a797682..43dd637df 100644 --- a/apps/cli/tests/message-handler-acp-batching.test.ts +++ b/apps/cli/tests/message-handler-acp-batching.test.ts @@ -96,7 +96,6 @@ const createHandlerHarness = async (sessionIds: SessionId[]) => { watch: vi.fn(() => ({ unsubscribe: vi.fn() })), getDocMeta: vi.fn(async () => ({ meta: { - machineId: 'm-1', needToArchiveSessions: {}, needToDeleteSessions: {}, }, @@ -467,214 +466,6 @@ describe('MessageHandler ACP batching', () => { } }); - it('reconciles stale compaction only after the owner daemon proves its turn is inactive', async () => { - vi.useRealTimers(); - const sessionId = 'stale-compaction-reconciliation' as SessionId; - const { repo, docs, workspaceDocument, handler } = await createHandlerHarness([sessionId]); - const doc = docs.get(sessionId); - if (!doc) throw new Error(`Missing session doc for ${sessionId}`); - const host = handler as unknown as { - beginConversationTurn(sessionId: SessionId): string; - enqueueACPUpdate(sessionId: SessionId, update: AcpSessionNotification): void; - flushACPUpdatesNow(sessionId: SessionId): Promise; - finalizeACPState(sessionId: SessionId, turnId?: string): Promise; - reconcileSessionContextCompaction(args: { - sessionId: SessionId; - turnId: string; - toolCallId: string; - }): Promise<{ outcome: string; error?: string }>; - executionService: { - getExecutionSnapshot(sessionId: SessionId): { - activeTurnId?: string; - hasActiveTurn: boolean; - hasBlockingPendingCreate: boolean; - hasReusableSession: boolean; - hasRewriteBarrier: boolean; - hasActiveAutomation: boolean; - }; - }; - sessionActivePresence: { has(sessionId: SessionId): boolean }; - sessionDispatchWatcher: { hasPendingDispatch(sessionId: SessionId): boolean }; - }; - const findCompaction = (history: SessionHistoryInput[], toolCallId = 'compact-stale') => - history - .flatMap((entry) => readItems(entry)) - .find( - (item) => - item.type === 'tool_call' && - item.activityKind === 'context_compaction' && - item.toolCallId === toolCallId - ); - - try { - const turnId = host.beginConversationTurn(sessionId); - host.enqueueACPUpdate(sessionId, { - sessionId, - update: { - sessionUpdate: 'tool_call', - toolCallId: 'compact-stale', - title: 'Context compacting', - status: 'in_progress', - _meta: { contextCompaction: true }, - }, - }); - await host.flushACPUpdatesNow(sessionId); - await host.finalizeACPState(sessionId, turnId); - - workspaceDocument.repo.getDocMeta.mockResolvedValueOnce({ - meta: { - machineId: 'm-other', - needToArchiveSessions: {}, - needToDeleteSessions: {}, - }, - }); - await expect( - host.reconcileSessionContextCompaction({ - sessionId, - turnId, - toolCallId: 'compact-stale', - }) - ).resolves.toMatchObject({ outcome: 'retry' }); - expect(findCompaction(await doc.getHistory())).toMatchObject({ status: 'in_progress' }); - - const getExecutionSnapshot = vi.spyOn(host.executionService, 'getExecutionSnapshot'); - getExecutionSnapshot.mockReturnValue({ - activeTurnId: turnId, - hasActiveTurn: true, - hasBlockingPendingCreate: false, - hasReusableSession: true, - hasRewriteBarrier: false, - hasActiveAutomation: false, - }); - await expect( - host.reconcileSessionContextCompaction({ - sessionId, - turnId, - toolCallId: 'compact-stale', - }) - ).resolves.toMatchObject({ outcome: 'retry' }); - expect(findCompaction(await doc.getHistory())).toMatchObject({ status: 'in_progress' }); - - getExecutionSnapshot.mockReturnValue({ - hasActiveTurn: false, - hasBlockingPendingCreate: false, - hasReusableSession: true, - hasRewriteBarrier: false, - hasActiveAutomation: false, - }); - const hasActivePresence = vi - .spyOn(host.sessionActivePresence, 'has') - .mockReturnValueOnce(true) - .mockReturnValue(false); - await expect( - host.reconcileSessionContextCompaction({ - sessionId, - turnId, - toolCallId: 'compact-stale', - }) - ).resolves.toMatchObject({ outcome: 'retry' }); - expect(findCompaction(await doc.getHistory())).toMatchObject({ status: 'in_progress' }); - - const ensureDocRoomJoined = vi.spyOn(doc, 'ensureDocRoomJoined').mockResolvedValue(undefined); - const waitUntilSynced = vi.spyOn(doc, 'waitUntilSynced').mockResolvedValueOnce(false); - const originalUpdateHistory = doc.updateHistory.bind(doc); - const updateHistory = vi.spyOn(doc, 'updateHistory'); - await expect( - host.reconcileSessionContextCompaction({ - sessionId, - turnId, - toolCallId: 'compact-stale', - }) - ).resolves.toMatchObject({ outcome: 'retry' }); - expect(ensureDocRoomJoined).toHaveBeenCalled(); - expect(updateHistory).not.toHaveBeenCalled(); - expect(findCompaction(await doc.getHistory())).toMatchObject({ status: 'in_progress' }); - - getExecutionSnapshot.mockReturnValue({ - hasActiveTurn: false, - hasBlockingPendingCreate: false, - hasReusableSession: true, - hasRewriteBarrier: false, - hasActiveAutomation: false, - }); - hasActivePresence.mockReset().mockReturnValue(false); - vi.spyOn(host.sessionDispatchWatcher, 'hasPendingDispatch').mockReturnValue(false); - const concurrentTurnId = 'assistant:concurrent-turn'; - updateHistory.mockImplementationOnce(async (updateFn, options) => { - await originalUpdateHistory((history) => { - history.push({ - id: concurrentTurnId, - role: 'assistant', - timestamp: '2026-09-12T00:00:00.000Z', - items: [{ type: 'text', text: 'concurrent turn' }], - }); - return history; - }); - getExecutionSnapshot.mockReturnValue({ - activeTurnId: concurrentTurnId, - hasActiveTurn: true, - hasBlockingPendingCreate: false, - hasReusableSession: true, - hasRewriteBarrier: false, - hasActiveAutomation: false, - }); - await originalUpdateHistory(updateFn, options); - }); - waitUntilSynced.mockResolvedValueOnce(true).mockResolvedValueOnce(true); - const reconciliation = await host.reconcileSessionContextCompaction({ - sessionId, - turnId, - toolCallId: 'compact-stale', - }); - expect(reconciliation.error).toBeUndefined(); - expect(reconciliation.outcome).toBe('reconciled'); - expect(updateHistory).toHaveBeenCalledWith(expect.any(Function), { onlyEntryId: turnId }); - expect((await doc.getHistory()).find((entry) => entry.id === concurrentTurnId)).toMatchObject( - { - items: [{ type: 'text', text: 'concurrent turn' }], - } - ); - expect(hasActivePresence).toHaveBeenCalled(); - expect(findCompaction(await doc.getHistory())).toMatchObject({ status: 'failed' }); - expect(isSessionContextCompacting(await doc.getHistory())).toBe(false); - - getExecutionSnapshot.mockReturnValue({ - hasActiveTurn: false, - hasBlockingPendingCreate: false, - hasReusableSession: true, - hasRewriteBarrier: false, - hasActiveAutomation: false, - }); - - const unsyncedTurnId = host.beginConversationTurn(sessionId); - host.enqueueACPUpdate(sessionId, { - sessionId, - update: { - sessionUpdate: 'tool_call', - toolCallId: 'compact-unsynced', - title: 'Context compacting', - status: 'in_progress', - _meta: { contextCompaction: true }, - }, - }); - await host.flushACPUpdatesNow(sessionId); - await host.finalizeACPState(sessionId, unsyncedTurnId); - waitUntilSynced.mockResolvedValueOnce(true).mockResolvedValueOnce(false); - await expect( - host.reconcileSessionContextCompaction({ - sessionId, - turnId: unsyncedTurnId, - toolCallId: 'compact-unsynced', - }) - ).resolves.toMatchObject({ outcome: 'retry' }); - expect(findCompaction(await doc.getHistory(), 'compact-unsynced')).toMatchObject({ - status: 'failed', - }); - } finally { - await destroyRepoOnRealTimers(repo); - } - }); - it('persists updates buffered during the finalization tail instead of dropping them', async () => { const sessionId = 's-1' as SessionId; const { repo, docs, handler } = await createHandlerHarness([sessionId]); diff --git a/apps/cli/tests/message-handler-machine-registration.test.ts b/apps/cli/tests/message-handler-machine-registration.test.ts index a2b8dabbc..f9401161b 100644 --- a/apps/cli/tests/message-handler-machine-registration.test.ts +++ b/apps/cli/tests/message-handler-machine-registration.test.ts @@ -187,7 +187,6 @@ describe('MessageHandler machine registration', () => { localFileResources: 1, providerSetup: 1, acpProtocolAuthentication: 2, - contextCompactionReconciliation: 1, }); await handler.cleanup(); diff --git a/packages/acp-extension-codex b/packages/acp-extension-codex index 314b38237..d7a0a9d1f 160000 --- a/packages/acp-extension-codex +++ b/packages/acp-extension-codex @@ -1 +1 @@ -Subproject commit 314b38237273f3b71335d5193498b60755665769 +Subproject commit d7a0a9d1fe125446708351236b4e7d7f185880e9 diff --git a/packages/components/src/atoms/presence.ts b/packages/components/src/atoms/presence.ts index a2cff2f97..b900624e4 100644 --- a/packages/components/src/atoms/presence.ts +++ b/packages/components/src/atoms/presence.ts @@ -2,7 +2,6 @@ import { atom } from 'jotai'; import { atomFamily } from 'jotai/utils'; import { collectOnlineMachineIdsFromPresence, - findFreshMachinePresenceState, findFreshSessionPresenceState, getServerNow, type LodyPresenceStateMap, @@ -37,18 +36,6 @@ export const sessionLiveStatusAtomFamily = atomFamily((sessionId: SessionId) => atom((get) => get(sessionLivePresenceAtomFamily(sessionId))?.status ?? null) ); -export const machineLivePresenceAtomFamily = atomFamily((machineId?: MachineId) => - atom((get) => - machineId - ? (findFreshMachinePresenceState( - get(lodyPresenceStatesAtom), - machineId, - get(lodyPresenceNowMsAtom) - ) ?? null) - : null - ) -); - const setsEqual = (a: ReadonlySet, b: ReadonlySet): boolean => { if (a.size !== b.size) return false; for (const id of a) { diff --git a/packages/components/src/atoms/runtime.ts b/packages/components/src/atoms/runtime.ts index 148feead9..1316a03f9 100644 --- a/packages/components/src/atoms/runtime.ts +++ b/packages/components/src/atoms/runtime.ts @@ -290,11 +290,6 @@ export type WorkspaceRuntime = { turnId: string, options?: { timeoutMs?: number } ) => Promise; - requestSessionContextCompactionReconciliation: ( - machineId: MachineId, - args: { sessionId: SessionId; turnId: string; toolCallId: string }, - options?: { timeoutMs?: number } - ) => Promise; requestSessionSteer: ( machineId: MachineId, args: { diff --git a/packages/components/src/components/sessions/README.md b/packages/components/src/components/sessions/README.md index 406ace023..3ef99ef1a 100644 --- a/packages/components/src/components/sessions/README.md +++ b/packages/components/src/components/sessions/README.md @@ -27,7 +27,6 @@ What each file in this directory is responsible for. Binding rules live in | File | Responsibility | | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `session-chat-interface.tsx` | Conversation surface: stream, header variants, read receipts, launchers | -| `use-session-context-compaction-reconciliation.ts` | One stale-compaction repair attempt per inactive candidate, daemon generation, and browser/Session-room connectivity evidence | | [`chat-share-image-dialog.tsx`](chat-share-image-dialog.tsx) | Selected-message card styling, historical metadata, PNG export, and local image copying; [draft contract](../../../../../specs/chat-share-image.md) | | `draft-session-chat-interface.tsx` | Draft variant of the conversation surface | | `session-chat-input-area.tsx` | Composer: attachments, run-config footer, submit | diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index 72283bdb5..f89ca5839 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -55,10 +55,7 @@ import { Button } from '@/ui/button'; import { isMacOSElectronRenderer, useElectronFullscreen } from '@/lib/electron'; import { getIpcServices } from '@/lib/electron-ipc-client'; import { matchesKeyboardEvent } from '@/lib/commands/key-matcher'; -import { - canStopAgentEnabled, - findActiveSessionContextCompaction, -} from '@/lib/session-context-compaction'; +import { isSessionContextCompacting, canStopAgentEnabled } from '@/lib/session-context-compaction'; import { hasFileTransfer, readDroppedTransfer } from '@/lib/file-drop'; import { resolveProgrammaticTurnAgentRole } from '@/lib/composer-agent-roles'; import { mergeDropZoneHandlers, useDropZone } from '@/hooks/use-drop-zone'; @@ -117,7 +114,6 @@ import { hasReportedPreviewTarget, isSessionGoalCleared, isSessionGoalActive, - machineSupportsContextCompactionReconciliationProtocol, normalizeSessionInputBlocks, normalizeSessionTurnInputConfig, resolveSessionAcpRuntimeConfig, @@ -149,12 +145,8 @@ import { openedSessionsAtomFamily, sessionMetaAtomFamily, } from '@/atoms/doc-meta'; -import { - useMachineOnlineStatus, - useMachinePresenceInstanceId, -} from '@/hooks/use-machine-online-status'; +import { useMachineOnlineStatus } from '@/hooks/use-machine-online-status'; import { useDelayedFlag } from '@/hooks/use-delayed-flag'; -import { useSessionContextCompactionReconciliation } from './use-session-context-compaction-reconciliation'; import { resolveSessionStatusStripState } from './session-status-strip'; import { isSyncingRoomSyncState } from '@/lib/room-sync-state'; import { @@ -2116,7 +2108,6 @@ export const SessionChatInterface = memo( const localHomeDir = useAtomValue(localHomeDirAtom); const liveSessionPresence = useAtomValue(sessionLivePresenceAtomFamily(session.id)); const liveSessionStatus = liveSessionPresence?.status ?? null; - const isSessionActive = liveSessionStatus != null; const isLocalSession = !!localMachineId && session.machineId === localMachineId; const [pendingRemoteHtmlFileName, setPendingRemoteHtmlFileName] = useState(null); const { @@ -2255,7 +2246,6 @@ export const SessionChatInterface = memo( const docMetaCacheReady = useAtomValue(docMetaCacheReadyAtom); const isMachineRemoved = !sessionMachine && docMetaCacheReady; const sessionMachineOnlineStatus = useMachineOnlineStatus(session.machineId); - const sessionMachinePresenceInstanceId = useMachinePresenceInstanceId(session.machineId); const browserOnline = useAtomValue(browserOnlineAtom); const externalHistorySyncLabel = isExternalHistoryRefreshing ? t('sessions.externalHistorySyncing', { @@ -2404,6 +2394,7 @@ export const SessionChatInterface = memo( const deferredSearchQuery = useDeferredValue(searchQuery); const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0); const lastSearchAnalyticsKeyRef = useRef(null); + const runtime = useAtomValue(activeWorkspaceRuntimeAtom); const queuedMessageBehavior = useAtomValue(queuedMessageBehaviorAtom); const { @@ -2708,25 +2699,10 @@ export const SessionChatInterface = memo( const canForkFromMenu = Boolean( onForkSessionExternal || (lastCompletedAssistantMessageId && onForkLastAssistant) ); - const activeContextCompaction = useMemo( - () => findActiveSessionContextCompaction(sessionHistory), + const isContextCompacting = useMemo( + () => isSessionContextCompacting(sessionHistory), [sessionHistory] ); - const isContextCompacting = activeContextCompaction !== null; - const canReconcileContextCompaction = - sessionMachineOnlineStatus === 'online' && - machineSupportsContextCompactionReconciliationProtocol(sessionMachine); - useSessionContextCompactionReconciliation({ - activeCompaction: activeContextCompaction, - canReconcile: canReconcileContextCompaction, - isConnectivityOnline: browserOnline, - isSessionRoomSynced: sessionDocSyncState === 'synced', - isSessionActive, - machineId: session.machineId, - ownerInstanceId: sessionMachinePresenceInstanceId, - runtime, - sessionId: session.id, - }); // Pending scheduled tasks (cron / wakeup) are derived on the fly from the // Cron*/ScheduleWakeup tool_call items already in history — nothing extra is // persisted. Serialize to a key so the input area only re-renders when the @@ -2802,6 +2778,7 @@ export const SessionChatInterface = memo( return () => clearTimeout(timer); }, [pendingGoalCommand]); + const isSessionActive = liveSessionStatus != null; // CLI-reported presence is the fact source for "working now". The only // frontend-derived state is the dispatched-but-not-started window, read // from the trailing pending user turn in history — never from meta diff --git a/packages/components/src/components/sessions/use-session-context-compaction-reconciliation.ts b/packages/components/src/components/sessions/use-session-context-compaction-reconciliation.ts deleted file mode 100644 index 2b90b4d4f..000000000 --- a/packages/components/src/components/sessions/use-session-context-compaction-reconciliation.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { useEffect, useRef } from 'react'; -import type { MachineId, SessionId } from '@lody/shared'; - -import type { WorkspaceRuntime } from '@/atoms/runtime'; -import type { ActiveSessionContextCompaction } from '@/lib/session-context-compaction'; - -type ReconciliationRuntime = Pick< - WorkspaceRuntime, - 'requestSessionContextCompactionReconciliation' ->; - -export const useSessionContextCompactionReconciliation = ({ - activeCompaction, - canReconcile, - isConnectivityOnline, - isSessionRoomSynced, - isSessionActive, - machineId, - ownerInstanceId, - runtime, - sessionId, -}: { - activeCompaction: ActiveSessionContextCompaction | null; - canReconcile: boolean; - isConnectivityOnline: boolean; - isSessionRoomSynced: boolean; - isSessionActive: boolean; - machineId: MachineId; - ownerInstanceId: string | null; - runtime: ReconciliationRuntime | null; - sessionId: SessionId; -}): void => { - const lastAttemptEvidenceRef = useRef(null); - - useEffect(() => { - if ( - !runtime || - !canReconcile || - !isConnectivityOnline || - !isSessionRoomSynced || - isSessionActive || - !activeCompaction?.turnFinished - ) { - lastAttemptEvidenceRef.current = null; - return; - } - const evidence = JSON.stringify([ - sessionId, - activeCompaction.turnId, - activeCompaction.toolCallId, - ownerInstanceId, - isConnectivityOnline, - isSessionRoomSynced, - ]); - if (lastAttemptEvidenceRef.current === evidence) return; - lastAttemptEvidenceRef.current = evidence; - void runtime - .requestSessionContextCompactionReconciliation(machineId, { - sessionId, - turnId: activeCompaction.turnId, - toolCallId: activeCompaction.toolCallId, - }) - .catch(() => undefined); - }, [ - activeCompaction, - canReconcile, - isConnectivityOnline, - isSessionRoomSynced, - isSessionActive, - machineId, - ownerInstanceId, - runtime, - sessionId, - ]); -}; diff --git a/packages/components/src/hooks/use-machine-online-status.ts b/packages/components/src/hooks/use-machine-online-status.ts index 7d11c1ef5..31601c290 100644 --- a/packages/components/src/hooks/use-machine-online-status.ts +++ b/packages/components/src/hooks/use-machine-online-status.ts @@ -2,7 +2,6 @@ import { useAtomValue } from 'jotai'; import type { MachineId } from '@lody/shared'; import { machineOnlineStatusAtomFamily, - machineLivePresenceAtomFamily, onlineMachineIdsAtom, type MachineOnlineStatus, } from '@/atoms/presence'; @@ -18,13 +17,6 @@ export function useMachineOnlineStatus( return useAtomValue(machineOnlineStatusAtomFamily(machineId ?? undefined)); } -/** Instance identity of the currently fresh daemon generation, when online. */ -export function useMachinePresenceInstanceId( - machineId: MachineId | null | undefined -): string | null { - return useAtomValue(machineLivePresenceAtomFamily(machineId ?? undefined))?.instanceId ?? null; -} - /** Machine ids with a fresh presence heartbeat. Stable reference between recomputes. */ export function useOnlineMachineIds(): ReadonlySet { return useAtomValue(onlineMachineIdsAtom); diff --git a/packages/components/src/lib/session-context-compaction.ts b/packages/components/src/lib/session-context-compaction.ts index a0475972b..b191c5362 100644 --- a/packages/components/src/lib/session-context-compaction.ts +++ b/packages/components/src/lib/session-context-compaction.ts @@ -1,36 +1,19 @@ import type { SessionHistory } from '@lody/shared'; -export type ActiveSessionContextCompaction = { - turnId: string; - toolCallId: string; - turnFinished: boolean; -}; - -export const findActiveSessionContextCompaction = ( - history: readonly Pick[] -): ActiveSessionContextCompaction | null => { +export const isSessionContextCompacting = ( + history: readonly Pick[] +): boolean => { for (let entryIndex = history.length - 1; entryIndex >= 0; entryIndex -= 1) { - const entry = history[entryIndex]; - if (!entry || entry.role !== 'assistant') continue; - const items = entry.items ?? []; + const items = history[entryIndex]?.items ?? []; for (let itemIndex = items.length - 1; itemIndex >= 0; itemIndex -= 1) { const item = items[itemIndex]; if (item?.type !== 'tool_call' || item.activityKind !== 'context_compaction') continue; - if (item.status !== 'pending' && item.status !== 'in_progress') return null; - return { - turnId: entry.id, - toolCallId: item.toolCallId, - turnFinished: entry.finished === true, - }; + return item.status === 'pending' || item.status === 'in_progress'; } } - return null; + return false; }; -export const isSessionContextCompacting = ( - history: readonly Pick[] -): boolean => findActiveSessionContextCompaction(history) !== null; - export type CanStopAgentOptions = { isContextCompacting: boolean; isSessionActive: boolean; diff --git a/packages/components/src/providers/create-workspace-runtime.ts b/packages/components/src/providers/create-workspace-runtime.ts index 10de6aed8..660886950 100644 --- a/packages/components/src/providers/create-workspace-runtime.ts +++ b/packages/components/src/providers/create-workspace-runtime.ts @@ -1662,7 +1662,6 @@ export async function createWorkspaceRuntime(deps: RuntimeDeps): Promise => { - try { - const protocolCapabilities = await deps.getMachineProtocolCapabilities(machineId); - if ( - !machineSupportsContextCompactionReconciliationProtocol({ protocolCapabilities }) - ) { - return null; - } - if (await canUseLocalMachineRpc(machineId)) { - const response = await getLocalMachineRpcSender()?.({ - method: 'session/reconcile-context-compaction', - machineId, - workspaceId, - params: args, - timeoutMs: options?.timeoutMs ?? 10_000, - }); - if (response?.ok) { - return response.result as SessionContextCompactionReconcileResponse; - } - return null; - } - return await ( - await getMachineRpcClient(machineId) - ).requestSessionContextCompactionReconciliation({ - ...args, - timeoutMs: options?.timeoutMs ?? 10_000, - }); - } catch { - return null; - } - }; - const requestSessionTerminate = async ( machineId: MachineId, sessionId: SessionId, @@ -1201,7 +1163,6 @@ export function createWorkspaceMachineRpcFacade(deps: WorkspaceMachineRpcFacadeD return { requestSessionCancel, - requestSessionContextCompactionReconciliation, requestSessionSteer, requestSessionGoal, requestSessionTerminate, diff --git a/packages/components/tests/doc-meta-presence.test.ts b/packages/components/tests/doc-meta-presence.test.ts index 9a963c3b5..621f33f10 100644 --- a/packages/components/tests/doc-meta-presence.test.ts +++ b/packages/components/tests/doc-meta-presence.test.ts @@ -28,7 +28,6 @@ import { import { lodyPresenceStatesAtom, lodyPresenceSyncStateAtom, - machineLivePresenceAtomFamily, machineOnlineStatusAtomFamily, onlineMachineIdsAtom, sessionLiveStatusAtomFamily, @@ -105,7 +104,6 @@ describe('doc meta presence overlay', () => { // liveness is exposed through the presence atoms instead. expect(store.get(machineMetaAtomFamily(machineRoomId))?.lastSeen).toBe(now - 240_000); expect(store.get(onlineMachineIdsAtom).has(machineId)).toBe(true); - expect(store.get(machineLivePresenceAtomFamily(machineId))?.instanceId).toBe(instanceId); store.set(lodyPresenceSyncStateAtom, 'synced'); expect(store.get(machineOnlineStatusAtomFamily(machineId))).toBe('online'); expect(store.get(machineOnlineStatusAtomFamily('missing-machine' as MachineId))).toBe( diff --git a/packages/components/tests/session-context-compaction.test.ts b/packages/components/tests/session-context-compaction.test.ts index 5ad99f732..398cf4ccc 100644 --- a/packages/components/tests/session-context-compaction.test.ts +++ b/packages/components/tests/session-context-compaction.test.ts @@ -1,21 +1,7 @@ -// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import type { SessionHistory } from '@lody/shared'; -import { act, createElement } from 'react'; -import { createRoot } from 'react-dom/client'; -import { describe, expect, it, vi } from 'vitest'; -import type { - MachineId, - SessionContextCompactionReconcileResponse, - SessionHistory, - SessionId, -} from '@lody/shared'; - -import { - canStopAgentEnabled, - findActiveSessionContextCompaction, - isSessionContextCompacting, -} from '../src/lib/session-context-compaction'; -import { useSessionContextCompactionReconciliation } from '../src/components/sessions/use-session-context-compaction-reconciliation'; +import { canStopAgentEnabled, isSessionContextCompacting } from '../src/lib/session-context-compaction'; const historyWithStatus = ( status: 'pending' | 'in_progress' | 'completed' | 'failed', @@ -23,8 +9,6 @@ const historyWithStatus = ( ) => [ { - id: 'assistant:turn-1', - role: 'assistant' as const, finished, items: [ { @@ -36,7 +20,7 @@ const historyWithStatus = ( }, ], }, - ] as Pick[]; + ] as Pick[]; describe('isSessionContextCompacting', () => { it('tracks pending and in-progress compaction tool calls', () => { @@ -44,14 +28,6 @@ describe('isSessionContextCompacting', () => { expect(isSessionContextCompacting(historyWithStatus('in_progress'))).toBe(true); }); - it('returns the exact active activity owner for reconciliation', () => { - expect(findActiveSessionContextCompaction(historyWithStatus('in_progress'))).toEqual({ - turnId: 'assistant:turn-1', - toolCallId: 'context-compaction-1', - turnFinished: false, - }); - }); - it('stops loading after compaction completes or fails', () => { expect(isSessionContextCompacting(historyWithStatus('completed'))).toBe(false); expect(isSessionContextCompacting(historyWithStatus('failed'))).toBe(false); @@ -63,160 +39,6 @@ describe('isSessionContextCompacting', () => { }); }); -describe('context compaction reconciliation retry transitions', () => { - it('retries after the same daemon releases active Session presence', async () => { - const sessionId = 'session-1' as SessionId; - const machineId = 'machine-1' as MachineId; - const activeCompaction = findActiveSessionContextCompaction( - historyWithStatus('in_progress', true) - ); - const request = vi.fn().mockResolvedValue({ - type: 'session/reconcile-context-compaction_response', - sessionId, - turnId: 'assistant:turn-1', - toolCallId: 'context-compaction-1', - outcome: 'retry', - } satisfies SessionContextCompactionReconcileResponse); - const runtime = { requestSessionContextCompactionReconciliation: request }; - - const Probe = ({ isSessionActive }: { isSessionActive: boolean }) => { - useSessionContextCompactionReconciliation({ - activeCompaction, - canReconcile: true, - isConnectivityOnline: true, - isSessionRoomSynced: true, - isSessionActive, - machineId, - ownerInstanceId: 'daemon-1', - runtime, - sessionId, - }); - return null; - }; - - (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - const root = createRoot(document.createElement('div')); - try { - await act(async () => root.render(createElement(Probe, { isSessionActive: true }))); - expect(request).not.toHaveBeenCalled(); - - await act(async () => root.render(createElement(Probe, { isSessionActive: false }))); - expect(request).toHaveBeenCalledTimes(1); - - await act(async () => root.render(createElement(Probe, { isSessionActive: false }))); - expect(request).toHaveBeenCalledTimes(1); - - await act(async () => root.render(createElement(Probe, { isSessionActive: true }))); - await act(async () => root.render(createElement(Probe, { isSessionActive: false }))); - expect(request).toHaveBeenCalledTimes(2); - } finally { - act(() => root.unmount()); - delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; - } - }); - - it('retries after browser connectivity returns while the local machine stays online', async () => { - const sessionId = 'session-1' as SessionId; - const machineId = 'machine-1' as MachineId; - const activeCompaction = findActiveSessionContextCompaction( - historyWithStatus('in_progress', true) - ); - const request = vi.fn().mockResolvedValue({ - type: 'session/reconcile-context-compaction_response', - sessionId, - turnId: 'assistant:turn-1', - toolCallId: 'context-compaction-1', - outcome: 'retry', - } satisfies SessionContextCompactionReconcileResponse); - const runtime = { requestSessionContextCompactionReconciliation: request }; - - const Probe = ({ isConnectivityOnline }: { isConnectivityOnline: boolean }) => { - useSessionContextCompactionReconciliation({ - activeCompaction, - canReconcile: true, - isConnectivityOnline, - isSessionRoomSynced: true, - isSessionActive: false, - machineId, - ownerInstanceId: 'daemon-1', - runtime, - sessionId, - }); - return null; - }; - - (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - const root = createRoot(document.createElement('div')); - try { - await act(async () => root.render(createElement(Probe, { isConnectivityOnline: false }))); - expect(request).not.toHaveBeenCalled(); - - await act(async () => root.render(createElement(Probe, { isConnectivityOnline: false }))); - expect(request).not.toHaveBeenCalled(); - - await act(async () => root.render(createElement(Probe, { isConnectivityOnline: true }))); - expect(request).toHaveBeenCalledTimes(1); - - await act(async () => root.render(createElement(Probe, { isConnectivityOnline: true }))); - expect(request).toHaveBeenCalledTimes(1); - - await act(async () => root.render(createElement(Probe, { isConnectivityOnline: false }))); - await act(async () => root.render(createElement(Probe, { isConnectivityOnline: true }))); - expect(request).toHaveBeenCalledTimes(2); - } finally { - act(() => root.unmount()); - delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; - } - }); - - it('retries after the Session room reconnects while browser connectivity stays online', async () => { - const sessionId = 'session-1' as SessionId; - const machineId = 'machine-1' as MachineId; - const activeCompaction = findActiveSessionContextCompaction( - historyWithStatus('in_progress', true) - ); - const request = vi.fn().mockResolvedValue({ - type: 'session/reconcile-context-compaction_response', - sessionId, - turnId: 'assistant:turn-1', - toolCallId: 'context-compaction-1', - outcome: 'retry', - } satisfies SessionContextCompactionReconcileResponse); - const runtime = { requestSessionContextCompactionReconciliation: request }; - - const Probe = ({ syncState }: { syncState: 'synced' | 'reconnecting' }) => { - useSessionContextCompactionReconciliation({ - activeCompaction, - canReconcile: true, - isConnectivityOnline: true, - isSessionRoomSynced: syncState === 'synced', - isSessionActive: false, - machineId, - ownerInstanceId: 'daemon-1', - runtime, - sessionId, - }); - return null; - }; - - (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - const root = createRoot(document.createElement('div')); - try { - await act(async () => root.render(createElement(Probe, { syncState: 'synced' }))); - expect(request).toHaveBeenCalledTimes(1); - - await act(async () => root.render(createElement(Probe, { syncState: 'reconnecting' }))); - expect(request).toHaveBeenCalledTimes(1); - - await act(async () => root.render(createElement(Probe, { syncState: 'synced' }))); - expect(request).toHaveBeenCalledTimes(2); - } finally { - act(() => root.unmount()); - delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; - } - }); -}); - describe('canStopAgentEnabled', () => { const base = { isContextCompacting: false, diff --git a/packages/components/tests/workspace-machine-rpc-facade.test.ts b/packages/components/tests/workspace-machine-rpc-facade.test.ts index 96e85b064..2ef8f926e 100644 --- a/packages/components/tests/workspace-machine-rpc-facade.test.ts +++ b/packages/components/tests/workspace-machine-rpc-facade.test.ts @@ -208,83 +208,6 @@ describe('createWorkspaceMachineRpcFacade', () => { expect(getMachineRpcClient).not.toHaveBeenCalled(); }); - it('uses the local owner daemon to reconcile an exact context compaction activity', async () => { - const invoke = vi.fn(async () => ({ - ok: true as const, - result: { - type: 'session/reconcile-context-compaction_response' as const, - sessionId, - turnId: 'assistant:turn-1', - toolCallId: 'compact-1', - outcome: 'reconciled' as const, - }, - })); - vi.stubGlobal('window', { - __LODY_ELECTRON__: true, - ipc: { invoke }, - }); - const getMachineRpcClient = vi.fn(); - const facade = createWorkspaceMachineRpcFacade({ - workspaceId, - getMachineProtocolCapabilities: async () => CURRENT_MACHINE_PROTOCOL_CAPABILITIES, - targetRouter: { - getPlaneForMachine: () => 'local', - resolvePlaneForMachine: vi.fn(async () => 'local'), - }, - getMachineRpcClient, - }); - - await expect( - facade.requestSessionContextCompactionReconciliation(localMachineId, { - sessionId, - turnId: 'assistant:turn-1', - toolCallId: 'compact-1', - }) - ).resolves.toMatchObject({ outcome: 'reconciled' }); - expect(invoke).toHaveBeenCalledWith( - 'machineRpc.send', - expect.objectContaining({ - machineId: localMachineId, - workspaceId, - method: 'session/reconcile-context-compaction', - params: { - sessionId, - turnId: 'assistant:turn-1', - toolCallId: 'compact-1', - }, - }) - ); - expect(getMachineRpcClient).not.toHaveBeenCalled(); - }); - - it('does not reconcile compaction through a daemon without the capability', async () => { - const invoke = vi.fn(); - vi.stubGlobal('window', { - __LODY_ELECTRON__: true, - ipc: { invoke }, - }); - const getMachineRpcClient = vi.fn(); - const facade = createWorkspaceMachineRpcFacade({ - workspaceId, - getMachineProtocolCapabilities: async () => undefined, - targetRouter: { - getPlaneForMachine: () => 'local', - resolvePlaneForMachine: vi.fn(async () => 'local'), - }, - getMachineRpcClient, - }); - - await expect( - facade.requestSessionContextCompactionReconciliation(localMachineId, { - sessionId, - turnId: 'assistant:turn-1', - toolCallId: 'compact-1', - }) - ).resolves.toBeNull(); - expect(invoke).not.toHaveBeenCalled(); - expect(getMachineRpcClient).not.toHaveBeenCalled(); - }); - it('uses the cloud Machine RPC client for a remote machine', async () => { const invoke = vi.fn(); vi.stubGlobal('window', { diff --git a/packages/loro-streams-rpc/AGENTS.md b/packages/loro-streams-rpc/AGENTS.md index 3ad60ffac..c64b4508a 100644 --- a/packages/loro-streams-rpc/AGENTS.md +++ b/packages/loro-streams-rpc/AGENTS.md @@ -55,10 +55,11 @@ and the `file/preview` namespace are in dispatch watcher — never run the agent turn inside the handler. Its `expiresAt` is deliberately short (== client timeout, ~15s) because a server restart replays the request stream from offset `'-1'`. -- Session RPC authorization is source-side because workspace RPC cannot authenticate - a claimed requester. Live status and compaction repair use target-daemon runtime, - never durable meta, to judge liveness. Repair is capability-gated and may change - only the exact inactive turn/tool ids supplied by the client. +- Session orchestration authorization is checked source-side with the source CLI + token because workspace RPC cannot authenticate a claimed requester identity. + `session/live-status` reads the target + daemon's active-presence controller and must not infer liveness from durable + `SessionMeta.status` or message pointers. - Remote lifecycle: [ACK contract](../../specs/machine-lifecycle-ack.md). RPC handlers never run installers inline. Accepted work exits via the CLI process boundary after a bounded ACK attempt, including delivery failure or timeout. diff --git a/packages/loro-streams-rpc/src/machine-rpc-server.ts b/packages/loro-streams-rpc/src/machine-rpc-server.ts index e329bde57..5579d0dea 100644 --- a/packages/loro-streams-rpc/src/machine-rpc-server.ts +++ b/packages/loro-streams-rpc/src/machine-rpc-server.ts @@ -34,7 +34,6 @@ import type { PreviewTarget, PreviewTargetApproval, SessionCancelResponse, - SessionContextCompactionReconcileResponse, SessionPreparationCancelSpec, SessionPreparationSpec, SessionPrepareCancelResponse, @@ -350,11 +349,6 @@ type RpcServerDeps = { getSessionLiveStatus?: (args: { sessionId: SessionId; }) => Promise; - reconcileSessionContextCompaction?: (args: { - sessionId: SessionId; - turnId: string; - toolCallId: string; - }) => Promise; steerSession?: (args: { sessionId: SessionId; expectedTurnId: string; @@ -1084,22 +1078,6 @@ export class LoroStreamsMachineRpcServer { await this.appendResultResponse(request.replyTo, request.id, request.method, response); return; } - case 'session/reconcile-context-compaction': { - if (!this.deps.reconcileSessionContextCompaction) { - await this.appendErrorResponse(request.replyTo, request.id, request.method, { - code: LORO_STREAMS_RPC_ERROR_CODES.methodUnavailable, - message: 'Context compaction reconciliation is not available on this machine.', - }); - return; - } - const response = await this.deps.reconcileSessionContextCompaction({ - sessionId: request.params.sessionId as SessionId, - turnId: request.params.turnId, - toolCallId: request.params.toolCallId, - }); - await this.appendResultResponse(request.replyTo, request.id, request.method, response); - return; - } case 'session/steer': { if (!this.deps.steerSession) { await this.appendErrorResponse(request.replyTo, request.id, request.method, { @@ -1635,7 +1613,6 @@ export class LoroStreamsMachineRpcServer { | MachineBugReportResponse | SessionCancelResponse | LoroSessionLiveStatusRpcResponse - | SessionContextCompactionReconcileResponse | SessionSteerResponse | SessionGoalResponse | SessionTerminateResponse diff --git a/packages/loro-streams-rpc/src/rpc.ts b/packages/loro-streams-rpc/src/rpc.ts index b64f0980e..c9013a958 100644 --- a/packages/loro-streams-rpc/src/rpc.ts +++ b/packages/loro-streams-rpc/src/rpc.ts @@ -45,7 +45,6 @@ import type { MachineStatusResponse, MachineUpgradeResponse, SessionCancelResponse, - SessionContextCompactionReconcileResponse, SessionGoalAction, SessionGoalResponse, SessionPreparationCancelSpec, @@ -95,7 +94,6 @@ import { MachineStatusResponseSchema, MachineUpgradeResponseSchema, SessionCancelResponseSchema, - SessionContextCompactionReconcileResponseSchema, SessionPreparationCancelSpecSchema, SessionPreparationSpecSchema, SessionPrepareCancelResponseSchema, @@ -194,7 +192,6 @@ export const LoroStreamsRpcMethodSchema = z.enum([ 'file/preview', 'session/cancel', 'session/live-status', - 'session/reconcile-context-compaction', 'session/steer', 'session/goal', 'session/terminate', @@ -448,17 +445,6 @@ export const LoroSessionLiveStatusRpcRequestSchema = BaseRpcRequestSchema.extend .strict(), }).strict(); -export const LoroSessionContextCompactionReconcileRpcRequestSchema = BaseRpcRequestSchema.extend({ - method: z.literal('session/reconcile-context-compaction'), - params: z - .object({ - sessionId: SessionIdSchema, - turnId: z.string().trim().min(1), - toolCallId: z.string().trim().min(1), - }) - .strict(), -}).strict(); - export const LoroSessionGoalRpcRequestSchema = BaseRpcRequestSchema.extend({ method: z.literal('session/goal'), params: z @@ -612,7 +598,6 @@ export const LoroStreamsRpcRequestSchema = z.discriminatedUnion('method', [ LoroFilePreviewRpcRequestSchema, LoroSessionCancelRpcRequestSchema, LoroSessionLiveStatusRpcRequestSchema, - LoroSessionContextCompactionReconcileRpcRequestSchema, LoroSessionSteerRpcRequestSchema, LoroSessionGoalRpcRequestSchema, LoroSessionTerminateRpcRequestSchema, @@ -750,9 +735,6 @@ export type LoroCodeCollabV2LspReferencesRpcRequest = z.infer< export type LoroFilePreviewRpcRequest = z.infer; export type LoroSessionCancelRpcRequest = z.infer; export type LoroSessionLiveStatusRpcRequest = z.infer; -export type LoroSessionContextCompactionReconcileRpcRequest = z.infer< - typeof LoroSessionContextCompactionReconcileRpcRequestSchema ->; export type LoroSessionTerminateRpcRequest = z.infer; export type LoroSessionForkRpcRequest = z.infer; export type LoroSessionEditAndResendRpcRequest = z.infer< @@ -1459,7 +1441,6 @@ export type LoroMachineRpcResult = | MachineBugReportResponse | SessionCancelResponse | LoroSessionLiveStatusRpcResponse - | SessionContextCompactionReconcileResponse | SessionSteerResponse | SessionGoalResponse | SessionTerminateResponse @@ -1485,7 +1466,7 @@ const toLegacyRpcErrorResponse = ( pingContext?: { requestId: string }, lifecycleContext?: { requestId: string; targetVersion?: string }, binaryContext?: { agentType: string; requestId?: string }, - cancelContext?: { sessionId: string; turnId?: string; toolCallId?: string }, + cancelContext?: { sessionId: string }, forkContext?: { sourceSessionId: string; targetSessionId: string }, editAndResendContext?: { sessionId: string; replacementUserTurnId: string }, steerContext?: { sessionId: string; userTurnId: string }, @@ -1617,17 +1598,6 @@ const toLegacyRpcErrorResponse = ( }; } - if (method === 'session/reconcile-context-compaction') { - return { - type: 'session/reconcile-context-compaction_response', - sessionId: (cancelContext?.sessionId ?? '') as SessionId, - turnId: cancelContext?.turnId ?? '', - toolCallId: cancelContext?.toolCallId ?? '', - outcome: 'retry', - error: `${error.code}: ${error.message}`, - }; - } - if (method === 'session/fork') { return sessionForkFailure( { @@ -1845,10 +1815,6 @@ const parseRpcSuccessResult = async ( const parsed = LoroSessionLiveStatusRpcResponseSchema.safeParse(response.result); return parsed.success ? parsed.data : null; } - if (response.method === 'session/reconcile-context-compaction') { - const parsed = SessionContextCompactionReconcileResponseSchema.safeParse(response.result); - return parsed.success ? (parsed.data as SessionContextCompactionReconcileResponse) : null; - } if (response.method === 'session/steer') { const parsed = SessionSteerResponseSchema.safeParse(response.result); return parsed.success ? (parsed.data as SessionSteerResponse) : null; @@ -1926,7 +1892,7 @@ export type LoroStreamsRpcPendingRegistration = { pingContext?: { requestId: string }; lifecycleContext?: { requestId: string; targetVersion?: string }; binaryContext?: { agentType: string; requestId?: string }; - cancelContext?: { sessionId: string; turnId?: string; toolCallId?: string }; + cancelContext?: { sessionId: string }; forkContext?: { sourceSessionId: string; targetSessionId: string }; editAndResendContext?: { sessionId: string; replacementUserTurnId: string }; steerContext?: { sessionId: string; userTurnId: string }; @@ -2700,23 +2666,6 @@ export class LoroStreamsMachineRpcClient { })) as LoroSessionLiveStatusRpcResponse | null; } - async requestSessionContextCompactionReconciliation(options: { - sessionId: SessionId; - turnId: string; - toolCallId: string; - timeoutMs?: number; - }): Promise { - return (await this.sendRequest({ - method: 'session/reconcile-context-compaction', - timeoutMs: options.timeoutMs ?? 10_000, - params: { - sessionId: options.sessionId, - turnId: options.turnId, - toolCallId: options.toolCallId, - }, - })) as SessionContextCompactionReconcileResponse | null; - } - async requestSessionSteer(options: { sessionId: string; expectedTurnId: string; @@ -3216,15 +3165,6 @@ export class LoroStreamsMachineRpcClient { sessionId: string; }; } - | { - method: 'session/reconcile-context-compaction'; - timeoutMs: number; - params: { - sessionId: SessionId; - turnId: string; - toolCallId: string; - }; - } | { method: 'session/steer'; timeoutMs: number; @@ -3440,9 +3380,7 @@ export class LoroStreamsMachineRpcClient { args.method === 'session/live-status' || args.method === 'session/terminate' ? { sessionId: args.params.sessionId } - : args.method === 'session/reconcile-context-compaction' - ? args.params - : undefined, + : undefined, forkContext: args.method === 'session/fork' ? { @@ -3570,9 +3508,6 @@ export class LoroStreamsMachineRpcClient { case 'session/live-status': request = { ...envelope, method: args.method, params: args.params }; break; - case 'session/reconcile-context-compaction': - request = { ...envelope, method: args.method, params: args.params }; - break; case 'session/steer': request = { ...envelope, method: args.method, params: args.params }; break; diff --git a/packages/loro-streams-rpc/tests/machine-rpc-server.test.ts b/packages/loro-streams-rpc/tests/machine-rpc-server.test.ts index 54443e322..cdec9bbed 100644 --- a/packages/loro-streams-rpc/tests/machine-rpc-server.test.ts +++ b/packages/loro-streams-rpc/tests/machine-rpc-server.test.ts @@ -863,154 +863,6 @@ describe('LoroStreamsMachineRpcServer', () => { server.stop(); }); - it('dispatches context compaction reconciliation with the exact activity owner', async () => { - const workspaceId = 'workspace-1' as WorkspaceId; - const machineId = 'machine-1' as MachineId; - const sessionId = 'session-1' as SessionId; - const fake = createFakeStreamClient(); - const reconcileSessionContextCompaction = vi.fn( - async (args: { sessionId: SessionId; turnId: string; toolCallId: string }) => ({ - type: 'session/reconcile-context-compaction_response' as const, - ...args, - outcome: 'reconciled' as const, - }) - ); - const server = new LoroStreamsMachineRpcServer({ - logger: createSilentLogger(), - workspaceId, - machineId, - streamClient: fake.streamClient, - getMachineStatus: vi.fn(), - refreshMachineAcpCapabilities: vi.fn(), - reconcileSessionContextCompaction, - }); - fake.pushBatch({ - messages: [ - { - jsonrpc: '2.0', - id: 'reconcile-1', - method: 'session/reconcile-context-compaction', - rpcVersion: '1', - machineId, - workspaceId, - replyTo: 'workspace-1:rpc:res:client-1', - sentAt: Date.now(), - expiresAt: Date.now() + 5000, - params: { - sessionId, - turnId: 'assistant:turn-1', - toolCallId: 'compact-1', - }, - }, - ], - nextOffset: '1', - cursor: 'cursor-1', - upToDate: true, - }); - - await server.start(); - await fake.waitForAppendedCount(1); - - expect(reconcileSessionContextCompaction).toHaveBeenCalledWith({ - sessionId, - turnId: 'assistant:turn-1', - toolCallId: 'compact-1', - }); - expect(fake.appended[0]?.value).toEqual( - expect.objectContaining({ - id: 'reconcile-1', - result: expect.objectContaining({ - outcome: 'reconciled', - turnId: 'assistant:turn-1', - toolCallId: 'compact-1', - }), - }) - ); - - server.stop(); - }); - - it('keeps control requests responsive while four reconciliations are running', async () => { - const workspaceId = 'workspace-1' as WorkspaceId; - const machineId = 'machine-1' as MachineId; - const fake = createFakeStreamClient(); - let releaseReconciliations!: () => void; - const reconciliationGate = new Promise((resolve) => { - releaseReconciliations = resolve; - }); - const reconcileSessionContextCompaction = vi.fn( - async (args: { sessionId: SessionId; turnId: string; toolCallId: string }) => { - await reconciliationGate; - return { - type: 'session/reconcile-context-compaction_response' as const, - ...args, - outcome: 'retry' as const, - }; - } - ); - const cancelSession = vi.fn(async (args: { sessionId: SessionId; turnId: string }) => ({ - type: 'session/cancel_response' as const, - sessionId: args.sessionId, - success: true, - })); - const server = new LoroStreamsMachineRpcServer({ - logger: createSilentLogger(), - workspaceId, - machineId, - streamClient: fake.streamClient, - getMachineStatus: vi.fn(), - refreshMachineAcpCapabilities: vi.fn(), - reconcileSessionContextCompaction, - cancelSession, - }); - const base = { - jsonrpc: '2.0' as const, - rpcVersion: '1', - machineId, - workspaceId, - replyTo: 'workspace-1:rpc:res:client-1', - sentAt: Date.now(), - expiresAt: Date.now() + 5_000, - }; - fake.pushBatch({ - messages: [ - ...Array.from({ length: 4 }, (_, index) => ({ - ...base, - id: `reconcile-${index}`, - method: 'session/reconcile-context-compaction', - params: { - sessionId: `session-${index}`, - turnId: `assistant:turn-${index}`, - toolCallId: `compact-${index}`, - }, - })), - { - ...base, - id: 'cancel-after-reconciliations', - method: 'session/cancel', - params: { sessionId: 'session-cancel', turnId: 'assistant:turn-cancel' }, - }, - ], - nextOffset: '1', - cursor: 'cursor-1', - upToDate: true, - }); - - try { - await server.start(); - await vi.waitFor(() => expect(reconcileSessionContextCompaction).toHaveBeenCalledTimes(4)); - await vi.waitFor(() => expect(cancelSession).toHaveBeenCalledTimes(1)); - expect( - fake.appended.some( - (entry) => (entry.value as { id?: string }).id === 'cancel-after-reconciliations' - ) - ).toBe(true); - } finally { - releaseReconciliations(); - server.stop(); - } - }); - it('appends responses to workspace-level replyTo streams for new clients', async () => { const workspaceId = 'workspace-1' as WorkspaceId; const machineId = 'machine-1' as MachineId; diff --git a/packages/shared/src/local-machine-rpc.ts b/packages/shared/src/local-machine-rpc.ts index fd7436b53..d6d3912bb 100644 --- a/packages/shared/src/local-machine-rpc.ts +++ b/packages/shared/src/local-machine-rpc.ts @@ -24,7 +24,6 @@ import { import { FilePreviewV3RequestSchema, FilePreviewV3ResponseSchema } from './file-preview'; import { SessionCancelResponseSchema, - SessionContextCompactionReconcileResponseSchema, SessionDispatchTurnResponseSchema, SessionEditAndResendResponseSchema, SessionEditAndResendSpecSchema, @@ -162,16 +161,6 @@ export const LocalMachineRpcRequestSchema = z.discriminatedUnion('method', [ }) .strict(), }).strict(), - BaseLocalMachineRpcRequestSchema.extend({ - method: z.literal('session/reconcile-context-compaction'), - params: z - .object({ - sessionId: SessionIdSchema, - turnId: z.string().trim().min(1), - toolCallId: z.string().trim().min(1), - }) - .strict(), - }).strict(), BaseLocalMachineRpcRequestSchema.extend({ method: z.literal('session/fork'), params: SessionForkSpecSchema, @@ -274,7 +263,6 @@ export const LocalMachineRpcResultSchema = z.union([ FilePreviewV3ResponseSchema, LocalFileResolutionSchema, SessionCancelResponseSchema, - SessionContextCompactionReconcileResponseSchema, SessionDispatchTurnResponseSchema, SessionEditAndResendResponseSchema, SessionForkResponseSchema, diff --git a/packages/shared/src/machine-protocol-capabilities.ts b/packages/shared/src/machine-protocol-capabilities.ts index d9151eaab..f81e8dde5 100644 --- a/packages/shared/src/machine-protocol-capabilities.ts +++ b/packages/shared/src/machine-protocol-capabilities.ts @@ -13,7 +13,6 @@ export const MACHINE_PROTOCOL_CAPABILITIES = { providerSetup: 'providerSetup', localFileResources: 'localFileResources', acpProtocolAuthentication: 'acpProtocolAuthentication', - contextCompactionReconciliation: 'contextCompactionReconciliation', } as const; export const ACP_AUTHENTICATION_INTERACTIONS_PROTOCOL_VERSION = 2; @@ -21,7 +20,6 @@ export const LOCAL_PROJECT_REMOVAL_PROTOCOL_VERSION = 1; export const PROVIDER_SETUP_PROTOCOL_VERSION = 1; export const LOCAL_FILE_RESOURCES_PROTOCOL_VERSION = 1; export const ACP_PROTOCOL_AUTHENTICATION_VERSION = 2; -export const CONTEXT_COMPACTION_RECONCILIATION_PROTOCOL_VERSION = 1; type MachineProtocolCapabilityCarrier = { protocolCapabilities?: MachineProtocolCapabilities; @@ -57,8 +55,6 @@ export const CURRENT_MACHINE_PROTOCOL_CAPABILITIES: MachineProtocolCapabilities [MACHINE_PROTOCOL_CAPABILITIES.providerSetup]: PROVIDER_SETUP_PROTOCOL_VERSION, [MACHINE_PROTOCOL_CAPABILITIES.localFileResources]: LOCAL_FILE_RESOURCES_PROTOCOL_VERSION, [MACHINE_PROTOCOL_CAPABILITIES.acpProtocolAuthentication]: ACP_PROTOCOL_AUTHENTICATION_VERSION, - [MACHINE_PROTOCOL_CAPABILITIES.contextCompactionReconciliation]: - CONTEXT_COMPACTION_RECONCILIATION_PROTOCOL_VERSION, }; /** Whether the target daemon supports interactive Custom/Registry ACP authentication. */ @@ -119,14 +115,3 @@ export function machineSupportsLocalFileResourcesProtocol( LOCAL_FILE_RESOURCES_PROTOCOL_VERSION ); } - -/** Whether the target daemon can settle legacy compaction state from live turn ownership. */ -export function machineSupportsContextCompactionReconciliationProtocol( - machine: MachineProtocolCapabilityCarrier | null | undefined -): boolean { - return machineSupportsProtocolCapability( - machine, - MACHINE_PROTOCOL_CAPABILITIES.contextCompactionReconciliation, - CONTEXT_COMPACTION_RECONCILIATION_PROTOCOL_VERSION - ); -} diff --git a/packages/shared/src/message-schemas.ts b/packages/shared/src/message-schemas.ts index 52e67235e..35fc17bb2 100644 --- a/packages/shared/src/message-schemas.ts +++ b/packages/shared/src/message-schemas.ts @@ -643,21 +643,6 @@ export const SessionCancelResponseSchema = z }) .strict(); -export const SessionContextCompactionReconcileResponseSchema = z - .object({ - type: z.literal('session/reconcile-context-compaction_response'), - sessionId: SessionIdSchema, - turnId: z.string().trim().min(1), - toolCallId: z.string().trim().min(1), - outcome: z.enum(['reconciled', 'retry']), - error: z.string().trim().min(1).optional(), - }) - .strict(); - -export type SessionContextCompactionReconcileResponse = z.infer< - typeof SessionContextCompactionReconcileResponseSchema ->; - export const SessionSteerRequestSchema = z .object({ type: z.literal('session/steer'), diff --git a/packages/shared/tests/local-machine-rpc.test.ts b/packages/shared/tests/local-machine-rpc.test.ts index a1978bb0a..c3c8b7f93 100644 --- a/packages/shared/tests/local-machine-rpc.test.ts +++ b/packages/shared/tests/local-machine-rpc.test.ts @@ -10,14 +10,6 @@ describe('local Machine RPC', () => { method: 'session/get-active-invocation-context', params: { sessionId: 'session-1' }, }, - { - method: 'session/reconcile-context-compaction', - params: { - sessionId: 'session-1', - turnId: 'assistant:turn-1', - toolCallId: 'context-compaction-1', - }, - }, { method: 'session/fork', params: { diff --git a/packages/shared/tests/machine-protocol-capabilities.test.ts b/packages/shared/tests/machine-protocol-capabilities.test.ts index b2fef9f34..83f64112b 100644 --- a/packages/shared/tests/machine-protocol-capabilities.test.ts +++ b/packages/shared/tests/machine-protocol-capabilities.test.ts @@ -2,11 +2,9 @@ import { describe, expect, it } from 'vitest'; import { ACP_AUTHENTICATION_INTERACTIONS_PROTOCOL_VERSION, - CONTEXT_COMPACTION_RECONCILIATION_PROTOCOL_VERSION, CURRENT_MACHINE_PROTOCOL_CAPABILITIES, MACHINE_PROTOCOL_CAPABILITIES, machineSupportsAcpAuthenticationInteractionsProtocol, - machineSupportsContextCompactionReconciliationProtocol, machineSupportsLocalFileResourcesProtocol, } from '../src/machine-protocol-capabilities'; @@ -37,20 +35,6 @@ describe('ACP authentication interaction protocol capability', () => { }); }); -it('binds legacy compaction reconciliation to its advertised protocol version', () => { - expect( - CURRENT_MACHINE_PROTOCOL_CAPABILITIES[ - MACHINE_PROTOCOL_CAPABILITIES.contextCompactionReconciliation - ] - ).toBe(CONTEXT_COMPACTION_RECONCILIATION_PROTOCOL_VERSION); - expect(machineSupportsContextCompactionReconciliationProtocol(undefined)).toBe(false); - expect( - machineSupportsContextCompactionReconciliationProtocol({ - protocolCapabilities: CURRENT_MACHINE_PROTOCOL_CAPABILITIES, - }) - ).toBe(true); -}); - it('requires an advertised local file resource protocol, independent of release version', () => { expect(machineSupportsLocalFileResourcesProtocol(undefined)).toBe(false); expect( diff --git a/specs/session-history-writes.md b/specs/session-history-writes.md index dc3a8bbb2..6de8521af 100644 --- a/specs/session-history-writes.md +++ b/specs/session-history-writes.md @@ -1,7 +1,7 @@ # Session history writes Status: draft -Translation: current +Translation: stale [中文](session-history-writes.zh.md) @@ -46,21 +46,12 @@ 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. -- A finished assistant turn may contain a legacy context-compaction activity whose - provider terminal state was never persisted. Opening that Session may request a - repair, but only its owning daemon may change the exact addressed turn and tool - item, and only after live execution ownership proves that turn is no longer - active. Turn `finished`, durable Session status, elapsed time, daemon restart, - or missing live evidence is insufficient. The renderer requests repair only while - the Session is inactive, browser connectivity is online, and the Session room is - synced. An offline, reconnecting, unsupported, - non-owning, active, or indeterminate daemon leaves history unchanged. Repair must - inspect a remotely synced Session document, reject any live Session work, recheck - ownership, and update only the named entry through `onlyEntryId`; a concurrent new - turn has a different identity and remains untouched. Report `reconciled` only after - the write is confirmed; every other result is `retry`. Retry evidence is bounded by - the exact candidate, daemon generation, browser and Session-room connectivity, and - stable Session activity transitions; presence heartbeats must not become polling. +- 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. A start acknowledgement or compaction-item + completion alone does 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. - 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 @@ -92,10 +83,7 @@ Non-history control-field validation remains outside this HistoryWriter contract - `packages/shared/src/{history-writer,history-write-schema,session-mirror}.ts` - `packages/shared/tests/history-writer.test.ts` and `history-writer.contract.ts` -- `apps/cli/src/lib/assistant-turn-finalize.ts` and the capability-gated - `session/reconcile-context-compaction` Machine RPC - [Decision](../.agents/notes/implemented/architecture/2026-09-07-single-history-writer.md) -- [Legacy compaction repair decision](../.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.md) - [Business-field repair and pending hash decision](../.agents/notes/implemented/architecture/2026-09-07-single-history-writer.md) - [Imported-history baseline repair](../.agents/notes/implemented/architecture/2026-09-07-single-history-writer.md) diff --git a/specs/session-history-writes.zh.md b/specs/session-history-writes.zh.md index 7cad66686..738824286 100644 --- a/specs/session-history-writes.zh.md +++ b/specs/session-history-writes.zh.md @@ -1,7 +1,7 @@ # 会话历史写入 Status: draft -Translation: current +Translation: stale [English](session-history-writes.md) @@ -32,16 +32,6 @@ Translation: current - 工具状态、权限请求与描述元数据(title/kind/locations)只解析本次变化的字段, 不重验未修改的工具内容;只修改 outcome 时保留已有请求信息。 修改工具身份或内容仍需完整 item 解析;新增元数据非法时,整条命令在写入前拒绝。 -- 已 finished 的 assistant turn 可能包含未持久化 provider 终态的旧 context-compaction - activity。打开该 Session 时可以请求修复,但只有 owner daemon 能修改指定 turn 和 tool - item,且必须先由实时执行所有权证明该 turn 已不活跃。Turn `finished`、持久化 Session - status、经过时间、daemon 重启或缺少 live evidence 均不充分。Renderer 只在 Session inactive、 - browser connectivity online 且 Session room 已 synced 时请求修复。Daemon 离线、正在重连、 - 不支持该能力、不持有 Session、仍有 live work 或无法确定时,历史保持不变。Repair 必须读取已完成远端同步的 Session document,拒绝 - 任何 live Session work,再次确认 owner,并通过 `onlyEntryId` 只更新指定 entry;并发的新 turn - 使用不同 identity,必须保持不变。只有写入确认后才能返回 `reconciled`,其他结果统一返回 - `retry`。Retry evidence 只由 exact candidate、daemon generation、browser 与 Session-room - connectivity 和稳定的 Session activity 状态切换界定;presence heartbeat 不得退化成轮询。 - 已接受的 steer 标记在写入和读取归一化后都必须保留;编辑重发不能把 steer 当作可独立重放的普通用户轮次。 @@ -69,10 +59,7 @@ Translation: current - `packages/shared/src/{history-writer,history-write-schema,session-mirror}.ts` - `packages/shared/tests/history-writer.test.ts` 与 `history-writer.contract.ts` -- `apps/cli/src/lib/assistant-turn-finalize.ts` 与经过 capability gate 的 - `session/reconcile-context-compaction` Machine RPC - [决策记录](../.agents/notes/implemented/architecture/2026-09-07-single-history-writer.zh.md) -- [旧 compaction 修复决策](../.agents/notes/implemented/bug-fix/2026-09-11-stale-context-compaction-reconciliation.zh.md) - [业务字段修复与待定 hash 决策](../.agents/notes/implemented/architecture/2026-09-07-single-history-writer.zh.md) - [外部历史基线修复](../.agents/notes/implemented/architecture/2026-09-07-single-history-writer.zh.md) From 9ae43cc0e75c8c20bf17076e773a04014579c955 Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:03:38 +0800 Subject: [PATCH 09/14] fix: pin native compaction cancellation acknowledgement fix Model: gpt-6 --- packages/acp-extension-codex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/acp-extension-codex b/packages/acp-extension-codex index d7a0a9d1f..61ed84ce8 160000 --- a/packages/acp-extension-codex +++ b/packages/acp-extension-codex @@ -1 +1 @@ -Subproject commit d7a0a9d1fe125446708351236b4e7d7f185880e9 +Subproject commit 61ed84ce884c43d57670c4795d2cdbd1b2b4b70c From 36265161c8a34971359e55be0d1a5cc53106cf98 Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:57:46 +0800 Subject: [PATCH 10/14] fix: retain session ownership while provider cancellation drains Model: gpt-6 --- ...26-09-12-native-compaction-cancellation.md | 34 +- apps/cli/src/session/AGENTS.md | 8 +- .../src/session/session-execution-service.ts | 10 +- .../tests/session-execution-service.test.ts | 366 +++++++++++------- specs/session-history-writes.md | 8 +- 5 files changed, 281 insertions(+), 145 deletions(-) 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 index fa27c2ad3..ca2effd4a 100644 --- 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 @@ -8,8 +8,10 @@ Translation: pending 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. PR #618 removes its -automatic historical reconciliation protocol and fixes this execution boundary. +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. PR #618 removes its automatic historical +reconciliation protocol and fixes these execution boundaries. Existing stale histories are not migrated by opening a conversation. ## Decision @@ -26,7 +28,23 @@ 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. -The existing CLI finalization from +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, including after asynchronous preparation, +so a handoff cannot submit a second prompt while the cancelled provider drains. 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. External owner interruption +still uses that existing raw-request drain and termination fallback. A provider +that never completes after normal Stop keeps ownership; this change adds no timer +or forced-termination policy to that path. + +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. @@ -45,16 +63,24 @@ neither hiding progress nor rewriting history can interrupt native execution. 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. 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. - 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 616 enabled adapter tests passed; 27 tests are skipped +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 203 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. Documentation translation remains pending. diff --git a/apps/cli/src/session/AGENTS.md b/apps/cli/src/session/AGENTS.md index da502aa6d..5408c383a 100644 --- a/apps/cli/src/session/AGENTS.md +++ b/apps/cli/src/session/AGENTS.md @@ -47,15 +47,15 @@ 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 +- Never steer a cancelling turn or strand unaccepted steer 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. - Resume must REOPEN the in-progress assistant entry, clearing diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 227fa4ed0..76f609a18 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -1463,7 +1463,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 +1498,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 +1535,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' ); } @@ -5402,7 +5402,7 @@ 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'); 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..88095c3d9 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,13 @@ describe('SessionExecutionService', () => { expect(deps.turnFinalization.finalizeACPState).toHaveBeenCalledTimes(1); }); - it('routes prompt-in-flight cancellation through the turn owner finalizer', async () => { + it('retains the compact owner after cancel ACK until ACP terminal before admitting another prompt', async () => { 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 +5326,46 @@ 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(); + 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(); + const terminal = await nativeTerminal.promise; + nativePending = false; + return terminal; + }, }; + const sendPrompt = agentClient.prompt.bind(agentClient); + vi.spyOn(agentClient, 'prompt').mockImplementation((id, blocks, options) => { + promptSignal = options?.signal; + return sendPrompt(id, blocks, options); + }); const session = { sessionId: 'session-prompt-cancel' as SessionId, acpSessionId: 'acp-prompt-cancel' as ACPSessionId, @@ -5352,8 +5390,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 +5413,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 +5428,70 @@ 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; + 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; + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + cancelAck.resolve(); + await vi.advanceTimersByTimeAsync(0); + 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[2]).toMatchObject({ status: 'pending' }); + expect(deps.recordChatFailure).not.toHaveBeenCalled(); + nativeTerminal.resolve({ stopReason: 'cancelled' }); + await running; + } finally { + cancelAck.resolve(); + nativeTerminal.resolve({ stopReason: 'cancelled' }); + await running; + 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 +5510,22 @@ describe('SessionExecutionService', () => { processingUserMsgId: undefined, }); expect(onTurnSettled).toHaveBeenCalledWith('cancelled'); + await service.continueSession(nextMessage); + expect(delivered).toEqual([ + [{ type: 'text', text: '/compact' }], + [{ type: 'text', text: 'continue' }], + ]); + expect(history[2]).toMatchObject({ id: nextMessage.userTurnId, status: 'handled' }); + 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,6 +5704,14 @@ 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]); expect(promptSignal?.aborted).toBe(true); expect(status).toEqual(SessionStatusFactory.idle()); diff --git a/specs/session-history-writes.md b/specs/session-history-writes.md index 6de8521af..25d4512af 100644 --- a/specs/session-history-writes.md +++ b/specs/session-history-writes.md @@ -48,8 +48,12 @@ That tolerance must not authorize creating new malformed items locally. - 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. A start acknowledgement or compaction-item - completion alone does not release execution ownership. The CLI persists unresolved + 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. 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. - Accepted steer provenance survives both writing and read normalization. Editing and From 88b43c948fd60beff8b5f6d38eaea39470e3179d Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:55:34 +0800 Subject: [PATCH 11/14] fix: restore cancellation timeout for live prompt owners Model: gpt-6 --- ...26-09-12-native-compaction-cancellation.md | 26 ++++--- .../src/session/session-execution-service.ts | 59 +++++++++------ .../tests/session-execution-service.test.ts | 71 ++++++++++++++++--- specs/session-history-writes.md | 9 ++- 4 files changed, 123 insertions(+), 42 deletions(-) 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 index ca2effd4a..206d2f394 100644 --- 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 @@ -10,8 +10,9 @@ 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. PR #618 removes its automatic historical -reconciliation protocol and fixes these execution boundaries. +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 @@ -39,10 +40,15 @@ 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. External owner interruption -still uses that existing raw-request drain and termination fallback. A provider -that never completes after normal Stop keeps ownership; this change adds no timer -or forced-termination policy to that path. +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) @@ -66,8 +72,10 @@ neither hiding progress nor rewriting history can interrupt native execution. - 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. External-interruption coverage retains raw completion, process termination, - and failed-termination cases. + 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. - Contract: [Session history writes](../../../../specs/session-history-writes.md). @@ -80,7 +88,7 @@ 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 203 tests. +The targeted Lody execution, dispatch-watcher and AgentClient suites pass 206 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. Documentation translation remains pending. diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 76f609a18..4cde1aa2d 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?: { @@ -2028,6 +2030,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 +2185,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) { @@ -5404,6 +5414,11 @@ export class SessionExecutionService { } // 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 88095c3d9..d706e59fc 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -5287,7 +5287,14 @@ describe('SessionExecutionService', () => { expect(deps.turnFinalization.finalizeACPState).toHaveBeenCalledTimes(1); }); - it('retains the compact owner after cancel ACK until ACP terminal before admitting another prompt', async () => { + const cancelCompletions = [ + 'native-terminal', + 'terminated', + 'termination-failed', + 'cancel-unacknowledged', + ] as const; + it.each(cancelCompletions)('retains cancelled ownership (%s)', async (completion) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); let meta: Record = {}; let history: Array> = [ { @@ -5330,6 +5337,8 @@ describe('SessionExecutionService', () => { const cancelSubmitted = createDeferred(); const cancelAck = createDeferred(); const nativeTerminal = createDeferred(); + const termination = createDeferred(); + let terminationRequested = false; let nativePending = false; let promptSignal: AbortSignal | undefined; const delivered: ContentBlock[][] = []; @@ -5356,9 +5365,11 @@ describe('SessionExecutionService', () => { if (delivered.length > 1) return { stopReason: 'end_turn' }; nativePending = true; promptStarted.resolve(); - const terminal = await nativeTerminal.promise; - nativePending = false; - return terminal; + try { + return await nativeTerminal.promise; + } finally { + nativePending = false; + } }, }; const sendPrompt = agentClient.prompt.bind(agentClient); @@ -5375,7 +5386,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 () => {}), @@ -5459,8 +5475,7 @@ describe('SessionExecutionService', () => { }) ).resolves.toEqual({ success: true }); await cancelSubmitted.promise; - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); - cancelAck.resolve(); + if (completion !== 'cancel-unacknowledged') cancelAck.resolve(); await vi.advanceTimersByTimeAsync(0); expect(agentClient.pendingPromptCompletion).not.toBeNull(); expect(service.getExecutionSnapshot(message.sessionId)).toMatchObject({ @@ -5481,10 +5496,49 @@ describe('SessionExecutionService', () => { expect(delivered).toEqual([[{ type: 'text', text: '/compact' }]]); expect(history[2]).toMatchObject({ status: 'pending' }); expect(deps.recordChatFailure).not.toHaveBeenCalled(); - nativeTerminal.resolve({ stopReason: 'cancelled' }); + 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 (completion === 'native-terminal') { + 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(completion !== 'native-terminal'); } finally { cancelAck.resolve(); + termination.resolve(); nativeTerminal.resolve({ stopReason: 'cancelled' }); await running; vi.useRealTimers(); @@ -5713,6 +5767,7 @@ describe('SessionExecutionService', () => { // 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/specs/session-history-writes.md b/specs/session-history-writes.md index 25d4512af..f2b33cfc2 100644 --- a/specs/session-history-writes.md +++ b/specs/session-history-writes.md @@ -51,10 +51,13 @@ That tolerance must not authorize creating new malformed items locally. 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. Start and interrupt + 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. + 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. - Accepted steer provenance survives both writing and read normalization. Editing and resending must not reinterpret a steer as an independently replayable user turn. From e4a860a2cf0a30c0e17caa378d64462450486895 Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:04:18 +0800 Subject: [PATCH 12/14] docs: remove duplicated session guideline rationale Keep the merged session guidelines below the documentation size limit without changing their contracts. Model: gpt-6 --- apps/cli/src/session/AGENTS.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/session/AGENTS.md b/apps/cli/src/session/AGENTS.md index 5408c383a..c70e8593e 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. @@ -64,9 +64,9 @@ Contract: specs/session-orchestration.md. `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. From ae24a7235bb9b8e39312eed851829da432a1974a Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:20:13 +0800 Subject: [PATCH 13/14] fix: preserve cancellation ownership after late steer acknowledgement Model: gpt-6 --- ...26-09-12-native-compaction-cancellation.md | 24 ++++-- ...09-12-native-compaction-cancellation.zh.md | 76 +++++++++++++++++++ apps/cli/src/session/AGENTS.md | 6 +- .../src/session/session-execution-service.ts | 7 +- .../tests/session-execution-service.test.ts | 75 +++++++++++++++++- specs/session-history-writes.md | 6 +- specs/session-history-writes.zh.md | 24 ++++-- 7 files changed, 197 insertions(+), 21 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-12-native-compaction-cancellation.zh.md 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 index 206d2f394..386b9a53c 100644 --- 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 @@ -1,7 +1,9 @@ # Cancel compaction through the native turn Status: implemented -Translation: pending +Translation: current + +[中文](2026-09-12-native-compaction-cancellation.zh.md) ## Abstract @@ -33,9 +35,13 @@ Lody records `runtime.cancelRequested` and sends provider cancel while the promp 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, including after asynchronous preparation, -so a handoff cannot submit a second prompt while the cancelled provider drains. Cancellation -before prompt submission and finalization teardown retain their existing paths. +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 returns +`stale-turn` without requeueing an already accepted steer and releases the application +lease; 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. @@ -77,7 +83,11 @@ neither hiding progress nor rewriting history can interrupt native execution. 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. + 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. Provider terminal then settles the source as cancelled and + releases ownership. This race failed on `e4a860a2` before the post-ACK guard. - 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). @@ -88,7 +98,7 @@ 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 206 tests. +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. Documentation translation remains pending. +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..055db4fd2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-12-native-compaction-cancellation.zh.md @@ -0,0 +1,76 @@ +# 通过 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。它返回 `stale-turn`,释放 application lease,不重新排队已被 +接受的 steer;现有 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;provider terminal 最后将 source + 结算为 cancelled 并释放 owner。补上 ACK 后检查前,该 race 在 `e4a860a2` 上失败。 +- 契约:[会话历史写入](../../../../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 c70e8593e..70565cbbd 100644 --- a/apps/cli/src/session/AGENTS.md +++ b/apps/cli/src/session/AGENTS.md @@ -55,9 +55,9 @@ Contract: specs/session-orchestration.md. 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. -- Never steer a cancelling turn or strand unaccepted steer 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 cannot transfer ownership or requeue. + 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 diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 4cde1aa2d..9f4f49401 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -1551,12 +1551,17 @@ 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. + return reject( + 'stale-turn', + 'Steer application arrived after cancellation or ownership changed' + ); } // The provider has accepted this steer and may execute tools before diff --git a/apps/cli/tests/session-execution-service.test.ts b/apps/cli/tests/session-execution-service.test.ts index d706e59fc..5d7344269 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -5289,12 +5289,14 @@ describe('SessionExecutionService', () => { 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> = [ { @@ -5337,6 +5339,11 @@ describe('SessionExecutionService', () => { 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; @@ -5377,6 +5384,19 @@ describe('SessionExecutionService', () => { 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, @@ -5465,6 +5485,24 @@ describe('SessionExecutionService', () => { 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', @@ -5477,6 +5515,22 @@ describe('SessionExecutionService', () => { 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: 'pending_apply', + }); + } expect(agentClient.pendingPromptCompletion).not.toBeNull(); expect(service.getExecutionSnapshot(message.sessionId)).toMatchObject({ hasActiveTurn: true, @@ -5494,7 +5548,9 @@ describe('SessionExecutionService', () => { meta.latestUserMsgId = nextMessage.userTurnId; await service.continueSession(nextMessage); expect(delivered).toEqual([[{ type: 'text', text: '/compact' }]]); - expect(history[2]).toMatchObject({ status: 'pending' }); + 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); @@ -5506,7 +5562,7 @@ describe('SessionExecutionService', () => { workspaceId: message.workspaceId, turnId: 'assistant-prompt-cancel', }); - if (completion === 'native-terminal') { + if (nativeCompletion) { nativeTerminal.resolve({ stopReason: 'cancelled' }); } else { await vi.advanceTimersByTimeAsync(1); @@ -5535,12 +5591,14 @@ describe('SessionExecutionService', () => { } await running; await vi.advanceTimersByTimeAsync(5_000); - expect(terminationRequested).toBe(completion !== 'native-terminal'); + expect(terminationRequested).toBe(!nativeCompletion); } finally { + steerApplied.resolve({ release: () => steerReleased.resolve() }); cancelAck.resolve(); termination.resolve(); nativeTerminal.resolve({ stopReason: 'cancelled' }); await running; + await steering; vi.useRealTimers(); } @@ -5564,12 +5622,21 @@ 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[2]).toMatchObject({ id: nextMessage.userTurnId, status: 'handled' }); + 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: 'pending_apply', + }); + } expect(meta).toMatchObject({ latestUserMsgId: nextMessage.userTurnId, lastHandledUserMsgId: nextMessage.userTurnId, diff --git a/specs/session-history-writes.md b/specs/session-history-writes.md index f2b33cfc2..f07f48c95 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) @@ -59,6 +59,10 @@ That tolerance must not authorize creating new malformed items locally. 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. + 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..aa6e62315 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,28 @@ 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。保持当前 cancellation owner, + 直到 provider 完成。不得重排该已接受的 steer:拒绝本地 ownership 转移不代表消息未投递。 - 已接受的 steer 标记在写入和读取归一化后都必须保留;编辑重发不能把 steer 当作可独立重放的普通用户轮次。 From cde039d5db9756fb9e9d51334613a48c739770ac Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:03:26 +0800 Subject: [PATCH 14/14] fix: terminalize accepted steers after cancellation Model: gpt-6 --- .../2026-09-12-native-compaction-cancellation.md | 12 +++++++----- .../2026-09-12-native-compaction-cancellation.zh.md | 10 ++++++---- apps/cli/src/session/AGENTS.md | 3 ++- apps/cli/src/session/session-execution-service.ts | 8 ++++++++ apps/cli/tests/session-execution-service.test.ts | 4 ++-- specs/session-history-writes.md | 6 ++++-- specs/session-history-writes.zh.md | 3 ++- 7 files changed, 31 insertions(+), 15 deletions(-) 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 index 386b9a53c..346633363 100644 --- 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 @@ -37,9 +37,10 @@ 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 returns -`stale-turn` without requeueing an already accepted steer and releases the application -lease; the existing owner continues to its cancelled terminal outcome. Neither the +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. @@ -86,8 +87,9 @@ neither hiding progress nor rewriting history can interrupt native execution. 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. Provider terminal then settles the source as cancelled and - releases ownership. This race failed on `e4a860a2` before the post-ACK guard. + 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). 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 index 055db4fd2..400ad5ed7 100644 --- 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 @@ -29,8 +29,9 @@ Lody 在 prompt in-flight 时记录 `runtime.cancelRequested` 并发送 provider 保留原有 owner fiber 和 runtime,直到 ACP 返回,再由 scope 执行取消收尾并释放 ownership。 期间新用户消息保持 pending,不能到达 ACP。steer 在异步准备后及 provider 接受 ACK 后都 检查取消标记。Stop 后到达的成功 ACK 不得替换 source invocation、将 source 强制结算为 -handled 或转移 ownership。它返回 `stale-turn`,释放 application lease,不重新排队已被 -接受的 steer;现有 owner 继续等待 cancelled terminal。daemon 与客户端均不会把这个 +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 时 @@ -62,8 +63,9 @@ renderer retry hook 和 daemon 历史修复均已移除。历史上的未完成 保留未完成历史和 ownership。外部中断测试仍覆盖 raw 完成、进程终止及终止失败。 - steer 测试覆盖请求前和异步准备期间的 Stop,验证未投递历史及其 dispatch pointer 保留。 受控的接受 ACK 在 Stop 后到达时,source invocation、用户轮次和 dispatch owner 均不改变, - source 历史不提前完成、不报告 handled,也不重放 steer;provider terminal 最后将 source - 结算为 cancelled 并释放 owner。补上 ACK 后检查前,该 race 在 `e4a860a2` 上失败。 + 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)。 diff --git a/apps/cli/src/session/AGENTS.md b/apps/cli/src/session/AGENTS.md index 70565cbbd..0c01fa3a0 100644 --- a/apps/cli/src/session/AGENTS.md +++ b/apps/cli/src/session/AGENTS.md @@ -55,7 +55,8 @@ Contract: specs/session-orchestration.md. 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. -- Never submit steer after Stop; a late accepted ACK cannot transfer ownership or requeue. +- 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 diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 9f4f49401..2d59b3e44 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -1558,6 +1558,14 @@ export class SessionExecutionService { runtime.activePromptRun !== ownedPromptRun ) { // 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' diff --git a/apps/cli/tests/session-execution-service.test.ts b/apps/cli/tests/session-execution-service.test.ts index 5d7344269..02c3975ca 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -5528,7 +5528,7 @@ describe('SessionExecutionService', () => { 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: 'pending_apply', + status: 'canceled', }); } expect(agentClient.pendingPromptCompletion).not.toBeNull(); @@ -5634,7 +5634,7 @@ describe('SessionExecutionService', () => { if (steering) { expect(deliveredSteers).toEqual([[{ type: 'text', text: 'change direction' }]]); expect(history.find((entry) => entry.id === 'steer-user-turn')).toMatchObject({ - status: 'pending_apply', + status: 'canceled', }); } expect(meta).toMatchObject({ diff --git a/specs/session-history-writes.md b/specs/session-history-writes.md index f07f48c95..555d99fef 100644 --- a/specs/session-history-writes.md +++ b/specs/session-history-writes.md @@ -61,8 +61,10 @@ That tolerance must not authorize creating new malformed items locally. 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. - 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. + 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 aa6e62315..868bc7b14 100644 --- a/specs/session-history-writes.zh.md +++ b/specs/session-history-writes.zh.md @@ -44,7 +44,8 @@ Translation: current 和压缩 item 的完成均不能释放执行 ownership。CLI 在取消确认后、接受下一轮前, 将尚未结束的压缩标记为 failed。打开 Session 不触发历史修复 RPC,也不改写旧结果。 - 已提交的 steer 等待接受时,若 Stop 先发生,则后到的成功 ACK 不得转移 ownership、 - 改变 source invocation 或将 source 结算为 handled。保持当前 cancellation owner, + 改变 source invocation 或将 source 结算为 handled。返回 `stale-turn` 前,将该 exact steer + 用户轮次标为 `canceled`,不改变 dispatch pointer。保持当前 cancellation owner, 直到 provider 完成。不得重排该已接受的 steer:拒绝本地 ownership 转移不代表消息未投递。 - 已接受的 steer 标记在写入和读取归一化后都必须保留;编辑重发不能把 steer 当作可独立重放的普通用户轮次。