Skip to content

Commit 271e3d9

Browse files
committed
mason: reconcile marker representation on every pass
1 parent 8fac08a commit 271e3d9

6 files changed

Lines changed: 718 additions & 166 deletions

File tree

packages/plugin/src/features/magic-context/compaction-marker.test.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { closeQuietly } from "../../shared/sqlite-helpers";
99
import {
1010
closeCompactionMarkerDb,
1111
findBoundaryUserMessage,
12+
generateMessageId,
1213
injectCompactionMarker,
1314
} from "./compaction-marker";
1415

@@ -134,6 +135,52 @@ describe("findBoundaryUserMessage", () => {
134135
});
135136

136137
describe("injectCompactionMarker", () => {
138+
it("keeps deterministic marker ids in OpenCode's lexicographic row order", () => {
139+
const dataHome = useTempDataHome("marker-inject-id-order-");
140+
const db = createOpenCodeDb(dataHome);
141+
const boundaryId = generateMessageId(1_000, 0n, "boundary");
142+
const retainedId = generateMessageId(1_002, 0n, "retained");
143+
insertMessage(db, boundaryId, "user", 1_000);
144+
insertMessage(db, retainedId, "assistant", 1_002);
145+
closeQuietly(db);
146+
147+
const result = injectCompactionMarker({
148+
sessionId: "ses-1",
149+
endOrdinal: 2,
150+
endMessageId: retainedId,
151+
summaryText: "summary placeholder",
152+
directory: dataHome,
153+
});
154+
155+
expect(result?.summaryMessageId).toMatch(/^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/);
156+
expect(result?.compactionPartId).toMatch(/^prt_[0-9a-f]{12}[0-9A-Za-z]{14}$/);
157+
expect(boundaryId < (result?.summaryMessageId ?? "")).toBe(true);
158+
expect((result?.summaryMessageId ?? "") < retainedId).toBe(true);
159+
160+
const inspection = new Database(join(dataHome, "opencode", "opencode.db"));
161+
const rows = inspection
162+
.prepare(
163+
"SELECT id, json_extract(data, '$.role') AS role, json_extract(data, '$.summary') AS summary, json_extract(data, '$.parentID') AS parentID FROM message WHERE session_id = 'ses-1' ORDER BY time_created ASC, id ASC",
164+
)
165+
.all() as Array<{
166+
id: string;
167+
role: string;
168+
summary: number | null;
169+
parentID: string | null;
170+
}>;
171+
expect(rows).toEqual([
172+
{ id: boundaryId, role: "user", summary: null, parentID: null },
173+
{
174+
id: result?.summaryMessageId,
175+
role: "assistant",
176+
summary: 1,
177+
parentID: boundaryId,
178+
},
179+
{ id: retainedId, role: "assistant", summary: null, parentID: null },
180+
]);
181+
closeQuietly(inspection);
182+
});
183+
137184
it("preserves the deterministic boundary in the healthy no-deletion case", () => {
138185
const dataHome = useTempDataHome("marker-inject-healthy-");
139186
const db = createOpenCodeDb(dataHome);
@@ -150,8 +197,16 @@ describe("injectCompactionMarker", () => {
150197
directory: dataHome,
151198
});
152199

153-
// Generated marker row ids include random base62 suffixes; compare only
154-
// the deterministic boundary field that the old ordinal path intended.
155200
expect(result?.boundaryMessageId).toBe("msg_001_user");
201+
expect(result?.summaryMessageId).toMatch(/^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/);
202+
203+
const retry = injectCompactionMarker({
204+
sessionId: "ses-1",
205+
endOrdinal: 3,
206+
endMessageId: "msg_003_target",
207+
summaryText: "summary placeholder",
208+
directory: dataHome,
209+
});
210+
expect(retry).toEqual(result);
156211
});
157212
});

packages/plugin/src/features/magic-context/compaction-marker.ts

Lines changed: 133 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
* whose parentID matches that user message's id
2828
*/
2929

30+
import { createHash } from "node:crypto";
3031
import { existsSync } from "node:fs";
3132
import { join } from "node:path";
3233
import { getDataDir } from "../../shared/data-path";
@@ -37,33 +38,44 @@ import { closeQuietly } from "../../shared/sqlite-helpers";
3738
// ── ID Generation ────────────────────────────────────────────────
3839

3940
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
40-
41-
function randomBase62(length: number): string {
42-
const chars: string[] = [];
43-
for (let i = 0; i < length; i++) {
44-
chars.push(BASE62_CHARS[Math.floor(Math.random() * BASE62_CHARS.length)]);
41+
const ID_PREFIX_HEX_LENGTH = 12;
42+
const ID_SUFFIX_LENGTH = 14;
43+
const ID_PREFIX_MASK = (1n << BigInt(ID_PREFIX_HEX_LENGTH * 4)) - 1n;
44+
45+
function deterministicBase62(seed: string, length: number): string {
46+
let value = BigInt(`0x${createHash("sha256").update(seed).digest("hex")}`);
47+
const chars = Array<string>(length);
48+
for (let index = length - 1; index >= 0; index -= 1) {
49+
chars[index] = BASE62_CHARS[Number(value % 62n)];
50+
value /= 62n;
4551
}
4652
return chars.join("");
4753
}
4854

4955
/**
5056
* Generate an OpenCode-compatible ascending ID.
51-
* Format: `prefix_[hex-chars][14-random-base62]`
52-
* The hex encodes `BigInt(timestamp_ms) * 0x1000n + counter`.
53-
* Current timestamps produce 14 hex chars; padStart(14) ensures consistency.
57+
* Format: `prefix_[12-hex-chars][14-deterministic-base62]`.
58+
* The time prefix preserves OpenCode's lexicographic ordering, while the hash
59+
* suffix makes retries for the same marker identity converge on the same rows.
5460
*/
55-
function generateId(prefix: string, timestampMs: number, counter = 0n): string {
56-
const encoded = BigInt(timestampMs) * 0x1000n + counter;
57-
const hex = encoded.toString(16).padStart(14, "0");
58-
return `${prefix}_${hex}${randomBase62(14)}`;
61+
function generateId(
62+
prefix: string,
63+
timestampMs: number,
64+
counter: bigint,
65+
identity: string,
66+
): string {
67+
const encoded =
68+
(BigInt(Math.max(0, Math.floor(timestampMs))) * 0x1000n + counter) & ID_PREFIX_MASK;
69+
const hex = encoded.toString(16).padStart(ID_PREFIX_HEX_LENGTH, "0");
70+
return `${prefix}_${hex}${deterministicBase62(`${prefix}\0${identity}`, ID_SUFFIX_LENGTH)}`;
5971
}
6072

61-
export function generateMessageId(timestampMs: number, counter = 0n): string {
62-
return generateId("msg", timestampMs, counter);
73+
export function generateMessageId(timestampMs: number, counter = 0n, identity = ""): string {
74+
return generateId("msg", timestampMs, counter, identity);
6375
}
6476

65-
export function generatePartId(timestampMs: number, counter = 0n): string {
66-
return generateId("prt", timestampMs, counter);
77+
export function generatePartId(timestampMs: number, counter = 0n, identity = ""): string {
78+
return generateId("prt", timestampMs, counter, identity);
6779
}
6880

6981
// ── DB Access ────────────────────────────────────────────────────
@@ -343,6 +355,66 @@ export interface InjectCompactionMarkerArgs {
343355
resolvedBoundary?: BoundaryUserMessage;
344356
}
345357

358+
function removeLegacyMarkerLineageRows(
359+
db: Database,
360+
args: {
361+
sessionId: string;
362+
boundaryMessageId: string;
363+
summaryText: string;
364+
summaryMessageId: string;
365+
compactionPartId: string;
366+
},
367+
): void {
368+
const legacySummaries = db
369+
.prepare(
370+
`SELECT m.id
371+
FROM message m
372+
WHERE m.session_id = ?
373+
AND m.id <> ?
374+
AND COALESCE(json_extract(m.data, '$.summary'), 0) = 1
375+
AND COALESCE(json_extract(m.data, '$.finish'), '') = 'stop'
376+
AND COALESCE(json_extract(m.data, '$.parentID'), '') = ?
377+
AND EXISTS (
378+
SELECT 1
379+
FROM part p
380+
WHERE p.session_id = m.session_id
381+
AND p.message_id = m.id
382+
AND COALESCE(json_extract(p.data, '$.type'), '') = 'text'
383+
AND COALESCE(json_extract(p.data, '$.text'), '') = ?
384+
)`,
385+
)
386+
.all(
387+
args.sessionId,
388+
args.summaryMessageId,
389+
args.boundaryMessageId,
390+
args.summaryText,
391+
) as Array<{ id?: unknown }>;
392+
const legacySummaryIds = legacySummaries.flatMap((row) =>
393+
typeof row.id === "string" ? [row.id] : [],
394+
);
395+
if (legacySummaryIds.length === 0) return;
396+
397+
const deleteSummaryParts = db.prepare(
398+
"DELETE FROM part WHERE session_id = ? AND message_id = ?",
399+
);
400+
const deleteSummary = db.prepare("DELETE FROM message WHERE session_id = ? AND id = ?");
401+
for (const summaryMessageId of legacySummaryIds) {
402+
deleteSummaryParts.run(args.sessionId, summaryMessageId);
403+
deleteSummary.run(args.sessionId, summaryMessageId);
404+
}
405+
406+
// A stale marker lineage can carry its own compaction part. Once the
407+
// lineage is identified, retain only the deterministic boundary part.
408+
db.prepare(
409+
`DELETE FROM part
410+
WHERE session_id = ?
411+
AND message_id = ?
412+
AND id <> ?
413+
AND COALESCE(json_extract(data, '$.type'), '') = 'compaction'
414+
AND COALESCE(json_extract(data, '$.auto'), 0) = 1`,
415+
).run(args.sessionId, args.boundaryMessageId, args.compactionPartId);
416+
}
417+
346418
/**
347419
* Inject a compaction marker into OpenCode's DB.
348420
* Returns the marker state if successful, null if boundary couldn't be found.
@@ -367,13 +439,17 @@ export function injectCompactionMarker(
367439
);
368440
return null;
369441
}
370-
// Use timestamps relative to the boundary so sort order is consistent
442+
// Use timestamps relative to the boundary so OpenCode's time/id ordering
443+
// places the marker immediately after the boundary.
371444
const boundaryTime = boundary.timeCreated;
372-
373-
// Generate IDs with timestamps that sort correctly — right after the boundary
374-
const summaryMsgId = generateMessageId(boundaryTime + 1, 1n);
375-
const compactionPartId = generatePartId(boundaryTime, 1n);
376-
const summaryPartId = generatePartId(boundaryTime + 1, 2n);
445+
const markerIdentity = `${args.sessionId}\0${args.endMessageId}`;
446+
const summaryMsgId = generateMessageId(
447+
boundaryTime + 1,
448+
1n,
449+
`${markerIdentity}\0summary-message`,
450+
);
451+
const compactionPartId = generatePartId(boundaryTime, 1n, `${markerIdentity}\0compaction-part`);
452+
const summaryPartId = generatePartId(boundaryTime + 1, 2n, `${markerIdentity}\0summary-part`);
377453

378454
const summaryMsgData = JSON.stringify({
379455
role: "assistant",
@@ -392,9 +468,27 @@ export function injectCompactionMarker(
392468

393469
try {
394470
db.transaction(() => {
395-
// 1. Add compaction part to the boundary user message
471+
// A committed insert can outlive a failed context-state write. Remove
472+
// any stale lineage in the transaction that writes the canonical rows.
473+
removeLegacyMarkerLineageRows(db, {
474+
sessionId: args.sessionId,
475+
boundaryMessageId: boundary.id,
476+
summaryText: args.summaryText,
477+
summaryMessageId: summaryMsgId,
478+
compactionPartId,
479+
});
480+
481+
// Deterministic IDs make this transaction an upsert on retry. Rewriting
482+
// the exact canonical row also repairs a partial or stale prior write.
396483
db.prepare(
397-
"INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)",
484+
`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
485+
VALUES (?, ?, ?, ?, ?, ?)
486+
ON CONFLICT(id) DO UPDATE SET
487+
message_id = excluded.message_id,
488+
session_id = excluded.session_id,
489+
time_created = excluded.time_created,
490+
time_updated = excluded.time_updated,
491+
data = excluded.data`,
398492
).run(
399493
compactionPartId,
400494
boundary.id,
@@ -404,14 +498,25 @@ export function injectCompactionMarker(
404498
'{"type":"compaction","auto":true}',
405499
);
406500

407-
// 2. Insert summary assistant message
408501
db.prepare(
409-
"INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)",
502+
`INSERT INTO message (id, session_id, time_created, time_updated, data)
503+
VALUES (?, ?, ?, ?, ?)
504+
ON CONFLICT(id) DO UPDATE SET
505+
session_id = excluded.session_id,
506+
time_created = excluded.time_created,
507+
time_updated = excluded.time_updated,
508+
data = excluded.data`,
410509
).run(summaryMsgId, args.sessionId, boundaryTime + 1, boundaryTime + 1, summaryMsgData);
411510

412-
// 3. Insert text part with the summary content
413511
db.prepare(
414-
"INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)",
512+
`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
513+
VALUES (?, ?, ?, ?, ?, ?)
514+
ON CONFLICT(id) DO UPDATE SET
515+
message_id = excluded.message_id,
516+
session_id = excluded.session_id,
517+
time_created = excluded.time_created,
518+
time_updated = excluded.time_updated,
519+
data = excluded.data`,
415520
).run(
416521
summaryPartId,
417522
summaryMsgId,

0 commit comments

Comments
 (0)