@staix/agent-mem 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +4 -4
- package/package.json +1 -1
- package/src/cli.ts +55 -2
- package/src/commands-table.ts +5 -0
- package/src/hook-contract.ts +5 -2
- package/src/hook-rss-wire.ts +2 -0
- package/src/hook-spool-replay.ts +332 -0
- package/src/hook-spool.ts +271 -55
- package/src/hook-transcript.ts +22 -6
- package/src/hooks.ts +121 -60
- package/src/memory/doctor.ts +32 -2
- package/src/memory/metrics.ts +4 -3
- package/src/memory/public-prune-view.ts +6 -2
- package/src/session-brief-worker.ts +105 -54
package/src/hooks.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { configuredPolicyOptions, loadAmemConfig, repoConfigRoot } from "./memor
|
|
|
15
15
|
import { HOOK_EVENTS, HOOK_INPUT_BYTES, HOOK_CONTEXT_BYTES, HOOK_OUTPUT_BYTES, isHookTranscriptProgress, type HookTranscriptProgress } from "./hook-contract.ts";
|
|
16
16
|
export { HOOK_EVENTS, HOOK_INPUT_BYTES, HOOK_CONTEXT_BYTES, HOOK_OUTPUT_BYTES, type HookEvent } from "./hook-contract.ts";
|
|
17
17
|
// Leave 10ms of the external 150ms R2 budget for teardown and the metrics write.
|
|
18
|
-
const HANDLER_MS = 140;
|
|
18
|
+
export const HANDLER_MS = 140;
|
|
19
19
|
// A timed-out child must be reaped and metrics flushed inside the unchanged handler/R2 budgets.
|
|
20
20
|
// ponytail: reserve measured native teardown variance; an asynchronous hard-deadline transport
|
|
21
21
|
// through storage/cleanup is the upgrade, not a longer external deadline.
|
|
@@ -68,14 +68,17 @@ async function before<T>(work: Promise<T>, deadline: number): Promise<T | undefi
|
|
|
68
68
|
}
|
|
69
69
|
const publicBefore = async (policy: MemoryPolicy, value: unknown, deadline: number) => await before(policy.screen(value), deadline) === true;
|
|
70
70
|
|
|
71
|
-
async function boundedOutput(stream: ReadableStream<Uint8Array>, onRss?: (value: unknown) => void, onTranscript?: (value: unknown) => void): Promise<string | undefined> {
|
|
71
|
+
async function boundedOutput(stream: ReadableStream<Uint8Array>, onRss?: (value: unknown) => void, onTranscript?: (value: unknown) => void, register?: (cancel: () => void) => void, onDrain?: (value: unknown) => void): Promise<string | undefined> {
|
|
72
72
|
const reader = stream.getReader(), decoder = new TextDecoder("utf-8", { fatal: true });
|
|
73
|
+
// #226: after a bounded reap the parent abandons this pipe; cancelling it leaves no pending read holding the event loop.
|
|
74
|
+
register?.(() => { reader.cancel().catch(() => {}); });
|
|
73
75
|
let length = 0, pending = "", body = "";
|
|
74
76
|
const line = (text: string) => {
|
|
75
77
|
if (!text) return;
|
|
76
|
-
const value = JSON.parse(text) as { rss?: unknown; transcript?: unknown };
|
|
78
|
+
const value = JSON.parse(text) as { rss?: unknown; transcript?: unknown; drain?: unknown };
|
|
77
79
|
if (onRss && Object.hasOwn(value, "rss")) onRss(value.rss);
|
|
78
80
|
else if (onTranscript && Object.hasOwn(value, "transcript")) onTranscript(value.transcript);
|
|
81
|
+
else if (onDrain && Object.hasOwn(value, "drain")) onDrain(value.drain);
|
|
79
82
|
else { if (body) throw new Error("worker output bound"); body = text; }
|
|
80
83
|
};
|
|
81
84
|
try {
|
|
@@ -106,73 +109,120 @@ async function parentProject(options: HookOptions, base: string, deadline: numbe
|
|
|
106
109
|
return { cwd, scope, project, receipt, usage };
|
|
107
110
|
}
|
|
108
111
|
|
|
112
|
+
/** #226: after SIGKILL the parent waits at most this long for the owned worker's exit and output. A worker stuck in
|
|
113
|
+
* uninterruptible I/O (fsync) can outlive the hook; the kill is already sent, its pipe is cancelled and its handle unref'd,
|
|
114
|
+
* so nothing keeps this process alive and nothing stays resident. R1 judges it after a fixed 2 s post-exit window. */
|
|
115
|
+
const REAP_BOUND_MS = 10;
|
|
116
|
+
async function boundedWait<T>(work: Promise<T>): Promise<{ value?: T; late: boolean }> {
|
|
117
|
+
let timer: ReturnType<typeof setTimeout> | undefined, late = false;
|
|
118
|
+
const value = await Promise.race([work, new Promise<undefined>(resolve => { timer = setTimeout(() => { late = true; resolve(undefined); }, REAP_BOUND_MS); })]);
|
|
119
|
+
if (timer) clearTimeout(timer);
|
|
120
|
+
return late ? { late } : { value: value as T, late };
|
|
121
|
+
}
|
|
122
|
+
/** Owned children killed but still alive when the hook returned; recorded in the hook metric. */
|
|
123
|
+
let abandonedChildren = 0;
|
|
124
|
+
/** Transcript hooks whose worker was killed before its drain finished; their staged records stay durable and pending. */
|
|
125
|
+
let drainSkipped = 0;
|
|
126
|
+
/** #226 instrumentation: the last worker drain's start (ms after hook start) and committed record count. */
|
|
127
|
+
let lastDrain: { startMs: number; drained: number } | undefined;
|
|
128
|
+
/** Hook start on the performance clock, set by the entry points, so a worker can report times relative to it. */
|
|
129
|
+
let hookStartedAt = performance.now();
|
|
130
|
+
|
|
109
131
|
/** Only capture work uses a short-lived owned child; native handles remain in this CLI.
|
|
132
|
+
* #226 (ruling b): a transcript hook boots that one worker at hook start, in parallel with native metadata. The worker
|
|
133
|
+
* scans as soon as its first stdin line (scan options) arrives and drains after the second (the native receipt). At the
|
|
134
|
+
* deadline the parent kills it and waits at most REAP_BOUND_MS; a kill skips that call's drain (counted, never a drop).
|
|
110
135
|
* Host callbacks cannot cross this boundary; a host needing them calls sessionStartBrief in-process instead. */
|
|
111
136
|
async function hookWorker(options: HookOptions, base: string, deadline: number, pending: PendingHookCapture, event: string, transcript?: unknown): Promise<{ context: string; rssKB: number; rawRssKB: number; captured: boolean; valid: boolean; rssSources?: HookRssSources; transcript?: HookTranscriptProgress }> {
|
|
112
137
|
const workDeadline = deadline - WORKER_CLEANUP_MS;
|
|
113
138
|
if (options.policy?.host || workDeadline - performance.now() < 5) return { context: unavailable, rssKB: 0, rawRssKB: 0, captured: false, valid: false };
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const maxTokens = Math.min(HOOK_CONTEXT_BYTES, configuredBrief ?? HOOK_CONTEXT_BYTES, Math.max(1, options.maxTokens ?? HOOK_CONTEXT_BYTES));
|
|
122
|
-
// The worker reads the configuration itself (same base, same cwd), so it screens with exactly the policy the parent did.
|
|
123
|
-
const payload = JSON.stringify({ cwd: options.cwd, base, current: pending.name, event, identity: metadata.receipt, transcript, maxTokens, ownedGroup: true, deadlineAt: performance.timeOrigin + workDeadline,
|
|
124
|
-
policy: { denyPaths: options.policy?.denyPaths, piiPatterns: options.policy?.piiPatterns } });
|
|
139
|
+
const early = transcript !== undefined;
|
|
140
|
+
const policy = { denyPaths: options.policy?.denyPaths, piiPatterns: options.policy?.piiPatterns };
|
|
141
|
+
let owned: Bun.Subprocess<"pipe", "pipe", "ignore"> | undefined, exited = false, killed = false, cancelOutput: (() => void) | undefined;
|
|
142
|
+
let tracker: HookWorkerRss | undefined, output: Promise<string | undefined> | undefined, exit: Promise<number> | undefined;
|
|
143
|
+
const result = { context: unavailable, rssKB: 0, rawRssKB: 0, captured: false, valid: false, rssSources: emptyRssSources(), transcript: undefined as HookTranscriptProgress | undefined };
|
|
144
|
+
const drainProgress: { startMs?: number; drained?: number } = {};
|
|
145
|
+
const spawn = () => {
|
|
125
146
|
const child = Bun.spawn([process.execPath, "--smol", fileURLToPath(new URL("./session-brief-worker.ts", import.meta.url))],
|
|
126
|
-
{ cwd: options.cwd, env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", AMEM_HOME: base }, detached: true, stdin:
|
|
147
|
+
{ cwd: options.cwd, env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", AMEM_HOME: base }, detached: true, stdin: "pipe", stdout: "pipe", stderr: "ignore" });
|
|
127
148
|
owned = child;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const output = boundedOutput(child.stdout, value => tracker.frame(value), value => {
|
|
149
|
+
exit = child.exited.then(code => { exited = true; return code; });
|
|
150
|
+
tracker = new HookWorkerRss(child.pid);
|
|
151
|
+
output = boundedOutput(child.stdout, value => tracker!.frame(value), value => {
|
|
132
152
|
if (!isHookTranscriptProgress(value)) throw new Error("worker transcript progress bound");
|
|
133
153
|
if (value.offset !== undefined && result.transcript?.offset !== undefined && value.offset < result.transcript.offset) throw new Error("worker transcript progress regressed");
|
|
134
|
-
|
|
154
|
+
// A reported scan fault stays reported: a later worker deadline frame cannot turn it into accepted progress (#221).
|
|
155
|
+
result.transcript = { ...result.transcript, ...value, ...(result.transcript?.stopReason === "unavailable" ? { stopReason: "unavailable" as const } : {}) };
|
|
156
|
+
}, cancel => { cancelOutput = cancel; }, value => {
|
|
157
|
+
// #226 instrumentation: bounded numeric drain progress; anything else is ignored, never trusted.
|
|
158
|
+
const row = value as { startMs?: unknown; drained?: unknown } | null;
|
|
159
|
+
if (row && typeof row.startMs === "number" && Number.isFinite(row.startMs) && row.startMs >= 0 && row.startMs < 10_000) drainProgress.startMs = row.startMs;
|
|
160
|
+
if (row && Number.isSafeInteger(row.drained) && (row.drained as number) >= 0 && (row.drained as number) <= 1024) drainProgress.drained = row.drained as number;
|
|
135
161
|
});
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (typeof parsed.rssKB === "number" && Number.isSafeInteger(parsed.rssKB) && parsed.rssKB > 0) result.rssKB = parsed.rssKB;
|
|
154
|
-
if (typeof parsed.context === "string" && Buffer.byteLength(parsed.context) <= maxTokens) result.context = parsed.context;
|
|
155
|
-
result.captured = parsed.captured === true; result.valid = parsed.valid === true;
|
|
156
|
-
return result;
|
|
157
|
-
} catch { return result; }
|
|
158
|
-
finally {
|
|
159
|
-
if (timer) clearTimeout(timer);
|
|
160
|
-
stop();
|
|
161
|
-
await exit;
|
|
162
|
-
// Bun subprocess usage is bytes; process.resourceUsage below is KiB.
|
|
163
|
-
const workerRawKB = (child.resourceUsage()?.maxRSS ?? 0) / 1024;
|
|
164
|
-
result.rawRssKB = metadata.usage.rawRssKB + workerRawKB;
|
|
165
|
-
if (await output === undefined) tracker.invalidate();
|
|
166
|
-
const accounting = tracker.finish(workerRawKB);
|
|
167
|
-
result.rssKB = metadata.usage.rssKB + accounting.rssKB; result.rssSources = combineRssSources(metadata.usage.rssSources, accounting.rssSources);
|
|
162
|
+
return child;
|
|
163
|
+
};
|
|
164
|
+
const send = (line: object) => { try { owned!.stdin.write(JSON.stringify(line) + "\n"); owned!.stdin.flush(); } catch { /* A dead worker reports nothing. */ } };
|
|
165
|
+
const close = () => { try { owned?.stdin.end(); } catch { /* Already closed. */ } };
|
|
166
|
+
const stop = () => {
|
|
167
|
+
if (!owned || exited) return;
|
|
168
|
+
tracker!.captureLive();
|
|
169
|
+
killed = true;
|
|
170
|
+
// The still-live leader is the detached process we just spawned, so its group is ours.
|
|
171
|
+
// The capture-only worker owns no native helper; those handles were reaped in this parent.
|
|
172
|
+
try { process.kill(-owned.pid, "SIGKILL"); } catch { try { owned.kill("SIGKILL"); } catch { /* Already exited. */ } }
|
|
173
|
+
};
|
|
174
|
+
let timer: ReturnType<typeof setTimeout> | undefined, metadata: Awaited<ReturnType<typeof parentProject>> | undefined;
|
|
175
|
+
try {
|
|
176
|
+
if (early) {
|
|
177
|
+
spawn();
|
|
178
|
+
send({ ownedGroup: true, scan: { cwd: options.cwd, base, transcript, policy, deadlineAt: performance.timeOrigin + workDeadline } });
|
|
168
179
|
}
|
|
180
|
+
const configuredBrief = loadAmemConfig(base, repoConfigRoot(options.cwd)).config.briefBytes;
|
|
181
|
+
const maxTokens = Math.min(HOOK_CONTEXT_BYTES, configuredBrief ?? HOOK_CONTEXT_BYTES, Math.max(1, options.maxTokens ?? HOOK_CONTEXT_BYTES));
|
|
182
|
+
metadata = await parentProject(options, base, workDeadline);
|
|
183
|
+
result.rssKB = metadata.usage.rssKB; result.rawRssKB = metadata.usage.rawRssKB; result.rssSources = combineRssSources(metadata.usage.rssSources);
|
|
184
|
+
if (!early && (!metadata.receipt || workDeadline - performance.now() < 5)) return result;
|
|
185
|
+
if (!owned) spawn();
|
|
186
|
+
result.rssSources.unavailable++;
|
|
187
|
+
// The worker reads the configuration itself (same base, same cwd), so it screens with exactly the policy the parent did.
|
|
188
|
+
// Without a receipt the worker still finishes the scan and reports it, but drains nothing.
|
|
189
|
+
send({ cwd: options.cwd, base, current: pending.name, event, identity: metadata.receipt, maxTokens, ownedGroup: true, deadlineAt: performance.timeOrigin + workDeadline, hookStartAt: performance.timeOrigin + hookStartedAt, policy });
|
|
190
|
+
close();
|
|
191
|
+
const answer = await Promise.race([
|
|
192
|
+
Promise.all([exit!, output!]),
|
|
193
|
+
new Promise<undefined>(resolve => { timer = setTimeout(() => { stop(); resolve(undefined); }, Math.max(1, workDeadline - performance.now())); }),
|
|
194
|
+
]);
|
|
195
|
+
if (!answer || !answer[1]) return result;
|
|
196
|
+
const parsed = JSON.parse(answer[1]) as { context?: unknown; captured?: unknown; valid?: unknown; complete?: unknown; rssKB?: unknown; rssTelemetryValid?: unknown };
|
|
197
|
+
if (parsed.complete !== true) return result;
|
|
198
|
+
if (parsed.rssTelemetryValid !== true) tracker!.invalidate();
|
|
199
|
+
if (typeof parsed.context === "string" && Buffer.byteLength(parsed.context) <= maxTokens) result.context = parsed.context;
|
|
200
|
+
result.captured = parsed.captured === true; result.valid = parsed.valid === true;
|
|
201
|
+
return result;
|
|
169
202
|
} catch { result.valid = false; return result; }
|
|
170
203
|
finally {
|
|
171
|
-
|
|
204
|
+
if (timer) clearTimeout(timer);
|
|
205
|
+
close();
|
|
172
206
|
if (owned) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
207
|
+
stop();
|
|
208
|
+
// #226: bounded reap; a worker stuck in uninterruptible I/O is abandoned after REAP_BOUND_MS, never awaited.
|
|
209
|
+
const reaped = exited || !(await boundedWait(exit!)).late;
|
|
210
|
+
const body = reaped ? await boundedWait(output!) : { late: true };
|
|
211
|
+
if (!reaped || body.late) { cancelOutput?.(); owned.unref(); }
|
|
212
|
+
if (!reaped) abandonedChildren++;
|
|
213
|
+
// Bun subprocess usage is bytes; process.resourceUsage is KiB. Not reaped: the last live sample is the RSS (ruling Q2).
|
|
214
|
+
const workerRawKB = reaped ? (owned.resourceUsage()?.maxRSS ?? 0) / 1024 : 0;
|
|
215
|
+
if (reaped ? body.late || body.value === undefined : !tracker!.hasLiveSample()) tracker!.invalidate();
|
|
216
|
+
const accounting = tracker!.finish(workerRawKB);
|
|
217
|
+
const usage = metadata?.usage ?? { rssKB: 0, rawRssKB: 0, rssSources: emptyRssSources() };
|
|
218
|
+
result.rssKB = usage.rssKB + accounting.rssKB; result.rawRssKB = usage.rawRssKB + workerRawKB; result.rssSources = combineRssSources(usage.rssSources, accounting.rssSources);
|
|
219
|
+
if (drainProgress.startMs !== undefined) lastDrain = { startMs: drainProgress.startMs, drained: drainProgress.drained ?? 0 };
|
|
220
|
+
if (early && killed) {
|
|
221
|
+
// #221: a kill without accepted progress is a deadline stop; a kill also skips this call's drain (ruling b).
|
|
222
|
+
if (result.transcript?.offset === undefined && result.transcript?.stopReason !== "unavailable") result.transcript = { stopReason: "deadline" };
|
|
223
|
+
if (!result.valid) drainSkipped++;
|
|
224
|
+
}
|
|
225
|
+
} else if (early) result.transcript ??= { stopReason: "deadline" };
|
|
176
226
|
}
|
|
177
227
|
}
|
|
178
228
|
|
|
@@ -245,7 +295,12 @@ async function sessionInParent(input: unknown, options: HookOptions, base: strin
|
|
|
245
295
|
async function captureAndWork(event: string, input: unknown, options: HookOptions, base: string, deadline: number) {
|
|
246
296
|
if (event === "SessionStart") return sessionInParent(input, options, base, deadline);
|
|
247
297
|
const transcript = (event === "Stop" || event === "SessionEnd") && input !== null && typeof input === "object" && "transcript_path" in input && typeof input.transcript_path === "string" ? input : undefined;
|
|
248
|
-
|
|
298
|
+
// #221: a transcript-bearing hook that ends without worker progress reports `deadline` only on positive evidence:
|
|
299
|
+
// the remaining time, measured at that exit, is below the worker's own start threshold. Policy, floor or invalid-input
|
|
300
|
+
// exits keep reporting nothing, so they cannot pass for bounded progress.
|
|
301
|
+
const deadlineEvidence = () => transcript !== undefined && deadline - WORKER_CLEANUP_MS - performance.now() < 5;
|
|
302
|
+
const withDeadline = <T extends { transcript?: HookTranscriptProgress }>(value: T): T => value.transcript === undefined && deadlineEvidence() ? { ...value, transcript: { stopReason: "deadline" } } : value;
|
|
303
|
+
const work = async (pending: PendingHookCapture) => withDeadline(await hookWorker({ ...options, cwd: pending.cwd }, base, deadline, pending, event, transcript));
|
|
249
304
|
let result: Awaited<ReturnType<typeof work>> | undefined;
|
|
250
305
|
let pending: PendingHookCapture | undefined;
|
|
251
306
|
try { pending = await stageHookCapture(event, input, { ...options, base, deferAtCapacity: true }); }
|
|
@@ -261,7 +316,11 @@ async function captureAndWork(event: string, input: unknown, options: HookOption
|
|
|
261
316
|
return { ...result, pending: false, dropped: true };
|
|
262
317
|
}
|
|
263
318
|
}
|
|
264
|
-
if (!pending)
|
|
319
|
+
if (!pending) {
|
|
320
|
+
if (result) return { ...result, pending: false };
|
|
321
|
+
// Staging declined (policy, floor, invalid input) or ran out of time: only measured time makes it a deadline stop.
|
|
322
|
+
return deadlineEvidence() ? { context: unavailable, rssKB: 0, rawRssKB: 0, captured: false, valid: false, pending: false, transcript: { stopReason: "deadline" as const } } : undefined;
|
|
323
|
+
}
|
|
265
324
|
result ??= await work(pending);
|
|
266
325
|
return { ...result, pending: !result.captured };
|
|
267
326
|
}
|
|
@@ -270,6 +329,7 @@ async function captureAndWork(event: string, input: unknown, options: HookOption
|
|
|
270
329
|
export async function runHook(event: string, input: unknown, options: HookOptions, host?: HookHostPorts): Promise<HookReceipt> {
|
|
271
330
|
const now = performance.now(), started = options.startedAt !== undefined && Number.isFinite(options.startedAt) && options.startedAt <= now ? options.startedAt : now;
|
|
272
331
|
const deadline = started + handlerMs(host);
|
|
332
|
+
hookStartedAt = started;
|
|
273
333
|
let base: string | undefined, captured = false, childRssKB = 0, childRawRssKB = 0, pending = false, dropped = false;
|
|
274
334
|
let childSources = emptyRssSources();
|
|
275
335
|
let transcriptProgress: HookTranscriptProgress | undefined;
|
|
@@ -291,13 +351,14 @@ export async function runHook(event: string, input: unknown, options: HookOption
|
|
|
291
351
|
finally {
|
|
292
352
|
if (base) try { const self = hookRssReading(); appendMetrics(base, [{ at: new Date().toISOString(), kind: "hook", wallMs: performance.now() - started,
|
|
293
353
|
rssMB: (self.kb + childRssKB) / 1024, rssSources: combineRssSources(rssSource(self), countedRssSources(childRssKB, childSources)), rssScope: "owned-images", rawRssMB: (process.resourceUsage().maxRSS + childRawRssKB) / 1024,
|
|
294
|
-
...(transcriptProgress ? { transcriptOffset: transcriptProgress.offset, transcriptStopReason: transcriptProgress.stopReason } : {}), ...(dropped ? { captureDrops: 1, captureDropReason: "spool_full" as const } : {}) }]); } catch { /* Metrics never change the native tool outcome. */ }
|
|
354
|
+
...(transcriptProgress ? { transcriptOffset: transcriptProgress.offset, transcriptStopReason: transcriptProgress.stopReason } : {}), ...(abandonedChildren ? { abandonedChildren } : {}), ...(drainSkipped ? { drainSkipped } : {}), ...(lastDrain ? { drainStartMs: lastDrain.startMs, drained: lastDrain.drained } : {}), ...(dropped ? { captureDrops: 1, captureDropReason: "spool_full" as const } : {}) }]); } catch { /* Metrics never change the native tool outcome. */ }
|
|
295
355
|
}
|
|
296
356
|
}
|
|
297
357
|
|
|
298
358
|
/** Bounded stdin and a fixed overall handler budget; failures produce no native decision and exit successfully. */
|
|
299
359
|
export async function hookMain(args: readonly string[], startedAt = performance.now(), host?: HookHostPorts): Promise<string> {
|
|
300
360
|
const deadlineMs = handlerMs(host);
|
|
361
|
+
hookStartedAt = startedAt;
|
|
301
362
|
let cleanup: (() => Promise<void>) | undefined;
|
|
302
363
|
let childRssKB = 0, childRawRssKB = 0, dropped = false;
|
|
303
364
|
let childSources = emptyRssSources();
|
|
@@ -341,7 +402,7 @@ export async function hookMain(args: readonly string[], startedAt = performance.
|
|
|
341
402
|
const self = hookRssReading();
|
|
342
403
|
appendMetrics(canonicalPath(memoryHome()), [{ at: new Date().toISOString(), kind: "hook", wallMs: performance.now() - startedAt,
|
|
343
404
|
rssMB: (self.kb + childRssKB) / 1024, rssSources: combineRssSources(rssSource(self), countedRssSources(childRssKB, childSources)), rssScope: "owned-images", rawRssMB: (process.resourceUsage().maxRSS + childRawRssKB) / 1024,
|
|
344
|
-
...(transcriptProgress ? { transcriptOffset: transcriptProgress.offset, transcriptStopReason: transcriptProgress.stopReason } : {}), ...(dropped ? { captureDrops: 1, captureDropReason: "spool_full" as const } : {}) }]);
|
|
405
|
+
...(transcriptProgress ? { transcriptOffset: transcriptProgress.offset, transcriptStopReason: transcriptProgress.stopReason } : {}), ...(abandonedChildren ? { abandonedChildren } : {}), ...(drainSkipped ? { drainSkipped } : {}), ...(lastDrain ? { drainStartMs: lastDrain.startMs, drained: lastDrain.drained } : {}), ...(dropped ? { captureDrops: 1, captureDropReason: "spool_full" as const } : {}) }]);
|
|
345
406
|
} catch { /* Invalid input and unavailable homes still fail open. */ }
|
|
346
407
|
}
|
|
347
408
|
}
|
package/src/memory/doctor.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { aliasedKeys, shardDir } from "./shard.ts";
|
|
|
19
19
|
import type { MemoryStore, View } from "./store.ts";
|
|
20
20
|
import { nextSyncAt, readSyncState, remoteConfigured, syncDue, usableState } from "./sync-cadence.ts";
|
|
21
21
|
import { readSetupDrift } from "../setup-drift.ts";
|
|
22
|
+
import { readHookSpoolStatus } from "../hook-spool.ts";
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* F15, `amem doctor`: one report over the memory home. It writes nothing into the home. Every check that needs a store (the notes, the brief, the index proof) runs on a
|
|
@@ -63,6 +64,8 @@ export interface DoctorReport {
|
|
|
63
64
|
duplicates: Section; stale: Section; candidates: Section; capture: Section; brief: Section; index: Section; readers: Section; policy: Section; cadence: Section;
|
|
64
65
|
/** Added after v1 shipped (#132); additive, so the schema stays amem.doctor/v1. */
|
|
65
66
|
drops: Section;
|
|
67
|
+
/** #217, additive: bounded read-only home-wide hook backlog inventory. */
|
|
68
|
+
spool?: Section;
|
|
66
69
|
/** #188, additive: present only when the project's curated logs hold forget or prune operations. Informational; status is always ok. */
|
|
67
70
|
masked?: Section;
|
|
68
71
|
/** #202, additive: applied native setup registrations checked against the shared path resolver. */
|
|
@@ -363,6 +366,22 @@ function dropsSection(options: DoctorOptions): Section {
|
|
|
363
366
|
...(total ? { hint: "capture rows were dropped recently (see counts by reason); amem doctor --json keeps the numbers" } : {}) };
|
|
364
367
|
}
|
|
365
368
|
|
|
369
|
+
/** Bounded read-only inventory and classification; no replay, claim or retirement. */
|
|
370
|
+
function spoolSection(options: DoctorOptions): Section {
|
|
371
|
+
try {
|
|
372
|
+
const inventory = readHookSpoolStatus(options.home);
|
|
373
|
+
const tiers = [inventory.active, inventory.reserve];
|
|
374
|
+
const full = tiers.some(tier => tier.files >= tier.maxFiles || tier.bytes >= tier.maxBytes);
|
|
375
|
+
const near = tiers.some(tier => tier.files >= tier.maxFiles * 0.9 || tier.bytes >= tier.maxBytes * 0.9);
|
|
376
|
+
return { ...inventory, status: !inventory.complete ? "unavailable" : full || near ? "attention" : "ok",
|
|
377
|
+
coverage: inventory.complete ? "complete" : "partial",
|
|
378
|
+
classificationCoverage: inventory.kinds.complete ? "complete" : "partial",
|
|
379
|
+
fill: full ? "full" : near ? "nearly_full" : !inventory.complete ? "unknown" : tiers.some(tier => tier.files > 0) ? "pending" : "empty",
|
|
380
|
+
...(!inventory.complete ? { reason: "hook spool inventory is partial; reported file and byte counts are lower bounds" } : {}),
|
|
381
|
+
...(full || near ? { hint: "pending hook spool needs attention: capture can be dropped when both tiers fill; supported orphan recovery is pending" } : {}) };
|
|
382
|
+
} catch { return unavailable("hook spool could not be read safely"); }
|
|
383
|
+
}
|
|
384
|
+
|
|
366
385
|
/** F16: which CLIs ingest at SessionEnd (setup receipts), and the person-owned sync cadence. amem registers no sync hook (a hook may not do network work). */
|
|
367
386
|
function cadenceSection(options: DoctorOptions): Section {
|
|
368
387
|
const ingest: Record<string, string> = {};
|
|
@@ -444,7 +463,7 @@ function guardScratch(): { adopt: (dir: string) => void; release: () => void } {
|
|
|
444
463
|
export const DOCTOR_GIT_TIMEOUT_MS = 5_000;
|
|
445
464
|
export function doctorReport(options: DoctorOptions): Promise<DoctorReport> {
|
|
446
465
|
// Doctor reads every surface below, live or through a scratch copy: a floor on any of them refuses the whole report before anything is read (#199 AC5).
|
|
447
|
-
for (const surface of ["curated", "capture", "candidates", "work-lists"] as const) assertFormatFloor(options.home, surface);
|
|
466
|
+
for (const surface of ["curated", "capture", "candidates", "work-lists", "hook-spool"] as const) assertFormatFloor(options.home, surface);
|
|
448
467
|
return withGitTimeout(DOCTOR_GIT_TIMEOUT_MS, () => runDoctor(options));
|
|
449
468
|
}
|
|
450
469
|
async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
@@ -497,7 +516,7 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
497
516
|
let sections: DoctorReport["sections"];
|
|
498
517
|
try {
|
|
499
518
|
await tick();
|
|
500
|
-
sections = { duplicates, stale, candidates, capture: captureSection(options), brief: await briefSection(options, curated, workBase, copyFailed), index, readers: readersSection(options), policy: policySection(options), cadence: cadenceSection(options), drops: dropsSection(options), setup, ...maskedSection(curated) };
|
|
519
|
+
sections = { duplicates, stale, candidates, capture: captureSection(options), brief: await briefSection(options, curated, workBase, copyFailed), index, readers: readersSection(options), policy: policySection(options), cadence: cadenceSection(options), drops: dropsSection(options), spool: spoolSection(options), setup, ...maskedSection(curated) };
|
|
501
520
|
await tick(); // a signal that arrived during the last synchronous stage is handled while the guard is still installed
|
|
502
521
|
} finally { try { curated?.close(); } finally { try { if (scratch) rmSync(scratch, { recursive: true, force: true }); } finally { unguard?.(); } } }
|
|
503
522
|
return { schema: DOCTOR_SCHEMA, status: worst(Object.values(sections).map(section => section.status)), home: { path: options.home, source: options.homeSource }, project: options.project, sections };
|
|
@@ -507,6 +526,7 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
507
526
|
const SECTION_TITLE: Record<keyof DoctorReport["sections"], string> = {
|
|
508
527
|
duplicates: "duplicate notes", stale: "stale path references", candidates: "review candidates", capture: "capture per CLI", brief: "session brief",
|
|
509
528
|
index: "index equals rebuild", readers: "reader leases", policy: "policy in effect", cadence: "ingest and sync cadence", drops: "recent capture drops",
|
|
529
|
+
spool: "pending hook spool",
|
|
510
530
|
setup: "native setup registrations",
|
|
511
531
|
masked: "forgotten or pruned entries (informational)",
|
|
512
532
|
};
|
|
@@ -528,6 +548,16 @@ function sectionDetail(name: string, detail: Record<string, unknown>, s: Style):
|
|
|
528
548
|
const counts = Object.entries((detail.counts ?? {}) as Record<string, number>);
|
|
529
549
|
return [counts.length ? `${counts.map(([reason, n]) => `${reason.replaceAll("_", " ")} ${n}`).join(", ")} (recent metrics tail)` : "none in the recent metrics tail"].map(line => detail.malformedLines ? `${line}; ${detail.malformedLines} malformed lines skipped` : line);
|
|
530
550
|
}
|
|
551
|
+
if (name === "spool") {
|
|
552
|
+
if (!detail.active || !detail.reserve) return [];
|
|
553
|
+
const tiers = ["active", "reserve"].map(name => {
|
|
554
|
+
const tier = detail[name] as { files: number; bytes: number; maxFiles: number; maxBytes: number };
|
|
555
|
+
return `${name} ${tier.files}/${tier.maxFiles} files, ${tier.bytes}/${tier.maxBytes} bytes`;
|
|
556
|
+
});
|
|
557
|
+
const kinds = detail.kinds as { native: number; summaries: number; unclassified: number; complete: boolean };
|
|
558
|
+
return [`${detail.fill}; ${tiers.join("; ")}; claimed ${detail.claimed}; oldest ${detail.oldestAt ?? "none observed"}; coverage ${detail.coverage}${detail.complete ? "" : " (counts are lower bounds)"}`,
|
|
559
|
+
`native ${kinds.native}, summaries ${kinds.summaries}, unclassified ${kinds.unclassified}; classification ${detail.classificationCoverage}${kinds.complete ? "" : " (native and summary counts are lower bounds)"}`];
|
|
560
|
+
}
|
|
531
561
|
if (name === "setup" && Array.isArray(detail.entries)) {
|
|
532
562
|
return (detail.entries as { target?: unknown; file?: unknown; entry?: unknown; reason?: unknown; expectedFile?: unknown }[]).map(item =>
|
|
533
563
|
singleLine(`${item.target} ${item.file}${item.expectedFile ? ` -> ${item.expectedFile}` : ""} ${item.entry}: ${item.reason}`));
|
package/src/memory/metrics.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { closeSync, constants, fstatSync, openSync, readSync, appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import type { HookRssSources } from "../hook-resources.ts";
|
|
3
|
+
import type { HookTranscriptStopReason } from "../hook-contract.ts";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
|
|
5
6
|
/** Rolling resource samples; importing this leaf never loads a memory store or capacity probe. */
|
|
6
|
-
export interface MetricSample { at: string; kind: "hook" | "search"; wallMs?: number; rssMB?: number; rawRssMB?: number; rssSources?: HookRssSources; rssScope?: "owned-images"; transcriptOffset?: number; transcriptStopReason?:
|
|
7
|
+
export interface MetricSample { at: string; kind: "hook" | "search"; wallMs?: number; rssMB?: number; rawRssMB?: number; rssSources?: HookRssSources; rssScope?: "owned-images"; transcriptOffset?: number; transcriptStopReason?: HookTranscriptStopReason; /** #226: owned children killed but still alive when the hook returned (bounded reap). */ abandonedChildren?: number; /** #226: a killed worker skipped this call's drain; staged records stay pending (not a capture drop). */ drainSkipped?: number; /** #226: when the worker drain started (ms after hook start) and how many records it committed. */ drainStartMs?: number; drained?: number; idleMB?: number; captureDrops?: number; captureDropReason?: CaptureDropReason }
|
|
7
8
|
const METRICS_MAX_BYTES = 64 * 1024, METRICS_KEEP = 200;
|
|
8
9
|
|
|
9
10
|
export function metricsPath(base: string): string {
|
|
@@ -37,8 +38,8 @@ export function readMetrics(base: string, limit = METRICS_KEEP): MetricSample[]
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
|
|
40
|
-
export type CaptureDropReason = "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown" | "project_archived" | "project_forgotten" | "unknown_schema_expired" | "transcript_line_bound";
|
|
41
|
-
const DROP_REASONS: readonly CaptureDropReason[] = ["spool_full", "cwd_removed", "undrainable_expired", "scope_unknown", "project_archived", "project_forgotten", "unknown_schema_expired", "transcript_line_bound"];
|
|
41
|
+
export type CaptureDropReason = "summary_capacity" | "scope_capacity" | "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown" | "project_archived" | "project_forgotten" | "unknown_schema_expired" | "transcript_line_bound";
|
|
42
|
+
const DROP_REASONS: readonly CaptureDropReason[] = ["summary_capacity", "scope_capacity", "spool_full", "cwd_removed", "undrainable_expired", "scope_unknown", "project_archived", "project_forgotten", "unknown_schema_expired", "transcript_line_bound"];
|
|
42
43
|
/** Numeric notice only, bounded to the rolling metric tail; never an all-time counter. Fails open to no counts: hooks never deny over metrics. */
|
|
43
44
|
export function captureDropSummary(base: string): Partial<Record<CaptureDropReason, number>> {
|
|
44
45
|
const read = readCaptureDrops(base);
|
|
@@ -49,14 +49,18 @@ export function waitForPublicPruneWriter(home: string, absoluteDeadline?: number
|
|
|
49
49
|
const deadline = Math.min(absoluteDeadline ?? Infinity, Date.now() + PROFILE_LOCK_TIMEOUT_MS);
|
|
50
50
|
const intentFile = join(home, PUBLIC_PRUNE_INTENT), lockFile = join(home, "profile.lock");
|
|
51
51
|
if (!lstatSync(intentFile, { throwIfNoEntry: false }) && !lstatSync(lockFile, { throwIfNoEntry: false })) return;
|
|
52
|
+
// #225: a cooperating writer that finishes unlinks its intent, then its lock. If that lands between this reader's
|
|
53
|
+
// open and fstat, the fd's link count is 0. Only that is the normal disappearance (ENOENT): bytes read through the fd
|
|
54
|
+
// are discarded and the bounded loop re-resolves the path from scratch. Any other link count still refuses.
|
|
55
|
+
const unlinked = (stat: Stats): void => { if (stat.nlink === 0) throw Object.assign(new Error("publication unlinked while open"), { code: "ENOENT" }); };
|
|
52
56
|
const small = (file: string, limit: number): Record<string, unknown> => {
|
|
53
57
|
const before = lstatSync(file); privateStat(before); guard(before.size > 0 && before.size <= limit);
|
|
54
58
|
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
55
59
|
try {
|
|
56
|
-
const opened = fstatSync(fd); privateStat(opened); guard(opened.size > 0 && opened.size <= limit);
|
|
60
|
+
const opened = fstatSync(fd); unlinked(opened); privateStat(opened); guard(opened.size > 0 && opened.size <= limit);
|
|
57
61
|
const bytes = Buffer.alloc(opened.size + 1); let count = 0;
|
|
58
62
|
while (count < bytes.length) { const read = readSync(fd, bytes, count, bytes.length - count, count); if (!read) break; count += read; }
|
|
59
|
-
const after = fstatSync(fd); privateStat(after); guard(count === opened.size && same(opened, after));
|
|
63
|
+
const after = fstatSync(fd); unlinked(after); privateStat(after); guard(count === opened.size && same(opened, after));
|
|
60
64
|
const parsed: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, count))); guard(object(parsed)); return parsed;
|
|
61
65
|
} finally { closeSync(fd); }
|
|
62
66
|
};
|
|
@@ -6,35 +6,45 @@ import { hookRssReading, hookPeakRssKB } from "./hook-resources.ts";
|
|
|
6
6
|
import { rssSourceCode } from "./hook-rss-wire.ts";
|
|
7
7
|
import { resolveAlias } from "./memory/aliases.ts";
|
|
8
8
|
import { HOOK_EVENTS, HOOK_INPUT_BYTES, type HookTranscriptProgress } from "./hook-contract.ts";
|
|
9
|
-
import { drainHookCaptures, hookCaptureScope } from "./hook-spool.ts";
|
|
9
|
+
import { drainHookCaptures, hookCaptureScope, hookReplayBudget } from "./hook-spool.ts";
|
|
10
|
+
/** Existing allowance for the scan's own cursor commit; the parent also retains its cleanup reserve. */
|
|
11
|
+
const SCAN_COMMIT_MS = 20;
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
/** #226: options arrive as newline-delimited JSON. A transcript hook first sends the scan options, then (after native
|
|
14
|
+
* metadata) the drain options; other hooks send only the drain options. Total input stays bounded. */
|
|
15
|
+
function optionLines(): () => Promise<string | undefined> {
|
|
16
|
+
const reader = Bun.stdin.stream().getReader(), decoder = new TextDecoder("utf-8", { fatal: true });
|
|
17
|
+
let pending = "", bytes = 0, done = false;
|
|
18
|
+
return async () => {
|
|
14
19
|
for (;;) {
|
|
15
|
-
const
|
|
20
|
+
const at = pending.indexOf("\n");
|
|
21
|
+
if (at >= 0) { const line = pending.slice(0, at); pending = pending.slice(at + 1); return line; }
|
|
22
|
+
if (done) { const rest = pending; pending = ""; return rest || undefined; }
|
|
23
|
+
const next = await reader.read();
|
|
24
|
+
if (next.done) { done = true; pending += decoder.decode(); continue; }
|
|
16
25
|
bytes += next.value.byteLength;
|
|
17
|
-
// Native input is at
|
|
18
|
-
if (bytes > HOOK_INPUT_BYTES + 32 * 1024) throw new Error("input bound");
|
|
19
|
-
|
|
26
|
+
// Native input is at most 64 KiB, sent at most twice (scan, then drain); leave bounded room for fixed options.
|
|
27
|
+
if (bytes > 2 * HOOK_INPUT_BYTES + 32 * 1024) throw new Error("input bound");
|
|
28
|
+
pending += decoder.decode(next.value, { stream: true });
|
|
20
29
|
}
|
|
21
|
-
|
|
22
|
-
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
|
|
30
|
+
};
|
|
23
31
|
}
|
|
24
32
|
|
|
25
33
|
// Fixed package helper, launched and reaped by each native hook; never registered or resident.
|
|
26
34
|
if (import.meta.main) {
|
|
27
|
-
let owned = false;
|
|
28
|
-
let
|
|
35
|
+
let owned = false, rssTelemetryValid = true;
|
|
36
|
+
let lastProgress: HookTranscriptProgress | undefined;
|
|
29
37
|
const frame = (rss: number[]) => { try { writeSync(1, JSON.stringify({ rss }) + "\n"); } catch { rssTelemetryValid = false; } };
|
|
30
|
-
const progress = (transcript: HookTranscriptProgress) => {
|
|
38
|
+
const progress = (transcript: HookTranscriptProgress) => {
|
|
39
|
+
lastProgress = { ...lastProgress, ...transcript, ...(lastProgress?.stopReason === "unavailable" ? { stopReason: "unavailable" as const } : {}) };
|
|
40
|
+
try { writeSync(1, JSON.stringify({ transcript: lastProgress }) + "\n"); } catch { rssTelemetryValid = false; }
|
|
41
|
+
};
|
|
42
|
+
const drain = (value: { startMs?: number; drained: number }) => { try { writeSync(1, JSON.stringify({ drain: value }) + "\n"); } catch { rssTelemetryValid = false; } };
|
|
31
43
|
frame([0]);
|
|
32
|
-
const stop = ()
|
|
44
|
+
const stop: () => never = () => {
|
|
33
45
|
if (!rssTelemetryValid) frame([4]);
|
|
34
46
|
const self = hookRssReading(); frame([3, self.kb, self.rawKB, rssSourceCode(self)]);
|
|
35
|
-
|
|
36
|
-
// Native Git is owned and reaped by the CLI parent, never by this worker.
|
|
37
|
-
if (owned) try { process.kill(-process.pid, "SIGKILL"); } catch { /* No group exists in a direct, non-owned invocation. */ }
|
|
47
|
+
if (owned) try { process.kill(-process.pid, "SIGKILL"); } catch { /* A direct unowned invocation has no group. */ }
|
|
38
48
|
process.exit(0);
|
|
39
49
|
};
|
|
40
50
|
const emit = async (result: unknown) => {
|
|
@@ -43,45 +53,86 @@ if (import.meta.main) {
|
|
|
43
53
|
await Bun.stdout.write(body.slice(0, -1) + `,"rssKB":${rssKB}}\n`);
|
|
44
54
|
stop();
|
|
45
55
|
};
|
|
56
|
+
// Ordinary hooks preload only the store, as before. They must not pay for a transcript module they never use.
|
|
57
|
+
let captureReady = false;
|
|
58
|
+
const captureModule = import("./memory/capture.ts");
|
|
59
|
+
captureModule.then(() => { captureReady = true; }, () => { /* Reported when the final drain awaits it. */ });
|
|
60
|
+
const next = optionLines(), now = () => performance.timeOrigin + performance.now();
|
|
61
|
+
let store: CaptureStore | undefined;
|
|
46
62
|
try {
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
63
|
+
const line = await next();
|
|
64
|
+
if (line === undefined) stop();
|
|
65
|
+
const first = JSON.parse(line) as Record<string, unknown>;
|
|
66
|
+
type Scan = { cwd: string; base: string; transcript: unknown; policy?: BriefOptions["policy"]; deadlineAt: number };
|
|
67
|
+
type DrainOptions = BriefOptions & { current: string; event: string; identity?: unknown; deadlineAt: number; ownedGroup: boolean; hookStartAt?: number };
|
|
68
|
+
const scan = first.scan as Scan | undefined;
|
|
69
|
+
const deadlineAt = scan?.deadlineAt ?? first.deadlineAt;
|
|
70
|
+
if (!Number.isFinite(deadlineAt) || first.ownedGroup !== true) stop();
|
|
49
71
|
owned = true;
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
72
|
+
const end = deadlineAt as number;
|
|
73
|
+
if (now() >= end) { if (scan) progress({ stopReason: "deadline" }); stop(); }
|
|
74
|
+
setTimeout(() => { if (scan) progress({ stopReason: "deadline" }); stop(); }, end - now()).unref();
|
|
75
|
+
|
|
76
|
+
// Read the native receipt while scan setup runs. Publication callbacks await it only when replay can proceed.
|
|
77
|
+
const drainLine = scan ? next() : Promise.resolve(line);
|
|
78
|
+
const ready = (async () => {
|
|
79
|
+
const text = await drainLine;
|
|
80
|
+
if (text === undefined) return undefined;
|
|
81
|
+
const options = JSON.parse(text) as DrainOptions;
|
|
82
|
+
if (options.ownedGroup !== true || options.deadlineAt !== end || !(HOOK_EVENTS as readonly string[]).includes(options.event) || options.event === "SessionStart") return undefined;
|
|
83
|
+
const expectedScope = hookCaptureScope(options.cwd, options.base!);
|
|
84
|
+
let project: string | undefined;
|
|
85
|
+
try { project = resolveAlias(options.base!, acceptHookProjectReceipt(options.identity, { cwd: options.cwd, base: options.base!, scope: expectedScope, timeRemaining: () => end - now() })); } catch { /* A bound scan may still stage without a project. */ }
|
|
86
|
+
return { options, expectedScope, project };
|
|
87
|
+
})();
|
|
88
|
+
let receiptReady = false;
|
|
89
|
+
ready.then(() => { receiptReady = true; }, () => { /* The final drain reports failure inside the owned helper. */ });
|
|
90
|
+
const budget = hookReplayBudget();
|
|
91
|
+
let drainedCount = 0, captured = false, drainStarted = false;
|
|
92
|
+
const replay = async (until: number, waitForReady = true) => {
|
|
93
|
+
// Scanning must not wait on native metadata or cold store imports between summary lines.
|
|
94
|
+
// The final replay may await them; publication/backpressure callbacks only use ready resources.
|
|
95
|
+
if (!waitForReady && (!receiptReady || !captureReady)) return false;
|
|
96
|
+
if (!budget.files || !budget.bytes || until - now() < 8) return false;
|
|
97
|
+
const context = await ready;
|
|
98
|
+
if (!context?.project || context.expectedScope === undefined || Math.min(end, until) - now() < 8) return false;
|
|
99
|
+
const { options, expectedScope, project } = context;
|
|
100
|
+
const { CaptureStore } = await captureModule;
|
|
101
|
+
const remaining = () => Math.min(end, until) - now();
|
|
102
|
+
if (remaining() < 8) return false;
|
|
103
|
+
if (!drainStarted) {
|
|
104
|
+
drain({ startMs: Math.round(now() - (options.hookStartAt ?? end)), drained: drainedCount });
|
|
105
|
+
drainStarted = true;
|
|
106
|
+
}
|
|
107
|
+
const drained = await drainHookCaptures({ ...options, base: options.base!, project, expectedScope, timeRemaining: remaining, replayBudget: budget }, (input, receipt) => {
|
|
108
|
+
store ??= new CaptureStore({ memoryDir: options.base!, project, busyTimeoutMs: 0, cwd: options.cwd });
|
|
109
|
+
const appended = store.append(input, { receipt });
|
|
110
|
+
drain({ drained: ++drainedCount }); // DB commit or durable capture-spool acknowledgement, as in the existing contract.
|
|
111
|
+
return appended;
|
|
112
|
+
});
|
|
113
|
+
captured ||= drained.captured.includes(options.current);
|
|
114
|
+
return drained.captured.length > 0;
|
|
115
|
+
};
|
|
116
|
+
let context: Awaited<typeof ready>;
|
|
117
|
+
try {
|
|
118
|
+
if (scan && hookCaptureScope(scan.cwd, scan.base) !== undefined) {
|
|
119
|
+
const scanEnd = end - SCAN_COMMIT_MS;
|
|
120
|
+
if (scanEnd - now() < 5) progress({ stopReason: "deadline" });
|
|
121
|
+
else try {
|
|
122
|
+
const { scanHookTranscriptTail } = await import("./hook-transcript.ts");
|
|
123
|
+
const scanned = await scanHookTranscriptTail(scan.transcript, {
|
|
124
|
+
cwd: scan.cwd, base: scan.base, policy: scan.policy, deadline: performance.now() + (scanEnd - now()),
|
|
125
|
+
// Same existing oldest-first drain and one shared budget, never a new 32-file allowance per publication.
|
|
126
|
+
onPublication: async () => { await replay(scanEnd, false); },
|
|
127
|
+
onBackpressure: () => replay(scanEnd, false),
|
|
128
|
+
});
|
|
129
|
+
if (scanned) progress(scanned);
|
|
130
|
+
} catch { progress({ stopReason: "unavailable" }); }
|
|
131
|
+
}
|
|
132
|
+
context = await ready;
|
|
133
|
+
await replay(end); // Newly staged summaries get their final commit opportunity in this hook too.
|
|
134
|
+
} finally { store?.close(); }
|
|
135
|
+
if (!context?.project || hookCaptureScope(context.options.cwd, context.options.base!) !== context.expectedScope) stop();
|
|
85
136
|
await emit({ context: "", captured, valid: true });
|
|
86
137
|
} catch { if (owned) stop(); process.exitCode = 1; }
|
|
87
138
|
}
|