@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
|
@@ -0,0 +1,859 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-lane watcher (spec §5.7).
|
|
3
|
+
*
|
|
4
|
+
* Armed at spawn, detached from the caller. Emits on progress, terminal, and
|
|
5
|
+
* silence (20 minutes with the process still alive). Terminal classification
|
|
6
|
+
* follows spec §5: an envelope decides done/ask/blocked; a process that exits
|
|
7
|
+
* with partial output and no envelope is `interrupted`; silence is never
|
|
8
|
+
* success.
|
|
9
|
+
*
|
|
10
|
+
* Progress has two sources. Output files (worker.out, worker.err,
|
|
11
|
+
* last-message.txt) cover codex and grok, which stream as they work. They do
|
|
12
|
+
* not cover Claude: `claude -p --output-format json` writes nothing until it
|
|
13
|
+
* exits, so a healthy Claude lane used to hit the silence deadline at exactly
|
|
14
|
+
* 20 minutes every time, and a hung one looked identical. The second source
|
|
15
|
+
* is logs/tool-activity.json, which the drain hook rewrites on every
|
|
16
|
+
* PreToolUse call (dropbox.ts). A worker that is working calls tools; a
|
|
17
|
+
* worker that has not called a tool or written output for the whole window
|
|
18
|
+
* is `stalled`, and the watcher says so on the lane record.
|
|
19
|
+
*
|
|
20
|
+
* What this cannot tell apart: a worker hung inside one tool call from a
|
|
21
|
+
* worker running one legitimately long tool call (a 25-minute build); a
|
|
22
|
+
* worker looping on tool calls without making progress from a working one;
|
|
23
|
+
* a model stuck mid-generation with no tool call from one that is writing a
|
|
24
|
+
* long answer. It also depends on the drain hook being registered in the
|
|
25
|
+
* provider home (hooks-install.ts): with no hook there is no touch, and the
|
|
26
|
+
* lane reads as stalled every window, which is the pre-existing behaviour.
|
|
27
|
+
* Distinguishing a hung tool from a slow one needs the provider to report
|
|
28
|
+
* per-call state, which none of the three CLIs expose today. `stalled` is
|
|
29
|
+
* therefore a flag for the senior to look at, never a kill.
|
|
30
|
+
*
|
|
31
|
+
* Queue admission of the next lane when a slot frees is owned by the
|
|
32
|
+
* periodic reconcile pass and is out of scope here.
|
|
33
|
+
*/
|
|
34
|
+
import * as fs from "node:fs";
|
|
35
|
+
import * as path from "node:path";
|
|
36
|
+
import { LanesError } from "./errors.js";
|
|
37
|
+
import { raiseEnvelopeQuestions } from "./questions.js";
|
|
38
|
+
import { isTerminalLaneState, parseLaneEnvelope, } from "./types.js";
|
|
39
|
+
import { readLane, retryUpdateLane } from "./store.js";
|
|
40
|
+
import { readWorkerRunResult } from "./delivery.js";
|
|
41
|
+
import { readQuotaSignal, recordAccountQuota } from "./quota.js";
|
|
42
|
+
import { readToolActivity, toolActivityPath } from "./dropbox.js";
|
|
43
|
+
import { loadLanesConfig, providerProgressEventMinIntervalMs } from "./config.js";
|
|
44
|
+
import { createLaneMesh, emitLaneDecisionEvents, emitLaneProgressEvent, progressEventDue, } from "./mesh-events.js";
|
|
45
|
+
import { envelopeQuestionId } from "./questions.js";
|
|
46
|
+
import { laneRunDir, missingPidProbe, probePidRecord, readPidRecord, readProcStarttime, readSpawnManifest, envelopeRepairMode, spawnEnvelopeRepair, } from "./spawn.js";
|
|
47
|
+
/** Spec §5.7: no signal for 20 minutes while the process is still alive. */
|
|
48
|
+
export const SILENCE_MS = 20 * 60 * 1000;
|
|
49
|
+
export const WATCHER_POLL_MS = 5_000;
|
|
50
|
+
/** Synced last_host_report_at cadence. Reconcile dead-host TTL is 20 minutes. */
|
|
51
|
+
export const WATCHER_HEARTBEAT_MS = 60 * 1000;
|
|
52
|
+
/**
|
|
53
|
+
* Cap on logs/watcher.out. A decision line is typically under 300 bytes, so
|
|
54
|
+
* 256 KiB holds hundreds of transitions. A stuck lane cannot fill the disk:
|
|
55
|
+
* once the file would exceed this, the oldest complete lines are dropped.
|
|
56
|
+
*/
|
|
57
|
+
export const WATCHER_OUT_MAX_BYTES = 256 * 1024;
|
|
58
|
+
export const WATCHER_DECISION_EVENTS = [
|
|
59
|
+
"liveness_transition",
|
|
60
|
+
"silence_deadline",
|
|
61
|
+
"envelope_observed",
|
|
62
|
+
"envelope_invalid",
|
|
63
|
+
"envelope_repair",
|
|
64
|
+
"classification",
|
|
65
|
+
"terminal_write",
|
|
66
|
+
"mesh_event",
|
|
67
|
+
"mesh_event_failed",
|
|
68
|
+
];
|
|
69
|
+
/** Terminal reason when the envelope failed to parse after the repair turn (spec §4). */
|
|
70
|
+
export const ENVELOPE_INVALID_REASON = "envelope_invalid";
|
|
71
|
+
export const WATCHER_SIGNAL_KINDS = ["progress", "terminal", "silence"];
|
|
72
|
+
export function parseWatcherSignalKind(value) {
|
|
73
|
+
if (typeof value === "string" &&
|
|
74
|
+
WATCHER_SIGNAL_KINDS.includes(value)) {
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
throw new LanesError("unknown_watcher_signal", `Unknown watcher signal ${JSON.stringify(value)}. Accepted: ${WATCHER_SIGNAL_KINDS.join(", ")}`, { accepted: [...WATCHER_SIGNAL_KINDS], value });
|
|
78
|
+
}
|
|
79
|
+
export function classifyLaneExit(input) {
|
|
80
|
+
if (input.envelope) {
|
|
81
|
+
if (input.envelope.decision === "done")
|
|
82
|
+
return { state: "done" };
|
|
83
|
+
if (input.envelope.decision === "ask")
|
|
84
|
+
return { state: "awaiting_input" };
|
|
85
|
+
if (input.envelope.decision === "blocked")
|
|
86
|
+
return { state: "blocked" };
|
|
87
|
+
throw new LanesError("unknown_envelope_decision", `Unknown envelope decision ${JSON.stringify(input.envelope.decision)}. Accepted: done, ask, blocked`, { accepted: ["done", "ask", "blocked"], value: input.envelope.decision });
|
|
88
|
+
}
|
|
89
|
+
if (input.invalidEnvelope) {
|
|
90
|
+
return { state: "failed", reason: ENVELOPE_INVALID_REASON };
|
|
91
|
+
}
|
|
92
|
+
if (input.quota) {
|
|
93
|
+
return {
|
|
94
|
+
state: input.hasPartialOutput ? "interrupted" : "failed",
|
|
95
|
+
reason: input.quota.reason,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (input.hasPartialOutput) {
|
|
99
|
+
return { state: "interrupted", reason: "exited_with_partial_output" };
|
|
100
|
+
}
|
|
101
|
+
return { state: "failed", reason: "exited_without_envelope" };
|
|
102
|
+
}
|
|
103
|
+
export function tickWatcher(input) {
|
|
104
|
+
const silenceMs = input.silenceMs ?? SILENCE_MS;
|
|
105
|
+
const at = new Date(input.nowMs).toISOString();
|
|
106
|
+
if (!input.processAlive) {
|
|
107
|
+
const classification = classifyLaneExit({
|
|
108
|
+
envelope: input.envelope,
|
|
109
|
+
hasPartialOutput: input.hasPartialOutput,
|
|
110
|
+
quota: input.quota,
|
|
111
|
+
invalidEnvelope: input.invalidEnvelope,
|
|
112
|
+
});
|
|
113
|
+
return {
|
|
114
|
+
emit: { kind: "terminal", at, classification },
|
|
115
|
+
nextLastSignalMs: input.nowMs,
|
|
116
|
+
done: true,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (input.progressed) {
|
|
120
|
+
return {
|
|
121
|
+
emit: { kind: "progress", at },
|
|
122
|
+
nextLastSignalMs: input.nowMs,
|
|
123
|
+
done: false,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (input.nowMs - input.lastSignalMs >= silenceMs) {
|
|
127
|
+
return {
|
|
128
|
+
emit: { kind: "silence", at },
|
|
129
|
+
nextLastSignalMs: input.nowMs,
|
|
130
|
+
done: false,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return { nextLastSignalMs: input.lastSignalMs, done: false };
|
|
134
|
+
}
|
|
135
|
+
export function watcherEventsPath(hqRoot, laneId) {
|
|
136
|
+
return path.join(laneRunDir(hqRoot, laneId), "watcher", "events.jsonl");
|
|
137
|
+
}
|
|
138
|
+
export function watcherOutLogPath(hqRoot, laneId) {
|
|
139
|
+
return path.join(laneRunDir(hqRoot, laneId), "logs", "watcher.out");
|
|
140
|
+
}
|
|
141
|
+
export function appendCappedLog(filePath, line, maxBytes = WATCHER_OUT_MAX_BYTES) {
|
|
142
|
+
const payload = line.endsWith("\n") ? line : `${line}\n`;
|
|
143
|
+
const payloadBytes = Buffer.byteLength(payload, "utf8");
|
|
144
|
+
if (payloadBytes > maxBytes)
|
|
145
|
+
return;
|
|
146
|
+
let existing = "";
|
|
147
|
+
try {
|
|
148
|
+
existing = fs.readFileSync(filePath, "utf8");
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
const code = err.code;
|
|
152
|
+
if (code !== "ENOENT")
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
let body = existing;
|
|
156
|
+
while (Buffer.byteLength(body, "utf8") + payloadBytes > maxBytes) {
|
|
157
|
+
const nl = body.indexOf("\n");
|
|
158
|
+
if (nl === -1) {
|
|
159
|
+
body = "";
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
body = body.slice(nl + 1);
|
|
163
|
+
}
|
|
164
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
165
|
+
fs.writeFileSync(filePath, body + payload, "utf8");
|
|
166
|
+
}
|
|
167
|
+
export function writeWatcherDecision(filePath, record, maxBytes = WATCHER_OUT_MAX_BYTES) {
|
|
168
|
+
const compact = {};
|
|
169
|
+
for (const [key, value] of Object.entries(record)) {
|
|
170
|
+
if (value !== undefined)
|
|
171
|
+
compact[key] = value;
|
|
172
|
+
}
|
|
173
|
+
appendCappedLog(filePath, JSON.stringify(compact), maxBytes);
|
|
174
|
+
}
|
|
175
|
+
export function watcherLastSignalPath(hqRoot, laneId) {
|
|
176
|
+
return path.join(laneRunDir(hqRoot, laneId), "watcher", "last-signal.json");
|
|
177
|
+
}
|
|
178
|
+
export function appendWatcherSignal(hqRoot, laneId, signal) {
|
|
179
|
+
const eventsPath = watcherEventsPath(hqRoot, laneId);
|
|
180
|
+
fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
|
|
181
|
+
fs.appendFileSync(eventsPath, `${JSON.stringify(signal)}\n`, "utf8");
|
|
182
|
+
fs.writeFileSync(watcherLastSignalPath(hqRoot, laneId), `${JSON.stringify(signal, null, 2)}\n`, "utf8");
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Slot-free notification. The periodic reconcile pass admits the next queued
|
|
186
|
+
* lane; this watcher must not do it. Intentionally empty.
|
|
187
|
+
*/
|
|
188
|
+
export function notifySlotFreed(_input) {
|
|
189
|
+
// Reconcile lane owns admission when a running slot frees (spec §5.7).
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* On exit, read the worker's own result for a resumable provider and copy
|
|
193
|
+
* only the session id and the two cache counters onto the record. A missing
|
|
194
|
+
* or unparseable id is recorded as absent with its reason, never invented.
|
|
195
|
+
* Non-resumable providers return null and the record is left as it was.
|
|
196
|
+
*/
|
|
197
|
+
function runResultFields(hqRoot, lane) {
|
|
198
|
+
const runDir = laneRunDir(hqRoot, lane.lane_id);
|
|
199
|
+
const result = readWorkerRunResult(runDir, lane.provider, readSpawnManifest(runDir).worker_out_offset ?? 0);
|
|
200
|
+
if (result === null)
|
|
201
|
+
return {};
|
|
202
|
+
const fields = {
|
|
203
|
+
last_run_usage: {
|
|
204
|
+
cache_read_input_tokens: result.cache_read_input_tokens,
|
|
205
|
+
cache_creation_input_tokens: result.cache_creation_input_tokens,
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
if (result.session_id !== null) {
|
|
209
|
+
fields.provider_session_id = result.session_id;
|
|
210
|
+
fields.provider_session_id_absent = undefined;
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
fields.provider_session_id = undefined;
|
|
214
|
+
fields.provider_session_id_absent = result.session_id_absent ?? "no_result_json";
|
|
215
|
+
}
|
|
216
|
+
return fields;
|
|
217
|
+
}
|
|
218
|
+
function maybeHeartbeatLane(hqRoot, laneId, nowMs) {
|
|
219
|
+
try {
|
|
220
|
+
retryUpdateLane(hqRoot, laneId, "watcher", (current) => {
|
|
221
|
+
if (current.state !== "running")
|
|
222
|
+
return current;
|
|
223
|
+
const last = Date.parse(current.last_host_report_at ?? "");
|
|
224
|
+
if (Number.isFinite(last) && nowMs - last < WATCHER_HEARTBEAT_MS) {
|
|
225
|
+
return current;
|
|
226
|
+
}
|
|
227
|
+
return { ...current, last_host_report_at: new Date(nowMs).toISOString() };
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
process.stderr.write(`hq lanes watcher: heartbeat failed for ${laneId}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
export function stalledReason(silenceMs) {
|
|
235
|
+
return `no_tool_call_or_output_for_${Math.round(silenceMs / 1000)}s`;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* The consumer of the silence signal. A silence emit on a running lane sets
|
|
239
|
+
* `stalled` on the record so `hq lanes list` and `show` surface it; the next
|
|
240
|
+
* progress emit clears it. Nothing here changes `state`.
|
|
241
|
+
*/
|
|
242
|
+
export function markLaneStalled(hqRoot, laneId, at, reason) {
|
|
243
|
+
return retryUpdateLane(hqRoot, laneId, "watcher", (current) => {
|
|
244
|
+
if (current.state !== "running")
|
|
245
|
+
return current;
|
|
246
|
+
if (current.stalled === true && current.stalled_reason === reason)
|
|
247
|
+
return current;
|
|
248
|
+
return { ...current, stalled: true, stalled_at: at, stalled_reason: reason };
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
export function clearLaneStalled(hqRoot, laneId) {
|
|
252
|
+
return retryUpdateLane(hqRoot, laneId, "watcher", (current) => {
|
|
253
|
+
if (current.stalled === undefined && current.stalled_at === undefined)
|
|
254
|
+
return current;
|
|
255
|
+
return { ...current, stalled: undefined, stalled_at: undefined, stalled_reason: undefined };
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
export function applyTerminalToLane(hqRoot, laneId, classification, opts = {}) {
|
|
259
|
+
return applyTerminalToLaneWithResult(hqRoot, laneId, classification, opts).record;
|
|
260
|
+
}
|
|
261
|
+
/** Terminal write outcome for emitters: only the CAS winner may publish mesh effects. */
|
|
262
|
+
export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, opts = {}) {
|
|
263
|
+
const at = opts.at ?? new Date().toISOString();
|
|
264
|
+
const existing = readLane(hqRoot, laneId);
|
|
265
|
+
if (!isTerminalLaneState(existing.state) &&
|
|
266
|
+
classification.state === "awaiting_input" &&
|
|
267
|
+
opts.envelope &&
|
|
268
|
+
(opts.envelope.questions?.length ?? 0) > 0) {
|
|
269
|
+
raiseEnvelopeQuestions(hqRoot, existing, opts.envelope, opts.envelopeRef ?? "envelopes/unknown.json", at);
|
|
270
|
+
}
|
|
271
|
+
let changed = false;
|
|
272
|
+
const updated = retryUpdateLane(hqRoot, laneId, "watcher", (current) => {
|
|
273
|
+
const terminal = current.state === "done" ||
|
|
274
|
+
current.state === "failed" ||
|
|
275
|
+
current.state === "interrupted" ||
|
|
276
|
+
current.state === "cancelled" ||
|
|
277
|
+
current.state === "killed";
|
|
278
|
+
if (terminal)
|
|
279
|
+
return current;
|
|
280
|
+
if (current.state === "awaiting_input" || current.state === "blocked") {
|
|
281
|
+
if (current.state === classification.state)
|
|
282
|
+
return current;
|
|
283
|
+
}
|
|
284
|
+
const next = {
|
|
285
|
+
...current,
|
|
286
|
+
...runResultFields(hqRoot, current),
|
|
287
|
+
state: classification.state,
|
|
288
|
+
queued_reason: undefined,
|
|
289
|
+
stalled: undefined,
|
|
290
|
+
stalled_at: undefined,
|
|
291
|
+
stalled_reason: undefined,
|
|
292
|
+
};
|
|
293
|
+
changed = true;
|
|
294
|
+
if (classification.reason) {
|
|
295
|
+
next.terminal_reason =
|
|
296
|
+
classification.reason;
|
|
297
|
+
}
|
|
298
|
+
if (opts.quota?.quota_reset_at) {
|
|
299
|
+
next.quota_reset_at = opts.quota.quota_reset_at;
|
|
300
|
+
}
|
|
301
|
+
if (opts.quota?.marker) {
|
|
302
|
+
next.model_quota_marker = opts.quota.marker;
|
|
303
|
+
}
|
|
304
|
+
if (opts.envelope && opts.envelopeRef) {
|
|
305
|
+
next.last_envelope = {
|
|
306
|
+
ref: opts.envelopeRef,
|
|
307
|
+
decision: opts.envelope.decision,
|
|
308
|
+
at,
|
|
309
|
+
};
|
|
310
|
+
next.story_status = opts.envelope.story_status;
|
|
311
|
+
if (opts.envelope.story_complete !== undefined) {
|
|
312
|
+
next.story_complete = opts.envelope.story_complete;
|
|
313
|
+
next.story_complete_reason = opts.envelope.story_complete_reason;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return next;
|
|
317
|
+
});
|
|
318
|
+
notifySlotFreed({
|
|
319
|
+
hqRoot,
|
|
320
|
+
provider: updated.provider,
|
|
321
|
+
account: updated.account,
|
|
322
|
+
});
|
|
323
|
+
return { record: updated, changed };
|
|
324
|
+
}
|
|
325
|
+
export function isPidAlive(pid) {
|
|
326
|
+
try {
|
|
327
|
+
process.kill(pid, 0);
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
const code = err.code;
|
|
332
|
+
if (code === "ESRCH")
|
|
333
|
+
return false;
|
|
334
|
+
if (code === "EPERM")
|
|
335
|
+
return true;
|
|
336
|
+
process.stderr.write(`hq lanes watcher: liveness probe for pid ${pid} failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
337
|
+
throw err;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
function fileMtimeMs(filePath) {
|
|
341
|
+
try {
|
|
342
|
+
return fs.statSync(filePath).mtimeMs;
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
const code = err.code;
|
|
346
|
+
if (code === "ENOENT")
|
|
347
|
+
return null;
|
|
348
|
+
process.stderr.write(`hq lanes watcher: failed to stat ${filePath}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
349
|
+
throw err;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function fileNonEmpty(filePath) {
|
|
353
|
+
try {
|
|
354
|
+
return fs.statSync(filePath).size > 0;
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
const code = err.code;
|
|
358
|
+
if (code === "ENOENT")
|
|
359
|
+
return false;
|
|
360
|
+
process.stderr.write(`hq lanes watcher: failed to stat ${filePath}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
361
|
+
throw err;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
export function readLatestEnvelope(hqRoot, laneId, minName) {
|
|
365
|
+
return readLatestEnvelopeRecord(hqRoot, laneId, minName)?.envelope ?? null;
|
|
366
|
+
}
|
|
367
|
+
/** Valid-only view over `readLatestEnvelopeFile`; an unparseable file reads as absent. */
|
|
368
|
+
export function readLatestEnvelopeRecord(hqRoot, laneId, minName) {
|
|
369
|
+
const latest = readLatestEnvelopeFile(hqRoot, laneId, minName);
|
|
370
|
+
if (latest === null)
|
|
371
|
+
return null;
|
|
372
|
+
if (latest.envelope === null) {
|
|
373
|
+
process.stderr.write(`hq lanes watcher: envelope ${latest.path} is not a valid hq-lane-envelope.v1: ${latest.error}\n`);
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
return { envelope: latest.envelope, ref: latest.ref };
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* `minName` is this run's envelope slot (basename from spawn.json). Envelopes
|
|
380
|
+
* that sort before it belong to an earlier run of the same lane and must not
|
|
381
|
+
* classify this exit: a resumed run that dies before writing its own envelope
|
|
382
|
+
* would otherwise be marked done again from the previous one.
|
|
383
|
+
*/
|
|
384
|
+
export function readLatestEnvelopeFile(hqRoot, laneId, minName) {
|
|
385
|
+
const dir = path.join(laneRunDir(hqRoot, laneId), "envelopes");
|
|
386
|
+
let names;
|
|
387
|
+
try {
|
|
388
|
+
names = fs
|
|
389
|
+
.readdirSync(dir)
|
|
390
|
+
.filter((name) => name.endsWith(".json"))
|
|
391
|
+
.filter((name) => minName === undefined || name >= minName)
|
|
392
|
+
.sort();
|
|
393
|
+
}
|
|
394
|
+
catch (err) {
|
|
395
|
+
const code = err.code;
|
|
396
|
+
if (code === "ENOENT")
|
|
397
|
+
return null;
|
|
398
|
+
process.stderr.write(`hq lanes watcher: failed to list envelopes for ${laneId}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
399
|
+
throw err;
|
|
400
|
+
}
|
|
401
|
+
if (names.length === 0)
|
|
402
|
+
return null;
|
|
403
|
+
const latest = names[names.length - 1];
|
|
404
|
+
const filePath = path.join(dir, latest);
|
|
405
|
+
let text;
|
|
406
|
+
try {
|
|
407
|
+
text = fs.readFileSync(filePath, "utf8");
|
|
408
|
+
}
|
|
409
|
+
catch (err) {
|
|
410
|
+
process.stderr.write(`hq lanes watcher: failed to read envelope ${filePath}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
411
|
+
throw err;
|
|
412
|
+
}
|
|
413
|
+
const ref = path.posix.join("envelopes", latest);
|
|
414
|
+
try {
|
|
415
|
+
return {
|
|
416
|
+
ref,
|
|
417
|
+
path: filePath,
|
|
418
|
+
text,
|
|
419
|
+
envelope: parseLaneEnvelope(JSON.parse(text)),
|
|
420
|
+
error: null,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
return {
|
|
425
|
+
ref,
|
|
426
|
+
path: filePath,
|
|
427
|
+
text,
|
|
428
|
+
envelope: null,
|
|
429
|
+
error: err instanceof Error ? err.message : String(err),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Wrap one mesh call. A failed emit is logged as `mesh_event_failed` with
|
|
435
|
+
* the event name and the error message, and never propagates: the lane's
|
|
436
|
+
* state is decided by the envelope and the process, not by the network.
|
|
437
|
+
*/
|
|
438
|
+
export async function emitMeshSoft(event, logDecision, at, fn, timeoutMs = 10_000) {
|
|
439
|
+
let timer;
|
|
440
|
+
try {
|
|
441
|
+
const controller = new AbortController();
|
|
442
|
+
await Promise.race([
|
|
443
|
+
fn(controller.signal),
|
|
444
|
+
new Promise((_resolve, reject) => {
|
|
445
|
+
timer = setTimeout(() => { controller.abort(); reject(new Error(`mesh request timed out after ${timeoutMs}ms`)); }, timeoutMs);
|
|
446
|
+
}),
|
|
447
|
+
]);
|
|
448
|
+
logDecision({ event: "mesh_event", at, mesh_event: event });
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
453
|
+
logDecision({ event: "mesh_event_failed", at, mesh_event: event, error: message });
|
|
454
|
+
process.stderr.write(`hq lanes watcher: mesh_event_failed ${event}: ${message}\n`);
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
finally {
|
|
458
|
+
if (timer)
|
|
459
|
+
clearTimeout(timer);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
function readProgressIntervalMs(hqRoot, lane) {
|
|
463
|
+
try {
|
|
464
|
+
const config = loadLanesConfig(hqRoot);
|
|
465
|
+
if (lane)
|
|
466
|
+
return providerProgressEventMinIntervalMs(config.providers[lane.provider]);
|
|
467
|
+
}
|
|
468
|
+
catch (err) {
|
|
469
|
+
process.stderr.write(`hq lanes watcher: lanes config unreadable, using default progress interval: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
470
|
+
}
|
|
471
|
+
return providerProgressEventMinIntervalMs({ max_running: 1, max_queued: 1, accounts: {} });
|
|
472
|
+
}
|
|
473
|
+
function readLaneSoft(hqRoot, laneId) {
|
|
474
|
+
try {
|
|
475
|
+
return readLane(hqRoot, laneId);
|
|
476
|
+
}
|
|
477
|
+
catch (err) {
|
|
478
|
+
process.stderr.write(`hq lanes watcher: lane read failed for ${laneId}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
/** Question ids an ask envelope raised, as `raiseEnvelopeQuestions` mints them. */
|
|
483
|
+
export function envelopeQuestionIds(laneId, envelopeRef, envelope) {
|
|
484
|
+
return (envelope.questions ?? []).map((_q, i) => envelopeQuestionId(laneId, envelopeRef, i));
|
|
485
|
+
}
|
|
486
|
+
function defaultSleep(ms) {
|
|
487
|
+
return new Promise((resolve) => {
|
|
488
|
+
setTimeout(resolve, ms);
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
|
|
492
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
493
|
+
const now = deps.now ?? (() => Date.now());
|
|
494
|
+
const isAlive = deps.isAlive ?? isPidAlive;
|
|
495
|
+
const readStarttime = deps.readStarttime ?? readProcStarttime;
|
|
496
|
+
const pollMs = deps.pollMs ?? WATCHER_POLL_MS;
|
|
497
|
+
const silenceMs = deps.silenceMs ?? SILENCE_MS;
|
|
498
|
+
const runDir = laneRunDir(hqRoot, laneId);
|
|
499
|
+
const outPath = deps.watcherOutPath ?? watcherOutLogPath(hqRoot, laneId);
|
|
500
|
+
const outMax = deps.watcherOutMaxBytes ?? WATCHER_OUT_MAX_BYTES;
|
|
501
|
+
const workerPidPath = path.join(runDir, "worker.pid");
|
|
502
|
+
const manifest = readSpawnManifest(runDir);
|
|
503
|
+
const minEnvelopeName = manifest.envelope_path !== undefined
|
|
504
|
+
? path.basename(manifest.envelope_path)
|
|
505
|
+
: undefined;
|
|
506
|
+
const logFiles = [
|
|
507
|
+
path.join(runDir, "logs", "worker.out"),
|
|
508
|
+
path.join(runDir, "logs", "worker.err"),
|
|
509
|
+
path.join(runDir, "logs", "last-message.txt"),
|
|
510
|
+
];
|
|
511
|
+
// Progress only. A tool call is not partial output: a worker that called
|
|
512
|
+
// tools and exited with no envelope and no output is still `failed`.
|
|
513
|
+
const activityFiles = [toolActivityPath(hqRoot, laneId)];
|
|
514
|
+
const progressFiles = [...logFiles, ...activityFiles];
|
|
515
|
+
// Mesh seam (spec §8). Non-interactive: nobody is at this process's tty.
|
|
516
|
+
const mesh = deps.mesh === undefined ? createLaneMesh({ interactive: false }) : deps.mesh;
|
|
517
|
+
const progressIntervalMs = deps.progressEventMinIntervalMs ?? readProgressIntervalMs(hqRoot, readLaneSoft(hqRoot, laneId));
|
|
518
|
+
const spawnRepair = deps.spawnRepair ?? spawnEnvelopeRepair;
|
|
519
|
+
const meshRequestTimeoutMs = deps.meshRequestTimeoutMs ?? 10_000;
|
|
520
|
+
let lastSignalMs = now();
|
|
521
|
+
let lastSeenMtime = 0;
|
|
522
|
+
let lastAlive = null;
|
|
523
|
+
let lastEnvelopeRef = null;
|
|
524
|
+
let lastInvalidRef = null;
|
|
525
|
+
let lastProgressEmitMs = (() => {
|
|
526
|
+
const stamp = readLaneSoft(hqRoot, laneId)?.last_progress_event_at;
|
|
527
|
+
return stamp ? Date.parse(stamp) || null : null;
|
|
528
|
+
})();
|
|
529
|
+
for (const file of progressFiles) {
|
|
530
|
+
const mtime = fileMtimeMs(file);
|
|
531
|
+
if (mtime !== null && mtime > lastSeenMtime)
|
|
532
|
+
lastSeenMtime = mtime;
|
|
533
|
+
}
|
|
534
|
+
const logDecision = (record) => {
|
|
535
|
+
writeWatcherDecision(outPath, record, outMax);
|
|
536
|
+
};
|
|
537
|
+
while (true) {
|
|
538
|
+
const worker = readPidRecord(workerPidPath);
|
|
539
|
+
const probe = worker === null
|
|
540
|
+
? missingPidProbe()
|
|
541
|
+
: probePidRecord(worker, isAlive, readStarttime);
|
|
542
|
+
const processAlive = probe.alive;
|
|
543
|
+
if (lastAlive !== false && !processAlive) {
|
|
544
|
+
logDecision({
|
|
545
|
+
event: "liveness_transition",
|
|
546
|
+
at: new Date(now()).toISOString(),
|
|
547
|
+
pid: probe.pid,
|
|
548
|
+
recorded_starttime: probe.recorded_starttime,
|
|
549
|
+
live_starttime: probe.live_starttime,
|
|
550
|
+
starttime_error: probe.starttime_error,
|
|
551
|
+
decided_by: probe.decided_by,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
lastAlive = processAlive;
|
|
555
|
+
let progressed = false;
|
|
556
|
+
let newestMtime = lastSeenMtime;
|
|
557
|
+
let newestFile = null;
|
|
558
|
+
for (const file of progressFiles) {
|
|
559
|
+
const mtime = fileMtimeMs(file);
|
|
560
|
+
if (mtime !== null && mtime > newestMtime) {
|
|
561
|
+
newestMtime = mtime;
|
|
562
|
+
newestFile = file;
|
|
563
|
+
progressed = true;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
const latestFile = readLatestEnvelopeFile(hqRoot, laneId, minEnvelopeName);
|
|
567
|
+
const latest = latestFile && latestFile.envelope
|
|
568
|
+
? { envelope: latestFile.envelope, ref: latestFile.ref }
|
|
569
|
+
: null;
|
|
570
|
+
const envelope = latest?.envelope ?? null;
|
|
571
|
+
if (latest && latest.ref !== lastEnvelopeRef) {
|
|
572
|
+
logDecision({
|
|
573
|
+
event: "envelope_observed",
|
|
574
|
+
at: new Date(now()).toISOString(),
|
|
575
|
+
filename: path.posix.basename(latest.ref),
|
|
576
|
+
decision: latest.envelope.decision,
|
|
577
|
+
});
|
|
578
|
+
lastEnvelopeRef = latest.ref;
|
|
579
|
+
}
|
|
580
|
+
if (latestFile && latestFile.envelope === null && latestFile.ref !== lastInvalidRef) {
|
|
581
|
+
logDecision({
|
|
582
|
+
event: "envelope_invalid",
|
|
583
|
+
at: new Date(now()).toISOString(),
|
|
584
|
+
filename: path.posix.basename(latestFile.ref),
|
|
585
|
+
error: latestFile.error,
|
|
586
|
+
});
|
|
587
|
+
lastInvalidRef = latestFile.ref;
|
|
588
|
+
}
|
|
589
|
+
const hasPartialOutput = logFiles.some((file) => fileNonEmpty(file));
|
|
590
|
+
let quota = null;
|
|
591
|
+
let quotaLane = null;
|
|
592
|
+
if (!processAlive && !envelope) {
|
|
593
|
+
try {
|
|
594
|
+
quotaLane = readLane(hqRoot, laneId);
|
|
595
|
+
quota = readQuotaSignal(runDir, quotaLane.model, new Date(now()), readSpawnManifest(runDir).worker_out_offset ?? 0);
|
|
596
|
+
}
|
|
597
|
+
catch (err) {
|
|
598
|
+
process.stderr.write(`hq lanes watcher: quota read failed for ${laneId}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
// Spec §4: the process exited and its envelope file does not parse.
|
|
602
|
+
// Exactly one repair turn, then failed with both errors.
|
|
603
|
+
let invalidEnvelope = null;
|
|
604
|
+
if (!processAlive && !envelope && latestFile && latestFile.envelope === null) {
|
|
605
|
+
const repair = decideEnvelopeRepair(hqRoot, laneId, latestFile, now(), spawnRepair, logDecision);
|
|
606
|
+
if (repair.action === "spawned") {
|
|
607
|
+
lastSignalMs = now();
|
|
608
|
+
lastAlive = null;
|
|
609
|
+
if (progressed)
|
|
610
|
+
lastSeenMtime = newestMtime;
|
|
611
|
+
await sleep(pollMs);
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
invalidEnvelope = { errors: repair.errors };
|
|
615
|
+
}
|
|
616
|
+
if (processAlive === false && envelope && latestFile) {
|
|
617
|
+
markRepairSucceeded(hqRoot, laneId, latestFile.ref);
|
|
618
|
+
}
|
|
619
|
+
const tick = tickWatcher({
|
|
620
|
+
nowMs: now(),
|
|
621
|
+
processAlive,
|
|
622
|
+
progressed,
|
|
623
|
+
lastSignalMs,
|
|
624
|
+
envelope,
|
|
625
|
+
hasPartialOutput,
|
|
626
|
+
silenceMs,
|
|
627
|
+
quota,
|
|
628
|
+
invalidEnvelope,
|
|
629
|
+
});
|
|
630
|
+
if (processAlive) {
|
|
631
|
+
maybeHeartbeatLane(hqRoot, laneId, now());
|
|
632
|
+
}
|
|
633
|
+
if (tick.emit) {
|
|
634
|
+
parseWatcherSignalKind(tick.emit.kind);
|
|
635
|
+
appendWatcherSignal(hqRoot, laneId, tick.emit);
|
|
636
|
+
if (tick.emit.kind === "silence") {
|
|
637
|
+
const reason = stalledReason(silenceMs);
|
|
638
|
+
logDecision({
|
|
639
|
+
event: "silence_deadline",
|
|
640
|
+
at: tick.emit.at,
|
|
641
|
+
last_activity_at: new Date(lastSignalMs).toISOString(),
|
|
642
|
+
last_tool_call_at: readToolActivity(hqRoot, laneId)?.at ?? null,
|
|
643
|
+
deadline_at: new Date(lastSignalMs + silenceMs).toISOString(),
|
|
644
|
+
silence_ms: silenceMs,
|
|
645
|
+
stalled_reason: reason,
|
|
646
|
+
});
|
|
647
|
+
try {
|
|
648
|
+
markLaneStalled(hqRoot, laneId, tick.emit.at, reason);
|
|
649
|
+
}
|
|
650
|
+
catch (err) {
|
|
651
|
+
process.stderr.write(`hq lanes watcher: stalled write failed for ${laneId}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
if (tick.emit.kind === "progress") {
|
|
655
|
+
const progressEmitAt = tick.emit.at;
|
|
656
|
+
try {
|
|
657
|
+
clearLaneStalled(hqRoot, laneId);
|
|
658
|
+
}
|
|
659
|
+
catch (err) {
|
|
660
|
+
process.stderr.write(`hq lanes watcher: stalled clear failed for ${laneId}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
661
|
+
}
|
|
662
|
+
// Spec §8 progress row. Only a tick that saw new tool activity or
|
|
663
|
+
// new output gets here (tickWatcher emits progress on `progressed`
|
|
664
|
+
// alone), and the throttle bounds the rate. Silence never reaches
|
|
665
|
+
// this branch.
|
|
666
|
+
if (mesh &&
|
|
667
|
+
progressEventDue({
|
|
668
|
+
lastEmitMs: lastProgressEmitMs,
|
|
669
|
+
nowMs: progressEmitAt ? Date.parse(progressEmitAt) : now(),
|
|
670
|
+
minIntervalMs: progressIntervalMs,
|
|
671
|
+
})) {
|
|
672
|
+
const lane = readLaneSoft(hqRoot, laneId);
|
|
673
|
+
if (lane) {
|
|
674
|
+
const detail = newestFile && activityFiles.includes(newestFile)
|
|
675
|
+
? `tool call at ${readToolActivity(hqRoot, laneId)?.at ?? tick.emit.at}`
|
|
676
|
+
: envelope
|
|
677
|
+
? "envelope written"
|
|
678
|
+
: "new output";
|
|
679
|
+
const sent = await emitMeshSoft("lane_progress", logDecision, progressEmitAt, (signal) => emitLaneProgressEvent(mesh, lane, detail, signal), meshRequestTimeoutMs);
|
|
680
|
+
if (sent) {
|
|
681
|
+
lastProgressEmitMs = Date.parse(progressEmitAt);
|
|
682
|
+
try {
|
|
683
|
+
retryUpdateLane(hqRoot, laneId, "watcher", (current) => ({
|
|
684
|
+
...current,
|
|
685
|
+
last_progress_event_at: progressEmitAt,
|
|
686
|
+
}));
|
|
687
|
+
}
|
|
688
|
+
catch (err) {
|
|
689
|
+
process.stderr.write(`hq lanes watcher: progress timestamp write failed for ${laneId}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (tick.emit.kind === "terminal" && tick.emit.classification) {
|
|
696
|
+
const classification = tick.emit.classification;
|
|
697
|
+
logDecision({
|
|
698
|
+
event: "classification",
|
|
699
|
+
at: tick.emit.at,
|
|
700
|
+
envelope_present: envelope !== null,
|
|
701
|
+
has_partial_output: hasPartialOutput,
|
|
702
|
+
state: classification.state,
|
|
703
|
+
reason: classification.reason,
|
|
704
|
+
...(invalidEnvelope ? { envelope_errors: invalidEnvelope.errors } : {}),
|
|
705
|
+
});
|
|
706
|
+
if (quota &&
|
|
707
|
+
quotaLane?.account &&
|
|
708
|
+
(classification.reason === "account_quota_exhausted" ||
|
|
709
|
+
classification.reason === "account_model_unavailable")) {
|
|
710
|
+
try {
|
|
711
|
+
recordAccountQuota(hqRoot, quotaLane.provider, quotaLane.account, quota);
|
|
712
|
+
}
|
|
713
|
+
catch (err) {
|
|
714
|
+
process.stderr.write(`hq lanes watcher: quota record failed for ${laneId}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
const terminalWrite = applyTerminalToLaneWithResult(hqRoot, laneId, classification, {
|
|
718
|
+
envelope,
|
|
719
|
+
envelopeRef: latest?.ref,
|
|
720
|
+
at: tick.emit.at,
|
|
721
|
+
quota,
|
|
722
|
+
});
|
|
723
|
+
const updated = terminalWrite.record;
|
|
724
|
+
logDecision({
|
|
725
|
+
event: "terminal_write",
|
|
726
|
+
at: tick.emit.at,
|
|
727
|
+
state: updated.state,
|
|
728
|
+
version: updated.version,
|
|
729
|
+
});
|
|
730
|
+
// Spec §8 terminal rows: done → lane_done + story review; blocked →
|
|
731
|
+
// lane_blocked; ask → lane_ask with the queue ids. After the state
|
|
732
|
+
// write, so a mesh failure can never leave the lane unclassified.
|
|
733
|
+
if (mesh && envelope && latest && terminalWrite.changed) {
|
|
734
|
+
const questionIds = envelope.decision === "ask"
|
|
735
|
+
? envelopeQuestionIds(laneId, latest.ref, envelope)
|
|
736
|
+
: undefined;
|
|
737
|
+
await emitMeshSoft(`lane_${envelope.decision}`, logDecision, tick.emit.at, (signal) => emitLaneDecisionEvents(mesh, updated, envelope, { questionIds, signal }), meshRequestTimeoutMs);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
lastSignalMs = tick.nextLastSignalMs;
|
|
742
|
+
if (progressed)
|
|
743
|
+
lastSeenMtime = newestMtime;
|
|
744
|
+
if (tick.done)
|
|
745
|
+
return;
|
|
746
|
+
await sleep(pollMs);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Spec §4 repair decision for an exited worker whose envelope file does not
|
|
751
|
+
* parse. First failure on a resumable provider with a recorded session id:
|
|
752
|
+
* record `envelope_repair` on the lane and spawn the repair turn. A provider
|
|
753
|
+
* with no session resume gets the same single attempt as a fresh turn. Anything
|
|
754
|
+
* else (already attempted, no session id on a resumable provider, spawn
|
|
755
|
+
* failure): give up with every error collected, and the caller classifies
|
|
756
|
+
* the lane `failed` with reason `envelope_invalid`.
|
|
757
|
+
*/
|
|
758
|
+
export function decideEnvelopeRepair(hqRoot, laneId, latest, nowMs, spawnRepair, logDecision) {
|
|
759
|
+
const at = new Date(nowMs).toISOString();
|
|
760
|
+
const error = latest.error ?? "unparseable envelope";
|
|
761
|
+
const lane = readLaneSoft(hqRoot, laneId);
|
|
762
|
+
if (!lane)
|
|
763
|
+
return { action: "give_up", errors: [error] };
|
|
764
|
+
const prior = lane.envelope_repair;
|
|
765
|
+
if (prior && prior.attempted) {
|
|
766
|
+
// The repair turn ran and its output does not parse either.
|
|
767
|
+
if (prior.attempted) {
|
|
768
|
+
const errors = [prior.first_error, error];
|
|
769
|
+
try {
|
|
770
|
+
retryUpdateLane(hqRoot, laneId, "watcher", (current) => ({
|
|
771
|
+
...current,
|
|
772
|
+
envelope_repair: { ...prior, succeeded: false, second_error: error },
|
|
773
|
+
}));
|
|
774
|
+
}
|
|
775
|
+
catch (err) {
|
|
776
|
+
process.stderr.write(`hq lanes watcher: envelope_repair write failed for ${laneId}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
777
|
+
}
|
|
778
|
+
logDecision({ event: "envelope_repair", at, outcome: "second_failure", errors });
|
|
779
|
+
return { action: "give_up", errors };
|
|
780
|
+
}
|
|
781
|
+
return { action: "give_up", errors: [prior.first_error] };
|
|
782
|
+
}
|
|
783
|
+
const runDir = laneRunDir(hqRoot, laneId);
|
|
784
|
+
// `resume` (claude, codex) continues the recorded session; `fresh_turn`
|
|
785
|
+
// (grok) runs the repair prompt as a new single turn on the same account.
|
|
786
|
+
const mode = envelopeRepairMode(lane.provider);
|
|
787
|
+
let skipped;
|
|
788
|
+
let sessionId = null;
|
|
789
|
+
if (mode === "resume") {
|
|
790
|
+
const result = readWorkerRunResult(runDir, lane.provider, readSpawnManifest(runDir).worker_out_offset ?? 0);
|
|
791
|
+
sessionId = result?.session_id ?? null;
|
|
792
|
+
if (sessionId === null)
|
|
793
|
+
skipped = result?.session_id_absent ?? "no_result_json";
|
|
794
|
+
}
|
|
795
|
+
const base = {
|
|
796
|
+
first_error: error,
|
|
797
|
+
envelope_ref: latest.ref,
|
|
798
|
+
at,
|
|
799
|
+
};
|
|
800
|
+
if (skipped !== undefined) {
|
|
801
|
+
writeRepairRecord(hqRoot, laneId, { ...base, attempted: false, succeeded: false, skipped_reason: skipped });
|
|
802
|
+
logDecision({ event: "envelope_repair", at, outcome: "skipped", skipped_reason: skipped, error });
|
|
803
|
+
return { action: "give_up", errors: [error] };
|
|
804
|
+
}
|
|
805
|
+
writeRepairRecord(hqRoot, laneId, { ...base, mode, attempted: true, succeeded: false });
|
|
806
|
+
try {
|
|
807
|
+
const pid = spawnRepair(hqRoot, lane, {
|
|
808
|
+
session_id: sessionId,
|
|
809
|
+
envelopePath: latest.path,
|
|
810
|
+
error,
|
|
811
|
+
rawText: latest.text,
|
|
812
|
+
});
|
|
813
|
+
logDecision({ event: "envelope_repair", at, outcome: "spawned", mode, pid, session_id: sessionId, error });
|
|
814
|
+
return { action: "spawned" };
|
|
815
|
+
}
|
|
816
|
+
catch (err) {
|
|
817
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
818
|
+
process.stderr.write(`hq lanes watcher: envelope repair spawn failed for ${laneId}: ${message}\n`);
|
|
819
|
+
writeRepairRecord(hqRoot, laneId, {
|
|
820
|
+
...base,
|
|
821
|
+
mode,
|
|
822
|
+
attempted: true,
|
|
823
|
+
succeeded: false,
|
|
824
|
+
second_error: `repair_spawn_failed: ${message}`,
|
|
825
|
+
});
|
|
826
|
+
logDecision({ event: "envelope_repair", at, outcome: "spawn_failed", error: message });
|
|
827
|
+
return { action: "give_up", errors: [error, `repair_spawn_failed: ${message}`] };
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
function writeRepairRecord(hqRoot, laneId, record) {
|
|
831
|
+
retryUpdateLane(hqRoot, laneId, "watcher", (current) => ({ ...current, envelope_repair: record }));
|
|
832
|
+
}
|
|
833
|
+
/** The repair run wrote a parseable envelope: close the record as succeeded. */
|
|
834
|
+
function markRepairSucceeded(hqRoot, laneId, _envelopeRef) {
|
|
835
|
+
try {
|
|
836
|
+
retryUpdateLane(hqRoot, laneId, "watcher", (current) => {
|
|
837
|
+
const prior = current.envelope_repair;
|
|
838
|
+
if (!prior || !prior.attempted || prior.succeeded) {
|
|
839
|
+
return current;
|
|
840
|
+
}
|
|
841
|
+
return { ...current, envelope_repair: { ...prior, succeeded: true, second_error: undefined } };
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
catch (err) {
|
|
845
|
+
process.stderr.write(`hq lanes watcher: envelope_repair success write failed for ${laneId}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
/** Exported so tests can prove the watcher reads the live record, not spawn env. */
|
|
849
|
+
export function laneIdentityFromRecord(hqRoot, laneId) {
|
|
850
|
+
const lane = readLane(hqRoot, laneId);
|
|
851
|
+
return {
|
|
852
|
+
lane_id: lane.lane_id,
|
|
853
|
+
company: lane.company,
|
|
854
|
+
project_id: lane.project_id,
|
|
855
|
+
story_id: lane.story_id,
|
|
856
|
+
senior: lane.senior,
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
//# sourceMappingURL=watcher.js.map
|