← back to Kickbacks Ai Upstream
src/activation/desyncDetector.ts
123 lines
import * as vscode from "vscode";
import { dlog } from "../log";
import { desyncDecision } from "../reassert";
export interface DesyncState {
lastApplyAt: number;
lastBlockStartAt: number;
}
/** Wired by extension.ts. All optional so the detector degrades to a passive
* logger if a dep is missing (and keeps the old call shape working in tests).
* - ccActivityAgeMs: independent "is the user using Claude Code right now"
* signal (CC transcript mtime age). null/unknown => treated as idle.
* - healthy: shouldReassert() snapshot — never act when signed-out / no ad /
* kill-switched.
* - hardReassert: the cheap file-identity nudge (cyclePatch for the debug
* path, restore+applyPatch for the production path). Guarded; never throws. */
export interface DesyncDeps {
ccActivityAgeMs: () => number | null;
healthy: () => boolean;
hardReassert: () => void;
/** True while the CC panel is in an active tool_use turn (sub-agent
* running). Disruptive escalation (reload, toast) is deferred until
* the turn completes. null = unknown. */
ccTurnActive?: () => boolean | null;
}
export function setupDesyncDetector(
state: DesyncState,
timers: NodeJS.Timeout[],
deps?: DesyncDeps,
): void {
const startedAt = Date.now();
let cyclePatchTried = false;
let reloadTried = false;
let toastShownAt = 0;
let lastLoggedAt = 0;
timers.push(setInterval(() => {
try {
const now = Date.now();
const d = desyncDecision({
now,
startedAt,
lastApplyAt: state.lastApplyAt,
lastBlockStartAt: state.lastBlockStartAt,
ccActivityAgeMs: deps ? deps.ccActivityAgeMs() : null,
// No deps (legacy/test): stay passive — never escalate without a
// health snapshot to gate on.
healthy: deps ? deps.healthy() : false,
cyclePatchTried,
reloadTried,
toastShownAt,
ccTurnActive: deps?.ccTurnActive?.() ?? null,
});
// Overlay recovered (block.start observed since apply): re-arm the ladder.
if (d.reason === "in-sync") {
cyclePatchTried = false;
reloadTried = false;
return;
}
if (d.action === "none") return;
// Throttled desync log (observability) — at most once / 5 min.
if (now - lastLoggedAt >= 5 * 60_000) {
lastLoggedAt = now;
dlog("ext", "block.desync", {
action: d.action,
ageSinceApplyMs: now - state.lastApplyAt,
lastBlockStartAt: state.lastBlockStartAt,
note: "patched on disk but overlay telemetry silent while CC is "
+ "active — webview cached the pre-patch module.",
});
}
if (d.action === "cycle") {
// Tier 1: cheapest disruption — re-mint the patch so the file's
// identity changes and VS Code re-evaluates the module. No reload.
cyclePatchTried = true;
dlog("ext", "block.desync.cycle", {});
try { deps?.hardReassert(); } catch { /* prime directive */ }
return;
}
if (d.action === "reload") {
// Tier 2: reload the focused webview's content (no window churn).
reloadTried = true;
dlog("ext", "block.desync.autoReload", {
note: "reloadWebviewContent targets the focused webview; "
+ "if the CC panel isn't focused this may be a no-op",
});
void vscode.commands.executeCommand(
"workbench.action.webview.reloadWebviewContent")
.then(
() => dlog("ext", "block.desync.autoReload.dispatched", {}),
() => dlog("ext", "block.desync.autoReload.fail", {}),
);
return;
}
if (d.action === "toast") {
// Tier 3 (last resort, user-consented): a full window reload.
toastShownAt = now;
void (async () => {
try {
const choice = await vscode.window.showWarningMessage?.(
"Kickbacks: ads aren't loading in the Claude Code panel. "
+ "Automatic heals didn't clear the cache. "
+ "A full window reload will fix it.",
"Reload Window", "Dismiss");
if (choice === "Reload Window") {
await vscode.commands.executeCommand(
"workbench.action.reloadWindow");
}
} catch { /* toast is best-effort */ }
})();
return;
}
} catch { /* prime directive */ }
}, 30_000));
}