fix(session): close the review gaps the boundary opened
- `SessionSummary.updatedAt`'s wire doc still said "Persisted file mtime", which stopped being true for attached sessions. - The core invariant let `session/inherited` fall through the merge-extensible default. It is core-owned, so it gets an explicit case; an unbalanced seed legally places it inside an open turn, which the relation permits. - The Agent Note claimed the boundary reaches disk via `live.pending`/ `scheduleDrain`. Verified false: the constructor append precedes `enter()`, so it never publishes on `session/event` and rides the creation seed instead. Attaching is therefore a write where none happened before — recorded, since only `load()` stays a pure read. - The deferred-index proposal asserted this change documented the cold-mtime skew on `dsh-host-apiproxy`. It did not; the README entry now exists. - `firstLiveSeq`'s firehose gap runs through its own seq, not below it. - The boundary is not always at `firstLiveSeq` (the idempotence guard), so consumers scan for the last one. - `lastActivityTime` excludes by type, so a pickup time still leaks onto a synthetic closer when a boundary ends an open turn. Documented. - Pin the fork claim end-to-end: a child inherits a still-running parent's open bracket below its own boundary, while the parent has none. Fails if the write moves back to the load path. - Fix the telemetry title that contradicted its own assertions. The `/status` call site cannot be pinned the way the other two are: the command appends its own `command/run` before rendering, so the boundary is never the log tail there. Its fixture now at least renders over a boundary-bearing log.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, () => {
|
||||
|
||||
Reference in New Issue
Block a user