@indigoai-us/hq-cli 5.126.0 → 5.127.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 +211 -0
- package/assets/lanes-hooks/hq-lanes-drain.json +15 -0
- package/assets/lanes-hooks/hq-lanes-drain.mjs +254 -0
- package/assets/lanes-hooks/hq-lanes-drain.sh +21 -0
- package/assets/lanes-reconcile/hq-lanes-reconcile.service +11 -0
- package/assets/lanes-reconcile/hq-lanes-reconcile.timer +12 -0
- package/dist/command-catalog.generated.d.ts +245 -11
- package/dist/command-catalog.generated.js +323 -11
- package/dist/command-registration-plan.d.ts +1 -0
- package/dist/command-registration-plan.js +11 -2
- package/dist/commands/agents.d.ts +12 -0
- package/dist/commands/agents.js +46 -2
- package/dist/commands/lanes.d.ts +55 -16
- package/dist/commands/lanes.js +1187 -250
- package/dist/commands/mesh.js +254 -19
- package/dist/lib/lanes/accounts.d.ts +67 -7
- package/dist/lib/lanes/accounts.js +233 -43
- package/dist/lib/lanes/compaction.d.ts +96 -0
- package/dist/lib/lanes/compaction.js +197 -0
- package/dist/lib/lanes/config.d.ts +15 -0
- package/dist/lib/lanes/config.js +65 -8
- package/dist/lib/lanes/delivery.d.ts +151 -0
- package/dist/lib/lanes/delivery.js +354 -0
- package/dist/lib/lanes/dropbox.d.ts +96 -0
- package/dist/lib/lanes/dropbox.js +408 -0
- package/dist/lib/lanes/effort.d.ts +50 -0
- package/dist/lib/lanes/effort.js +109 -0
- package/dist/lib/lanes/errors.d.ts +2 -2
- package/dist/lib/lanes/hooks-install.d.ts +81 -0
- package/dist/lib/lanes/hooks-install.js +326 -0
- package/dist/lib/lanes/hq-root.d.ts +19 -0
- package/dist/lib/lanes/hq-root.js +28 -0
- package/dist/lib/lanes/index.d.ts +35 -5
- package/dist/lib/lanes/index.js +19 -4
- package/dist/lib/lanes/liveness.d.ts +7 -1
- package/dist/lib/lanes/liveness.js +64 -5
- package/dist/lib/lanes/machine-identity.d.ts +34 -0
- package/dist/lib/lanes/machine-identity.js +92 -0
- package/dist/lib/lanes/mesh-events.d.ts +109 -0
- package/dist/lib/lanes/mesh-events.js +275 -0
- package/dist/lib/lanes/outpost-dispatch.d.ts +65 -0
- package/dist/lib/lanes/outpost-dispatch.js +90 -0
- package/dist/lib/lanes/questions.d.ts +75 -0
- package/dist/lib/lanes/questions.js +243 -0
- package/dist/lib/lanes/quota.d.ts +95 -0
- package/dist/lib/lanes/quota.js +320 -0
- package/dist/lib/lanes/reconcile-install.d.ts +69 -0
- package/dist/lib/lanes/reconcile-install.js +221 -0
- package/dist/lib/lanes/reconcile.d.ts +102 -0
- package/dist/lib/lanes/reconcile.js +985 -0
- package/dist/lib/lanes/registry.d.ts +18 -5
- package/dist/lib/lanes/registry.js +293 -19
- package/dist/lib/lanes/spawn.d.ts +274 -0
- package/dist/lib/lanes/spawn.js +1363 -0
- package/dist/lib/lanes/store.d.ts +7 -3
- package/dist/lib/lanes/store.js +150 -32
- package/dist/lib/lanes/types.d.ts +346 -4
- package/dist/lib/lanes/types.js +138 -5
- package/dist/lib/lanes/watcher-main.d.ts +9 -0
- package/dist/lib/lanes/watcher-main.js +22 -0
- package/dist/lib/lanes/watcher.d.ts +216 -0
- package/dist/lib/lanes/watcher.js +859 -0
- package/dist/lib/mesh/api.d.ts +71 -7
- package/dist/lib/mesh/api.js +167 -28
- package/dist/lib/mesh/live/backfill-held.d.ts +18 -7
- package/dist/lib/mesh/live/backfill-held.js +66 -21
- package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/run.js +111 -54
- package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +3 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.js +31 -8
- package/dist/lib/scan-packages/index.d.ts +2 -0
- package/dist/lib/scan-packages/index.js +1 -1
- package/dist/lib/work-context/state.d.ts +12 -0
- package/dist/lib/work-context/state.js +131 -3
- package/package.json +3 -1
package/dist/lib/lanes/config.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
10
|
import * as os from "node:os";
|
|
11
11
|
import * as path from "node:path";
|
|
12
|
+
import { parseConfigEffort } from "./effort.js";
|
|
12
13
|
import { LanesConfigError } from "./errors.js";
|
|
13
14
|
import { CONFIG_SCHEMA_VERSION, LANE_PROVIDERS, isLaneProvider, parseMajorSchema, } from "./types.js";
|
|
14
15
|
export const LANES_CONFIG_REL = path.join("personal", "lanes", "config.json");
|
|
@@ -16,6 +17,17 @@ export const DEFAULT_MAX_QUEUED = 3;
|
|
|
16
17
|
export const DEFAULT_CPU_BUSY_PAUSE = 0.85;
|
|
17
18
|
export const DEFAULT_CPU_BUSY_RESUME = 0.7;
|
|
18
19
|
export const DEFAULT_MIN_AVAILABLE_MEM_MB = 1024;
|
|
20
|
+
/**
|
|
21
|
+
* Owner directive 2026-09-22: "if it's been an hour since last msg, always
|
|
22
|
+
* run compact". Override per provider with `compact_after_ms`.
|
|
23
|
+
*/
|
|
24
|
+
export const DEFAULT_COMPACT_AFTER_MS = 60 * 60 * 1000;
|
|
25
|
+
/**
|
|
26
|
+
* Watcher `lane_progress` throttle: at most one thread event per five minutes
|
|
27
|
+
* per lane, and only when the tick saw new activity. Override per provider
|
|
28
|
+
* with `progress_event_min_interval_ms`.
|
|
29
|
+
*/
|
|
30
|
+
export const DEFAULT_PROGRESS_EVENT_MIN_INTERVAL_MS = 5 * 60 * 1000;
|
|
19
31
|
/** Spec §9: last event within 10 minutes → live. */
|
|
20
32
|
export const DEFAULT_LIVE_WITHIN_MS = 10 * 60 * 1000;
|
|
21
33
|
/** Spec §9: turn_start with no turn_end after 2 hours → lost. */
|
|
@@ -131,8 +143,20 @@ function parseAccount(provider, id, raw, deps) {
|
|
|
131
143
|
if (raw.max_running !== undefined) {
|
|
132
144
|
account.max_running = positiveInt(raw.max_running, `providers.${provider}.accounts.${id}.max_running`);
|
|
133
145
|
}
|
|
146
|
+
if (raw.model !== undefined) {
|
|
147
|
+
account.model = nonEmptyModel(raw.model, `providers.${provider}.accounts.${id}.model`);
|
|
148
|
+
}
|
|
149
|
+
if (raw.effort !== undefined) {
|
|
150
|
+
account.effort = parseConfigEffort(provider, raw.effort, `providers.${provider}.accounts.${id}.effort`);
|
|
151
|
+
}
|
|
134
152
|
return account;
|
|
135
153
|
}
|
|
154
|
+
function nonEmptyModel(value, field) {
|
|
155
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
156
|
+
throw new LanesConfigError("invalid_config", `${field} must be a non-empty string`, { field, value });
|
|
157
|
+
}
|
|
158
|
+
return value.trim();
|
|
159
|
+
}
|
|
136
160
|
function rejectDuplicateHomes(provider, accounts) {
|
|
137
161
|
const seen = new Map();
|
|
138
162
|
for (const [id, account] of Object.entries(accounts)) {
|
|
@@ -176,18 +200,38 @@ function parseProvider(provider, raw, deps) {
|
|
|
176
200
|
}
|
|
177
201
|
}
|
|
178
202
|
rejectDuplicateHomes(provider, accounts);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (
|
|
183
|
-
|
|
203
|
+
// default_account is accepted so existing files parse, and ignored for
|
|
204
|
+
// selection. An omitted field is not an error.
|
|
205
|
+
let defaultAccount;
|
|
206
|
+
if (raw.default_account !== undefined) {
|
|
207
|
+
if (typeof raw.default_account !== "string" || raw.default_account.length === 0) {
|
|
208
|
+
throw new LanesConfigError("invalid_config", `providers.${provider}.default_account must be a non-empty string`, { provider, field: "default_account" });
|
|
209
|
+
}
|
|
210
|
+
defaultAccount = raw.default_account;
|
|
211
|
+
if (Object.keys(accounts).length > 0 && accounts[defaultAccount] === undefined) {
|
|
212
|
+
throw new LanesConfigError("invalid_config", `providers.${provider}.default_account ${JSON.stringify(defaultAccount)} is not a configured account`, { provider, default_account: defaultAccount, accounts: Object.keys(accounts) });
|
|
213
|
+
}
|
|
184
214
|
}
|
|
185
|
-
|
|
215
|
+
const parsed = {
|
|
186
216
|
max_running: positiveInt(raw.max_running, `providers.${provider}.max_running`, DEFAULT_PROVIDER_MAX_RUNNING[provider]),
|
|
187
217
|
max_queued: positiveInt(raw.max_queued, `providers.${provider}.max_queued`, DEFAULT_MAX_QUEUED),
|
|
188
|
-
default_account: defaultAccount,
|
|
189
218
|
accounts,
|
|
190
219
|
};
|
|
220
|
+
if (defaultAccount !== undefined)
|
|
221
|
+
parsed.default_account = defaultAccount;
|
|
222
|
+
if (raw.model !== undefined) {
|
|
223
|
+
parsed.model = nonEmptyModel(raw.model, `providers.${provider}.model`);
|
|
224
|
+
}
|
|
225
|
+
if (raw.effort !== undefined) {
|
|
226
|
+
parsed.effort = parseConfigEffort(provider, raw.effort, `providers.${provider}.effort`);
|
|
227
|
+
}
|
|
228
|
+
if (raw.compact_after_ms !== undefined) {
|
|
229
|
+
parsed.compact_after_ms = positiveInt(raw.compact_after_ms, `providers.${provider}.compact_after_ms`);
|
|
230
|
+
}
|
|
231
|
+
if (raw.progress_event_min_interval_ms !== undefined) {
|
|
232
|
+
parsed.progress_event_min_interval_ms = positiveInt(raw.progress_event_min_interval_ms, `providers.${provider}.progress_event_min_interval_ms`);
|
|
233
|
+
}
|
|
234
|
+
return parsed;
|
|
191
235
|
}
|
|
192
236
|
function parseThrottle(raw) {
|
|
193
237
|
const obj = raw === undefined ? {} : raw;
|
|
@@ -231,7 +275,6 @@ export function documentedDefaultConfig(deps = defaultDeps()) {
|
|
|
231
275
|
providers[provider] = {
|
|
232
276
|
max_running: DEFAULT_PROVIDER_MAX_RUNNING[provider],
|
|
233
277
|
max_queued: DEFAULT_MAX_QUEUED,
|
|
234
|
-
default_account: "a",
|
|
235
278
|
accounts: defaultAccountMap(provider, deps),
|
|
236
279
|
};
|
|
237
280
|
}
|
|
@@ -263,6 +306,12 @@ export function parseLanesConfig(raw, deps = defaultDeps()) {
|
|
|
263
306
|
throw new LanesConfigError("unknown_provider", `Unknown provider(s) ${unknown.join(", ")}. Accepted: ${LANE_PROVIDERS.join(", ")}`, { accepted: [...LANE_PROVIDERS], value: unknown });
|
|
264
307
|
}
|
|
265
308
|
}
|
|
309
|
+
// Spec §5.5: a missing *file* yields the documented defaults, and the
|
|
310
|
+
// enable switch — not omission — is how an operator turns an account off.
|
|
311
|
+
// The provider set is fixed at {claude, codex, grok} (§5), so a present
|
|
312
|
+
// config that names only `grok` still receives the documented claude and
|
|
313
|
+
// codex defaults (including real-home `~/.claude` / `~/.codex`). Omitting
|
|
314
|
+
// a provider key is not a disable.
|
|
266
315
|
const providers = {};
|
|
267
316
|
for (const provider of LANE_PROVIDERS) {
|
|
268
317
|
const entry = isPlainObject(providersRaw) ? providersRaw[provider] : undefined;
|
|
@@ -341,6 +390,14 @@ export function writeLanesConfig(hqRoot, config, deps = defaultWriteDeps()) {
|
|
|
341
390
|
throw err;
|
|
342
391
|
}
|
|
343
392
|
}
|
|
393
|
+
/** Resume-time compaction threshold for a provider, defaulting to one hour. */
|
|
394
|
+
export function providerCompactAfterMs(provider) {
|
|
395
|
+
return provider.compact_after_ms ?? DEFAULT_COMPACT_AFTER_MS;
|
|
396
|
+
}
|
|
397
|
+
/** Minimum gap between two `lane_progress` events, defaulting to five minutes. */
|
|
398
|
+
export function providerProgressEventMinIntervalMs(provider) {
|
|
399
|
+
return provider.progress_event_min_interval_ms ?? DEFAULT_PROGRESS_EVENT_MIN_INTERVAL_MS;
|
|
400
|
+
}
|
|
344
401
|
/** Account running cap, defaulting to the provider ceiling when unset. */
|
|
345
402
|
export function accountMaxRunning(provider, accountId) {
|
|
346
403
|
const account = provider.accounts[accountId];
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Follow-up delivery decision, per provider and per lane state, in one place.
|
|
3
|
+
*
|
|
4
|
+
* Two mechanisms exist. A live provider process drains the drop box on its
|
|
5
|
+
* next `PreToolUse` event (dropbox.ts). A Claude process is print-and-exit,
|
|
6
|
+
* so after it exits the only way to deliver is `claude -p --resume <sid>`,
|
|
7
|
+
* which continues the recorded session and keeps its prompt cache. Codex
|
|
8
|
+
* (0.155.1) has `codex exec resume <thread-id>`; the id is the `thread_id`
|
|
9
|
+
* of the `thread.started` event on the `--json` stdout stream, and the same
|
|
10
|
+
* id comes back on every resumed turn. Grok's CLI (1.0.34) has
|
|
11
|
+
* `--resume <id>` and its `--single --output-format json` result carries a
|
|
12
|
+
* `sessionId`, but no live run has shown `--single --resume` continue a
|
|
13
|
+
* session (the grok account was out of balance when this was written), so
|
|
14
|
+
* an exited grok lane keeps no delivery path until that is proven.
|
|
15
|
+
*
|
|
16
|
+
* Every dispatch site (`hq lanes message`, spawn, reconcile resume) asks this
|
|
17
|
+
* module rather than testing provider or state inline. A guard bound at one
|
|
18
|
+
* site and not another is the defect class this branch has already shipped.
|
|
19
|
+
*/
|
|
20
|
+
import { type LaneProvider, type LaneRecord, type LaneState } from "./types.js";
|
|
21
|
+
/** Which providers can continue an exited worker from a recorded session id. */
|
|
22
|
+
export declare const PROVIDER_SESSION_RESUME: Record<LaneProvider, boolean>;
|
|
23
|
+
export declare function providerSupportsSessionResume(provider: LaneProvider): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* States an operator message may resume from, given a session id.
|
|
26
|
+
*
|
|
27
|
+
* `done`: the worker finished its brief, wrote a done envelope and exited on
|
|
28
|
+
* its own. Nothing about the lane says the operator is finished with it; a
|
|
29
|
+
* follow-up is the normal next step, and the session is intact.
|
|
30
|
+
*
|
|
31
|
+
* `blocked`: the worker exited after writing a blocked envelope that names
|
|
32
|
+
* what it needs. An operator message is usually that missing input. The state
|
|
33
|
+
* is not terminal (spec §6.7) and nothing stopped the lane deliberately.
|
|
34
|
+
*
|
|
35
|
+
* Refused by `hq lanes message`:
|
|
36
|
+
* `awaiting_input` has its own path, `hq lanes questions answer`, and the
|
|
37
|
+
* reconcile pass resumes it once every question is answered. `killed` and
|
|
38
|
+
* `cancelled` were stopped on purpose by the operator; a message must not
|
|
39
|
+
* silently restart them. `failed` means the run ended without an envelope or
|
|
40
|
+
* never started; the operator has not seen why. `interrupted` exited with
|
|
41
|
+
* partial output and no envelope, so the lane's state was never closed
|
|
42
|
+
* cleanly; only the explicit `hq lanes resume` opt-in continues it
|
|
43
|
+
* (RESUME_COMMAND_STATES). `queued` and `running` take the drop box, not a
|
|
44
|
+
* resume.
|
|
45
|
+
*/
|
|
46
|
+
export declare const RESUMABLE_LANE_STATES: readonly ["done", "blocked"];
|
|
47
|
+
export type ResumableLaneState = (typeof RESUMABLE_LANE_STATES)[number];
|
|
48
|
+
export declare function isResumableLaneState(state: LaneState): state is ResumableLaneState;
|
|
49
|
+
/**
|
|
50
|
+
* States `hq lanes resume` continues from. `interrupted` is the one a plain
|
|
51
|
+
* message refuses: the run ended without closing its state, and continuing
|
|
52
|
+
* it is a decision the operator makes by name. `done` and `blocked` are here
|
|
53
|
+
* so the one command covers every exited-with-a-session case.
|
|
54
|
+
*/
|
|
55
|
+
export declare const RESUME_COMMAND_STATES: readonly ["interrupted", "done", "blocked"];
|
|
56
|
+
export type ResumeCommandState = (typeof RESUME_COMMAND_STATES)[number];
|
|
57
|
+
export declare function isResumeCommandState(state: LaneState): state is ResumeCommandState;
|
|
58
|
+
export declare const LANE_DELIVERY_MECHANISMS: readonly ["dropbox", "resume", "none"];
|
|
59
|
+
export type LaneDeliveryMechanism = (typeof LANE_DELIVERY_MECHANISMS)[number];
|
|
60
|
+
export type DeliveryLane = Pick<LaneRecord, "state" | "provider" | "provider_session_id">;
|
|
61
|
+
/**
|
|
62
|
+
* `dropbox`: a process is (or is about to be) alive to drain the inbox.
|
|
63
|
+
* `resume`: the process has exited, the provider can resume, the state allows
|
|
64
|
+
* it, and a session id was recorded from the worker's own result.
|
|
65
|
+
* `none`: nothing can deliver; the caller must refuse and write nothing.
|
|
66
|
+
*/
|
|
67
|
+
export declare function laneDeliveryMechanism(lane: DeliveryLane): LaneDeliveryMechanism;
|
|
68
|
+
export declare function hasRecordedSessionId(lane: Pick<LaneRecord, "provider_session_id">): lane is Pick<LaneRecord, "provider_session_id"> & {
|
|
69
|
+
provider_session_id: string;
|
|
70
|
+
};
|
|
71
|
+
export declare function deliveryRefusalReason(lane: DeliveryLane): string;
|
|
72
|
+
/** Reasons a resumable provider's exit recorded no session id. Never a fabricated value. */
|
|
73
|
+
export declare const SESSION_ID_ABSENT_REASONS: readonly ["worker_out_missing", "no_result_json", "no_session_id_field", "no_thread_started_event"];
|
|
74
|
+
export type SessionIdAbsentReason = (typeof SESSION_ID_ABSENT_REASONS)[number];
|
|
75
|
+
export interface WorkerRunResult {
|
|
76
|
+
session_id: string | null;
|
|
77
|
+
session_id_absent: SessionIdAbsentReason | null;
|
|
78
|
+
cache_read_input_tokens: number | null;
|
|
79
|
+
cache_creation_input_tokens: number | null;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Parse the `claude -p --output-format json` result. stdout is appended
|
|
83
|
+
* across runs of the same lane, so the last `type: "result"` object is this
|
|
84
|
+
* run's. Only `session_id` and the two cache counters are read out; the
|
|
85
|
+
* prompt, the response body and everything else in the payload stay on disk.
|
|
86
|
+
*/
|
|
87
|
+
export declare function parseClaudeResultOutput(text: string): WorkerRunResult;
|
|
88
|
+
/**
|
|
89
|
+
* Parse the `codex exec --json` event stream. The first event of a run is
|
|
90
|
+
* `{"type":"thread.started","thread_id":"<uuid>"}`; that id is what
|
|
91
|
+
* `codex exec resume <id>` takes, and a resumed run emits the same id again.
|
|
92
|
+
* Usage comes from the last `turn.completed` event: `cached_input_tokens`
|
|
93
|
+
* is the cache read and `cache_write_input_tokens` the cache creation.
|
|
94
|
+
* Nothing else on the stream is read out.
|
|
95
|
+
*/
|
|
96
|
+
export declare function parseCodexEventOutput(text: string): WorkerRunResult;
|
|
97
|
+
/** Per-provider stdout parser; null for a provider whose exit carries nothing to record. */
|
|
98
|
+
export declare function parseWorkerRunOutput(provider: LaneProvider, text: string): WorkerRunResult | null;
|
|
99
|
+
/**
|
|
100
|
+
* Read this run's result from the worker's own stdout. stdout is opened in
|
|
101
|
+
* append mode across runs, so only bytes past `workerOutOffset` (recorded in
|
|
102
|
+
* spawn.json at launch) are this run's. Returns null for a provider whose
|
|
103
|
+
* exit carries nothing to record, so those lanes keep behaving exactly as
|
|
104
|
+
* before.
|
|
105
|
+
*/
|
|
106
|
+
export declare function readWorkerRunResult(runDir: string, provider: LaneProvider,
|
|
107
|
+
/** Bytes of worker.out that belong to earlier runs; parsing starts after them. */
|
|
108
|
+
workerOutOffset?: number): WorkerRunResult | null;
|
|
109
|
+
export declare const LAUNCH_TRIGGERS: readonly ["admit", "answered_questions", "message", "resume"];
|
|
110
|
+
export type LaunchTriggerKind = (typeof LAUNCH_TRIGGERS)[number];
|
|
111
|
+
export type LaunchTrigger = {
|
|
112
|
+
kind: "admit";
|
|
113
|
+
} | {
|
|
114
|
+
kind: "answered_questions";
|
|
115
|
+
} | {
|
|
116
|
+
kind: "message";
|
|
117
|
+
text: string;
|
|
118
|
+
message_id: string;
|
|
119
|
+
} | {
|
|
120
|
+
/** `hq lanes resume`: the operator's explicit opt-in to continue an exited lane. */
|
|
121
|
+
kind: "resume";
|
|
122
|
+
/** Lane state at the moment of the command; recorded as `last_resume.resumed_from`. */
|
|
123
|
+
from: ResumeCommandState;
|
|
124
|
+
text?: string;
|
|
125
|
+
message_id?: string;
|
|
126
|
+
};
|
|
127
|
+
export type LaunchPlan = {
|
|
128
|
+
mode: "fresh";
|
|
129
|
+
} | {
|
|
130
|
+
mode: "resume";
|
|
131
|
+
session_id: string;
|
|
132
|
+
};
|
|
133
|
+
/**
|
|
134
|
+
* Refusal for `hq lanes resume`, in precedence order: wrong state, provider
|
|
135
|
+
* without a resume path, no recorded session. Null when the resume can go.
|
|
136
|
+
*/
|
|
137
|
+
export declare function resumeCommandRefusal(lane: DeliveryLane): {
|
|
138
|
+
error: "lane_not_resumable" | "resume_unsupported" | "no_provider_session";
|
|
139
|
+
message: string;
|
|
140
|
+
} | null;
|
|
141
|
+
/**
|
|
142
|
+
* Decide fresh-vs-resume for a spawn. Admission always starts fresh with the
|
|
143
|
+
* brief. Answered questions resume the recorded session when the provider
|
|
144
|
+
* can, else start fresh with the answers file (today's behaviour). An operator
|
|
145
|
+
* message can only be a resume, and only when `laneDeliveryMechanism` says so.
|
|
146
|
+
* `hq lanes resume` is also always a resume, gated by `resumeCommandRefusal`.
|
|
147
|
+
*/
|
|
148
|
+
export declare function planLaneLaunch(lane: DeliveryLane, trigger: LaunchTrigger): LaunchPlan;
|
|
149
|
+
/** Lane states a launch may claim `running` from, per trigger. */
|
|
150
|
+
export declare function claimableStatesFor(trigger: LaunchTrigger): readonly LaneState[];
|
|
151
|
+
//# sourceMappingURL=delivery.d.ts.map
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Follow-up delivery decision, per provider and per lane state, in one place.
|
|
3
|
+
*
|
|
4
|
+
* Two mechanisms exist. A live provider process drains the drop box on its
|
|
5
|
+
* next `PreToolUse` event (dropbox.ts). A Claude process is print-and-exit,
|
|
6
|
+
* so after it exits the only way to deliver is `claude -p --resume <sid>`,
|
|
7
|
+
* which continues the recorded session and keeps its prompt cache. Codex
|
|
8
|
+
* (0.155.1) has `codex exec resume <thread-id>`; the id is the `thread_id`
|
|
9
|
+
* of the `thread.started` event on the `--json` stdout stream, and the same
|
|
10
|
+
* id comes back on every resumed turn. Grok's CLI (1.0.34) has
|
|
11
|
+
* `--resume <id>` and its `--single --output-format json` result carries a
|
|
12
|
+
* `sessionId`, but no live run has shown `--single --resume` continue a
|
|
13
|
+
* session (the grok account was out of balance when this was written), so
|
|
14
|
+
* an exited grok lane keeps no delivery path until that is proven.
|
|
15
|
+
*
|
|
16
|
+
* Every dispatch site (`hq lanes message`, spawn, reconcile resume) asks this
|
|
17
|
+
* module rather than testing provider or state inline. A guard bound at one
|
|
18
|
+
* site and not another is the defect class this branch has already shipped.
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from "node:fs";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
import { LanesError } from "./errors.js";
|
|
23
|
+
import { parseLaneProvider, } from "./types.js";
|
|
24
|
+
/** Which providers can continue an exited worker from a recorded session id. */
|
|
25
|
+
export const PROVIDER_SESSION_RESUME = {
|
|
26
|
+
claude: true,
|
|
27
|
+
codex: true,
|
|
28
|
+
grok: false,
|
|
29
|
+
};
|
|
30
|
+
export function providerSupportsSessionResume(provider) {
|
|
31
|
+
return PROVIDER_SESSION_RESUME[parseLaneProvider(provider)];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* States an operator message may resume from, given a session id.
|
|
35
|
+
*
|
|
36
|
+
* `done`: the worker finished its brief, wrote a done envelope and exited on
|
|
37
|
+
* its own. Nothing about the lane says the operator is finished with it; a
|
|
38
|
+
* follow-up is the normal next step, and the session is intact.
|
|
39
|
+
*
|
|
40
|
+
* `blocked`: the worker exited after writing a blocked envelope that names
|
|
41
|
+
* what it needs. An operator message is usually that missing input. The state
|
|
42
|
+
* is not terminal (spec §6.7) and nothing stopped the lane deliberately.
|
|
43
|
+
*
|
|
44
|
+
* Refused by `hq lanes message`:
|
|
45
|
+
* `awaiting_input` has its own path, `hq lanes questions answer`, and the
|
|
46
|
+
* reconcile pass resumes it once every question is answered. `killed` and
|
|
47
|
+
* `cancelled` were stopped on purpose by the operator; a message must not
|
|
48
|
+
* silently restart them. `failed` means the run ended without an envelope or
|
|
49
|
+
* never started; the operator has not seen why. `interrupted` exited with
|
|
50
|
+
* partial output and no envelope, so the lane's state was never closed
|
|
51
|
+
* cleanly; only the explicit `hq lanes resume` opt-in continues it
|
|
52
|
+
* (RESUME_COMMAND_STATES). `queued` and `running` take the drop box, not a
|
|
53
|
+
* resume.
|
|
54
|
+
*/
|
|
55
|
+
export const RESUMABLE_LANE_STATES = ["done", "blocked"];
|
|
56
|
+
export function isResumableLaneState(state) {
|
|
57
|
+
return RESUMABLE_LANE_STATES.includes(state);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* States `hq lanes resume` continues from. `interrupted` is the one a plain
|
|
61
|
+
* message refuses: the run ended without closing its state, and continuing
|
|
62
|
+
* it is a decision the operator makes by name. `done` and `blocked` are here
|
|
63
|
+
* so the one command covers every exited-with-a-session case.
|
|
64
|
+
*/
|
|
65
|
+
export const RESUME_COMMAND_STATES = ["interrupted", "done", "blocked"];
|
|
66
|
+
export function isResumeCommandState(state) {
|
|
67
|
+
return RESUME_COMMAND_STATES.includes(state);
|
|
68
|
+
}
|
|
69
|
+
export const LANE_DELIVERY_MECHANISMS = ["dropbox", "resume", "none"];
|
|
70
|
+
/**
|
|
71
|
+
* `dropbox`: a process is (or is about to be) alive to drain the inbox.
|
|
72
|
+
* `resume`: the process has exited, the provider can resume, the state allows
|
|
73
|
+
* it, and a session id was recorded from the worker's own result.
|
|
74
|
+
* `none`: nothing can deliver; the caller must refuse and write nothing.
|
|
75
|
+
*/
|
|
76
|
+
export function laneDeliveryMechanism(lane) {
|
|
77
|
+
if (lane.state === "queued" || lane.state === "running")
|
|
78
|
+
return "dropbox";
|
|
79
|
+
if (providerSupportsSessionResume(lane.provider) &&
|
|
80
|
+
isResumableLaneState(lane.state) &&
|
|
81
|
+
hasRecordedSessionId(lane)) {
|
|
82
|
+
return "resume";
|
|
83
|
+
}
|
|
84
|
+
return "none";
|
|
85
|
+
}
|
|
86
|
+
export function hasRecordedSessionId(lane) {
|
|
87
|
+
return (typeof lane.provider_session_id === "string" &&
|
|
88
|
+
lane.provider_session_id.trim().length > 0);
|
|
89
|
+
}
|
|
90
|
+
export function deliveryRefusalReason(lane) {
|
|
91
|
+
const state = lane.state;
|
|
92
|
+
switch (state) {
|
|
93
|
+
case "queued":
|
|
94
|
+
case "running":
|
|
95
|
+
return "";
|
|
96
|
+
case "awaiting_input":
|
|
97
|
+
return "Lane is awaiting_input. The worker has exited after writing an ask envelope. Use hq lanes questions answer <question-id>.";
|
|
98
|
+
case "done":
|
|
99
|
+
case "blocked": {
|
|
100
|
+
if (!providerSupportsSessionResume(lane.provider)) {
|
|
101
|
+
return `Lane is ${state}. Provider ${lane.provider} cannot resume an exited worker; there is no delivery path.`;
|
|
102
|
+
}
|
|
103
|
+
if (!hasRecordedSessionId(lane)) {
|
|
104
|
+
return `Lane is ${state}. No provider session id was recorded from the worker's result, so the session cannot be resumed.`;
|
|
105
|
+
}
|
|
106
|
+
return "";
|
|
107
|
+
}
|
|
108
|
+
case "killed":
|
|
109
|
+
case "cancelled":
|
|
110
|
+
return `Lane is ${state}. The operator stopped it on purpose; a message does not restart it. Resuming from ${state} needs an explicit opt-in, which does not exist yet.`;
|
|
111
|
+
case "interrupted":
|
|
112
|
+
return `Lane is ${state}. The run did not close cleanly and the operator has not decided what to do with it. Resuming from ${state} needs an explicit opt-in: hq lanes resume <lane-id> [--text <message>].`;
|
|
113
|
+
case "failed":
|
|
114
|
+
return `Lane is ${state}. The run did not close cleanly and the operator has not decided what to do with it. Resuming from ${state} needs an explicit opt-in, which does not exist yet.`;
|
|
115
|
+
default: {
|
|
116
|
+
const _exhaustive = state;
|
|
117
|
+
return _exhaustive;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Reasons a resumable provider's exit recorded no session id. Never a fabricated value. */
|
|
122
|
+
export const SESSION_ID_ABSENT_REASONS = [
|
|
123
|
+
"worker_out_missing",
|
|
124
|
+
"no_result_json",
|
|
125
|
+
"no_session_id_field",
|
|
126
|
+
"no_thread_started_event",
|
|
127
|
+
];
|
|
128
|
+
function nonNegativeInt(value) {
|
|
129
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0
|
|
130
|
+
? value
|
|
131
|
+
: null;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Parse the `claude -p --output-format json` result. stdout is appended
|
|
135
|
+
* across runs of the same lane, so the last `type: "result"` object is this
|
|
136
|
+
* run's. Only `session_id` and the two cache counters are read out; the
|
|
137
|
+
* prompt, the response body and everything else in the payload stay on disk.
|
|
138
|
+
*/
|
|
139
|
+
export function parseClaudeResultOutput(text) {
|
|
140
|
+
const lines = text.split("\n");
|
|
141
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
142
|
+
const line = (lines[i] ?? "").trim();
|
|
143
|
+
if (!line.startsWith("{"))
|
|
144
|
+
continue;
|
|
145
|
+
let parsed;
|
|
146
|
+
try {
|
|
147
|
+
parsed = JSON.parse(line);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
153
|
+
continue;
|
|
154
|
+
const rec = parsed;
|
|
155
|
+
if (rec.type !== "result")
|
|
156
|
+
continue;
|
|
157
|
+
const usage = rec.usage && typeof rec.usage === "object" && !Array.isArray(rec.usage)
|
|
158
|
+
? rec.usage
|
|
159
|
+
: {};
|
|
160
|
+
const sessionId = typeof rec.session_id === "string" && rec.session_id.trim().length > 0
|
|
161
|
+
? rec.session_id.trim()
|
|
162
|
+
: null;
|
|
163
|
+
return {
|
|
164
|
+
session_id: sessionId,
|
|
165
|
+
session_id_absent: sessionId === null ? "no_session_id_field" : null,
|
|
166
|
+
cache_read_input_tokens: nonNegativeInt(usage.cache_read_input_tokens),
|
|
167
|
+
cache_creation_input_tokens: nonNegativeInt(usage.cache_creation_input_tokens),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
session_id: null,
|
|
172
|
+
session_id_absent: "no_result_json",
|
|
173
|
+
cache_read_input_tokens: null,
|
|
174
|
+
cache_creation_input_tokens: null,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Parse the `codex exec --json` event stream. The first event of a run is
|
|
179
|
+
* `{"type":"thread.started","thread_id":"<uuid>"}`; that id is what
|
|
180
|
+
* `codex exec resume <id>` takes, and a resumed run emits the same id again.
|
|
181
|
+
* Usage comes from the last `turn.completed` event: `cached_input_tokens`
|
|
182
|
+
* is the cache read and `cache_write_input_tokens` the cache creation.
|
|
183
|
+
* Nothing else on the stream is read out.
|
|
184
|
+
*/
|
|
185
|
+
export function parseCodexEventOutput(text) {
|
|
186
|
+
let threadId = null;
|
|
187
|
+
let usage = {};
|
|
188
|
+
let sawEvent = false;
|
|
189
|
+
for (const raw of text.split("\n")) {
|
|
190
|
+
const line = raw.trim();
|
|
191
|
+
if (!line.startsWith("{"))
|
|
192
|
+
continue;
|
|
193
|
+
let parsed;
|
|
194
|
+
try {
|
|
195
|
+
parsed = JSON.parse(line);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
201
|
+
continue;
|
|
202
|
+
const rec = parsed;
|
|
203
|
+
if (typeof rec.type !== "string")
|
|
204
|
+
continue;
|
|
205
|
+
sawEvent = true;
|
|
206
|
+
if (rec.type === "thread.started" && threadId === null) {
|
|
207
|
+
threadId =
|
|
208
|
+
typeof rec.thread_id === "string" && rec.thread_id.trim().length > 0
|
|
209
|
+
? rec.thread_id.trim()
|
|
210
|
+
: null;
|
|
211
|
+
}
|
|
212
|
+
else if (rec.type === "turn.completed") {
|
|
213
|
+
usage =
|
|
214
|
+
rec.usage && typeof rec.usage === "object" && !Array.isArray(rec.usage)
|
|
215
|
+
? rec.usage
|
|
216
|
+
: {};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
session_id: threadId,
|
|
221
|
+
session_id_absent: threadId !== null ? null : sawEvent ? "no_thread_started_event" : "no_result_json",
|
|
222
|
+
cache_read_input_tokens: nonNegativeInt(usage.cached_input_tokens),
|
|
223
|
+
cache_creation_input_tokens: nonNegativeInt(usage.cache_write_input_tokens),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/** Per-provider stdout parser; null for a provider whose exit carries nothing to record. */
|
|
227
|
+
export function parseWorkerRunOutput(provider, text) {
|
|
228
|
+
switch (parseLaneProvider(provider)) {
|
|
229
|
+
case "claude":
|
|
230
|
+
return parseClaudeResultOutput(text);
|
|
231
|
+
case "codex":
|
|
232
|
+
return parseCodexEventOutput(text);
|
|
233
|
+
case "grok":
|
|
234
|
+
return null;
|
|
235
|
+
default:
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Read this run's result from the worker's own stdout. stdout is opened in
|
|
241
|
+
* append mode across runs, so only bytes past `workerOutOffset` (recorded in
|
|
242
|
+
* spawn.json at launch) are this run's. Returns null for a provider whose
|
|
243
|
+
* exit carries nothing to record, so those lanes keep behaving exactly as
|
|
244
|
+
* before.
|
|
245
|
+
*/
|
|
246
|
+
export function readWorkerRunResult(runDir, provider,
|
|
247
|
+
/** Bytes of worker.out that belong to earlier runs; parsing starts after them. */
|
|
248
|
+
workerOutOffset = 0) {
|
|
249
|
+
if (!providerSupportsSessionResume(provider))
|
|
250
|
+
return null;
|
|
251
|
+
const outPath = path.join(runDir, "logs", "worker.out");
|
|
252
|
+
let text;
|
|
253
|
+
try {
|
|
254
|
+
const bytes = fs.readFileSync(outPath);
|
|
255
|
+
text = bytes.subarray(Math.min(workerOutOffset, bytes.length)).toString("utf8");
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
const code = err.code;
|
|
259
|
+
if (code === "ENOENT") {
|
|
260
|
+
return {
|
|
261
|
+
session_id: null,
|
|
262
|
+
session_id_absent: "worker_out_missing",
|
|
263
|
+
cache_read_input_tokens: null,
|
|
264
|
+
cache_creation_input_tokens: null,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
process.stderr.write(`hq lanes: failed to read ${outPath}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
268
|
+
throw err;
|
|
269
|
+
}
|
|
270
|
+
return parseWorkerRunOutput(provider, text);
|
|
271
|
+
}
|
|
272
|
+
export const LAUNCH_TRIGGERS = ["admit", "answered_questions", "message", "resume"];
|
|
273
|
+
/**
|
|
274
|
+
* Refusal for `hq lanes resume`, in precedence order: wrong state, provider
|
|
275
|
+
* without a resume path, no recorded session. Null when the resume can go.
|
|
276
|
+
*/
|
|
277
|
+
export function resumeCommandRefusal(lane) {
|
|
278
|
+
if (!isResumeCommandState(lane.state)) {
|
|
279
|
+
return {
|
|
280
|
+
error: "lane_not_resumable",
|
|
281
|
+
message: `Lane is ${lane.state}. hq lanes resume continues a lane in ${RESUME_COMMAND_STATES.join(", ")}; ${lane.state === "awaiting_input"
|
|
282
|
+
? "use hq lanes questions answer <question-id>."
|
|
283
|
+
: lane.state === "queued" || lane.state === "running"
|
|
284
|
+
? "use hq lanes message for a live lane."
|
|
285
|
+
: "a lane in this state is not resumed."}`,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
if (!providerSupportsSessionResume(lane.provider)) {
|
|
289
|
+
return {
|
|
290
|
+
error: "resume_unsupported",
|
|
291
|
+
message: `Lane is ${lane.state}. Provider ${lane.provider} cannot resume an exited worker; there is no delivery path.`,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (!hasRecordedSessionId(lane)) {
|
|
295
|
+
return {
|
|
296
|
+
error: "no_provider_session",
|
|
297
|
+
message: `Lane is ${lane.state}. No provider session id was recorded from the worker's result, so the session cannot be resumed.`,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Decide fresh-vs-resume for a spawn. Admission always starts fresh with the
|
|
304
|
+
* brief. Answered questions resume the recorded session when the provider
|
|
305
|
+
* can, else start fresh with the answers file (today's behaviour). An operator
|
|
306
|
+
* message can only be a resume, and only when `laneDeliveryMechanism` says so.
|
|
307
|
+
* `hq lanes resume` is also always a resume, gated by `resumeCommandRefusal`.
|
|
308
|
+
*/
|
|
309
|
+
export function planLaneLaunch(lane, trigger) {
|
|
310
|
+
switch (trigger.kind) {
|
|
311
|
+
case "admit":
|
|
312
|
+
return { mode: "fresh" };
|
|
313
|
+
case "answered_questions":
|
|
314
|
+
if (providerSupportsSessionResume(lane.provider) && hasRecordedSessionId(lane)) {
|
|
315
|
+
return { mode: "resume", session_id: lane.provider_session_id };
|
|
316
|
+
}
|
|
317
|
+
return { mode: "fresh" };
|
|
318
|
+
case "message": {
|
|
319
|
+
if (laneDeliveryMechanism(lane) !== "resume" || !hasRecordedSessionId(lane)) {
|
|
320
|
+
throw new LanesError("lane_not_accepting_messages", deliveryRefusalReason(lane) || `Lane is ${lane.state} and cannot be resumed`, { state: lane.state, provider: lane.provider });
|
|
321
|
+
}
|
|
322
|
+
return { mode: "resume", session_id: lane.provider_session_id };
|
|
323
|
+
}
|
|
324
|
+
case "resume": {
|
|
325
|
+
const refusal = resumeCommandRefusal(lane);
|
|
326
|
+
if (refusal !== null || !hasRecordedSessionId(lane)) {
|
|
327
|
+
throw new LanesError(refusal?.error ?? "no_provider_session", refusal?.message ?? `Lane is ${lane.state} and has no recorded provider session`, { state: lane.state, provider: lane.provider });
|
|
328
|
+
}
|
|
329
|
+
return { mode: "resume", session_id: lane.provider_session_id };
|
|
330
|
+
}
|
|
331
|
+
default: {
|
|
332
|
+
const _exhaustive = trigger;
|
|
333
|
+
return _exhaustive;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/** Lane states a launch may claim `running` from, per trigger. */
|
|
338
|
+
export function claimableStatesFor(trigger) {
|
|
339
|
+
switch (trigger.kind) {
|
|
340
|
+
case "admit":
|
|
341
|
+
return ["queued"];
|
|
342
|
+
case "answered_questions":
|
|
343
|
+
return ["awaiting_input"];
|
|
344
|
+
case "message":
|
|
345
|
+
return RESUMABLE_LANE_STATES;
|
|
346
|
+
case "resume":
|
|
347
|
+
return RESUME_COMMAND_STATES;
|
|
348
|
+
default: {
|
|
349
|
+
const _exhaustive = trigger;
|
|
350
|
+
return _exhaustive;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
//# sourceMappingURL=delivery.js.map
|