dsh-context-pruner 0.0.0-stage → 0.1.1
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.md +784 -2
- package/cordis.patch.yml +19 -0
- package/lib/backend.js +190 -0
- package/lib/client.js +1230 -0
- package/lib/compact.js +407 -0
- package/lib/index.js +795 -0
- package/lib/inspect.js +390 -0
- package/lib/rounds.js +160 -0
- package/package.json +59 -4
package/lib/compact.js
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manual region compaction: compact a CALLER-CHOSEN span while the agent is idle.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* DSH's compaction seam offers two entry points, and neither does what this
|
|
7
|
+
* plugin needs:
|
|
8
|
+
*
|
|
9
|
+
* | Entry | Range source | Turn requirement |
|
|
10
|
+
* |---|---|---|
|
|
11
|
+
* | `CompactionEngine#compactRegion(start, end)` | caller-supplied | needs an OPEN TURN (`owner: 'current-turn'`) |
|
|
12
|
+
* | `CompactionEngine#compactNow()` | internal `selectCompactableRange()` | idle-safe (`owner: null`) |
|
|
13
|
+
*
|
|
14
|
+
* So a user asking "drop rounds 3-7, keep everything else" cannot be served:
|
|
15
|
+
* the ranged call refuses to run from an idle session, and the idle call
|
|
16
|
+
* chooses its own range.
|
|
17
|
+
*
|
|
18
|
+
* This module implements the missing combination: the manual (idle-safe) path
|
|
19
|
+
* with a caller-supplied span. It reproduces the upstream transaction's shape
|
|
20
|
+
* faithfully — the log-recorded lock bracket, edge validation, change
|
|
21
|
+
* detection, shrink validation, and the single surface replacement — because
|
|
22
|
+
* those are what make a compaction safe and replayable.
|
|
23
|
+
*
|
|
24
|
+
* @module dsh-context-pruner/compact
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { CompactionId, ManualCompactionError, compactCheckpointSource } from "@deepseek-ai/dsh-compaction";
|
|
28
|
+
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
29
|
+
import { randomUUID } from "node:crypto";
|
|
30
|
+
import { isDeepStrictEqual } from "node:util";
|
|
31
|
+
|
|
32
|
+
/** Framing that marks a replacement as an established checkpoint. */
|
|
33
|
+
const CHECKPOINT_PREAMBLE =
|
|
34
|
+
"This is a correction checkpoint replacing an earlier span of the conversation to free up context. Treat the capt" +
|
|
35
|
+
"ured context as established background and build on it without restating it. Continue the task directly from the " +
|
|
36
|
+
"messages that follow, without acknowledging this checkpoint.";
|
|
37
|
+
|
|
38
|
+
/** Opening tag of the framed summary body. */
|
|
39
|
+
const SUMMARY_OPEN_TAG = "<compacted-summary>";
|
|
40
|
+
|
|
41
|
+
/** Closing tag of the framed summary body. */
|
|
42
|
+
const SUMMARY_CLOSE_TAG = "</compacted-summary>";
|
|
43
|
+
|
|
44
|
+
/** One closed checkpoint in the documented `<compacted-summary>` form. */
|
|
45
|
+
function frameSummary(summary) {
|
|
46
|
+
return [
|
|
47
|
+
{ type: "text", text: `${CHECKPOINT_PREAMBLE}\n\n${SUMMARY_OPEN_TAG}` },
|
|
48
|
+
...summary,
|
|
49
|
+
{ type: "text", text: SUMMARY_CLOSE_TAG }
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Raised when the selected span no longer matches what was validated. */
|
|
54
|
+
class SpanChangedError extends Error {
|
|
55
|
+
/** @param message - diagnostic text. */
|
|
56
|
+
constructor(message) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.name = "SpanChangedError";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Inspect the durable entry state: whether a turn is open, whether an
|
|
64
|
+
* unmatched compaction lock exists, and the newest seed boundary.
|
|
65
|
+
*
|
|
66
|
+
* Mirrors the upstream tail scan: an unmatched `compaction/start` AFTER the
|
|
67
|
+
* newest `session/end-seed` is live and blocks; an older one is stale evidence
|
|
68
|
+
* from a previous process lifecycle and does not.
|
|
69
|
+
*
|
|
70
|
+
* @param session - session whose event tail is scanned.
|
|
71
|
+
* @returns `{ openTurn, unmatchedCompactionStart, latestEndSeedSeq }`.
|
|
72
|
+
*/
|
|
73
|
+
function inspectEntryState(session) {
|
|
74
|
+
let openTurn = null;
|
|
75
|
+
let openTurnStateKnown = false;
|
|
76
|
+
let unmatchedCompactionStart;
|
|
77
|
+
let compactionEntryStateKnown = false;
|
|
78
|
+
let latestEndSeedSeq;
|
|
79
|
+
|
|
80
|
+
for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
|
|
81
|
+
const event = session.eventAt(seq);
|
|
82
|
+
if (latestEndSeedSeq === undefined && event.type === "session/end-seed") latestEndSeedSeq = event.seq;
|
|
83
|
+
|
|
84
|
+
if (!compactionEntryStateKnown) {
|
|
85
|
+
if (event.type === "compaction/start") {
|
|
86
|
+
unmatchedCompactionStart = event;
|
|
87
|
+
compactionEntryStateKnown = true;
|
|
88
|
+
} else if (event.type === "compaction/end") {
|
|
89
|
+
compactionEntryStateKnown = true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!openTurnStateKnown) {
|
|
93
|
+
if (event.type === "turn/start") {
|
|
94
|
+
openTurn = event.data.turn;
|
|
95
|
+
openTurnStateKnown = true;
|
|
96
|
+
} else if (event.type === "turn/end") {
|
|
97
|
+
openTurnStateKnown = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (openTurnStateKnown && compactionEntryStateKnown && latestEndSeedSeq !== undefined) break;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { openTurn, unmatchedCompactionStart, latestEndSeedSeq };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Reject a durable unmatched compaction marker that is still live.
|
|
108
|
+
*
|
|
109
|
+
* @param unmatchedCompactionStart - latest unmatched opening marker, if any.
|
|
110
|
+
* @param latestEndSeedSeq - newest constructor-seed boundary, if any.
|
|
111
|
+
* @throws {ManualCompactionError} with code `busy` when a lock is live.
|
|
112
|
+
*/
|
|
113
|
+
function assertCompactionInactive(unmatchedCompactionStart, latestEndSeedSeq) {
|
|
114
|
+
if (
|
|
115
|
+
unmatchedCompactionStart === undefined ||
|
|
116
|
+
(latestEndSeedSeq !== undefined && latestEndSeedSeq > unmatchedCompactionStart.seq)
|
|
117
|
+
) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
throw new ManualCompactionError("busy", "compaction already in progress: the session compaction lock is active");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Resolve a positional node range and validate its tool-pairing edges.
|
|
125
|
+
*
|
|
126
|
+
* Both edges must be balanced so an assistant tool call is never separated
|
|
127
|
+
* from its result: splitting a pair corrupts the projected conversation.
|
|
128
|
+
*
|
|
129
|
+
* @param session - session supplying the surface.
|
|
130
|
+
* @param start - inclusive first surface seq.
|
|
131
|
+
* @param end - inclusive last surface seq.
|
|
132
|
+
* @returns `{ start, end, startIdx, endIdx, shadowedSeqs }`.
|
|
133
|
+
* @throws {Error} when a seq is absent, reversed, or an edge is unbalanced.
|
|
134
|
+
*/
|
|
135
|
+
function resolveRange(session, start, end) {
|
|
136
|
+
const nodes = session.surface.nodes;
|
|
137
|
+
const startIdx = nodes.indexOf(start);
|
|
138
|
+
const endIdx = nodes.indexOf(end);
|
|
139
|
+
|
|
140
|
+
if (startIdx === -1) throw new Error(`prune: start seq ${start} is not on the surface`);
|
|
141
|
+
if (endIdx === -1) throw new Error(`prune: end seq ${end} is not on the surface`);
|
|
142
|
+
if (startIdx > endIdx) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`prune: start seq ${start} (position ${startIdx}) comes after end seq ${end} (position ${endIdx}) on the surface`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
start,
|
|
150
|
+
end,
|
|
151
|
+
startIdx,
|
|
152
|
+
endIdx,
|
|
153
|
+
shadowedSeqs: nodes.slice(startIdx, endIdx + 1)
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Check that a chosen span's edges do not split a tool call from its result.
|
|
159
|
+
*
|
|
160
|
+
* @param session - session supplying the surface and pairing history.
|
|
161
|
+
* @param range - the resolved range.
|
|
162
|
+
* @param balancedBefore - `toolPairingBalancedBefore` from the compaction seam.
|
|
163
|
+
* @param balancedAfter - `toolPairingBalancedAfter` from the compaction seam.
|
|
164
|
+
* @returns an error message when unbalanced, otherwise `undefined`.
|
|
165
|
+
*/
|
|
166
|
+
export function checkBalance(session, range, balancedBefore, balancedAfter) {
|
|
167
|
+
if (!balancedBefore(session, range.start)) {
|
|
168
|
+
return "the first node starts mid-step: an assistant tool call would be separated from its result";
|
|
169
|
+
}
|
|
170
|
+
if (!balancedAfter(session, range.end)) {
|
|
171
|
+
return "the last node ends mid-step: an assistant tool call would be separated from its result, or the step is still open";
|
|
172
|
+
}
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Compact an explicit surface span into one summary, from an idle agent.
|
|
178
|
+
*
|
|
179
|
+
* The transaction, in order:
|
|
180
|
+
*
|
|
181
|
+
* 1. Resolve and validate the span (present, ordered, balanced edges).
|
|
182
|
+
* 2. Assert no live compaction lock and no open turn.
|
|
183
|
+
* 3. Append the log-only `compaction/start` marker — this IS the lock.
|
|
184
|
+
* 4. Summarize the span through `summarize`.
|
|
185
|
+
* 5. Re-validate that the span is unchanged (concurrent edits abort).
|
|
186
|
+
* 6. Append `compaction/summary`, then the replacement `user/message` whose
|
|
187
|
+
* `surfaceOp: { op: 'replace' }` shadows the span, then `compaction/end`.
|
|
188
|
+
* 7. On any failure after the start marker, make exactly one close attempt
|
|
189
|
+
* carrying the error, so a crash stays detectable.
|
|
190
|
+
*
|
|
191
|
+
* @param options.session - session whose surface is replaced.
|
|
192
|
+
* @param options.agent - agent used by the summarizer for routing context.
|
|
193
|
+
* @param options.start - inclusive first surface seq.
|
|
194
|
+
* @param options.end - inclusive last surface seq.
|
|
195
|
+
* @param options.summarize - produces `{ summary, provider, model, ... }` for the span.
|
|
196
|
+
* @param options.meter - token meter used for pricing and shrink validation.
|
|
197
|
+
* @param options.signal - optional cancellation.
|
|
198
|
+
* @param options.sourceCommandId - initiating command identity, for correlation.
|
|
199
|
+
* @param options.balancedBefore - `toolPairingBalancedBefore` from the seam.
|
|
200
|
+
* @param options.balancedAfter - `toolPairingBalancedAfter` from the seam.
|
|
201
|
+
* @param options.flush - optional durability checkpoint awaited after success.
|
|
202
|
+
* @returns the committed `CompactionResult`.
|
|
203
|
+
* @throws {ManualCompactionError} for expected busy / changed / summary / commit / persistence failures.
|
|
204
|
+
*/
|
|
205
|
+
export async function compactExplicitRegion(options) {
|
|
206
|
+
const {
|
|
207
|
+
session,
|
|
208
|
+
agent,
|
|
209
|
+
start,
|
|
210
|
+
end,
|
|
211
|
+
summarize,
|
|
212
|
+
meter,
|
|
213
|
+
signal,
|
|
214
|
+
sourceCommandId,
|
|
215
|
+
balancedBefore,
|
|
216
|
+
balancedAfter,
|
|
217
|
+
flush
|
|
218
|
+
} = options;
|
|
219
|
+
|
|
220
|
+
// --- 1. Pass 1 validation, before any durable write. -------------------
|
|
221
|
+
signal?.throwIfAborted();
|
|
222
|
+
const selection = resolveRange(session, start, end);
|
|
223
|
+
|
|
224
|
+
// --- 2. Idle + lock admission. ----------------------------------------
|
|
225
|
+
const entryState = inspectEntryState(session);
|
|
226
|
+
assertCompactionInactive(entryState.unmatchedCompactionStart, entryState.latestEndSeedSeq);
|
|
227
|
+
if (entryState.openTurn !== null) {
|
|
228
|
+
throw new ManualCompactionError(
|
|
229
|
+
"busy",
|
|
230
|
+
"prune needs an idle agent: this session already has an open turn, so compacting a chosen span would race the running turn"
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// --- 3. Price the span and capture the exact nodes selected. ----------
|
|
235
|
+
const measurement = meter.measure(session);
|
|
236
|
+
const selectedNodes = measurement.nodes.slice(selection.startIdx, selection.endIdx + 1);
|
|
237
|
+
if (
|
|
238
|
+
selectedNodes.length !== selection.shadowedSeqs.length ||
|
|
239
|
+
selectedNodes.some((node, index) => node.seq !== selection.shadowedSeqs[index])
|
|
240
|
+
) {
|
|
241
|
+
throw new ManualCompactionError("changed", "prune: the selected surface changed before summarization began");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const shadowedTokenCount = selectedNodes.reduce((total, node) => total + node.heuristicTokens, 0);
|
|
245
|
+
const shadowedRouteTokenCount = selectedNodes.reduce((total, node) => total + node.tokens, 0);
|
|
246
|
+
|
|
247
|
+
// Replay exactly the selected nodes, mirroring the shipped input builder.
|
|
248
|
+
const regionMessages = selection.shadowedSeqs
|
|
249
|
+
.map((seq) => session.deriveEventMessage(session.eventAt(seq)))
|
|
250
|
+
.filter((message) => message !== null && message !== undefined);
|
|
251
|
+
const headSeq = session.surface.nodes[0];
|
|
252
|
+
const headMessage = headSeq === undefined ? undefined : session.deriveEventMessage(session.eventAt(headSeq));
|
|
253
|
+
const systemHead = headMessage?.role === "system" ? headMessage : undefined;
|
|
254
|
+
const input = {
|
|
255
|
+
...(agent.session.requestHeader()?.tools === undefined
|
|
256
|
+
? {}
|
|
257
|
+
: { tools: agent.session.requestHeader().tools }),
|
|
258
|
+
messages: systemHead === undefined ? regionMessages : [systemHead, ...regionMessages]
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// --- 4. Acquire the durable lock. -------------------------------------
|
|
262
|
+
const compactionId = CompactionId(randomUUID());
|
|
263
|
+
const lifecycle = {
|
|
264
|
+
compactionId,
|
|
265
|
+
...(sourceCommandId === undefined ? {} : { sourceCommandId }),
|
|
266
|
+
turn: null
|
|
267
|
+
};
|
|
268
|
+
const startEvent = session.append("compaction/start", lifecycle);
|
|
269
|
+
|
|
270
|
+
let failure;
|
|
271
|
+
let closed = false;
|
|
272
|
+
let closing = false;
|
|
273
|
+
let stage = "summary";
|
|
274
|
+
let committed;
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
// --- 5. Summarize (yields). ---------------------------------------
|
|
278
|
+
const summarized = await summarize(input, agent, signal);
|
|
279
|
+
signal?.throwIfAborted();
|
|
280
|
+
|
|
281
|
+
// --- 6. The span must be unchanged after the await. ---------------
|
|
282
|
+
//
|
|
283
|
+
// Re-resolving `start`/`end` by seq is NOT sufficient: a concurrent
|
|
284
|
+
// replacement elsewhere shifts every later position, yet both endpoint
|
|
285
|
+
// seqs remain findable, so a naive recheck passes while the span now
|
|
286
|
+
// spans different content. Compare the ABSOLUTE position window
|
|
287
|
+
// instead, which is what the replacement will actually shadow.
|
|
288
|
+
const reNodes = meter.measure(session).nodes;
|
|
289
|
+
const windowUnchanged =
|
|
290
|
+
reNodes.length >= selection.endIdx + 1 &&
|
|
291
|
+
selection.shadowedSeqs.every(
|
|
292
|
+
(seq, offset) => reNodes[selection.startIdx + offset]?.seq === seq
|
|
293
|
+
) &&
|
|
294
|
+
isDeepStrictEqual(
|
|
295
|
+
[
|
|
296
|
+
session.surface.nodes[selection.startIdx],
|
|
297
|
+
session.surface.nodes[selection.endIdx]
|
|
298
|
+
],
|
|
299
|
+
[start, end]
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
if (!windowUnchanged) {
|
|
303
|
+
throw new SpanChangedError("prune: the selected span changed while the summary was being written");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// --- 7. Shrink validation: a checkpoint must actually save space. --
|
|
307
|
+
// Staged as "summary": a checkpoint that fails to shrink is a failure to
|
|
308
|
+
// produce a useful summary, not a commit failure.
|
|
309
|
+
const checkpointMessage = createUserMessage({
|
|
310
|
+
content: frameSummary(summarized.summary),
|
|
311
|
+
source: compactCheckpointSource(compactionId, sourceCommandId)
|
|
312
|
+
});
|
|
313
|
+
const framedTokens = meter.estimateMessage(checkpointMessage);
|
|
314
|
+
if (framedTokens >= shadowedRouteTokenCount) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
`prune: the checkpoint is not smaller than the span it replaces (${framedTokens} >= ${shadowedRouteTokenCount} tokens)`
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// From here on the surface is actually mutated, so a failure is a
|
|
321
|
+
// commit failure.
|
|
322
|
+
stage = "commit";
|
|
323
|
+
|
|
324
|
+
// The metering event must be immediately followed by its replacement:
|
|
325
|
+
// a consumer pairs a replacement with the shadow-price event directly
|
|
326
|
+
// before it.
|
|
327
|
+
const summaryEvent = session.append("compaction/summary", {
|
|
328
|
+
compactionId,
|
|
329
|
+
...(sourceCommandId === undefined ? {} : { sourceCommandId }),
|
|
330
|
+
summary: summarized.summary,
|
|
331
|
+
...(summarized.rawOutput === undefined ? {} : { rawOutput: summarized.rawOutput }),
|
|
332
|
+
...(summarized.llmStreamCall === true ? { llmStreamCall: true } : {}),
|
|
333
|
+
shadowedRange: { start, end },
|
|
334
|
+
shadowedSeqs: [...selection.shadowedSeqs],
|
|
335
|
+
shadowedTokenCount,
|
|
336
|
+
provider: summarized.provider,
|
|
337
|
+
model: summarized.model,
|
|
338
|
+
...(summarized.maxTokens === undefined ? {} : { maxTokens: summarized.maxTokens }),
|
|
339
|
+
...(summarized.usage === undefined ? {} : { usage: summarized.usage })
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const replacement = session.append("user/message", checkpointMessage, {
|
|
343
|
+
surfaceOp: { op: "replace", startSeq: start, endSeq: end },
|
|
344
|
+
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...selection.shadowedSeqs]
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
closing = true;
|
|
348
|
+
const endEvent = session.append("compaction/end", lifecycle);
|
|
349
|
+
closed = true;
|
|
350
|
+
|
|
351
|
+
committed = {
|
|
352
|
+
compactionId,
|
|
353
|
+
...(sourceCommandId === undefined ? {} : { sourceCommandId }),
|
|
354
|
+
startSeq: startEvent.seq,
|
|
355
|
+
summarySeq: summaryEvent.seq,
|
|
356
|
+
endSeq: endEvent.seq,
|
|
357
|
+
summary: summarized.summary,
|
|
358
|
+
shadowedRange: { start, end },
|
|
359
|
+
shadowedSeqs: [...selection.shadowedSeqs],
|
|
360
|
+
shadowedTokenCount,
|
|
361
|
+
replacementSeq: replacement.seq
|
|
362
|
+
};
|
|
363
|
+
} catch (error) {
|
|
364
|
+
failure = { error, stage: closing ? "commit" : stage };
|
|
365
|
+
if (!closing) {
|
|
366
|
+
closing = true;
|
|
367
|
+
try {
|
|
368
|
+
session.append("compaction/end", { ...lifecycle, error: String(error?.message ?? error) });
|
|
369
|
+
closed = true;
|
|
370
|
+
} catch (closeError) {
|
|
371
|
+
failure = { error: closeError, stage: "commit" };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// --- 8. Durability checkpoint. ----------------------------------------
|
|
377
|
+
let flushFailure;
|
|
378
|
+
if (closed && flush !== undefined) {
|
|
379
|
+
try {
|
|
380
|
+
await flush();
|
|
381
|
+
} catch (error) {
|
|
382
|
+
flushFailure = error;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
signal?.throwIfAborted();
|
|
387
|
+
|
|
388
|
+
if (failure !== undefined) {
|
|
389
|
+
if (failure.stage === "commit") {
|
|
390
|
+
throw new ManualCompactionError("commit", "prune did not commit cleanly", { cause: failure.error });
|
|
391
|
+
}
|
|
392
|
+
if (failure.error instanceof SpanChangedError) {
|
|
393
|
+
throw new ManualCompactionError("changed", "the history selected for pruning changed during the operation", {
|
|
394
|
+
cause: failure.error
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
throw new ManualCompactionError("summary", "prune could not produce a useful checkpoint", {
|
|
398
|
+
cause: failure.error
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
if (flushFailure !== undefined) {
|
|
402
|
+
throw new ManualCompactionError("persistence", "prune durability checkpoint failed", { cause: flushFailure });
|
|
403
|
+
}
|
|
404
|
+
if (committed === undefined) throw new Error("prune committed without a result");
|
|
405
|
+
|
|
406
|
+
return committed;
|
|
407
|
+
}
|