@indigoai-us/hq-cli 5.97.3-rc.1 → 5.97.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.97.3] — 2026-08-10
6
+
5
7
  ## [5.97.3-rc.1] — 2026-08-10
6
8
 
7
9
  ## [5.97.2]
@@ -1,3 +1,4 @@
1
+ import { type StdioOptions } from 'node:child_process';
1
2
  import { type QmdProcessResult, type RunQmdOptions } from './index.js';
2
3
  export type BackgroundResult = {
3
4
  state: 'skipped-agent' | 'skipped' | 'quiet' | 'busy' | 'completed' | 'update-failed' | 'terminated';
@@ -17,6 +18,7 @@ export type BackgroundDependencies = {
17
18
  runQmd: (args: string[], options?: RunQmdOptions) => QmdProcessResult;
18
19
  spawnWorker: (options: {
19
20
  logPath: string;
21
+ fallbackLogPath?: string;
20
22
  }) => number;
21
23
  /** Test seam for simulating a competing owner replacing the atomic record. */
22
24
  afterOwnerPublish?: (ownerFile: string) => void;
@@ -28,6 +30,60 @@ export type BackgroundStatus = {
28
30
  lock: 'held' | 'stale' | 'free';
29
31
  completedAt?: number;
30
32
  };
33
+ /**
34
+ * Injectable seams for the launcher's worker-log open and detached spawn. Split
35
+ * out purely as a test seam so a synthetic open failure can be forced without
36
+ * touching real files or the invoking uid.
37
+ */
38
+ export type SpawnWorkerIo = {
39
+ mkdirSync: (directory: string) => void;
40
+ openSync: (file: string) => number;
41
+ closeSync: (fd: number) => void;
42
+ spawn: (command: string, args: string[], options: {
43
+ detached: boolean;
44
+ stdio: StdioOptions;
45
+ }) => {
46
+ pid?: number;
47
+ unref: () => void;
48
+ };
49
+ /** Report a degraded open without swallowing it (stderr notice + breadcrumb). */
50
+ report: (info: WorkerLogDegradation) => void;
51
+ };
52
+ export type WorkerLogDegradation = {
53
+ requested: string;
54
+ used: string | null;
55
+ usedFallback: boolean;
56
+ code?: string;
57
+ syscall?: string;
58
+ };
59
+ type WorkerLogOpen = {
60
+ fd: number | null;
61
+ logPath: string | null;
62
+ usedFallback: boolean;
63
+ error?: {
64
+ code?: string;
65
+ syscall?: string;
66
+ };
67
+ };
68
+ /**
69
+ * Open the detached worker's log, degrading instead of crashing. Try the
70
+ * requested path; on ANY open failure retry once against a per-user fallback
71
+ * under the caller's own $HOME; if that also fails, return a no-log result so
72
+ * the launcher still spawns the worker.
73
+ *
74
+ * This is the fix for Sentry indigo-d0/hq-cli 7663380187: HQ's /handoff always
75
+ * points --log at the fixed, shared, world-writable /tmp/qmd-handoff.log, and an
76
+ * unguarded fs.openSync(logPath, 'a') here threw EACCES whenever that file
77
+ * already existed owned by another uid, so the reindex worker never spawned and
78
+ * the raw errno reached Sentry. A diagnostic side-channel must not take down the
79
+ * feature it exists to observe — every other worker-log writer in this module
80
+ * (appendWorkerLog, capWorkerLog) already swallows I/O failures the same way.
81
+ */
82
+ export declare function openWorkerLog(logPath: string, fallbackLogPath: string | undefined, io: Pick<SpawnWorkerIo, 'mkdirSync' | 'openSync'>): WorkerLogOpen;
83
+ export declare function defaultSpawnWorker({ logPath, fallbackLogPath }: {
84
+ logPath: string;
85
+ fallbackLogPath?: string;
86
+ }, io?: SpawnWorkerIo): number;
31
87
  /** Defaults used by the CLI; tests supply every nondeterministic dependency. */
32
88
  export declare function defaultBackgroundDependencies(hqRoot: string): BackgroundDependencies;
33
89
  /** Match the shell forwarder's hosted-agent markers before looking up qmd. */
@@ -39,4 +95,5 @@ export declare function runBackgroundLauncher(dependencies: BackgroundDependenci
39
95
  export declare function runBackgroundWorker(dependencies: BackgroundDependencies): Promise<BackgroundResult>;
40
96
  /** Report the background lock and latest successful completion for `hq index status`. */
41
97
  export declare function backgroundStatus(dependencies: BackgroundDependencies): BackgroundStatus;
98
+ export {};
42
99
  //# sourceMappingURL=background.d.ts.map
@@ -1,20 +1,99 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
+ import { Sentry } from '../../sentry.js';
4
5
  import { reconcileCollections as defaultReconcileCollections, resolveQmdBin as defaultResolveQmdBin, runQmd as defaultRunQmd, } from './index.js';
5
6
  const LOCK_NAME = 'qmd-reindex-bg.lock';
6
7
  const COMPLETE_NAME = 'qmd-reindex-bg.completed';
7
- function defaultSpawnWorker({ logPath }) {
8
- fs.mkdirSync(path.dirname(logPath), { recursive: true });
9
- const log = fs.openSync(logPath, 'a');
8
+ function errnoInfo(error) {
9
+ const e = error;
10
+ return { code: e?.code, syscall: e?.syscall };
11
+ }
12
+ /**
13
+ * Open the detached worker's log, degrading instead of crashing. Try the
14
+ * requested path; on ANY open failure retry once against a per-user fallback
15
+ * under the caller's own $HOME; if that also fails, return a no-log result so
16
+ * the launcher still spawns the worker.
17
+ *
18
+ * This is the fix for Sentry indigo-d0/hq-cli 7663380187: HQ's /handoff always
19
+ * points --log at the fixed, shared, world-writable /tmp/qmd-handoff.log, and an
20
+ * unguarded fs.openSync(logPath, 'a') here threw EACCES whenever that file
21
+ * already existed owned by another uid, so the reindex worker never spawned and
22
+ * the raw errno reached Sentry. A diagnostic side-channel must not take down the
23
+ * feature it exists to observe — every other worker-log writer in this module
24
+ * (appendWorkerLog, capWorkerLog) already swallows I/O failures the same way.
25
+ */
26
+ export function openWorkerLog(logPath, fallbackLogPath, io) {
27
+ try {
28
+ io.mkdirSync(path.dirname(logPath));
29
+ return { fd: io.openSync(logPath), logPath, usedFallback: false };
30
+ }
31
+ catch (primaryError) {
32
+ const error = errnoInfo(primaryError);
33
+ if (fallbackLogPath && fallbackLogPath !== logPath) {
34
+ try {
35
+ io.mkdirSync(path.dirname(fallbackLogPath));
36
+ return { fd: io.openSync(fallbackLogPath), logPath: fallbackLogPath, usedFallback: true, error };
37
+ }
38
+ catch { /* fall through to the no-log result below */ }
39
+ }
40
+ return { fd: null, logPath: null, usedFallback: false, error };
41
+ }
42
+ }
43
+ /**
44
+ * Surface a degraded worker-log open without swallowing it: one best-effort
45
+ * stderr line naming the rejected path and where output went, plus a Sentry
46
+ * breadcrumb carrying only the errno — never the fallback path, which lives
47
+ * under $HOME. The condition is now handled and degraded, so it is deliberately
48
+ * NOT captured as an exception; the breadcrumb just gives the next genuine
49
+ * failure at this site the errno evidence this event lacked.
50
+ */
51
+ function reportWorkerLogDegradation(info) {
52
+ const reason = info.code ? ` (${info.code}${info.syscall ? ` on ${info.syscall}` : ''})` : '';
53
+ const destination = info.used ? `writing worker output to ${info.used} instead` : 'disabling worker output';
54
+ try {
55
+ process.stderr.write(`hq: cannot open background reindex log ${info.requested}${reason}; ${destination}.\n`);
56
+ }
57
+ catch { /* the notice itself is best-effort and must never crash the launcher */ }
58
+ try {
59
+ Sentry.addBreadcrumb({
60
+ category: 'qmd.background',
61
+ level: 'warning',
62
+ message: 'worker log open degraded',
63
+ data: { code: info.code, syscall: info.syscall, usedFallback: info.usedFallback },
64
+ });
65
+ }
66
+ catch { /* breadcrumb is best-effort; no Sentry client is active in tests */ }
67
+ }
68
+ const defaultSpawnWorkerIo = {
69
+ mkdirSync: (directory) => { fs.mkdirSync(directory, { recursive: true }); },
70
+ openSync: (file) => fs.openSync(file, 'a'),
71
+ closeSync: (fd) => { fs.closeSync(fd); },
72
+ spawn: (command, args, options) => spawn(command, args, options),
73
+ report: reportWorkerLogDegradation,
74
+ };
75
+ export function defaultSpawnWorker({ logPath, fallbackLogPath }, io = defaultSpawnWorkerIo) {
76
+ const opened = openWorkerLog(logPath, fallbackLogPath, io);
77
+ if (opened.error) {
78
+ io.report({ requested: logPath, used: opened.logPath, usedFallback: opened.usedFallback, ...opened.error });
79
+ }
10
80
  const entry = process.argv[1];
11
81
  if (!entry)
12
82
  throw new Error('Cannot determine hq CLI entrypoint for background worker');
13
- const child = spawn(process.execPath, [entry, 'index', 'background', '--worker', '--log', logPath], {
83
+ // Hand the child the log path we actually opened so its own appendWorkerLog /
84
+ // capWorkerLog write to the same file. When nothing could be opened, omit
85
+ // --log entirely so the child resolves its own default rather than re-failing
86
+ // on the path we already rejected — the reindex itself must still run.
87
+ const logArgs = opened.logPath ? ['--log', opened.logPath] : [];
88
+ const stdio = opened.fd === null
89
+ ? ['ignore', 'ignore', 'ignore']
90
+ : ['ignore', opened.fd, opened.fd];
91
+ const child = io.spawn(process.execPath, [entry, 'index', 'background', '--worker', ...logArgs], {
14
92
  detached: true,
15
- stdio: ['ignore', log, log],
93
+ stdio,
16
94
  });
17
- fs.closeSync(log);
95
+ if (opened.fd !== null)
96
+ io.closeSync(opened.fd);
18
97
  child.unref();
19
98
  if (!child.pid)
20
99
  throw new Error('Unable to start qmd background worker');
@@ -358,7 +437,15 @@ export function runBackgroundLauncher(dependencies) {
358
437
  catch {
359
438
  return { state: 'skipped' };
360
439
  }
361
- return { state: 'launched', pid: dependencies.spawnWorker({ logPath: workerLogPath(dependencies.env) }) };
440
+ // Fall back to a per-user log under the caller's own $HOME when the requested
441
+ // (often the shared, world-writable /tmp) log cannot be opened — see
442
+ // openWorkerLog. `home` is validated non-empty above, so the fallback is
443
+ // always inside a directory this user owns.
444
+ const fallbackLogPath = path.join(home, '.hq', 'logs', 'qmd-handoff.log');
445
+ return {
446
+ state: 'launched',
447
+ pid: dependencies.spawnWorker({ logPath: workerLogPath(dependencies.env), fallbackLogPath }),
448
+ };
362
449
  }
363
450
  /** Run the single-flight cleanup → update → embed pipeline in a worker only. */
364
451
  export async function runBackgroundWorker(dependencies) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.97.3-rc.1",
3
+ "version": "5.97.3",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {