Skip to content

Commit c94798c

Browse files
committed
fix: close 5 correctness findings from Oracle audit wave 2
Three parallel read-only Oracles (nudges/emergency-drop/reclaim; telemetry/ forward-pressure/boundary; historian pipeline/decay/recomp/migration). Cores verified safe (Ch1/emergency-math/two-pass/defer-safety; forward-pressure floor; protected-tail open-arc fence; decay defer-replay; parse/validate). Five source-confirmed fixes; two design questions banked to .alfonso/oracle-loop-findings.md (D3 emergency skeleton policy, D4 historian publish atomicity — both structural/cache-path, need review before changing). 1. Channel 2 stale-delivery (event-handler.ts). deliverChannel2IfPending ignored the baseline's reducedSinceRefresh flag, so if the agent already called ctx_reduce since the last transform, delivery used stale-high tail tokens and nudged the agent to drop output it just dropped. Skip when reducedSinceRefresh (the pending intent stays armed for the next fresh re-eval). PARITY: Pi's maybeDeliverChannel2Pi already had this guard — this is an OpenCode parity fix. 2. Discard-last user-observation gap (compartment-runner-incremental.ts + Pi pi-historian-runner.ts). Facts are skipped on a discard-last run (the provisional last compartment re-emits next run), but user_observations were stored unconditionally → a reworded re-emission next run double-stores the candidate. Gate observations on !discardedLast too (hoisted the Pi compute above the observations block). Both harnesses. 3. Memory-migration apply+guard atomicity (memory-migration.ts + Pi pi-memory-migration.ts). applyMemoryMigration (delete+reinsert+epoch bump) and markMemoryMigrationDone ran separately; a crash between them left the project migrated-to-v2 but UNGUARDED → a retry re-migrates v2 rows (new ids/stats/ embeddings, dup observation candidates). Wrap both in one db.transaction() (nested = savepoint, inner tx still works). Both harnesses. 4. transform_decisions retention cap missing (transform-decision-log.ts). The documented 2000-row/(session,harness) cap was never enforced — only full session-delete pruned. A long session's cache-affecting passes grew the telemetry table unbounded (dashboard loads all rows for attribution). Prune newest-2000 after each insert on the same non-blocking handle. +tests. 5. Pi telemetry older-entry misattribution (transform-decision-log.ts). The backward value-skip resolver returned an OLDER assistant when no new one had arrived (branch still ends at snapshot) → this pass's cache decision recorded against the wrong message. Made it index-aware: resolve the snapshot's index, bind only to the first assistant AFTER it; refuse (null, stay pending) if the snapshot is absent. Dashboard-attribution only (no runtime/cache corruption). +tests. Also: corrected the stale Channel-1 denominator comment (ctx-reduce-nudge.ts: historyBudget → workingWindow). Gate: plugin 2174/0 (+6), Pi 471/0, tsc+biome clean both harnesses.
1 parent 7d8e4b0 commit c94798c

8 files changed

Lines changed: 228 additions & 22 deletions

File tree

packages/pi-plugin/src/pi-historian-runner.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -940,12 +940,19 @@ export async function runPiHistorian(deps: PiHistorianDeps): Promise<void> {
940940
// rendered m[0]/m[1] bytes.
941941
queueDropsForCompartmentalizedMessages(db, sessionId, lastNewEnd);
942942

943+
// discard-last: when the provisional last compartment was dropped, its
944+
// facts AND observations are not durable yet — skip both this run
945+
// (unanchored; re-derived next run). Computed before the observations
946+
// block so we can gate it too (parity with OpenCode).
947+
const discardedLast = newCompartments.length < emittedCompartments.length;
948+
943949
// user observations are inserted POST-COMMIT,
944950
// best-effort, so an auxiliary failure never rolls back the publish.
945951
// Gated on the user-memory feature so opted-out users never have
946952
// behavioral candidates persisted (privacy parity with OpenCode).
947953
if (
948954
userMemoriesEnabled === true &&
955+
!discardedLast &&
949956
validatedPass.userObservations?.length
950957
) {
951958
try {
@@ -971,11 +978,6 @@ export async function runPiHistorian(deps: PiHistorianDeps): Promise<void> {
971978
}
972979
}
973980

974-
// discard-last: when the provisional last
975-
// compartment was dropped, its facts are not durable yet — skip fact
976-
// promotion this run (facts are unanchored; re-derived next run).
977-
const discardedLast = newCompartments.length < emittedCompartments.length;
978-
979981
// register the project for embeddings against the
980982
// LIVE directory ONCE up front (not inside the promotion block), so a
981983
// discard-last pass that skips promotion still registers before the

packages/pi-plugin/src/pi-memory-migration.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -193,13 +193,15 @@ export async function runPiMemoryMigration(
193193
routed = parsed.userObservations.length;
194194
}
195195

196-
const { removed, inserted } = applyMemoryMigration(
197-
deps.db,
198-
projectPath,
199-
parsed,
200-
);
201-
202-
markMemoryMigrationDone(deps.db, projectPath);
196+
// Apply the destructive rewrite AND set the done-guard atomically (parity with
197+
// OpenCode). Separate, a crash between them leaves the project migrated-to-v2
198+
// but UNGUARDED, so a retry re-migrates v2 rows. A nested db.transaction() runs
199+
// as a savepoint, so applyMemoryMigration's inner transaction still works.
200+
const { removed, inserted } = deps.db.transaction(() => {
201+
const counts = applyMemoryMigration(deps.db, projectPath, parsed);
202+
markMemoryMigrationDone(deps.db, projectPath);
203+
return counts;
204+
})();
203205
return {
204206
ran: true,
205207
summary: `Re-evaluated ${removed} memor${removed === 1 ? "y" : "ies"} into ${inserted} v2-taxonomy memor${inserted === 1 ? "y" : "ies"}${routed > 0 ? `, routed ${routed} user trait${routed === 1 ? "" : "s"} to your profile` : ""}.`,

packages/plugin/src/features/magic-context/memory/memory-migration.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -510,9 +510,17 @@ export async function runMemoryMigration(
510510
routed = result.userObservations.length;
511511
}
512512

513-
const { removed, inserted } = applyMemoryMigration(db, projectPath, result);
514-
515-
markMemoryMigrationDone(db, projectPath);
513+
// Apply the destructive rewrite AND set the done-guard atomically. If they
514+
// were separate and a crash landed between them, the project would be left
515+
// already-migrated-to-v2 but UNGUARDED — a retry would re-migrate v2 rows
516+
// (new ids/stats/embeddings, possible duplicate observation candidates).
517+
// A nested db.transaction() runs as a savepoint, so applyMemoryMigration's
518+
// own inner transaction still works.
519+
const { removed, inserted } = db.transaction(() => {
520+
const counts = applyMemoryMigration(db, projectPath, result);
521+
markMemoryMigrationDone(db, projectPath);
522+
return counts;
523+
})();
516524
return {
517525
ran: true,
518526
removed,
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2+
import { mkdtempSync, rmSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { Database } from "../../shared/sqlite";
6+
import { closeQuietly } from "../../shared/sqlite-helpers";
7+
import { runMigrations } from "./migrations";
8+
import { initializeDatabase } from "./storage-db";
9+
import { __test, TRANSFORM_DECISIONS_RETENTION } from "./transform-decision-log";
10+
11+
let dir: string;
12+
let dbPath: string;
13+
let db: Database;
14+
15+
beforeEach(() => {
16+
dir = mkdtempSync(join(tmpdir(), "mc-txn-decision-"));
17+
dbPath = join(dir, "context.db");
18+
db = new Database(dbPath);
19+
initializeDatabase(db);
20+
runMigrations(db);
21+
__test.reset();
22+
});
23+
24+
afterEach(() => {
25+
closeQuietly(db);
26+
rmSync(dir, { recursive: true, force: true });
27+
__test.reset();
28+
});
29+
30+
function baseRow(messageId: string, tsMs: number) {
31+
return {
32+
sessionId: "ses-1",
33+
harness: "opencode" as const,
34+
messageId,
35+
tsMs,
36+
decision: "execute" as const,
37+
materialized: true,
38+
materializeReason: "model_change" as const,
39+
emergency: false,
40+
droppedTokens: 0,
41+
droppedCount: 0,
42+
inputTokens: 100,
43+
bustedThisPass: true,
44+
};
45+
}
46+
47+
function rowCount(): number {
48+
return (
49+
db
50+
.prepare(
51+
"SELECT COUNT(*) AS c FROM transform_decisions WHERE session_id = 'ses-1' AND harness = 'opencode'",
52+
)
53+
.get() as { c: number }
54+
).c;
55+
}
56+
57+
describe("transform_decisions retention cap", () => {
58+
it("prunes to the newest TRANSFORM_DECISIONS_RETENTION rows per (session,harness)", () => {
59+
// Write cap + 5 rows with strictly increasing ts and distinct message ids.
60+
const total = TRANSFORM_DECISIONS_RETENTION + 5;
61+
for (let i = 0; i < total; i++) {
62+
__test.writeRow(dbPath, baseRow(`msg-${i}`, 1000 + i));
63+
}
64+
expect(rowCount()).toBe(TRANSFORM_DECISIONS_RETENTION);
65+
66+
// The oldest (smallest ts) must be gone; the newest must remain.
67+
const oldest = db
68+
.prepare("SELECT 1 FROM transform_decisions WHERE message_id = 'msg-0'")
69+
.get();
70+
const newest = db
71+
.prepare(`SELECT 1 FROM transform_decisions WHERE message_id = 'msg-${total - 1}'`)
72+
.get();
73+
expect(oldest ?? null).toBeNull();
74+
expect(newest ?? null).not.toBeNull();
75+
});
76+
77+
it("does not prune below the cap", () => {
78+
for (let i = 0; i < 10; i++) {
79+
__test.writeRow(dbPath, baseRow(`m-${i}`, 1000 + i));
80+
}
81+
expect(rowCount()).toBe(10);
82+
});
83+
});
84+
85+
describe("findNewestPiAssistantEntryIdAfter (index-aware binding)", () => {
86+
const asst = (id: string) => ({
87+
id,
88+
type: "message",
89+
message: { role: "assistant" },
90+
});
91+
const user = (id: string) => ({
92+
id,
93+
type: "message",
94+
message: { role: "user" },
95+
});
96+
97+
it("binds to the first assistant AFTER the snapshot", () => {
98+
const entries = [asst("a1"), user("u1"), asst("a2")];
99+
expect(__test.findNewestPiAssistantEntryIdAfter(entries, "a1")).toBe("a2");
100+
});
101+
102+
it("returns null when no assistant exists after the snapshot (no older-entry fallback)", () => {
103+
// Branch still ends at the snapshot — a value-skip scan would wrongly
104+
// return the older a1; the index-aware version must refuse.
105+
const entries = [asst("a1"), asst("a2")];
106+
expect(__test.findNewestPiAssistantEntryIdAfter(entries, "a2")).toBeNull();
107+
});
108+
109+
it("refuses to bind when the snapshot id is absent (compacted/reordered)", () => {
110+
const entries = [asst("a1"), asst("a2")];
111+
expect(__test.findNewestPiAssistantEntryIdAfter(entries, "missing-snapshot")).toBeNull();
112+
});
113+
114+
it("with a null snapshot, binds to the FIRST assistant (recorded when none existed)", () => {
115+
// Null snapshot = no assistant at record time, so the first assistant to
116+
// arrive is the one this decision belongs to (not the newest — that would
117+
// misattribute to a later pass's message if resolve lagged).
118+
const entries = [asst("a1"), user("u1"), asst("a2"), user("u2")];
119+
expect(__test.findNewestPiAssistantEntryIdAfter(entries, null)).toBe("a1");
120+
});
121+
});

packages/plugin/src/features/magic-context/transform-decision-log.ts

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ import { getDatabasePath } from "./storage-db";
55
export type TransformDecisionHarness = "opencode" | "pi";
66
export type TransformSchedulerDecision = "execute" | "defer";
77

8+
/**
9+
* Max transform_decisions rows kept per (session_id, harness). Pruned newest-first
10+
* after every insert so a long session's cache-affecting passes never grow this
11+
* telemetry table without bound (the dashboard loads all matching rows for cause
12+
* attribution).
13+
*/
14+
export const TRANSFORM_DECISIONS_RETENTION = 2000;
15+
816
export type CanonicalMaterializeReason =
917
| "system_hash"
1018
| "model_change"
@@ -247,16 +255,41 @@ function findNewestPiAssistantEntryIdAfter(
247255
snapshotNewestAssistantEntryId: string | null,
248256
): string | null {
249257
if (!Array.isArray(entries)) return null;
250-
for (let i = entries.length - 1; i >= 0; i--) {
258+
259+
// Bind only to an assistant entry positioned AFTER the snapshot. A pure
260+
// value-skip backward scan misattributes when NO new assistant has arrived
261+
// yet (the branch still ends at the snapshot): it would skip the snapshot by
262+
// value and fall back to an OLDER assistant, recording THIS pass's cache
263+
// decision against the wrong message. Resolve the snapshot's INDEX first, then
264+
// return the first assistant after it. If the snapshot id is absent (compacted
265+
// away / reordered), refuse to bind (return null) — the pending row stays for
266+
// a later pass (at most one per session; overwritten by the next bust), never
267+
// attaching to an older entry.
268+
let startIndex = 0;
269+
if (snapshotNewestAssistantEntryId !== null) {
270+
let snapshotIndex = -1;
271+
for (let i = entries.length - 1; i >= 0; i--) {
272+
const entry = entries[i];
273+
if (
274+
entry &&
275+
typeof entry === "object" &&
276+
(entry as { id?: unknown }).id === snapshotNewestAssistantEntryId
277+
) {
278+
snapshotIndex = i;
279+
break;
280+
}
281+
}
282+
if (snapshotIndex === -1) return null;
283+
startIndex = snapshotIndex + 1;
284+
}
285+
286+
for (let i = startIndex; i < entries.length; i++) {
251287
const entry = entries[i];
252288
if (!entry || typeof entry !== "object") continue;
253289
const row = entry as { id?: unknown; type?: unknown; message?: unknown };
254290
if (row.type !== "message" || typeof row.id !== "string" || row.id.length === 0) {
255291
continue;
256292
}
257-
if (snapshotNewestAssistantEntryId !== null && row.id === snapshotNewestAssistantEntryId) {
258-
continue;
259-
}
260293
const message = row.message;
261294
if (
262295
message &&
@@ -301,6 +334,27 @@ function writeTransformDecisionRow(dbPath: string, row: TransformDecisionRow): v
301334
Math.max(0, Math.floor(row.droppedCount)),
302335
Math.max(0, Math.floor(row.inputTokens)),
303336
);
337+
// Enforce the per-(session,harness) retention cap so a long session's
338+
// cache-affecting passes can't grow this telemetry table unbounded (the
339+
// dashboard loads all matching rows for cause attribution). Keep the
340+
// newest TRANSFORM_DECISIONS_RETENTION rows by (ts_ms, rowid). Best-effort
341+
// on the same non-blocking handle; a failure just defers the prune.
342+
db.prepare(
343+
`DELETE FROM transform_decisions
344+
WHERE session_id = ? AND harness = ?
345+
AND rowid NOT IN (
346+
SELECT rowid FROM transform_decisions
347+
WHERE session_id = ? AND harness = ?
348+
ORDER BY ts_ms DESC, rowid DESC
349+
LIMIT ?
350+
)`,
351+
).run(
352+
row.sessionId,
353+
row.harness,
354+
row.sessionId,
355+
row.harness,
356+
TRANSFORM_DECISIONS_RETENTION,
357+
);
304358
} finally {
305359
closeQuietly(db);
306360
}
@@ -323,4 +377,8 @@ export const __test = {
323377
setWriterForTests(writer: TransformDecisionWriter | null): void {
324378
writerOverrideForTests = writer;
325379
},
380+
writeRow(dbPath: string, row: TransformDecisionRow): void {
381+
writeTransformDecisionRow(dbPath, row);
382+
},
383+
findNewestPiAssistantEntryIdAfter,
326384
};

packages/plugin/src/hooks/magic-context/compartment-runner-incremental.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -762,8 +762,13 @@ export async function runCompartmentAgent(deps: CompartmentRunnerDeps): Promise<
762762
// Store user behavior observations as candidates ONLY when the user-memory
763763
// feature is enabled. Without this gate we'd persist behavioral candidates
764764
// for users who opted out of user memories entirely (privacy).
765+
// discard-last: skip observation candidates for the discarded provisional
766+
// compartment for the SAME reason facts are skipped above — observations
767+
// are unanchored, so a reworded re-emission next run would double-store.
768+
// (`discardedLast` computed above for the fact-promotion gate.)
765769
if (
766770
deps.experimentalUserMemories === true &&
771+
!discardedLast &&
767772
validatedPass.userObservations &&
768773
validatedPass.userObservations.length > 0
769774
) {

packages/plugin/src/hooks/magic-context/ctx-reduce-nudge.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44
// later transform, so this is "free sticky" — no anchor store, no CAS, no
55
// replay machinery (unlike the deleted assistant/user-anchored nudges).
66
//
7-
// The metric is `severity = (undropped / historyBudget) × pressure`:
7+
// The metric is `severity = (undropped / workingWindow) × pressure`:
88
// - `undropped` = approximate tokens of NON-dropped tool output in the live
9-
// tail (dropped outputs are `[dropped …]` / `[truncated]` sentinels, so a
10-
// simple tail walk excludes them — no agent-vs-heuristic attribution).
9+
// tail (dropped outputs are `[dropped …]` sentinels, so a simple tail walk
10+
// excludes them — no agent-vs-heuristic attribution).
11+
// - `workingWindow` = the execute-threshold token budget (the range the agent
12+
// actually moves in), NOT the tiny historyBudget — using historyBudget here
13+
// saturated severity and nagged constantly (the prior denominator bug).
1114
// - `pressure` = current usage% / execute-threshold%.
1215
// Either factor low ⇒ quiet, so a disciplined agent and an early-exploring
1316
// agent are both spared; only "lots of reclaimable space AND near compaction"

packages/plugin/src/hooks/magic-context/event-handler.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,13 @@ async function deliverChannel2IfPending(deps: EventHandlerDeps, sessionId: strin
131131
// self-gates on the live-server probe (channel2-delivery.ts), so plain
132132
// TUI still 404s out; it no-ops unless a `pending` intent exists.
133133
const baseline = deps.channel1StateBySession?.get(sessionId);
134+
// If the agent already called ctx_reduce since the last transform refreshed
135+
// the baseline, the tailToolTokens/turnToolTokens here are STALE-HIGH (they
136+
// predate the reduction) — delivering now would nudge the agent to drop
137+
// output it just dropped. Skip until the next transform recomputes a fresh
138+
// baseline. Parity with Pi's maybeDeliverChannel2Pi (reducedSinceRefresh
139+
// guard). The pending intent stays armed for that fresh re-evaluation.
140+
if (baseline?.reducedSinceRefresh) return;
134141
await maybeDeliverChannel2(sessionId, {
135142
db: deps.db,
136143
serverUrl: deps.serverUrl,

0 commit comments

Comments
 (0)