serialized audit: the audit+merge critical section is process-wide, one run at a time (owner 2026-09-28)
Extraction may run in parallel across sources (extractors are source-local and graph-blind), but the auditor explores the LIVE graph through its tools and stamps identity verdicts against what it finds — two concurrent audits would each link into a graph the other is about to change, the structural mint of duplicate nodes. withAuditLock: a FIFO promise-chain mutex on globalThis (the same module-instance idiom as the run registries — a module-level lock would be per-route-instance and useless in dev). The check runs before any LLM is invoked or the graph read; the lock spans audit through merge inclusive (the merge is the graph mutation) and is reacquired per batch, so interleaved multi-batch runs each audit against the latest graph instead of starving each other. Applied at both front doors — chat batches and notes imports. New 'waiting' phase (extracting → waiting → auditing → merging): visible in the status feed so a queued run never looks stalled in extracting. Abort-aware on both sides of the queue: a run aborted while waiting leaves without auditing (its chain link still releases — successors never hang); a run aborted mid-audit releases in finally. Tests pin: a second run queues at the gate and its auditor is invoked only after the first run's merge is in the ledgers (the post-merge-graph invariant itself); a run aborted while waiting never audits, checkpoints nothing, and leaves a healthy chain for a third run. Gates: typecheck, 1061/1061, lint, build.
This commit is contained in:
@@ -480,8 +480,12 @@ function runAuditor(
|
||||
* The post-batch embedding tail was its own phase (accuracy wave,
|
||||
* 2026-09-19) until wave 7 killed the embedder seam wholesale (this
|
||||
* interlude) — birth-session clusters need no post-merge pass. The
|
||||
* phases tell the truth about what the loop is actually waiting on. */
|
||||
export type ExtractionPhase = "extracting" | "auditing" | "merging";
|
||||
* phases tell the truth about what the loop is actually waiting on.
|
||||
* "waiting" (owner 2026-09-28): the serialized-audit gate — the run's
|
||||
* audit+merge critical section is queued behind another run's; the
|
||||
* extractor is done but the auditor may not start until the graph is
|
||||
* stable. */
|
||||
export type ExtractionPhase = "extracting" | "waiting" | "auditing" | "merging";
|
||||
|
||||
/** One in-flight pipeline run. Module-level: one process, one registry.
|
||||
* Exported for the status payload's consumer typing (the dock). */
|
||||
@@ -517,6 +521,11 @@ interface LhExtractionGlobal {
|
||||
* same millisecond never collide on the usage idempotency key. */
|
||||
lastStartedAt: number;
|
||||
};
|
||||
/** The serialized-audit mutex (owner 2026-09-28): a FIFO promise
|
||||
* chain — the audit+merge critical section runs one run at a time,
|
||||
* process-wide. globalThis for the same module-instance reason as
|
||||
* the run registries. */
|
||||
__lhAuditLock?: { tail: Promise<void> };
|
||||
}
|
||||
|
||||
/** THE RUN REGISTRIES live on globalThis, not module level — the
|
||||
@@ -681,6 +690,46 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
run.usage[stage]!.steps.push(usage);
|
||||
}
|
||||
|
||||
/** SERIALIZED AUDIT (owner 2026-09-28): the auditor explores the LIVE
|
||||
* graph through its tools and stamps identity verdicts against what
|
||||
* it finds — so the audit+merge section is a process-wide critical
|
||||
* section. Two concurrent audits would each link into a graph the
|
||||
* other is about to change: the structural mint of duplicate nodes.
|
||||
* The extractor stays parallel (source-local, graph-blind). The
|
||||
* check runs BEFORE any LLM is invoked or the graph read; the lock
|
||||
* is FIFO, held through the merge, reacquired per batch, and
|
||||
* abort-aware on both sides of the queue (a waiter that aborts
|
||||
* leaves without auditing — its chain link still resolves, so
|
||||
* successors never hang). */
|
||||
async function withAuditLock<T>(chatId: string, kind: "chat" | "notes", label: string | undefined, critical: () => Promise<T>): Promise<T> {
|
||||
setPhase(chatId, "waiting", kind, label);
|
||||
const g = globalThis as typeof globalThis & LhExtractionGlobal;
|
||||
const lock = (g.__lhAuditLock ??= { tail: Promise.resolve() });
|
||||
const prev = lock.tail;
|
||||
let release!: () => void;
|
||||
lock.tail = new Promise<void>((resolve) => (release = resolve));
|
||||
const signal = runningExtractions.get(chatId)?.controller.signal;
|
||||
let onAbort: (() => void) | undefined;
|
||||
const aborted = new Promise<never>((_, reject) => {
|
||||
if (!signal) return;
|
||||
onAbort = () => {
|
||||
const err = new Error("extraction aborted while waiting for the audit gate");
|
||||
err.name = "AbortError";
|
||||
reject(err);
|
||||
};
|
||||
if (signal.aborted) onAbort();
|
||||
else signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
try {
|
||||
await Promise.race([prev, aborted]);
|
||||
signal?.throwIfAborted(); // aborted exactly at handoff
|
||||
return await critical();
|
||||
} finally {
|
||||
if (onAbort && signal && !signal.aborted) signal.removeEventListener("abort", onAbort);
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
async function extract(chatId: string, view: string): Promise<SessionFindings> {
|
||||
const signal = runningExtractions.get(chatId)?.controller.signal;
|
||||
signal?.throwIfAborted(); // an abort during a sync gap ends the run here
|
||||
@@ -808,36 +857,44 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
);
|
||||
setPhase(chatId, "extracting", "chat");
|
||||
const findings = await extract(chatId, view);
|
||||
setPhase(chatId, "auditing", "chat");
|
||||
const auditResult = await audit(chatId, findings);
|
||||
setPhase(chatId, "merging", "chat");
|
||||
// SESSION-DELETION GUARD (wave 6): a chat deleted mid-run must never
|
||||
// merge. The deletion route aborts this run first and runs its purge
|
||||
// as one sync block; this closes the last window — a run that slipped
|
||||
// past the wind-down finds the chat gone HERE and rides the existing
|
||||
// abort path (info, not error): nothing merged, nothing checkpointed.
|
||||
if (!chatStore.getChat(chatId)) {
|
||||
const gone = new Error(`chat deleted mid-run: ${chatId}`);
|
||||
gone.name = "AbortError";
|
||||
throw gone;
|
||||
}
|
||||
// The batch's messages, snapshotted when the run computed
|
||||
// coverage — if a message mutates mid-run (a quiz answer
|
||||
// merges in), the stored fingerprint won't match its new
|
||||
// one and it re-opens: honest by construction.
|
||||
const foldedAt = new Date().toISOString();
|
||||
const stats = mergeFindings(chatId, auditResult, { chatStore, graphStore }, {
|
||||
foldedAt,
|
||||
coverage: batch
|
||||
.filter((slot) => messageViewLines(slot.message, slot.message.id).length > 0)
|
||||
.map((slot) => ({ chatId, fp: slot.fp, tokens: slot.tokens, foldedAt })),
|
||||
// SERIALIZED AUDIT (owner 2026-09-28): audit + merge are the
|
||||
// graph-mutating critical section — one run at a time,
|
||||
// process-wide, so the auditor explores the graph as it
|
||||
// stands AFTER every earlier run's merge. Reacquired per
|
||||
// batch: interleaved runs each audit against the latest
|
||||
// graph instead of starving each other.
|
||||
await withAuditLock(chatId, "chat", undefined, async () => {
|
||||
setPhase(chatId, "auditing", "chat");
|
||||
const auditResult = await audit(chatId, findings);
|
||||
setPhase(chatId, "merging", "chat");
|
||||
// SESSION-DELETION GUARD (wave 6): a chat deleted mid-run must never
|
||||
// merge. The deletion route aborts this run first and runs its purge
|
||||
// as one sync block; this closes the last window — a run that slipped
|
||||
// past the wind-down finds the chat gone HERE and rides the existing
|
||||
// abort path (info, not error): nothing merged, nothing checkpointed.
|
||||
if (!chatStore.getChat(chatId)) {
|
||||
const gone = new Error(`chat deleted mid-run: ${chatId}`);
|
||||
gone.name = "AbortError";
|
||||
throw gone;
|
||||
}
|
||||
// The batch's messages, snapshotted when the run computed
|
||||
// coverage — if a message mutates mid-run (a quiz answer
|
||||
// merges in), the stored fingerprint won't match its new
|
||||
// one and it re-opens: honest by construction.
|
||||
const foldedAt = new Date().toISOString();
|
||||
const batchStats = mergeFindings(chatId, auditResult, { chatStore, graphStore }, {
|
||||
foldedAt,
|
||||
coverage: batch
|
||||
.filter((slot) => messageViewLines(slot.message, slot.message.id).length > 0)
|
||||
.map((slot) => ({ chatId, fp: slot.fp, tokens: slot.tokens, foldedAt })),
|
||||
});
|
||||
total.nodes += batchStats.nodes;
|
||||
total.edges += batchStats.edges;
|
||||
});
|
||||
total.nodes += stats.nodes;
|
||||
total.edges += stats.edges;
|
||||
batchesDone++;
|
||||
console.info(
|
||||
`[extraction ${chatId}] batch ${batchesDone}/${batches.length} merged: ` +
|
||||
`${stats.nodes} nodes, ${stats.edges} edges`,
|
||||
`${total.nodes} nodes, ${total.edges} edges (cumulative)`,
|
||||
);
|
||||
}
|
||||
const stats = total;
|
||||
@@ -918,10 +975,15 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
console.info(`[extraction ${id}] notes pipeline start (${input.filePath})`);
|
||||
setPhase(id, "extracting", "notes", input.filePath);
|
||||
const findings = await notesExtract(input);
|
||||
setPhase(id, "auditing", "notes", input.filePath);
|
||||
const auditResult = await audit(id, findings);
|
||||
setPhase(id, "merging", "notes", input.filePath);
|
||||
const stats = mergeFindings(id, auditResult, { chatStore, graphStore }, { foldedAt: input.importedAt });
|
||||
// SERIALIZED AUDIT (owner 2026-09-28): the same gate as the chat
|
||||
// path — a notes import and a chat run may extract in parallel,
|
||||
// but audit + merge one at a time, process-wide.
|
||||
const stats = await withAuditLock(id, "notes", input.filePath, async () => {
|
||||
setPhase(id, "auditing", "notes", input.filePath);
|
||||
const auditResult = await audit(id, findings);
|
||||
setPhase(id, "merging", "notes", input.filePath);
|
||||
return mergeFindings(id, auditResult, { chatStore, graphStore }, { foldedAt: input.importedAt });
|
||||
});
|
||||
// Same post-merge settle as the chat path.
|
||||
settleGraphLayout(graphStore);
|
||||
persistRunUsage(id, Math.round(performance.now() - t0), stats);
|
||||
|
||||
Reference in New Issue
Block a user