@remnic/core 10.0.97 → 10.0.99
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/dist/access-admin-ops-surface.js +1 -1
- package/dist/access-cli.js +1 -1
- package/dist/access-extraction-force-flush.js +1 -1
- package/dist/access-http-briefing.js +1 -1
- package/dist/access-http-query.js +1 -1
- package/dist/access-http.js +1 -1
- package/dist/access-identity-continuity-surface.js +1 -1
- package/dist/access-lcm-surface.js +1 -1
- package/dist/access-mcp.js +1 -1
- package/dist/access-observe-write-surface.js +1 -1
- package/dist/access-operations-batch.js +1 -1
- package/dist/access-operations.js +1 -1
- package/dist/access-recall-concurrency.js +1 -1
- package/dist/access-recall-response.js +1 -1
- package/dist/access-recall-surface.js +1 -1
- package/dist/access-service.js +1 -1
- package/dist/{chunk-AMRGZOIP.js → chunk-UBJ5RPDM.js} +2 -2
- package/dist/{chunk-3ETADXEP.js → chunk-VZMRKYNR.js} +40 -3
- package/dist/chunk-VZMRKYNR.js.map +1 -0
- package/dist/cli.js +1 -1
- package/dist/explicit-capture.js +1 -1
- package/dist/index.js +1 -1
- package/dist/orchestration/backlog-scheduler.d.ts +6 -1
- package/dist/orchestration/backlog-scheduler.d.ts.map +1 -1
- package/dist/orchestration/backlog-scheduler.js +20 -4
- package/dist/orchestration/backlog-scheduler.js.map +1 -1
- package/dist/orchestration/buffer-drain-runner.js +2 -2
- package/dist/orchestration/orchestrator-init.d.ts +3 -0
- package/dist/orchestration/orchestrator-init.d.ts.map +1 -1
- package/dist/orchestration/task-exit.d.ts +15 -0
- package/dist/orchestration/task-exit.d.ts.map +1 -0
- package/dist/orchestration/turn-ingestion.d.ts +1 -7
- package/dist/orchestration/turn-ingestion.d.ts.map +1 -1
- package/dist/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator.js +1 -1
- package/dist/recall-navigation.js +1 -1
- package/package.json +2 -2
- package/src/backlog-scheduler.test.ts +48 -0
- package/src/orchestration/backlog-scheduler.ts +23 -2
- package/src/orchestration/extraction-queue-bounded-retention.test.ts +20 -0
- package/src/orchestration/orchestrator-init.ts +25 -0
- package/src/orchestration/task-exit.ts +27 -0
- package/src/orchestration/turn-ingestion.ts +7 -9
- package/src/orchestrator-init.test.ts +132 -1
- package/src/orchestrator.ts +3 -0
- package/dist/chunk-3ETADXEP.js.map +0 -1
- /package/dist/{chunk-AMRGZOIP.js.map → chunk-UBJ5RPDM.js.map} +0 -0
|
@@ -70,6 +70,8 @@ export interface BacklogSchedulerOptions {
|
|
|
70
70
|
pollMs?: number;
|
|
71
71
|
retryBaseMs?: number;
|
|
72
72
|
retryMaxMs?: number;
|
|
73
|
+
/** Bound on `stop()`'s wait for aborted drains to settle. */
|
|
74
|
+
stopSettleTimeoutMs?: number;
|
|
73
75
|
now?: () => number;
|
|
74
76
|
}
|
|
75
77
|
|
|
@@ -114,6 +116,9 @@ interface KeyState {
|
|
|
114
116
|
const DEFAULT_POLL_MS = 15_000;
|
|
115
117
|
const DEFAULT_RETRY_BASE_MS = 30_000;
|
|
116
118
|
const DEFAULT_RETRY_MAX_MS = 15 * 60_000;
|
|
119
|
+
// Aborted drains normally settle within seconds (a chunk boundary); this only
|
|
120
|
+
// bounds the pathological case, well inside the daemon's stop budget.
|
|
121
|
+
const DEFAULT_STOP_SETTLE_TIMEOUT_MS = 30_000;
|
|
117
122
|
|
|
118
123
|
export class BacklogScheduler {
|
|
119
124
|
private readonly deps: BacklogSchedulerDeps;
|
|
@@ -141,6 +146,7 @@ export class BacklogScheduler {
|
|
|
141
146
|
pollMs: options.pollMs ?? DEFAULT_POLL_MS,
|
|
142
147
|
retryBaseMs: options.retryBaseMs ?? DEFAULT_RETRY_BASE_MS,
|
|
143
148
|
retryMaxMs: options.retryMaxMs ?? DEFAULT_RETRY_MAX_MS,
|
|
149
|
+
stopSettleTimeoutMs: options.stopSettleTimeoutMs ?? DEFAULT_STOP_SETTLE_TIMEOUT_MS,
|
|
144
150
|
now: options.now,
|
|
145
151
|
};
|
|
146
152
|
}
|
|
@@ -155,7 +161,10 @@ export class BacklogScheduler {
|
|
|
155
161
|
}
|
|
156
162
|
|
|
157
163
|
/** Idempotent. Stops the timer, aborts in-flight drains (their claims stay
|
|
158
|
-
* retryable) and waits for them to settle.
|
|
164
|
+
* retryable) and waits up to `stopSettleTimeoutMs` for them to settle. A
|
|
165
|
+
* drain that ignores abort is named in a warning and left behind: the
|
|
166
|
+
* caller's later shutdown phases (orchestrator flush and dispose) must
|
|
167
|
+
* still run before the service manager's kill. */
|
|
159
168
|
async stop(): Promise<void> {
|
|
160
169
|
if (this.stopped) return;
|
|
161
170
|
this.stopped = true;
|
|
@@ -168,7 +177,19 @@ export class BacklogScheduler {
|
|
|
168
177
|
entry.controller.abort(new Error("backlog scheduler stopping"));
|
|
169
178
|
aborts.push(entry.settled);
|
|
170
179
|
}
|
|
171
|
-
|
|
180
|
+
const bound = Promise.withResolvers<"timeout">();
|
|
181
|
+
const boundTimer = setTimeout(() => bound.resolve("timeout"), this.options.stopSettleTimeoutMs);
|
|
182
|
+
try {
|
|
183
|
+
const result = await Promise.race([Promise.allSettled(aborts), bound.promise]);
|
|
184
|
+
if (result === "timeout") {
|
|
185
|
+
log.warn(
|
|
186
|
+
`backlog scheduler stop: ${this.inFlight.size} drain(s) ignored abort for ${this.options.stopSettleTimeoutMs}ms; ` +
|
|
187
|
+
`continuing shutdown (stuck keys: ${[...this.inFlight.keys()].join(", ")})`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
} finally {
|
|
191
|
+
clearTimeout(boundTimer);
|
|
192
|
+
}
|
|
172
193
|
}
|
|
173
194
|
|
|
174
195
|
/** Bounded snapshot for ops/watchers: counters + per-key states + live sets. */
|
|
@@ -299,6 +299,26 @@ test("abort while queued: settled task starts, skips extraction, queue drains",
|
|
|
299
299
|
assert.equal(settled.length, 1, "queued task settles exactly once from the abort");
|
|
300
300
|
});
|
|
301
301
|
|
|
302
|
+
test("abort while queued: onTaskExit fires at once, not after the queue head finishes", async () => {
|
|
303
|
+
const h = makeHarness();
|
|
304
|
+
const controller = new AbortController();
|
|
305
|
+
// Occupies the single worker; the drain stays blocked until release().
|
|
306
|
+
await h.coordinator.processTurn("user", "running", "k");
|
|
307
|
+
let exited = 0;
|
|
308
|
+
await h.coordinator.queueBufferedExtraction([makeTurn("queued then aborted")], "trigger_mode", {
|
|
309
|
+
bufferKey: "k",
|
|
310
|
+
preemptable: true,
|
|
311
|
+
abortSignal: controller.signal,
|
|
312
|
+
onTaskExit: () => { exited += 1; },
|
|
313
|
+
});
|
|
314
|
+
controller.abort();
|
|
315
|
+
// A claim-lease owner waiting on this exit must not be held by an unrelated
|
|
316
|
+
// running extraction: nothing of this task ever ran.
|
|
317
|
+
assert.equal(exited, 1, "queued task that never started reports exit on abort");
|
|
318
|
+
await h.release();
|
|
319
|
+
assert.equal(exited, 1, "the later no-op closure run does not report a second exit");
|
|
320
|
+
});
|
|
321
|
+
|
|
302
322
|
test("sharing bound: distinct reference slots equal the union, not the snapshot sum", async () => {
|
|
303
323
|
const h = makeHarness();
|
|
304
324
|
const turns = 30;
|
|
@@ -115,6 +115,7 @@ export class OrchestratorInitCoordinator {
|
|
|
115
115
|
| { epoch: number; controller: AbortController }
|
|
116
116
|
| undefined;
|
|
117
117
|
private readonly startupStorageWrites = new Set<Promise<void>>();
|
|
118
|
+
private readonly startupWarmups = new Set<Promise<void>>();
|
|
118
119
|
|
|
119
120
|
constructor(
|
|
120
121
|
private readonly deps: OrchestratorInitDeps,
|
|
@@ -138,6 +139,11 @@ export class OrchestratorInitCoordinator {
|
|
|
138
139
|
}
|
|
139
140
|
}
|
|
140
141
|
|
|
142
|
+
/** Wait for background startup warm-ups; destroy() only, never the deferredReady gate. */
|
|
143
|
+
async drainStartupWarmups(): Promise<void> {
|
|
144
|
+
await Promise.allSettled([...this.startupWarmups]);
|
|
145
|
+
}
|
|
146
|
+
|
|
141
147
|
async drainEntityMigrations(): Promise<void> {
|
|
142
148
|
await Promise.allSettled([...this.startupStorageWrites]);
|
|
143
149
|
const drains = [this.deps.storage.drainEntityCanonicalIdMigration()];
|
|
@@ -657,6 +663,25 @@ export class OrchestratorInitCoordinator {
|
|
|
657
663
|
|
|
658
664
|
if (signal.aborted || !this.isCurrentStartupEpoch(epoch)) return;
|
|
659
665
|
|
|
666
|
+
// Build the default namespace's authoritative fact-hash index now, in the
|
|
667
|
+
// background. The first explicit save otherwise pays the whole hot+cold
|
|
668
|
+
// corpus rebuild (measured 3.1 s CPU, 11 to 33 s wall on jarvis-ml). A save
|
|
669
|
+
// that arrives mid-build joins this same in-flight promise inside
|
|
670
|
+
// StorageManager, so the rebuild never runs twice. Not awaited: the
|
|
671
|
+
// deferredReady gate must not wait on it, and a failure only means the first
|
|
672
|
+
// save builds the index itself, as before.
|
|
673
|
+
const factHashWarm = (async () => {
|
|
674
|
+
const storage = await this.deps.storageRouter.storageFor(this.deps.config.defaultNamespace);
|
|
675
|
+
await storage.isFactContentHashAuthoritative();
|
|
676
|
+
})().catch((err) => {
|
|
677
|
+
log.debug(`fact-hash index startup warm-up failed (non-fatal): ${err}`);
|
|
678
|
+
});
|
|
679
|
+
// Tracked apart from startupStorageWrites: destroy() waits for it so the
|
|
680
|
+
// rebuild never touches state/ after teardown, but the deferredReady gate
|
|
681
|
+
// (which drains startupStorageWrites) must not.
|
|
682
|
+
this.startupWarmups.add(factHashWarm);
|
|
683
|
+
void factHashWarm.finally(() => this.startupWarmups.delete(factHashWarm));
|
|
684
|
+
|
|
660
685
|
// Warmup: run cheap searches to pre-load QMD embedding models and the
|
|
661
686
|
// embedding-fallback JSON index so the first real recall is fast.
|
|
662
687
|
const warmupPromises: Promise<void>[] = [];
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exit-once bookkeeping for a queued extraction task (`onTaskExit`).
|
|
3
|
+
*
|
|
4
|
+
* A task settled (abort, queue deadline) before its closure ever ran has
|
|
5
|
+
* nothing to exit, so `settled()` reports exit at once; a claim-lease owner
|
|
6
|
+
* waiting on the exit is then not held behind an unrelated running extraction.
|
|
7
|
+
* A closure that did start reports exit when it finishes. Either way the
|
|
8
|
+
* callback fires exactly once.
|
|
9
|
+
*/
|
|
10
|
+
export function createTaskExit(onExit?: () => void) {
|
|
11
|
+
let started = false;
|
|
12
|
+
let reported = false;
|
|
13
|
+
const report = (): void => {
|
|
14
|
+
if (reported) return;
|
|
15
|
+
reported = true;
|
|
16
|
+
onExit?.();
|
|
17
|
+
};
|
|
18
|
+
return {
|
|
19
|
+
start: () => {
|
|
20
|
+
started = true;
|
|
21
|
+
},
|
|
22
|
+
settled: () => {
|
|
23
|
+
if (!started) report();
|
|
24
|
+
},
|
|
25
|
+
report,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
// The class lives in ../buffer-admission.js next to BufferAdmissionRefusedError;
|
|
61
61
|
// re-exported here so existing importers keep their import path.
|
|
62
62
|
import { ReplayAdmissionRefusedError, StorageTargetRequiredError } from "../buffer-admission.js";
|
|
63
|
+
import { createTaskExit } from "./task-exit.js";
|
|
63
64
|
export { ReplayAdmissionRefusedError, StorageTargetRequiredError };
|
|
64
65
|
|
|
65
66
|
export interface TurnIngestionDeps {
|
|
@@ -798,13 +799,7 @@ export class TurnIngestionCoordinator {
|
|
|
798
799
|
abortSignal?: AbortSignal;
|
|
799
800
|
/** Automatic buffered work only. Replay and import must not set this. */
|
|
800
801
|
preemptable?: boolean;
|
|
801
|
-
/**
|
|
802
|
-
* Invoked when the queued closure has FULLY exited (after settlement).
|
|
803
|
-
* Settlement can fire before the closure exits — e.g. an abort listener
|
|
804
|
-
* settles while persistence is still in flight — so callers that
|
|
805
|
-
* release resources on settlement (claim leases) can use this to wait
|
|
806
|
-
* for the actual task exit first.
|
|
807
|
-
*/
|
|
802
|
+
/** Fires once when the task is done: closure exited, or settled before it started. */
|
|
808
803
|
onTaskExit?: () => void;
|
|
809
804
|
/** Fail-closed force-flush only. Not implied by failOnExtractionFailure. */
|
|
810
805
|
preemptRunning?: boolean;
|
|
@@ -845,6 +840,7 @@ export class TurnIngestionCoordinator {
|
|
|
845
840
|
: undefined;
|
|
846
841
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
847
842
|
let settled = false;
|
|
843
|
+
const exit = createTaskExit(options.onTaskExit);
|
|
848
844
|
const clearQueueWaitTimer = (): void => {
|
|
849
845
|
if (timeout) {
|
|
850
846
|
clearTimeout(timeout);
|
|
@@ -867,6 +863,7 @@ export class TurnIngestionCoordinator {
|
|
|
867
863
|
clearQueueWaitTimer();
|
|
868
864
|
clearAbortListener();
|
|
869
865
|
options.onTaskSettled?.(error, result);
|
|
866
|
+
exit.settled();
|
|
870
867
|
return true;
|
|
871
868
|
};
|
|
872
869
|
if (options.abortSignal) {
|
|
@@ -910,6 +907,7 @@ export class TurnIngestionCoordinator {
|
|
|
910
907
|
const queuedTurnsSource: { chain: SnapshotChain } | { direct: BufferTurn[] } =
|
|
911
908
|
sharingNode ? { chain: sharingNode } : { direct: turnsToExtract };
|
|
912
909
|
const accepted = this.deps.extractionQueueCoordinator.enqueue(async (preemptHandle) => {
|
|
910
|
+
exit.start();
|
|
913
911
|
try {
|
|
914
912
|
await this.deps.extractionQueueCoordinator.runPreemptable(preemptHandle, preemptable, options.abortSignal, async (abortSignal) => {
|
|
915
913
|
if (settled) return;
|
|
@@ -1094,13 +1092,13 @@ export class TurnIngestionCoordinator {
|
|
|
1094
1092
|
});
|
|
1095
1093
|
} finally {
|
|
1096
1094
|
if (sharingNode) this.snapshotSharing.release(sharingNode);
|
|
1097
|
-
|
|
1095
|
+
exit.report();
|
|
1098
1096
|
}
|
|
1099
1097
|
}, { front: options.preemptRunning === true, key: bufferKey });
|
|
1100
1098
|
if (!accepted) {
|
|
1101
1099
|
if (sharingNode) this.snapshotSharing.release(sharingNode);
|
|
1102
1100
|
settleTask(new Error("extraction queue is stopped"));
|
|
1103
|
-
|
|
1101
|
+
exit.report();
|
|
1104
1102
|
return;
|
|
1105
1103
|
}
|
|
1106
1104
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import test from "node:test";
|
|
@@ -1595,3 +1595,134 @@ for (const namespace of ["default", "shared"]) {
|
|
|
1595
1595
|
}
|
|
1596
1596
|
});
|
|
1597
1597
|
}
|
|
1598
|
+
|
|
1599
|
+
test("daemon start builds the fact-hash index once in the background and a save right after start joins it", async () => {
|
|
1600
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-warm-fact-hash-"));
|
|
1601
|
+
const orchestrator = new Orchestrator(
|
|
1602
|
+
parseConfig({
|
|
1603
|
+
memoryDir,
|
|
1604
|
+
workspaceDir: path.join(memoryDir, "workspace"),
|
|
1605
|
+
qmdEnabled: false,
|
|
1606
|
+
namespacesEnabled: true,
|
|
1607
|
+
namespaceCatalogEnabled: false,
|
|
1608
|
+
defaultNamespace: "default",
|
|
1609
|
+
sharedNamespace: "shared",
|
|
1610
|
+
namespacePolicies: [],
|
|
1611
|
+
embeddingFallbackEnabled: false,
|
|
1612
|
+
identityContinuityEnabled: false,
|
|
1613
|
+
transcriptEnabled: false,
|
|
1614
|
+
hourlySummariesEnabled: false,
|
|
1615
|
+
compoundingEnabled: false,
|
|
1616
|
+
knowledgeIndexEnabled: false,
|
|
1617
|
+
}),
|
|
1618
|
+
);
|
|
1619
|
+
let releaseCorpus: (() => void) | undefined;
|
|
1620
|
+
try {
|
|
1621
|
+
const storage = await orchestrator.getStorage("default");
|
|
1622
|
+
const realRead = storage.readAllMemories.bind(storage);
|
|
1623
|
+
let corpusReads = 0;
|
|
1624
|
+
let signalStarted!: () => void;
|
|
1625
|
+
const rebuildStarted = new Promise<void>((resolve) => { signalStarted = resolve; });
|
|
1626
|
+
const corpusHeld = new Promise<void>((resolve) => { releaseCorpus = resolve; });
|
|
1627
|
+
storage.readAllMemories = async () => {
|
|
1628
|
+
corpusReads += 1;
|
|
1629
|
+
signalStarted();
|
|
1630
|
+
await corpusHeld;
|
|
1631
|
+
return realRead();
|
|
1632
|
+
};
|
|
1633
|
+
|
|
1634
|
+
assert.notEqual(await withTestDeadline(orchestrator.initialize()), "deadline", "initialize must not wait on the held rebuild");
|
|
1635
|
+
// SAFETY: test reads the private deferredReady gate, as the neighbouring tests do.
|
|
1636
|
+
assert.notEqual(
|
|
1637
|
+
await withTestDeadline((orchestrator as any).deferredReady),
|
|
1638
|
+
"deadline",
|
|
1639
|
+
"deferredReady must not wait on the held rebuild",
|
|
1640
|
+
);
|
|
1641
|
+
let giveUp: NodeJS.Timeout | undefined;
|
|
1642
|
+
const outcome = await Promise.race([
|
|
1643
|
+
rebuildStarted.then(() => "started" as const),
|
|
1644
|
+
new Promise<"not-started">((resolve) => { giveUp = setTimeout(() => resolve("not-started"), 3_000); }),
|
|
1645
|
+
]);
|
|
1646
|
+
clearTimeout(giveUp);
|
|
1647
|
+
assert.equal(outcome, "started", "start must begin the fact-hash corpus rebuild before any save arrives");
|
|
1648
|
+
|
|
1649
|
+
// Two saves landing while the warm-up still runs: both wait on that one flight.
|
|
1650
|
+
const saves = Promise.all([storage.lookupContentHash("first save"), storage.lookupContentHash("second save")]);
|
|
1651
|
+
releaseCorpus?.();
|
|
1652
|
+
const results = await withTestDeadline(saves);
|
|
1653
|
+
if (results === "deadline") assert.fail("saves joining the warm-up never settled");
|
|
1654
|
+
assert.equal(corpusReads, 1, "the saves must not start a second corpus rebuild");
|
|
1655
|
+
assert.deepEqual(results.map((r) => r.authoritative), [true, true]);
|
|
1656
|
+
} finally {
|
|
1657
|
+
releaseCorpus?.();
|
|
1658
|
+
await orchestrator.destroy().catch(() => undefined);
|
|
1659
|
+
await rm(memoryDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
|
|
1660
|
+
}
|
|
1661
|
+
});
|
|
1662
|
+
|
|
1663
|
+
test("destroy persists a buffered turn before it waits on a running fact-hash warm-up", async () => {
|
|
1664
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-warm-destroy-flush-"));
|
|
1665
|
+
const marker = "w7p-buffered-turn-marker";
|
|
1666
|
+
const orchestrator = new Orchestrator(
|
|
1667
|
+
parseConfig({
|
|
1668
|
+
memoryDir,
|
|
1669
|
+
workspaceDir: path.join(memoryDir, "workspace"),
|
|
1670
|
+
qmdEnabled: false,
|
|
1671
|
+
namespacesEnabled: true,
|
|
1672
|
+
namespaceCatalogEnabled: false,
|
|
1673
|
+
defaultNamespace: "default",
|
|
1674
|
+
sharedNamespace: "shared",
|
|
1675
|
+
namespacePolicies: [],
|
|
1676
|
+
bufferSaveDebounceMs: 600_000,
|
|
1677
|
+
embeddingFallbackEnabled: false,
|
|
1678
|
+
identityContinuityEnabled: false,
|
|
1679
|
+
transcriptEnabled: false,
|
|
1680
|
+
hourlySummariesEnabled: false,
|
|
1681
|
+
compoundingEnabled: false,
|
|
1682
|
+
knowledgeIndexEnabled: false,
|
|
1683
|
+
}),
|
|
1684
|
+
);
|
|
1685
|
+
let releaseCorpus: (() => void) | undefined;
|
|
1686
|
+
try {
|
|
1687
|
+
const storage = await orchestrator.getStorage("default");
|
|
1688
|
+
const realRead = storage.readAllMemories.bind(storage);
|
|
1689
|
+
let signalStarted!: () => void;
|
|
1690
|
+
const rebuildStarted = new Promise<void>((resolve) => { signalStarted = resolve; });
|
|
1691
|
+
const corpusHeld = new Promise<void>((resolve) => { releaseCorpus = resolve; });
|
|
1692
|
+
storage.readAllMemories = async () => {
|
|
1693
|
+
signalStarted();
|
|
1694
|
+
await corpusHeld;
|
|
1695
|
+
return realRead();
|
|
1696
|
+
};
|
|
1697
|
+
assert.notEqual(await withTestDeadline(orchestrator.initialize()), "deadline", "initialize must not wait on the held rebuild");
|
|
1698
|
+
assert.notEqual(await withTestDeadline(rebuildStarted), "deadline", "the warm-up must start the rebuild");
|
|
1699
|
+
|
|
1700
|
+
await orchestrator.buffer.addTurn("warm-destroy-key", {
|
|
1701
|
+
role: "user",
|
|
1702
|
+
content: marker,
|
|
1703
|
+
timestamp: new Date().toISOString(),
|
|
1704
|
+
});
|
|
1705
|
+
const bufferOnDisk = async (): Promise<boolean> => {
|
|
1706
|
+
for (const name of await readdir(memoryDir, { recursive: true })) {
|
|
1707
|
+
if ((await readFile(path.join(memoryDir, name), "utf8").catch(() => "")).includes(marker)) return true;
|
|
1708
|
+
}
|
|
1709
|
+
return false;
|
|
1710
|
+
};
|
|
1711
|
+
assert.equal(await bufferOnDisk(), false, "precondition: the debounced save has not run");
|
|
1712
|
+
|
|
1713
|
+
const destroying = orchestrator.destroy();
|
|
1714
|
+
const startedAt = Date.now();
|
|
1715
|
+
let persisted = false;
|
|
1716
|
+
while (Date.now() - startedAt < 1_500 && !persisted) {
|
|
1717
|
+
persisted = await bufferOnDisk();
|
|
1718
|
+
if (!persisted) await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1719
|
+
}
|
|
1720
|
+
assert.equal(persisted, true, "the buffered turn must reach disk within 1.5 s of destroy() while the corpus read is held");
|
|
1721
|
+
releaseCorpus?.();
|
|
1722
|
+
assert.notEqual(await withTestDeadline(destroying), "deadline", "destroy must finish once the rebuild is released");
|
|
1723
|
+
} finally {
|
|
1724
|
+
releaseCorpus?.();
|
|
1725
|
+
await orchestrator.destroy().catch(() => undefined);
|
|
1726
|
+
await rm(memoryDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
|
|
1727
|
+
}
|
|
1728
|
+
});
|
package/src/orchestrator.ts
CHANGED
|
@@ -1025,6 +1025,9 @@ export class Orchestrator {
|
|
|
1025
1025
|
} catch (err) {
|
|
1026
1026
|
bufferFlushError = err;
|
|
1027
1027
|
}
|
|
1028
|
+
// After the buffer flush: a long fact-hash warm-up must never delay
|
|
1029
|
+
// persisting unsent turns (a kill during the wait would lose them).
|
|
1030
|
+
await this._orchestratorInitCoordinator?.drainStartupWarmups();
|
|
1028
1031
|
const extractionDrainError = await drainExtractionAndShutdownDependencyPropagation(
|
|
1029
1032
|
this.extractionQueueCoordinator,
|
|
1030
1033
|
this._dependencyPropagationDelivery,
|