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:
Hypatia May
2026-07-30 11:38:51 +08:00
parent 2a53806275
commit b341155652
41 changed files with 850 additions and 122 deletions

View File

@@ -1207,8 +1207,9 @@ describe('agent loop', () => {
const replayed = ctx.sessions.create(SessionId('replayed'), { seed: [...agent.session.events] })
expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages())
// event-by-event identity of types
expect(replayed.events.map(e => e.type)).toEqual(
// event-by-event identity of types over the inherited prefix
expect(replayed.events.slice(0, agent.session.seq).map(e => e.type)).toEqual(
agent.session.events.map(e => e.type))
expect(replayed.events.at(-1)?.type).toBe('session/inherited')
})
})

View File

@@ -283,7 +283,8 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
agentOptions: { provider: 'mock', model: 'mock' },
setup: async (agentCtx) => {
expect(agentCtx.agent?.id).toBe(sessionId)
expect(agentCtx.agent?.session.events).toHaveLength(2)
// The two persisted events plus the inherited-history boundary.
expect(agentCtx.agent?.session.events).toHaveLength(3)
agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
order.push('setup:start')
@@ -585,7 +586,10 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent
// The resumed session carries the prior history…
expect(a2.session.id).toBe('sess-resume')
expect(a2.session.events.length).toBe(events1.length)
// …below one boundary marking all of it inherited.
expect(a2.session.events.length).toBe(events1.length + 1)
expect(a2.session.firstLiveSeq).toBe(events1.length)
expect(a2.session.events.at(-1)?.type).toBe('session/inherited')
const replay = new Session(SessionId('replay'), events1)
expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages())

View File

@@ -23,7 +23,7 @@ export * from './types.ts'
export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm'
export { isJsonValue, snapshotJsonValue } from './json.ts'
export type { JsonValue } from './json.ts'
export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
export { interruptedTurnClosers, isInheritedSeq, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
export { decodeStorageRecord, packChunkRuns } from './chunk-rows.ts'
export type { ChunkRow, StorageRecord } from './chunk-rows.ts'
export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'
@@ -388,8 +388,12 @@ export class Session {
* log as a publication substitute (telemetry adoption) start here. Distinct
* from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed
* session's constructor seed is its full stored log, while its header keeps
* the original fork value — this field is the in-process construction fact
* and is deliberately not persisted.
* the original fork value — this field is the in-process construction fact.
*
* Not persisted itself: a nonzero value is projected into the log as the
* `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.
*/
readonly firstLiveSeq: number
@@ -427,6 +431,13 @@ export class Session {
}
this.firstLiveSeq = this.log.length
this.header = snapshotSessionHeader(id, header)
// Appended here so the marker is already in `events` when a backend
// captures the creation seed: no load-time write. Re-marking is skipped
// because a cold session is resumed on first touch, so repeatedly opening
// one must not grow its log per open.
if (this.firstLiveSeq > 0 && this.log.at(-1)?.type !== 'session/inherited') {
this.append('session/inherited', {})
}
}
/** Cached immutable public snapshot of the private append-only log. */

View File

@@ -1,7 +1,8 @@
/**
* Crash-recovery repair for an interrupted session log. It preserves a fully
* written final turn and supplies the missing tool, step, and turn boundaries
* needed to resume with a provider-valid transcript.
* needed to resume with a provider-valid transcript, plus the inherited-history
* boundary a plugin-owned bracket reads to tell dead history from live work.
* @module @deepseek-ai/dsh-session/repair
*/
@@ -9,6 +10,50 @@ import { MessageId, freezeMessage, type CallId } from '@deepseek-ai/dsh-llm'
import type { ToolResultMessage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from './types.ts'
/**
* Whether the event at `seq` was inherited rather than written by the lifecycle
* that owns `events` — the stored-history reading of `Session.firstLiveSeq`.
*
* An owner of a standalone open/close bracket calls this on an unmatched
* opening marker: `true` means the operation cannot still be running, because
* the lifecycle that opened it has ended (a crashed writer, a succeeding
* process, or a parent the events were forked out of). `false` means it belongs
* to the current lifecycle and must be treated as live.
*
* Reads the log rather than a `Session`, so it serves a consumer holding only
* loaded events; in-process, compare against `session.firstLiveSeq` instead.
* @param events - the log to scan, contiguous from seq 0.
* @param seq - the event seq to classify.
* @returns true when a `session/inherited` boundary sits at or above `seq`.
*/
export function isInheritedSeq(events: readonly SessionEvent[], seq: number): boolean {
// Tail-first: an unmarked log costs no full scan, and bracket queries are
// usually about recent events.
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index]
/* v8 ignore next -- a contiguous log has no holes; the guard is for the index type */
if (event === undefined) continue
if (event.seq < seq) return false
if (event.type === 'session/inherited') return true
}
return false
}
/**
* The `time` of the log's last event that represents actual work, skipping the
* `session/inherited` boundary.
*
* Picking a session up is not activity, and lazy resume means browsing writes a
* boundary, so activity ordering (a resume picker, a session list) must skip it
* or every opened session sorts as freshly worked in.
* @param events - the log to scan, in seq order.
* @returns the latest non-boundary event's `time`, or undefined when the log has
* no such event (empty, or nothing but boundaries).
*/
export function lastActivityTime(events: readonly SessionEvent[]): number | undefined {
return events.findLast(event => event.type !== 'session/inherited')?.time
}
/** Recovery code for an assistant tool request that never reached a recorded call start. */
export const TOOL_NOT_STARTED = 'TOOL_NOT_STARTED'

View File

@@ -250,6 +250,26 @@ export interface SessionEventMap {
* It is log-only; the latest snapshot reconstructs the request header.
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
/**
* The log-only durable projection of {@link Session.firstLiveSeq}: everything
* BELOW it was inherited through a constructor seed — resume, fork, or replay
* — and no writer in this session's lifecycle produced it. Appended as the
* first live event of every seeded session.
*
* A plugin owning a standalone open/close bracket (`compact/start` …
* `compact/end`) needs it because inherited history and live work are
* otherwise byte-identical: an unmatched opening marker below this boundary
* belongs to an ended lifecycle, so it is dead whether the writer crashed,
* the process succeeded it, or the events were forked out of a parent that is
* still running. Read it through `isInheritedSeq`.
*
* NOT a liveness signal about other writers: a concurrently live session may
* hold an open bracket over the same stored history with its own boundary
* elsewhere, so tolerating concurrent writers needs a signal beyond the log.
*
* The payload is empty by design — position and `time` carry the meaning.
*/
'session/inherited': Record<string, never>
}
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */

View File

@@ -50,6 +50,14 @@ function lastSeq(session: Session): number {
return event.seq
}
/** A seeded child's inherited prefix: its log minus the constructor's boundary. */
function inherited(session: Session): readonly SessionEvent[] {
const events = session.events
const last = events.at(-1)
if (last?.type !== 'session/inherited') throw new Error('seeded child is missing its inherited boundary')
return events.slice(0, -1)
}
describe('SessionStore.fork', () => {
it('forks an empty live session as an empty child with lineage metadata', async () => {
const { ctx, sessions } = await setup()
@@ -73,7 +81,7 @@ describe('SessionStore.fork', () => {
const child = sessions.fork(SessionId('parent'), undefined, SessionId('child'))
expect(child.events).toEqual(source.events)
expect(inherited(child)).toEqual(source.events)
expect(child.events).not.toBe(source.events)
expect(child.events[1]).not.toBe(source.events[1])
expect(() => {
@@ -97,8 +105,8 @@ describe('SessionStore.fork', () => {
const child = sessions.fork(source, undefined, SessionId('log-only-child'))
expect(child.events).toEqual(source.events)
expect(child.events.at(-1)).toMatchObject({
expect(inherited(child)).toEqual(source.events)
expect(inherited(child).at(-1)).toMatchObject({
type: 'test/log-only',
data: { value: 'after execution' },
})
@@ -114,7 +122,7 @@ describe('SessionStore.fork', () => {
const child = sessions.fork(source, firstBoundary, SessionId('child-from-first'))
expect(child.events).toEqual(source.events.slice(0, firstBoundary + 1))
expect(inherited(child)).toEqual(source.events.slice(0, firstBoundary + 1))
expect(child.header.seedLength).toBe(firstBoundary + 1)
expect(child.deriveMessages()).toEqual([{
id: expect.any(String) as unknown,
@@ -141,7 +149,7 @@ describe('SessionStore.fork', () => {
const child = sessions.fork(source, lastSeq(source), SessionId(`child-${reason.kind}`))
expect(child.events.at(-1)?.type).toBe('turn/end')
expect(inherited(child).at(-1)?.type).toBe('turn/end')
expect(child.header.seedLength).toBe(source.events.length)
}
})

View File

@@ -112,7 +112,19 @@ describe('Session properties', () => {
const original = build(events)
const replayed = new Session(SessionId(`replay-${counter++}`), [...original.events])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
expect(replayed.seq).toBe(original.seq)
// A non-empty replay grows by exactly one log-only boundary.
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
expect(replayed.seq).toBe(original.seq === 0 ? 0 : original.seq + 1)
}))
})
it('replaying an already-inherited log adds no further boundary', () => {
fc.assert(fc.property(logArb, (events) => {
const original = build(events)
const once = new Session(SessionId(`idem-a-${counter++}`), [...original.events])
const twice = new Session(SessionId(`idem-b-${counter++}`), [...once.events])
// Lazy resume makes browsing a pickup, so this must not grow per open.
expect(twice.events).toEqual(once.events)
}))
})

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
import { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts'
import { interruptedTurnClosers, isInheritedSeq, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts'
import type { SessionEvent, SurfaceEvent } from '../src/index.ts'
/**
@@ -273,3 +273,102 @@ describe('interruptedTurnClosers', () => {
expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end'])
})
})
/**
* The stored-history reading of the inherited boundary. A bracket owner calls
* this on an unmatched opening marker to decide whether the operation can still
* be running, so the classification of the marker's own seq — and of the
* boundary seq itself — is the contract.
*/
describe('isInheritedSeq', () => {
const inheritedAt = (seq: number): SessionEvent =>
({ type: 'session/inherited', seq, time: seq, data: {} })
it('classifies nothing as inherited in a log without a boundary', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
]
expect(isInheritedSeq(events, 0)).toBe(false)
expect(isInheritedSeq(events, 1)).toBe(false)
})
it('treats an empty log as owning nothing', () => {
expect(isInheritedSeq([], 0)).toBe(false)
})
it('splits the log at the boundary', () => {
// seqs 0-1 inherited; the boundary at 2; seq 3 written by this lifecycle.
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
inheritedAt(2),
userTurnStart(2, 3),
]
expect(isInheritedSeq(events, 0)).toBe(true)
expect(isInheritedSeq(events, 1)).toBe(true)
// The boundary's own seq counts as inherited: it belongs to the pickup.
expect(isInheritedSeq(events, 2)).toBe(true)
expect(isInheritedSeq(events, 3)).toBe(false)
})
it('reports inherited for an event below a later boundary', () => {
// Two pickups in turn: the tail scan must not stop at the nearer boundary.
const events: SessionEvent[] = [
userTurnStart(1, 0),
inheritedAt(1),
userTurnStart(2, 2),
inheritedAt(3),
userTurnStart(3, 4),
]
expect(isInheritedSeq(events, 0)).toBe(true)
expect(isInheritedSeq(events, 2)).toBe(true)
expect(isInheritedSeq(events, 4)).toBe(false)
})
})
/**
* Activity ordering excludes the pickup boundary. A resume picker or session
* list sorting by log tail would otherwise promote every session the user
* merely opened above the ones they actually worked in.
*/
describe('lastActivityTime', () => {
const inheritedAt = (seq: number, time: number): SessionEvent =>
({ type: 'session/inherited', seq, time, data: {} })
it('has no answer for an empty log', () => {
expect(lastActivityTime([])).toBeUndefined()
})
it('reports the log tail when no boundary is present', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'turn/end', seq: 1, time: 500, data: { turn: 1, reason: { kind: 'completed' } } },
]
expect(lastActivityTime(events)).toBe(500)
})
it('skips a trailing boundary in favour of the last real work', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'turn/end', seq: 1, time: 500, data: { turn: 1, reason: { kind: 'completed' } } },
inheritedAt(2, 9_000),
]
// Resumed long after the work, but never worked in again.
expect(lastActivityTime(events)).toBe(500)
})
it('reports work done above a boundary', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
inheritedAt(1, 9_000),
{ type: 'turn/end', seq: 2, time: 9_500, data: { turn: 1, reason: { kind: 'completed' } } },
]
expect(lastActivityTime(events)).toBe(9_500)
})
it('has no answer for a log of nothing but boundaries', () => {
// Unreachable via the constructor, but the projection is a pure function.
expect(lastActivityTime([inheritedAt(0, 1), inheritedAt(1, 2)])).toBeUndefined()
})
})

View File

@@ -112,7 +112,7 @@ describe('Session', () => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
const replayed = new Session(SessionId('aborted-replay'), structuredClone(session.events))
expect(replayed.events).toEqual(session.events)
expect(replayed.events.slice(0, -1)).toEqual(session.events)
const turnEnd = replayed.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
})
@@ -188,7 +188,10 @@ describe('Session', () => {
const replayed = new Session(SessionId('s3-replay'), [...original.events])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
expect(replayed.seq).toBe(original.seq)
// The seed verbatim, plus the boundary the constructor appends over it.
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
expect(replayed.seq).toBe(original.seq + 1)
expect(replayed.firstLiveSeq).toBe(original.seq)
})
it('rejects pre-provider request headers and assistant messages on seed/load', () => {
@@ -217,7 +220,7 @@ describe('Session', () => {
const unrelatedPrimitiveData = {
type: 'plugin/event', seq: 0, time: 1, data: null,
} as unknown as SessionEvent
expect(new Session(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events)
expect(new Session(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events.slice(0, 1))
.toEqual([unrelatedPrimitiveData])
})
@@ -522,7 +525,8 @@ describe('Session', () => {
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
] as SessionEvent[]
const session = new Session(SessionId('seed-ok'), goodSeed)
expect(session.events).toHaveLength(3)
expect(session.events.slice(0, 3)).toEqual(goodSeed)
expect(session.firstLiveSeq).toBe(3)
})
it('reads each seed array entry once so validation and storage use the same event', () => {
@@ -546,7 +550,7 @@ describe('Session', () => {
const session = new Session(SessionId('seed-entry-snapshot'), seed)
expect(reads).toBe(1)
expect(session.events).toEqual([accepted])
expect(session.events.slice(0, 1)).toEqual([accepted])
})
it('reads a nested seed-data getter once and stores its first JSON value', () => {
@@ -624,7 +628,7 @@ describe('Session', () => {
const session = new Session(SessionId('seed-null-prototype'), [event])
expect(session.events).toEqual([{ ...event }])
expect(session.events.slice(0, 1)).toEqual([{ ...event }])
})
it('reads a nested seed-metadata getter once and stores its first JSON value', () => {
@@ -1647,6 +1651,7 @@ describe('todo/write event', () => {
const replayed = new Session(SessionId('t4-replay'), [...original.events])
expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos)
.toEqual([{ content: 'only', status: 'completed' }])
expect(replayed.seq).toBe(original.seq)
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
expect(replayed.firstLiveSeq).toBe(original.seq)
})
})