Skip to content
Open
10 changes: 10 additions & 0 deletions .agents/docs/sessions-live-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,13 @@ 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. Active, ambiguous,
unchanged, and unreachable attempts remain retryable after history activity,
reconnect, or daemon replacement; only a remotely confirmed repair is terminal.
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,74 @@
# 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 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 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. 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,
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, 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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# 使用实时所有权证据协调陈旧的上下文压缩状态

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 会发起经过 capability gate 的协调请求。只有写入已确认的
`reconciled` 结果会被永久去重;`active`、`unknown` 和 `unchanged` 可以在后续历史活动、
offline-to-online 转换或新的 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 不是快速控制面工作。

目标 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、
写入确认失败、rewrite barrier 释放后的普通 turn 与 Goal turn dispatch、owner-daemon generation、
control lane 隔离、本地 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
Loading
Loading