@indigoai-us/hq-cli 5.345.56 → 5.345.58
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 +9 -0
- package/dist/command-catalog.generated.d.ts +88 -0
- package/dist/command-catalog.generated.js +109 -0
- package/dist/commands/access.d.ts +14 -0
- package/dist/commands/access.js +69 -1
- package/dist/commands/lanes-loop.d.ts +12 -0
- package/dist/commands/lanes-loop.js +214 -0
- package/dist/commands/lanes-questions.js +37 -11
- package/dist/commands/lanes-resume.js +19 -1
- package/dist/commands/lanes-shared.d.ts +12 -1
- package/dist/commands/lanes.js +262 -22
- package/dist/lib/lanes/delivery.d.ts +3 -0
- package/dist/lib/lanes/delivery.js +5 -0
- package/dist/lib/lanes/dropbox.d.ts +3 -0
- package/dist/lib/lanes/dropbox.js +103 -1
- package/dist/lib/lanes/flag-keys.d.ts +4 -0
- package/dist/lib/lanes/flag-keys.js +4 -0
- package/dist/lib/lanes/loop.d.ts +158 -0
- package/dist/lib/lanes/loop.js +743 -0
- package/dist/lib/lanes/outpost-dispatch.d.ts +9 -1
- package/dist/lib/lanes/outpost-dispatch.js +21 -7
- package/dist/lib/lanes/reconcile.d.ts +19 -0
- package/dist/lib/lanes/reconcile.js +173 -6
- package/dist/lib/lanes/spawn.d.ts +2 -0
- package/dist/lib/lanes/spawn.js +10 -8
- package/dist/lib/lanes/store.js +49 -2
- package/dist/lib/lanes/types.d.ts +18 -0
- package/dist/lib/lanes/types.js +2 -2
- package/dist/lib/lanes/watcher.js +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
/** Durable envelope queue and runtime primitives for conduct worker lanes. */
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { providerSupportsSessionResume } from "./delivery.js";
|
|
6
|
+
export const LOOP_SCHEMA = "hq-lane-loop.v1";
|
|
7
|
+
export const PHASE_ENVELOPE_SCHEMA = "hq-phase-envelope/v1";
|
|
8
|
+
export const PHASE_HANDOFF_SCHEMA = "hq-phase-handoff/v1";
|
|
9
|
+
export const LOOP_ENVELOPE_LIMIT = 1024 * 1024;
|
|
10
|
+
export function loopAdmissionPath(state, hasPendingResume, initialAdmission) {
|
|
11
|
+
if (initialAdmission)
|
|
12
|
+
return "initial";
|
|
13
|
+
if (state === "queued" && hasPendingResume)
|
|
14
|
+
return "deferred";
|
|
15
|
+
return "resume";
|
|
16
|
+
}
|
|
17
|
+
function isRecord(value) {
|
|
18
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
function nonEmpty(value) {
|
|
21
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
22
|
+
}
|
|
23
|
+
function validateDeadline(envelope) {
|
|
24
|
+
if (envelope.deadline_seconds !== undefined &&
|
|
25
|
+
(typeof envelope.deadline_seconds !== "number" || !Number.isFinite(envelope.deadline_seconds) || envelope.deadline_seconds <= 0)) {
|
|
26
|
+
throw new Error("deadline_seconds must be a positive finite number");
|
|
27
|
+
}
|
|
28
|
+
if (envelope.deadline !== undefined &&
|
|
29
|
+
(typeof envelope.deadline !== "string" || !Number.isFinite(Date.parse(envelope.deadline)))) {
|
|
30
|
+
throw new Error("deadline must be an ISO date-time");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Validate both hq-cli phase envelopes and the conduct protocol §8 shape. */
|
|
34
|
+
export function validateLoopEnvelope(value) {
|
|
35
|
+
if (!isRecord(value))
|
|
36
|
+
throw new Error("envelope must be a JSON object");
|
|
37
|
+
if (value.kind === "stop")
|
|
38
|
+
return value;
|
|
39
|
+
if (value.schema === PHASE_ENVELOPE_SCHEMA) {
|
|
40
|
+
if (!nonEmpty(value.story_id) || !nonEmpty(value.phase) || !nonEmpty(value.worker_id) || !nonEmpty(value.worktree)) {
|
|
41
|
+
throw new Error("hq-phase-envelope/v1 requires story_id, phase, worker_id, and worktree");
|
|
42
|
+
}
|
|
43
|
+
if (!path.isAbsolute(value.worktree))
|
|
44
|
+
throw new Error("worktree must be an absolute path");
|
|
45
|
+
if (!(value.incoming_handoff === null || nonEmpty(value.incoming_handoff)))
|
|
46
|
+
throw new Error("incoming_handoff must be a path or null");
|
|
47
|
+
if (!Array.isArray(value.acceptance_criteria) || !value.acceptance_criteria.every((item) => typeof item === "string"))
|
|
48
|
+
throw new Error("acceptance_criteria must be an array of strings");
|
|
49
|
+
if (typeof value.fresh_call !== "boolean")
|
|
50
|
+
throw new Error("fresh_call must be a boolean");
|
|
51
|
+
if (value.engine !== undefined && !["claude", "codex", "grok"].includes(String(value.engine))) {
|
|
52
|
+
throw new Error("engine must be claude, codex, or grok");
|
|
53
|
+
}
|
|
54
|
+
if (value.result_path !== undefined && !nonEmpty(value.result_path))
|
|
55
|
+
throw new Error("result_path must be a non-empty path");
|
|
56
|
+
validateDeadline(value);
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
if (value.kind !== "phase")
|
|
60
|
+
throw new Error('envelope kind must be "phase" or "stop"');
|
|
61
|
+
if (!nonEmpty(value.prompt))
|
|
62
|
+
throw new Error("phase envelope requires a non-empty prompt");
|
|
63
|
+
if (value.engine !== undefined && !["claude", "codex", "grok"].includes(String(value.engine))) {
|
|
64
|
+
throw new Error("engine must be claude, codex, or grok");
|
|
65
|
+
}
|
|
66
|
+
if (value.result_path !== undefined && !nonEmpty(value.result_path))
|
|
67
|
+
throw new Error("result_path must be a non-empty path");
|
|
68
|
+
validateDeadline(value);
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
export function validateSection8Handoff(value, envelope) {
|
|
72
|
+
if (!isRecord(value) || value.schema !== PHASE_HANDOFF_SCHEMA)
|
|
73
|
+
throw new Error("result must use schema hq-phase-handoff/v1");
|
|
74
|
+
for (const field of ["story_id", "phase", "worker_id"]) {
|
|
75
|
+
if (!nonEmpty(value[field]))
|
|
76
|
+
throw new Error(`handoff requires non-empty ${field}`);
|
|
77
|
+
}
|
|
78
|
+
for (const field of ["summary", "context_for_next"]) {
|
|
79
|
+
if (typeof value[field] !== "string")
|
|
80
|
+
throw new Error(`handoff requires string ${field}`);
|
|
81
|
+
}
|
|
82
|
+
if (value.story_id !== envelope.story_id || value.phase !== envelope.phase) {
|
|
83
|
+
throw new Error("handoff story_id and phase must match the queued phase envelope");
|
|
84
|
+
}
|
|
85
|
+
if (!(value.status === "passed" || value.status === "failed" || value.status === "blocked")) {
|
|
86
|
+
throw new Error("handoff status must be passed, failed, or blocked");
|
|
87
|
+
}
|
|
88
|
+
if (!Array.isArray(value.files_changed) || !value.files_changed.every((item) => typeof item === "string"))
|
|
89
|
+
throw new Error("handoff files_changed must be an array of strings");
|
|
90
|
+
if (!Array.isArray(value.commits) || !value.commits.every((item) => typeof item === "string"))
|
|
91
|
+
throw new Error("handoff commits must be an array of strings");
|
|
92
|
+
if (!isRecord(value.back_pressure))
|
|
93
|
+
throw new Error("handoff back_pressure must be an object");
|
|
94
|
+
for (const gate of ["tests", "lint", "typecheck", "build"]) {
|
|
95
|
+
if (!(value.back_pressure[gate] === "pass" || value.back_pressure[gate] === "fail" || value.back_pressure[gate] === "skip")) {
|
|
96
|
+
throw new Error(`handoff back_pressure.${gate} must be pass, fail, or skip`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
function atomicJson(file, value) {
|
|
102
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
103
|
+
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
104
|
+
try {
|
|
105
|
+
fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
106
|
+
fs.renameSync(temporary, file);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
try {
|
|
110
|
+
fs.unlinkSync(temporary);
|
|
111
|
+
}
|
|
112
|
+
catch (cleanupError) {
|
|
113
|
+
if (cleanupError.code !== "ENOENT") {
|
|
114
|
+
throw new AggregateError([error, cleanupError], "Could not clean up an atomic JSON write");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function readJson(file) {
|
|
121
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
122
|
+
}
|
|
123
|
+
function pendingDir(runDir) { return path.join(runDir, "loop", "pending"); }
|
|
124
|
+
function activeDir(runDir) { return path.join(runDir, "loop", "active"); }
|
|
125
|
+
function claimedDir(runDir) { return path.join(runDir, "loop", "claimed"); }
|
|
126
|
+
function resultsDir(runDir) { return path.join(runDir, "loop", "results"); }
|
|
127
|
+
function loopPath(runDir) { return path.join(runDir, "loop", "loop.json"); }
|
|
128
|
+
function sessionsPath(runDir) { return path.join(runDir, "loop", "sessions.json"); }
|
|
129
|
+
function acquireEnqueueLock(runDir) {
|
|
130
|
+
const root = path.join(runDir, "loop");
|
|
131
|
+
fs.mkdirSync(root, { recursive: true });
|
|
132
|
+
const lock = path.join(root, "enqueue.lock");
|
|
133
|
+
const sequenceFile = path.join(root, "enqueue-sequence.json");
|
|
134
|
+
const deadline = Date.now() + 5_000;
|
|
135
|
+
for (;;) {
|
|
136
|
+
try {
|
|
137
|
+
fs.mkdirSync(lock);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
if (error.code !== "EEXIST")
|
|
142
|
+
throw error;
|
|
143
|
+
try {
|
|
144
|
+
if (Date.now() - fs.statSync(lock).mtimeMs > 30_000) {
|
|
145
|
+
// Rename is the ownership claim: only one stale-lock contender can
|
|
146
|
+
// move this directory. A plain rm followed by mkdir lets two
|
|
147
|
+
// contenders both believe they reclaimed it.
|
|
148
|
+
const stale = `${lock}.stale-${process.pid}-${randomUUID()}`;
|
|
149
|
+
try {
|
|
150
|
+
fs.renameSync(lock, stale);
|
|
151
|
+
fs.rmSync(stale, { recursive: true, force: true });
|
|
152
|
+
}
|
|
153
|
+
catch (renameError) {
|
|
154
|
+
if (renameError.code !== "ENOENT")
|
|
155
|
+
throw renameError;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch (statError) {
|
|
160
|
+
if (statError.code !== "ENOENT")
|
|
161
|
+
throw statError;
|
|
162
|
+
}
|
|
163
|
+
if (Date.now() >= deadline)
|
|
164
|
+
throw new Error("Timed out acquiring the loop enqueue lock");
|
|
165
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return () => fs.rmSync(lock, { recursive: true, force: true });
|
|
169
|
+
}
|
|
170
|
+
function nextQueueSequence(runDir) {
|
|
171
|
+
const root = path.join(runDir, "loop");
|
|
172
|
+
const sequenceFile = path.join(root, "enqueue-sequence.json");
|
|
173
|
+
let sequence = 0;
|
|
174
|
+
try {
|
|
175
|
+
const prior = readJson(sequenceFile);
|
|
176
|
+
if (isRecord(prior) && Number.isSafeInteger(prior.sequence) && Number(prior.sequence) >= 0)
|
|
177
|
+
sequence = Number(prior.sequence);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (error.code !== "ENOENT" && !(error instanceof SyntaxError))
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
sequence += 1;
|
|
184
|
+
atomicJson(sequenceFile, { sequence });
|
|
185
|
+
return sequence;
|
|
186
|
+
}
|
|
187
|
+
function acquireRunnerLease(runDir) {
|
|
188
|
+
const lock = path.join(runDir, "loop", "runner.lock");
|
|
189
|
+
fs.mkdirSync(path.dirname(lock), { recursive: true });
|
|
190
|
+
try {
|
|
191
|
+
fs.mkdirSync(lock);
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
if (error.code !== "EEXIST")
|
|
195
|
+
throw error;
|
|
196
|
+
let pid;
|
|
197
|
+
try {
|
|
198
|
+
const owner = readJson(path.join(lock, "owner.json"));
|
|
199
|
+
if (isRecord(owner) && typeof owner.pid === "number")
|
|
200
|
+
pid = owner.pid;
|
|
201
|
+
}
|
|
202
|
+
catch (readError) {
|
|
203
|
+
if (readError.code !== "ENOENT" && !(readError instanceof SyntaxError))
|
|
204
|
+
throw readError;
|
|
205
|
+
}
|
|
206
|
+
let alive = false;
|
|
207
|
+
if (pid !== undefined && Number.isSafeInteger(pid) && pid > 0) {
|
|
208
|
+
try {
|
|
209
|
+
process.kill(pid, 0);
|
|
210
|
+
alive = true;
|
|
211
|
+
}
|
|
212
|
+
catch (probeError) {
|
|
213
|
+
alive = probeError.code === "EPERM";
|
|
214
|
+
if (!alive && probeError.code !== "ESRCH")
|
|
215
|
+
throw probeError;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const age = Date.now() - fs.statSync(lock).mtimeMs;
|
|
219
|
+
if (alive || age <= 60_000)
|
|
220
|
+
throw new Error(`Loop runner is already active${pid ? ` (pid ${pid})` : ""}`);
|
|
221
|
+
const stale = `${lock}.stale-${process.pid}-${randomUUID()}`;
|
|
222
|
+
try {
|
|
223
|
+
fs.renameSync(lock, stale);
|
|
224
|
+
}
|
|
225
|
+
catch (renameError) {
|
|
226
|
+
if (renameError.code === "ENOENT") {
|
|
227
|
+
throw new Error("Loop runner lease changed while reclaiming it");
|
|
228
|
+
}
|
|
229
|
+
throw renameError;
|
|
230
|
+
}
|
|
231
|
+
fs.rmSync(stale, { recursive: true, force: true });
|
|
232
|
+
try {
|
|
233
|
+
fs.mkdirSync(lock);
|
|
234
|
+
}
|
|
235
|
+
catch (mkdirError) {
|
|
236
|
+
if (mkdirError.code === "EEXIST") {
|
|
237
|
+
throw new Error("Loop runner lease was claimed by another controller");
|
|
238
|
+
}
|
|
239
|
+
throw mkdirError;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const ownerFile = path.join(lock, "owner.json");
|
|
243
|
+
let released = false;
|
|
244
|
+
const release = () => {
|
|
245
|
+
if (released)
|
|
246
|
+
return;
|
|
247
|
+
released = true;
|
|
248
|
+
clearInterval(heartbeat);
|
|
249
|
+
try {
|
|
250
|
+
fs.rmSync(lock, { recursive: true, force: true });
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
if (error.code !== "ENOENT")
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
const heartbeat = setInterval(() => {
|
|
258
|
+
try {
|
|
259
|
+
atomicJson(ownerFile, { pid: process.pid, heartbeat_at: new Date().toISOString() });
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
process.stderr.write(`hq lanes: loop lease heartbeat failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
263
|
+
}
|
|
264
|
+
}, 15_000);
|
|
265
|
+
atomicJson(ownerFile, { pid: process.pid, heartbeat_at: new Date().toISOString() });
|
|
266
|
+
process.once("exit", release);
|
|
267
|
+
fs.mkdirSync(pendingDir(runDir), { recursive: true });
|
|
268
|
+
for (const active of envelopeFiles(activeDir(runDir))) {
|
|
269
|
+
const recovered = path.join(pendingDir(runDir), `0000000000000-recovered-${path.basename(active)}`);
|
|
270
|
+
try {
|
|
271
|
+
fs.renameSync(active, recovered);
|
|
272
|
+
}
|
|
273
|
+
catch (recoveryError) {
|
|
274
|
+
if (recoveryError.code !== "ENOENT") {
|
|
275
|
+
release();
|
|
276
|
+
throw recoveryError;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return release;
|
|
281
|
+
}
|
|
282
|
+
function envelopeFiles(dir) {
|
|
283
|
+
try {
|
|
284
|
+
return fs.readdirSync(dir).filter((name) => !name.startsWith(".")).sort()
|
|
285
|
+
.map((name) => path.join(dir, name));
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
if (error.code === "ENOENT")
|
|
289
|
+
return [];
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function fileEnvelope(file) {
|
|
294
|
+
const value = readJson(file);
|
|
295
|
+
if (!isRecord(value) || value.schema_version !== LOOP_SCHEMA || !nonEmpty(value.envelope_id) || !nonEmpty(value.queued_at)) {
|
|
296
|
+
throw new Error(`Invalid queued loop envelope record: ${file}`);
|
|
297
|
+
}
|
|
298
|
+
return { ...value, envelope: validateLoopEnvelope(value.envelope) };
|
|
299
|
+
}
|
|
300
|
+
/** Queue depth and FIFO ordering are based on the timestamp-prefixed filename. */
|
|
301
|
+
export function listLoopQueue(runDir) {
|
|
302
|
+
return envelopeFiles(pendingDir(runDir)).map(fileEnvelope);
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Copy and enqueue atomically. Unique timestamp/ULID-like names establish FIFO;
|
|
306
|
+
* temporary files are ignored by claimers until rename completes.
|
|
307
|
+
*/
|
|
308
|
+
export function enqueueLoopEnvelope(input) {
|
|
309
|
+
const envelope = validateLoopEnvelope(input.envelope);
|
|
310
|
+
const serialized = JSON.stringify(envelope);
|
|
311
|
+
if (Buffer.byteLength(serialized, "utf8") > LOOP_ENVELOPE_LIMIT)
|
|
312
|
+
throw new Error("envelope exceeds the 1 MiB limit");
|
|
313
|
+
const now = (input.now ?? (() => new Date()))();
|
|
314
|
+
const queuedAt = now.toISOString();
|
|
315
|
+
const envelopeId = nonEmpty(envelope.id)
|
|
316
|
+
? String(envelope.id)
|
|
317
|
+
: randomUUID();
|
|
318
|
+
const dir = pendingDir(input.runDir);
|
|
319
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
320
|
+
const release = acquireEnqueueLock(input.runDir);
|
|
321
|
+
try {
|
|
322
|
+
const records = [pendingDir(input.runDir), activeDir(input.runDir), claimedDir(input.runDir), path.join(input.runDir, "loop", "stalled"), path.join(input.runDir, "loop", "withdrawn")]
|
|
323
|
+
.flatMap(envelopeFiles)
|
|
324
|
+
.map((file) => {
|
|
325
|
+
try {
|
|
326
|
+
return fileEnvelope(file);
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
process.stderr.write(`hq lanes: ignored unreadable loop record ${file}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
.filter((record) => record !== undefined);
|
|
334
|
+
if (records.some((record) => record.envelope_id === envelopeId) || fs.existsSync(path.join(resultsDir(input.runDir), resultName(envelopeId)))) {
|
|
335
|
+
throw new Error(`envelope id ${envelopeId} was already used in this loop lane`);
|
|
336
|
+
}
|
|
337
|
+
const incomingStop = envelope.kind === "stop";
|
|
338
|
+
if (records.some((record) => record.envelope.kind === "stop")) {
|
|
339
|
+
throw new Error(incomingStop ? "a stop envelope is already queued or active" : "cannot enqueue a phase after a stop envelope");
|
|
340
|
+
}
|
|
341
|
+
const sequence = nextQueueSequence(input.runDir);
|
|
342
|
+
const prefix = `${String(sequence).padStart(16, "0")}-${String(now.getTime()).padStart(13, "0")}-${randomUUID()}`;
|
|
343
|
+
const destination = path.join(dir, `${prefix}-${envelopeId.replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
|
|
344
|
+
const temporary = `${destination}.${process.pid}.tmp`;
|
|
345
|
+
const record = { schema_version: LOOP_SCHEMA, envelope_id: envelopeId, queued_at: queuedAt, envelope };
|
|
346
|
+
try {
|
|
347
|
+
fs.writeFileSync(temporary, `${JSON.stringify(record)}\n`, { flag: "wx", mode: 0o600 });
|
|
348
|
+
fs.renameSync(temporary, destination);
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
try {
|
|
352
|
+
fs.unlinkSync(temporary);
|
|
353
|
+
}
|
|
354
|
+
catch (cleanupError) {
|
|
355
|
+
if (cleanupError.code !== "ENOENT") {
|
|
356
|
+
throw new AggregateError([error, cleanupError], "Could not clean up an enqueued envelope");
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
const position = envelopeFiles(dir).indexOf(destination) + 1;
|
|
362
|
+
return { lane_id: input.laneId, envelope_id: envelopeId, position, queue_depth: envelopeFiles(dir).length, queued_at: queuedAt };
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
release();
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/** Atomically claims exactly the FIFO head. The active rename is the claim. */
|
|
369
|
+
export function claimNextLoopEnvelope(runDir) {
|
|
370
|
+
fs.mkdirSync(activeDir(runDir), { recursive: true });
|
|
371
|
+
fs.mkdirSync(claimedDir(runDir), { recursive: true });
|
|
372
|
+
for (const pending of envelopeFiles(pendingDir(runDir))) {
|
|
373
|
+
const active = path.join(activeDir(runDir), path.basename(pending));
|
|
374
|
+
try {
|
|
375
|
+
fs.renameSync(pending, active);
|
|
376
|
+
return { file: active, record: fileEnvelope(active) };
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
const code = error.code;
|
|
380
|
+
if (code === "ENOENT" || code === "EEXIST")
|
|
381
|
+
continue;
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
function resultName(id) { return `${id.replace(/[^A-Za-z0-9._-]/g, "_")}.json`; }
|
|
388
|
+
/** Withdraw all unclaimed envelopes; active work remains claimed by the worker. */
|
|
389
|
+
export function interruptLoopQueue(runDir) {
|
|
390
|
+
const withdrawn = [];
|
|
391
|
+
const alreadyPickedUp = envelopeFiles(activeDir(runDir)).map((file) => fileEnvelope(file).envelope_id);
|
|
392
|
+
const withdrawnDir = path.join(runDir, "loop", "withdrawn");
|
|
393
|
+
fs.mkdirSync(withdrawnDir, { recursive: true });
|
|
394
|
+
for (const file of envelopeFiles(pendingDir(runDir))) {
|
|
395
|
+
const record = fileEnvelope(file);
|
|
396
|
+
try {
|
|
397
|
+
fs.renameSync(file, path.join(withdrawnDir, path.basename(file)));
|
|
398
|
+
withdrawn.push(record.envelope_id);
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
if (error.code !== "ENOENT")
|
|
402
|
+
throw error;
|
|
403
|
+
alreadyPickedUp.push(record.envelope_id);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return { withdrawn, already_picked_up: alreadyPickedUp };
|
|
407
|
+
}
|
|
408
|
+
export function loopStatus(runDir, laneId) {
|
|
409
|
+
const loop = fs.existsSync(loopPath(runDir)) && isRecord(readJson(loopPath(runDir))) ? readJson(loopPath(runDir)) : {};
|
|
410
|
+
const active = envelopeFiles(activeDir(runDir)).map(fileEnvelope)[0];
|
|
411
|
+
const queueDepth = envelopeFiles(pendingDir(runDir)).length;
|
|
412
|
+
const stallCounts = isRecord(loop.stall_counts)
|
|
413
|
+
? Object.fromEntries(Object.entries(loop.stall_counts).filter((entry) => typeof entry[1] === "number"))
|
|
414
|
+
: {};
|
|
415
|
+
const processState = loop.state === "running" || loop.state === "parked" || loop.state === "stopped" || loop.state === "failed" ? loop.state : "waiting";
|
|
416
|
+
return {
|
|
417
|
+
mode: "loop", lane_id: laneId, state: processState, queue_depth: queueDepth,
|
|
418
|
+
active_envelope_id: active?.envelope_id ?? null,
|
|
419
|
+
active_phase: active ? phaseName(active.envelope, active.envelope_id) : null,
|
|
420
|
+
stall_counts: stallCounts,
|
|
421
|
+
...(typeof loop.pid === "number" ? { pid: loop.pid } : {}),
|
|
422
|
+
updated_at: typeof loop.updated_at === "string" ? loop.updated_at : new Date(0).toISOString(),
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
export function setLoopControllerPid(runDir, laneId, pid) {
|
|
426
|
+
persistStatus(runDir, laneId, { pid });
|
|
427
|
+
}
|
|
428
|
+
export function clearLoopControllerPid(runDir, laneId, pid) {
|
|
429
|
+
let currentPid;
|
|
430
|
+
try {
|
|
431
|
+
const status = readJson(loopPath(runDir));
|
|
432
|
+
if (isRecord(status))
|
|
433
|
+
currentPid = status.pid;
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
if (error.code !== "ENOENT" && !(error instanceof SyntaxError))
|
|
437
|
+
throw error;
|
|
438
|
+
}
|
|
439
|
+
if (currentPid === pid)
|
|
440
|
+
persistStatus(runDir, laneId, { pid: undefined });
|
|
441
|
+
}
|
|
442
|
+
export function loopControllerIsAlive(runDir) {
|
|
443
|
+
let pid;
|
|
444
|
+
try {
|
|
445
|
+
const status = readJson(loopPath(runDir));
|
|
446
|
+
if (isRecord(status))
|
|
447
|
+
pid = status.pid;
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
if (error.code !== "ENOENT" && !(error instanceof SyntaxError))
|
|
451
|
+
throw error;
|
|
452
|
+
}
|
|
453
|
+
if (typeof pid !== "number") {
|
|
454
|
+
try {
|
|
455
|
+
const owner = readJson(path.join(runDir, "loop", "runner.lock", "owner.json"));
|
|
456
|
+
if (isRecord(owner))
|
|
457
|
+
pid = owner.pid;
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
if (error.code !== "ENOENT" && !(error instanceof SyntaxError))
|
|
461
|
+
throw error;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0)
|
|
465
|
+
return false;
|
|
466
|
+
try {
|
|
467
|
+
process.kill(pid, 0);
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
if (error.code === "EPERM")
|
|
472
|
+
return true;
|
|
473
|
+
if (error.code === "ESRCH")
|
|
474
|
+
return false;
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
function phaseName(envelope, fallback) {
|
|
479
|
+
const value = envelope;
|
|
480
|
+
return String(value.phase ?? value.label ?? value.id ?? fallback);
|
|
481
|
+
}
|
|
482
|
+
function envelopeStory(envelope) {
|
|
483
|
+
const value = envelope;
|
|
484
|
+
return String(value.story_id ?? "");
|
|
485
|
+
}
|
|
486
|
+
function envelopeProvider(envelope, fallback) {
|
|
487
|
+
const provider = envelope.engine;
|
|
488
|
+
return provider === "claude" || provider === "codex" || provider === "grok" ? provider : fallback;
|
|
489
|
+
}
|
|
490
|
+
function phaseDeadlineBudget(envelope, defaultSeconds) {
|
|
491
|
+
const value = envelope;
|
|
492
|
+
if (typeof value.deadline_seconds === "number")
|
|
493
|
+
return value.deadline_seconds;
|
|
494
|
+
const configured = Number(process.env.HQ_LANE_LOOP_PHASE_DEADLINE_SECS);
|
|
495
|
+
return Number.isFinite(configured) && configured > 0 ? configured : defaultSeconds;
|
|
496
|
+
}
|
|
497
|
+
function persistStatus(runDir, laneId, patch) {
|
|
498
|
+
let current = {};
|
|
499
|
+
try {
|
|
500
|
+
const value = readJson(loopPath(runDir));
|
|
501
|
+
if (isRecord(value))
|
|
502
|
+
current = value;
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
if (error.code !== "ENOENT" && !(error instanceof SyntaxError))
|
|
506
|
+
throw error;
|
|
507
|
+
}
|
|
508
|
+
atomicJson(loopPath(runDir), {
|
|
509
|
+
...current, ...patch, schema_version: LOOP_SCHEMA, mode: "loop", lane_id: laneId,
|
|
510
|
+
updated_at: new Date().toISOString(),
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
function abortAfter(ms, parent) {
|
|
514
|
+
const controller = new AbortController();
|
|
515
|
+
const timer = setTimeout(() => controller.abort(new Error("phase deadline exceeded")), Math.min(Math.max(ms, 1), 2_147_483_647));
|
|
516
|
+
const onAbort = () => controller.abort(parent?.reason);
|
|
517
|
+
parent?.addEventListener("abort", onAbort, { once: true });
|
|
518
|
+
return { signal: controller.signal, clear: () => { clearTimeout(timer); parent?.removeEventListener("abort", onAbort); } };
|
|
519
|
+
}
|
|
520
|
+
/** Run queued phases serially. Sessions are isolated by story and provider. */
|
|
521
|
+
export async function runLoopLane(options) {
|
|
522
|
+
const now = options.now ?? (() => new Date());
|
|
523
|
+
const defaultDeadlineSeconds = options.defaultDeadlineSeconds ?? 3600;
|
|
524
|
+
const sessions = new Map();
|
|
525
|
+
try {
|
|
526
|
+
const storedSessions = readJson(sessionsPath(options.runDir));
|
|
527
|
+
if (isRecord(storedSessions)) {
|
|
528
|
+
for (const [key, sessionId] of Object.entries(storedSessions)) {
|
|
529
|
+
if (typeof sessionId === "string" && sessionId.trim())
|
|
530
|
+
sessions.set(key, sessionId);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
if (error.code !== "ENOENT" && !(error instanceof SyntaxError))
|
|
536
|
+
throw error;
|
|
537
|
+
}
|
|
538
|
+
const firstDeadline = new Map();
|
|
539
|
+
const stalls = new Map();
|
|
540
|
+
let completed = 0;
|
|
541
|
+
fs.mkdirSync(options.runDir, { recursive: true });
|
|
542
|
+
const releaseRunner = acquireRunnerLease(options.runDir);
|
|
543
|
+
persistStatus(options.runDir, options.lane.lane_id, { pid: process.pid });
|
|
544
|
+
try {
|
|
545
|
+
const previous = readJson(loopPath(options.runDir));
|
|
546
|
+
if (isRecord(previous) && isRecord(previous.stall_counts)) {
|
|
547
|
+
for (const [id, count] of Object.entries(previous.stall_counts)) {
|
|
548
|
+
if (typeof count === "number" && Number.isSafeInteger(count) && count > 0)
|
|
549
|
+
stalls.set(id, count);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
if (error.code !== "ENOENT" && !(error instanceof SyntaxError))
|
|
555
|
+
throw error;
|
|
556
|
+
}
|
|
557
|
+
persistStatus(options.runDir, options.lane.lane_id, { state: "waiting", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null, stall_counts: {} });
|
|
558
|
+
while (!options.signal?.aborted) {
|
|
559
|
+
const claimed = claimNextLoopEnvelope(options.runDir);
|
|
560
|
+
if (!claimed) {
|
|
561
|
+
await new Promise((resolve) => setTimeout(resolve, options.idlePollMs ?? 200));
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
const { file, record } = claimed;
|
|
565
|
+
Object.assign(record, { __active_dir: activeDir(options.runDir) });
|
|
566
|
+
const envelope = record.envelope;
|
|
567
|
+
if (envelope.kind === "stop") {
|
|
568
|
+
const stopped = { schema_version: LOOP_SCHEMA, envelope_id: record.envelope_id, status: "stopped", started_at: record.queued_at, finished_at: now().toISOString() };
|
|
569
|
+
writeResultAt(options.runDir, file, record, stopped);
|
|
570
|
+
persistStatus(options.runDir, options.lane.lane_id, { state: "stopped", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null, stall_counts: Object.fromEntries(stalls) });
|
|
571
|
+
releaseRunner();
|
|
572
|
+
return { state: "stopped", completed };
|
|
573
|
+
}
|
|
574
|
+
const provider = envelopeProvider(envelope, options.lane.provider);
|
|
575
|
+
const story = envelopeStory(envelope);
|
|
576
|
+
const sessionKey = `${story}\0${provider}`;
|
|
577
|
+
const fresh = envelope.fresh === true || envelope.fresh_call === true;
|
|
578
|
+
const previousSession = sessions.get(sessionKey);
|
|
579
|
+
const startedAt = now().toISOString();
|
|
580
|
+
const startedAtMs = Date.parse(startedAt);
|
|
581
|
+
let deadlineAtMs = record.deadline_at_ms;
|
|
582
|
+
if (deadlineAtMs === undefined) {
|
|
583
|
+
const absoluteDeadline = envelope.deadline;
|
|
584
|
+
if (typeof absoluteDeadline === "string")
|
|
585
|
+
deadlineAtMs = Date.parse(absoluteDeadline);
|
|
586
|
+
else {
|
|
587
|
+
const budget = record.deadline_budget_seconds ?? firstDeadline.get(record.envelope_id) ?? phaseDeadlineBudget(envelope, defaultDeadlineSeconds);
|
|
588
|
+
firstDeadline.set(record.envelope_id, budget);
|
|
589
|
+
record.deadline_budget_seconds = budget;
|
|
590
|
+
deadlineAtMs = startedAtMs + budget * 1000;
|
|
591
|
+
}
|
|
592
|
+
record.deadline_at_ms = deadlineAtMs;
|
|
593
|
+
}
|
|
594
|
+
atomicJson(file, record);
|
|
595
|
+
persistStatus(options.runDir, options.lane.lane_id, { state: "running", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: record.envelope_id, active_phase: phaseName(envelope, record.envelope_id), stall_counts: Object.fromEntries(stalls) });
|
|
596
|
+
const timer = abortAfter(deadlineAtMs - startedAtMs, options.signal);
|
|
597
|
+
try {
|
|
598
|
+
let result;
|
|
599
|
+
let fallback = false;
|
|
600
|
+
try {
|
|
601
|
+
const resumable = options.canResume?.(provider) ?? providerSupportsSessionResume(provider);
|
|
602
|
+
result = await options.execute({ lane: options.lane, envelope, envelopeId: record.envelope_id, signal: timer.signal, fresh: fresh || previousSession === undefined || !resumable, ...(fresh || previousSession === undefined || !resumable ? {} : { sessionId: previousSession }) });
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
const resumable = options.canResume?.(provider) ?? providerSupportsSessionResume(provider);
|
|
606
|
+
if (!fresh && previousSession && resumable && !timer.signal.aborted) {
|
|
607
|
+
fallback = true;
|
|
608
|
+
result = await options.execute({ lane: options.lane, envelope, envelopeId: record.envelope_id, signal: timer.signal, fresh: true });
|
|
609
|
+
}
|
|
610
|
+
else
|
|
611
|
+
throw error;
|
|
612
|
+
}
|
|
613
|
+
if (timer.signal.aborted)
|
|
614
|
+
throw timer.signal.reason;
|
|
615
|
+
const finishedAt = now().toISOString();
|
|
616
|
+
const success = {
|
|
617
|
+
schema_version: LOOP_SCHEMA, envelope_id: record.envelope_id, status: "done", started_at: startedAt, finished_at: finishedAt,
|
|
618
|
+
output: result.output, ...(result.sessionId ? { session_id: result.sessionId } : {}),
|
|
619
|
+
...(previousSession && !fresh && !fallback ? { resumed: true } : { resumed: false }),
|
|
620
|
+
...(fallback ? { fresh_fallback: true } : {}),
|
|
621
|
+
};
|
|
622
|
+
if (envelope.schema === PHASE_ENVELOPE_SCHEMA) {
|
|
623
|
+
let handoff;
|
|
624
|
+
try {
|
|
625
|
+
handoff = JSON.parse(result.output);
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
throw new Error("section 8 phase result is not valid JSON");
|
|
629
|
+
}
|
|
630
|
+
const validated = validateSection8Handoff(handoff, envelope);
|
|
631
|
+
if (validated.worker_id !== envelope.worker_id) {
|
|
632
|
+
throw new Error("handoff worker_id must match the queued phase envelope");
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
writeResultAt(options.runDir, file, record, success);
|
|
636
|
+
if (result.sessionId) {
|
|
637
|
+
sessions.set(sessionKey, result.sessionId);
|
|
638
|
+
atomicJson(sessionsPath(options.runDir), Object.fromEntries(sessions));
|
|
639
|
+
}
|
|
640
|
+
stalls.delete(record.envelope_id);
|
|
641
|
+
completed += 1;
|
|
642
|
+
}
|
|
643
|
+
catch (error) {
|
|
644
|
+
if (options.signal?.aborted) {
|
|
645
|
+
timer.clear();
|
|
646
|
+
const pending = path.join(pendingDir(options.runDir), path.basename(file));
|
|
647
|
+
fs.mkdirSync(pendingDir(options.runDir), { recursive: true });
|
|
648
|
+
fs.renameSync(file, pending);
|
|
649
|
+
persistStatus(options.runDir, options.lane.lane_id, { state: "waiting", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null, stall_counts: Object.fromEntries(stalls) });
|
|
650
|
+
releaseRunner();
|
|
651
|
+
return { state: "aborted", completed };
|
|
652
|
+
}
|
|
653
|
+
if (!timer.signal.aborted) {
|
|
654
|
+
const failure = error instanceof Error ? error.message : String(error);
|
|
655
|
+
writeResultAt(options.runDir, file, record, { schema_version: LOOP_SCHEMA, envelope_id: record.envelope_id, status: "failed", started_at: startedAt, finished_at: now().toISOString(), error: failure });
|
|
656
|
+
completed += 1;
|
|
657
|
+
persistStatus(options.runDir, options.lane.lane_id, { state: "waiting", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null, stall_counts: Object.fromEntries(stalls) });
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
const attempt = (stalls.get(record.envelope_id) ?? 0) + 1;
|
|
661
|
+
stalls.set(record.envelope_id, attempt);
|
|
662
|
+
persistStatus(options.runDir, options.lane.lane_id, { state: "running", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: record.envelope_id, active_phase: phaseName(envelope, record.envelope_id), stall_counts: Object.fromEntries(stalls) });
|
|
663
|
+
appendJournal(options.runDir, { event: "phase_stalled", envelope_id: record.envelope_id, phase: phaseName(envelope, record.envelope_id), attempt, at: now().toISOString() });
|
|
664
|
+
if (attempt === 1) {
|
|
665
|
+
timer.clear();
|
|
666
|
+
sessions.delete(sessionKey);
|
|
667
|
+
atomicJson(sessionsPath(options.runDir), Object.fromEntries(sessions));
|
|
668
|
+
// Restore the same envelope at the FIFO head. Rename preserves a single claim.
|
|
669
|
+
const head = path.join(pendingDir(options.runDir), `0000000000000-retry-${path.basename(file)}`);
|
|
670
|
+
fs.mkdirSync(pendingDir(options.runDir), { recursive: true });
|
|
671
|
+
fs.renameSync(file, head);
|
|
672
|
+
persistStatus(options.runDir, options.lane.lane_id, { state: "waiting", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null, stall_counts: Object.fromEntries(stalls) });
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
676
|
+
const result = { schema_version: LOOP_SCHEMA, envelope_id: record.envelope_id, status: "stalled", started_at: startedAt, finished_at: now().toISOString(), error: message };
|
|
677
|
+
writeResultAt(options.runDir, file, record, result);
|
|
678
|
+
await options.raiseQuestion?.({ envelope, envelopeId: record.envelope_id, attempt, error: message });
|
|
679
|
+
completed += 1;
|
|
680
|
+
persistStatus(options.runDir, options.lane.lane_id, { state: "parked", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null, stall_counts: Object.fromEntries(stalls) });
|
|
681
|
+
releaseRunner();
|
|
682
|
+
return { state: "parked", completed };
|
|
683
|
+
}
|
|
684
|
+
finally {
|
|
685
|
+
timer.clear();
|
|
686
|
+
}
|
|
687
|
+
persistStatus(options.runDir, options.lane.lane_id, { state: "waiting", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null, stall_counts: Object.fromEntries(stalls) });
|
|
688
|
+
}
|
|
689
|
+
releaseRunner();
|
|
690
|
+
return { state: "aborted", completed };
|
|
691
|
+
}
|
|
692
|
+
function appendJournal(runDir, value) {
|
|
693
|
+
const file = path.join(runDir, "loop", "stalls.jsonl");
|
|
694
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
695
|
+
fs.appendFileSync(file, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
|
696
|
+
}
|
|
697
|
+
function writeResultAt(runDir, activeFile, record, result) {
|
|
698
|
+
const localPath = path.join(resultsDir(runDir), resultName(record.envelope_id));
|
|
699
|
+
atomicJson(localPath, result);
|
|
700
|
+
const envelope = record.envelope;
|
|
701
|
+
const externalPath = envelope.result_path;
|
|
702
|
+
if (typeof externalPath === "string" && externalPath.trim()) {
|
|
703
|
+
try {
|
|
704
|
+
if (envelope.schema === PHASE_ENVELOPE_SCHEMA) {
|
|
705
|
+
if (result.status === "done") {
|
|
706
|
+
const parsed = JSON.parse(result.output ?? "null");
|
|
707
|
+
atomicJson(path.resolve(externalPath), validateSection8Handoff(parsed, envelope));
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
const stalled = result.status === "stalled";
|
|
711
|
+
const failure = {
|
|
712
|
+
schema: PHASE_HANDOFF_SCHEMA,
|
|
713
|
+
story_id: String(envelope.story_id), phase: String(envelope.phase),
|
|
714
|
+
worker_id: String(envelope.worker_id), status: stalled ? "blocked" : "failed",
|
|
715
|
+
summary: stalled ? "Phase stalled twice and needs operator direction." : "Phase execution failed.",
|
|
716
|
+
files_changed: [], commits: [],
|
|
717
|
+
back_pressure: { tests: "skip", lint: "skip", typecheck: "skip", build: "skip" },
|
|
718
|
+
context_for_next: result.error ?? result.status,
|
|
719
|
+
};
|
|
720
|
+
atomicJson(path.resolve(externalPath), failure);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
else
|
|
724
|
+
atomicJson(path.resolve(externalPath), result);
|
|
725
|
+
}
|
|
726
|
+
catch (error) {
|
|
727
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
728
|
+
const recorded = {
|
|
729
|
+
...result,
|
|
730
|
+
...(result.status === "done" ? { status: "failed" } : {}),
|
|
731
|
+
error: [result.error, `result_path_error: ${message}`].filter(Boolean).join("; "),
|
|
732
|
+
};
|
|
733
|
+
atomicJson(localPath, recorded);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
const claim = path.join(claimedDir(runDir), path.basename(activeFile));
|
|
737
|
+
const destination = result.status === "stalled"
|
|
738
|
+
? path.join(runDir, "loop", "stalled", path.basename(activeFile))
|
|
739
|
+
: claim;
|
|
740
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
741
|
+
fs.renameSync(activeFile, destination);
|
|
742
|
+
}
|
|
743
|
+
//# sourceMappingURL=loop.js.map
|