@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 and the CLI's normal
294
- // process exit then runs the same idempotent owner release. Exiting prevents
295
- // a synchronous pipeline from continuing after it has released ownership.
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
- const logPath = dependencies.env.QMD_REINDEX_LOG
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 { /* cleanup is intentionally best-effort */ }
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
- catch { /* a completed embed attempt still permits the completion stamp */ }
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.94.3",
3
+ "version": "5.95.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {