@indigoai-us/hq-cli 5.134.0 → 5.136.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/command-catalog.generated.d.ts +71 -0
  3. package/dist/command-catalog.generated.js +93 -0
  4. package/dist/commands/bot-jobs.d.ts +127 -0
  5. package/dist/commands/bot-jobs.js +445 -0
  6. package/dist/commands/bot.js +3 -0
  7. package/dist/commands/lanes.d.ts +4 -2
  8. package/dist/commands/lanes.js +150 -2
  9. package/dist/lib/bot/api.d.ts +66 -0
  10. package/dist/lib/bot/api.js +38 -0
  11. package/dist/lib/bot/index.d.ts +3 -0
  12. package/dist/lib/bot/index.js +3 -0
  13. package/dist/lib/bot/inflight.d.ts +42 -0
  14. package/dist/lib/bot/job-completions.d.ts +91 -0
  15. package/dist/lib/bot/job-completions.js +132 -0
  16. package/dist/lib/bot/job-run.d.ts +109 -0
  17. package/dist/lib/bot/job-run.js +246 -0
  18. package/dist/lib/bot/paths.d.ts +9 -1
  19. package/dist/lib/bot/paths.js +14 -0
  20. package/dist/lib/bot/prompt.d.ts +12 -0
  21. package/dist/lib/bot/prompt.js +31 -0
  22. package/dist/lib/bot/room-policy.d.ts +18 -1
  23. package/dist/lib/bot/room-policy.js +20 -2
  24. package/dist/lib/bot/run.d.ts +23 -1
  25. package/dist/lib/bot/run.js +404 -7
  26. package/dist/lib/lanes/config.d.ts +7 -0
  27. package/dist/lib/lanes/config.js +28 -1
  28. package/dist/lib/lanes/index.d.ts +5 -1
  29. package/dist/lib/lanes/index.js +3 -1
  30. package/dist/lib/lanes/monitor.d.ts +48 -0
  31. package/dist/lib/lanes/monitor.js +409 -0
  32. package/dist/lib/lanes/spawn.d.ts +10 -1
  33. package/dist/lib/lanes/spawn.js +60 -5
  34. package/dist/lib/lanes/types.d.ts +6 -0
  35. package/dist/lib/lanes/types.js +1 -1
  36. package/dist/lib/lanes/watch.d.ts +22 -0
  37. package/dist/lib/lanes/watch.js +122 -0
  38. package/package.json +1 -1
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * `hq lanes` — worker lane management and decision queue (spec §5–§7, §10–§11).
3
3
  *
4
- * Every command prints JSON on stdout and takes no interactive input, except
4
+ * Existing commands print JSON on stdout and take no interactive input, except
5
5
  * `accounts login`, which hands the terminal to the vendor's own sign-in.
6
+ * `watch` and the monitor reminder are the line-oriented text surfaces.
6
7
  */
7
8
  import { spawn, spawnSync } from "node:child_process";
8
9
  import { randomBytes } from "node:crypto";
@@ -17,7 +18,7 @@ import { vaultApiFetch } from "../utils/vault-api.js";
17
18
  import { openMeshTransport } from "../lib/mesh/client.js";
18
19
  import { createLaneMesh, emitLaneDecisionEvents, } from "../lib/lanes/mesh-events.js";
19
20
  import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
20
- import { AccountResolutionError, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountMaxRunning, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, MAX_LANE_DEPTH, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneState, parseLivenessState, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, readQuestion, resolveAccount, resolveLaneWorker, isLoggedOut, runReconcilePass, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasRecordedSessionId, providerSupportsSessionResume, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
21
+ import { AccountResolutionError, coveredLaneIds, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountMaxRunning, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, MAX_WATCHED_LANES, monitorReminderText, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneState, parseLivenessState, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, readQuestion, resolveAccount, resolveLaneWorker, isLoggedOut, runReconcilePass, runLaneWatch, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasRecordedSessionId, providerSupportsSessionResume, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
21
22
  import { questionBodyAbsPath } from "../lib/lanes/questions.js";
22
23
  import { compactionAfterUsage, } from "../lib/lanes/compaction.js";
23
24
  import { providerCompactAfterMs } from "../lib/lanes/config.js";
@@ -62,6 +63,24 @@ function wrap(action) {
62
63
  }
63
64
  };
64
65
  }
66
+ function failText(err) {
67
+ const message = err instanceof Error ? err.message : String(err);
68
+ process.stderr.write(`hq lanes: ${message}\n`);
69
+ process.exit(MONITOR_ERROR_EXIT_CODE);
70
+ }
71
+ function wrapText(action) {
72
+ return async (...args) => {
73
+ let code;
74
+ try {
75
+ code = await action(...args);
76
+ }
77
+ catch (err) {
78
+ failText(err);
79
+ }
80
+ if (code !== 0)
81
+ process.exit(code);
82
+ };
83
+ }
65
84
  function collectRepeatable(value, previous = []) {
66
85
  return previous.concat(value);
67
86
  }
@@ -206,6 +225,12 @@ function resolveDeps(deps = {}) {
206
225
  probeProvider: deps.probeProvider ?? defaultProbe,
207
226
  runCompaction: deps.runCompaction,
208
227
  locateTranscript: deps.locateTranscript,
228
+ monitorEvidence: deps.monitorEvidence ??
229
+ ((sessionId) => readLiveMonitorEvidence(sessionId, {
230
+ homedir: () => deps.homedir?.() ?? os.homedir(),
231
+ env,
232
+ now: deps.now ?? (() => new Date()),
233
+ })),
209
234
  stageOutpostPayload: deps.stageOutpostPayload ?? defaultStageOutpostPayload,
210
235
  dispatchOutpostExec: deps.dispatchOutpostExec ?? defaultDispatchOutpostExec,
211
236
  fetchText: deps.fetchText ?? defaultFetchText,
@@ -387,6 +412,115 @@ function loadAllLanes(hqRoot) {
387
412
  function loadAllQuestions(hqRoot) {
388
413
  return listQuestionIds(hqRoot).map((id) => readQuestion(hqRoot, id));
389
414
  }
415
+ const MONITOR_ACTIVE_LANE_STATES = new Set([
416
+ "running",
417
+ "queued",
418
+ "awaiting_input",
419
+ "blocked",
420
+ ]);
421
+ function resolveMonitorEngine(requested, env) {
422
+ const value = requested?.trim().toLowerCase() ||
423
+ env.HQ_CONDUCT_ENGINE?.trim().toLowerCase() ||
424
+ "claude";
425
+ if (!MONITOR_ENGINES.includes(value)) {
426
+ throw new LanesError("monitor_invalid_engine", `Unknown monitor-check engine ${JSON.stringify(value)}. Accepted: ${MONITOR_ENGINES.join(", ")}`, { accepted: [...MONITOR_ENGINES], value });
427
+ }
428
+ return value;
429
+ }
430
+ async function runWatch(ctx, laneIds, opts) {
431
+ const rawInterval = opts.intervalMs === undefined ? undefined : Number(opts.intervalMs);
432
+ await runLaneWatch(ctx.hqRoot(), laneIds, {
433
+ intervalMs: rawInterval,
434
+ sleep: ctx.sleep,
435
+ writeLine: (line) => console.log(opts.json ? JSON.stringify({ event: line }) : line),
436
+ writeError: (line) => process.stderr.write(`${line}\n`),
437
+ });
438
+ return 0;
439
+ }
440
+ function monitorCallsForLaneIds(laneIds) {
441
+ const calls = [];
442
+ for (let offset = 0; offset < laneIds.length; offset += MAX_WATCHED_LANES) {
443
+ const chunk = laneIds.slice(offset, offset + MAX_WATCHED_LANES);
444
+ calls.push({
445
+ command: `hq lanes watch ${chunk.join(" ")}`,
446
+ timeout_ms: MONITOR_MAX_TIMEOUT_MS,
447
+ persistent: true,
448
+ });
449
+ }
450
+ return calls;
451
+ }
452
+ async function runMonitorCheck(ctx, opts) {
453
+ if (opts.reminder) {
454
+ console.log(monitorReminderText());
455
+ return 0;
456
+ }
457
+ const sessionId = opts.session?.trim();
458
+ if (!sessionId) {
459
+ throw new LanesError("monitor_session_required", "hq lanes monitor-check requires --session <id> unless --reminder is used");
460
+ }
461
+ const engine = resolveMonitorEngine(opts.engine, ctx.env);
462
+ const activeLanes = loadAllLanes(ctx.hqRoot()).filter((lane) => lane.senior.kind === "session" &&
463
+ lane.senior.id === sessionId &&
464
+ MONITOR_ACTIVE_LANE_STATES.has(lane.state));
465
+ if (engine !== "claude") {
466
+ if (opts.json) {
467
+ printJson({
468
+ ok: true,
469
+ action: "monitor-check",
470
+ session_id: sessionId,
471
+ engine,
472
+ active_lane_ids: activeLanes.map((lane) => lane.lane_id),
473
+ covered_lane_ids: [],
474
+ uncovered_lane_ids: [],
475
+ skipped: "engine_has_no_claude_monitor",
476
+ });
477
+ }
478
+ return 0;
479
+ }
480
+ if (activeLanes.length === 0) {
481
+ if (opts.json) {
482
+ printJson({
483
+ ok: true,
484
+ action: "monitor-check",
485
+ session_id: sessionId,
486
+ engine,
487
+ active_lane_ids: [],
488
+ covered_lane_ids: [],
489
+ uncovered_lane_ids: [],
490
+ });
491
+ }
492
+ return 0;
493
+ }
494
+ const evidence = await ctx.monitorEvidence(sessionId);
495
+ if (evidence.sessionId !== sessionId) {
496
+ throw new LanesError("monitor_evidence_session_mismatch", `Monitor evidence was for ${JSON.stringify(evidence.sessionId)}, expected ${JSON.stringify(sessionId)}`);
497
+ }
498
+ const covered = coveredLaneIds(evidence, ctx.now());
499
+ const activeIds = activeLanes.map((lane) => lane.lane_id);
500
+ const uncovered = activeIds.filter((laneId) => !covered.has(laneId));
501
+ const monitorCalls = monitorCallsForLaneIds(uncovered);
502
+ if (opts.json) {
503
+ printJson({
504
+ ok: uncovered.length === 0,
505
+ action: "monitor-check",
506
+ session_id: sessionId,
507
+ engine,
508
+ active_lane_ids: activeIds,
509
+ covered_lane_ids: activeIds.filter((laneId) => covered.has(laneId)),
510
+ uncovered_lane_ids: uncovered,
511
+ monitor_call: monitorCalls.length === 1 ? monitorCalls[0] : null,
512
+ monitor_calls: monitorCalls,
513
+ });
514
+ }
515
+ else if (uncovered.length > 0) {
516
+ for (const laneId of uncovered)
517
+ console.log(`uncovered lane: ${laneId}`);
518
+ for (const monitorCall of monitorCalls) {
519
+ console.log(`Monitor(command=${JSON.stringify(monitorCall.command)}, timeout_ms=${monitorCall.timeout_ms}, persistent=true)`);
520
+ }
521
+ }
522
+ return uncovered.length > 0 ? MONITOR_UNCOVERED_EXIT_CODE : 0;
523
+ }
390
524
  function sessionMetaPath(hqRoot, sessionId) {
391
525
  return path.join(hqRoot, "workspace", "sessions", sessionId, "meta.yaml");
392
526
  }
@@ -3193,6 +3327,20 @@ export function registerLanesCommand(parent, deps = {}) {
3193
3327
  .action(wrap(async (opts) => {
3194
3328
  await runList(ctx, opts);
3195
3329
  }));
3330
+ lanes
3331
+ .command("watch")
3332
+ .description("Watch lane state and question changes until all lanes are terminal")
3333
+ .argument("<lane-id...>", "Lane ids to watch")
3334
+ .option("--interval-ms <ms>", "Polling interval, bounded to 100ms–30s", "5000")
3335
+ .action(wrapText(async (laneIds, opts) => runWatch(ctx, laneIds, opts)));
3336
+ lanes
3337
+ .command("monitor-check")
3338
+ .description("Check that active Claude senior lanes have live Monitor watch tasks")
3339
+ .option("--session <id>", "Senior session id")
3340
+ .option("--engine <engine>", "claude | codex | grok; defaults to HQ_CONDUCT_ENGINE or claude")
3341
+ .option("--reminder", "Print the senior Monitor reminder text")
3342
+ .option("--json", "JSON on stdout")
3343
+ .action(wrapText(async (opts) => runMonitorCheck(ctx, opts)));
3196
3344
  lanes
3197
3345
  .command("show")
3198
3346
  .description("Show one lane")
@@ -29,6 +29,59 @@ export interface InboxItem {
29
29
  mentionedMe?: boolean;
30
30
  memberCount?: number;
31
31
  senderTrust?: "member";
32
+ /**
33
+ * Scheduled-job run items only (channel === "job"). The cloud fires the job
34
+ * on its own schedule and writes this durable item; the run carries its own
35
+ * prompt instead of `text` (server: hq-pro-agents jobs/run-record.ts
36
+ * `JobRunInboxItem`).
37
+ */
38
+ jobId?: string;
39
+ prompt?: string;
40
+ context?: Record<string, unknown> | string;
41
+ script?: string;
42
+ firedAt?: string;
43
+ staleAfter?: string;
44
+ requestId?: string;
45
+ }
46
+ /** The terminal outcome a box reports for one fired run. */
47
+ export type JobRunStatus = "succeeded" | "failed" | "skipped-precondition";
48
+ export interface JobRunCompletion {
49
+ status: JobRunStatus;
50
+ output?: string;
51
+ disposition?: string;
52
+ }
53
+ /** A job record as the cloud returns it (hq-pro-agents jobs/schema.ts `JobRecord`). */
54
+ export interface BotJobRecord {
55
+ jobId: string;
56
+ prompt: string;
57
+ status?: string;
58
+ schedule?: {
59
+ kind?: string;
60
+ cron?: string;
61
+ at?: string;
62
+ timezone?: string;
63
+ };
64
+ context?: Record<string, unknown>;
65
+ script?: string;
66
+ createdAt?: string;
67
+ nextRunAt?: string;
68
+ lastRunAt?: string;
69
+ lastStatus?: string;
70
+ runCount?: number;
71
+ }
72
+ export interface CreateJobInput {
73
+ prompt: string;
74
+ context?: Record<string, unknown>;
75
+ script?: string;
76
+ schedule: {
77
+ kind: "once";
78
+ at: string;
79
+ timezone: string;
80
+ } | {
81
+ kind: "recurring";
82
+ cron: string;
83
+ timezone: string;
84
+ };
32
85
  }
33
86
  /** @deprecated use InboxItem */
34
87
  export type DmInboxItem = InboxItem;
@@ -132,6 +185,19 @@ export declare class BotApi {
132
185
  pullInbox(agentUid: string): Promise<InboxItem[]>;
133
186
  /** POST /v1/agents/{uid}/inbox/{messageId}/ack */
134
187
  ackInbox(agentUid: string, messageId: string): Promise<void>;
188
+ /**
189
+ * POST /v1/agents/{uid}/jobs/{jobId}/runs/{messageId}/complete — report one
190
+ * fired run as finished. MANDATORY: nothing else clears the job's
191
+ * in-progress flag, so a run that is never reported makes every later fire
192
+ * of that job `skipped-overlap` for up to an hour.
193
+ */
194
+ completeJobRun(agentUid: string, jobId: string, messageId: string, completion: JobRunCompletion): Promise<void>;
195
+ /** GET /v1/agents/{uid}/jobs — the bot's own scheduled jobs (self-only). */
196
+ listJobs(agentUid: string): Promise<BotJobRecord[]>;
197
+ /** POST /v1/agents/{uid}/jobs — schedule a new job for the bot itself. */
198
+ createJob(agentUid: string, input: CreateJobInput): Promise<BotJobRecord>;
199
+ /** POST /v1/agents/{uid}/jobs/{jobId}/cancel — cancel and delete its schedule. */
200
+ cancelJob(agentUid: string, jobId: string): Promise<void>;
135
201
  /** POST /v1/notify/dm — send as the bot (agent identity). */
136
202
  sendDm(input: {
137
203
  toPersonUid: string;
@@ -101,6 +101,44 @@ export class BotApi {
101
101
  async ackInbox(agentUid, messageId) {
102
102
  await this.call(`/v1/agents/${encodeURIComponent(agentUid)}/inbox/${encodeURIComponent(messageId)}/ack`, { method: "POST", body: {} });
103
103
  }
104
+ /**
105
+ * POST /v1/agents/{uid}/jobs/{jobId}/runs/{messageId}/complete — report one
106
+ * fired run as finished. MANDATORY: nothing else clears the job's
107
+ * in-progress flag, so a run that is never reported makes every later fire
108
+ * of that job `skipped-overlap` for up to an hour.
109
+ */
110
+ async completeJobRun(agentUid, jobId, messageId, completion) {
111
+ await this.call(`/v1/agents/${encodeURIComponent(agentUid)}/jobs/${encodeURIComponent(jobId)}/runs/${encodeURIComponent(messageId)}/complete`, {
112
+ method: "POST",
113
+ body: {
114
+ status: completion.status,
115
+ ...(completion.output !== undefined ? { output: completion.output } : {}),
116
+ ...(completion.disposition !== undefined ? { disposition: completion.disposition } : {}),
117
+ },
118
+ }, [200, 201, 202, 204]);
119
+ }
120
+ /** GET /v1/agents/{uid}/jobs — the bot's own scheduled jobs (self-only). */
121
+ async listJobs(agentUid) {
122
+ const { body } = await this.call(`/v1/agents/${encodeURIComponent(agentUid)}/jobs`);
123
+ return body.jobs ?? [];
124
+ }
125
+ /** POST /v1/agents/{uid}/jobs — schedule a new job for the bot itself. */
126
+ async createJob(agentUid, input) {
127
+ const { body } = await this.call(`/v1/agents/${encodeURIComponent(agentUid)}/jobs`, {
128
+ method: "POST",
129
+ body: {
130
+ prompt: input.prompt,
131
+ ...(input.context ? { context: input.context } : {}),
132
+ ...(input.script ? { script: input.script } : {}),
133
+ schedule: input.schedule,
134
+ },
135
+ });
136
+ return body.job;
137
+ }
138
+ /** POST /v1/agents/{uid}/jobs/{jobId}/cancel — cancel and delete its schedule. */
139
+ async cancelJob(agentUid, jobId) {
140
+ await this.call(`/v1/agents/${encodeURIComponent(agentUid)}/jobs/${encodeURIComponent(jobId)}/cancel`, { method: "POST", body: {} }, [200, 201, 202, 204]);
141
+ }
104
142
  /** POST /v1/notify/dm — send as the bot (agent identity). */
105
143
  async sendDm(input) {
106
144
  const payload = { toPersonUid: input.toPersonUid, body: input.body };
@@ -17,4 +17,7 @@ export * from "./local-config.js";
17
17
  export * from "./self-heal.js";
18
18
  export * from "./runnable.js";
19
19
  export * from "./local-name.js";
20
+ export * from "./job-run.js";
21
+ export * from "./job-completions.js";
22
+ export * from "./inflight.js";
20
23
  //# sourceMappingURL=index.d.ts.map
@@ -17,4 +17,7 @@ export * from "./local-config.js";
17
17
  export * from "./self-heal.js";
18
18
  export * from "./runnable.js";
19
19
  export * from "./local-name.js";
20
+ export * from "./job-run.js";
21
+ export * from "./job-completions.js";
22
+ export * from "./inflight.js";
20
23
  //# sourceMappingURL=index.js.map
@@ -26,6 +26,48 @@ export interface InflightTurn {
26
26
  kind: "room";
27
27
  channelId: string;
28
28
  };
29
+ /**
30
+ * Scheduled-job runs: the job this message is a run of, so a turn cut off by
31
+ * a restart is still reported to the cloud (which is the only thing that
32
+ * clears the job's in-progress flag).
33
+ */
34
+ jobId?: string;
35
+ /**
36
+ * Set as soon as this run's outcome has been REPORTED to the cloud (accepted,
37
+ * or durably queued for retry). A recovery pass must not report it again: the
38
+ * server's complete is last-write-wins, so a second "failed" would overwrite
39
+ * a recorded "succeeded" after a crash between reporting and acking.
40
+ */
41
+ jobCompleted?: "succeeded" | "failed" | "skipped-precondition";
42
+ /**
43
+ * The "scheduled job" header this run's FIRST message carries, so a report
44
+ * posted by the recovery path still reads as a scheduled run rather than an
45
+ * unsolicited DM.
46
+ */
47
+ jobHeader?: string;
48
+ /**
49
+ * The bot a scheduled run's marker belongs to. `hq bot adopt` can place a
50
+ * DIFFERENT bot into a folder that survived (bot.json gone, the rest left);
51
+ * recovery drops a job marker stamped with another agent instead of posting
52
+ * its report into this bot's DM and completing it under this identity.
53
+ * Absent on markers written before it was recorded: those behave as before.
54
+ */
55
+ agentUid?: string;
56
+ /**
57
+ * The run's own report, as it is recorded with the cloud, kept apart from the
58
+ * body saved for resending (which may carry the header or earlier progress).
59
+ */
60
+ jobOutput?: string;
61
+ /**
62
+ * Why a started scheduled run did not finish (the runtime's error, or the
63
+ * owner stopping it), saved BEFORE the "did not finish" post is attempted. If
64
+ * that post fails, recovery reports and tells the owner THIS reason rather
65
+ * than claiming the bot was restarted.
66
+ */
67
+ jobFailure?: {
68
+ reason: string;
69
+ excerpt?: string;
70
+ };
29
71
  rootEventId?: string;
30
72
  /** DM message carrying the thinking reaction, so a restart can clear it. */
31
73
  reactionEventId?: string;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * job-completions.json — run completions the cloud has not accepted yet.
3
+ *
4
+ * Reporting a finished run is MANDATORY: nothing else clears the job's
5
+ * in-progress flag, and until it is cleared every later fire of that job is
6
+ * skipped as an overlap for up to an hour. So when the completion call fails
7
+ * (the network was down, the token was stale), the bot logs it loudly, still
8
+ * acks the inbox item — the work is done and must never run twice — and keeps
9
+ * the completion here to retry.
10
+ *
11
+ * A retry only re-sends a small report; it never re-runs the work.
12
+ *
13
+ * Retries are spaced by EXPONENTIAL BACKOFF and given up on by WALL-CLOCK AGE,
14
+ * not by a count of attempts. The inbox polls every 2 seconds, so a fixed
15
+ * ten-attempt budget was spent in about twenty seconds — an ordinary sixty-
16
+ * second network drop permanently lost the completion and wedged that job for
17
+ * the rest of the server's in-progress hour. Giving up is pegged to that same
18
+ * hour, after which the cloud releases the slot by itself anyway.
19
+ *
20
+ * ── After a reinstall: restore, adopt, repair (decision, 2026-09-16) ─────────
21
+ *
22
+ * This file lives in the bot's own folder (~/.hq/bots/<name>/), which is never
23
+ * synced. What `hq bot restore` / `hq bot adopt` do to it decides what happens
24
+ * to a completion owed by a run on the OLD machine:
25
+ *
26
+ * - REPAIR (the folder is still here; only the credentials are re-issued): the
27
+ * file survives and its completions are still reported, with the new
28
+ * credentials. That is correct — it is the same agent, and the complete route
29
+ * authorizes by agent identity, not by machine.
30
+ * - ADOPT onto a fresh machine: there is no file, so nothing is reported from
31
+ * here. That is not a lost report: the old machine ACKED the run before it
32
+ * queued the completion (the work must never run twice), so the cloud will
33
+ * not redeliver it, and the job's in-progress slot is released by the cloud
34
+ * itself within the hour. Not duplicated, and not jammed for longer than the
35
+ * outage already cost.
36
+ * - ADOPT into a folder that SURVIVED (bot.json gone, the rest left behind):
37
+ * completions stamped with THIS bot's agentUid are reported as normal. A
38
+ * completion stamped with a DIFFERENT agentUid — a same-named bot that was
39
+ * deleted and re-created — is dropped, never sent: it belongs to another
40
+ * identity, and reporting it against this one would be a cross-identity
41
+ * write that can only fail or, worse, land on the wrong run.
42
+ *
43
+ * Per-job model SESSIONS (session-job-<jobId>.json) need no such care: a job
44
+ * that finds no session simply starts a fresh one, and a scheduled run carries
45
+ * everything it needs in its own prompt.
46
+ */
47
+ import type { JobRunStatus } from "./api.js";
48
+ export declare const BOT_JOB_COMPLETIONS_NAME = "job-completions.json";
49
+ /** First retry gap; each failure doubles it. */
50
+ export declare const COMPLETION_RETRY_BASE_MS = 2000;
51
+ /** Never wait longer than this between retries. */
52
+ export declare const COMPLETION_RETRY_MAX_MS: number;
53
+ /**
54
+ * Stop retrying a completion older than this. Matches the server's
55
+ * DEFAULT_JOBS_MAX_IN_PROGRESS_SECONDS: past it the cloud reclaims the job's
56
+ * in-progress slot on its own, so a later report changes nothing.
57
+ */
58
+ export declare const MAX_COMPLETION_AGE_MS: number;
59
+ export interface PendingJobCompletion {
60
+ v: 1;
61
+ /**
62
+ * The agent that owes this report. Checked before every retry so a folder
63
+ * that outlived its bot never reports under a different identity. Absent on
64
+ * entries written before it was recorded; those are treated as this bot's.
65
+ */
66
+ agentUid?: string;
67
+ jobId: string;
68
+ /** The inbox item id — the run id in the completion route. */
69
+ messageId: string;
70
+ status: JobRunStatus;
71
+ output?: string;
72
+ disposition?: string;
73
+ firstFailedAt: string;
74
+ attempts: number;
75
+ /** Epoch ms before which this must not be retried (exponential backoff). */
76
+ nextAttemptAt: number;
77
+ }
78
+ export declare function botJobCompletionsPath(dir: string): string;
79
+ export declare function readPendingJobCompletions(dir: string): PendingJobCompletion[];
80
+ /** The gap before attempt N (1-based), doubling and capped. */
81
+ export declare function completionRetryDelayMs(attempts: number): number;
82
+ /** True when this entry is due for another attempt. */
83
+ export declare function isCompletionDue(entry: PendingJobCompletion, nowMs: number): boolean;
84
+ /** True when this entry was owed by a different bot identity than `agentUid`. */
85
+ export declare function isCompletionForOtherAgent(entry: PendingJobCompletion, agentUid: string): boolean;
86
+ /** True when the cloud has already reclaimed the slot; retrying changes nothing. */
87
+ export declare function isCompletionExpired(entry: PendingJobCompletion, nowMs: number): boolean;
88
+ /** Record (or back off) a completion the cloud has not taken. */
89
+ export declare function recordPendingJobCompletion(dir: string, entry: Omit<PendingJobCompletion, "v" | "firstFailedAt" | "attempts" | "nextAttemptAt">, now: Date): PendingJobCompletion;
90
+ export declare function clearPendingJobCompletion(dir: string, messageId: string): void;
91
+ //# sourceMappingURL=job-completions.d.ts.map
@@ -0,0 +1,132 @@
1
+ /**
2
+ * job-completions.json — run completions the cloud has not accepted yet.
3
+ *
4
+ * Reporting a finished run is MANDATORY: nothing else clears the job's
5
+ * in-progress flag, and until it is cleared every later fire of that job is
6
+ * skipped as an overlap for up to an hour. So when the completion call fails
7
+ * (the network was down, the token was stale), the bot logs it loudly, still
8
+ * acks the inbox item — the work is done and must never run twice — and keeps
9
+ * the completion here to retry.
10
+ *
11
+ * A retry only re-sends a small report; it never re-runs the work.
12
+ *
13
+ * Retries are spaced by EXPONENTIAL BACKOFF and given up on by WALL-CLOCK AGE,
14
+ * not by a count of attempts. The inbox polls every 2 seconds, so a fixed
15
+ * ten-attempt budget was spent in about twenty seconds — an ordinary sixty-
16
+ * second network drop permanently lost the completion and wedged that job for
17
+ * the rest of the server's in-progress hour. Giving up is pegged to that same
18
+ * hour, after which the cloud releases the slot by itself anyway.
19
+ *
20
+ * ── After a reinstall: restore, adopt, repair (decision, 2026-09-16) ─────────
21
+ *
22
+ * This file lives in the bot's own folder (~/.hq/bots/<name>/), which is never
23
+ * synced. What `hq bot restore` / `hq bot adopt` do to it decides what happens
24
+ * to a completion owed by a run on the OLD machine:
25
+ *
26
+ * - REPAIR (the folder is still here; only the credentials are re-issued): the
27
+ * file survives and its completions are still reported, with the new
28
+ * credentials. That is correct — it is the same agent, and the complete route
29
+ * authorizes by agent identity, not by machine.
30
+ * - ADOPT onto a fresh machine: there is no file, so nothing is reported from
31
+ * here. That is not a lost report: the old machine ACKED the run before it
32
+ * queued the completion (the work must never run twice), so the cloud will
33
+ * not redeliver it, and the job's in-progress slot is released by the cloud
34
+ * itself within the hour. Not duplicated, and not jammed for longer than the
35
+ * outage already cost.
36
+ * - ADOPT into a folder that SURVIVED (bot.json gone, the rest left behind):
37
+ * completions stamped with THIS bot's agentUid are reported as normal. A
38
+ * completion stamped with a DIFFERENT agentUid — a same-named bot that was
39
+ * deleted and re-created — is dropped, never sent: it belongs to another
40
+ * identity, and reporting it against this one would be a cross-identity
41
+ * write that can only fail or, worse, land on the wrong run.
42
+ *
43
+ * Per-job model SESSIONS (session-job-<jobId>.json) need no such care: a job
44
+ * that finds no session simply starts a fresh one, and a scheduled run carries
45
+ * everything it needs in its own prompt.
46
+ */
47
+ import * as fs from "node:fs";
48
+ import * as path from "node:path";
49
+ export const BOT_JOB_COMPLETIONS_NAME = "job-completions.json";
50
+ /** First retry gap; each failure doubles it. */
51
+ export const COMPLETION_RETRY_BASE_MS = 2_000;
52
+ /** Never wait longer than this between retries. */
53
+ export const COMPLETION_RETRY_MAX_MS = 5 * 60_000;
54
+ /**
55
+ * Stop retrying a completion older than this. Matches the server's
56
+ * DEFAULT_JOBS_MAX_IN_PROGRESS_SECONDS: past it the cloud reclaims the job's
57
+ * in-progress slot on its own, so a later report changes nothing.
58
+ */
59
+ export const MAX_COMPLETION_AGE_MS = 60 * 60_000;
60
+ export function botJobCompletionsPath(dir) {
61
+ return path.join(dir, BOT_JOB_COMPLETIONS_NAME);
62
+ }
63
+ function isPending(raw) {
64
+ const c = raw;
65
+ return c?.v === 1 && typeof c.jobId === "string" && typeof c.messageId === "string" && typeof c.status === "string";
66
+ }
67
+ export function readPendingJobCompletions(dir) {
68
+ try {
69
+ const raw = JSON.parse(fs.readFileSync(botJobCompletionsPath(dir), "utf8"));
70
+ if (raw?.v !== 1 || !Array.isArray(raw.completions))
71
+ return [];
72
+ return raw.completions.filter(isPending);
73
+ }
74
+ catch {
75
+ return [];
76
+ }
77
+ }
78
+ /** The gap before attempt N (1-based), doubling and capped. */
79
+ export function completionRetryDelayMs(attempts) {
80
+ const exponent = Math.max(0, attempts - 1);
81
+ // Bound the shift so a long-lived entry cannot overflow into Infinity.
82
+ const raw = COMPLETION_RETRY_BASE_MS * 2 ** Math.min(exponent, 20);
83
+ return Math.min(COMPLETION_RETRY_MAX_MS, raw);
84
+ }
85
+ /** True when this entry is due for another attempt. */
86
+ export function isCompletionDue(entry, nowMs) {
87
+ return nowMs >= (entry.nextAttemptAt ?? 0);
88
+ }
89
+ /** True when this entry was owed by a different bot identity than `agentUid`. */
90
+ export function isCompletionForOtherAgent(entry, agentUid) {
91
+ return typeof entry.agentUid === "string" && entry.agentUid !== "" && entry.agentUid !== agentUid;
92
+ }
93
+ /** True when the cloud has already reclaimed the slot; retrying changes nothing. */
94
+ export function isCompletionExpired(entry, nowMs) {
95
+ const first = new Date(entry.firstFailedAt).getTime();
96
+ if (Number.isNaN(first))
97
+ return false;
98
+ return nowMs - first >= MAX_COMPLETION_AGE_MS;
99
+ }
100
+ function write(dir, completions) {
101
+ if (completions.length === 0) {
102
+ fs.rmSync(botJobCompletionsPath(dir), { force: true });
103
+ return;
104
+ }
105
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
106
+ const tmp = path.join(dir, `.job-completions.tmp.${process.pid}.${Date.now()}`);
107
+ const file = { v: 1, completions };
108
+ fs.writeFileSync(tmp, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
109
+ fs.renameSync(tmp, botJobCompletionsPath(dir));
110
+ }
111
+ /** Record (or back off) a completion the cloud has not taken. */
112
+ export function recordPendingJobCompletion(dir, entry, now) {
113
+ const rest = readPendingJobCompletions(dir);
114
+ const existing = rest.find((c) => c.messageId === entry.messageId);
115
+ const attempts = (existing?.attempts ?? 0) + 1;
116
+ const next = {
117
+ v: 1,
118
+ ...entry,
119
+ firstFailedAt: existing?.firstFailedAt ?? now.toISOString(),
120
+ attempts,
121
+ nextAttemptAt: now.getTime() + completionRetryDelayMs(attempts),
122
+ };
123
+ write(dir, [...rest.filter((c) => c.messageId !== entry.messageId), next]);
124
+ return next;
125
+ }
126
+ export function clearPendingJobCompletion(dir, messageId) {
127
+ const rest = readPendingJobCompletions(dir);
128
+ const kept = rest.filter((c) => c.messageId !== messageId);
129
+ if (kept.length !== rest.length)
130
+ write(dir, kept);
131
+ }
132
+ //# sourceMappingURL=job-completions.js.map