mirror of
https://github.com/wassname/pi-dynamic-context-pruning.git
synced 2026-06-27 16:46:12 +08:00
fix: prevent Infinity anchorTimestamp ghost block spiral
The root cause of the compression spiral (101 failures, 2 hours): resolveAnchorTimestamp returned Infinity when no message followed the compression range. Infinity corrupted JSON serialization (became null), and null timestamps in JS overlap checks coerced to 0, making every range appear to overlap the ghost block b7. Fixes: - resolveAnchorTimestamp returns endTimestamp + 1 instead of Infinity - Validate all timestamps are finite before creating a block (fail fast) - Skip blocks with non-finite timestamps in overlap checks and compression - Include existing block range in overlap error messages (diagnostic) - Filter out corrupted blocks on session restore - Guard synthetic message timestamp creation against non-finite values - Add regression tests for Infinity anchor and null-timestamp blocks
This commit is contained in:
+29
-6
@@ -65,17 +65,20 @@ function resolveIdToTimestamp(
|
|||||||
* Determine the anchor timestamp for a compression block — the timestamp of
|
* Determine the anchor timestamp for a compression block — the timestamp of
|
||||||
* the first raw message that appears strictly after `endTimestamp`.
|
* the first raw message that appears strictly after `endTimestamp`.
|
||||||
*
|
*
|
||||||
* Returns `Infinity` when the range extends to the very end of the visible
|
* Returns `endTimestamp + 1` when the range extends to the very end of the
|
||||||
* conversation (nothing comes after it).
|
* visible conversation (nothing comes after it). We never use Infinity because
|
||||||
|
* it corrupts JSON serialization (becomes null) and breaks numeric comparisons.
|
||||||
*/
|
*/
|
||||||
function resolveAnchorTimestamp(endTimestamp: number, state: DcpState): number {
|
function resolveAnchorTimestamp(endTimestamp: number, state: DcpState): number {
|
||||||
let anchor = Infinity
|
let anchor: number | null = null
|
||||||
for (const ts of state.messageIdSnapshot.values()) {
|
for (const ts of state.messageIdSnapshot.values()) {
|
||||||
if (ts > endTimestamp && ts < anchor) {
|
if (ts > endTimestamp && (anchor === null || ts < anchor)) {
|
||||||
anchor = ts
|
anchor = ts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return anchor
|
// Fall back to endTimestamp + 1 instead of Infinity to avoid JSON
|
||||||
|
// serialization corruption (Infinity → null) and comparison breakage.
|
||||||
|
return anchor ?? endTimestamp + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -132,9 +135,27 @@ export function registerCompressTool(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Validate timestamps are finite ──────────────────────────────
|
||||||
|
if (!Number.isFinite(startTimestamp)) {
|
||||||
|
throw new Error(
|
||||||
|
`Start ID "${startId}" resolved to a non-finite timestamp (${startTimestamp}). ` +
|
||||||
|
`This usually means the referenced message has a corrupted timestamp.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(endTimestamp)) {
|
||||||
|
throw new Error(
|
||||||
|
`End ID "${endId}" resolved to a non-finite timestamp (${endTimestamp}). ` +
|
||||||
|
`This usually means the referenced message has a corrupted timestamp.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Overlap check against existing active blocks ─────────────────
|
// ── Overlap check against existing active blocks ─────────────────
|
||||||
for (const existing of state.compressionBlocks) {
|
for (const existing of state.compressionBlocks) {
|
||||||
if (!existing.active) continue
|
if (!existing.active) continue
|
||||||
|
// Skip blocks with corrupted timestamps
|
||||||
|
if (!Number.isFinite(existing.startTimestamp) || !Number.isFinite(existing.endTimestamp)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
const overlaps =
|
const overlaps =
|
||||||
startTimestamp <= existing.endTimestamp &&
|
startTimestamp <= existing.endTimestamp &&
|
||||||
existing.startTimestamp <= endTimestamp
|
existing.startTimestamp <= endTimestamp
|
||||||
@@ -142,7 +163,9 @@ export function registerCompressTool(
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
`Overlapping compression ranges are not supported. ` +
|
`Overlapping compression ranges are not supported. ` +
|
||||||
`New range (${startId}..${endId}) overlaps existing block ` +
|
`New range (${startId}..${endId}) overlaps existing block ` +
|
||||||
`b${existing.id} "${existing.topic}"`,
|
`b${existing.id} "${existing.topic}" ` +
|
||||||
|
`(b${existing.id} covers ${existing.startTimestamp}..${existing.endTimestamp}, ` +
|
||||||
|
`new range covers ${startTimestamp}..${endTimestamp})`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,16 @@ export default function (pi: ExtensionAPI) {
|
|||||||
const data = entry.data as any
|
const data = entry.data as any
|
||||||
|
|
||||||
if (data?.compressionBlocks) {
|
if (data?.compressionBlocks) {
|
||||||
state.compressionBlocks = data.compressionBlocks
|
// Filter out blocks with corrupted (null/NaN/Infinity) timestamps —
|
||||||
|
// these were caused by Infinity anchorTimestamp values that became
|
||||||
|
// null after JSON round-trip.
|
||||||
|
const validBlocks = data.compressionBlocks.filter(
|
||||||
|
(b: any) =>
|
||||||
|
Number.isFinite(b.startTimestamp) &&
|
||||||
|
Number.isFinite(b.endTimestamp) &&
|
||||||
|
Number.isFinite(b.anchorTimestamp),
|
||||||
|
)
|
||||||
|
state.compressionBlocks = validBlocks
|
||||||
state.nextBlockId = data.nextBlockId ?? state.compressionBlocks.length
|
state.nextBlockId = data.nextBlockId ?? state.compressionBlocks.length
|
||||||
state.tokensSaved = data.tokensSaved ?? 0
|
state.tokensSaved = data.tokensSaved ?? 0
|
||||||
state.totalPruneCount = data.totalPruneCount ?? 0
|
state.totalPruneCount = data.totalPruneCount ?? 0
|
||||||
|
|||||||
Generated
+3796
-4
File diff suppressed because it is too large
Load Diff
+111
@@ -708,4 +708,115 @@ function findOrphanedToolUse(result: any[]): string | null {
|
|||||||
console.log("TEST 9 PASSED\n");
|
console.log("TEST 9 PASSED\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test 10 — INFINITY ANCHOR BUG (regression test)
|
||||||
|
//
|
||||||
|
// Previously, when a compression block's range extended to the end of the
|
||||||
|
// conversation, resolveAnchorTimestamp returned Infinity. This caused:
|
||||||
|
// 1. JSON serialization turned Infinity into null, corrupting saved state
|
||||||
|
// 2. Null timestamps in overlap checks caused false positives (every range
|
||||||
|
// appeared to overlap the ghost block)
|
||||||
|
// 3. The model entered a compression spiral, unable to consolidate blocks
|
||||||
|
//
|
||||||
|
// Fix: resolveAnchorTimestamp returns endTimestamp + 1 instead of Infinity.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
console.log("TEST 10: Infinity anchor timestamp regression");
|
||||||
|
|
||||||
|
// Conversation where the last message is at timestamp 4000.
|
||||||
|
// Compression block covers up to the end, so anchor should be 4001, not Infinity.
|
||||||
|
const messages: any[] = [
|
||||||
|
{ role: "user", content: [{ type: "text", text: "read file" }], timestamp: 1000 },
|
||||||
|
{ role: "assistant", content: [{ type: "toolCall", id: "toolu_1", name: "read", arguments: {} }], timestamp: 2000 },
|
||||||
|
{ role: "toolResult", toolCallId: "toolu_1", toolName: "read", isError: false, content: [{ type: "text", text: "file data" }], timestamp: 3000 },
|
||||||
|
{ role: "user", content: [{ type: "text", text: "thanks" }], timestamp: 4000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Block that extends to the end of conversation
|
||||||
|
const state = makeState([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
topic: "file read",
|
||||||
|
summary: "File was read.",
|
||||||
|
startTimestamp: 1000,
|
||||||
|
endTimestamp: 4000,
|
||||||
|
anchorTimestamp: 4001, // Fixed: was Infinity before the bugfix
|
||||||
|
active: true,
|
||||||
|
summaryTokenEstimate: 5,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = applyPruning(messages, state, makeConfig());
|
||||||
|
|
||||||
|
console.log(" Result messages:");
|
||||||
|
for (const m of result) {
|
||||||
|
const preview = Array.isArray(m.content)
|
||||||
|
? m.content.map((b: any) => b.text ?? b.type ?? "?").join(" | ").slice(0, 60)
|
||||||
|
: String(m.content).slice(0, 60);
|
||||||
|
console.log(` role="${m.role}" ts=${m.timestamp} content="${preview}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The synthetic message timestamp must be finite (not Infinity)
|
||||||
|
const synthetic = result.find(
|
||||||
|
(m: any) => m.role === "user" && typeof m.content?.[0]?.text === "string" && m.content[0].text.includes("Compressed section")
|
||||||
|
);
|
||||||
|
assert.ok(synthetic, "FAIL — no synthetic compressed message found");
|
||||||
|
assert.ok(
|
||||||
|
Number.isFinite(synthetic.timestamp),
|
||||||
|
`FAIL — synthetic message has non-finite timestamp: ${synthetic.timestamp}`
|
||||||
|
);
|
||||||
|
console.log(` PASS: synthetic message has finite timestamp (${synthetic.timestamp})`);
|
||||||
|
|
||||||
|
console.log("TEST 10 PASSED\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test 11 — CORRUPTED BLOCK WITH NULL/INFINITY TIMESTAMPS (resilience)
|
||||||
|
//
|
||||||
|
// Blocks from older sessions may have null/Infinity timestamps due to JSON
|
||||||
|
// round-trip corruption. These blocks should be skipped during compression
|
||||||
|
// application and should not block new compress operations.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
console.log("TEST 11: corrupted block with null/Infinity timestamps is skipped");
|
||||||
|
|
||||||
|
const messages: any[] = [
|
||||||
|
{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: 1000 },
|
||||||
|
{ role: "assistant", content: [{ type: "text", text: "hi" }], timestamp: 2000 },
|
||||||
|
{ role: "user", content: [{ type: "text", text: "bye" }], timestamp: 3000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Block with corrupted timestamps (null from JSON round-trip)
|
||||||
|
const state = makeState([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
topic: "ghost block",
|
||||||
|
summary: "This block has corrupted timestamps.",
|
||||||
|
startTimestamp: null as any, // null from JSON deserialization of Infinity
|
||||||
|
endTimestamp: null as any,
|
||||||
|
anchorTimestamp: null as any,
|
||||||
|
active: true,
|
||||||
|
summaryTokenEstimate: 5,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = applyPruning(messages, state, makeConfig());
|
||||||
|
|
||||||
|
console.log(" Result messages:");
|
||||||
|
for (const m of result) {
|
||||||
|
const preview = Array.isArray(m.content)
|
||||||
|
? m.content.map((b: any) => b.text ?? b.type ?? "?").join(" | ").slice(0, 60)
|
||||||
|
: String(m.content).slice(0, 60);
|
||||||
|
console.log(` role="${m.role}" ts=${m.timestamp} content="${preview}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All 3 original messages should survive (ghost block was skipped)
|
||||||
|
assert.strictEqual(result.length, 3, `FAIL — expected 3 messages, got ${result.length}`);
|
||||||
|
console.log(" PASS: corrupted block skipped, all original messages preserved");
|
||||||
|
|
||||||
|
console.log("TEST 11 PASSED\n");
|
||||||
|
}
|
||||||
|
|
||||||
console.log("All tests passed.");
|
console.log("All tests passed.");
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ function applyCompressionBlocks(messages: any[], state: DcpState): any[] {
|
|||||||
if (activeBlocks.length === 0) return messages;
|
if (activeBlocks.length === 0) return messages;
|
||||||
|
|
||||||
for (const block of activeBlocks) {
|
for (const block of activeBlocks) {
|
||||||
|
// Skip blocks with corrupted timestamps (from pre-fix sessions)
|
||||||
|
if (!Number.isFinite(block.startTimestamp) || !Number.isFinite(block.endTimestamp)) continue;
|
||||||
|
|
||||||
// Find start and end indices by timestamp
|
// Find start and end indices by timestamp
|
||||||
const startIdx = messages.findIndex((m) => m.timestamp === block.startTimestamp);
|
const startIdx = messages.findIndex((m) => m.timestamp === block.startTimestamp);
|
||||||
const endIdx = messages.findIndex((m) => m.timestamp === block.endTimestamp);
|
const endIdx = messages.findIndex((m) => m.timestamp === block.endTimestamp);
|
||||||
@@ -153,7 +156,10 @@ function applyCompressionBlocks(messages: any[], state: DcpState): any[] {
|
|||||||
"</dcp-block-id>",
|
"</dcp-block-id>",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
timestamp: block.anchorTimestamp - 0.5,
|
// anchorTimestamp is always finite (resolveAnchorTimestamp returns
|
||||||
|
// endTimestamp + 1 instead of Infinity), but guard against corrupted
|
||||||
|
// state from older sessions where Infinity/null could leak in.
|
||||||
|
timestamp: Number.isFinite(block.anchorTimestamp) ? block.anchorTimestamp - 0.5 : block.endTimestamp + 0.5,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Estimate tokens added by the summary
|
// Estimate tokens added by the summary
|
||||||
|
|||||||
Reference in New Issue
Block a user