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.
- package/README.de.md +5 -4
- package/README.es.md +5 -4
- package/README.fr.md +5 -4
- package/README.it.md +5 -4
- package/README.ja.md +5 -4
- package/README.ko.md +5 -4
- package/README.md +14 -5
- package/README.pt.md +5 -4
- package/README.ru.md +5 -4
- package/README.zh-TW.md +5 -4
- package/README.zh.md +5 -4
- package/client.js +323 -40
- package/cordis.patch.yml +23 -20
- package/host/config.js +51 -7
- package/host/context-trim/fork.js +5 -1
- package/host/context-trim/index.js +329 -178
- package/host/context-trim/metrics.js +7 -7
- package/host/context-trim/state.js +351 -50
- package/host/context-trim/summarizer.js +147 -33
- package/host/context-trim/surface.js +415 -11
- package/host/context-trim/trigger.js +170 -51
- package/host/context-trim/view.js +144 -20
- package/host/context-watch.js +80 -73
- package/host/routes.js +64 -4
- package/package.json +2 -2
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
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
|
-
|
|
582
|
-
|
|
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);
|