feat(telemetry): adopt from the construction boundary — constructor seeds never re-export

A cursor-less adoption (process restart + resume, fork, seam-module
reload) replayed the session's full log from seq 0, re-exporting
history that already left the process — a resume re-billed its entire
stored log on every restart, and a fork re-shipped the parent's prefix
under the child's id, doubling query-time counts on OTLP backends with
no native ingest dedupe.

dsh-session now exposes the fact the constructor already validated but
discarded: Session.firstLiveSeq, the constructor-seed length — the
first seq appended in this process. header.seedLength cannot serve
here: it is the durable fork-lineage boundary, and a resumed session's
constructor seed is its full stored log while the header keeps the
original fork value (llm-replay and session-query-sqlite depend on
that meaning). Constructor seeds also never publish on the
session/event firehose, so adoption replaying them was inconsistent
with the system's own publication semantics.

Adoption's cursor-less fallback starts at firstLiveSeq; seed events
still feed the chunk projection, so mid-step continuations re-drop
after a resume. Fork streams are no longer self-contained: records now
carry session.seed_length (with the existing session.parent_id) so
receivers stitch the child's stream onto the parent's. Accepted cost,
consistent with at-most-once delivery and recorded in the revival
Agent Note: a resume no longer backfills records a previous process
failed to deliver — a deployment with that requirement needs the
deferred outbox, not replay.

Pinned red-first: seeded adoption exports nothing (assertion reversed
from the prior seed-readback test, obsolete behavior changed with its
test), resume-shaped seed rebuilds the projection without exporting,
and fork records carry the stitch attributes.
This commit is contained in:
kingwl
2026-07-27 18:35:03 +08:00
parent 1f6b02ba9e
commit 6adb14b56d
17 changed files with 134 additions and 31 deletions

View File

@@ -117,17 +117,23 @@ export class TelemetryCoordinator {
/**
* Adopt a session: replay its log THROUGH the projection from the handoff
* cursor (or from the start when no cursor survived), then rely on the
* firehose for everything after. Events at or below the cursor still feed
* the projection state (first-chunk tracking) without being re-handed, so
* a resumed fiber drops mid-step chunk continuations exactly like the
* fiber that saw the step begin.
* cursor, then rely on the firehose for everything after. When no cursor
* survived, replay starts at the session's construction boundary
* (`firstLiveSeq`), not seq 0: constructor seeds never publish on the
* firehose, and their content already left the process under another
* identity — the same id in a previous process (resume) or the parent's
* stream (fork, stitched by receivers via `session.seed_length`). Events
* at or below the start still feed the projection state (first-chunk
* tracking) without being re-handed, so a resumed fiber drops mid-step
* chunk continuations exactly like the fiber that saw the step begin. The
* cost, accepted with the seam's at-most-once stance: a resume no longer
* backfills records a previous process failed to deliver.
* @param session - the live session to adopt; a second adoption is a no-op.
*/
private adopt(session: Session): void {
if (this.adopted.has(session)) return
this.adopted.add(session)
const cursor = handoffCursor.get(session) ?? -1
const cursor = handoffCursor.get(session) ?? session.firstLiveSeq - 1
// Containment is PER EVENT, matching the firehose: one rejected record
// is withheld fail-closed while the rest of the historical replay
// proceeds — wrapping the whole loop would let a single failure silently
@@ -264,8 +270,11 @@ function identityOf(session: Session, event: SessionEvent): Record<string, strin
'event.type': event.type,
'event.seq': event.seq,
}
const { cwd, parentSession } = session.header
const { cwd, parentSession, seedLength } = session.header
if (cwd !== undefined) attributes['session.cwd'] = cwd
if (parentSession !== undefined) attributes['session.parent_id'] = String(parentSession)
// The durable fork boundary: a forked stream starts here, and its prefix
// lives in the parent's stream — receivers stitch on (parent_id, seed_length).
if (seedLength !== undefined) attributes['session.seed_length'] = seedLength
return attributes
}