Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Settle context compaction after provider failure

Status: implemented
Translation: current

[中文](2026-09-10-context-compaction-terminal-state.zh.md)

## Abstract

A failed or cancelled remote context-compaction request could leave its tool-call
item in `pending` or `in_progress`, so both the transcript and session-usage footer
kept showing an indefinite spinner after the provider was no longer active. Lody
now persists unresolved compaction activities in that turn as `failed` after a
failed provider prompt settles or the cancelled prompt is successfully terminated.
Renderers continue to follow the durable tool-call status directly.

## Decision

The compaction activity and its owning assistant turn have different terminal
signals. `SessionHistory.finished` records host finalization, including interrupted
turn teardown, and therefore does not prove that the provider prompt stopped. The
UI must not infer a compaction terminal state from that field.

The prompt error path has stronger evidence: a prompt that started has returned
control with an error and is no longer in flight. Before finalization clears the
turn state, it asks the finalizer to change only `pending` or `in_progress`
context-compaction items in that exact assistant turn to `failed`. This lifecycle
signal covers transport failures such as `Connection failed: error sending request`
without depending on a numeric ACP error code or a message allowlist.

Cancellation has two phases. Host teardown first records the cancelled turn while
leaving compaction active. The execution owner then waits for the raw ACP request;
after five seconds it may terminate the old session, and a failed termination keeps
waiting for raw completion. Only after that drain completes does the host settle
the unresolved compaction, flush the usage state, and release the owner. Explicit
provider terminal states remain unchanged, and a late provider update for the same
`toolCallId` can still replace `failed` with `completed`.

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.

## Scope and verification

This fixes [issue #570](https://github.com/LodyAI/Lody/issues/570) in
[PR #573](https://github.com/LodyAI/Lody/pull/573). Together with the provider
ownership recovery from [PR #571](https://github.com/LodyAI/Lody/pull/571), it also
completes the compaction activity cleanup required by
[issue #267](https://github.com/LodyAI/Lody/issues/267): an interrupted `/compact`
remains active while the provider prompt is active, then becomes terminal before
the next prompt can acquire the session.

Unit coverage uses the exact #570 transport-error shape and verifies that a settled
provider prompt requests compaction settlement without an ACP code. Deterministic
cancellation coverage verifies that compaction remains active while raw provider
ownership is retained, becomes failed after raw completion or successful
termination, stays active after failed termination, and is settled before the next
prompt runs. The lifecycle regression also covers Loro document reopening, a late
completed update, and a new compaction in the next turn. No Model API Simulator or
end-to-end test was added.
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Provider 失败后收敛上下文压缩状态

Status: implemented
Translation: current

[English](2026-09-10-context-compaction-terminal-state.md)

## 摘要

远端上下文压缩请求失败或取消后,其 tool-call 条目可能仍停留在 `pending` 或
`in_progress`,导致 provider 已不再活跃时,消息流和会话用量底栏仍无限显示旋转状态。
现在,失败的 provider prompt 已结束或取消中的 prompt 被成功 terminate 后,Lody 会把该
turn 内未收敛的压缩活动持久化为 `failed`;渲染层继续直接遵循持久化 tool-call 状态。

## 决策

压缩活动与承载它的 assistant turn 具有不同的终止信号。`SessionHistory.finished` 记录
host finalization,其中也包括被中断 turn 的 teardown,因此不能证明 provider prompt 已
停止;UI 不应从该字段推断压缩终态。

Prompt error path 提供了更强的证据:一个已经启动的 prompt 带错误返回了控制权,且已不再
处于 in-flight 状态。在 finalization 清理 turn state 前,这条路径要求 finalizer 仅把该
assistant turn 内 `pending` 或 `in_progress` 的 context-compaction 条目改为 `failed`。这个
生命周期信号可以覆盖 `Connection failed: error sending request` 一类传输错误,不依赖
numeric ACP error code 或错误文案白名单。

取消分为两个阶段。Host teardown 先记录 turn 已取消,但保持 compaction 活跃;随后执行
owner 等待 raw ACP request,五秒后可以 terminate 旧 session,而 terminate 失败时继续等待
raw completion。只有 drain 完成以后,host 才收敛未完成 compaction、flush usage state,
并释放 owner。Provider 已写入的终态保持不变;相同 `toolCallId` 的迟到 provider update 仍可
把 `failed` 覆盖为 `completed`。

该设计修复未来的 error path,并处理之后再次经过 failure-aware finalization 的历史。
它不会迁移已经持久化的陈旧历史,因为这些历史里没有 durable evidence 能区分 #570 和
“已中断但 provider prompt 仍活跃”的情况。

## 范围与验证

本修复通过 [PR #573](https://github.com/LodyAI/Lody/pull/573) 处理
[issue #570](https://github.com/LodyAI/Lody/issues/570)。它与
[PR #571](https://github.com/LodyAI/Lody/pull/571) 的 provider ownership recovery 组合后,
也补全了 [issue #267](https://github.com/LodyAI/Lody/issues/267) 所需的 compaction activity
清理:被中断的 `/compact` 在 provider prompt 仍活跃时保持 active,并在下一 prompt 可以
取得 session 前进入终态。

单元测试直接使用 #570 的原始传输错误 shape,验证 prompt 已结束时即使没有 ACP code 也会
请求 compaction settlement。确定性的 cancellation 测试验证:保留 raw provider ownership
期间 compaction 仍为 active;raw completion 或成功 terminate 后变为 failed;terminate
失败后继续 active;且下一 prompt 执行前已经完成收敛。生命周期回归还覆盖 Loro 文档重新
打开、迟到的 completed update,以及下一 turn 的新压缩。本次没有增加 Model API
Simulator 或端到端测试。
51 changes: 51 additions & 0 deletions apps/cli/src/lib/assistant-turn-finalize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,57 @@ describe('markAssistantTurnFinished', () => {
expect(history[0]?.permissionWaitMs).toBe(4_000);
});

it('settles an incomplete compaction only with provider failure evidence', () => {
const history = [
assistantEntry({
id: 'assistant:u1',
items: [
{
type: 'tool_call',
toolCallId: 'compact-1',
title: 'Context compacting',
status: 'in_progress',
activityKind: 'context_compaction',
},
],
}),
];

markAssistantTurnFinished(history, { endedAt: TURN_ENDED_AT });
expect(history[0]?.items?.[0]).toMatchObject({ status: 'in_progress' });

markAssistantTurnFinished(history, {
endedAt: APP_CLOSED_AT,
settleContextCompactionAsFailed: true,
});
expect(history[0]).toMatchObject({ finished: true, endedAt: TURN_ENDED_AT });
expect(history[0]?.items?.[0]).toMatchObject({ status: 'failed' });
});

it('preserves a provider terminal compaction during failure finalization', () => {
const history = [
assistantEntry({
id: 'assistant:u1',
items: [
{
type: 'tool_call',
toolCallId: 'compact-1',
title: 'Context compacting',
status: 'completed',
activityKind: 'context_compaction',
},
],
}),
];

markAssistantTurnFinished(history, {
endedAt: TURN_ENDED_AT,
settleContextCompactionAsFailed: true,
});

expect(history[0]?.items?.[0]).toMatchObject({ status: 'completed' });
});

it('never stamps a user or system entry standing after the turn', () => {
const history: SessionHistoryInput[] = [
assistantEntry({ id: 'assistant:u1' }),
Expand Down
19 changes: 18 additions & 1 deletion apps/cli/src/lib/assistant-turn-finalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,29 @@ export const markAssistantTurnFinished = (
turnId?: string | undefined;
endedAt: number;
permissionWaitMs?: number | undefined;
/** The provider prompt returned an error before its compaction emitted a terminal update. */
settleContextCompactionAsFailed?: boolean | undefined;
}
): SessionHistoryInput[] => {
const { turnId, endedAt, permissionWaitMs } = options;
const { turnId, endedAt, permissionWaitMs, settleContextCompactionAsFailed } = options;
for (let i = history.length - 1; i >= 0; i--) {
const entry = history[i];
if (entry && entry.role === 'assistant' && (!turnId || entry.id === turnId)) {
if (settleContextCompactionAsFailed && entry.items) {
let changed = false;
const items = entry.items.map((item) => {
if (
item.type !== 'tool_call' ||
item.activityKind !== 'context_compaction' ||
(item.status !== 'pending' && item.status !== 'in_progress')
) {
return item;
}
changed = true;
return { ...item, status: 'failed' as const };
});
if (changed) entry.items = items;
}
// Already finalized: its terminal timing is the truth, not this call's clock.
if (entry.finished === true) break;
entry.finished = true;
Expand Down
17 changes: 13 additions & 4 deletions apps/cli/src/lib/message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3118,8 +3118,8 @@ export class MessageHandler {
userTurnId
),
turnFinalization: {
finalizeACPState: async (sessionId, turnId) =>
await this.finalizeACPState(sessionId, turnId),
finalizeACPState: async (sessionId, turnId, options) =>
await this.finalizeACPState(sessionId, turnId, options),
persistCodeCollabTurnDiffs: async (sessionId, turnId) =>
await this.persistCodeCollabTurnDiffs(sessionId, turnId),
flushSessionUsage: async (sessionId) => await this.flushSessionUsage(sessionId),
Expand Down Expand Up @@ -5822,7 +5822,11 @@ export class MessageHandler {
}
}

private async finalizeACPState(sessionId: SessionId, turnId?: string): Promise<void> {
private async finalizeACPState(
sessionId: SessionId,
turnId?: string,
options?: { settleContextCompactionAsFailed?: boolean }
): Promise<void> {
// Finalization marks the last assistant entry finished — that entry must
// exist and be correctly ordered first, so wait for the turn history gate
// (bounded; opens on user-turn sync or timeout).
Expand All @@ -5849,7 +5853,12 @@ export class MessageHandler {
// Mark the owning assistant entry as finished and record timing.
const sessionDoc = await this.workspaceDocument.getOrCreateSessionDoc(sessionId);
await sessionDoc.updateHistory((history) =>
markAssistantTurnFinished(history, { turnId, endedAt, permissionWaitMs })
markAssistantTurnFinished(history, {
turnId,
endedAt,
permissionWaitMs,
settleContextCompactionAsFailed: options?.settleContextCompactionAsFailed,
})
);
await sessionDoc.waitUntilSynced();
} catch (error) {
Expand Down
41 changes: 34 additions & 7 deletions apps/cli/src/session/session-execution-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,11 @@ const SILENT_TURN_FAILURE_MESSAGE =
'new session if this conversation has grown too long.';

type TurnFinalizationEffects = {
finalizeACPState: (sessionId: SessionId, turnId?: string) => Promise<void>;
finalizeACPState: (
sessionId: SessionId,
turnId?: string,
options?: { settleContextCompactionAsFailed?: boolean }
) => Promise<void>;
persistCodeCollabTurnDiffs?: (sessionId: SessionId, turnId: string) => Promise<boolean>;
flushSessionUsage: (sessionId: SessionId) => Promise<void>;
syncSessionBranchName: (sessionId: SessionId, session: ISession) => Promise<string | null>;
Expand Down Expand Up @@ -1974,6 +1978,19 @@ export class SessionExecutionService {
)
);
}

if (runtime?.promptStarted) {
yield* self.ignoreWithWarning(
options.sessionId,
'Failed to settle context compaction after cancelled ACP prompt stopped',
self.tryPromise(async () => {
await self.deps.turnFinalization.finalizeACPState(options.sessionId, options.turnId, {
settleContextCompactionAsFailed: true,
});
await self.persistTurnDiffsAndFlushUsage(options.sessionId, options.turnId);
})
);
}
}).pipe(
Effect.ensuring(
Effect.sync(() => {
Expand Down Expand Up @@ -2191,7 +2208,9 @@ export class SessionExecutionService {
if (options.userTurnId) {
await this.markTurnFailed(options.sessionId, options.sessionDoc, options.userTurnId);
}
await this.handleTurnError(options.sessionId, options.sessionDoc, options.error);
await this.handleTurnError(options.sessionId, options.sessionDoc, options.error, {
providerPromptSettled: options.runtime.promptStarted && !options.runtime.promptInFlight,
});
await options.onUnhandledError?.(options.error);
}

Expand Down Expand Up @@ -2294,18 +2313,26 @@ export class SessionExecutionService {
private async handleTurnError(
sessionId: SessionId,
sessionDoc: SessionDocument,
error?: unknown
error?: unknown,
options?: { providerPromptSettled?: boolean }
): Promise<void> {
await this.deps.turnFinalization.finalizeACPState(sessionId);
const acpError = error ? parseACPError(error) : null;
const providerDisconnected = error ? isAgentDisconnectedError(error) : false;
await this.deps.turnFinalization.finalizeACPState(
sessionId,
this.currentTurnBySession.get(sessionId),
{
settleContextCompactionAsFailed:
options?.providerPromptSettled === true || acpError !== null || providerDisconnected,
}
);
await this.persistCodeCollabTurnDiffsAfterACPFinalization(
sessionId,
this.currentTurnBySession.get(sessionId)
);
await this.deps.turnFinalization.flushSessionUsage(sessionId);

if (error) {
const acpError = parseACPError(error);

if (acpError) {
const failureReason = mapACPErrorToFailureReason(acpError);
const userMessage = getACPErrorUserMessage(acpError);
Expand Down Expand Up @@ -2346,7 +2373,7 @@ export class SessionExecutionService {
);
}
}
} else if (isAgentDisconnectedError(error)) {
} else if (providerDisconnected) {
this.deps.logger.warn(
`[${sessionId}] Agent disconnected during chat, terminating session for clean restart`
);
Expand Down
Loading
Loading