diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml index 77e99f140f..7ead9ed6b4 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md -2026-07-30-session-inherited-log-boundary.md: 3b9cb09d1d1a16d30fd212b439a97e3d9296bd6d -2026-07-30-session-inherited-log-boundary.zh.md: de048bc994ca97f937f74783b9055fbeb171314a +2026-07-30-session-inherited-log-boundary.md: b5ed9598078fae6fedd4c92613d57a7632eee323 +2026-07-30-session-inherited-log-boundary.zh.md: 83d3ce9ae87728228bcdfe73db1b2049bd55cc61 diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md index 3b9cb09d1d..b5ed959807 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md @@ -24,9 +24,11 @@ Two guards keep the marker from becoming noise. An empty seed writes nothing: a ## Persistence needs no changes -The marker is in `session.events` before the coordinator captures its creation seed, so it persists through the ordinary seed path — `onCreated`'s `createCore` + `appendCore`, or the ownerless-claim suffix write. No load-path write, no revision bump at load, no durable mark on a rejected `append`, and a read-only store still serves loads. +The constructor append happens before `enter()`, so the session has no store attachment: the marker never publishes on `session/event`, exactly like the seed events below it. It is instead part of the log `initFor` captures as the creation seed, and persists through the ordinary seed path — `onCreated`'s `createCore` + `appendCore`, or the ownerless-claim suffix write. A consumer that watches the firehose therefore never sees the boundary and must read it from the log. -Being a live event, it reaches disk through the write-behind drain (`session/event` → `live.pending` → `scheduleDrain`) rather than a synchronous commit, so a crash can lose it. That costs nothing: `pending` drains in order, so a lost boundary means every live event above it is lost too, and the next pickup reads the same bytes the previous one did, appends its own boundary, and classifies the bracket identically. In-process consumers should prefer `firstLiveSeq`, which is exact before any write. +Consequences for the seam: `load()` stays a pure read, with no revision bump, no `commitRepair` on a balanced log, and no durable mark left by a rejected `append`. **Attaching is not a pure read**, though — a pickup now writes where nothing was written before, so a read-only or full disk fails at `session/created` rather than at the first real turn. That is the one cost this placement adds, and it is narrower than the load-path version's (which failed the load itself). + +A crash before the seed write reaches disk loses the boundary, and that costs nothing: the pending batch is written in order, so a lost boundary means every event above it is lost too. The next pickup reads the same bytes the previous one did, appends its own boundary, and classifies the bracket identically. In-process consumers should prefer `firstLiveSeq`, which is exact before any write. ## Scope of the guarantee diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md index de048bc994..83d3ce9ae8 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md @@ -24,9 +24,11 @@ Status: implemented ## 持久化无需任何改动 -协调器捕获创建种子时该标记已在 `session.events` 中,因此它通过普通的种子路径落盘——`onCreated` 的 `createCore` + `appendCore`,或无主认领的后缀写入。没有加载路径写入、加载时没有 revision 递增、被拒绝的 `append` 不留下持久标记,只读存储依然可以服务加载。 +构造函数中的 append 发生在 `enter()` 之前,因此会话尚无 store attachment:该标记不会在 `session/event` 上发布,与它下方的种子事件完全一样。它属于 `initFor` 捕获的那份创建种子,并通过普通的种子路径落盘——`onCreated` 的 `createCore` + `appendCore`,或无主认领的后缀写入。因此监听 firehose 的消费方永远看不到这条边界,必须从日志中读取它。 -作为实时事件,它经由后写式 drain(`session/event` → `live.pending` → `scheduleDrain`)而不是同步提交到达磁盘,因此崩溃可能丢掉它。这没有代价:`pending` 按序 drain,所以丢掉一个边界意味着它上面的每个实时事件也一起丢掉,而下一次接手读到的字节与上一次相同,会追加自己的边界,并对括号作出完全相同的判定。进程内消费方应优先使用 `firstLiveSeq`,它在任何写入之前就是精确的。 +对 seam 的影响:`load()` 仍是纯读取,没有 revision 递增,对平衡日志不走 `commitRepair`,被拒绝的 `append` 也不留下持久标记。但**接手不是纯读取**——如今一次拾起会在此前完全无写入的路径上产生写入,因此只读存储或磁盘写满会在 `session/created` 处报错,而不是在第一个真实轮次处。这是本放置方式新增的唯一成本,并且比加载路径方案的成本更窄(后者会让加载本身失败)。 + +若崩溃发生在种子写入到达磁盘之前,边界会丢失,而这没有代价:待处理批次按序写入,所以丢掉一个边界意味着它上面的每个事件也一起丢掉。下一次接手读到的字节与上一次相同,会追加自己的边界,并对括号作出完全相同的判定。进程内消费方应优先使用 `firstLiveSeq`,它在任何写入之前就是精确的。 ## 保证的适用范围 diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml index 643238c384..fb497a66e5 100644 --- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md -2026-07-29-durable-last-activity-index.md: 7d8a83e96d9339054b0d15b2331284c8aecfe65d -2026-07-29-durable-last-activity-index.zh.md: b081aa91cfa51ef8bb87e93f4091431f2d6ddce1 +2026-07-29-durable-last-activity-index.md: 2569ce8dcdef4ce85713e4c3905c3a170be00a98 +2026-07-29-durable-last-activity-index.zh.md: 64078982bb09b0f94c41d5424d195d371c1a263f diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md index 7d8a83e96d..2569ce8dcd 100644 --- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md +++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md @@ -12,7 +12,7 @@ mtime answers a different question: when the artifact was last written. Every du The attached projection has a real fix — `lastActivityTime()` skips boundaries — but it needs the event log, and the cold path deliberately does not read one. Reading the log to compute `updatedAt` would defeat the header-only listing that keeps `list()` scaling with session count rather than log size. -The boundary change raised the frequency of this defect and documented it as a known limitation on `dsh-host-apiproxy`. It did not introduce the approximation, and removing the approximation is a durable-format decision, which is why it is scoped here rather than there. +The [boundary change](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md) raised the frequency of this defect, because a pickup now writes where nothing was written before; `dsh-host-apiproxy`'s README records it under Known Limitations. It did not introduce the approximation, and removing the approximation is a durable-format decision, which is why it is scoped here rather than there. ## Proposal diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md index b081aa91cf..64078982bb 100644 --- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md @@ -12,7 +12,7 @@ mtime 回答的是另一个问题:这份产物上次是什么时候被写入 已附加会话的那个投影有真正的修复办法(`lastActivityTime()` 会跳过边界),但它需要事件日志,而冷路径有意不去读日志。为计算 `updatedAt` 而读取日志,会让只读 header 的列举失去意义,而正是它让 `list()` 的开销随会话数量而非日志体量增长。 -边界那次变更提高了这个缺陷的出现频率,并把它作为一项已知限制记录在 `dsh-host-apiproxy` 上。它并没有引入这套近似做法,而移除这套近似是一项持久格式决策,因此它的范围划在本文,而不是那里。 +[边界那次变更](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)提高了这个缺陷的出现频率,因为一次拾起如今会在此前完全无写入的路径上产生写入;`dsh-host-apiproxy` 的 README 已在 Known Limitations 中记录该项。它并没有引入这套近似做法,而移除这套近似是一项持久格式决策,因此它的范围划在本文,而不是那里。 ## 提案 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 316c5a9830..1ddfba85ec 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1590,7 +1590,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:706`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:710`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 77d44ee3ac..9ffcfd8772 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/session.md -session.md: 25fb5471e600541a3a2d5d2b67083f5b443dcd52 -session.zh.md: 9cf8c61d3c2ca27c4cdc6adf5804e59dd73b5f5e +session.md: 03a1a3ee3350009e2f26cc52ff013643a04b5bd9 +session.zh.md: d1c676f1c9297582d9e80ed5d15af78c7ef68e52 diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 25fb5471e6..03a1a3ee33 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -97,6 +97,10 @@ interface SessionEventMap { * and no writer in this lifecycle produced it. Payload is empty — position * and `time` carry the meaning. * + * Locate the LAST one rather than reading `firstLiveSeq`: a seed already + * ending in a boundary is not re-marked, so reopening an untouched session + * does not grow its log per pickup. + * * An owner of a standalone open/close bracket (`compact/start` … * `compact/end`) reads it because inherited history and live work are * otherwise byte-identical: an unmatched opening marker below the boundary @@ -346,6 +350,10 @@ declare class Session { * `session/inherited` event at this seq, which is what a consumer reading * STORED history reads. Prefer this field in-process — it is exact before * the marker's write reaches storage. + * + * The marker is appended before the store attaches, so when one exists the + * event AT this seq did not publish either: the firehose gap runs through + * `firstLiveSeq`, not just below it. */ readonly firstLiveSeq: number; constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader); diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 9cf8c61d3c..d1c676f1c9 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -97,6 +97,10 @@ interface SessionEventMap { * and no writer in this lifecycle produced it. Payload is empty — position * and `time` carry the meaning. * + * Locate the LAST one rather than reading `firstLiveSeq`: a seed already + * ending in a boundary is not re-marked, so reopening an untouched session + * does not grow its log per pickup. + * * An owner of a standalone open/close bracket (`compact/start` … * `compact/end`) reads it because inherited history and live work are * otherwise byte-identical: an unmatched opening marker below the boundary @@ -348,6 +352,10 @@ declare class Session { * `session/inherited` event at this seq, which is what a consumer reading * STORED history reads. Prefer this field in-process — it is exact before * the marker's write reaches storage. + * + * The marker is appended before the store attaches, so when one exists the + * event AT this seq did not publish either: the firehose gap runs through + * `firstLiveSeq`, not just below it. */ readonly firstLiveSeq: number; constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader); diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 3718a5770e..7498adce79 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -78,7 +78,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:306`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:281`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts) ## Events @@ -413,6 +413,10 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s * and no writer in this lifecycle produced it. Payload is empty — position * and `time` carry the meaning. * + * Locate the LAST one rather than reading `firstLiveSeq`: a seed already + * ending in a boundary is not re-marked, so reopening an untouched session + * does not grow its log per pickup. + * * An owner of a standalone open/close bracket (`compact/start` … * `compact/end`) reads it because inherited history and live work are * otherwise byte-identical: an unmatched opening marker below the boundary @@ -423,7 +427,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s 'session/inherited': Record ``` -Source: [`packages/core/session/src/types.ts:266`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts) #### `session/title` — log-only diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index ba27261902..318224ff30 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -394,6 +394,10 @@ export class Session { * `session/inherited` event at this seq, which is what a consumer reading * STORED history reads. Prefer this field in-process — it is exact before * the marker's write reaches storage. + * + * The marker is appended before the store attaches, so when one exists the + * event AT this seq did not publish either: the firehose gap runs through + * `firstLiveSeq`, not just below it. */ readonly firstLiveSeq: number diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts index e57db4fe1a..5a119f3297 100644 --- a/packages/core/session/src/invariant.ts +++ b/packages/core/session/src/invariant.ts @@ -144,6 +144,9 @@ function validateEvent( } case 'user/message': break + case 'session/inherited': + // Unconstrained: an unbalanced seed legally puts it inside an open turn. + break case 'steering/message': case 'todo/write': case 'request/header': { diff --git a/packages/core/session/src/repair.ts b/packages/core/session/src/repair.ts index d8cf2db3c2..1a76b2d4a8 100644 --- a/packages/core/session/src/repair.ts +++ b/packages/core/session/src/repair.ts @@ -14,6 +14,11 @@ import type { SessionEvent } from './types.ts' * The `time` of the log's last event representing actual work, skipping the * `session/inherited` boundary — picking a session up is not activity, so * activity ordering must exclude it. + * + * Excluded by type, so a pickup time still leaks when a boundary is the last + * event of an open turn: {@link interruptedTurnClosers} copies it onto the + * synthetic `turn/end`, which this counts as work. Reachable only by seeding an + * unbalanced log directly — `load()` balances first. * @param events - the log to scan, in seq order. * @returns the latest non-boundary event's `time`, or undefined when there is none. */ diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 149af02468..35bd493e1f 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -256,6 +256,10 @@ export interface SessionEventMap { * and no writer in this lifecycle produced it. Payload is empty — position * and `time` carry the meaning. * + * Locate the LAST one rather than reading `firstLiveSeq`: a seed already + * ending in a boundary is not re-marked, so reopening an untouched session + * does not grow its log per pickup. + * * An owner of a standalone open/close bracket (`compact/start` … * `compact/end`) reads it because inherited history and live work are * otherwise byte-identical: an unmatched opening marker below the boundary diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index fcfbd229cf..e32191a36b 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -7,6 +7,8 @@ import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { 'test/log-only': { value: string } + /** Stands in for a plugin's open/close bracket (`compact/start`). */ + 'test/bracket-open': { id: string } } } @@ -154,6 +156,27 @@ describe('SessionStore.fork', () => { } }) + it('marks a bracket the child inherited from a still-running parent', async () => { + // The constructor placement's central claim, unreachable from the + // persistence load path. + const { ctx, sessions } = await setup() + const parent = ctx.sessions.create(SessionId('bracket-parent'), { meta: { cwd: '/workspace' } }) + appendClosedTurn(parent, 1, 'work') + const open = parent.append('test/bracket-open', { id: 'op-1' }) + + const child = sessions.fork(parent, undefined, SessionId('bracket-child')) + + // Parent: nothing above the bracket, so its owner must treat it as live. + expect(parent.events.at(-1)).toBe(open) + expect(parent.events.some(event => event.type === 'session/inherited')).toBe(false) + // Child: the same bracket sits below its boundary, so it is dead history. + const boundary = child.events.at(-1) + expect(boundary).toMatchObject({ type: 'session/inherited' }) + expect(boundary!.seq).toBeGreaterThan(open.seq) + expect(child.firstLiveSeq).toBe(open.seq + 1) + expect(inherited(child).at(-1)).toMatchObject({ type: 'test/bracket-open', data: { id: 'op-1' } }) + }) + it('rejects invalid boundaries before creating a child', async () => { const { ctx, sessions } = await setup() const empty = ctx.sessions.create(SessionId('empty')) diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts index aa0cd5bd64..281ba50f4c 100644 --- a/packages/core/session/tests/invariant.spec.ts +++ b/packages/core/session/tests/invariant.spec.ts @@ -382,6 +382,24 @@ describe('session-log invariants', () => { .toThrow(/turn 1 is still open/) }) + it('accepts the inherited boundary whether or not a turn is open', async () => { + const { ctx } = await setup() + // Balanced seed: between turns. + expect(() => ctx.sessions.create(SessionId('inherited-between-turns'), { seed: [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + ] })).not.toThrow() + // Unbalanced seed: inside the open turn, which the relation permits. + const open = ctx.sessions.create(SessionId('inherited-inside-open-turn'), { seed: [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + ] }) + expect(open.events.map(event => event.type)).toEqual(['turn/start', 'session/inherited']) + // Still open afterwards: the boundary moves no cursor. + expect(() => open.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })) + .toThrow(/turn 1 is still open/) + expect(() => open.append('turn/end', { turn: 1, reason: { kind: 'completed' } })).not.toThrow() + }) + it('removes all listeners when the companion is disposed', async () => { const { ctx, fiber } = await setup() const session = ctx.sessions.create() diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 18202ed036..95438c076e 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 1f0daedc54888a1951bc83c474f83287aaf42307 -README.zh.md: abf5417cdbe93f1199c621ac101249986969da93 +README.md: 3123f64d19a77e0082b8d0ed6f93d279da0eb065 +README.zh.md: 0bc2886aae3c3492d8361acabfec163a10eb4b30 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 1f0daedc54..3123f64d19 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -42,3 +42,4 @@ None; this package neither assembles nor sends a provider request. - **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code. - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. - **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)). +- **A cold session's `updatedAt` counts a mere pickup as a write (per-file backends only)** — the attached projection excludes the `session/inherited` boundary, because picking a session up is not activity, but a cold session's `updatedAt` is its log file's mtime and every durable write refreshes that, the boundary included. `agentFor()` resumes a cold session on first touch, so merely opening one in a client writes it. This applies only where `locate()` resolves a per-session artifact, i.e. JSONL; SQLite returns `undefined`, so its cold sessions fall back to `createdAt` and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the [last-activity-index Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md). diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index abf5417cdb..0bc2886aae 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -42,3 +42,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr - **预留 seam 不进入 `RpcMethodMap`**:`session.fork`、`prompt.mode: 'inject'`、`task.list`、`host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。 - **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 - **Linux 原生选择器依赖桌面工具**:在 `native` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。 +- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/inherited` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime,而每一次持久写入都会刷新它,包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONL;SQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会排在它最后一次真实活动之后。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。 diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 3a824c8261..bdb3598d1d 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -127,7 +127,11 @@ export interface SessionModels { /** Session list entry (v1 builds no index: list does readdir+stat). */ export interface SessionSummary { sessionId: SessionId - /** Persisted file mtime. */ + /** + * Last activity. Attached: the last non-`session/inherited` event, since a + * pickup is not activity. Cold: the log's mtime, or `createdAt` for a backend + * with no per-session file (README Known Limitations covers the skew). + */ updatedAt: number /** Status of the attached agent; always false for cold (unattached) sessions. */ running: boolean diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index d994444df1..2b37bc3cb9 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -86,8 +86,7 @@ describe('attached updatedAt excludes the inherited-history boundary', () => { await ctx.plugin(AgentRegistry) const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) - // Old work, resumed just now: the boundary's time is far above the work's, - // so reading the log tail would report the pickup as activity. + // Old work, resumed just now: the log tail would report the pickup. const worked = 1_000_000 const resumed = ctx.sessions.create(sid('resumed-untouched'), { seed: [ diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index a429efb789..58228179d5 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -191,7 +191,7 @@ describe('TelemetryCoordinator adoption', () => { expect(seqs.filter(([id]) => id === 'seeded')).toEqual([['seeded', 2], ['seeded', 3]]) }) - it('resume shape: a full-log seed exports nothing yet still rebuilds the chunk projection', async () => { + it('resume shape: a full-log seed exports only its own boundary and rebuilds the chunk projection', async () => { const backend = new FakeBackend() const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt b/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt index 58cc124b75..b35a53ed0d 100644 --- a/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt +++ b/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt @@ -48,7 +48,7 @@ buffer 17| "│ │" style 0-0 dim style 55-55 dim -18| "│ Agent: idle · 7 events · 1 turn · 1 step · 1 │" +18| "│ Agent: idle · 8 events · 1 turn · 1 step · 1 │" style 0-0 dim style 3-12 dim style 55-55 dim diff --git a/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt b/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt index b5f776918f..70ec6a3a04 100644 --- a/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt +++ b/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt @@ -45,7 +45,7 @@ buffer 16| "│ │" style 0-0 dim style 81-81 dim -17| "│ Agent: idle · 7 events · 1 turn · 1 step · 1 tool call │" +17| "│ Agent: idle · 8 events · 1 turn · 1 step · 1 tool call │" style 0-0 dim style 3-12 dim style 81-81 dim diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index bb870ec004..912febae70 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -909,6 +909,12 @@ describe('TUI terminal-state snapshots', () => { messageSeqs: [1], source: { kind: 'fallback' }, }) + // Renders over a boundary-bearing log. It cannot pin the exclusion: + // `/status` appends its own `command/run` first, so the boundary is + // never the tail here. The other two call sites pin it. + dateNow.mockReturnValue(Date.parse('2026-07-22T10:10:11.000Z')) + session.append('session/inherited', {}) + dateNow.mockReturnValue(Date.parse('2026-07-22T09:10:11.000Z')) }, }, { columns: 92, rows: 32 }) await renderAfter(harness, () => {