@indigoai-us/hq-cli 5.94.3 → 5.95.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.
|
@@ -9,7 +9,7 @@ export type SearchIndexDependencies = {
|
|
|
9
9
|
resolveQmdVersion: () => string | undefined;
|
|
10
10
|
runQmd: (args: string[], options?: RunQmdOptions) => QmdProcessResult;
|
|
11
11
|
runBackgroundLauncher?: (dependencies: BackgroundDependencies) => BackgroundResult;
|
|
12
|
-
runBackgroundWorker?: (dependencies: BackgroundDependencies) => BackgroundResult
|
|
12
|
+
runBackgroundWorker?: (dependencies: BackgroundDependencies) => BackgroundResult | Promise<BackgroundResult>;
|
|
13
13
|
backgroundStatus?: (dependencies: BackgroundDependencies) => BackgroundStatus;
|
|
14
14
|
};
|
|
15
15
|
/** Incrementally update qmd, embedding only when an operator explicitly asks. */
|
|
@@ -73,13 +73,13 @@ export function registerIndexCommand(program, dependencies = defaults) {
|
|
|
73
73
|
.option('--log <path>', 'Write worker output to this log file')
|
|
74
74
|
.addOption(new Option('--worker').hideHelp())
|
|
75
75
|
.option('--hq-root <path>', 'HQ root to index (defaults to auto-detected root)')
|
|
76
|
-
.action((options) => {
|
|
76
|
+
.action(async (options) => {
|
|
77
77
|
const hqRoot = resolveRoot(options.hqRoot);
|
|
78
78
|
const background = makeBackgroundDependencies(hqRoot, dependencies);
|
|
79
79
|
if (options.log)
|
|
80
80
|
background.env = { ...background.env, QMD_REINDEX_LOG: options.log };
|
|
81
81
|
const result = options.worker
|
|
82
|
-
? (dependencies.runBackgroundWorker ?? runBackgroundWorker)(background)
|
|
82
|
+
? await (dependencies.runBackgroundWorker ?? runBackgroundWorker)(background)
|
|
83
83
|
: (dependencies.runBackgroundLauncher ?? runBackgroundLauncher)(background);
|
|
84
84
|
if (!options.worker && result.state === 'launched')
|
|
85
85
|
console.log(result.pid);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type QmdProcessResult, type RunQmdOptions } from './index.js';
|
|
2
2
|
export type BackgroundResult = {
|
|
3
|
-
state: 'skipped-agent' | 'skipped' | 'quiet' | 'busy' | 'completed' | 'update-failed';
|
|
3
|
+
state: 'skipped-agent' | 'skipped' | 'quiet' | 'busy' | 'completed' | 'update-failed' | 'terminated';
|
|
4
4
|
} | {
|
|
5
5
|
state: 'launched';
|
|
6
6
|
pid: number;
|
|
@@ -20,6 +20,9 @@ export type BackgroundDependencies = {
|
|
|
20
20
|
}) => number;
|
|
21
21
|
/** Test seam for simulating a competing owner replacing the atomic record. */
|
|
22
22
|
afterOwnerPublish?: (ownerFile: string) => void;
|
|
23
|
+
/** Test seams for signal delivery; production uses the real process. */
|
|
24
|
+
processEvents?: Pick<NodeJS.Process, 'once'>;
|
|
25
|
+
exit?: (code: number) => void;
|
|
23
26
|
};
|
|
24
27
|
export type BackgroundStatus = {
|
|
25
28
|
lock: 'held' | 'stale' | 'free';
|
|
@@ -33,7 +36,7 @@ export declare function installWorkerCleanup(cleanup: () => void, processEvents?
|
|
|
33
36
|
/** Start a detached worker; this public entry never owns the qmd pipeline. */
|
|
34
37
|
export declare function runBackgroundLauncher(dependencies: BackgroundDependencies): BackgroundResult;
|
|
35
38
|
/** Run the single-flight cleanup → update → embed pipeline in a worker only. */
|
|
36
|
-
export declare function runBackgroundWorker(dependencies: BackgroundDependencies): BackgroundResult
|
|
39
|
+
export declare function runBackgroundWorker(dependencies: BackgroundDependencies): Promise<BackgroundResult>;
|
|
37
40
|
/** Report the background lock and latest successful completion for `hq index status`. */
|
|
38
41
|
export declare function backgroundStatus(dependencies: BackgroundDependencies): BackgroundStatus;
|
|
39
42
|
//# sourceMappingURL=background.d.ts.map
|
|
@@ -170,6 +170,10 @@ function acquireClaim(home, observedGeneration, dependencies) {
|
|
|
170
170
|
const claimant = path.join(claim, `c.${dependencies.pid}.${dependencies.random()}`);
|
|
171
171
|
try {
|
|
172
172
|
fs.mkdirSync(claimant);
|
|
173
|
+
// Test-only deterministic failure inject (unset in production) — shell
|
|
174
|
+
// parity with the script's claimant write_owner_record injection.
|
|
175
|
+
if (dependencies.env.QMD_FORCE_CLAIMANT_WRITE_FAIL)
|
|
176
|
+
throw new Error('forced claimant write failure');
|
|
173
177
|
fs.writeFileSync(path.join(claimant, 'owner'), `pid=${dependencies.pid}\nts=${dependencies.now()}\n`);
|
|
174
178
|
return { claim, claimant };
|
|
175
179
|
}
|
|
@@ -236,6 +240,10 @@ function createAndPublishLock(home, dependencies) {
|
|
|
236
240
|
const ownerFile = path.join(directory, 'owner');
|
|
237
241
|
const temporary = path.join(directory, `.owner.tmp.${dependencies.pid}.${dependencies.random()}`);
|
|
238
242
|
try {
|
|
243
|
+
// Test-only deterministic failure inject (unset in production) — shell
|
|
244
|
+
// parity: the script's write_owner_record honored the same variable.
|
|
245
|
+
if (dependencies.env.QMD_FORCE_OWNER_WRITE_FAIL)
|
|
246
|
+
throw new Error('forced owner write failure');
|
|
239
247
|
fs.writeFileSync(temporary, `pid=${dependencies.pid}\nts=${dependencies.now()}\nnonce=${nonce}\n`);
|
|
240
248
|
fs.renameSync(temporary, ownerFile);
|
|
241
249
|
dependencies.afterOwnerPublish?.(ownerFile);
|
|
@@ -290,11 +298,52 @@ function releaseLock(home, dependencies) {
|
|
|
290
298
|
export function installWorkerCleanup(cleanup, processEvents = process, exit = () => undefined) {
|
|
291
299
|
processEvents.once('exit', cleanup);
|
|
292
300
|
// Unlike Bash, Node cannot turn a SIGKILL or an already-defaulted signal into
|
|
293
|
-
// catchable cleanup. SIGINT/SIGTERM are registered here
|
|
294
|
-
//
|
|
295
|
-
//
|
|
301
|
+
// catchable cleanup. SIGINT/SIGTERM/SIGHUP are registered here (the shell
|
|
302
|
+
// worker trapped `INT TERM HUP`; a detached worker's controlling terminal
|
|
303
|
+
// going away delivers HUP, and the default disposition would kill the
|
|
304
|
+
// process without releasing the lock) and the CLI's normal process exit then
|
|
305
|
+
// runs the same idempotent owner release. Exiting prevents a synchronous
|
|
306
|
+
// pipeline from continuing after it has released ownership.
|
|
296
307
|
processEvents.once('SIGINT', () => { cleanup(); exit(0); });
|
|
297
308
|
processEvents.once('SIGTERM', () => { cleanup(); exit(0); });
|
|
309
|
+
processEvents.once('SIGHUP', () => { cleanup(); exit(0); });
|
|
310
|
+
}
|
|
311
|
+
function workerLogPath(env) {
|
|
312
|
+
return env.QMD_REINDEX_LOG
|
|
313
|
+
?? env.QMD_HANDOFF_LOG
|
|
314
|
+
?? path.join(env.HANDOFF_LOG_DIR ?? '/tmp', 'qmd-handoff.log');
|
|
315
|
+
}
|
|
316
|
+
function appendWorkerLog(logPath, text) {
|
|
317
|
+
if (!text)
|
|
318
|
+
return;
|
|
319
|
+
try {
|
|
320
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
321
|
+
fs.appendFileSync(logPath, text.endsWith('\n') ? text : `${text}\n`);
|
|
322
|
+
}
|
|
323
|
+
catch { /* logging is best-effort, exactly like the shell worker's >>"$LOG" */ }
|
|
324
|
+
}
|
|
325
|
+
/** Keep only the trailing QMD_HANDOFF_LOG_MAX_BYTES of the worker log (shell cap_log parity). */
|
|
326
|
+
function capWorkerLog(logPath, env) {
|
|
327
|
+
const raw = env.QMD_HANDOFF_LOG_MAX_BYTES ?? '65536';
|
|
328
|
+
if (!/^\d+$/.test(raw))
|
|
329
|
+
return;
|
|
330
|
+
const max = Number(raw);
|
|
331
|
+
if (max === 0)
|
|
332
|
+
return;
|
|
333
|
+
try {
|
|
334
|
+
if (!fs.existsSync(logPath) || fs.statSync(logPath).size <= max)
|
|
335
|
+
return;
|
|
336
|
+
const content = fs.readFileSync(logPath);
|
|
337
|
+
fs.writeFileSync(logPath, content.subarray(content.length - max));
|
|
338
|
+
}
|
|
339
|
+
catch { /* best-effort, matching the shell's cap_log */ }
|
|
340
|
+
}
|
|
341
|
+
function stepOutput(result) {
|
|
342
|
+
return `${result.stdout ?? ''}${result.stderr ?? ''}`;
|
|
343
|
+
}
|
|
344
|
+
function errorOutput(error) {
|
|
345
|
+
const e = error;
|
|
346
|
+
return `${e.stdout ?? ''}${e.stderr ?? ''}` || (e.message ?? '');
|
|
298
347
|
}
|
|
299
348
|
/** Start a detached worker; this public entry never owns the qmd pipeline. */
|
|
300
349
|
export function runBackgroundLauncher(dependencies) {
|
|
@@ -309,13 +358,10 @@ export function runBackgroundLauncher(dependencies) {
|
|
|
309
358
|
catch {
|
|
310
359
|
return { state: 'skipped' };
|
|
311
360
|
}
|
|
312
|
-
|
|
313
|
-
?? dependencies.env.QMD_HANDOFF_LOG
|
|
314
|
-
?? path.join(dependencies.env.HANDOFF_LOG_DIR ?? '/tmp', 'qmd-handoff.log');
|
|
315
|
-
return { state: 'launched', pid: dependencies.spawnWorker({ logPath }) };
|
|
361
|
+
return { state: 'launched', pid: dependencies.spawnWorker({ logPath: workerLogPath(dependencies.env) }) };
|
|
316
362
|
}
|
|
317
363
|
/** Run the single-flight cleanup → update → embed pipeline in a worker only. */
|
|
318
|
-
export function runBackgroundWorker(dependencies) {
|
|
364
|
+
export async function runBackgroundWorker(dependencies) {
|
|
319
365
|
if (isHostedAgent(dependencies.env))
|
|
320
366
|
return { state: 'skipped-agent' };
|
|
321
367
|
const home = dependencies.env.HOME;
|
|
@@ -329,14 +375,32 @@ export function runBackgroundWorker(dependencies) {
|
|
|
329
375
|
}
|
|
330
376
|
if (isRecentCompletion(home, dependencies) || !acquireLock(home, dependencies))
|
|
331
377
|
return { state: 'busy' };
|
|
378
|
+
// Shell-worker log parity: each step's captured output appends to the
|
|
379
|
+
// handoff log and the log keeps only its trailing QMD_HANDOFF_LOG_MAX_BYTES.
|
|
380
|
+
// The cap lives inside the exit cleanup because a signal-path process.exit
|
|
381
|
+
// never unwinds to a `finally` — the shell capped in its EXIT trap for the
|
|
382
|
+
// same reason.
|
|
383
|
+
const logPath = workerLogPath(dependencies.env);
|
|
332
384
|
let released = false;
|
|
333
385
|
const cleanup = () => {
|
|
334
386
|
if (released)
|
|
335
387
|
return;
|
|
336
388
|
released = true;
|
|
337
389
|
releaseLock(home, dependencies);
|
|
390
|
+
capWorkerLog(logPath, dependencies.env);
|
|
391
|
+
};
|
|
392
|
+
installWorkerCleanup(cleanup, dependencies.processEvents ?? process, dependencies.exit ?? ((code) => process.exit(code)));
|
|
393
|
+
// A signal that lands during a synchronous qmd step cannot interrupt it, and
|
|
394
|
+
// Node defers the handler until the event loop next turns — which a fully
|
|
395
|
+
// synchronous pipeline never lets happen, so SIGTERM used to be processed
|
|
396
|
+
// only AFTER update/embed had already run. Bash traps fire between commands;
|
|
397
|
+
// yielding one event-loop turn between steps restores that contract. If the
|
|
398
|
+
// handler ran (production exits; tests inject `exit`), `released` is set and
|
|
399
|
+
// the pipeline stops before its next step.
|
|
400
|
+
const signalWindow = async () => {
|
|
401
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
402
|
+
return released;
|
|
338
403
|
};
|
|
339
|
-
installWorkerCleanup(cleanup, process, (code) => process.exit(code));
|
|
340
404
|
try {
|
|
341
405
|
if (isRecentCompletion(home, dependencies))
|
|
342
406
|
return { state: 'busy' };
|
|
@@ -349,21 +413,36 @@ export function runBackgroundWorker(dependencies) {
|
|
|
349
413
|
// The shell worker has no collection-registration step. Keep this #306
|
|
350
414
|
// integration best-effort so it cannot suppress a later index update.
|
|
351
415
|
}
|
|
416
|
+
if (await signalWindow())
|
|
417
|
+
return { state: 'terminated' };
|
|
352
418
|
try {
|
|
353
|
-
dependencies.runQmd(['cleanup'], { cwd: dependencies.hqRoot });
|
|
419
|
+
appendWorkerLog(logPath, stepOutput(dependencies.runQmd(['cleanup'], { cwd: dependencies.hqRoot })));
|
|
354
420
|
}
|
|
355
|
-
catch {
|
|
421
|
+
catch (error) {
|
|
422
|
+
appendWorkerLog(logPath, errorOutput(error)); // cleanup is intentionally best-effort
|
|
423
|
+
}
|
|
424
|
+
if (await signalWindow())
|
|
425
|
+
return { state: 'terminated' };
|
|
356
426
|
try {
|
|
357
|
-
dependencies.runQmd(['update'], { cwd: dependencies.hqRoot });
|
|
427
|
+
appendWorkerLog(logPath, stepOutput(dependencies.runQmd(['update'], { cwd: dependencies.hqRoot })));
|
|
358
428
|
}
|
|
359
|
-
catch {
|
|
429
|
+
catch (error) {
|
|
430
|
+
appendWorkerLog(logPath, errorOutput(error));
|
|
431
|
+
appendWorkerLog(logPath, `[qmd-reindex-bg] update-failed ts=${dependencies.now()}`);
|
|
360
432
|
return { state: 'update-failed' };
|
|
361
433
|
}
|
|
434
|
+
if (await signalWindow())
|
|
435
|
+
return { state: 'terminated' };
|
|
362
436
|
try {
|
|
363
|
-
dependencies.runQmd(['embed'], { cwd: dependencies.hqRoot });
|
|
437
|
+
appendWorkerLog(logPath, stepOutput(dependencies.runQmd(['embed'], { cwd: dependencies.hqRoot })));
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
appendWorkerLog(logPath, errorOutput(error)); // a completed embed attempt still permits the stamp
|
|
364
441
|
}
|
|
365
|
-
|
|
442
|
+
if (await signalWindow())
|
|
443
|
+
return { state: 'terminated' };
|
|
366
444
|
writeCompletion(home, dependencies);
|
|
445
|
+
appendWorkerLog(logPath, `[qmd-reindex-bg] done ts=${dependencies.now()}`);
|
|
367
446
|
return { state: 'completed' };
|
|
368
447
|
}
|
|
369
448
|
finally {
|