@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.
- package/CHANGELOG.md +28 -0
- package/dist/command-catalog.generated.d.ts +71 -0
- package/dist/command-catalog.generated.js +93 -0
- package/dist/commands/bot-jobs.d.ts +127 -0
- package/dist/commands/bot-jobs.js +445 -0
- package/dist/commands/bot.js +3 -0
- package/dist/commands/lanes.d.ts +4 -2
- package/dist/commands/lanes.js +150 -2
- package/dist/lib/bot/api.d.ts +66 -0
- package/dist/lib/bot/api.js +38 -0
- package/dist/lib/bot/index.d.ts +3 -0
- package/dist/lib/bot/index.js +3 -0
- package/dist/lib/bot/inflight.d.ts +42 -0
- package/dist/lib/bot/job-completions.d.ts +91 -0
- package/dist/lib/bot/job-completions.js +132 -0
- package/dist/lib/bot/job-run.d.ts +109 -0
- package/dist/lib/bot/job-run.js +246 -0
- package/dist/lib/bot/paths.d.ts +9 -1
- package/dist/lib/bot/paths.js +14 -0
- package/dist/lib/bot/prompt.d.ts +12 -0
- package/dist/lib/bot/prompt.js +31 -0
- package/dist/lib/bot/room-policy.d.ts +18 -1
- package/dist/lib/bot/room-policy.js +20 -2
- package/dist/lib/bot/run.d.ts +23 -1
- package/dist/lib/bot/run.js +404 -7
- package/dist/lib/lanes/config.d.ts +7 -0
- package/dist/lib/lanes/config.js +28 -1
- package/dist/lib/lanes/index.d.ts +5 -1
- package/dist/lib/lanes/index.js +3 -1
- package/dist/lib/lanes/monitor.d.ts +48 -0
- package/dist/lib/lanes/monitor.js +409 -0
- package/dist/lib/lanes/spawn.d.ts +10 -1
- package/dist/lib/lanes/spawn.js +60 -5
- package/dist/lib/lanes/types.d.ts +6 -0
- package/dist/lib/lanes/types.js +1 -1
- package/dist/lib/lanes/watch.d.ts +22 -0
- package/dist/lib/lanes/watch.js +122 -0
- package/package.json +1 -1
package/dist/commands/lanes.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `hq lanes` — worker lane management and decision queue (spec §5–§7, §10–§11).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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")
|
package/dist/lib/bot/api.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/bot/api.js
CHANGED
|
@@ -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 };
|
package/dist/lib/bot/index.d.ts
CHANGED
|
@@ -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
|
package/dist/lib/bot/index.js
CHANGED
|
@@ -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
|