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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user