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:
@@ -378,7 +378,9 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
await ctx.sessions.flush(child)
|
||||
const loaded = await ctx.sessionPersistence.load(child.id)
|
||||
|
||||
expect(loaded.events).toEqual(source.events)
|
||||
// The inherited prefix reaches disk verbatim, then the child's boundary.
|
||||
expect(loaded.events.slice(0, source.events.length)).toEqual(source.events)
|
||||
expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited', seq: source.events.length })
|
||||
expect(loaded.meta).toMatchObject({
|
||||
id: SessionId('persist-child'),
|
||||
cwd: '/workspace',
|
||||
|
||||
@@ -218,7 +218,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
live.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.sessions.flush(live)
|
||||
const loaded = await ctx.sessionPersistence.load(id)
|
||||
expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'turn/end'])
|
||||
// The seeded constructor's boundary persisted between the stored
|
||||
// turn/start and the turn/end appended live.
|
||||
expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'session/inherited', 'turn/end'])
|
||||
expect(loaded.events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'completed' } },
|
||||
@@ -477,11 +479,14 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
const forked = ctx.sessions.create(SessionId('forked'), { seed, meta: { cwd: WORK } })
|
||||
await ctx.sessions.flush(forked) // onCreated persisted the seed
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('forked'))
|
||||
expect(loaded.events).toEqual(seed)
|
||||
// Fork is where the marker earns its keep: the inherited prefix may
|
||||
// carry a bracket the still-running parent owns.
|
||||
expect(loaded.events.slice(0, seed.length)).toEqual(seed)
|
||||
expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited', seq: seed.length })
|
||||
// A flush with no NEW events must not double-write.
|
||||
await ctx.sessions.flush(forked)
|
||||
const reloaded = await ctx.sessionPersistence.load(SessionId('forked'))
|
||||
expect(reloaded.events).toEqual(seed)
|
||||
expect(reloaded.events).toEqual(loaded.events)
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
@@ -510,7 +515,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
await second.ctx.sessions.flush(s2)
|
||||
|
||||
const reloaded = await second.ctx.sessionPersistence.load(SessionId('resumed'))
|
||||
expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
||||
// 0-5 the resumed seed, 6 the boundary, 7-8 the new turn.
|
||||
expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8])
|
||||
expect(reloaded.events[6]).toMatchObject({ type: 'session/inherited' })
|
||||
} finally {
|
||||
await second.fiber.dispose()
|
||||
await fix.cleanup()
|
||||
@@ -791,7 +798,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
const live = ctx.sessions.create(SessionId('lazy-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } })
|
||||
await expect(ctx.sessions.flush(live)).resolves.toBeUndefined()
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('lazy-claim'))
|
||||
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5])
|
||||
// Seeded 0-5 plus the constructor's boundary at 6.
|
||||
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6])
|
||||
expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited' })
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
@@ -844,7 +853,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
}, { inject: ['sessions'] }))
|
||||
await ctx.sessions.flush(cont)
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('claim'))
|
||||
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
||||
// 6-7 the claimed suffix; 8 the boundary over the whole seed.
|
||||
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8])
|
||||
expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited' })
|
||||
expect(loaded.meta).toEqual(durableMeta)
|
||||
expect(loaded.meta.createdAt).toBe(1000)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user