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:
@@ -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. */
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user