@indigoai-us/hq-cli 5.320.0 → 5.322.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.
- package/CHANGELOG.md +14 -0
- package/assets/scaffold/core/scripts/repo-run-registry.sh +3 -1
- package/dist/command-catalog.generated.d.ts +24 -0
- package/dist/command-catalog.generated.js +30 -0
- package/dist/commands/core-hook-batch-command.d.ts +3 -0
- package/dist/commands/core-hook-batch-command.js +27 -0
- package/dist/commands/core-hook-batch-flag.d.ts +4 -0
- package/dist/commands/core-hook-batch-flag.js +7 -0
- package/dist/commands/core-hook-batch-native.d.ts +10 -0
- package/dist/commands/core-hook-batch-native.js +85 -0
- package/dist/commands/core-repo-run-registry-native.js +6 -1
- package/dist/commands/core-session-journal-native.d.ts +8 -1
- package/dist/commands/core-session-journal-native.js +46 -16
- package/dist/commands/core.js +2 -0
- package/dist/commands/daemon.js +1 -1
- package/dist/fast-core.js +13 -0
- package/dist/fast-scaffold.d.ts +2 -0
- package/dist/fast-scaffold.js +10 -0
- package/dist/lib/daemon/requests.d.ts +1 -0
- package/dist/lib/daemon/requests.js +1 -1
- package/dist/lib/daemon/run.js +5 -4
- package/dist/lib/daemon/succession.js +1 -1
- package/dist/lib/daemon/supervisor.d.ts +2 -1
- package/dist/lib/daemon/supervisor.js +134 -7
- package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/run.js +36 -15
- package/dist/lib/work-context/bind-best.d.ts +1 -1
- package/dist/lib/work-context/bind-best.js +3 -3
- package/dist/utils/vault-api.js +87 -37
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.322.0] — 2026-10-02
|
|
6
|
+
|
|
7
|
+
- Retried idempotent vault API GETs once when API Gateway returned its generic Lambda-invoke 504 response without a function request ID.
|
|
8
|
+
|
|
9
|
+
- Defer CLI-update daemon restarts until the sync runner finishes its pass, coalesce pending restart requests, and bound mesh daemon shutdown steps with clearer SIGTERM escalation diagnostics.
|
|
10
|
+
|
|
11
|
+
## [5.321.0] — 2026-10-02
|
|
12
|
+
|
|
13
|
+
- Project catalog cache keys include their source directory, and runtime-probe tests isolate the host CLI and scope checks to hooks.
|
|
14
|
+
|
|
15
|
+
- Fixed intermittent timeouts in the concurrent journal parity test.
|
|
16
|
+
- Fix repo-run-registry so an unremovable stale lock exits with a clear error instead of retrying forever.
|
|
17
|
+
- Added the default-off `hq core hook-batch journal-due` command for one-process PostToolUse journal counter reads.
|
|
18
|
+
|
|
5
19
|
## [5.320.0] — 2026-10-02
|
|
6
20
|
- Write session-journal titles as escaped YAML strings so free-form titles remain parseable.
|
|
7
21
|
- Added a default-off hq-flags gate that lets lane list pollers skip remote owner refreshes to reduce poll latency.
|
|
@@ -96,7 +96,9 @@ _try_lock() {
|
|
|
96
96
|
mtime="$(portable_stat_mtime "$LOCK_DIR" 2>/dev/null || echo 0)"
|
|
97
97
|
age=$(( $(date +%s) - mtime ))
|
|
98
98
|
if [[ $age -gt 60 ]]; then
|
|
99
|
-
rm -rf "$LOCK_DIR" 2>/dev/null
|
|
99
|
+
if ! rm -rf "$LOCK_DIR" 2>/dev/null; then
|
|
100
|
+
_die "could not remove stale registry lock: $LOCK_DIR"
|
|
101
|
+
fi
|
|
100
102
|
continue
|
|
101
103
|
fi
|
|
102
104
|
fi
|
|
@@ -5971,6 +5971,30 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
5971
5971
|
}];
|
|
5972
5972
|
readonly options: readonly [];
|
|
5973
5973
|
readonly subcommands: readonly [];
|
|
5974
|
+
}, {
|
|
5975
|
+
readonly name: "hook-batch";
|
|
5976
|
+
readonly description: "Batch PostToolUse hook helper calls";
|
|
5977
|
+
readonly aliases: readonly [];
|
|
5978
|
+
readonly hidden: false;
|
|
5979
|
+
readonly usage: "[options] [command]";
|
|
5980
|
+
readonly arguments: readonly [];
|
|
5981
|
+
readonly options: readonly [];
|
|
5982
|
+
readonly subcommands: readonly [{
|
|
5983
|
+
readonly name: "journal-due";
|
|
5984
|
+
readonly description: "Resolve the journal path, increment the tool counter, and return its value";
|
|
5985
|
+
readonly aliases: readonly [];
|
|
5986
|
+
readonly hidden: false;
|
|
5987
|
+
readonly usage: "[options]";
|
|
5988
|
+
readonly arguments: readonly [];
|
|
5989
|
+
readonly options: readonly [{
|
|
5990
|
+
readonly flags: "--session <key>";
|
|
5991
|
+
readonly description: "Session key used by the journal counter";
|
|
5992
|
+
}, {
|
|
5993
|
+
readonly flags: "--reset";
|
|
5994
|
+
readonly description: "Reset the tool counter before incrementing it";
|
|
5995
|
+
}];
|
|
5996
|
+
readonly subcommands: readonly [];
|
|
5997
|
+
}];
|
|
5974
5998
|
}, {
|
|
5975
5999
|
readonly name: "session-journal";
|
|
5976
6000
|
readonly description: "Write and read session journal entries";
|
|
@@ -7681,6 +7681,36 @@ export const COMMAND_CATALOG = [
|
|
|
7681
7681
|
"options": [],
|
|
7682
7682
|
"subcommands": []
|
|
7683
7683
|
},
|
|
7684
|
+
{
|
|
7685
|
+
"name": "hook-batch",
|
|
7686
|
+
"description": "Batch PostToolUse hook helper calls",
|
|
7687
|
+
"aliases": [],
|
|
7688
|
+
"hidden": false,
|
|
7689
|
+
"usage": "[options] [command]",
|
|
7690
|
+
"arguments": [],
|
|
7691
|
+
"options": [],
|
|
7692
|
+
"subcommands": [
|
|
7693
|
+
{
|
|
7694
|
+
"name": "journal-due",
|
|
7695
|
+
"description": "Resolve the journal path, increment the tool counter, and return its value",
|
|
7696
|
+
"aliases": [],
|
|
7697
|
+
"hidden": false,
|
|
7698
|
+
"usage": "[options]",
|
|
7699
|
+
"arguments": [],
|
|
7700
|
+
"options": [
|
|
7701
|
+
{
|
|
7702
|
+
"flags": "--session <key>",
|
|
7703
|
+
"description": "Session key used by the journal counter"
|
|
7704
|
+
},
|
|
7705
|
+
{
|
|
7706
|
+
"flags": "--reset",
|
|
7707
|
+
"description": "Reset the tool counter before incrementing it"
|
|
7708
|
+
}
|
|
7709
|
+
],
|
|
7710
|
+
"subcommands": []
|
|
7711
|
+
}
|
|
7712
|
+
]
|
|
7713
|
+
},
|
|
7684
7714
|
{
|
|
7685
7715
|
"name": "session-journal",
|
|
7686
7716
|
"description": "Write and read session journal entries",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { resolveLiveRoot } from "../utils/hq-roots.js";
|
|
2
|
+
import { describeCoreCommand } from "./core-command-catalog.js";
|
|
3
|
+
import { CORE_HOOK_BATCH_HELP, runCoreHookBatchFast } from "./core-hook-batch-native.js";
|
|
4
|
+
import { coreHookBatchEnabled } from "./core-hook-batch-flag.js";
|
|
5
|
+
export function registerCoreHookBatchCommand(core) {
|
|
6
|
+
const group = core.command("hook-batch")
|
|
7
|
+
.description("Batch PostToolUse hook helper calls");
|
|
8
|
+
const journalDue = group.command("journal-due")
|
|
9
|
+
.description("Resolve the journal path, increment the tool counter, and return its value")
|
|
10
|
+
.requiredOption("--session <key>", "Session key used by the journal counter")
|
|
11
|
+
.option("--reset", "Reset the tool counter before incrementing it")
|
|
12
|
+
.addHelpText("after", `\n${CORE_HOOK_BATCH_HELP}`)
|
|
13
|
+
.action(async (options) => {
|
|
14
|
+
if (!await coreHookBatchEnabled()) {
|
|
15
|
+
process.stderr.write("hook-batch: journal-due requires the core-hook-batch flag\n");
|
|
16
|
+
process.exitCode = 1;
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const root = resolveLiveRoot({ hqRoot: core.opts().hqRoot });
|
|
20
|
+
const args = ["--session", options.session, ...(options.reset ? ["--reset"] : [])];
|
|
21
|
+
const code = runCoreHookBatchFast(args, "", process.cwd(), root);
|
|
22
|
+
if (code !== 0)
|
|
23
|
+
process.exitCode = code;
|
|
24
|
+
});
|
|
25
|
+
describeCoreCommand(journalDue, { execution: "native", root: "live" });
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=core-hook-batch-command.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Default-off gate for the batched PostToolUse helper command. */
|
|
2
|
+
export const CORE_HOOK_BATCH_FLAG = "core-hook-batch";
|
|
3
|
+
export async function coreHookBatchEnabled() {
|
|
4
|
+
const { resolveProcessFlagGate } = await import("../lib/flag-registry.js");
|
|
5
|
+
return resolveProcessFlagGate(CORE_HOOK_BATCH_FLAG, { fallback: false }, () => false);
|
|
6
|
+
}
|
|
7
|
+
//# sourceMappingURL=core-hook-batch-flag.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type SessionJournalOperationResult } from "./core-session-journal-native.js";
|
|
2
|
+
import type { SessionHelperRuntimeOverrides } from "./core-session-helper-runtime.js";
|
|
3
|
+
export declare const CORE_HOOK_BATCH_HELP = "Usage: hq core hook-batch journal-due --session <key> [--reset]\n\nRuns session-journal dir-path, tool-counter increment, and tool-counter read\ninside one process. With --reset, tool-counter reset runs before increment.\n\nOutput framing: one ASCII line per helper call, in execution order:\n HQHB1|<step>|<exit-code>|<stdout-base64>|<stderr-base64>\nThe delimiter is |; the final newline ends each record. Base64 preserves each\nhelper stream byte-for-byte. In Bash, read each field and decode a stream with\nbase64 --decode (GNU) or base64 -D (BSD); jq is not needed. A helper's code 0\ndoes not imply empty output, and empty streams have empty base64 fields.\n";
|
|
4
|
+
type BatchResult = SessionJournalOperationResult;
|
|
5
|
+
/** Build the no-jq frame over the same in-process operation used by session-journal. */
|
|
6
|
+
export declare function runCoreHookBatch(args: readonly string[], stdin: string, cwd: string, hqRoot: string | undefined, dependencies?: SessionHelperRuntimeOverrides): BatchResult;
|
|
7
|
+
/** Fast-route entrypoint; the encoded records are the only top-level output. */
|
|
8
|
+
export declare function runCoreHookBatchFast(args: readonly string[], stdin: string, cwd: string, hqRoot: string | undefined): number | undefined;
|
|
9
|
+
export {};
|
|
10
|
+
//# sourceMappingURL=core-hook-batch-native.d.ts.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { runSessionJournalOperation } from "./core-session-journal-native.js";
|
|
2
|
+
export const CORE_HOOK_BATCH_HELP = `Usage: hq core hook-batch journal-due --session <key> [--reset]
|
|
3
|
+
|
|
4
|
+
Runs session-journal dir-path, tool-counter increment, and tool-counter read
|
|
5
|
+
inside one process. With --reset, tool-counter reset runs before increment.
|
|
6
|
+
|
|
7
|
+
Output framing: one ASCII line per helper call, in execution order:
|
|
8
|
+
HQHB1|<step>|<exit-code>|<stdout-base64>|<stderr-base64>
|
|
9
|
+
The delimiter is |; the final newline ends each record. Base64 preserves each
|
|
10
|
+
helper stream byte-for-byte. In Bash, read each field and decode a stream with
|
|
11
|
+
base64 --decode (GNU) or base64 -D (BSD); jq is not needed. A helper's code 0
|
|
12
|
+
does not imply empty output, and empty streams have empty base64 fields.
|
|
13
|
+
`;
|
|
14
|
+
function usageError() {
|
|
15
|
+
return { code: 1, stdout: Buffer.alloc(0), stderr: Buffer.from(CORE_HOOK_BATCH_HELP) };
|
|
16
|
+
}
|
|
17
|
+
function frame(name, result) {
|
|
18
|
+
const line = [
|
|
19
|
+
"HQHB1",
|
|
20
|
+
name,
|
|
21
|
+
String(result.code),
|
|
22
|
+
result.stdout.toString("base64"),
|
|
23
|
+
result.stderr.toString("base64"),
|
|
24
|
+
].join("|");
|
|
25
|
+
return Buffer.from(`${line}\n`, "ascii");
|
|
26
|
+
}
|
|
27
|
+
function parseArgs(args) {
|
|
28
|
+
if (args.includes("--help") || args.includes("-h"))
|
|
29
|
+
return "help";
|
|
30
|
+
let session;
|
|
31
|
+
let reset = false;
|
|
32
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
33
|
+
const arg = args[index];
|
|
34
|
+
if (arg === "--session") {
|
|
35
|
+
const value = args[index + 1];
|
|
36
|
+
if (!value || value.startsWith("--"))
|
|
37
|
+
return undefined;
|
|
38
|
+
session = value;
|
|
39
|
+
index += 1;
|
|
40
|
+
}
|
|
41
|
+
else if (arg === "--reset") {
|
|
42
|
+
reset = true;
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!session)
|
|
49
|
+
return undefined;
|
|
50
|
+
return { session, reset };
|
|
51
|
+
}
|
|
52
|
+
function runParsedCoreHookBatch(parsed, stdin, cwd, hqRoot, dependencies) {
|
|
53
|
+
const sessionArgs = ["--session", parsed.session];
|
|
54
|
+
const steps = [
|
|
55
|
+
{ name: "dir-path", args: ["dir-path"] },
|
|
56
|
+
...(parsed.reset ? [{ name: "reset", args: ["tool-counter", "reset", ...sessionArgs] }] : []),
|
|
57
|
+
{ name: "increment", args: ["tool-counter", "increment", ...sessionArgs] },
|
|
58
|
+
{ name: "read", args: ["tool-counter", "read", ...sessionArgs] },
|
|
59
|
+
];
|
|
60
|
+
const records = steps.map((step) => {
|
|
61
|
+
const result = runSessionJournalOperation(step.args, stdin, cwd, hqRoot, dependencies);
|
|
62
|
+
return frame(step.name, result);
|
|
63
|
+
});
|
|
64
|
+
return { code: 0, stdout: Buffer.concat(records), stderr: Buffer.alloc(0) };
|
|
65
|
+
}
|
|
66
|
+
/** Build the no-jq frame over the same in-process operation used by session-journal. */
|
|
67
|
+
export function runCoreHookBatch(args, stdin, cwd, hqRoot, dependencies = {}) {
|
|
68
|
+
const parsed = parseArgs(args);
|
|
69
|
+
if (parsed === "help")
|
|
70
|
+
return { code: 0, stdout: Buffer.from(CORE_HOOK_BATCH_HELP), stderr: Buffer.alloc(0) };
|
|
71
|
+
if (!parsed)
|
|
72
|
+
return usageError();
|
|
73
|
+
return runParsedCoreHookBatch(parsed, stdin, cwd, hqRoot, dependencies);
|
|
74
|
+
}
|
|
75
|
+
/** Fast-route entrypoint; the encoded records are the only top-level output. */
|
|
76
|
+
export function runCoreHookBatchFast(args, stdin, cwd, hqRoot) {
|
|
77
|
+
const parsed = parseArgs(args);
|
|
78
|
+
if (!parsed || parsed === "help")
|
|
79
|
+
return undefined;
|
|
80
|
+
const result = runParsedCoreHookBatch(parsed, stdin, cwd, hqRoot, {});
|
|
81
|
+
process.stdout.write(result.stdout);
|
|
82
|
+
process.stderr.write(result.stderr);
|
|
83
|
+
return result.code;
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=core-hook-batch-native.js.map
|
|
@@ -131,7 +131,12 @@ export function runRepoRunRegistry(args, deps) {
|
|
|
131
131
|
catch { /* shell falls back to epoch zero when stat fails */ }
|
|
132
132
|
const age = Math.floor(now().getTime() / 1000) - Math.floor(mtimeMs / 1000);
|
|
133
133
|
if (age > 60) {
|
|
134
|
-
|
|
134
|
+
try {
|
|
135
|
+
f.rmSync(lockDir, { recursive: true, force: true });
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
throw new Error(`ERROR: could not remove stale registry lock: ${lockDir}`);
|
|
139
|
+
}
|
|
135
140
|
continue;
|
|
136
141
|
}
|
|
137
142
|
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { type SessionHelperRuntimeOverrides } from "./core-session-helper-runtime.js";
|
|
2
|
+
export type SessionJournalOperationResult = {
|
|
3
|
+
code: number;
|
|
4
|
+
stdout: Buffer;
|
|
5
|
+
stderr: Buffer;
|
|
6
|
+
};
|
|
7
|
+
/** Execute one native helper call while retaining its two output byte streams. */
|
|
8
|
+
export declare function runSessionJournalOperation(args: readonly string[], _stdin: string, cwd: string, hqRoot: string | undefined, dependencies?: SessionHelperRuntimeOverrides): SessionJournalOperationResult;
|
|
2
9
|
/** Native implementation of the fail-soft session journal helper. */
|
|
3
|
-
export declare function runSessionJournalFast(args: readonly string[],
|
|
10
|
+
export declare function runSessionJournalFast(args: readonly string[], stdin: string, cwd: string, hqRoot: string | undefined, dependencies?: SessionHelperRuntimeOverrides): number;
|
|
4
11
|
//# sourceMappingURL=core-session-journal-native.d.ts.map
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { resolveSessionHelperRuntime } from "./core-session-helper-runtime.js";
|
|
4
|
+
import { resolveBundledAsset } from "../utils/hq-roots.js";
|
|
4
5
|
const USAGE = `session-journal.sh: usage:
|
|
5
6
|
session-journal.sh write "<title>" [--files f1,f2,...] [--body-file PATH] [--session KEY]
|
|
6
7
|
session-journal.sh list [--date YYYY-MM-DD]
|
|
@@ -94,7 +95,7 @@ function acquireJournalWriteLock(dir) {
|
|
|
94
95
|
try {
|
|
95
96
|
fs.writeFileSync(ownerFile, `${process.pid}\n`, { flag: "wx" });
|
|
96
97
|
}
|
|
97
|
-
catch
|
|
98
|
+
catch {
|
|
98
99
|
fs.rmSync(lock, { recursive: true, force: true });
|
|
99
100
|
return undefined;
|
|
100
101
|
}
|
|
@@ -178,7 +179,7 @@ function options(args, valueFlags) {
|
|
|
178
179
|
}
|
|
179
180
|
return values;
|
|
180
181
|
}
|
|
181
|
-
function runToolCounter(root, action, sessionArg, env, now) {
|
|
182
|
+
function runToolCounter(root, action, sessionArg, env, now, writers) {
|
|
182
183
|
const rawKey = sessionArg || env.HQ_JOURNAL_SESSION || env.HQ_HOOK_SESSION_ID || "";
|
|
183
184
|
const key = counterKey(rawKey);
|
|
184
185
|
const dir = journalDir(root, now().toISOString().slice(0, 10));
|
|
@@ -188,31 +189,60 @@ function runToolCounter(root, action, sessionArg, env, now) {
|
|
|
188
189
|
catch { /* legacy mkdir failure is ignored */ }
|
|
189
190
|
const counter = path.join(dir, `.tool-count-${key}`);
|
|
190
191
|
const lock = `${counter}.lock`;
|
|
192
|
+
const reportDirectoryWriteFailure = (error, line) => {
|
|
193
|
+
const code = error.code;
|
|
194
|
+
if (code !== "EISDIR")
|
|
195
|
+
return;
|
|
196
|
+
const asset = resolveBundledAsset("core/scripts/session-journal.sh");
|
|
197
|
+
writers.stderr(`${asset}: line ${line}: ${counter}: Is a directory\n`);
|
|
198
|
+
};
|
|
191
199
|
if (action === "increment") {
|
|
192
200
|
withCounterLock(lock, () => {
|
|
193
201
|
try {
|
|
194
202
|
fs.writeFileSync(counter, `${readCounter(counter) + 1}\n`);
|
|
195
203
|
}
|
|
196
|
-
catch {
|
|
204
|
+
catch (error) {
|
|
205
|
+
reportDirectoryWriteFailure(error, 312);
|
|
206
|
+
}
|
|
197
207
|
});
|
|
198
208
|
}
|
|
199
209
|
else if (action === "read") {
|
|
200
|
-
|
|
210
|
+
writers.stdout(`${readCounter(counter)}\n`);
|
|
201
211
|
}
|
|
202
212
|
else if (action === "reset") {
|
|
203
213
|
withCounterLock(lock, () => {
|
|
204
214
|
try {
|
|
205
215
|
fs.writeFileSync(counter, "0\n");
|
|
206
216
|
}
|
|
207
|
-
catch {
|
|
217
|
+
catch (error) {
|
|
218
|
+
reportDirectoryWriteFailure(error, 320);
|
|
219
|
+
}
|
|
208
220
|
});
|
|
209
221
|
}
|
|
210
222
|
else {
|
|
211
|
-
|
|
223
|
+
writers.stderr("session-journal: tool-counter: subcommand required (increment|read|reset)\n");
|
|
212
224
|
}
|
|
213
225
|
}
|
|
226
|
+
/** Execute one native helper call while retaining its two output byte streams. */
|
|
227
|
+
export function runSessionJournalOperation(args, _stdin, cwd, hqRoot, dependencies = {}) {
|
|
228
|
+
const stdout = [];
|
|
229
|
+
const stderr = [];
|
|
230
|
+
const writers = {
|
|
231
|
+
stdout: (chunk) => stdout.push(Buffer.from(chunk)),
|
|
232
|
+
stderr: (chunk) => stderr.push(Buffer.from(chunk)),
|
|
233
|
+
};
|
|
234
|
+
const code = executeSessionJournal(args, cwd, hqRoot, dependencies, writers);
|
|
235
|
+
return { code, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) };
|
|
236
|
+
}
|
|
214
237
|
/** Native implementation of the fail-soft session journal helper. */
|
|
215
|
-
export function runSessionJournalFast(args,
|
|
238
|
+
export function runSessionJournalFast(args, stdin, cwd, hqRoot, dependencies = {}) {
|
|
239
|
+
const result = runSessionJournalOperation(args, stdin, cwd, hqRoot, dependencies);
|
|
240
|
+
process.stdout.write(result.stdout);
|
|
241
|
+
process.stderr.write(result.stderr);
|
|
242
|
+
return result.code;
|
|
243
|
+
}
|
|
244
|
+
function executeSessionJournal(args, cwd, hqRoot, dependencies, writers) {
|
|
245
|
+
const warning = (message) => { writers.stderr(`session-journal: ${message}\n`); };
|
|
216
246
|
const runtime = resolveSessionHelperRuntime(dependencies);
|
|
217
247
|
const today = () => runtime.now().toISOString().slice(0, 10);
|
|
218
248
|
const nowIso = () => runtime.now().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
@@ -296,8 +326,8 @@ export function runSessionJournalFast(args, _stdin, cwd, hqRoot, dependencies =
|
|
|
296
326
|
finally {
|
|
297
327
|
releaseJournalWriteLock(writeLock);
|
|
298
328
|
}
|
|
299
|
-
runToolCounter(root, "reset", counterKey(session || runtime.env.HQ_JOURNAL_SESSION || runtime.env.HQ_HOOK_SESSION_ID || ""), runtime.env, runtime.now);
|
|
300
|
-
|
|
329
|
+
runToolCounter(root, "reset", counterKey(session || runtime.env.HQ_JOURNAL_SESSION || runtime.env.HQ_HOOK_SESSION_ID || ""), runtime.env, runtime.now, writers);
|
|
330
|
+
writers.stdout(`${output}\n`);
|
|
301
331
|
return 0;
|
|
302
332
|
}
|
|
303
333
|
if (command === "list" || command === "read" || command === "index-path" || command === "dir-path") {
|
|
@@ -306,19 +336,19 @@ export function runSessionJournalFast(args, _stdin, cwd, hqRoot, dependencies =
|
|
|
306
336
|
const date = options(afterSequence, ["--date"]).get("--date") || today();
|
|
307
337
|
const dir = journalDir(root, date);
|
|
308
338
|
if (command === "dir-path") {
|
|
309
|
-
|
|
339
|
+
writers.stdout(dir);
|
|
310
340
|
return 0;
|
|
311
341
|
}
|
|
312
342
|
if (command === "index-path") {
|
|
313
|
-
|
|
343
|
+
writers.stdout(`${dir}/INDEX.md\n`);
|
|
314
344
|
return 0;
|
|
315
345
|
}
|
|
316
346
|
if (command === "list") {
|
|
317
347
|
const index = path.join(dir, "INDEX.md");
|
|
318
348
|
if (hasFile(index))
|
|
319
|
-
|
|
349
|
+
writers.stdout(fs.readFileSync(index));
|
|
320
350
|
else
|
|
321
|
-
|
|
351
|
+
writers.stdout(`(no journal for ${date})\n`);
|
|
322
352
|
return 0;
|
|
323
353
|
}
|
|
324
354
|
if (!sequence) {
|
|
@@ -339,7 +369,7 @@ export function runSessionJournalFast(args, _stdin, cwd, hqRoot, dependencies =
|
|
|
339
369
|
}
|
|
340
370
|
})();
|
|
341
371
|
if (match)
|
|
342
|
-
|
|
372
|
+
writers.stdout(fs.readFileSync(path.join(dir, match)));
|
|
343
373
|
else
|
|
344
374
|
warning(`read: no entry ${normalized} for ${date}`);
|
|
345
375
|
return 0;
|
|
@@ -347,10 +377,10 @@ export function runSessionJournalFast(args, _stdin, cwd, hqRoot, dependencies =
|
|
|
347
377
|
if (command === "tool-counter") {
|
|
348
378
|
const action = rest[0] ?? "";
|
|
349
379
|
const session = options(rest.slice(1), ["--session"]).get("--session") ?? "";
|
|
350
|
-
runToolCounter(root, action, session, runtime.env, runtime.now);
|
|
380
|
+
runToolCounter(root, action, session, runtime.env, runtime.now, writers);
|
|
351
381
|
return 0;
|
|
352
382
|
}
|
|
353
|
-
|
|
383
|
+
writers.stderr(USAGE);
|
|
354
384
|
return 1;
|
|
355
385
|
}
|
|
356
386
|
//# sourceMappingURL=core-session-journal-native.js.map
|
package/dist/commands/core.js
CHANGED
|
@@ -47,6 +47,7 @@ import { registerCoreRefreshVaultAccessNative } from "./core-refresh-vault-acces
|
|
|
47
47
|
import { registerCoreWorkMeshBindTrustedNative } from "./core-work-mesh-bind-trusted-native.js";
|
|
48
48
|
import { registerCoreHookHelperNativeCommands } from "./core-native-hook-helper-commands.js";
|
|
49
49
|
import { registerCoreHookStateHelpers } from "./core-hook-state-helpers-native.js";
|
|
50
|
+
import { registerCoreHookBatchCommand } from "./core-hook-batch-command.js";
|
|
50
51
|
import { resolveLiveRoot } from "../utils/hq-roots.js";
|
|
51
52
|
import { interpreterFor, runBundledScript } from "../utils/run-bundled-script.js";
|
|
52
53
|
import { CompanyIndexUserError, renderIndexTarget } from "../lib/index-render/index.js";
|
|
@@ -397,6 +398,7 @@ export function registerCoreCommands(program) {
|
|
|
397
398
|
registerCoreWorkMeshBindTrustedNative(core);
|
|
398
399
|
registerCoreHookHelperNativeCommands(core);
|
|
399
400
|
registerCoreHookStateHelpers(core);
|
|
401
|
+
registerCoreHookBatchCommand(core);
|
|
400
402
|
const sessionHelperCommands = [
|
|
401
403
|
{
|
|
402
404
|
name: "session-journal", asset: "core/scripts/session-journal.sh", root: "live",
|
package/dist/commands/daemon.js
CHANGED
|
@@ -632,7 +632,7 @@ async function runServiceSelfUpdate(d) {
|
|
|
632
632
|
tools: TOOL_NAMES.map((name) => hostToolUpdater(name, env, d.home)),
|
|
633
633
|
updateHqCloud: async () => updateManagedHqCloud(paths.root, env),
|
|
634
634
|
hqCloudUnits: hqCloudUnits(detected.type, enabledServices(detected.type, readDaemonConfig(paths.configPath))),
|
|
635
|
-
requestDaemonRestart: () => writeControlRequest(paths.requestsDir, { action: "restart-daemon" }),
|
|
635
|
+
requestDaemonRestart: () => writeControlRequest(paths.requestsDir, { action: "restart-daemon", deferUntilSyncIdle: true }),
|
|
636
636
|
requestRestart: (unit) => writeControlRequest(paths.requestsDir, { action: "restart", unit }),
|
|
637
637
|
log: d.out,
|
|
638
638
|
});
|
package/dist/fast-core.js
CHANGED
|
@@ -35,6 +35,11 @@ export async function runFastCore(argv) {
|
|
|
35
35
|
const envOverride = localBooleanFlagOverride(entry.familyFlagKey, process.env);
|
|
36
36
|
familyEnabled = envOverride ?? await (await import("./native-fast-route.js")).fastFlagEnabled(entry.familyFlagKey, process.env, { numericEnvironmentOverride: false });
|
|
37
37
|
}
|
|
38
|
+
if (entry.kind === "native" && entry.fallbackToCommandGraph && !familyEnabled) {
|
|
39
|
+
const { runCli } = await import("./main.js");
|
|
40
|
+
await runCli();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
38
43
|
const useShell = entry.kind === "native" && Boolean(entry.familyFlagKey) && !familyEnabled;
|
|
39
44
|
const worktreeScopeFixEnabled = entry.name === "repo-run-registry"
|
|
40
45
|
? await (await import("./native-fast-route.js")).fastFlagEnabled(CORE_RRR_WORKTREE_SCOPE_FIX_FLAG, process.env, { numericEnvironmentOverride: false })
|
|
@@ -74,6 +79,14 @@ export async function runFastCore(argv) {
|
|
|
74
79
|
const readsStdin = entry.readsStdinFor?.(parsed.args) ?? entry.readsStdin ?? false;
|
|
75
80
|
const stdin = readsStdin ? await readStdinIfPiped(process.stdin) : "";
|
|
76
81
|
const code = await handler(parsed.args, stdin, process.cwd(), hqRoot, entry.name.split(" ").at(-1) ?? entry.name, parsed.hqRootArg, worktreeScopeFixEnabled);
|
|
82
|
+
if (code === undefined) {
|
|
83
|
+
if (!entry.fallbackToCommandGraph) {
|
|
84
|
+
throw new Error(`Native fast handler ${entry.exportName} requested an unavailable command-graph fallback`);
|
|
85
|
+
}
|
|
86
|
+
const { runCli } = await import("./main.js");
|
|
87
|
+
await runCli();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
77
90
|
// Let Node drain handler output before terminating. The timeout guard's
|
|
78
91
|
// stderr message must survive the native route's process boundary.
|
|
79
92
|
process.exitCode = code;
|
package/dist/fast-scaffold.d.ts
CHANGED
|
@@ -32,6 +32,8 @@ export type FastScaffoldScript = {
|
|
|
32
32
|
familyFlagKey?: string;
|
|
33
33
|
/** Shell rollback companion used while the family gate is off. */
|
|
34
34
|
asset?: string;
|
|
35
|
+
/** Return to the Commander graph when this new route has no shell companion. */
|
|
36
|
+
fallbackToCommandGraph?: boolean;
|
|
35
37
|
/** Root context used by the native handler. */
|
|
36
38
|
root?: "live" | "cwd" | "passthrough";
|
|
37
39
|
/** Cwd used for a shell rollback companion. Defaults to the caller cwd. */
|
package/dist/fast-scaffold.js
CHANGED
|
@@ -217,6 +217,16 @@ export const FAST_SCAFFOLD_SCRIPTS = [
|
|
|
217
217
|
shellHqRoot: "live",
|
|
218
218
|
...(name === "session-project" ? { readsStdin: true, readsStdinFor: (args) => args[0] === "ingest-plan" } : {}),
|
|
219
219
|
})),
|
|
220
|
+
{
|
|
221
|
+
name: "hook-batch journal-due",
|
|
222
|
+
kind: "native",
|
|
223
|
+
modulePath: "./commands/core-hook-batch-native.js",
|
|
224
|
+
exportName: "runCoreHookBatchFast",
|
|
225
|
+
flagKey: "hq-core-native-fast-route",
|
|
226
|
+
familyFlagKey: "core-hook-batch",
|
|
227
|
+
root: "live",
|
|
228
|
+
fallbackToCommandGraph: true,
|
|
229
|
+
},
|
|
220
230
|
...[
|
|
221
231
|
{ name: "eval-trigger", modulePath: "./commands/core-eval-trigger-native.js", exportName: "runEvalTriggerFast", asset: "core/scripts/eval-trigger.sh", root: "cwd", shellCwd: "caller", shellHqRoot: "none", readsStdinFor: (args) => args[0] === "--check" },
|
|
222
232
|
{ name: "migrate-policy-triggers", modulePath: "./commands/core-migrate-policy-triggers-native.js", exportName: "runMigratePolicyTriggersFast", asset: "core/scripts/migrate-policy-triggers.sh", root: "live", shellCwd: "caller", shellHqRoot: "live" },
|
|
@@ -22,6 +22,7 @@ export type ControlRequest = {
|
|
|
22
22
|
/** Restart the whole daemon, for example after the updater installed a new CLI. */
|
|
23
23
|
| {
|
|
24
24
|
action: "restart-daemon";
|
|
25
|
+
deferUntilSyncIdle?: boolean;
|
|
25
26
|
};
|
|
26
27
|
export declare function writeControlRequest(dir: string, request: ControlRequest, now?: () => number): string;
|
|
27
28
|
export declare function takeControlRequests(dir: string): ControlRequest[];
|
|
@@ -18,7 +18,7 @@ function isControlRequest(value) {
|
|
|
18
18
|
if (v.action === "trigger" || v.action === "stop")
|
|
19
19
|
return typeof v.unit === "string";
|
|
20
20
|
if (v.action === "restart-daemon")
|
|
21
|
-
return
|
|
21
|
+
return v.deferUntilSyncIdle === undefined || typeof v.deferUntilSyncIdle === "boolean";
|
|
22
22
|
if (v.action === "reconcile")
|
|
23
23
|
return true;
|
|
24
24
|
return false;
|
package/dist/lib/daemon/run.js
CHANGED
|
@@ -594,7 +594,7 @@ export async function runDaemon(opts) {
|
|
|
594
594
|
}
|
|
595
595
|
};
|
|
596
596
|
// Set once the shutdown path exists below; a restart request before then is dropped.
|
|
597
|
-
let restartDaemon = () =>
|
|
597
|
+
let restartDaemon = () => false;
|
|
598
598
|
const supervisor = createSupervisor({
|
|
599
599
|
machineType,
|
|
600
600
|
host: opts.host,
|
|
@@ -730,12 +730,12 @@ export async function runDaemon(opts) {
|
|
|
730
730
|
restartDaemon = () => {
|
|
731
731
|
if (!restartsForParent(opts)) {
|
|
732
732
|
log("warn", "a restart was requested, but no service manager would start this daemon again; restart it yourself");
|
|
733
|
-
return;
|
|
733
|
+
return false;
|
|
734
734
|
}
|
|
735
735
|
if (stopping || restartPending)
|
|
736
|
-
return;
|
|
736
|
+
return true;
|
|
737
737
|
restartPending = true;
|
|
738
|
-
|
|
738
|
+
return handoffWanted()
|
|
739
739
|
.catch((err) => {
|
|
740
740
|
log("warn", `could not decide on a handoff; restarting every service: ${err.message}`);
|
|
741
741
|
return false;
|
|
@@ -745,6 +745,7 @@ export async function runDaemon(opts) {
|
|
|
745
745
|
handOver();
|
|
746
746
|
else
|
|
747
747
|
shutdown("restarting onto the updated CLI", RESTART_EXIT_CODE);
|
|
748
|
+
return true;
|
|
748
749
|
});
|
|
749
750
|
};
|
|
750
751
|
process.on("SIGTERM", () => shutdown("received SIGTERM"));
|
|
@@ -202,7 +202,7 @@ export async function stopOrphans(orphans, deps) {
|
|
|
202
202
|
remaining = remaining.filter(same);
|
|
203
203
|
}
|
|
204
204
|
for (const r of remaining) {
|
|
205
|
-
deps.log("warn", `${r.id} (pid ${r.pid})
|
|
205
|
+
deps.log("warn", `${r.id} (pid ${r.pid}) did not exit within ${graceMs / 1_000} s of SIGTERM; sending SIGKILL`);
|
|
206
206
|
send(r, "SIGKILL");
|
|
207
207
|
}
|
|
208
208
|
}
|
|
@@ -26,6 +26,7 @@ import type { MachineType } from "./machine-type.js";
|
|
|
26
26
|
import type { ControlRequest } from "./requests.js";
|
|
27
27
|
import { syncPassResult } from "./sync-runner.js";
|
|
28
28
|
export declare const STOP_ESCALATION_MS = 10000;
|
|
29
|
+
export declare const CLI_UPDATE_SYNC_IDLE_MAX_MS: number;
|
|
29
30
|
/** After a handoff, how long a replaced unit must stay up before the next one is replaced. */
|
|
30
31
|
export declare const HANDOFF_STAGGER_MS = 15000;
|
|
31
32
|
/** How long a draining unit may take to finish its work before it gets SIGTERM. */
|
|
@@ -94,7 +95,7 @@ export interface SupervisorDeps {
|
|
|
94
95
|
runInternal: (task: string) => Promise<boolean>;
|
|
95
96
|
takeRequests: () => ControlRequest[];
|
|
96
97
|
/** Restart the whole daemon process (a `restart-daemon` request). */
|
|
97
|
-
restartDaemon?: () => void
|
|
98
|
+
restartDaemon?: () => void | boolean | Promise<boolean | void>;
|
|
98
99
|
writeState: (state: DaemonState) => void;
|
|
99
100
|
log: (level: "info" | "warn" | "error", msg: string) => void;
|
|
100
101
|
now?: () => number;
|
|
@@ -27,6 +27,7 @@ const BACKOFF_BASE_MS = 1_000;
|
|
|
27
27
|
const BACKOFF_MAX_MS = 60_000;
|
|
28
28
|
const HEALTHY_RUN_MS = 60_000;
|
|
29
29
|
export const STOP_ESCALATION_MS = 10_000;
|
|
30
|
+
export const CLI_UPDATE_SYNC_IDLE_MAX_MS = 10 * 60_000;
|
|
30
31
|
const MESH_OWNER_RECHECK_MS = 15_000;
|
|
31
32
|
const MESH_OWNER_LOG_INTERVAL_MS = 10 * 60_000;
|
|
32
33
|
/** After a handoff, how long a replaced unit must stay up before the next one is replaced. */
|
|
@@ -61,6 +62,13 @@ export function planKey(plan, cliVersion) {
|
|
|
61
62
|
function errorMessage(err) {
|
|
62
63
|
return err instanceof Error ? err.message : String(err);
|
|
63
64
|
}
|
|
65
|
+
function lastProtocolHint(slot) {
|
|
66
|
+
if (slot.lastProtocolStatus)
|
|
67
|
+
return ` (last status: ${slot.lastProtocolStatus})`;
|
|
68
|
+
if (slot.lastProtocolLine)
|
|
69
|
+
return ` (last protocol line: ${slot.lastProtocolLine})`;
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
64
72
|
export function createSupervisor(deps) {
|
|
65
73
|
const now = deps.now ?? Date.now;
|
|
66
74
|
const setT = deps.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
|
|
@@ -81,6 +89,9 @@ export function createSupervisor(deps) {
|
|
|
81
89
|
const live = new Set();
|
|
82
90
|
let drained = null;
|
|
83
91
|
let onDrained = null;
|
|
92
|
+
let deferredCliRestart = false;
|
|
93
|
+
let deferredCliRestartTimer;
|
|
94
|
+
let cliRestartInProgress = false;
|
|
84
95
|
const snapshot = () => ({
|
|
85
96
|
version: 1,
|
|
86
97
|
pid: deps.pid,
|
|
@@ -108,6 +119,71 @@ export function createSupervisor(deps) {
|
|
|
108
119
|
clearT(slot.heartbeatTimer);
|
|
109
120
|
slot.heartbeatTimer = undefined;
|
|
110
121
|
};
|
|
122
|
+
const clearDeferredCliRestart = () => {
|
|
123
|
+
deferredCliRestart = false;
|
|
124
|
+
if (deferredCliRestartTimer !== undefined)
|
|
125
|
+
clearT(deferredCliRestartTimer);
|
|
126
|
+
deferredCliRestartTimer = undefined;
|
|
127
|
+
};
|
|
128
|
+
const runDeferredCliRestart = () => {
|
|
129
|
+
if (!deferredCliRestart)
|
|
130
|
+
return;
|
|
131
|
+
clearDeferredCliRestart();
|
|
132
|
+
cliRestartInProgress = true;
|
|
133
|
+
const sync = [...slots.values()].find((slot) => slot.plan.service === "sync" && slot.child);
|
|
134
|
+
if (sync)
|
|
135
|
+
terminate(sync);
|
|
136
|
+
deps.log("info", "sync runner is between passes; restarting daemon onto the updated CLI");
|
|
137
|
+
const releaseHold = () => {
|
|
138
|
+
cliRestartInProgress = false;
|
|
139
|
+
const currentSync = [...slots.values()].find((slot) => slot.plan.service === "sync");
|
|
140
|
+
if (!currentSync)
|
|
141
|
+
return;
|
|
142
|
+
if (currentSync.child)
|
|
143
|
+
terminate(currentSync, () => activate(currentSync));
|
|
144
|
+
else
|
|
145
|
+
activate(currentSync);
|
|
146
|
+
};
|
|
147
|
+
try {
|
|
148
|
+
const result = deps.restartDaemon?.();
|
|
149
|
+
if (result === false)
|
|
150
|
+
releaseHold();
|
|
151
|
+
else if (result && typeof result === "object" && "then" in result) {
|
|
152
|
+
void Promise.resolve(result).then((accepted) => {
|
|
153
|
+
if (accepted === false)
|
|
154
|
+
releaseHold();
|
|
155
|
+
}).catch((err) => {
|
|
156
|
+
deps.log("error", `CLI-update restart handoff failed: ${errorMessage(err)}`);
|
|
157
|
+
releaseHold();
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
deps.log("error", `CLI-update restart handoff failed: ${errorMessage(err)}`);
|
|
163
|
+
releaseHold();
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
const requestCliUpdateRestart = () => {
|
|
167
|
+
if (!deps.restartDaemon)
|
|
168
|
+
return;
|
|
169
|
+
const sync = [...slots.values()].find((slot) => slot.plan.service === "sync" && slot.child);
|
|
170
|
+
if (!sync) {
|
|
171
|
+
void deps.restartDaemon();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (sync.syncPassActive === false) {
|
|
175
|
+
deferredCliRestart = true;
|
|
176
|
+
runDeferredCliRestart();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (deferredCliRestart) {
|
|
180
|
+
deps.log("info", "coalescing CLI-update restart while waiting for sync to become idle");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
deferredCliRestart = true;
|
|
184
|
+
deps.log("info", "deferring CLI-update restart until sync is idle (maximum 10 minutes)");
|
|
185
|
+
deferredCliRestartTimer = setT(runDeferredCliRestart, CLI_UPDATE_SYNC_IDLE_MAX_MS);
|
|
186
|
+
};
|
|
111
187
|
const setBlocked = (slot) => {
|
|
112
188
|
const block = slot.plan.blocked;
|
|
113
189
|
slot.state.status = block.state;
|
|
@@ -136,6 +212,12 @@ export function createSupervisor(deps) {
|
|
|
136
212
|
then?.();
|
|
137
213
|
return;
|
|
138
214
|
}
|
|
215
|
+
if (slot.expectingExit) {
|
|
216
|
+
// Keep the original stop deadline and signal; only update the action
|
|
217
|
+
// that runs after exit (for example, start the changed command).
|
|
218
|
+
slot.afterExit = then;
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
139
221
|
slot.expectingExit = true;
|
|
140
222
|
slot.afterExit = then;
|
|
141
223
|
if (slot.killTimer !== undefined)
|
|
@@ -145,7 +227,7 @@ export function createSupervisor(deps) {
|
|
|
145
227
|
slot.killTimer = undefined;
|
|
146
228
|
if (slot.child !== child)
|
|
147
229
|
return;
|
|
148
|
-
deps.log("warn", `${slot.plan.id}
|
|
230
|
+
deps.log("warn", `${slot.plan.id} did not exit within ${STOP_ESCALATION_MS / 1_000} s of SIGTERM; sending SIGKILL${lastProtocolHint(slot)}`);
|
|
149
231
|
signalChild(slot, child, "SIGKILL");
|
|
150
232
|
}, STOP_ESCALATION_MS);
|
|
151
233
|
};
|
|
@@ -211,6 +293,25 @@ export function createSupervisor(deps) {
|
|
|
211
293
|
}
|
|
212
294
|
if (!event)
|
|
213
295
|
return;
|
|
296
|
+
slot.lastProtocolLine = line.length > 240 ? `${line.slice(0, 237)}...` : line;
|
|
297
|
+
if (slot.plan.service === "sync") {
|
|
298
|
+
if (event.type === "fanout-plan")
|
|
299
|
+
slot.syncPassActive = true;
|
|
300
|
+
else if (event.type === "all-complete")
|
|
301
|
+
slot.syncPassActive = false;
|
|
302
|
+
else if (event.type === "status" && typeof event.state === "string") {
|
|
303
|
+
slot.lastProtocolStatus = event.state;
|
|
304
|
+
if (event.state === "stopped" || event.state === "waiting-for-lock")
|
|
305
|
+
slot.syncPassActive = false;
|
|
306
|
+
else if (slot.syncPassActive === undefined && (event.state === "starting" || event.state === "running")) {
|
|
307
|
+
// Before the first pass boundary, status heartbeats cannot prove
|
|
308
|
+
// that startup work has not already entered a pass.
|
|
309
|
+
slot.syncPassActive = true;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (deferredCliRestart && slot.syncPassActive === false)
|
|
313
|
+
runDeferredCliRestart();
|
|
314
|
+
}
|
|
214
315
|
const at = now();
|
|
215
316
|
slot.state.lastHeartbeatAt = new Date(at).toISOString();
|
|
216
317
|
const pass = syncPassResult(event, slot.state.lastHeartbeatAt);
|
|
@@ -358,6 +459,15 @@ export function createSupervisor(deps) {
|
|
|
358
459
|
const startLong = (slot) => {
|
|
359
460
|
if (stopped)
|
|
360
461
|
return;
|
|
462
|
+
if (cliRestartInProgress && slot.plan.service === "sync") {
|
|
463
|
+
slot.state.status = "waiting";
|
|
464
|
+
slot.state.reason = "daemon restart pending";
|
|
465
|
+
delete slot.state.nextRunAt;
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
delete slot.lastProtocolLine;
|
|
469
|
+
delete slot.lastProtocolStatus;
|
|
470
|
+
delete slot.syncPassActive;
|
|
361
471
|
let child;
|
|
362
472
|
try {
|
|
363
473
|
child = deps.spawn(slot.plan);
|
|
@@ -550,7 +660,10 @@ export function createSupervisor(deps) {
|
|
|
550
660
|
deps.log("info", `restarting ${plan.id}: its command changed`);
|
|
551
661
|
existing.attempts = 0;
|
|
552
662
|
existing.state.restarts = 0;
|
|
553
|
-
|
|
663
|
+
if (cliRestartInProgress && plan.service === "sync")
|
|
664
|
+
terminate(existing);
|
|
665
|
+
else
|
|
666
|
+
terminate(existing, () => activate(existing));
|
|
554
667
|
}
|
|
555
668
|
}
|
|
556
669
|
slots = next;
|
|
@@ -650,11 +763,19 @@ export function createSupervisor(deps) {
|
|
|
650
763
|
return;
|
|
651
764
|
}
|
|
652
765
|
if (req.action === "restart-daemon") {
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
766
|
+
if (req.deferUntilSyncIdle && deps.restartDaemon) {
|
|
767
|
+
deps.log("info", "CLI-update daemon restart requested");
|
|
768
|
+
requestCliUpdateRestart();
|
|
769
|
+
}
|
|
770
|
+
else if (deps.restartDaemon) {
|
|
771
|
+
deps.log("info", "restarting the daemon (requested)");
|
|
772
|
+
clearDeferredCliRestart();
|
|
773
|
+
void deps.restartDaemon();
|
|
774
|
+
}
|
|
775
|
+
else {
|
|
776
|
+
deps.log("info", "restarting the daemon (requested)");
|
|
657
777
|
deps.log("warn", "this daemon cannot restart itself; restart it to load the new version");
|
|
778
|
+
}
|
|
658
779
|
return;
|
|
659
780
|
}
|
|
660
781
|
// A bot created since the last plan read is asked for before the timer
|
|
@@ -756,7 +877,11 @@ export function createSupervisor(deps) {
|
|
|
756
877
|
clearT(requestTimer);
|
|
757
878
|
if (handoffTimer !== undefined)
|
|
758
879
|
clearT(handoffTimer);
|
|
880
|
+
clearDeferredCliRestart();
|
|
881
|
+
const childOwners = new Map();
|
|
759
882
|
for (const slot of slots.values()) {
|
|
883
|
+
if (slot.child)
|
|
884
|
+
childOwners.set(slot.child, slot);
|
|
760
885
|
clearTimer(slot);
|
|
761
886
|
clearHeartbeatTimer(slot);
|
|
762
887
|
slot.child = undefined;
|
|
@@ -784,7 +909,8 @@ export function createSupervisor(deps) {
|
|
|
784
909
|
}
|
|
785
910
|
const escalation = setT(() => {
|
|
786
911
|
for (const child of live) {
|
|
787
|
-
|
|
912
|
+
const owner = childOwners.get(child);
|
|
913
|
+
deps.log("warn", `${owner?.plan.id ?? `pid ${child.pid ?? "?"}`} did not exit within ${STOP_ESCALATION_MS / 1_000} s of SIGTERM; sending SIGKILL${owner ? lastProtocolHint(owner) : ""}`);
|
|
788
914
|
try {
|
|
789
915
|
child.kill("SIGKILL");
|
|
790
916
|
}
|
|
@@ -818,6 +944,7 @@ export function createSupervisor(deps) {
|
|
|
818
944
|
clearT(requestTimer);
|
|
819
945
|
if (handoffTimer !== undefined)
|
|
820
946
|
clearT(handoffTimer);
|
|
947
|
+
clearDeferredCliRestart();
|
|
821
948
|
for (const slot of slots.values()) {
|
|
822
949
|
clearTimer(slot);
|
|
823
950
|
if (slot.killTimer !== undefined)
|
|
@@ -24,6 +24,8 @@ export declare const FLUSH_INTERVAL_MS = 10000;
|
|
|
24
24
|
/** Keep startup recovery bounded: the next daemon start continues the backlog. */
|
|
25
25
|
export declare const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
|
|
26
26
|
export declare const DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS = 2000;
|
|
27
|
+
/** Three sequential async shutdown steps must finish within six seconds. */
|
|
28
|
+
export declare const SHUTDOWN_STEP_TIMEOUT_MS = 2000;
|
|
27
29
|
/** Non-zero exit reserved for CLI version drift so legacy Restart=on-failure units restart. */
|
|
28
30
|
export declare const DAEMON_VERSION_DRIFT_EXIT_CODE = 75;
|
|
29
31
|
/**
|
|
@@ -45,6 +45,8 @@ export const FLUSH_INTERVAL_MS = 10_000;
|
|
|
45
45
|
/** Keep startup recovery bounded: the next daemon start continues the backlog. */
|
|
46
46
|
export const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
|
|
47
47
|
export const DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS = 2_000;
|
|
48
|
+
/** Three sequential async shutdown steps must finish within six seconds. */
|
|
49
|
+
export const SHUTDOWN_STEP_TIMEOUT_MS = 2_000;
|
|
48
50
|
/** Non-zero exit reserved for CLI version drift so legacy Restart=on-failure units restart. */
|
|
49
51
|
export const DAEMON_VERSION_DRIFT_EXIT_CODE = 75;
|
|
50
52
|
/**
|
|
@@ -871,29 +873,48 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
871
873
|
catch {
|
|
872
874
|
/* ignore */
|
|
873
875
|
}
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
876
|
+
const stopStep = async (name, action) => {
|
|
877
|
+
let deadline;
|
|
878
|
+
let timedOut = false;
|
|
879
|
+
const timeout = new Promise((resolve) => {
|
|
880
|
+
deadline = timers.setTimeout(() => {
|
|
881
|
+
timedOut = true;
|
|
882
|
+
resolve();
|
|
883
|
+
}, SHUTDOWN_STEP_TIMEOUT_MS);
|
|
884
|
+
});
|
|
878
885
|
try {
|
|
879
|
-
await
|
|
886
|
+
await Promise.race([Promise.resolve().then(action), timeout]);
|
|
887
|
+
if (timedOut)
|
|
888
|
+
log(dir, `${name} did not finish within ${SHUTDOWN_STEP_TIMEOUT_MS / 1_000} s; continuing shutdown`);
|
|
880
889
|
}
|
|
881
890
|
catch (err) {
|
|
882
891
|
const msg = err instanceof Error ? err.message : String(err);
|
|
883
|
-
log(dir,
|
|
892
|
+
log(dir, `${name} error: ${msg.slice(0, 120)}`);
|
|
893
|
+
}
|
|
894
|
+
finally {
|
|
895
|
+
if (deadline !== undefined)
|
|
896
|
+
timers.clearTimeout(deadline);
|
|
884
897
|
}
|
|
898
|
+
};
|
|
899
|
+
// Final flush, then clean MQTT DISCONNECT so the broker emits the
|
|
900
|
+
// lifecycle event; offline is published server-side only.
|
|
901
|
+
await stopStep("shutdown flush", () => doFlushCycle("shutdown", { force: true }));
|
|
902
|
+
if (workCache) {
|
|
903
|
+
await stopStep("work cache stop", () => workCache.stop());
|
|
885
904
|
}
|
|
886
905
|
if (presence) {
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
906
|
+
await stopStep("presence stop", () => presence.stop());
|
|
907
|
+
}
|
|
908
|
+
try {
|
|
909
|
+
patchDaemonState(dir, { mqttState: "closed", companiesOnline: [] }, now);
|
|
910
|
+
}
|
|
911
|
+
catch (err) {
|
|
912
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
913
|
+
log(dir, `daemon shutdown state update error: ${msg.slice(0, 120)}`);
|
|
914
|
+
}
|
|
915
|
+
finally {
|
|
916
|
+
releasePidLock(dir, lockDeps);
|
|
894
917
|
}
|
|
895
|
-
patchDaemonState(dir, { mqttState: "closed", companiesOnline: [] }, now);
|
|
896
|
-
releasePidLock(dir, lockDeps);
|
|
897
918
|
log(dir, "daemon stopped");
|
|
898
919
|
};
|
|
899
920
|
const onSignal = () => {
|
|
@@ -41,7 +41,7 @@ export declare function discoverProjects(input: {
|
|
|
41
41
|
env?: NodeJS.ProcessEnv;
|
|
42
42
|
home?: string;
|
|
43
43
|
}): CatalogProject[];
|
|
44
|
-
/** discoverProjects memoized by directory mtime. A
|
|
44
|
+
/** discoverProjects memoized by source directory and mtime. A hit does not rescan. */
|
|
45
45
|
export declare function discoverProjectsCached(input: Parameters<typeof discoverProjects>[0], nowMs?: number): CatalogProject[];
|
|
46
46
|
export declare function refineProjectBinding(input: {
|
|
47
47
|
hint?: string;
|
|
@@ -215,14 +215,14 @@ function catalogStamp(input) {
|
|
|
215
215
|
if (!dir)
|
|
216
216
|
return "0";
|
|
217
217
|
try {
|
|
218
|
-
return
|
|
218
|
+
return `${dir}:${fs.statSync(dir).mtimeMs}`;
|
|
219
219
|
}
|
|
220
220
|
catch {
|
|
221
|
-
return
|
|
221
|
+
return `${dir}:0`;
|
|
222
222
|
}
|
|
223
223
|
}).join("|");
|
|
224
224
|
}
|
|
225
|
-
/** discoverProjects memoized by directory mtime. A
|
|
225
|
+
/** discoverProjects memoized by source directory and mtime. A hit does not rescan. */
|
|
226
226
|
export function discoverProjectsCached(input, nowMs = Date.now()) {
|
|
227
227
|
const key = `${input.hqRoot ?? ""}|${input.companySlug ?? ""}|${input.companyUid ?? ""}|${input.home ?? ""}|${catalogStamp(input)}`;
|
|
228
228
|
if (catalogCache && catalogCache.key === key && nowMs - catalogCache.at < CATALOG_CACHE_TTL_MS) {
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -23,6 +23,57 @@ import { readCachedScopeUid, writeCachedScopeUid } from './secrets-cache.js';
|
|
|
23
23
|
* whole-process watchdog.
|
|
24
24
|
*/
|
|
25
25
|
const IDENTITY_LOOKUP_TIMEOUT_MS = 15_000;
|
|
26
|
+
const LAMBDA_INVOKE_RETRY_MAX_DELAY_MS = 100;
|
|
27
|
+
function isLambdaInvokeServiceError(response, bodyText) {
|
|
28
|
+
if (response.status !== 504) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const body = JSON.parse(bodyText);
|
|
33
|
+
return (typeof body === 'object' &&
|
|
34
|
+
body !== null &&
|
|
35
|
+
!Array.isArray(body) &&
|
|
36
|
+
Object.keys(body).length === 1 &&
|
|
37
|
+
body.message === 'Internal server error');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function fetchWithLambdaInvokeRetry(fetchOnce, retryEligible, signal) {
|
|
44
|
+
const first = await fetchOnce();
|
|
45
|
+
if (!retryEligible || first.status !== 504)
|
|
46
|
+
return first;
|
|
47
|
+
const bodyText = await first.text();
|
|
48
|
+
if (!isLambdaInvokeServiceError(first, bodyText)) {
|
|
49
|
+
return new Response(bodyText, {
|
|
50
|
+
status: first.status,
|
|
51
|
+
statusText: first.statusText,
|
|
52
|
+
headers: first.headers,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const delay = 25 + Math.floor(Math.random() * (LAMBDA_INVOKE_RETRY_MAX_DELAY_MS - 24));
|
|
56
|
+
await jitteredDelayWithAbort(delay, signal);
|
|
57
|
+
return fetchOnce();
|
|
58
|
+
}
|
|
59
|
+
function jitteredDelayWithAbort(ms, signal) {
|
|
60
|
+
if (signal?.aborted) {
|
|
61
|
+
return Promise.reject(signal.reason ?? new DOMException('The operation was aborted', 'AbortError'));
|
|
62
|
+
}
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const cleanup = () => signal?.removeEventListener('abort', abort);
|
|
65
|
+
const timer = setTimeout(() => {
|
|
66
|
+
cleanup();
|
|
67
|
+
resolve();
|
|
68
|
+
}, ms);
|
|
69
|
+
const abort = () => {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
cleanup();
|
|
72
|
+
reject(signal?.reason ?? new DOMException('The operation was aborted', 'AbortError'));
|
|
73
|
+
};
|
|
74
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
75
|
+
});
|
|
76
|
+
}
|
|
26
77
|
/**
|
|
27
78
|
* The identity-lookup abort bound, in milliseconds. Defaults to the shipped
|
|
28
79
|
* IDENTITY_LOOKUP_TIMEOUT_MS (15s). An optional HQ_IDENTITY_LOOKUP_TIMEOUT_MS
|
|
@@ -192,41 +243,40 @@ export async function vaultApiFetch(opts) {
|
|
|
192
243
|
level: "info",
|
|
193
244
|
data: { url: safeUrl, method },
|
|
194
245
|
});
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
246
|
+
const requestInit = {
|
|
247
|
+
method,
|
|
248
|
+
headers: {
|
|
249
|
+
Authorization: `Bearer ${opts.token}`,
|
|
250
|
+
'Content-Type': 'application/json',
|
|
251
|
+
'x-hq-client-name': HQ_CLIENT_NAME,
|
|
252
|
+
// US-003: version alongside the client family, mirroring the desktop
|
|
253
|
+
// app's client_info.rs headers, so the server can see client-version
|
|
254
|
+
// skew on EVERY authenticated request, not only health heartbeats.
|
|
255
|
+
// Fixed build constant, never caller-supplied; attribution only.
|
|
256
|
+
'x-hq-client-version': CLI_VERSION,
|
|
257
|
+
...opts.headers,
|
|
258
|
+
},
|
|
259
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
260
|
+
signal: opts.signal,
|
|
261
|
+
};
|
|
262
|
+
const fetchWithTransportBreadcrumb = async () => {
|
|
263
|
+
try {
|
|
264
|
+
return await fetch(url.toString(), requestInit);
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
// Keep the original transport failure and its bounded, redacted trail on
|
|
268
|
+
// both the first request and the single retry.
|
|
269
|
+
const code = networkTransportErrorCode(err);
|
|
270
|
+
Sentry.addBreadcrumb({
|
|
271
|
+
category: "http",
|
|
272
|
+
message: `${method} ${path} → transport error`,
|
|
273
|
+
level: "warning",
|
|
274
|
+
data: { url: safeUrl, method, ...(code ? { code } : {}) },
|
|
275
|
+
});
|
|
276
|
+
throw err;
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
const response = await fetchWithLambdaInvokeRetry(fetchWithTransportBreadcrumb, method.toUpperCase() === 'GET' && opts.body === undefined, opts.signal);
|
|
230
280
|
if (!response.ok) {
|
|
231
281
|
Sentry.addBreadcrumb({
|
|
232
282
|
category: "http",
|
|
@@ -287,10 +337,10 @@ export async function vaultApiFetchPublic(opts) {
|
|
|
287
337
|
level: 'info',
|
|
288
338
|
data: { url: safeUrl, method: 'GET' },
|
|
289
339
|
});
|
|
290
|
-
const response = await fetch(url.toString(), {
|
|
340
|
+
const response = await fetchWithLambdaInvokeRetry(() => fetch(url.toString(), {
|
|
291
341
|
method: 'GET',
|
|
292
342
|
headers: { 'Content-Type': 'application/json' },
|
|
293
|
-
});
|
|
343
|
+
}), true);
|
|
294
344
|
if (!response.ok) {
|
|
295
345
|
Sentry.addBreadcrumb({
|
|
296
346
|
category: 'http',
|