@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
|
@@ -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
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
|
93
|
+
stdio,
|
|
16
94
|
});
|
|
17
|
-
|
|
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
|
-
|
|
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) {
|