Skip to content
Open
8 changes: 8 additions & 0 deletions .agents/docs/sessions-live-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)补上旧历史修复,
但不放宽这一约束。

## 范围与验证

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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。
76 changes: 75 additions & 1 deletion apps/cli/src/lib/assistant-turn-finalize.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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' });
});
});
25 changes: 25 additions & 0 deletions apps/cli/src/lib/assistant-turn-finalize.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
79 changes: 78 additions & 1 deletion apps/cli/src/lib/message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
getCodeCollabFileIndexSignalFlockDocId,
type SessionContextWindowUsage,
type SessionHistoryInput,
type SessionContextCompactionReconcileResponse,
type SessionLegacyMetaFields,
PERMISSION_REQUEST_TIMEOUT_MS,
type ChatFailedCode,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -6775,6 +6783,75 @@ export class MessageHandler {
}
}

private async reconcileSessionContextCompaction(args: {
sessionId: SessionId;
turnId: string;
toolCallId: string;
}): Promise<SessionContextCompactionReconcileResponse> {
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<SessionCodeCollabHostStartResponse> {
Expand Down
Loading
Loading