refactor(session): rename the seed boundary to end-seed
This commit is contained in:
@@ -382,25 +382,25 @@ export class Session {
|
||||
|
||||
/**
|
||||
* The first seq appended IN THIS PROCESS: the length of the constructor
|
||||
* seed (0 without one). Events below it entered through construction —
|
||||
* replay, fork, or resume — and were never published on the `session/event`
|
||||
* firehose (constructor seeds do not emit), so consumers that replay the
|
||||
* 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.
|
||||
* seed (0 without one). Events with smaller seq values entered through
|
||||
* construction — replay, fork, or resume — and were never published on the
|
||||
* `session/event` firehose (constructor seeds do not emit), so consumers
|
||||
* that replay the 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.
|
||||
*
|
||||
* Not persisted itself: a seeded session projects it into the log as the
|
||||
* `session/inherited` event, which is what a consumer reading STORED history
|
||||
* reads. Locate that event as the log's LAST boundary, not at this seq — a
|
||||
* `session/end-seed` event, which is what a consumer reading STORED history
|
||||
* reads. Locate the LAST such event, not necessarily one at this seq — a
|
||||
* seed already ending in one is not re-marked, so reopening an untouched
|
||||
* session leaves the boundary below `firstLiveSeq`. Prefer this field
|
||||
* in-process: it is exact before the marker's write reaches storage.
|
||||
* session leaves that event at a smaller seq than `firstLiveSeq`. Prefer
|
||||
* this field in-process: it is exact before the marker reaches storage.
|
||||
*
|
||||
* When this lifecycle did append a boundary it sits at this seq, appended
|
||||
* before the store attached, so that event did not publish either — the
|
||||
* firehose gap then runs through `firstLiveSeq` rather than stopping below
|
||||
* it. Otherwise this seq holds an ordinary published write.
|
||||
* When this lifecycle appends the marker, it occupies this seq before the
|
||||
* store attaches and therefore does not publish either. Otherwise this seq
|
||||
* holds an ordinary published write.
|
||||
*/
|
||||
readonly firstLiveSeq: number
|
||||
|
||||
@@ -442,8 +442,8 @@ export class Session {
|
||||
// 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', {})
|
||||
if (this.firstLiveSeq > 0 && this.log.at(-1)?.type !== 'session/end-seed') {
|
||||
this.append('session/end-seed', {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ function validateEvent(
|
||||
}
|
||||
case 'user/message':
|
||||
break
|
||||
case 'session/inherited':
|
||||
case 'session/end-seed':
|
||||
// Unconstrained: an unbalanced seed legally puts it inside an open turn.
|
||||
break
|
||||
case 'steering/message':
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 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, plus the activity-time
|
||||
* read that must skip the inherited-history boundary — which this module does
|
||||
* read that must skip the end-seed boundary — which this module does
|
||||
* not write (`Session`'s constructor does) but whose synthetic closers can
|
||||
* inherit that boundary's timestamp, the one real coupling between the two.
|
||||
* @module @deepseek-ai/dsh-session/repair
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
* `session/end-seed` 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
|
||||
@@ -25,7 +25,7 @@ import type { SessionEvent } from './types.ts'
|
||||
* @returns the latest non-boundary event's `time`, or undefined when there is none.
|
||||
*/
|
||||
export function lastActivityTime(events: readonly SessionEvent[]): number | undefined {
|
||||
return events.findLast(event => event.type !== 'session/inherited')?.time
|
||||
return events.findLast(event => event.type !== 'session/end-seed')?.time
|
||||
}
|
||||
|
||||
/** Recovery code for an assistant tool request that never reached a recorded call start. */
|
||||
|
||||
@@ -251,27 +251,28 @@ export interface SessionEventMap {
|
||||
*/
|
||||
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
|
||||
/**
|
||||
* 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 lifecycle produced it. Payload is empty — position
|
||||
* and `time` carry the meaning.
|
||||
* Marks the end of a constructor seed. Events before it have smaller seq
|
||||
* values and came from the seed (resume, fork, or replay); this lifecycle
|
||||
* produced none of them. This log-only event is the durable projection of
|
||||
* {@link Session.firstLiveSeq}. Its 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.
|
||||
* Locate the LAST one in stored history. A seed already ending in one is not
|
||||
* re-marked, so reopening an untouched session does not grow its log per
|
||||
* pickup and the event need not be at the current `firstLiveSeq`.
|
||||
*
|
||||
* `Session`'s constructor is the only legitimate writer. The invariant
|
||||
* companion deliberately constrains nothing here, so a plugin appending one
|
||||
* would silently turn every live bracket below it into dead history.
|
||||
* would silently classify every live bracket before it as seed history.
|
||||
*
|
||||
* 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
|
||||
* belongs to an ended lifecycle, whatever ended it. NOT a liveness signal
|
||||
* about other writers — a concurrently live session holds its own boundary
|
||||
* elsewhere, so tolerating concurrent writers needs a signal beyond the log.
|
||||
* `compact/end`) reads it because seed history and live work are otherwise
|
||||
* byte-identical: an unmatched opening marker before this event belongs to
|
||||
* an ended lifecycle, whatever ended it. NOT a liveness signal about other
|
||||
* writers — a concurrently live session holds its own boundary elsewhere,
|
||||
* so tolerating concurrent writers needs a signal beyond the log.
|
||||
*/
|
||||
'session/inherited': Record<string, never>
|
||||
'session/end-seed': Record<string, never>
|
||||
}
|
||||
|
||||
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
|
||||
|
||||
@@ -52,11 +52,11 @@ function lastSeq(session: Session): number {
|
||||
return event.seq
|
||||
}
|
||||
|
||||
/** A seeded child's inherited prefix: its log minus the constructor's boundary. */
|
||||
/** A seeded child's constructor seed: its log minus the end-seed marker. */
|
||||
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')
|
||||
if (last?.type !== 'session/end-seed') throw new Error('seeded child is missing its end-seed marker')
|
||||
return events.slice(0, -1)
|
||||
}
|
||||
|
||||
@@ -166,12 +166,12 @@ describe('SessionStore.fork', () => {
|
||||
|
||||
const child = sessions.fork(parent, undefined, SessionId('bracket-child'))
|
||||
|
||||
// Parent: nothing above the bracket, so its owner must treat it as live.
|
||||
// Parent: no end-seed event follows the bracket, so its owner treats 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.
|
||||
expect(parent.events.some(event => event.type === 'session/end-seed')).toBe(false)
|
||||
// Child: the same bracket is before end-seed, so it belongs to the seed.
|
||||
const boundary = child.events.at(-1)
|
||||
expect(boundary).toMatchObject({ type: 'session/inherited' })
|
||||
expect(boundary).toMatchObject({ type: 'session/end-seed' })
|
||||
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' } })
|
||||
|
||||
@@ -382,7 +382,7 @@ describe('session-log invariants', () => {
|
||||
.toThrow(/turn 1 is still open/)
|
||||
})
|
||||
|
||||
it('accepts the inherited boundary whether or not a turn is open', async () => {
|
||||
it('accepts end-seed 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: [
|
||||
@@ -393,7 +393,7 @@ describe('session-log invariants', () => {
|
||||
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'])
|
||||
expect(open.events.map(event => event.type)).toEqual(['turn/start', 'session/end-seed'])
|
||||
// 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/)
|
||||
|
||||
@@ -118,7 +118,7 @@ describe('Session properties', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('replaying an already-inherited log adds no further boundary', () => {
|
||||
it('replaying a log that already ends in end-seed adds no further marker', () => {
|
||||
fc.assert(fc.property(logArb, (events) => {
|
||||
const original = build(events)
|
||||
const once = new Session(SessionId(`idem-a-${counter++}`), [...original.events])
|
||||
|
||||
@@ -275,8 +275,8 @@ describe('interruptedTurnClosers', () => {
|
||||
})
|
||||
|
||||
describe('lastActivityTime', () => {
|
||||
const inheritedAt = (seq: number, time: number): SessionEvent =>
|
||||
({ type: 'session/inherited', seq, time, data: {} })
|
||||
const endSeedAt = (seq: number, time: number): SessionEvent =>
|
||||
({ type: 'session/end-seed', seq, time, data: {} })
|
||||
|
||||
it('has no answer for an empty log', () => {
|
||||
expect(lastActivityTime([])).toBeUndefined()
|
||||
@@ -294,16 +294,16 @@ describe('lastActivityTime', () => {
|
||||
const events: SessionEvent[] = [
|
||||
userTurnStart(1, 0),
|
||||
{ type: 'turn/end', seq: 1, time: 500, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
inheritedAt(2, 9_000),
|
||||
endSeedAt(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', () => {
|
||||
it('reports work appended after end-seed', () => {
|
||||
const events: SessionEvent[] = [
|
||||
userTurnStart(1, 0),
|
||||
inheritedAt(1, 9_000),
|
||||
endSeedAt(1, 9_000),
|
||||
{ type: 'turn/end', seq: 2, time: 9_500, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
expect(lastActivityTime(events)).toBe(9_500)
|
||||
@@ -311,6 +311,6 @@ describe('lastActivityTime', () => {
|
||||
|
||||
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()
|
||||
expect(lastActivityTime([endSeedAt(0, 1), endSeedAt(1, 2)])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -188,7 +188,7 @@ describe('Session', () => {
|
||||
|
||||
const replayed = new Session(SessionId('s3-replay'), [...original.events])
|
||||
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
|
||||
// The seed verbatim, plus the boundary the constructor appends over it.
|
||||
// The seed verbatim, plus the end-seed event the constructor appends.
|
||||
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
|
||||
expect(replayed.seq).toBe(original.seq + 1)
|
||||
expect(replayed.firstLiveSeq).toBe(original.seq)
|
||||
|
||||
Reference in New Issue
Block a user