diff --git a/src/compress-tool.ts b/src/compress-tool.ts index 8ce0b84..542e117 100644 --- a/src/compress-tool.ts +++ b/src/compress-tool.ts @@ -341,7 +341,7 @@ async function handleCompress(args: CompressArgs, runtime: AcpRuntime, ctx: Exte }); const turnKey = lastUserMessageId(entries) ?? sid; const snapshot = compressibleSnapshotText(turn.nudge); - if (runtime.compressRetryCappedFor(turnKey)) { + if (runtime.compressRetryCappedFor(sid, turnKey)) { logWarn("compress", { sid, event: "capped-reject", turnKey }); return cappedRejectionText(snapshot); } diff --git a/src/index.ts b/src/index.ts index 60e9fa3..3fe4c96 100644 --- a/src/index.ts +++ b/src/index.ts @@ -177,9 +177,9 @@ function wireSessionLifecycle(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf } if (standDownIfProxied(ctx)) return; runtime.store.invalidate(); - runtime.clearNudgeTracking(); + runtime.clearNudgeTracking(ctx.sessionManager.getSessionId()); runtime.throttleFor(ctx.sessionManager.getSessionId()).reset(); - runtime.clearCompressRetryTracking(); + runtime.clearCompressRetryTracking(ctx.sessionManager.getSessionId()); resetDelegateUsage(); setDelegateDisplayUsage("separate"); setDelegatePolicy(DEFAULT_DELEGATE_POLICY); @@ -233,8 +233,11 @@ function wireSessionLifecycle(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf delegateStatusWidget.setContext(ctx, runningRunsSnapshot); }); pi.on("session_shutdown", (_event, ctx) => { - runtime.clearDeadCompress(ctx.sessionManager.getSessionId()); - runtime.dropTokenScale(ctx.sessionManager.getSessionId()); + const sid = ctx.sessionManager.getSessionId(); + runtime.clearDeadCompress(sid); + runtime.dropTokenScale(sid); + runtime.clearNudgeTracking(sid); + runtime.clearCompressRetryTracking(sid); delegateStatusWidget.dispose(); closeLogStream(); }); @@ -353,7 +356,7 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf state.nudge.lastNudgeShownTokens = 0; state.nudge.lastPerMessageNudgeTokens = 0; state.nudge.lastShownByTier = {}; - runtime.clearNudgeTokenStamps(); + runtime.clearNudgeTokenStamps(sid); logInfo("growth-scale", { sid, event: "scale-flip-reset", anchorStale: !hostFloorActive }); } debug.event("context-in", { @@ -474,7 +477,7 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf // the cap suppression sees the newest outcome (a success on this fire // must lift the cap on this same fire). const compressOutcomes = collectCompressOutcomes(entries, turnStartIndex(entries)); - const outcome = compressOutcomes.length > 0 ? runtime.noteCompressOutcomes(turnKey, compressOutcomes) : null; + const outcome = compressOutcomes.length > 0 ? runtime.noteCompressOutcomes(sid, turnKey, compressOutcomes) : null; // Growth-aware re-inject bookkeeping (issue #269) runs on EVERY context // event, not only when the kernel wants to inject: the drop re-anchor @@ -493,14 +496,14 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf Math.max(config.nudge.growthFloor, Math.round(config.modelContextLimit * config.nudge.growthRatio)), ); const reInjectFloor = Math.max(config.nudge.minGrowthFloor, config.nudge.minGrowthRatio * adaptiveGrowth); - let shownAt = runtime.nudgeShownTokensFor(turnKey); + let shownAt = runtime.nudgeShownTokensFor(sid, turnKey); if (shownAt !== undefined && tokenCount < shownAt - adaptiveGrowth) { // Mirror the kernel's drop re-anchor (nudgeNode): after a successful // compress the meter collapses; growth since the last shown must // restart from the new baseline, not from the old peak. logInfo("nudge", { sid: ctx.sessionManager.getSessionId(), event: "drop-reanchor", turnKey, from: shownAt, to: tokenCount }); shownAt = tokenCount; - runtime.markNudgeShown(turnKey, tokenCount); + runtime.markNudgeShown(sid, turnKey, tokenCount); } if (turn.nudge?.shouldInject) { @@ -538,9 +541,9 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf // keeps usage pinned at emergency). Once this turn burned // MAX_COMPRESS_ATTEMPTS attempts, stop re-injecting the nudge — the // kernel's emergency truncation still shrinks context mechanically. - const retryCapped = runtime.compressRetryCappedFor(turnKey); + const retryCapped = runtime.compressRetryCappedFor(sid, turnKey); const reInjectReady = shownAt === undefined || tokenCount - shownAt >= reInjectFloor; - const alreadyShown = retryCapped || (!emergency && runtime.nudgeShownFor(turnKey) && !reInjectReady); + const alreadyShown = retryCapped || (!emergency && runtime.nudgeShownFor(sid, turnKey) && !reInjectReady); if (!alreadyShown) { rebuilt.push(nudgeMessage(turn.nudge, turn.state.blocks.filter((b) => b.active), runtime.prompts)); const rendered = renderNudgeText(turn.nudge, runtime.prompts); @@ -552,7 +555,7 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf if (debugOn && ctx.hasUI) { ctx.ui.notify(`[ACP nudge → context]${emergency ? " [EMERGENCY]" : ""}\n${rendered.text}${example}`); } - if (!emergency) runtime.markNudgeShown(turnKey, tokenCount); + if (!emergency) runtime.markNudgeShown(sid, turnKey, tokenCount); debug.event("nudge-injected", { sid: ctx.sessionManager.getSessionId(), voice: rendered.voice, channels: ["context", debugOn ? "terminal" : null].filter(Boolean), emergency, turnKey, reInject: shownAt !== undefined, text: rendered.text + example }); } else { debug.event("nudge-suppressed", { sid: ctx.sessionManager.getSessionId(), turnKey, reason: turn.nudge.reason, shownAt: shownAt ?? null, tokenCount, adaptiveGrowth, reInjectFloor }); diff --git a/src/runtime.ts b/src/runtime.ts index f70bc24..ff44632 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -72,25 +72,25 @@ export interface AcpRuntime { setAdapter(adapter: AdapterConfig): void; prompts: Prompts; setPrompts(prompts: Prompts): void; - markNudgeShown(turnKey: string, tokenCount?: number): void; - nudgeShownFor(turnKey: string): boolean; + markNudgeShown(sid: string, turnKey: string, tokenCount?: number): void; + nudgeShownFor(sid: string, turnKey: string): boolean; /** tokenCount at the last actual nudge injection for this turn, for growth-aware re-inject (issue #269). */ - nudgeShownTokensFor(turnKey: string): number | undefined; + nudgeShownTokensFor(sid: string, turnKey: string): number | undefined; /** Clears the token-count stamps recorded by markNudgeShown — used on a token-scale flip (issue #267) so the same-turn re-inject floor (#269 / PR #316) is not computed against an old-scale stamp. */ - clearNudgeTokenStamps(): void; + clearNudgeTokenStamps(sid: string): void; /** Process compress toolResults for the CURRENT user turn only (the caller * scopes the list — see collectCompressOutcomes in src/index.ts); idempotent * per toolCallId. Outcome classes: isError or noop (0-block panel) → * failure (count++), success panel (>= 1 block) → reset, other non-error * text → neutral (count unchanged). Returns the failure count and * whether the cap was just reached. */ - noteCompressOutcomes(turnKey: string, outcomes: ReadonlyArray<{ toolCallId: string; isError: boolean; success: boolean; noop?: boolean }>): { count: number; cappedNow: boolean }; + noteCompressOutcomes(sid: string, turnKey: string, outcomes: ReadonlyArray<{ toolCallId: string; isError: boolean; success: boolean; noop?: boolean }>): { count: number; cappedNow: boolean }; /** True when this turn already burned MAX_COMPRESS_ATTEMPTS failed/no-op * compress calls — used to stop re-injecting the (dedup-exempt) emergency * nudge that would otherwise keep looping no-op compressions (issue #6). */ - compressRetryCappedFor(turnKey: string): boolean; - clearNudgeTracking(): void; - clearCompressRetryTracking(): void; + compressRetryCappedFor(sid: string, turnKey: string): boolean; + clearNudgeTracking(sid: string): void; + clearCompressRetryTracking(sid: string): void; liveContextLimit(ctx: ExtensionContext): number; configFor(ctx: ExtensionContext): Config; /** [#336] Effective compress.reasoning drop settings for the active model @@ -272,8 +272,31 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime { let adapterRef = adapter; let lastUserConfigKey: string | undefined; let promptsRef: Prompts = defaultPrompts; - const nudgeShownTurns = new Set(); - const nudgeShownTokens = new Map(); + const nudgeShownTurns = new Map>(); + const nudgeShownTokens = new Map>(); + function markNudgeShown(sid: string, turnKey: string, tokenCount?: number): void { + let turns = nudgeShownTurns.get(sid); + if (!turns) { turns = new Set(); nudgeShownTurns.set(sid, turns); } + turns.add(turnKey); + if (tokenCount !== undefined) { + let toks = nudgeShownTokens.get(sid); + if (!toks) { toks = new Map(); nudgeShownTokens.set(sid, toks); } + toks.set(turnKey, tokenCount); + } + } + function nudgeShownFor(sid: string, turnKey: string): boolean { + return nudgeShownTurns.get(sid)?.has(turnKey) ?? false; + } + function nudgeShownTokensFor(sid: string, turnKey: string): number | undefined { + return nudgeShownTokens.get(sid)?.get(turnKey); + } + function clearNudgeTracking(sid: string): void { + nudgeShownTurns.delete(sid); + nudgeShownTokens.delete(sid); + } + function clearNudgeTokenStamps(sid: string): void { + nudgeShownTokens.delete(sid); + } // Per-session overflow self-heal state (learned window + armed emergency). const overflowEpisodes = new Map(); function overflowFor(sid: string): OverflowEpisode { @@ -331,38 +354,46 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime { // caller feeds only CURRENT-turn outcomes; success resets the counter, // neutral outcomes (non-error text that is not a success panel) leave it // frozen so mixed failure modes cannot bypass the cap. - const compressOutcomeSeen = new Set(); - let compressFailTurnKey: string | null = null; - let compressFailCount = 0; - - function noteCompressOutcomes(turnKey: string, outcomes: ReadonlyArray<{ toolCallId: string; isError: boolean; success: boolean; noop?: boolean }>): { count: number; cappedNow: boolean } { - if (compressFailTurnKey !== turnKey) { - compressFailTurnKey = turnKey; - compressFailCount = 0; + interface CompressOutcomeTracker { + seen: Set; + failTurnKey: string | null; + failCount: number; + } + const compressOutcomes = new Map(); + function compressTrackerFor(sid: string): CompressOutcomeTracker { + let t = compressOutcomes.get(sid); + if (!t) { t = { seen: new Set(), failTurnKey: null, failCount: 0 }; compressOutcomes.set(sid, t); } + return t; + } + + function noteCompressOutcomes(sid: string, turnKey: string, outcomes: ReadonlyArray<{ toolCallId: string; isError: boolean; success: boolean; noop?: boolean }>): { count: number; cappedNow: boolean } { + const t = compressTrackerFor(sid); + if (t.failTurnKey !== turnKey) { + t.failTurnKey = turnKey; + t.failCount = 0; } - const prevCount = compressFailCount; + const prevCount = t.failCount; for (const o of outcomes) { - if (compressOutcomeSeen.has(o.toolCallId)) continue; - compressOutcomeSeen.add(o.toolCallId); + if (t.seen.has(o.toolCallId)) continue; + t.seen.add(o.toolCallId); if (o.isError || o.noop === true) { - compressFailCount += 1; + t.failCount += 1; } else if (o.success) { - compressFailCount = 0; + t.failCount = 0; } // neutral: counter untouched } - const cappedNow = compressFailCount >= MAX_COMPRESS_ATTEMPTS && prevCount < MAX_COMPRESS_ATTEMPTS; - return { count: compressFailCount, cappedNow }; + const cappedNow = t.failCount >= MAX_COMPRESS_ATTEMPTS && prevCount < MAX_COMPRESS_ATTEMPTS; + return { count: t.failCount, cappedNow }; } - function compressRetryCappedFor(turnKey: string): boolean { - return compressFailTurnKey === turnKey && compressFailCount >= MAX_COMPRESS_ATTEMPTS; + function compressRetryCappedFor(sid: string, turnKey: string): boolean { + const t = compressOutcomes.get(sid); + return t !== undefined && t.failTurnKey === turnKey && t.failCount >= MAX_COMPRESS_ATTEMPTS; } - function clearCompressRetryTracking(): void { - compressOutcomeSeen.clear(); - compressFailTurnKey = null; - compressFailCount = 0; + function clearCompressRetryTracking(sid: string): void { + compressOutcomes.delete(sid); } async function acquireLock(sid: string): Promise<() => void> { @@ -467,4 +498,4 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime { let refused = false; let refusalMessage: string | null = null; - return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get refusalMessage() { return refusalMessage; }, set refusalMessage(v: string | null) { refusalMessage = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k, t) => { nudgeShownTurns.add(k); if (t !== undefined) nudgeShownTokens.set(k, t); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), nudgeShownTokensFor: (k) => nudgeShownTokens.get(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); nudgeShownTokens.clear(); }, clearNudgeTokenStamps: () => nudgeShownTokens.clear(), noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reasoningDropFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop , noteTokenScale, dropTokenScale };} + return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get refusalMessage() { return refusalMessage; }, set refusalMessage(v: string | null) { refusalMessage = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown, nudgeShownFor, nudgeShownTokensFor, clearNudgeTracking, clearNudgeTokenStamps, noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reasoningDropFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop , noteTokenScale, dropTokenScale };} diff --git a/tests/compress-retry.test.ts b/tests/compress-retry.test.ts index b507e93..928a51b 100644 --- a/tests/compress-retry.test.ts +++ b/tests/compress-retry.test.ts @@ -95,47 +95,47 @@ test("noteCompressOutcomes: counts, caps, resets on success, resets per turn, ne const success = (id: string) => ({ toolCallId: id, isError: false, success: true }); const neutral = (id: string) => ({ toolCallId: id, isError: false, success: false }); - let r = rt.noteCompressOutcomes("u1", [fail("t0")]); + let r = rt.noteCompressOutcomes("s", "u1", [fail("t0")]); assert.equal(r.count, 1); assert.equal(r.cappedNow, false); // idempotent re-fire (same toolCallIds): count frozen - r = rt.noteCompressOutcomes("u1", [fail("t0")]); + r = rt.noteCompressOutcomes("s", "u1", [fail("t0")]); assert.equal(r.count, 1, "no double count on re-fire"); // neutral outcome: no reset - r = rt.noteCompressOutcomes("u1", [fail("t0"), neutral("n1")]); + r = rt.noteCompressOutcomes("s", "u1", [fail("t0"), neutral("n1")]); assert.equal(r.count, 1, "neutral does not reset the counter"); // a NEW failure after a neutral one: attempt 2, not 1 — neutral cannot // bypass the cap by resetting between failures - r = rt.noteCompressOutcomes("u1", [fail("t0"), neutral("n1"), fail("t9")]); + r = rt.noteCompressOutcomes("s", "u1", [fail("t0"), neutral("n1"), fail("t9")]); assert.equal(r.count, 2); // third distinct failure → cap, cappedNow fires once - r = rt.noteCompressOutcomes("u1", [fail("t0"), neutral("n1"), fail("t9"), fail("tc")]); + r = rt.noteCompressOutcomes("s", "u1", [fail("t0"), neutral("n1"), fail("t9"), fail("tc")]); assert.equal(r.count, 3); assert.equal(r.cappedNow, true); - r = rt.noteCompressOutcomes("u1", [fail("t0"), neutral("n1"), fail("t9"), fail("tc")]); + r = rt.noteCompressOutcomes("s", "u1", [fail("t0"), neutral("n1"), fail("t9"), fail("tc")]); assert.equal(r.cappedNow, false, "cap notification is one-shot"); assert.equal(MAX_COMPRESS_ATTEMPTS, 3); // success resets the counter - r = rt.noteCompressOutcomes("u1", [fail("t0"), neutral("n1"), fail("t9"), fail("tc"), success("ts")]); + r = rt.noteCompressOutcomes("s", "u1", [fail("t0"), neutral("n1"), fail("t9"), fail("tc"), success("ts")]); assert.equal(r.count, 0); // a NEW failure after success counts a fresh cycle - r = rt.noteCompressOutcomes("u1", [fail("t0"), neutral("n1"), fail("t9"), fail("tc"), success("ts"), fail("td")]); + r = rt.noteCompressOutcomes("s", "u1", [fail("t0"), neutral("n1"), fail("t9"), fail("tc"), success("ts"), fail("td")]); assert.equal(r.count, 1); // new user turn → fresh counter even without a success in between - r = rt.noteCompressOutcomes("u1", [fail("t0"), neutral("n1"), fail("t9"), fail("tc"), success("ts"), fail("td"), fail("te"), fail("tf")]); + r = rt.noteCompressOutcomes("s", "u1", [fail("t0"), neutral("n1"), fail("t9"), fail("tc"), success("ts"), fail("td"), fail("te"), fail("tf")]); assert.equal(r.count, 3, "back at cap"); - r = rt.noteCompressOutcomes("u2", [fail("x0")]); + r = rt.noteCompressOutcomes("s", "u2", [fail("x0")]); assert.equal(r.count, 1); // a deduped stale failure must not count against a new turn - r = rt.noteCompressOutcomes("u3", [fail("x0")]); + r = rt.noteCompressOutcomes("s", "u3", [fail("x0")]); assert.equal(r.count, 0, "stale id deduped, count stays 0 after turn change"); }); @@ -280,22 +280,22 @@ test("noteCompressOutcomes: no-op panels advance the counter toward the cap", () const rt = createRuntime({}); const noop = (id: string) => ({ toolCallId: id, isError: false, success: false, noop: true }); - let r = rt.noteCompressOutcomes("u1", [noop("t0")]); + let r = rt.noteCompressOutcomes("s", "u1", [noop("t0")]); assert.equal(r.count, 1); - r = rt.noteCompressOutcomes("u1", [noop("t0"), noop("t1")]); + r = rt.noteCompressOutcomes("s", "u1", [noop("t0"), noop("t1")]); assert.equal(r.count, 2); - r = rt.noteCompressOutcomes("u1", [noop("t0"), noop("t1"), noop("t2")]); + r = rt.noteCompressOutcomes("s", "u1", [noop("t0"), noop("t1"), noop("t2")]); assert.equal(r.count, 3); assert.equal(r.cappedNow, true); - assert.equal(rt.compressRetryCappedFor("u1"), true, "capped state is queryable per turn"); - assert.equal(rt.compressRetryCappedFor("u2"), false, "other turns are unaffected"); + assert.equal(rt.compressRetryCappedFor("s", "u1"), true, "capped state is queryable per turn"); + assert.equal(rt.compressRetryCappedFor("s", "u2"), false, "other turns are unaffected"); const success = (id: string) => ({ toolCallId: id, isError: false, success: true, noop: false }); - r = rt.noteCompressOutcomes("u1", [noop("t0"), noop("t1"), noop("t2"), success("ts")]); + r = rt.noteCompressOutcomes("s", "u1", [noop("t0"), noop("t1"), noop("t2"), success("ts")]); assert.equal(r.count, 0, "genuine success lifts the cap"); - assert.equal(rt.compressRetryCappedFor("u1"), false); + assert.equal(rt.compressRetryCappedFor("s", "u1"), false); }); test("emergency nudge stops re-injecting once the turn's cap is burned (issue #6 loop breaker)", async () => { @@ -336,3 +336,39 @@ test("emergency nudge stops re-injecting once the turn's cap is burned (issue #6 assert.ok(nudgeCount(rRe) >= 1, "after a successful compress the emergency nudge may resume"); await rm(`${stateFile}.acp.json`, { force: true }); }); + +test("noteCompressOutcomes: concurrent sessions keep independent counters on the same turn (#317)", () => { + const rt = createRuntime({}); + const fail = (id: string) => ({ toolCallId: id, isError: true, success: false }); + + assert.equal(rt.noteCompressOutcomes("A", "u1", [fail("a0")]).count, 1); + assert.equal(rt.noteCompressOutcomes("A", "u1", [fail("a0"), fail("a1")]).count, 2); + assert.equal(rt.noteCompressOutcomes("B", "u1", [fail("b0")]).count, 1, "B has its own counter"); + + const ra = rt.noteCompressOutcomes("A", "u1", [fail("a0"), fail("a1"), fail("a2")]); + assert.equal(ra.count, 3, "A reaches its own cap"); + assert.equal(rt.compressRetryCappedFor("A", "u1"), true, "A capped independently"); + assert.equal(rt.compressRetryCappedFor("B", "u1"), false, "B not capped by A's failures"); +}); + +test("clearCompressRetryTracking: drops one session only; siblings survive (#317)", () => { + const rt = createRuntime({}); + const fail = (id: string) => ({ toolCallId: id, isError: true, success: false }); + rt.noteCompressOutcomes("A", "u1", [fail("a0"), fail("a1"), fail("a2")]); + rt.noteCompressOutcomes("B", "u1", [fail("b0")]); + rt.clearCompressRetryTracking("A"); + assert.equal(rt.compressRetryCappedFor("A", "u1"), false, "A cleared"); + assert.equal(rt.noteCompressOutcomes("B", "u1", [fail("b0"), fail("b1")]).count, 2, "B unaffected by A's clear"); +}); + +test("nudge ledger: per-session mark/query/clear isolation (#317)", () => { + const rt = createRuntime({}); + rt.markNudgeShown("A", "u1"); + assert.equal(rt.nudgeShownFor("A", "u1"), true); + assert.equal(rt.nudgeShownFor("B", "u1"), false, "B unaffected by A's nudge record"); + rt.clearNudgeTracking("A"); + assert.equal(rt.nudgeShownFor("A", "u1"), false, "A cleared"); + rt.markNudgeShown("A", "u2"); + assert.equal(rt.nudgeShownFor("A", "u2"), true); + assert.equal(rt.nudgeShownFor("B", "u2"), false); +});