Skip to content

Commit 34d95dc

Browse files
committed
fix: gate 4 cache-busting code paths behind isCacheBustingPass
Finding 1 (HIGH): Compartment injection now uses in-memory cache on defer passes. New compartments from background historian are only picked up on execute/flush passes, preventing message[0] rewrites on cache-safe passes. Finding 2 (HIGH): nudgePlacements.clear() after pending ops and flushed statuses now gated behind isCacheBustingPass, preventing the two-pass pattern where execute clears anchor then defer re-anchors. Finding 4 (MEDIUM): When nudger() returns null on defer, existing anchor is replayed via reinjectNudgeAtAnchor instead of cleared. Anchor retirement only happens on cache-busting passes. Finding 5 (MEDIUM): stripSystemInjectedMessages gated behind isCacheBustingPass so dynamic protected-tail shifts don't remove previously-cached messages on defer passes. Also: early scheduler/usage computation in transform.ts eliminates duplicate loadContextUsage/resolveSchedulerDecision calls.
1 parent 866812a commit 34d95dc

6 files changed

Lines changed: 103 additions & 34 deletions

File tree

scripts/context-dump/run-context-dump.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ export async function runContextDump(sessionId: string): Promise<ContextDumpResu
147147
try {
148148
const preparedCompartmentInjection = isSubagent
149149
? null
150-
: prepareCompartmentInjection(contextDb, sessionId, transformedMessages as unknown as MessageLike[])
150+
: prepareCompartmentInjection(contextDb, sessionId, transformedMessages as unknown as MessageLike[], true)
151151
const compartmentInjection = preparedCompartmentInjection
152152
? renderCompartmentInjection(sessionId, transformedMessages as unknown as MessageLike[], preparedCompartmentInjection)
153153
: { injected: false, compartmentEndMessage: -1, compartmentCount: 0, skippedVisibleMessages: 0 }

src/hooks/magic-context/inject-compartments.ts

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,25 @@ import type { MessageLike } from "./tag-messages";
1414
export interface PreparedCompartmentInjection {
1515
block: string;
1616
compartmentEndMessage: number;
17+
compartmentEndMessageId: string;
1718
compartmentCount: number;
1819
skippedVisibleMessages: number;
1920
factCount: number;
2021
memoryCount: number;
2122
}
2223

24+
/**
25+
* In-memory cache of the last compartment injection result per session.
26+
* On defer (cache-safe) passes, the cached result is replayed so that historian
27+
* publications between passes do not bust the Anthropic prompt-cache prefix.
28+
* The cache is refreshed only on cache-busting passes (execute / explicit flush).
29+
*/
30+
const injectionCache = new Map<string, PreparedCompartmentInjection>();
31+
32+
export function clearInjectionCache(sessionId: string): void {
33+
injectionCache.delete(sessionId);
34+
}
35+
2336
export interface CompartmentInjectionResult {
2437
injected: boolean;
2538
compartmentEndMessage: number;
@@ -104,11 +117,30 @@ export function prepareCompartmentInjection(
104117
db: Database,
105118
sessionId: string,
106119
messages: MessageLike[],
120+
isCacheBusting: boolean,
107121
projectPath?: string,
108122
injectionBudgetTokens?: number,
109123
): PreparedCompartmentInjection | null {
124+
// On defer (cache-safe) passes, replay the cached injection result so that
125+
// historian publications between passes do not bust the prompt-cache prefix.
126+
const cached = injectionCache.get(sessionId);
127+
if (!isCacheBusting && cached) {
128+
// Re-do the splice with the cached boundary (messages are rebuilt fresh each pass)
129+
if (cached.compartmentEndMessageId.length > 0) {
130+
const cutoffIndex = messages.findIndex(
131+
(message) => message.info.id === cached.compartmentEndMessageId,
132+
);
133+
if (cutoffIndex >= 0) {
134+
const remaining = messages.slice(cutoffIndex + 1);
135+
messages.splice(0, messages.length, ...remaining);
136+
}
137+
}
138+
return cached;
139+
}
140+
110141
const compartments = getCompartments(db, sessionId);
111142
if (compartments.length === 0) {
143+
injectionCache.delete(sessionId);
112144
return null;
113145
}
114146

@@ -122,15 +154,15 @@ export function prepareCompartmentInjection(
122154
// Audit note: `as` cast is safe here — session_meta schema is owned by this plugin and the two
123155
// columns are guaranteed present after initializeDatabase(). A type guard would add overhead on a
124156
// hot path (every transform) for a table we fully control.
125-
const cached = db
157+
const cachedMemory = db
126158
.prepare(
127159
"SELECT memory_block_cache, memory_block_count FROM session_meta WHERE session_id = ?",
128160
)
129161
.get(sessionId) as { memory_block_cache: string; memory_block_count: number } | null;
130162

131-
if (cached?.memory_block_cache) {
132-
memoryBlock = cached.memory_block_cache;
133-
memoryCount = cached.memory_block_count;
163+
if (cachedMemory?.memory_block_cache) {
164+
memoryBlock = cachedMemory.memory_block_cache;
165+
memoryCount = cachedMemory.memory_block_count;
134166
} else {
135167
let memories = getMemoriesByProject(db, projectPath, ["active", "permanent"]);
136168
if (injectionBudgetTokens && memories.length > 0) {
@@ -160,14 +192,17 @@ export function prepareCompartmentInjection(
160192
compartmentEndMessage: lastEnd,
161193
},
162194
);
163-
return {
195+
const result: PreparedCompartmentInjection = {
164196
block,
165197
compartmentEndMessage: lastEnd,
198+
compartmentEndMessageId: "",
166199
compartmentCount: compartments.length,
167200
skippedVisibleMessages: 0,
168201
factCount: facts.length,
169202
memoryCount,
170203
};
204+
injectionCache.set(sessionId, result);
205+
return result;
171206
}
172207

173208
let skippedVisibleMessages = 0;
@@ -178,14 +213,17 @@ export function prepareCompartmentInjection(
178213
messages.splice(0, messages.length, ...remaining);
179214
}
180215

181-
return {
216+
const result: PreparedCompartmentInjection = {
182217
block,
183218
compartmentEndMessage: lastEnd,
219+
compartmentEndMessageId: lastEndMessageId,
184220
compartmentCount: compartments.length,
185221
skippedVisibleMessages,
186222
factCount: facts.length,
187223
memoryCount,
188224
};
225+
injectionCache.set(sessionId, result);
226+
return result;
189227
}
190228

191229
export function renderCompartmentInjection(

src/hooks/magic-context/transform-compartment-phase.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export async function runCompartmentPhase(args: RunCompartmentPhaseArgs): Promis
7878
args.db,
7979
args.resolvedSessionId,
8080
args.messages,
81+
args.cacheAlreadyBusting ?? false,
8182
args.projectPath,
8283
args.injectionBudgetTokens,
8384
);

src/hooks/magic-context/transform-nudge-cache.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,10 @@ describe("createTransform nudge cache handling", () => {
128128
expect(getPersistedNudgePlacement(db, "ses-1")).toBeNull();
129129
});
130130

131-
it("clears stored nudge placement when flushed statuses mutate content", async () => {
132-
//#given
131+
it("clears stored nudge placement when flushed statuses mutate content on execute pass", async () => {
132+
//#given — execute pass so nudge placement clear is allowed
133133
useTempDataHome("context-transform-nudge-flushed-");
134-
const scheduler: Scheduler = { shouldExecute: mock(() => "defer" as const) };
134+
const scheduler: Scheduler = { shouldExecute: mock(() => "execute" as const) };
135135
const db = openDatabase();
136136
const nudgePlacements = createNudgePlacementStore(db);
137137
const contextUsageMap = new Map<string, { usage: ContextUsage; updatedAt: number }>([

src/hooks/magic-context/transform-postprocess-phase.ts

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
renderCompartmentInjection,
2020
} from "./inject-compartments";
2121
import { getNoteNudgeText } from "./note-nudger";
22+
import { reinjectNudgeAtAnchor } from "./nudge-injection";
2223
import type { NudgePlacementStore } from "./nudge-placement-store";
2324
import type { ContextNudge } from "./nudger";
2425
import {
@@ -101,6 +102,8 @@ export function runPostTransformPhase(args: RunPostTransformPhaseArgs): void {
101102
const hasPendingUserOps = pendingOps.length > 0;
102103
const shouldApplyPendingOps =
103104
(args.schedulerDecision === "execute" || isExplicitFlush) && !compartmentRunning;
105+
// Central cache-busting gate used by all mutation paths below.
106+
const isCacheBustingPass = isExplicitFlush || shouldApplyPendingOps;
104107
const shouldRunHeuristics =
105108
args.fullFeatureMode &&
106109
!compartmentRunning &&
@@ -228,7 +231,9 @@ export function runPostTransformPhase(args: RunPostTransformPhaseArgs): void {
228231
updateSessionMeta(args.db, args.sessionId, { lastTransformError: getErrorMessage(error) });
229232
args.nudgePlacements.clear(args.sessionId);
230233
}
231-
if (didMutateFromPendingOperations) {
234+
// Only clear nudge placements on cache-busting passes. Clearing on defer would
235+
// cause the next pass to re-anchor the nudge on a cached assistant message (Finding 2).
236+
if (didMutateFromPendingOperations && isCacheBustingPass) {
232237
args.nudgePlacements.clear(args.sessionId);
233238
}
234239

@@ -274,25 +279,28 @@ export function runPostTransformPhase(args: RunPostTransformPhaseArgs): void {
274279
}
275280

276281
// Remove system-injected messages (notifications, reminders, internal markers)
277-
// that are OUTSIDE the protected tail. Recent ones in the tail may contain
278-
// actionable info (e.g., background task IDs the agent needs for background_output).
279-
// Use the configured protected_tags count to find the boundary — messages in the
280-
// last N tags are kept.
281-
const protectedTailStart = Math.max(0, args.messages.length - args.protectedTags * 2);
282-
const strippedSystemInjected = stripSystemInjectedMessages(args.messages, protectedTailStart);
283-
if (strippedSystemInjected > 0) {
284-
sessionLog(
285-
args.sessionId,
286-
`stripped ${strippedSystemInjected} system-injected messages (notifications/reminders)`,
282+
// that are OUTSIDE the protected tail. Only strip on cache-busting passes because
283+
// the dynamic protected tail boundary can shift as conversation grows, which would
284+
// remove previously-cached messages on defer passes (Finding 5).
285+
if (isCacheBustingPass) {
286+
const protectedTailStart = Math.max(0, args.messages.length - args.protectedTags * 2);
287+
const strippedSystemInjected = stripSystemInjectedMessages(
288+
args.messages,
289+
protectedTailStart,
287290
);
291+
if (strippedSystemInjected > 0) {
292+
sessionLog(
293+
args.sessionId,
294+
`stripped ${strippedSystemInjected} system-injected messages (notifications/reminders)`,
295+
);
296+
}
288297
}
289298

290299
const pendingUserTurnReminder = getPersistedStickyTurnReminder(args.db, args.sessionId);
291300
if (pendingUserTurnReminder) {
292301
// Only clear the reminder when the pass is already cache-busting (execute/flush).
293302
// Clearing on a cache-safe pass would remove text from an anchored user message,
294303
// changing cached content and busting the Anthropic prompt-cache prefix.
295-
const isCacheBustingPass = isExplicitFlush || shouldApplyPendingOps || shouldRunHeuristics;
296304
if (args.hasRecentReduceCall && isCacheBustingPass) {
297305
clearPersistedStickyTurnReminder(args.db, args.sessionId);
298306
sessionLog(
@@ -351,8 +359,22 @@ export function runPostTransformPhase(args: RunPostTransformPhaseArgs): void {
351359
const t9 = performance.now();
352360
applyContextNudge(args.messages, nudge, args.nudgePlacements, args.sessionId);
353361
logTransformTiming(args.sessionId, "applyContextNudge", t9);
354-
} else {
362+
} else if (isCacheBustingPass) {
363+
// Only retire the nudge anchor on cache-busting passes (Finding 4).
364+
// Clearing on defer would remove previously-injected nudge text from
365+
// the cached assistant message.
355366
args.nudgePlacements.clear(args.sessionId);
367+
} else {
368+
// Defer pass: replay existing anchor to keep cached content stable.
369+
const existing = args.nudgePlacements.get(args.sessionId);
370+
if (existing) {
371+
reinjectNudgeAtAnchor(
372+
args.messages,
373+
existing.nudgeText,
374+
args.nudgePlacements,
375+
args.sessionId,
376+
);
377+
}
356378
}
357379

358380
const deferredNoteText = getNoteNudgeText(args.db, args.sessionId);

src/hooks/magic-context/transform.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,18 @@ export function createTransform(deps: TransformDeps) {
105105
const canRunCompartments =
106106
fullFeatureMode && deps.client !== undefined && compartmentDirectory.length > 0;
107107

108+
// Compute cache-busting status early so compartment injection can use it.
109+
// The scheduler and flush state are available before tagging.
110+
const contextUsageEarly = loadContextUsage(deps.contextUsageMap, db, sessionId);
111+
const schedulerDecisionEarly = resolveSchedulerDecision(
112+
deps.scheduler,
113+
sessionMeta,
114+
contextUsageEarly,
115+
sessionId,
116+
);
117+
const isCacheBusting =
118+
deps.flushedSessions.has(sessionId) || schedulerDecisionEarly === "execute";
119+
108120
let pendingCompartmentInjection: PreparedCompartmentInjection | null = null;
109121
if (fullFeatureMode) {
110122
const projectPath = deps.memoryConfig?.enabled
@@ -114,6 +126,7 @@ export function createTransform(deps: TransformDeps) {
114126
db,
115127
sessionId,
116128
messages,
129+
isCacheBusting,
117130
projectPath,
118131
deps.memoryConfig?.injectionBudgetTokens,
119132
);
@@ -165,9 +178,10 @@ export function createTransform(deps: TransformDeps) {
165178
logTransformTiming(sessionId, "batchFinalize:flushed", t2);
166179
} catch (error) {
167180
sessionLog(sessionId, "transform failed applying flushed statuses:", error);
168-
deps.nudgePlacements.clear(sessionId);
181+
// Only clear on cache-busting passes to avoid re-anchor on next defer (Finding 2).
182+
if (isCacheBusting) deps.nudgePlacements.clear(sessionId);
169183
}
170-
if (didMutateFromFlushedStatuses) {
184+
if (didMutateFromFlushedStatuses && isCacheBusting) {
171185
deps.nudgePlacements.clear(sessionId);
172186
}
173187

@@ -196,15 +210,9 @@ export function createTransform(deps: TransformDeps) {
196210
}
197211
}
198212

199-
const contextUsage = loadContextUsage(deps.contextUsageMap, db, sessionId);
200-
const schedulerDecision = resolveSchedulerDecision(
201-
deps.scheduler,
202-
sessionMeta,
203-
contextUsage,
204-
sessionId,
205-
);
206-
const isCacheBusting =
207-
deps.flushedSessions.has(sessionId) || schedulerDecision === "execute";
213+
// Reuse the early scheduler result — inputs haven't changed.
214+
const contextUsage = contextUsageEarly;
215+
const schedulerDecision = schedulerDecisionEarly;
208216
const rawGetNotifParams = deps.getNotificationParams;
209217
const compartmentPhase = await runCompartmentPhase({
210218
canRunCompartments,

0 commit comments

Comments
 (0)