@indigoai-us/hq-cli 5.345.128 → 5.345.129
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 +12 -0
- package/dist/commands/core-bucket-c-work-mesh-offer-native.js +8 -2
- package/dist/commands/daemon.js +2 -0
- package/dist/commands/lanes.js +26 -14
- package/dist/commands/mesh.js +32 -19
- package/dist/lib/bot/inbox-poll-backoff-flag.d.ts +3 -5
- package/dist/lib/bot/inbox-poll-backoff-flag.js +20 -12
- package/dist/lib/bot/inbox-wake.d.ts +18 -0
- package/dist/lib/bot/inbox-wake.js +125 -0
- package/dist/lib/bot/run.d.ts +8 -0
- package/dist/lib/bot/run.js +51 -7
- package/dist/lib/bot/runtime/messages-stream.js +31 -3
- package/dist/lib/daemon/catalog.d.ts +4 -0
- package/dist/lib/daemon/catalog.js +6 -3
- package/dist/lib/daemon/flag-monitor.d.ts +4 -1
- package/dist/lib/daemon/flag-monitor.js +12 -6
- package/dist/lib/daemon/flag.d.ts +2 -0
- package/dist/lib/daemon/flag.js +11 -5
- package/dist/lib/daemon/hq-child-bootstrap.mjs +47 -7
- package/dist/lib/daemon/kill-switch-flags.d.ts +5 -0
- package/dist/lib/daemon/kill-switch-flags.js +5 -0
- package/dist/lib/daemon/lanes-reconcile-loader.d.ts +7 -0
- package/dist/lib/daemon/lanes-reconcile-loader.js +24 -0
- package/dist/lib/daemon/lanes-reconcile-watcher.d.ts +17 -0
- package/dist/lib/daemon/lanes-reconcile-watcher.js +80 -0
- package/dist/lib/daemon/remote-control.d.ts +8 -5
- package/dist/lib/daemon/remote-control.js +8 -7
- package/dist/lib/daemon/run.d.ts +4 -1
- package/dist/lib/daemon/run.js +133 -20
- package/dist/lib/doctor/json-output.d.ts +15 -3
- package/dist/lib/doctor/json-output.js +13 -1
- package/dist/lib/lanes/daemon-reconcile.d.ts +3 -0
- package/dist/lib/lanes/daemon-reconcile.js +8 -0
- package/dist/lib/lanes/reconcile-result.d.ts +10 -0
- package/dist/lib/lanes/reconcile-result.js +11 -0
- package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +1 -1
- package/dist/lib/mesh/live/daemon/work-subscribe.js +8 -1
- package/dist/lib/mesh/live/paths.d.ts +2 -0
- package/dist/lib/mesh/live/paths.js +2 -0
- package/dist/lib/mesh/story-status.d.ts +2 -2
- package/dist/lib/mesh/story-status.js +2 -1
- package/dist/utils/resident-package-preload.js +6 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.345.129] — 2026-10-14
|
|
6
|
+
|
|
7
|
+
- Claude Remote Control account pools now stay connected across Claude CLI updates. The daemon still restarts a pool if its host exits, but no longer kills busy pool sessions after the two-hour update deadline.
|
|
8
|
+
|
|
9
|
+
- The resident daemon now preloads the lane reconcile module that its in-process lane watcher loads. Main CI had been failing the resident preload coverage test since the idle-wakeup change (#1920).
|
|
10
|
+
- Session-start health no longer files a stale personal sync-manifest warning (`sync.manifest.personal`) as an unrepaired problem after `hq doctor --fix`. `--fix` cannot upload personal manifests, so `hq doctor --json` and `--client-health-report` now omit that WARN the same way they omit the leftover personal-journal WARN. A FAIL on that row still shows, and plain `hq doctor` still prints the WARN.
|
|
11
|
+
- Local bots now recover on their own when a saved Claude conversation no longer
|
|
12
|
+
exists. Before, every message in that chat failed with
|
|
13
|
+
`error_during_execution` until the bot's session file was removed by hand.
|
|
14
|
+
- Fix mesh project ensure dropping PRD stories in the queued state. PRD files without a userStories array now fail before Board writes, and unsupported ledger states cannot produce a partial projection.
|
|
15
|
+
- Idle local bots now wake from Work Mesh inbox notifications with a 60-second recovery pull. The daemon watches lane and child-handoff files, scans for sync processes only when its unit is missing, and leaves post-sync reindexing to compatible hq-cloud 6.18.75 or later runners.
|
|
16
|
+
|
|
5
17
|
## [5.345.128] — 2026-10-14
|
|
6
18
|
- `hq auth refresh` now says which identity it ensured and where its token file is, for example `HQ session ensured (machine identity <name>; token file: <path>)` or `HQ session ensured (person session; token file: <path>)`. The identity is read after the refresh, so a fallback to the machine identity is reported as machine. Token values are never printed.
|
|
7
19
|
- `hq auth refresh --json` prints `{refreshed, tokenSource, tokenFile, reason?}` on stdout and exits 1 when no session was refreshed.
|
|
@@ -37,8 +37,14 @@ function cloudUid(root, company) {
|
|
|
37
37
|
}
|
|
38
38
|
if (inCompany && /^ {2}[^\s#]/.test(line))
|
|
39
39
|
break;
|
|
40
|
-
if (inCompany
|
|
41
|
-
|
|
40
|
+
if (inCompany) {
|
|
41
|
+
// Match the shell's awk field contract: `$1 == "cloud_uid:"` and use
|
|
42
|
+
// only `$2`. A value must be separated from the key by whitespace;
|
|
43
|
+
// trailing comments and later fields do not become part of the UID.
|
|
44
|
+
const [key, value] = line.trim().split(/\s+/);
|
|
45
|
+
if (key === "cloud_uid:")
|
|
46
|
+
return (value ?? "").replace(/["']/g, "");
|
|
47
|
+
}
|
|
42
48
|
}
|
|
43
49
|
return "";
|
|
44
50
|
}
|
package/dist/commands/daemon.js
CHANGED
|
@@ -907,6 +907,7 @@ async function runTmuxService(d, opts) {
|
|
|
907
907
|
cwd: hqRoot,
|
|
908
908
|
argv: opts.argv,
|
|
909
909
|
label: opts.label,
|
|
910
|
+
restartOnToolUpdate: opts.restartOnToolUpdate,
|
|
910
911
|
// Only a new version of the tool restarts the session: not a same-version
|
|
911
912
|
// reinstall, and not an hq-cli release (the host's own code rarely matters).
|
|
912
913
|
binaryFingerprint: () => toolFingerprint(opts.watch),
|
|
@@ -1094,6 +1095,7 @@ async function runServiceRemoteControlPoolHost(d, accountId) {
|
|
|
1094
1095
|
argv: [claude, ...remoteControlPoolArgs(pool)],
|
|
1095
1096
|
label: `claude remote-control pool ${accountId}`,
|
|
1096
1097
|
watch: claude,
|
|
1098
|
+
restartOnToolUpdate: false,
|
|
1097
1099
|
signal: controller.signal,
|
|
1098
1100
|
sessionEnv: {
|
|
1099
1101
|
HOME: d.home,
|
package/dist/commands/lanes.js
CHANGED
|
@@ -24,6 +24,7 @@ export { resolveAnswerChoice } from "./lanes-questions.js";
|
|
|
24
24
|
import { registerOutpostPrunerCommand } from "./outpost-pruner.js";
|
|
25
25
|
import { registerLaneKpisCommand } from "./lanes-kpis.js";
|
|
26
26
|
import { registerLaneKeepAliveAuditCommand } from "./lanes-keep-alive-audit.js";
|
|
27
|
+
import { finishReconcilePass } from "../lib/lanes/reconcile-result.js";
|
|
27
28
|
import { registerLaneAutopilotReportCommand } from "./lanes-autopilot.js";
|
|
28
29
|
import { registerLaneChecksCommand } from "./lanes-checks.js";
|
|
29
30
|
import { registerLaneDropboxRepairCommand } from "./lanes-dropbox-repair.js";
|
|
@@ -41,6 +42,7 @@ import { runResidentCommandWithPackageLease } from "../utils/resident-package-pr
|
|
|
41
42
|
import { codexEnvEvidence, grokEnvEvidence } from "../lib/doctor/platform.js";
|
|
42
43
|
import { readLanesFlagLocalOverride, readLanesRegistryFlag, readLanesRegistryFlagFailOff, readLanesRegistryFlagFailOpen } from "../lib/lanes/registry-flag.js";
|
|
43
44
|
import { readLaneStatusRowFields } from "../lib/lanes/status-fields.js";
|
|
45
|
+
import { registerDaemonLanesReconcileHandler } from "../lib/lanes/daemon-reconcile.js";
|
|
44
46
|
import { appendProjectFocusOverride, readProjectStoryFocusData, removeProjectFocusOverride } from "../lib/lanes/story-ledger.js";
|
|
45
47
|
import { appendLaneFocusOverride, assertFocusGuardAllows, makeFocusOverride } from "../lib/lanes/focus-guard.js";
|
|
46
48
|
import { LANE_FOCUS_GUARD_FLAG, LANE_FOCUS_DASHBOARD_FLAG, LANE_HIRE_GRANT_FINISHED_LANES_FLAG, LANE_LIST_SKIP_REMOTE_REFRESH_FLAG, LANE_MESSAGE_ROUTE_OWNER_HOST_FLAG, LANE_ORPHANED_BARE_ID_DROPBOX_REPAIR_FLAG, LANE_PROFILE_PIN_EXHAUSTED_FALLBACK_FLAG, LANE_STATUS_ROW_FIELDS_FLAG, LANE_REAP_STOP_FLAG, LANE_REUSE_REFRESHES_PROFILE_FLAG, LANE_WORKER_CONTINUITY_FLAG, LANE_CONDUCT_LOOP_FLAG, LANE_ENVELOPE_REPAIR_GUIDANCE_FLAG, } from "../lib/lanes/flag-keys.js";
|
|
@@ -9538,21 +9540,21 @@ async function runReconcile(ctx, opts, recoverQueuedMessages) {
|
|
|
9538
9540
|
summary.errors.push({ code: error instanceof LanesError ? error.error : "lead_autopilot_failed", message });
|
|
9539
9541
|
process.stderr.write(`hq lanes autopilot: ${message}\n`);
|
|
9540
9542
|
}
|
|
9541
|
-
|
|
9542
|
-
|
|
9543
|
-
|
|
9544
|
-
|
|
9545
|
-
|
|
9546
|
-
|
|
9547
|
-
|
|
9548
|
-
|
|
9549
|
-
|
|
9550
|
-
|
|
9551
|
-
|
|
9552
|
-
|
|
9553
|
-
|
|
9554
|
-
process.exit(1);
|
|
9543
|
+
if (!opts.inProcess) {
|
|
9544
|
+
printJson({
|
|
9545
|
+
...summary,
|
|
9546
|
+
...(autopilotRegistrySync ? { autopilot_registry_sync: autopilotRegistrySync } : {}),
|
|
9547
|
+
...(autopilot ? { autopilot } : {}),
|
|
9548
|
+
...(remoteLost.length > 0 ? { remote_lost: remoteLost } : {}),
|
|
9549
|
+
...(mirrorRefresh.unverifiedRemote.size > 0
|
|
9550
|
+
? {
|
|
9551
|
+
unverified_remote_lanes: [...mirrorRefresh.unverifiedRemote.keys()]
|
|
9552
|
+
.filter((id) => !terminalizedRemoteIds.has(id)),
|
|
9553
|
+
}
|
|
9554
|
+
: {}),
|
|
9555
|
+
});
|
|
9555
9556
|
}
|
|
9557
|
+
finishReconcilePass(summary, opts.inProcess ? "daemon" : "cli");
|
|
9556
9558
|
}
|
|
9557
9559
|
async function runReconcileInstall(ctx, opts) {
|
|
9558
9560
|
// `hq daemon` runs lanes-reconcile on this machine; a separate timer would run it twice.
|
|
@@ -9757,6 +9759,16 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
9757
9759
|
withResumeHireAdmission,
|
|
9758
9760
|
};
|
|
9759
9761
|
const messageHandlers = createLaneMessageHandlers(messageHandlerDeps);
|
|
9762
|
+
const daemonReconcileCtx = { ...ctx, env: { ...ctx.env } };
|
|
9763
|
+
registerDaemonLanesReconcileHandler(async () => {
|
|
9764
|
+
const hqRoot = daemonReconcileCtx.hqRoot();
|
|
9765
|
+
daemonReconcileCtx.env.HQ_ROOT = hqRoot;
|
|
9766
|
+
daemonReconcileCtx.cwd = () => hqRoot;
|
|
9767
|
+
daemonReconcileCtx.callerCwd = () => hqRoot;
|
|
9768
|
+
await runReconcile(daemonReconcileCtx, { inProcess: true }, async (lane) => {
|
|
9769
|
+
await messageHandlers.recoverQueuedMessages(daemonReconcileCtx, lane.lane_id);
|
|
9770
|
+
});
|
|
9771
|
+
});
|
|
9760
9772
|
monitorLaneMessageApis.set(parent, (laneId, payload) => messageHandlers.deliverMonitorLaneMessage(ctx, laneId, payload));
|
|
9761
9773
|
const resumeHandlers = createLaneResumeHandlers({
|
|
9762
9774
|
onLaneWorkerStarted: deps.onLaneWorkerStarted,
|
package/dist/commands/mesh.js
CHANGED
|
@@ -307,16 +307,21 @@ function parseProjectReposJson(raw, source) {
|
|
|
307
307
|
throw err;
|
|
308
308
|
}
|
|
309
309
|
}
|
|
310
|
-
function projectStoriesFromJson(value, source) {
|
|
310
|
+
function projectStoriesFromJson(value, source, options = {}) {
|
|
311
311
|
const plan = Array.isArray(value) ? null : record(value);
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
312
|
+
if (options.requireUserStories && (!plan || !Array.isArray(plan.userStories))) {
|
|
313
|
+
fail(`${source} must be a PRD object with a userStories array.`);
|
|
314
|
+
}
|
|
315
|
+
const rawStories = options.requireUserStories
|
|
316
|
+
? plan?.userStories ?? null
|
|
317
|
+
: Array.isArray(value)
|
|
318
|
+
? value
|
|
319
|
+
: Array.isArray(plan?.userStories)
|
|
320
|
+
? plan.userStories
|
|
321
|
+
: Array.isArray(plan?.stories)
|
|
322
|
+
? plan.stories
|
|
323
|
+
: null;
|
|
324
|
+
if (!Array.isArray(rawStories)) {
|
|
320
325
|
fail(`${source} must be a JSON story array or an object with userStories.`);
|
|
321
326
|
}
|
|
322
327
|
const rejectedStories = [];
|
|
@@ -398,7 +403,7 @@ function failProjectStoryStates(rejected, json) {
|
|
|
398
403
|
}
|
|
399
404
|
function parseProjectPlan(raw, source, options = {}) {
|
|
400
405
|
try {
|
|
401
|
-
const plan = projectStoriesFromJson(JSON.parse(raw), source);
|
|
406
|
+
const plan = projectStoriesFromJson(JSON.parse(raw), source, options);
|
|
402
407
|
if (plan.rejectedStories.length && !options.allowRejectedStories) {
|
|
403
408
|
throw new RejectedProjectStoryStatesError(plan.rejectedStories);
|
|
404
409
|
}
|
|
@@ -431,7 +436,7 @@ async function runProjectSet(projectId, opts) {
|
|
|
431
436
|
fail("Pass only one of --repos-json or --repo.");
|
|
432
437
|
}
|
|
433
438
|
const source = opts.storiesFile
|
|
434
|
-
? parseProjectPlan(readFileSync(opts.storiesFile, "utf8"), opts.storiesFile)
|
|
439
|
+
? parseProjectPlan(readFileSync(opts.storiesFile, "utf8"), opts.storiesFile, { requireUserStories: true })
|
|
435
440
|
: opts.storiesJson
|
|
436
441
|
? parseProjectPlan(opts.storiesJson, "--stories-json")
|
|
437
442
|
: undefined;
|
|
@@ -588,12 +593,17 @@ async function runProjectMemberAdd(projectId, personUid, opts) {
|
|
|
588
593
|
async function runProjectEnsure(projectId, opts) {
|
|
589
594
|
if (!opts.storiesFile?.trim())
|
|
590
595
|
fail("`--stories-file <prd.json>` is required.");
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
596
|
+
let source;
|
|
597
|
+
try {
|
|
598
|
+
source = parseProjectPlan(readFileSync(opts.storiesFile, "utf8"), opts.storiesFile, { requireUserStories: true });
|
|
599
|
+
}
|
|
600
|
+
catch (err) {
|
|
601
|
+
if (!(err instanceof RejectedProjectStoryStatesError) || !opts.json)
|
|
602
|
+
throw err;
|
|
603
|
+
failProjectStoryStates(err.rejected, true);
|
|
595
604
|
return;
|
|
596
605
|
}
|
|
606
|
+
const { token, company } = await withCompany(opts);
|
|
597
607
|
let current;
|
|
598
608
|
try {
|
|
599
609
|
current = await getProjectView(token, company.companyUid, projectId);
|
|
@@ -738,7 +748,10 @@ async function runProjectEnsure(projectId, opts) {
|
|
|
738
748
|
async function runProjectReconcile(projectId, opts) {
|
|
739
749
|
if (!opts.storiesFile?.trim())
|
|
740
750
|
fail("`--stories-file <prd.json>` is required.");
|
|
741
|
-
const source = parseProjectPlan(readFileSync(opts.storiesFile, "utf8"), opts.storiesFile, {
|
|
751
|
+
const source = parseProjectPlan(readFileSync(opts.storiesFile, "utf8"), opts.storiesFile, {
|
|
752
|
+
requireUserStories: true,
|
|
753
|
+
allowRejectedStories: true,
|
|
754
|
+
});
|
|
742
755
|
const { token, company } = await withCompany(opts);
|
|
743
756
|
let current;
|
|
744
757
|
try {
|
|
@@ -852,7 +865,7 @@ async function runPrdSync(opts) {
|
|
|
852
865
|
return;
|
|
853
866
|
}
|
|
854
867
|
try {
|
|
855
|
-
const source = parseProjectPlan(readFileSync(file, "utf8"), file);
|
|
868
|
+
const source = parseProjectPlan(readFileSync(file, "utf8"), file, { requireUserStories: true });
|
|
856
869
|
await ensureMissingBoardStories({
|
|
857
870
|
token,
|
|
858
871
|
company: {
|
|
@@ -1091,7 +1104,7 @@ async function runBindProject(target, opts) {
|
|
|
1091
1104
|
}
|
|
1092
1105
|
if (!current.companyUid)
|
|
1093
1106
|
fail(`Session ${sessionId} has no company. Bind a company first.`);
|
|
1094
|
-
const plan = parseProjectPlan(readFileSync(prdPath, "utf8"), prdPath);
|
|
1107
|
+
const plan = parseProjectPlan(readFileSync(prdPath, "utf8"), prdPath, { requireUserStories: true });
|
|
1095
1108
|
const { token, company } = await withCompany({ company: opts.company ?? current.companySlug });
|
|
1096
1109
|
await ensureProjectBoard({
|
|
1097
1110
|
token,
|
|
@@ -1234,7 +1247,7 @@ async function runContextReconcile(opts) {
|
|
|
1234
1247
|
validateMembership,
|
|
1235
1248
|
ensureProjectBoard: token
|
|
1236
1249
|
? async ({ companyUid, projectId, prdPath }) => {
|
|
1237
|
-
const plan = parseProjectPlan(readFileSync(prdPath, "utf8"), prdPath);
|
|
1250
|
+
const plan = parseProjectPlan(readFileSync(prdPath, "utf8"), prdPath, { requireUserStories: true });
|
|
1238
1251
|
await ensureProjectBoard({
|
|
1239
1252
|
token,
|
|
1240
1253
|
company: { companyUid, companySlug: companyUid },
|
|
@@ -2,6 +2,8 @@ import { type FlagClient, type FlagClientOptions } from "@indigoai-us/hq-flags-c
|
|
|
2
2
|
import { type BotConfig } from "./config.js";
|
|
3
3
|
export declare const INBOX_POLL_BACKOFF_FLAG = "agents.inbox-poll-backoff-local-cli";
|
|
4
4
|
export declare const INBOX_POLL_BACKOFF_FLAG_FALLBACK = false;
|
|
5
|
+
export declare const INBOX_MQTT_WAKE_FLAG = "agents.bot-inbox-mqtt-wake";
|
|
6
|
+
export declare const INBOX_MQTT_WAKE_FLAG_FALLBACK = true;
|
|
5
7
|
export declare const INBOX_POLL_BACKOFF_FLAG_TIMEOUT_MS = 2000;
|
|
6
8
|
export declare const INBOX_POLL_BACKOFF_FLAG_CACHE_MS: number;
|
|
7
9
|
type InboxPollBackoffFlagClient = Pick<FlagClient, "ready" | "isEnabled" | "close">;
|
|
@@ -14,11 +16,7 @@ export interface InboxPollBackoffFlagReaderDependencies {
|
|
|
14
16
|
reportFailure?: (error: unknown) => void;
|
|
15
17
|
}
|
|
16
18
|
export declare function safeErrorClass(error: unknown): string;
|
|
17
|
-
/**
|
|
18
|
-
* Create a per-bot reader. The flag client is opened at most once per three
|
|
19
|
-
* minutes; a missing identity, failed request, or malformed snapshot keeps
|
|
20
|
-
* idle polling at its existing two-second interval.
|
|
21
|
-
*/
|
|
22
19
|
export declare function createInboxPollBackoffFlagReader(config: Pick<BotConfig, "kind" | "workerId" | "companies" | "companySlug" | "hqRoot">, dependencies?: InboxPollBackoffFlagReaderDependencies): () => Promise<boolean>;
|
|
20
|
+
export declare function createInboxMqttWakeFlagReader(config: Pick<BotConfig, "kind" | "workerId" | "companies" | "companySlug" | "hqRoot">, dependencies?: InboxPollBackoffFlagReaderDependencies): () => Promise<boolean>;
|
|
23
21
|
export {};
|
|
24
22
|
//# sourceMappingURL=inbox-poll-backoff-flag.d.ts.map
|
|
@@ -5,6 +5,8 @@ import { resolveFlagRegistryEndpoint } from "../flag-registry-endpoint.js";
|
|
|
5
5
|
import { effectiveBotCompanies, effectiveBotKind } from "./config.js";
|
|
6
6
|
export const INBOX_POLL_BACKOFF_FLAG = "agents.inbox-poll-backoff-local-cli";
|
|
7
7
|
export const INBOX_POLL_BACKOFF_FLAG_FALLBACK = false;
|
|
8
|
+
export const INBOX_MQTT_WAKE_FLAG = "agents.bot-inbox-mqtt-wake";
|
|
9
|
+
export const INBOX_MQTT_WAKE_FLAG_FALLBACK = true;
|
|
8
10
|
// One read every three minutes on a fresh connection. From a home network the
|
|
9
11
|
// TCP and TLS handshakes plus the resolve call take about 250-400 ms, so a
|
|
10
12
|
// 150 ms budget timed out on every read and kept the gate off.
|
|
@@ -15,8 +17,8 @@ export function safeErrorClass(error) {
|
|
|
15
17
|
const name = error instanceof Error ? error.name : "UnknownError";
|
|
16
18
|
return /^[A-Za-z][A-Za-z0-9]{0,63}$/.test(name) ? name : "UnknownError";
|
|
17
19
|
}
|
|
18
|
-
function reportFlagFailure(error) {
|
|
19
|
-
process.stderr.write(`HQ local bot inbox
|
|
20
|
+
function reportFlagFailure(error, fallback) {
|
|
21
|
+
process.stderr.write(`HQ local bot inbox flag lookup failed (${safeErrorClass(error)}); using default ${fallback ? "on" : "off"}.\n`);
|
|
20
22
|
}
|
|
21
23
|
function companyContexts(config, env) {
|
|
22
24
|
if (effectiveBotKind(config) !== "company")
|
|
@@ -44,9 +46,9 @@ function companyContexts(config, env) {
|
|
|
44
46
|
/**
|
|
45
47
|
* Create a per-bot reader. The flag client is opened at most once per three
|
|
46
48
|
* minutes; a missing identity, failed request, or malformed snapshot keeps
|
|
47
|
-
*
|
|
49
|
+
* registered fallback.
|
|
48
50
|
*/
|
|
49
|
-
|
|
51
|
+
function createInboxFlagReader(config, flagKey, fallback, dependencies = {}) {
|
|
50
52
|
const env = dependencies.env ?? process.env;
|
|
51
53
|
const contexts = companyContexts(config, env);
|
|
52
54
|
const endpoint = resolveFlagRegistryEndpoint(undefined, env);
|
|
@@ -54,15 +56,15 @@ export function createInboxPollBackoffFlagReader(config, dependencies = {}) {
|
|
|
54
56
|
const createClient = dependencies.createClient ?? createFlagClient;
|
|
55
57
|
const getToken = dependencies.getToken ?? (() => loadCachedTokens()?.idToken ?? "");
|
|
56
58
|
const doFetch = dependencies.fetch ?? globalThis.fetch;
|
|
57
|
-
const report = dependencies.reportFailure ?? reportFlagFailure;
|
|
58
|
-
let cachedValue =
|
|
59
|
+
const report = dependencies.reportFailure ?? ((error) => reportFlagFailure(error, fallback));
|
|
60
|
+
let cachedValue = fallback;
|
|
59
61
|
let lastReadAt = Number.NEGATIVE_INFINITY;
|
|
60
62
|
let pending = null;
|
|
61
63
|
const readContext = async (context) => {
|
|
62
64
|
let client;
|
|
63
65
|
let failed = false;
|
|
64
66
|
let failureReported = false;
|
|
65
|
-
let enabled =
|
|
67
|
+
let enabled = fallback;
|
|
66
68
|
const reportFailureOnce = (error) => {
|
|
67
69
|
failed = true;
|
|
68
70
|
if (failureReported)
|
|
@@ -84,7 +86,7 @@ export function createInboxPollBackoffFlagReader(config, dependencies = {}) {
|
|
|
84
86
|
});
|
|
85
87
|
await client.ready();
|
|
86
88
|
if (!failed) {
|
|
87
|
-
enabled = resolveFlagGate(client,
|
|
89
|
+
enabled = resolveFlagGate(client, flagKey, { fallback }, () => fallback);
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
catch (error) {
|
|
@@ -98,12 +100,12 @@ export function createInboxPollBackoffFlagReader(config, dependencies = {}) {
|
|
|
98
100
|
reportFailureOnce(error);
|
|
99
101
|
}
|
|
100
102
|
}
|
|
101
|
-
return failed ?
|
|
103
|
+
return failed ? fallback : enabled;
|
|
102
104
|
};
|
|
103
105
|
const read = async () => {
|
|
104
106
|
if (!contexts || contexts.length === 0) {
|
|
105
|
-
report(new Error("local bot inbox
|
|
106
|
-
return
|
|
107
|
+
report(new Error("local bot inbox flag has no resolvable company identity"));
|
|
108
|
+
return fallback;
|
|
107
109
|
}
|
|
108
110
|
const values = await Promise.all(contexts.map(readContext));
|
|
109
111
|
return values.every(Boolean);
|
|
@@ -123,7 +125,7 @@ export function createInboxPollBackoffFlagReader(config, dependencies = {}) {
|
|
|
123
125
|
})
|
|
124
126
|
.catch((error) => {
|
|
125
127
|
report(error);
|
|
126
|
-
cachedValue =
|
|
128
|
+
cachedValue = fallback;
|
|
127
129
|
return cachedValue;
|
|
128
130
|
})
|
|
129
131
|
.finally(() => {
|
|
@@ -132,4 +134,10 @@ export function createInboxPollBackoffFlagReader(config, dependencies = {}) {
|
|
|
132
134
|
return pending;
|
|
133
135
|
};
|
|
134
136
|
}
|
|
137
|
+
export function createInboxPollBackoffFlagReader(config, dependencies = {}) {
|
|
138
|
+
return createInboxFlagReader(config, INBOX_POLL_BACKOFF_FLAG, INBOX_POLL_BACKOFF_FLAG_FALLBACK, dependencies);
|
|
139
|
+
}
|
|
140
|
+
export function createInboxMqttWakeFlagReader(config, dependencies = {}) {
|
|
141
|
+
return createInboxFlagReader(config, INBOX_MQTT_WAKE_FLAG, INBOX_MQTT_WAKE_FLAG_FALLBACK, dependencies);
|
|
142
|
+
}
|
|
135
143
|
//# sourceMappingURL=inbox-poll-backoff-flag.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
export { AGENT_INBOX_PUSH_STATE_BASENAME } from "../mesh/live/paths.js";
|
|
3
|
+
type WakeEvent = "wake" | "timeout" | "aborted";
|
|
4
|
+
type Watcher = Pick<fs.FSWatcher, "close"> & {
|
|
5
|
+
on?: (event: "error", listener: (error: Error) => void) => unknown;
|
|
6
|
+
};
|
|
7
|
+
export interface InboxWakeWatcher {
|
|
8
|
+
wait(delayMs: number, signal?: AbortSignal): Promise<WakeEvent>;
|
|
9
|
+
isLiveFor?(agentUid: string): boolean;
|
|
10
|
+
close(): void;
|
|
11
|
+
}
|
|
12
|
+
/** Watches the content-free Work Mesh inbox sequence, with a bounded poll fallback. */
|
|
13
|
+
export declare function createInboxWakeWatcher(root: string, options?: {
|
|
14
|
+
watch?: (root: string, listener: (event: string, filename: string | Buffer | null) => void) => Watcher;
|
|
15
|
+
onError?: (error: unknown) => void;
|
|
16
|
+
now?: () => number;
|
|
17
|
+
}): InboxWakeWatcher;
|
|
18
|
+
//# sourceMappingURL=inbox-wake.d.ts.map
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { AGENT_INBOX_PUSH_STATE_BASENAME } from "../mesh/live/paths.js";
|
|
4
|
+
export { AGENT_INBOX_PUSH_STATE_BASENAME } from "../mesh/live/paths.js";
|
|
5
|
+
const INBOX_PUSH_SUBSCRIPTION_STALE_MS = 15_000;
|
|
6
|
+
function readWakeState(root, onError) {
|
|
7
|
+
const target = path.join(root, AGENT_INBOX_PUSH_STATE_BASENAME);
|
|
8
|
+
try {
|
|
9
|
+
const info = fs.lstatSync(target);
|
|
10
|
+
if (!info.isFile() || info.isSymbolicLink())
|
|
11
|
+
return { wakeSequence: 0, connected: false, heartbeatAtMs: 0 };
|
|
12
|
+
const parsed = JSON.parse(fs.readFileSync(target, "utf8"));
|
|
13
|
+
return {
|
|
14
|
+
wakeSequence: Number.isSafeInteger(parsed.wakeSequence) && Number(parsed.wakeSequence) >= 0
|
|
15
|
+
? Number(parsed.wakeSequence)
|
|
16
|
+
: 0,
|
|
17
|
+
connected: parsed.connected === true,
|
|
18
|
+
heartbeatAtMs: typeof parsed.heartbeatAtMs === "number" && Number.isFinite(parsed.heartbeatAtMs)
|
|
19
|
+
? parsed.heartbeatAtMs
|
|
20
|
+
: 0,
|
|
21
|
+
...(typeof parsed.agentUid === "string" ? { agentUid: parsed.agentUid } : {}),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (error.code !== "ENOENT")
|
|
26
|
+
onError?.(error);
|
|
27
|
+
return { wakeSequence: 0, connected: false, heartbeatAtMs: 0 };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Watches the content-free Work Mesh inbox sequence, with a bounded poll fallback. */
|
|
31
|
+
export function createInboxWakeWatcher(root, options = {}) {
|
|
32
|
+
const now = options.now ?? Date.now;
|
|
33
|
+
let lastState = readWakeState(root, options.onError);
|
|
34
|
+
let lastSequence = lastState.wakeSequence;
|
|
35
|
+
let closed = false;
|
|
36
|
+
let pendingWake = false;
|
|
37
|
+
const waiters = new Set();
|
|
38
|
+
let watcher;
|
|
39
|
+
const signalWake = () => {
|
|
40
|
+
pendingWake = waiters.size === 0;
|
|
41
|
+
for (const resolve of waiters)
|
|
42
|
+
resolve("wake");
|
|
43
|
+
waiters.clear();
|
|
44
|
+
};
|
|
45
|
+
try {
|
|
46
|
+
watcher = (options.watch ?? ((directory, listener) => fs.watch(directory, listener)))(root, (_event, filename) => {
|
|
47
|
+
if (filename !== null && filename.toString() !== AGENT_INBOX_PUSH_STATE_BASENAME)
|
|
48
|
+
return;
|
|
49
|
+
const nextState = readWakeState(root, options.onError);
|
|
50
|
+
const sequenceAdvanced = nextState.wakeSequence > lastSequence;
|
|
51
|
+
const subscriptionChanged = nextState.connected !== lastState.connected || nextState.agentUid !== lastState.agentUid;
|
|
52
|
+
lastState = nextState;
|
|
53
|
+
if (sequenceAdvanced) {
|
|
54
|
+
lastSequence = nextState.wakeSequence;
|
|
55
|
+
signalWake();
|
|
56
|
+
}
|
|
57
|
+
else if (subscriptionChanged) {
|
|
58
|
+
signalWake();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
watcher.on?.("error", (error) => {
|
|
62
|
+
options.onError?.(error);
|
|
63
|
+
const failedWatcher = watcher;
|
|
64
|
+
watcher = undefined;
|
|
65
|
+
try {
|
|
66
|
+
failedWatcher?.close();
|
|
67
|
+
}
|
|
68
|
+
catch (closeError) {
|
|
69
|
+
options.onError?.(closeError);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
// Close the read/register race: a writer can advance the sequence after
|
|
73
|
+
// the baseline read but before fs.watch is attached.
|
|
74
|
+
const currentState = readWakeState(root, options.onError);
|
|
75
|
+
if (currentState.wakeSequence > lastSequence) {
|
|
76
|
+
lastSequence = currentState.wakeSequence;
|
|
77
|
+
signalWake();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
options.onError?.(error);
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
wait(delayMs, signal) {
|
|
85
|
+
if (closed || signal?.aborted)
|
|
86
|
+
return Promise.resolve("aborted");
|
|
87
|
+
if (pendingWake) {
|
|
88
|
+
pendingWake = false;
|
|
89
|
+
return Promise.resolve("wake");
|
|
90
|
+
}
|
|
91
|
+
return new Promise((resolve) => {
|
|
92
|
+
let settled = false;
|
|
93
|
+
const finish = (event) => {
|
|
94
|
+
if (settled)
|
|
95
|
+
return;
|
|
96
|
+
settled = true;
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
waiters.delete(finish);
|
|
99
|
+
signal?.removeEventListener("abort", abort);
|
|
100
|
+
resolve(event);
|
|
101
|
+
};
|
|
102
|
+
const abort = () => finish("aborted");
|
|
103
|
+
const timer = setTimeout(() => finish("timeout"), Math.max(0, delayMs));
|
|
104
|
+
waiters.add(finish);
|
|
105
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
isLiveFor(agentUid) {
|
|
109
|
+
const state = readWakeState(root, options.onError);
|
|
110
|
+
const ageMs = now() - state.heartbeatAtMs;
|
|
111
|
+
return state.connected && state.agentUid === agentUid &&
|
|
112
|
+
ageMs >= 0 && ageMs <= INBOX_PUSH_SUBSCRIPTION_STALE_MS;
|
|
113
|
+
},
|
|
114
|
+
close() {
|
|
115
|
+
if (closed)
|
|
116
|
+
return;
|
|
117
|
+
closed = true;
|
|
118
|
+
watcher?.close();
|
|
119
|
+
for (const resolve of waiters)
|
|
120
|
+
resolve("aborted");
|
|
121
|
+
waiters.clear();
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=inbox-wake.js.map
|
package/dist/lib/bot/run.d.ts
CHANGED
|
@@ -58,7 +58,9 @@ import type { VaultObjectDownloader } from "../agent-kit/attachment-download.js"
|
|
|
58
58
|
import { type HeartbeatAgentUidSource, type HeartbeatCredsSource, type HeartbeatIdentityMismatchReporter } from "../heartbeat-identity.js";
|
|
59
59
|
import { type BotState, type BotStatusFile } from "./status.js";
|
|
60
60
|
import { type CliUpdate, type CliUpdateWatch } from "./cli-update.js";
|
|
61
|
+
import { createInboxWakeWatcher } from "./inbox-wake.js";
|
|
61
62
|
export declare const INBOX_POLL_INTERVAL_MS = 2000;
|
|
63
|
+
export declare const INBOX_MQTT_WAKE_FALLBACK_MS = 60000;
|
|
62
64
|
export declare const HEARTBEAT_INTERVAL_MS = 30000;
|
|
63
65
|
/** First 429/5xx wait when Retry-After is absent (inbox polls at 2s). */
|
|
64
66
|
export declare const API_BACKOFF_BASE_MS = 4000;
|
|
@@ -154,6 +156,12 @@ export interface BotRunDeps {
|
|
|
154
156
|
signInRetryMs?: number;
|
|
155
157
|
/** How long model turns are held after FAILURE_LIMIT failures in a window (default FAILURE_WINDOW_MS). */
|
|
156
158
|
modelHoldMs?: number;
|
|
159
|
+
/** Kill-switch reader for the Work Mesh MQTT inbox wake path. */
|
|
160
|
+
inboxMqttWakeEnabled?: () => Promise<boolean>;
|
|
161
|
+
/** Work Mesh state directory; tests use a scratch directory. */
|
|
162
|
+
inboxWakeRoot?: string;
|
|
163
|
+
/** Test seam for the inbox wake source. */
|
|
164
|
+
createInboxWakeWatcher?: typeof createInboxWakeWatcher;
|
|
157
165
|
/**
|
|
158
166
|
* Downloads a chat attachment's bytes with the bot's own machine identity
|
|
159
167
|
* (vault presign). Unset = attachments are listed with a "cannot download"
|
package/dist/lib/bot/run.js
CHANGED
|
@@ -89,8 +89,11 @@ import { clearBotSession, readBotSession, writeBotSession } from "./session.js";
|
|
|
89
89
|
import { defaultBotStatus, patchBotStatus, readBotStatus } from "./status.js";
|
|
90
90
|
import { CLI_VERSION } from "../../cli-version.js";
|
|
91
91
|
import { CLI_UPDATE_RESTART_EXIT_CODE } from "./cli-update.js";
|
|
92
|
-
import { createInboxPollBackoffFlagReader, safeErrorClass } from "./inbox-poll-backoff-flag.js";
|
|
92
|
+
import { createInboxMqttWakeFlagReader, createInboxPollBackoffFlagReader, safeErrorClass } from "./inbox-poll-backoff-flag.js";
|
|
93
|
+
import { createInboxWakeWatcher } from "./inbox-wake.js";
|
|
94
|
+
import { workMeshRoot } from "../mesh/live/paths.js";
|
|
93
95
|
export const INBOX_POLL_INTERVAL_MS = 2_000;
|
|
96
|
+
export const INBOX_MQTT_WAKE_FALLBACK_MS = 60_000;
|
|
94
97
|
export const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
95
98
|
/** First 429/5xx wait when Retry-After is absent (inbox polls at 2s). */
|
|
96
99
|
export const API_BACKOFF_BASE_MS = 4_000;
|
|
@@ -337,6 +340,21 @@ export async function runBot(deps) {
|
|
|
337
340
|
const runTurn = deps.runTurn ?? ((rt, input) => runRuntimeTurn(rt, input));
|
|
338
341
|
const inboxIntervalMs = deps.inboxIntervalMs ?? INBOX_POLL_INTERVAL_MS;
|
|
339
342
|
const inboxPollBackoffEnabled = deps.inboxPollBackoffEnabled ?? createInboxPollBackoffFlagReader(config);
|
|
343
|
+
const inboxMqttWakeEnabled = deps.inboxMqttWakeEnabled ?? createInboxMqttWakeFlagReader(config);
|
|
344
|
+
const makeInboxWakeWatcher = deps.createInboxWakeWatcher ?? createInboxWakeWatcher;
|
|
345
|
+
const inboxWakeWatcherRef = { current: null };
|
|
346
|
+
const getInboxWakeWatcher = () => {
|
|
347
|
+
const watcher = inboxWakeWatcherRef.current ?? makeInboxWakeWatcher(deps.inboxWakeRoot ?? workMeshRoot(), {
|
|
348
|
+
onError: (error) => log("warn", `Work Mesh inbox wake unavailable (${safeErrorClass(error)}); retaining the fallback inbox poll`),
|
|
349
|
+
});
|
|
350
|
+
inboxWakeWatcherRef.current = watcher;
|
|
351
|
+
return watcher;
|
|
352
|
+
};
|
|
353
|
+
const closeInboxWakeWatcher = () => {
|
|
354
|
+
const watcher = inboxWakeWatcherRef.current;
|
|
355
|
+
watcher?.close();
|
|
356
|
+
inboxWakeWatcherRef.current = null;
|
|
357
|
+
};
|
|
340
358
|
const jobQuietOptionEnabled = deps.jobQuietOptionEnabled ?? botJobQuietOptionEnabled;
|
|
341
359
|
const heartbeatIntervalMs = deps.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS;
|
|
342
360
|
const exit = deps.exit ?? ((code) => process.exit(code));
|
|
@@ -443,6 +461,7 @@ export async function runBot(deps) {
|
|
|
443
461
|
if (state === "stopped" || state === "failed")
|
|
444
462
|
return;
|
|
445
463
|
state = final;
|
|
464
|
+
closeInboxWakeWatcher();
|
|
446
465
|
setStatus({ state: final, ...(final === "failed" ? { lastError: reason.slice(0, 400) } : {}) });
|
|
447
466
|
log(final === "failed" ? "error" : "info", `bot ${final}: ${reason}`);
|
|
448
467
|
if (!deps.skipPidLock)
|
|
@@ -771,7 +790,7 @@ export async function runBot(deps) {
|
|
|
771
790
|
inboxWaitGeneration += 1;
|
|
772
791
|
activeInboxWait?.abort();
|
|
773
792
|
};
|
|
774
|
-
const waitForInbox = async (delayMs, adaptive) => {
|
|
793
|
+
const waitForInbox = async (delayMs, adaptive, useMqttWake = false) => {
|
|
775
794
|
if (!adaptive) {
|
|
776
795
|
await sleep(delayMs);
|
|
777
796
|
return;
|
|
@@ -781,8 +800,13 @@ export async function runBot(deps) {
|
|
|
781
800
|
while (remainingMs > 0 && !stopping && state === "running") {
|
|
782
801
|
const controller = new AbortController();
|
|
783
802
|
activeInboxWait = controller;
|
|
784
|
-
const chunkMs = Math.min(inboxIntervalMs, remainingMs);
|
|
785
|
-
|
|
803
|
+
const chunkMs = useMqttWake ? remainingMs : Math.min(inboxIntervalMs, remainingMs);
|
|
804
|
+
if (useMqttWake) {
|
|
805
|
+
await getInboxWakeWatcher().wait(chunkMs, controller.signal);
|
|
806
|
+
}
|
|
807
|
+
else {
|
|
808
|
+
await sleep(chunkMs, controller.signal);
|
|
809
|
+
}
|
|
786
810
|
if (activeInboxWait === controller)
|
|
787
811
|
activeInboxWait = null;
|
|
788
812
|
if (stopping || state !== "running")
|
|
@@ -795,9 +819,8 @@ export async function runBot(deps) {
|
|
|
795
819
|
continue;
|
|
796
820
|
}
|
|
797
821
|
remainingMs -= chunkMs;
|
|
798
|
-
//
|
|
799
|
-
|
|
800
|
-
if (remainingMs > 0 && pollForCliUpdate())
|
|
822
|
+
// The MQTT wake or bounded fallback also checks for CLI updates.
|
|
823
|
+
if (remainingMs > 0 && !useMqttWake && pollForCliUpdate())
|
|
801
824
|
break;
|
|
802
825
|
}
|
|
803
826
|
};
|
|
@@ -2332,6 +2355,18 @@ export async function runBot(deps) {
|
|
|
2332
2355
|
log("warn", `inbox poll backoff flag lookup failed (${safeErrorClass(error)}); using default-off`);
|
|
2333
2356
|
return false;
|
|
2334
2357
|
});
|
|
2358
|
+
const useMqttWake = await inboxMqttWakeEnabled().catch((error) => {
|
|
2359
|
+
log("warn", `inbox MQTT wake flag lookup failed (${safeErrorClass(error)}); using default-on`);
|
|
2360
|
+
return true;
|
|
2361
|
+
});
|
|
2362
|
+
// Arm before pulling so a wake arriving between the pull and wait is not
|
|
2363
|
+
// lost. The watcher also checks the sequence again after registration.
|
|
2364
|
+
if (useMqttWake) {
|
|
2365
|
+
getInboxWakeWatcher();
|
|
2366
|
+
}
|
|
2367
|
+
else {
|
|
2368
|
+
closeInboxWakeWatcher();
|
|
2369
|
+
}
|
|
2335
2370
|
let items;
|
|
2336
2371
|
try {
|
|
2337
2372
|
items = await api.pullInbox(config.agentUid);
|
|
@@ -2361,6 +2396,15 @@ export async function runBot(deps) {
|
|
|
2361
2396
|
}
|
|
2362
2397
|
if (stopping || state !== "running")
|
|
2363
2398
|
break;
|
|
2399
|
+
if (useMqttWake) {
|
|
2400
|
+
const hasLiveInboxSubscription = items.length === 0 &&
|
|
2401
|
+
getInboxWakeWatcher().isLiveFor?.(config.agentUid) === true;
|
|
2402
|
+
const delayMs = items.length > 0 || !hasLiveInboxSubscription
|
|
2403
|
+
? inboxIntervalMs
|
|
2404
|
+
: INBOX_MQTT_WAKE_FALLBACK_MS;
|
|
2405
|
+
await waitForInbox(delayMs, true, true);
|
|
2406
|
+
continue;
|
|
2407
|
+
}
|
|
2364
2408
|
if (!useInboxBackoff) {
|
|
2365
2409
|
inboxBackoffIndex = 0;
|
|
2366
2410
|
await waitForInbox(inboxIntervalMs, false);
|