fix(compact): decide step-alignment from surface tool-pairing, fire compaction pre-step (CBR-001)

Codex round 1 CBR-001: a head-anchored compaction checkpoint was
mis-classified by the log-position step-alignment scan, so a second
auto-compaction over a checkpoint-headed surface silently failed.

Root cause: `isStepAlignedStart/End` scanned the LOG by seq, but a
`replace` op lands a checkpoint at a high log seq whose SURFACE position
is the head — its log neighbours (the open step's assistant/message) are
not its surface neighbours, so the forward scan wrongly reported mid-step.

Fix, per the agreed direction:
- Replace the two log-position predicates with one surface-anchored
  helper `isToolPairingBalanced(nodes, events, beforeSeq)` in
  `dsh-session` (renamed step-boundary.ts → tool-pairing.ts). A cut is
  balanced when no unanswered tool-call precedes it on the surface; a
  region is collapsible iff both edges are balanced cuts. The open-tail
  and free-node cases fall out of the same counter. It also throws on a
  corrupt surface (a tool/result with no matching call).
- Move compaction off the in-step seam to a new "pre-step" seam fired
  after turn/start and before step/start, so a compaction's log-only
  compact/* records and its replacement node land cleanly OUTSIDE any
  step (the honest structure crash-safety relies on). Renamed the event
  agent/pre-request → agent/pre-step and switched its dispatch from
  parallel → serial (listeners mutate the surface as a side effect;
  serial isolates them so concurrent appends can't interleave). Extended
  the catalog generator to accept @mode serial.

Regression coverage: a real-loop test driving an auto-compaction asserts
the landed checkpoint is a balanced cut on both sides; unit tests pin the
checkpoint case, the mid-step injection case, multi-call steps, and the
corrupt-surface guard. Proven red on the old log-position logic.
This commit is contained in:
Hypatia May
2026-06-26 13:51:01 +08:00
parent cec32faa4e
commit d6da8ca29a
17 changed files with 912 additions and 448 deletions

View File

@@ -19,7 +19,7 @@ export { isJsonValue } from './json.ts'
export { interruptedTurnClosers } from './repair.ts'
export type { SurfaceNode } from './surface.ts'
export { isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { isStepAlignedStart, isStepAlignedEnd } from './step-boundary.ts'
export { isToolPairingBalanced } from './tool-pairing.ts'
declare module 'cordis' {
interface Context {

View File

@@ -1,97 +0,0 @@
/**
* Step-boundary predicates over a session log: is a given surface node a SAFE
* place to start or end a region that will be collapsed (e.g. by compaction)?
*
* The invariant a consumer needs: a collapsed region must NOT partially overlap
* a step. A step's surface nodes form a contiguous run, and a region must
* contain either ALL of a step's nodes or NONE of them — otherwise it can split
* an `assistant/message`'s `tool-call` blocks from their `tool/result`s, leaving
* the rehydrated transcript with a dangling tool-call or an orphaned tool-result
* (which every provider rejects). This is the compaction-time mirror of the
* crash-recovery imbalance that {@link interruptedTurnClosers} repairs on load.
*
* Nodes that belong to NO step — a pre-step `user/message` (drained before the
* first `step/start`), inter-step `steering/message`, or an injection
* `context/message` (wrapped in a bare `turn/start → context/message → turn/end`
* with no step) — carry no tool pairing and are free boundaries on both sides.
*
* The scans classify each neighbor event into three buckets: a turn/step
* BOUNDARY marker (the region edge is clean), a SURFACE node (the region edge
* is mid-step), or NOISE to skip (`assistant/chunk`, the log-only `compact/*`
* records, and any future non-surface event). "Surface node" is decided by the
* shared {@link isSurfaceEvent} guard so the two notions can't drift.
*
* @module @deepseek-ai/dsh-session/step-boundary
*/
import type { SessionEvent } from './types.ts'
import { isSurfaceEvent } from './surface.ts'
/** Turn/step boundary marker types — the walls the scans stop on. */
const BOUNDARY_TYPES = new Set<string>(['turn/start', 'turn/end', 'step/start', 'step/end'])
/**
* Whether the surface node at `seq` is a SAFE START for a collapsed region —
* i.e. it is the first surface node of its step, or it belongs to no step at
* all (a free inter-step / pre-step / injection node).
*
* Scans BACKWARD from `seq`, skipping noise, and stops at the first significant
* event: a turn/step boundary marker ⇒ aligned (nothing of `seq`'s step lies
* before it), a surface node ⇒ NOT aligned (a predecessor surface node sits in
* the same step, so starting here would orphan it), start-of-log ⇒ aligned.
*
* No open-step check is needed on the start side: an open (unclosed) step can
* only ever be the LAST turn's last step, never before a valid region start.
*/
export function isStepAlignedStart(events: readonly SessionEvent[], seq: number): boolean {
for (let i = seq - 1; i >= 0; i--) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const event = events[i]!
if (BOUNDARY_TYPES.has(event.type)) return true
if (isSurfaceEvent(event)) return false
}
return true
}
/**
* Whether the surface node at `seq` is a SAFE END for a collapsed region —
* i.e. it is the last surface node of a CLOSED step, or it belongs to no step
* at all.
*
* Scans FORWARD from `seq`, skipping noise, and stops at the first significant
* event: a turn/step boundary marker ⇒ aligned (the step/turn closes after
* `seq`, or a new one begins because `seq` was inter-step), a surface node ⇒
* NOT aligned (a later surface node sits in the same step). Reaching
* end-of-log is aligned ONLY when `seq` is not inside an OPEN step — an open
* trailing step's `tool-call`s have no `tool/result`s yet, so collapsing it
* would defer the orphan to when those results land later. {@link isInOpenStep}
* decides that via a backward scan.
*/
export function isStepAlignedEnd(events: readonly SessionEvent[], seq: number): boolean {
for (let i = seq + 1; i < events.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const event = events[i]!
if (BOUNDARY_TYPES.has(event.type)) return true
if (isSurfaceEvent(event)) return false
}
// End of log: aligned only if `seq` is not inside a still-open step.
return !isInOpenStep(events, seq)
}
/**
* Whether `seq` sits inside an OPEN step — a `step/start` with no later
* `step/end`. Only meaningful at the tail (the EOL branch of
* {@link isStepAlignedEnd}): scans BACKWARD for the nearest turn/step boundary.
* The nearest one being `step/start` means a step opened before `seq` and never
* closed (no `step/end` lies after `seq`, or the forward scan would not have
* reached EOL) — so `seq` is mid-open-step. Any other nearest boundary (or none)
* means `seq` is inter-step / pre-step.
*/
function isInOpenStep(events: readonly SessionEvent[], seq: number): boolean {
for (let i = seq - 1; i >= 0; i--) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const type = events[i]!.type
if (BOUNDARY_TYPES.has(type)) return type === 'step/start'
}
return false
}

View File

@@ -0,0 +1,100 @@
/**
* Tool-pairing balance over a session's SURFACE: is a given cut point in the
* surface a safe edge for a collapsed region (e.g. compaction)?
*
* The invariant a consumer needs: a collapsed region must never separate an
* `assistant/message`'s `tool-call` blocks from their answering `tool/result`s
* — that would leave the rehydrated transcript with a dangling tool-call or an
* orphaned tool-result, which every provider rejects. (This is the
* compaction-time mirror of the crash-recovery imbalance that
* {@link interruptedTurnClosers} repairs on load.) Steps were once used as a
* proxy for this bracketing, but a compaction REWRITES the surface — it lands a
* replacement node at a high log seq whose SURFACE position is the head — so a
* scan over the LOG's `step/*` markers mis-reads such a node's neighbours. The
* pairing the invariant actually protects lives in the surface nodes' own
* content (a `tool-call` block's id, a `tool/result`'s `callId`), which travels
* with the node through any reshaping, so alignment is decided over the surface
* directly.
*
* A **cut** is a gap between two adjacent surface nodes (named by the node it
* sits immediately before), or the after-tail gap (`null`). Walking the surface
* head→tail and assigning each node a delta — `+1` per `tool-call` block on an
* `assistant/message`, `-1` per `tool/result`, `0` otherwise — the depth at a
* cut is the number of still-unanswered tool calls before it. A cut is
* **balanced** when that depth is `0`. A region `[start..end]` is safe to
* collapse iff BOTH its edges are balanced cuts: the cut before `start` and the
* cut after `end`. Nodes that belong to no step (a pre-step `user/message`, an
* inter-step `steering/message`, an injection `context/message`) carry no
* pairing, contribute `0`, and so are free boundaries — exactly as before, but
* now as a consequence of the balance rather than a special case. An open
* trailing step (an assistant whose `tool/result`s have not landed yet) keeps
* the depth positive through the tail, so no cut inside it is balanced — the
* old explicit open-step check falls out of the same counter.
*
* @module @deepseek-ai/dsh-session/tool-pairing
*/
import type { SessionEvent } from './types.ts'
import type { SurfaceNode } from './surface.ts'
/**
* The tool-pairing delta of a surface node: how it shifts the count of
* unanswered tool calls. An `assistant/message` opens one bracket per
* `tool-call` block; a `tool/result` closes one; every other surface node
* (`user/message`, `context/message`, `steering/message`, a usage-only
* `assistant/message` with no tool-call blocks) is pairing-neutral.
*/
function nodeDelta(event: SessionEvent): number {
switch (event.type) {
case 'assistant/message':
return event.data.content.filter(block => block.type === 'tool-call').length
case 'tool/result':
return -1
// Non-pairing surface nodes and every non-surface event contribute nothing.
default:
return 0
}
}
/**
* Whether the surface prefix ending at the given cut has BALANCED tool-call /
* tool-result brackets — i.e. every `tool-call` block on the surface before the
* cut has its answering `tool/result` before the cut too, so the cut is a safe
* edge for a collapsed region (it cannot split an assistant↔result pair).
*
* `nodes` is the surface linked list in head→tail order (e.g.
* `session.surface.nodes`); `events` is the session log, used to look each
* node's event up by `seq`. `beforeSeq` names the cut by the surface node it
* sits immediately before; the after-tail cut (the whole surface) is `null`,
* as is any `beforeSeq` not present on the surface.
*
* A region `[start..end]` is collapsible iff both edges are balanced cuts: call
* `isToolPairingBalanced(nodes, events, start)` for the cut before `start`, and
* `isToolPairingBalanced(nodes, events, after)` — where `after` is `end`'s
* surface successor (`SurfaceNode.next`), or `null` when `end` is the tail —
* for the cut after `end`.
*
* @throws if the surface prefix drives the unanswered-call depth negative — a
* `tool/result` with no preceding open `tool-call` on the surface. That is a
* corrupt surface (a structural invariant violation), surfaced loudly here
* rather than silently mis-classifying a boundary.
*/
export function isToolPairingBalanced(
nodes: readonly SurfaceNode[],
events: readonly SessionEvent[],
beforeSeq: number | null,
): boolean {
let depth = 0
for (const node of nodes) {
if (node.seq === beforeSeq) return depth === 0
// node.seq is a surface-node seq, always a valid log index by construction.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
depth += nodeDelta(events[node.seq]!)
if (depth < 0) {
throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`)
}
}
// Reached the after-tail cut (beforeSeq === null, or a seq not on the
// surface): the whole-surface prefix is balanced iff depth returned to 0.
return depth === 0
}