feat(session): project the inherited-history boundary into the log
A plugin owning a standalone open/close bracket cannot tell a dead marker from a live one: an unmatched `compact/start` reads identically whether the previous writer died mid-compaction or a compaction is running now. `Session.firstLiveSeq` already holds that answer exactly, but only in memory. Append the log-only `session/inherited` event at that seq from the seeded constructor — the single waist all six seeded-start paths pass through (resume, configured startup on a persisted id, `sessions.fork()`, a subagent fork child, `adopt()`'s live prefix, and a bare seeded `create`). Read it through the new `isInheritedSeq(events, seq)`. The constructor placement means persistence needs no changes: the marker is already in `events` when a backend captures the creation seed, so it rides the ordinary seed path with no load-time write. It also covers fork, where the inherited bracket's owner may still be running — the case a persistence-layer boundary could not reach. Activity ordering excludes the boundary through `lastActivityTime()`, since lazy resume makes browsing a pickup and the three call sites would otherwise float every opened session to the top of a picker or list.
This commit is contained in:
@@ -78,6 +78,43 @@ describe('sessions.list cold merge', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('attached updatedAt excludes the inherited-history boundary', () => {
|
||||
it('reports the last real work, not the pickup, so a resumed-untouched session does not float', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
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.
|
||||
const worked = 1_000_000
|
||||
const resumed = ctx.sessions.create(sid('resumed-untouched'), {
|
||||
seed: [
|
||||
{ type: 'turn/start', seq: 0, time: worked, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/end', seq: 1, time: worked, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
],
|
||||
meta: { cwd: '/proj', createdAt: 500 },
|
||||
})
|
||||
ctx.agents.register({ id: resumed.id, session: resumed, status: 'idle', ctx } as Agent)
|
||||
const boundary = resumed.events.at(-1)
|
||||
expect(boundary?.type).toBe('session/inherited')
|
||||
expect(boundary?.time).toBeGreaterThan(worked)
|
||||
|
||||
const listed = await api.sessions.list(request({}))
|
||||
if (!listed.result.ok) throw new Error('list failed')
|
||||
const summary = listed.result.value.items.find(item => item.sessionId === 'resumed-untouched')
|
||||
expect(summary?.updatedAt).toBe(worked)
|
||||
|
||||
// Real work above the boundary does move it.
|
||||
resumed.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const after = await api.sessions.list(request({}))
|
||||
if (!after.result.ok) throw new Error('list failed')
|
||||
const moved = after.result.value.items.find(item => item.sessionId === 'resumed-untouched')
|
||||
expect(moved?.updatedAt).toBeGreaterThan(worked)
|
||||
})
|
||||
})
|
||||
|
||||
describe('degenerate composition (no persistence, no factory)', () => {
|
||||
it('list skips the cold merge and resume maps a non-not-found failure to internal', async () => {
|
||||
const ctx = new Context()
|
||||
|
||||
Reference in New Issue
Block a user