Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,10 @@ This plugin captures session activity and stores it in your Deeplake workspace:
| `HIVEMIND_CAPTURE` | `true` | Set to `false` to disable capture |
| `HIVEMIND_CAPTURE_ONLY_CLI` | _(none)_ | Set to `true` to capture only interactive CLI sessions. Sessions spawned by the Claude Agent SDK (Python/TypeScript) are skipped; their `CLAUDE_CODE_ENTRYPOINT` is `sdk-py` / `sdk-ts`, so they fail the substring check for `cli`. |
| `HIVEMIND_SKILLIFY_EVERY_N_TURNS` | `20` | Assistant turns between auto skill-mining attempts. Lower = more frequent mining (cheaper sessions, noisier output); higher = fewer attempts on longer histories. |
| `HIVEMIND_SUMMARY_EVERY_N_MSGS` | `50` | Captured events between periodic session summaries. The first summary of a session runs at 10 events regardless. Raise it to cut background summary runs. |
| `HIVEMIND_SUMMARY_EVERY_HOURS` | `2` | Time-based summary cadence, used when at least one new event has arrived since the last summary. |
| `HIVEMIND_WIKI_WORKER` | _(none)_ | Set to `1` to disable the background session-summary worker entirely (no `claude -p` summary runs). Also set automatically inside the worker as a recursion guard. Capture and recall keep working. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| `HIVEMIND_GRAPH_ON_STOP` | _(none)_ | Set to `0` to disable the code-graph rebuild that runs on `Stop` / `SessionEnd`. |
| `HIVEMIND_EMBEDDINGS` | `true` | Set to `false` to force lexical-only mode |
| `HIVEMIND_PROACTIVE_RECALL_DISABLED` | _(none)_ | Set to `1` to disable **proactive recall** (auto-searching team memory on each recall-worthy prompt and injecting a relevant snippet into the agent's context). On by default. Does **not** affect capture or the agent's own grep/skill recall. Alt form: `HIVEMIND_PROACTIVE_RECALL=0`. |
| `HIVEMIND_RECALL_MIN_OVERLAP` | `2` | Proactive recall (lexical mode): min distinct prompt keywords a summary must share to be injected. Higher = stricter. |
Expand Down
86 changes: 80 additions & 6 deletions harnesses/pi/extension-source/hivemind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,13 @@ interface SummaryState {
lastSummaryAt: number;
lastSummaryCount: number;
totalCount: number;
// Mirror of src/hooks/summary-state.ts. A run that fails never calls
// finalizeSummary, so without these the first-summary clause below stays
// true forever and refires on every captured event (issue #331). They must
// also survive a read/write round-trip here, because this file rewrites the
// whole state object that the shared worker finalizes against.
lastAttemptAt?: number;
attemptsSinceSuccess?: number;
}
interface SummaryConfig {
everyNMessages: number;
Expand Down Expand Up @@ -688,15 +695,28 @@ function readSummaryState(sessionId: string): SummaryState | null {
lastSummaryAt: Number(raw.lastSummaryAt) || 0,
lastSummaryCount: Number(raw.lastSummaryCount) || 0,
totalCount: Number(raw.totalCount) || 0,
lastAttemptAt: Number(raw.lastAttemptAt) || 0,
attemptsSinceSuccess: Number(raw.attemptsSinceSuccess) || 0,
};
} catch { return null; }
}

function writeSummaryState(sessionId: string, state: SummaryState): void {
/**
* Returns false when the state could not be persisted. Most callers treat a
* failed write as non-fatal, but the #331 attempt stamp must fail closed: an
* unpersisted stamp means the next captured event refires immediately, which
* is the bug the back-off exists to prevent.
*/
function writeSummaryState(sessionId: string, state: SummaryState): boolean {
try {
mkdirSync(SUMMARY_STATE_DIR, { recursive: true });
writeFileSync(summaryStatePath(sessionId), JSON.stringify(state));
} catch { /* non-fatal */ }
return true;
} catch { return false; }
}

function releaseSummaryLock(sessionId: string): void {
try { unlinkSync(summaryLockPath(sessionId)); } catch { /* already gone */ }
}

function bumpCounter(sessionId: string): SummaryState {
Expand All @@ -706,8 +726,18 @@ function bumpCounter(sessionId: string): SummaryState {
return cur;
}

function shouldTriggerNow(state: SummaryState, cfg: SummaryConfig): boolean {
/** Mirror of retryBackoffMs in src/hooks/summary-state.ts: 1..30 minutes. */
function retryBackoffMs(attemptsSinceSuccess: number): number {
if (attemptsSinceSuccess <= 0) return 0;
return Math.min(2 ** (attemptsSinceSuccess - 1), 30) * 60 * 1000;
}

function shouldTriggerNow(state: SummaryState, cfg: SummaryConfig, now = Date.now()): boolean {
const msgsSince = state.totalCount - state.lastSummaryCount;
// A run started but never finalized — it failed. Hold off instead of
// respawning the worker on the next captured event (issue #331).
const attempts = state.attemptsSinceSuccess ?? 0;
if (attempts > 0 && now - (state.lastAttemptAt ?? 0) < retryBackoffMs(attempts)) return false;
// First-chat trigger: index a fresh session quickly (10 events) instead of
// waiting until N=50. Mirrors summary-state.ts in CC/codex.
if (state.lastSummaryCount === 0 && state.totalCount >= FIRST_SUMMARY_AT) return true;
Expand Down Expand Up @@ -796,6 +826,15 @@ function spawnWikiWorker(
cwd: string,
reason: "periodic" | "final",
): void {
// Documented off switch for background summaries (README Configuration):
// it must cover the FINAL session-shutdown summary too, not just the
// periodic one, or "disables the background session-summary worker
// entirely" is false. Doubles as the recursion guard the worker sets on
// its own children.
if (process.env.HIVEMIND_WIKI_WORKER === "1") {
logHm(`spawnWikiWorker(${reason}): HIVEMIND_WIKI_WORKER=1 — background summaries disabled`);
return;
}
if (!existsSync(PI_WIKI_WORKER_PATH)) {
logHm(`spawnWikiWorker(${reason}): no worker at ${PI_WIKI_WORKER_PATH} — install via 'hivemind pi install' or rebuild`);
return;
Expand All @@ -807,6 +846,25 @@ function spawnWikiWorker(
logHm(`spawnWikiWorker(${reason}): lock held — skipping (a worker is already running)`);
return;
}
// Stamp the attempt AFTER winning the lock, against freshly-read state
// (issue #331). Stamping in the caller — before the lock, using the state
// object read at trigger time — could overwrite a concurrent worker's
// successful finalization with stale counters, and would record a
// lock-suppressed non-spawn as a failed attempt. Periodic only: a final
// summary at session shutdown gets no back-off, matching summary-state.ts.
if (reason === "periodic") {
const fresh = readSummaryState(sessionId);
const stamped = fresh !== null && writeSummaryState(sessionId, {
...fresh,
lastAttemptAt: Date.now(),
attemptsSinceSuccess: (fresh.attemptsSinceSuccess ?? 0) + 1,
});
if (!stamped) {
logHm(`spawnWikiWorker(${reason}): could not persist the attempt stamp — releasing the lock and skipping (a spawn we cannot record would refire on every event)`);
releaseSummaryLock(sessionId);
return;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// tmp dir owned by the worker; it removes it on completion.
const tmpDir = join(tmpdir(), `deeplake-wiki-${sessionId}-${Date.now()}`);
try { mkdirSync(tmpDir, { recursive: true }); } catch { /* ignore */ }
Expand All @@ -832,16 +890,32 @@ function spawnWikiWorker(
promptTemplate: WIKI_PROMPT_TEMPLATE,
};
try { writeFileSync(configPath, JSON.stringify(config)); }
catch (e: any) { logHm(`spawnWikiWorker(${reason}): writeFileSync failed: ${e?.message ?? e}`); return; }
catch (e: any) {
// Release: pi's tryAcquireSummaryLock has no stale reclaim, so a lock
// left behind here is held for the rest of the session and every later
// periodic AND final spawn is skipped.
logHm(`spawnWikiWorker(${reason}): writeFileSync failed: ${e?.message ?? e}`);
releaseSummaryLock(sessionId);
return;
}
logHm(`spawnWikiWorker(${reason}): spawning ${PI_WIKI_WORKER_PATH} session=${sessionId} provider=${config.piProvider} model=${config.piModel}`);
try {
spawn(process.execPath, [PI_WIKI_WORKER_PATH, configPath], {
const child = spawn(process.execPath, [PI_WIKI_WORKER_PATH, configPath], {
detached: true,
stdio: "ignore",
env: { ...process.env, HIVEMIND_WIKI_WORKER: "1", HIVEMIND_CAPTURE: "false" },
}).unref();
});
// ENOENT / EPERM arrive as an ASYNC 'error' event, never a sync throw —
// without this listener the worker never runs AND the lock is never
// released, so the session stops summarizing silently.
child.on("error", (err: any) => {
logHm(`spawnWikiWorker(${reason}): spawn errored: ${err?.message ?? err}`);
releaseSummaryLock(sessionId);
});
child.unref();
} catch (e: any) {
logHm(`spawnWikiWorker(${reason}): spawn failed: ${e?.message ?? e}`);
releaseSummaryLock(sessionId);
}
}

Expand Down
7 changes: 7 additions & 0 deletions src/hooks/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
loadTriggerConfig,
shouldTrigger,
tryAcquireLock,
markSummaryAttempt,
releaseLock,
ensureSessionOwner,
} from "./summary-state.js";
Expand Down Expand Up @@ -273,6 +274,12 @@ function maybeTriggerPeriodicSummary(sessionId: string, cwd: string, config: Con

wikiLog(`Periodic: threshold hit (total=${state.totalCount}, since=${state.totalCount - state.lastSummaryCount}, N=${cfg.everyNMessages}, hours=${cfg.everyHours})`);
try {
// Stamp the attempt BEFORE spawning: a run that fails never reaches
// finalizeSummary, and without this the trigger would refire on the
// very next captured event (issue #331). Inside the try so a failed
// state write releases the lock instead of leaking it until the
// 10-minute stale reclaim.
markSummaryAttempt(sessionId);
spawnWikiWorker({
config,
sessionId,
Expand Down
7 changes: 7 additions & 0 deletions src/hooks/codex/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
loadTriggerConfig,
shouldTrigger,
tryAcquireLock,
markSummaryAttempt,
releaseLock,
} from "../summary-state.js";
import { bundleDirFromImportMeta, spawnCodexWikiWorker, wikiLog } from "./spawn-wiki-worker.js";
Expand Down Expand Up @@ -200,6 +201,12 @@ function maybeTriggerPeriodicSummary(sessionId: string, cwd: string, config: Con

wikiLog(`Periodic: threshold hit (total=${state.totalCount}, since=${state.totalCount - state.lastSummaryCount}, N=${cfg.everyNMessages}, hours=${cfg.everyHours})`);
try {
// Stamp the attempt BEFORE spawning: a run that fails never reaches
// finalizeSummary, and without this the trigger would refire on the
// very next captured event (issue #331). Inside the try so a failed
// state write releases the lock instead of leaking it until the
// 10-minute stale reclaim.
markSummaryAttempt(sessionId);
spawnCodexWikiWorker({
config,
sessionId,
Expand Down
7 changes: 7 additions & 0 deletions src/hooks/cursor/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
loadTriggerConfig,
shouldTrigger,
tryAcquireLock,
markSummaryAttempt,
releaseLock,
} from "../summary-state.js";
import { bundleDirFromImportMeta, spawnCursorWikiWorker, wikiLog } from "./spawn-wiki-worker.js";
Expand Down Expand Up @@ -231,6 +232,12 @@ function maybeTriggerPeriodicSummary(sessionId: string, cwd: string, config: Con
}
wikiLog(`Periodic: threshold hit (total=${state.totalCount}, since=${state.totalCount - state.lastSummaryCount}, N=${cfg.everyNMessages}, hours=${cfg.everyHours})`);
try {
// Stamp the attempt BEFORE spawning: a run that fails never reaches
// finalizeSummary, and without this the trigger would refire on the
// very next captured event (issue #331). Inside the try so a failed
// state write releases the lock instead of leaking it until the
// 10-minute stale reclaim.
markSummaryAttempt(sessionId);
spawnCursorWikiWorker({
config,
sessionId,
Expand Down
7 changes: 7 additions & 0 deletions src/hooks/hermes/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
loadTriggerConfig,
shouldTrigger,
tryAcquireLock,
markSummaryAttempt,
releaseLock,
} from "../summary-state.js";
import { bundleDirFromImportMeta, spawnHermesWikiWorker, wikiLog } from "./spawn-wiki-worker.js";
Expand Down Expand Up @@ -226,6 +227,12 @@ function maybeTriggerPeriodicSummary(sessionId: string, cwd: string, config: Con
}
wikiLog(`Periodic: threshold hit (total=${state.totalCount}, since=${state.totalCount - state.lastSummaryCount}, N=${cfg.everyNMessages}, hours=${cfg.everyHours})`);
try {
// Stamp the attempt BEFORE spawning: a run that fails never reaches
// finalizeSummary, and without this the trigger would refire on the
// very next captured event (issue #331). Inside the try so a failed
// state write releases the lock instead of leaking it until the
// 10-minute stale reclaim.
markSummaryAttempt(sessionId);
spawnHermesWikiWorker({
config,
sessionId,
Expand Down
53 changes: 53 additions & 0 deletions src/hooks/summary-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ export interface SummaryState {
lastSummaryAt: number;
lastSummaryCount: number;
totalCount: number;
/**
* Epoch ms of the last summary run we *started*, successful or not.
* Distinct from lastSummaryAt, which only advances on success.
*/
lastAttemptAt?: number;
/**
* Summary runs started since the last successful one. Drives the retry
* back-off in shouldTrigger; reset to 0 by finalizeSummary.
*/
attemptsSinceSuccess?: number;
}

const STATE_DIR = join(homedir(), ".claude", "hooks", "summary-state");
Expand Down Expand Up @@ -291,10 +301,48 @@ export function finalizeSummary(sessionId: string, jsonlLines: number): void {
lastSummaryAt: Date.now(),
lastSummaryCount: jsonlLines,
totalCount: Math.max(prev?.totalCount ?? 0, jsonlLines),
lastAttemptAt: prev?.lastAttemptAt,
attemptsSinceSuccess: 0,
});
});
}

/**
* Record that a summary run is being STARTED. Called by the periodic trigger
* immediately after it wins the spawn lock, before the worker is spawned.
*
* Without this, a summary run that fails (claude bin unresolvable, non-zero
* exit, no summary file produced) never advances any counter: the worker
* returns early without calling finalizeSummary and releases the lock in its
* `finally`, so `lastSummaryCount` stays 0 and shouldTrigger's
* first-summary clause keeps firing on EVERY captured event. The observed
* result is one full `claude -p` process per tool call for the rest of the
* session (issue #331).
*/
export function markSummaryAttempt(sessionId: string): void {
withRmwLock(sessionId, () => {
const prev = readState(sessionId);
writeState(sessionId, {
lastSummaryAt: prev?.lastSummaryAt ?? Date.now(),
lastSummaryCount: prev?.lastSummaryCount ?? 0,
totalCount: prev?.totalCount ?? 0,
lastAttemptAt: Date.now(),
attemptsSinceSuccess: (prev?.attemptsSinceSuccess ?? 0) + 1,
});
});
}

/**
* Exponential back-off after failed summary runs: 1, 2, 4, 8, 16 minutes,
* capped at 30. A run that succeeds resets the counter via finalizeSummary,
* so a healthy session never waits.
*/
export function retryBackoffMs(attemptsSinceSuccess: number): number {
if (attemptsSinceSuccess <= 0) return 0;
const minutes = Math.min(2 ** (attemptsSinceSuccess - 1), 30);
return minutes * 60 * 1000;
}

export interface TriggerConfig {
everyNMessages: number;
everyHours: number;
Expand All @@ -313,6 +361,11 @@ const FIRST_SUMMARY_AT = 10;

export function shouldTrigger(state: SummaryState, cfg: TriggerConfig, now = Date.now()): boolean {
const msgsSince = state.totalCount - state.lastSummaryCount;
// A previous run started but never finalized — it failed. Hold off for the
// back-off window instead of respawning a full `claude -p` on the next
// captured event (issue #331). markSummaryAttempt stamps lastAttemptAt.
const attempts = state.attemptsSinceSuccess ?? 0;
if (attempts > 0 && now - (state.lastAttemptAt ?? 0) < retryBackoffMs(attempts)) return false;
if (state.lastSummaryCount === 0 && state.totalCount >= FIRST_SUMMARY_AT) return true;
if (msgsSince >= cfg.everyNMessages) return true;
if (msgsSince > 0 && now - state.lastSummaryAt >= cfg.everyHours * 3600 * 1000) return true;
Expand Down
18 changes: 18 additions & 0 deletions tests/claude-code/capture-hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const spawnMock = vi.fn();
const wikiLogMock = vi.fn();
const tryAcquireLockMock = vi.fn();
const releaseLockMock = vi.fn();
const markSummaryAttemptMock = vi.fn();
const bumpTotalCountMock = vi.fn();
const loadTriggerConfigMock = vi.fn();
const shouldTriggerMock = vi.fn();
Expand All @@ -41,6 +42,7 @@ vi.mock("../../src/hooks/spawn-wiki-worker.js", () => ({
bundleDirFromImportMeta: () => "/fake/bundle",
}));
vi.mock("../../src/hooks/summary-state.js", () => ({
markSummaryAttempt: (...a: any[]) => markSummaryAttemptMock(...a),
tryAcquireLock: (...a: any[]) => tryAcquireLockMock(...a),
releaseLock: (...a: any[]) => releaseLockMock(...a),
bumpTotalCount: (...a: any[]) => bumpTotalCountMock(...a),
Expand Down Expand Up @@ -99,6 +101,7 @@ beforeEach(() => {
wikiLogMock.mockReset();
tryAcquireLockMock.mockReset().mockReturnValue(true);
releaseLockMock.mockReset();
markSummaryAttemptMock.mockReset();
bumpTotalCountMock.mockReset().mockReturnValue({
lastSummaryAt: Date.now(), lastSummaryCount: 0, totalCount: 1,
});
Expand Down Expand Up @@ -355,6 +358,10 @@ describe("capture hook — periodic trigger helper", () => {
);
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock.mock.calls[0][0]).toMatchObject({ sessionId: "sid-1", reason: "Periodic" });
// The attempt must be stamped BEFORE the spawn, otherwise a failing
// worker leaves lastSummaryCount at 0 and the trigger refires on every
// subsequent captured event (issue #331).
expect(markSummaryAttemptMock).toHaveBeenCalledWith("sid-1");
});

it("logs 'periodic trigger suppressed' when the lock is already held", async () => {
Expand All @@ -367,6 +374,17 @@ describe("capture hook — periodic trigger helper", () => {
);
});

it("releases the lock if markSummaryAttempt throws", async () => {
// The stamp writes to the state file. If that write fails it must not
// leak the spawn lock — otherwise the session stops summarizing until
// the 10-minute stale reclaim.
shouldTriggerMock.mockReturnValue(true);
markSummaryAttemptMock.mockImplementation(() => { throw new Error("state write failed"); });
await runHook();
expect(spawnMock).not.toHaveBeenCalled();
expect(releaseLockMock).toHaveBeenCalledWith("sid-1");
});

it("releases the lock if spawnWikiWorker throws", async () => {
shouldTriggerMock.mockReturnValue(true);
spawnMock.mockImplementation(() => { throw new Error("spawn failed"); });
Expand Down
Loading