dsh-switchman 1.10.2 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -68,6 +68,17 @@
68
68
  * - Never append while a durable compaction/start..compaction/end lock pair
69
69
  * is open for the session (design constraint).
70
70
  *
71
+ * LADDER SHIP (docs/ladder-fold.md): tier ≥ 2 blocks ride the same pair,
72
+ * but their material is the REPLACEMENT NODES their consumed blocks shipped
73
+ * (each consumed block's surfaceReplaced marker carries atSeq). The span
74
+ * from the first to the last replacement node must contain EXACTLY the
75
+ * consumed replacement nodes — engine folds are frontier-ordered, nothing
76
+ * can legitimately interleave, and any mismatch tombstones
77
+ * (ladder-span-mismatch / ladder-span-gone / ladder-consumed-skipped)
78
+ * instead of guessing. A ladder block whose consumed blocks have not
79
+ * shipped yet stays pending: they sit EARLIER in the same sweep (ledger
80
+ * order) and ship before it is reached again on a later sweep.
81
+ *
71
82
  * Everything fails open: a missing agents service / session.append / surface
72
83
  * support degrades to today's behavior (wire=raw banner + tally watermark),
73
84
  * and every no-op with pending work is logged to decisions.log.
@@ -75,6 +86,136 @@
75
86
 
76
87
  import { refOf, summaryMessageOf } from "./view.js";
77
88
 
89
+ /** Ladder-block probe (tier ≥ 2): a distillation whose surface material is
90
+ * the REPLACEMENT NODES of its consumed blocks, not a raw ref range. */
91
+ function isLadderBlock(block) {
92
+ return block !== null && typeof block === "object" && (block.tier === 2 || block.tier === 3);
93
+ }
94
+
95
+ // ---- tool-pair closure ------------------------------------------------------
96
+
97
+ /** Call ids OPENED by one message, across every wire shape the plugin can
98
+ * see: the DSH-native content block {type:'tool-call', id} (the shape that
99
+ * reaches real captures — packages/llm/llm/src/types.ts ToolCallBlock), the
100
+ * provider-shaped {type:'tool_use', id} block, and the OpenAI
101
+ * message.tool_calls array. Duplicate ids are kept — the caller only asks
102
+ * "is this id answered somewhere in the span". */
103
+ function toolCallIdsOf(message) {
104
+ const ids = [];
105
+ if (message === null || typeof message !== "object") return ids;
106
+ if (Array.isArray(message.tool_calls)) {
107
+ for (const call of message.tool_calls) {
108
+ if (typeof call?.id === "string" && call.id !== "") ids.push(call.id);
109
+ }
110
+ }
111
+ if (Array.isArray(message.content)) {
112
+ for (const block of message.content) {
113
+ if ((block?.type === "tool-call" || block?.type === "tool_use") && typeof block.id === "string" && block.id !== "") ids.push(block.id);
114
+ }
115
+ }
116
+ return ids;
117
+ }
118
+
119
+ /** Call ids ANSWERED by one message: the DSH-native role:'tool' message's
120
+ * toolCallId, the provider-shaped tool_call_id field, and tool_result /
121
+ * tool-result content blocks' tool_use_id. */
122
+ function toolResultIdsOf(message) {
123
+ const ids = [];
124
+ if (message === null || typeof message !== "object") return ids;
125
+ if (typeof message.toolCallId === "string" && message.toolCallId !== "") ids.push(message.toolCallId);
126
+ if (typeof message.tool_call_id === "string" && message.tool_call_id !== "") ids.push(message.tool_call_id);
127
+ if (Array.isArray(message.content)) {
128
+ for (const block of message.content) {
129
+ if (block?.type !== "tool_result" && block?.type !== "tool-result") continue;
130
+ const id = block.tool_use_id ?? block.toolCallId ?? block.tool_call_id;
131
+ if (typeof id === "string" && id !== "") ids.push(id);
132
+ }
133
+ }
134
+ return ids;
135
+ }
136
+
137
+ /** Dense node-index-aligned message list built from derivingPairsOf: entry i
138
+ * is the message surface node i derives, or null for a node that derives
139
+ * none (empty assistant step, ...). closeToolPairSpan indexes this list by
140
+ * SURFACE NODE INDEX while derivingPairsOf compacts that space — this is the
141
+ * bridge, and it reuses the already-resolved messages. */
142
+ function denseMessagesOf(pairs, nodeCount) {
143
+ const messages = new Array(nodeCount).fill(null);
144
+ for (const pair of pairs) messages[pair.nodeIndex] = pair.message;
145
+ return messages;
146
+ }
147
+
148
+ /** Close a surface span over complete tool pairs. A shipped fold must never
149
+ * separate an assistant tool call from its tool results: the provider wire
150
+ * serializers reject the orphan half ("DeepSeek Messages tool result has no
151
+ * matching call", llm-deepseek/src/serialize.ts:130-150), and the host's own
152
+ * compaction validates the same rule (dsh-compaction/lib/index.js:557).
153
+ * view.js segments pairwise, but a fold block's range can still be captured
154
+ * or resolved off a surface whose boundary sits between the two halves
155
+ * (trigger.js computeFoldableRange stops at a segment end; a pre-fix capture,
156
+ * a host compaction, or a later block all can leave the boundary there), so
157
+ * this pass re-closes the span against the LIVE node list.
158
+ *
159
+ * pairs: DENSE message list aligned to surface node indexes (null entries for
160
+ * nodes that derive no message); nodeCount: surface node count (indices past
161
+ * pairs.length are treated as absent). Returns { startIdx, endIdx } or null
162
+ * when the span cannot be closed without leaving the surface.
163
+ *
164
+ * FORWARD: every call opened INSIDE the span must be answered inside it;
165
+ * otherwise the results directly following the span end are pulled in (one
166
+ * node per iteration, so several consecutive role:'tool' nodes for one
167
+ * assistant step all land). A call whose next surface node does not answer it
168
+ * (the surface ends first, or a different message sits between) is
169
+ * unclosable: null.
170
+ *
171
+ * BACKWARD: a result at the span start whose call still lives earlier on the
172
+ * surface pulls the start back over that assistant node (and the run of
173
+ * results after it), so shipping the fold cannot orphan a result it leaves
174
+ * behind. When that call node is already shadowed/absent, the leading result
175
+ * is a PRE-EXISTING orphan: removing it is the healing direction and the
176
+ * span stays as captured (the earlier fold's shadowed set already owns the
177
+ * call). A node that both opens calls and carries results is handled by both
178
+ * passes defensively. */
179
+ function closeToolPairSpan(pairs, startIdx, endIdx, nodeCount) {
180
+ const count = Number.isInteger(nodeCount) ? nodeCount : pairs.length;
181
+ if (!Number.isInteger(startIdx) || !Number.isInteger(endIdx)) return null;
182
+ if (startIdx < 0 || endIdx < startIdx || endIdx >= count) return null;
183
+ const messageAt = (index) => (index >= 0 && index < count ? pairs[index] ?? null : null);
184
+ let start = startIdx;
185
+ let end = endIdx;
186
+
187
+ // Backward pass (bounded by count: every step moves start strictly earlier).
188
+ for (let guard = 0; guard <= count; guard++) {
189
+ const resultIds = toolResultIdsOf(messageAt(start));
190
+ if (resultIds.length === 0) break;
191
+ let owner = -1;
192
+ for (let i = start - 1; i >= 0; i--) {
193
+ const callIds = toolCallIdsOf(messageAt(i));
194
+ if (callIds.some((id) => resultIds.includes(id))) {
195
+ owner = i;
196
+ break;
197
+ }
198
+ }
199
+ if (owner < 0) break; // call already shadowed/absent: healing direction, span unchanged
200
+ start = owner;
201
+ }
202
+
203
+ // Forward pass: recompute the open calls over the current span each
204
+ // iteration, so a multi-result assistant step extends over all of them.
205
+ for (let guard = 0; guard <= count; guard++) {
206
+ const open = new Set();
207
+ for (let i = start; i <= end; i++) for (const id of toolCallIdsOf(messageAt(i))) open.add(id);
208
+ for (let i = start; i <= end; i++) for (const id of toolResultIdsOf(messageAt(i))) open.delete(id);
209
+ if (open.size === 0) return { startIdx: start, endIdx: end };
210
+ const next = end + 1;
211
+ if (next >= count) return null; // surface ends before the results: unclosable
212
+ const nextIds = toolResultIdsOf(messageAt(next));
213
+ if (!nextIds.some((id) => open.has(id))) return null; // next node answers a different call: unclosable
214
+ end = next;
215
+ }
216
+ return null;
217
+ }
218
+
78
219
  /** Config field reader (fail-open default), same pattern as index.js. */
79
220
  function readBool(config, key, fallback) {
80
221
  try {
@@ -429,10 +570,11 @@ export function createSurfaceFoldPass(deps = {}) {
429
570
  const session = sessionOf(sessionId);
430
571
  if (!surfaceCapable(session)) return false;
431
572
  const nodes = session.surface.nodes.slice();
432
- const located = locateOnSurface(derivingPairsOf(session, nodes), block);
573
+ const pairs = derivingPairsOf(session, nodes);
574
+ const located = locateOnSurface(pairs, block);
433
575
  if (located === null) return false;
434
576
  let startIdx = located[0];
435
- const endIdx = located[1];
577
+ let endIdx = located[1];
436
578
  // Head clamp at CAPTURE time: the stored range must never include
437
579
  // the leading system message, so a block captured here stays clean
438
580
  // even when it ships from a later process. surfaceHeadClamped tells
@@ -443,6 +585,13 @@ export function createSurfaceFoldPass(deps = {}) {
443
585
  startIdx = 1;
444
586
  block.surfaceHeadClamped = true;
445
587
  }
588
+ // NOTE: the capture stores the LOCATED span only — it must not be
589
+ // pre-closed over tool pairs. The ship-time closure runs after
590
+ // validateStoredSeqs re-checks the stored endpoints against the
591
+ // block's refRange, so an extended capture (whose endpoints no
592
+ // longer derive the ref ids) would fail validation, fall back to
593
+ // live location and tombstone as a relocated range — a silently
594
+ // unshippable block. The sweep is the authoritative closure pass.
446
595
  block.surfaceSeqs = nodes.slice(startIdx, endIdx + 1);
447
596
  try {
448
597
  store.persist(store.ledgerOf(sessionId));
@@ -486,12 +635,25 @@ export function createSurfaceFoldPass(deps = {}) {
486
635
  if (typeof sessionId !== "string" || sessionId === "") return 0;
487
636
  const ledger = store.ledgerOf(sessionId);
488
637
  const blocks = Array.isArray(ledger.blocks) ? ledger.blocks : [];
638
+ // [2026-11-07]-[hintless sweeps used a null generation, so
639
+ // generation-keyed tombstones could NEVER re-open]-[the idle sweep
640
+ // passes no sessionHint: every tombstone (incomplete-seqs,
641
+ // tool-pair-unclosed, append-rejected, ...) was filtered out forever
642
+ // even after the surface changed (inspection P1-8). Fall back to the
643
+ // live session's generation when the hint does not carry one.]
489
644
  const generation = (() => {
490
645
  try {
491
- return Number.isFinite(sessionHint?.surface?.contentGeneration) ? sessionHint.surface.contentGeneration : null;
646
+ if (Number.isFinite(sessionHint?.surface?.contentGeneration)) return sessionHint.surface.contentGeneration;
647
+ } catch {
648
+ /* fall through to the live lookup */
649
+ }
650
+ try {
651
+ const live = surfaceCapable(sessionHint) ? sessionHint : sessionOf(sessionId);
652
+ if (live !== null && typeof live === "object" && Number.isFinite(live.surface?.contentGeneration)) return live.surface.contentGeneration;
492
653
  } catch {
493
- return null;
654
+ /* unreadable session: null, fail-open */
494
655
  }
656
+ return null;
495
657
  })();
496
658
  const pending = blocks.filter((block) => {
497
659
  if (block?.surfaceReplaced !== null && block?.surfaceReplaced !== undefined) return false;
@@ -517,16 +679,214 @@ export function createSurfaceFoldPass(deps = {}) {
517
679
  skip(sessionId, "compaction-lock", via, `blocks=${pending.length}`);
518
680
  return 0;
519
681
  }
520
- const nodes = surface.nodes.slice();
521
- const pairs = derivingPairsOf(session, nodes);
522
- const liveGeneration = Number.isFinite(surface.contentGeneration) ? surface.contentGeneration : 0;
682
+ // Live surface snapshot — REFRESHED after every successful ship: the
683
+ // shadowed seqs leave surface.nodes, so the next pending block must
684
+ // resolve/close against the new node list (a stale snapshot validated
685
+ // or closed a span that no longer exists).
686
+ let nodes = surface.nodes.slice();
687
+ let pairs = derivingPairsOf(session, nodes);
688
+ let nodeMessages = denseMessagesOf(pairs, nodes.length);
689
+ // A ship is a replace op, which bumps surface.contentGeneration: refresh
690
+ // it with the snapshot so a tombstone written later in this sweep keys
691
+ // on the post-ship generation instead of re-opening on the next sweep.
692
+ let liveGeneration = Number.isFinite(surface.contentGeneration) ? surface.contentGeneration : 0;
523
693
  // The host rejects a replace op over node 0 only when node 0 actually
524
694
  // carries the system prompt (assertSystemHeadRewrite). Resolve that
525
695
  // head role once per sweep — sessions without a leading system
526
696
  // message fold their head node like any other.
527
- const headIsSystem = headNodeIsSystem(session, nodes);
697
+ let headIsSystem = headNodeIsSystem(session, nodes);
528
698
  let shipped = 0;
529
699
  for (const block of pending) {
700
+ // ---- LADDER BRANCH (tier ≥ 2, docs/ladder-fold.md) --------------
701
+ // A distillation's surface material is the REPLACEMENT NODES of its
702
+ // consumed blocks (each carries a surfaceReplaced marker with the
703
+ // replacement event seq). The span from the first to the last
704
+ // replacement node must contain EXACTLY those nodes — engine folds
705
+ // are frontier-ordered, so nothing can legitimately interleave —
706
+ // and every mismatch tombstones instead of guessing.
707
+ if (isLadderBlock(block)) {
708
+ const consumedIds = Array.isArray(block.consumes) ? block.consumes : [];
709
+ const byId = new Map(blocks.map((entry) => [entry?.id, entry]));
710
+ const consumed = consumedIds.map((id) => byId.get(id));
711
+ if (consumed.some((entry) => entry === undefined || entry === null)) {
712
+ block.surfaceSkip = { reason: "ladder-consumed-skipped", contentGeneration: liveGeneration, at: Date.now() };
713
+ try {
714
+ store.persist(ledger);
715
+ } catch {
716
+ /* best effort */
717
+ }
718
+ skip(sessionId, "ladder-consumed-skipped", via, `block=${block.id}`);
719
+ continue;
720
+ }
721
+ const waiting = consumed.find((entry) => entry.surfaceReplaced === null || entry.surfaceReplaced === undefined);
722
+ if (waiting !== undefined) {
723
+ if (tombstoneOf(waiting) !== null) {
724
+ // A consumed block is hard-skipped on this generation: its
725
+ // replacement node can never materialize, so the ladder span
726
+ // can never be exact. Generation-keyed like every skip — a
727
+ // surface change re-opens both blocks.
728
+ block.surfaceSkip = { reason: "ladder-consumed-skipped", contentGeneration: liveGeneration, at: Date.now() };
729
+ try {
730
+ store.persist(ledger);
731
+ } catch {
732
+ /* best effort */
733
+ }
734
+ skip(sessionId, "ladder-consumed-skipped", via, `block=${block.id} consumed=${waiting.id}`);
735
+ }
736
+ // Otherwise the consumed block is still pending and sits EARLIER
737
+ // in this loop (ledger order): it ships — or skips — before this
738
+ // iteration reaches the ladder block again on a later sweep.
739
+ continue;
740
+ }
741
+ const indexOfSeq = new Map(nodes.map((seq, index) => [seq, index]));
742
+ // Summary-message id positions on the LIVE surface: the
743
+ // replacement message (acp_summary_<blockId>, deterministic body)
744
+ // is content-stable, so a branch/compaction that re-projected the
745
+ // surface invalidates the stored atSeqs but NOT the identity of
746
+ // the replacement nodes — the same re-location-not-re-target rule
747
+ // the T1 stale-capture path follows.
748
+ // [2026-11-07]-[observed live: a DSH branch rebuilt the surface
749
+ // projection, orphaning every T1 atSeq and tombstoning the pending
750
+ // T2 as ladder-span-gone forever]-[id re-location lets the ladder
751
+ // ship after any surface re-projection; the spanExact id-set check
752
+ // below keeps it exact.]
753
+ const idIndexOf = new Map();
754
+ for (let i = 0; i < nodeMessages.length; i++) {
755
+ const message = nodeMessages[i];
756
+ if (message !== null && typeof message === "object" && typeof message.id === "string" && message.id.startsWith("acp_summary_")) {
757
+ idIndexOf.set(message.id, i);
758
+ }
759
+ }
760
+ const atSeqs = [];
761
+ let unresolved = null;
762
+ for (const entry of consumed) {
763
+ const atSeq = Number.isFinite(entry.surfaceReplaced.atSeq) ? entry.surfaceReplaced.atSeq : null;
764
+ if (atSeq !== null && indexOfSeq.has(atSeq)) {
765
+ atSeqs.push(atSeq);
766
+ continue;
767
+ }
768
+ const relocatedIdx = idIndexOf.get(`acp_summary_${entry.id}`);
769
+ if (relocatedIdx === undefined) {
770
+ unresolved = entry;
771
+ break;
772
+ }
773
+ atSeqs.push(nodes[relocatedIdx]);
774
+ }
775
+ if (unresolved !== null) {
776
+ // A consumed replacement node left the live surface (host
777
+ // compaction shadowed it): the span is unreconstructable.
778
+ block.surfaceSkip = { reason: "ladder-span-gone", contentGeneration: liveGeneration, at: Date.now() };
779
+ try {
780
+ store.persist(ledger);
781
+ } catch {
782
+ /* best effort */
783
+ }
784
+ skip(sessionId, "ladder-span-gone", via, `block=${block.id} consumed=${unresolved.id}`);
785
+ continue;
786
+ }
787
+ const nodeIndexes = atSeqs.map((seq) => indexOfSeq.get(seq));
788
+ const ladderFirstIdx = Math.min(...nodeIndexes);
789
+ const ladderLastIdx = Math.max(...nodeIndexes);
790
+ const spanSeqs = nodes.slice(ladderFirstIdx, ladderLastIdx + 1);
791
+ const expectedSeqs = new Set(atSeqs);
792
+ let spanExact = spanSeqs.length === atSeqs.length && spanSeqs.every((seq) => expectedSeqs.has(seq));
793
+ // Id-set exactness: after id re-location the span must contain
794
+ // EXACTLY the consumed blocks' summary messages — a span that
795
+ // picked up a stranger's acp_summary node is not this fold's
796
+ // material (the length check alone cannot tell them apart).
797
+ if (spanExact) {
798
+ const expectedIds = new Set(consumed.map((entry) => `acp_summary_${entry.id}`));
799
+ for (let i = ladderFirstIdx; i <= ladderLastIdx; i++) {
800
+ const message = nodeMessages[i];
801
+ if (message === null || message === undefined || typeof message.id !== "string" || !expectedIds.has(message.id)) {
802
+ spanExact = false;
803
+ break;
804
+ }
805
+ }
806
+ }
807
+ if (spanExact) {
808
+ for (let i = ladderFirstIdx; i <= ladderLastIdx; i++) {
809
+ const message = nodeMessages[i];
810
+ if (message === null || message === undefined || typeof message.id !== "string" || !message.id.startsWith("acp_summary_")) {
811
+ spanExact = false;
812
+ break;
813
+ }
814
+ }
815
+ }
816
+ if (!spanExact) {
817
+ block.surfaceSkip = { reason: "ladder-span-mismatch", contentGeneration: liveGeneration, at: Date.now() };
818
+ try {
819
+ store.persist(ledger);
820
+ } catch {
821
+ /* best effort */
822
+ }
823
+ skip(sessionId, "ladder-span-mismatch", via, `block=${block.id}`);
824
+ continue;
825
+ }
826
+ // Defensive system-head clamp (a summary node can never be node
827
+ // 0 — T1 folds start after the head — but a drift-hostile guard
828
+ // costs one comparison).
829
+ if (headIsSystem && ladderFirstIdx === 0) {
830
+ block.surfaceSkip = { reason: "system-head", contentGeneration: liveGeneration, at: Date.now() };
831
+ try {
832
+ store.persist(ledger);
833
+ } catch {
834
+ /* best effort */
835
+ }
836
+ skip(sessionId, "system-head", via, `block=${block.id} ladder`);
837
+ continue;
838
+ }
839
+ const ladderStartSeq = nodes[ladderFirstIdx];
840
+ const ladderEndSeq = nodes[ladderLastIdx];
841
+ const ladderKey = replacedKey(sessionId, ladderStartSeq, ladderEndSeq, liveGeneration);
842
+ if (wasReplaced(ladderKey)) continue; // belt over the durable marker
843
+ try {
844
+ session.append("compaction/prune", {
845
+ shadowedRange: { start: ladderStartSeq, end: ladderEndSeq },
846
+ shadowedSeqs: spanSeqs,
847
+ shadowedTokenCount: Number.isFinite(block.approxTokens) && block.approxTokens > 0 ? Math.round(block.approxTokens) : spanSeqs.length,
848
+ });
849
+ const event = session.append("user/message", replacementMessageOf(block), {
850
+ surfaceOp: { op: "replace", startSeq: ladderStartSeq, endSeq: ladderEndSeq },
851
+ sourceEventSeqs: spanSeqs,
852
+ });
853
+ block.surfaceReplaced = {
854
+ startSeq: ladderStartSeq,
855
+ endSeq: ladderEndSeq,
856
+ seqs: spanSeqs.slice(),
857
+ contentGeneration: liveGeneration,
858
+ atSeq: Number.isFinite(event?.seq) ? event.seq : null,
859
+ via,
860
+ at: Date.now(),
861
+ };
862
+ delete block.surfaceSkip;
863
+ rememberReplaced(ladderKey);
864
+ shipped += 1;
865
+ // Same post-ship snapshot refresh as the T1 branch: the
866
+ // shadowed seqs left surface.nodes.
867
+ nodes = surface.nodes.slice();
868
+ pairs = derivingPairsOf(session, nodes);
869
+ nodeMessages = denseMessagesOf(pairs, nodes.length);
870
+ headIsSystem = headNodeIsSystem(session, nodes);
871
+ liveGeneration = Number.isFinite(surface.contentGeneration) ? surface.contentGeneration : 0;
872
+ decisionLog(`session=${sessionId} surface-replace startSeq=${ladderStartSeq} endSeq=${ladderEndSeq} seqs=${spanSeqs.length} tokens=${Number.isFinite(block.approxTokens) ? Math.round(block.approxTokens) : "?"} generation=${liveGeneration} ladder=${block.tier} via=${via}`);
873
+ try {
874
+ store.persist(ledger);
875
+ } catch {
876
+ /* the in-memory marker still guards this process */
877
+ }
878
+ } catch (error) {
879
+ const message = String(error?.message ?? error).replace(/\s+/g, " ").slice(0, 160);
880
+ block.surfaceSkip = { reason: `append-rejected:${message}`, contentGeneration: liveGeneration, at: Date.now() };
881
+ try {
882
+ store.persist(ledger);
883
+ } catch {
884
+ /* best effort */
885
+ }
886
+ skip(sessionId, `append-rejected:${message}`, via, `block=${block.id} ladder=${block.tier}`);
887
+ }
888
+ continue;
889
+ }
530
890
  // Resolve the shadow range: stored capture first (validated dense
531
891
  // against the LIVE surface). A stale/incomplete capture — or a
532
892
  // capture-less legacy block — falls back to live location, but ONLY
@@ -536,10 +896,12 @@ export function createSurfaceFoldPass(deps = {}) {
536
896
  // are simply gone): never a silent re-target onto a different range.
537
897
  let startSeq = null;
538
898
  let endSeq = null;
899
+ let startIdx = null;
900
+ let endIdx = null;
539
901
  let seqs = null;
540
902
  const stored = Array.isArray(block.surfaceSeqs) ? validateStoredSeqs(session, nodes, block.surfaceSeqs, block) : null;
541
903
  if (stored !== null) {
542
- ({ startSeq, endSeq, seqs } = stored);
904
+ ({ startIdx, endIdx, startSeq, endSeq, seqs } = stored);
543
905
  } else {
544
906
  const located = locateOnSurface(pairs, block);
545
907
  if (located === null) {
@@ -578,10 +940,43 @@ export function createSurfaceFoldPass(deps = {}) {
578
940
  skip(sessionId, "incomplete-seqs", via, `block=${block.id} overlap`, false);
579
941
  continue;
580
942
  }
581
- startSeq = nodes[located[0]];
582
- endSeq = nodes[located[1]];
943
+ startIdx = located[0];
944
+ endIdx = located[1];
945
+ startSeq = nodes[startIdx];
946
+ endSeq = nodes[endIdx];
583
947
  seqs = locatedSeqs;
584
948
  }
949
+ // Tool-pair closure over the LIVE surface (both resolution paths):
950
+ // a shipped range must never separate an assistant's tool call from
951
+ // its results — the provider wire serializers reject the orphan half
952
+ // ("DeepSeek Messages tool result has no matching call",
953
+ // llm-deepseek/src/serialize.ts:130-150). Extend forward over the
954
+ // results of calls opened inside the range, and backward over the
955
+ // live call of an orphan result at the range start. Unclosable (a
956
+ // call with no result on this surface, a range whose next node
957
+ // answers a different call) tombstones as tool-pair-unclosed — the
958
+ // same safe hard-skip family as incomplete-seqs: never ship a
959
+ // pair-splitting range. The block re-opens on the next surface
960
+ // content change (tool-pair-unclosed is generation-keyed like every
961
+ // other skip).
962
+ const closed = closeToolPairSpan(nodeMessages, startIdx, endIdx, nodes.length);
963
+ if (closed === null) {
964
+ block.surfaceSkip = { reason: "tool-pair-unclosed", contentGeneration: liveGeneration, at: Date.now() };
965
+ try {
966
+ store.persist(ledger);
967
+ } catch {
968
+ /* best effort */
969
+ }
970
+ skip(sessionId, "tool-pair-unclosed", via, `block=${block.id}`);
971
+ continue;
972
+ }
973
+ if (closed.startIdx !== startIdx || closed.endIdx !== endIdx) {
974
+ startIdx = closed.startIdx;
975
+ endIdx = closed.endIdx;
976
+ startSeq = nodes[startIdx];
977
+ endSeq = nodes[endIdx];
978
+ seqs = nodes.slice(startIdx, endIdx + 1);
979
+ }
585
980
  // System head clamp (ship time, BOTH resolution paths): when the
586
981
  // first surface node carries the system prompt, the v4 validator
587
982
  // rejects any prune whose shadowedSeqs contain it
@@ -658,6 +1053,15 @@ export function createSurfaceFoldPass(deps = {}) {
658
1053
  delete block.surfaceSkip;
659
1054
  rememberReplaced(key);
660
1055
  shipped += 1;
1056
+ // The shadowed seqs have just left surface.nodes: refresh the
1057
+ // snapshot every later pending block resolves against, so its
1058
+ // dense validation / re-location / pair closure reads the
1059
+ // POST-ship surface instead of a range that no longer exists.
1060
+ nodes = surface.nodes.slice();
1061
+ pairs = derivingPairsOf(session, nodes);
1062
+ nodeMessages = denseMessagesOf(pairs, nodes.length);
1063
+ headIsSystem = headNodeIsSystem(session, nodes);
1064
+ liveGeneration = Number.isFinite(surface.contentGeneration) ? surface.contentGeneration : 0;
661
1065
  decisionLog(`session=${sessionId} surface-replace startSeq=${startSeq} endSeq=${endSeq} seqs=${seqs.length} tokens=${Number.isFinite(block.approxTokens) ? Math.round(block.approxTokens) : "?"} generation=${liveGeneration} via=${via}`);
662
1066
  try {
663
1067
  store.persist(ledger);