@andromarces/agent-loops 0.2.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/src/role.mjs ADDED
@@ -0,0 +1,661 @@
1
+ // `agent-loop role`: run one worker or reviewer turn, or finish/abort a run,
2
+ // through the same guards as the headless loop, driven by a lifecycle state
3
+ // file instead of an in-process orchestrator. Stdout carries exactly one JSON
4
+ // envelope per invocation; all logs go to stderr.
5
+ import { readFile, appendFile, rename } from "node:fs/promises";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { defaultAgents, normalizeAgent, supportedAgents } from "./agents/index.mjs";
8
+ import {
9
+ assertOpenCodeOptions,
10
+ readArgValue,
11
+ readNonNegativeInt,
12
+ readPositiveInt,
13
+ } from "./lib/args.mjs";
14
+ import { logInfo, setVerbose, setLogsToStderr } from "./lib/log.mjs";
15
+ import { parseReportBlock, parseVerdict } from "./lib/report.mjs";
16
+ import {
17
+ TERMINAL_LIFECYCLES,
18
+ appendSessionIndex,
19
+ readState,
20
+ statePaths,
21
+ withStateLock,
22
+ writeState,
23
+ } from "./lib/runstate.mjs";
24
+ import { assertGitWorkTree } from "./lib/snapshot.mjs";
25
+ import { runChild } from "./runtime.mjs";
26
+ import { validateAction } from "./contracts/orchestrator-action.mjs";
27
+
28
+ const OPERATIONS = new Set(["dispatch", "finish", "abort"]);
29
+ const MODES = new Set(["work-first", "review-first", "review-only"]);
30
+ const ROLE_NAMES = new Set(["worker", "reviewer"]);
31
+ const DEFAULT_MAX_STEPS = 20;
32
+ const DEFAULT_TIMEOUT = 3600;
33
+ // Bound for `raw` in the envelope when the closing block could not be parsed.
34
+ const RAW_TAIL_LIMIT = 2000;
35
+
36
+ class RoleError extends Error {}
37
+
38
+ const INIT_FIELDS = ["task", "mode", "parentSession", "maxSteps", "timeout"];
39
+
40
+ /**
41
+ * Parses `agent-loop role [dispatch|finish|abort] [flags]`. Reuses the flag
42
+ * reading rules and OpenCode validation of the headless CLI. Semantics that
43
+ * depend on the state file (init detection, change rejection) are checked at
44
+ * execution time, not parse time.
45
+ */
46
+ export function parseRoleArgs(argv) {
47
+ const args = {
48
+ operation: "dispatch",
49
+ cwd: process.cwd(),
50
+ role: null,
51
+ task: null,
52
+ mode: null,
53
+ parentSession: null,
54
+ worker: null,
55
+ workerModel: null,
56
+ workerEffort: null,
57
+ reviewer: null,
58
+ reviewerModel: null,
59
+ reviewerEffort: null,
60
+ maxSteps: null,
61
+ timeout: null,
62
+ promptFile: null,
63
+ transcript: null,
64
+ resumeInterrupted: false,
65
+ reason: null,
66
+ verbose: false,
67
+ timeoutProvided: false,
68
+ };
69
+
70
+ let index = 0;
71
+ if (argv.length > 0 && OPERATIONS.has(argv[0])) {
72
+ args.operation = argv[0];
73
+ index = 1;
74
+ }
75
+
76
+ const readValue = (flag, i) => readArgValue(argv, flag, i);
77
+
78
+ for (; index < argv.length; index++) {
79
+ const arg = argv[index];
80
+
81
+ switch (arg) {
82
+ case "--role":
83
+ args.role = readValue(arg, ++index);
84
+ if (!ROLE_NAMES.has(args.role)) {
85
+ throw new RoleError(`--role must be worker or reviewer, got: ${args.role}`);
86
+ }
87
+ break;
88
+
89
+ case "--cwd":
90
+ args.cwd = resolve(readValue(arg, ++index));
91
+ break;
92
+
93
+ case "--task":
94
+ args.task = readValue(arg, ++index);
95
+ break;
96
+
97
+ case "--mode":
98
+ args.mode = readValue(arg, ++index);
99
+ if (!MODES.has(args.mode)) {
100
+ throw new RoleError(
101
+ `--mode must be one of work-first, review-first, review-only, got: ${args.mode}`,
102
+ );
103
+ }
104
+ break;
105
+
106
+ case "--parent-session":
107
+ args.parentSession = readValue(arg, ++index);
108
+ break;
109
+
110
+ case "--worker":
111
+ args.worker = readValue(arg, ++index);
112
+ break;
113
+ case "--worker-model":
114
+ args.workerModel = readValue(arg, ++index);
115
+ break;
116
+ case "--worker-effort":
117
+ args.workerEffort = readValue(arg, ++index);
118
+ break;
119
+
120
+ case "--reviewer":
121
+ args.reviewer = readValue(arg, ++index);
122
+ break;
123
+ case "--reviewer-model":
124
+ args.reviewerModel = readValue(arg, ++index);
125
+ break;
126
+ case "--reviewer-effort":
127
+ args.reviewerEffort = readValue(arg, ++index);
128
+ break;
129
+
130
+ case "--max-steps":
131
+ args.maxSteps = readPositiveInt(arg, readValue(arg, ++index));
132
+ break;
133
+
134
+ case "--timeout": {
135
+ const seconds = readNonNegativeInt(arg, readValue(arg, ++index));
136
+ args.timeout = seconds === 0 ? null : seconds;
137
+ args.timeoutProvided = true;
138
+ break;
139
+ }
140
+
141
+ case "--prompt-file":
142
+ args.promptFile = resolve(readValue(arg, ++index));
143
+ break;
144
+
145
+ case "--transcript":
146
+ args.transcript = resolve(readValue(arg, ++index));
147
+ break;
148
+
149
+ case "--resume-interrupted":
150
+ args.resumeInterrupted = true;
151
+ break;
152
+
153
+ case "--reason":
154
+ args.reason = readValue(arg, ++index);
155
+ break;
156
+
157
+ case "--verbose":
158
+ args.verbose = true;
159
+ break;
160
+
161
+ default:
162
+ throw new RoleError(`Unknown argument: ${arg}`);
163
+ }
164
+ }
165
+
166
+ return args;
167
+ }
168
+
169
+ /**
170
+ * Init detection is keyed on `--task` alone: the first call carries the task;
171
+ * later calls read the configuration from the state file, so a provided flag
172
+ * that matches the state passes through and a changed one is rejected.
173
+ */
174
+ function isInitCall(args) {
175
+ return args.task !== null;
176
+ }
177
+
178
+ /** Loads state for a non-init call, rejecting a missing state file. */
179
+ async function loadExistingState(paths, cwd) {
180
+ const state = await readState(paths.stateFile);
181
+ if (!state) {
182
+ throw new RoleError(
183
+ `No run state for ${cwd}. Start one with: agent-loop role --task "..." --worker ... --reviewer ...`,
184
+ );
185
+ }
186
+ return state;
187
+ }
188
+
189
+ function validateInitFlags(args, agents = {}) {
190
+ if (args.task === null || String(args.task).trim() === "") {
191
+ throw new RoleError('Init requires --task, for example --task "Implement the change."');
192
+ }
193
+ // review-only never dispatches the worker, so --worker is optional there.
194
+ const requiredRoles =
195
+ (args.mode ?? "work-first") === "review-only" ? ["reviewer"] : ["worker", "reviewer"];
196
+ for (const roleName of requiredRoles) {
197
+ if (args[roleName] === null) {
198
+ throw new RoleError(`Missing required --${roleName}.`);
199
+ }
200
+ }
201
+ // Every supplied role is still validated; review-only may omit the worker.
202
+ // A model or effort without its role kind is an orphan option, not a silent drop.
203
+ for (const roleName of ["worker", "reviewer"]) {
204
+ const kind = args[roleName];
205
+ if (kind === null) {
206
+ for (const flag of [`${roleName}Model`, `${roleName}Effort`]) {
207
+ if (args[flag] !== null) {
208
+ throw new RoleError(`--${kebab(flag)} requires --${roleName}.`);
209
+ }
210
+ }
211
+ continue;
212
+ }
213
+ if (!supportedAgents.has(kind) && !agents[normalizeAgent(kind)]) {
214
+ throw new RoleError(`Unsupported ${roleName}: ${kind}`);
215
+ }
216
+ assertOpenCodeOptions(roleName, kind, args[`${roleName}Model`], args[`${roleName}Effort`]);
217
+ }
218
+ }
219
+
220
+ function initialState(args) {
221
+ return {
222
+ task: args.task,
223
+ mode: args.mode ?? "work-first",
224
+ cwd: args.cwd,
225
+ parentSession: args.parentSession,
226
+ maxSteps: args.maxSteps ?? DEFAULT_MAX_STEPS,
227
+ timeout: args.timeoutProvided ? args.timeout : DEFAULT_TIMEOUT,
228
+ stepsUsed: 0,
229
+ lifecycle: "active",
230
+ roles: {
231
+ worker: roleState(args, "worker"),
232
+ reviewer: roleState(args, "reviewer"),
233
+ },
234
+ lastDispatch: null,
235
+ lastResult: null,
236
+ };
237
+ }
238
+
239
+ /** Role state, or null when the role was not configured at init. */
240
+ function roleState(source, roleName) {
241
+ const kind = source[roleName];
242
+ if (kind === null) {
243
+ return null;
244
+ }
245
+ return {
246
+ kind: normalizeAgent(kind),
247
+ model: source[`${roleName}Model`] ?? null,
248
+ effort: source[`${roleName}Effort`] ?? null,
249
+ sessionId: null,
250
+ };
251
+ }
252
+
253
+ /**
254
+ * New-run rule: init over a terminal or absent state file archives any
255
+ * existing file as `state.<timestamp>.json` and creates a new state; init over
256
+ * a non-terminal lifecycle is rejected and the parent must abort it first.
257
+ */
258
+ async function initRun(args, paths, existing, agents) {
259
+ validateInitFlags(args, agents);
260
+
261
+ if (existing) {
262
+ if (!TERMINAL_LIFECYCLES.has(existing.lifecycle)) {
263
+ throw new RoleError(
264
+ `Existing run is ${existing.lifecycle}; abort it before starting a new run.`,
265
+ );
266
+ }
267
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
268
+ const archived = join(dirname(paths.stateFile), `state.${stamp}.json`);
269
+ await rename(paths.stateFile, archived);
270
+ logInfo(`archived terminal state file to ${archived}`);
271
+ }
272
+
273
+ const state = initialState(args);
274
+ await writeState(paths.stateFile, state);
275
+ if (args.parentSession) {
276
+ await appendSessionIndex(paths.sessionIndexFile, paths.stateFile);
277
+ }
278
+ logInfo(`initialized run state (mode: ${state.mode}, maxSteps: ${state.maxSteps})`);
279
+ return state;
280
+ }
281
+
282
+ const INIT_COMPARATORS = {
283
+ task: (state) => state.task,
284
+ mode: (state) => state.mode,
285
+ parentSession: (state) => state.parentSession,
286
+ maxSteps: (state) => state.maxSteps,
287
+ timeout: (state) => state.timeout,
288
+ };
289
+
290
+ /** Later calls read configuration from the state file and reject any change. */
291
+ function rejectInitFlagChanges(args, state) {
292
+ const provided = [];
293
+ for (const flag of INIT_FIELDS) {
294
+ const isGiven = flag === "timeout" ? args.timeoutProvided : args[flag] !== null;
295
+ if (isGiven) {
296
+ provided.push([flag, args[flag], INIT_COMPARATORS[flag](state)]);
297
+ }
298
+ }
299
+ for (const roleName of ["worker", "reviewer"]) {
300
+ for (const [flag, path] of [
301
+ [roleName, "kind"],
302
+ [`${roleName}Model`, "model"],
303
+ [`${roleName}Effort`, "effort"],
304
+ ]) {
305
+ const value = args[flag];
306
+ if (value !== null) {
307
+ // Only the role kind is normalized (`antigravity` -> `agy`); model and
308
+ // effort are opaque pass-through strings compared verbatim. A role the
309
+ // init left unset (null) compares as null, so any supplied value is a
310
+ // change.
311
+ const comparable = flag === roleName ? normalizeAgent(value) : value;
312
+ provided.push([flag, comparable, state.roles[roleName]?.[path] ?? null]);
313
+ }
314
+ }
315
+ }
316
+ for (const [flag, value, existing] of provided) {
317
+ if (value !== existing) {
318
+ throw new RoleError(
319
+ `--${kebab(flag)} cannot be changed after init (state holds: ${JSON.stringify(existing ?? null)}).`,
320
+ );
321
+ }
322
+ }
323
+ }
324
+
325
+ function kebab(name) {
326
+ return name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
327
+ }
328
+
329
+ async function readPrompt(args, stdin) {
330
+ const text = args.promptFile ? await readFile(args.promptFile, "utf8") : await stdin(args);
331
+ if (text.trim() === "") {
332
+ throw new RoleError(
333
+ args.promptFile
334
+ ? `Prompt file is empty: ${args.promptFile}`
335
+ : "Prompt on stdin is empty. Pipe a prompt or use --prompt-file.",
336
+ );
337
+ }
338
+ return text;
339
+ }
340
+
341
+ function readStdin() {
342
+ return new Promise((resolveText, reject) => {
343
+ if (process.stdin.isTTY) {
344
+ reject(new RoleError("No prompt received on stdin. Pipe a prompt or use --prompt-file."));
345
+ return;
346
+ }
347
+ let data = "";
348
+ process.stdin.setEncoding("utf8");
349
+ process.stdin.on("data", (chunk) => {
350
+ data += chunk;
351
+ });
352
+ process.stdin.on("end", () => resolveText(data));
353
+ process.stdin.on("error", reject);
354
+ });
355
+ }
356
+
357
+ function createEventSink(transcriptFile) {
358
+ const events = [];
359
+ const onEvent = (event) => {
360
+ if (transcriptFile) {
361
+ // Stamped at emit time, like the headless transcript.
362
+ events.push({ ...event, at: new Date().toISOString() });
363
+ }
364
+ };
365
+ // Appends once per invocation so a process exit cannot lose the events.
366
+ onEvent.flush = async () => {
367
+ if (!transcriptFile || events.length === 0) {
368
+ return;
369
+ }
370
+ const text = events.map((event) => `${JSON.stringify(event)}\n`).join("");
371
+ try {
372
+ await appendFile(transcriptFile, text, "utf8");
373
+ } catch (err) {
374
+ // Transcript failures must not change the command outcome.
375
+ console.error(`Warning: Failed to append transcript to ${transcriptFile}: ${err.message}`);
376
+ }
377
+ events.length = 0;
378
+ };
379
+ return onEvent;
380
+ }
381
+
382
+ /**
383
+ * Dispatch operation: initialize on the first call, then charge the step,
384
+ * mark `dispatched`, run exactly one child turn, and record the result.
385
+ */
386
+ async function dispatch(args, { agents, stdin = readStdin, signal }) {
387
+ if (!args.role) {
388
+ throw new RoleError("dispatch requires --role worker or reviewer.");
389
+ }
390
+ if (args.reason !== null) {
391
+ throw new RoleError("--reason is only valid for abort.");
392
+ }
393
+
394
+ await assertGitWorkTree(args.cwd);
395
+ const paths = statePaths(
396
+ args.parentSession ? { cwd: args.cwd, parentSession: args.parentSession } : { cwd: args.cwd },
397
+ );
398
+ const onEvent = createEventSink(args.transcript);
399
+
400
+ try {
401
+ return await withStateLock(paths.lockFile, () =>
402
+ dispatchLocked(args, { agents, stdin, signal, paths, onEvent }),
403
+ );
404
+ } finally {
405
+ await onEvent.flush();
406
+ }
407
+ }
408
+
409
+ async function dispatchLocked(args, { agents, stdin, signal, paths, onEvent }) {
410
+ let state = await readState(paths.stateFile);
411
+ const init = isInitCall(args);
412
+ if (init) {
413
+ state = await initRun(args, paths, state, agents);
414
+ } else {
415
+ state = await loadExistingState(paths, args.cwd);
416
+ rejectInitFlagChanges(args, state);
417
+ }
418
+
419
+ const roleName = args.role;
420
+
421
+ // Hard guard that survives compaction and restart: review-only never runs the worker.
422
+ if (state.mode === "review-only" && roleName === "worker") {
423
+ throw new RoleError("mode review-only rejects --role worker.");
424
+ }
425
+
426
+ if (TERMINAL_LIFECYCLES.has(state.lifecycle)) {
427
+ throw new RoleError(`Run is ${state.lifecycle}; no further dispatch is possible.`);
428
+ }
429
+
430
+ if (state.lifecycle === "interrupted") {
431
+ if (!args.resumeInterrupted) {
432
+ throw new RoleError(
433
+ "Previous turn is interrupted. Use abort, or dispatch --resume-interrupted to continue.",
434
+ );
435
+ }
436
+ state.resumeDecision = { at: new Date().toISOString() };
437
+ } else if (state.lifecycle === "dispatched") {
438
+ // A live lock owner would have thrown in withStateLock, so the previous
439
+ // turn ended uncertainly. The first call after the crash always marks
440
+ // `interrupted`, exits non-zero, and spawns no child; a maintainer can
441
+ // resume from `interrupted` on a later call.
442
+ state.lifecycle = "interrupted";
443
+ await writeState(paths.stateFile, state);
444
+ throw new RoleError(
445
+ "Previous turn ended uncertainly; state marked interrupted. Use abort, or dispatch --resume-interrupted to continue.",
446
+ );
447
+ }
448
+
449
+ if (state.stepsUsed >= state.maxSteps) {
450
+ throw new RoleError(`Step budget exhausted (${state.stepsUsed}/${state.maxSteps}).`);
451
+ }
452
+
453
+ const prompt = await readPrompt(args, stdin);
454
+
455
+ // Charge the step before execution, matching the headless runtime.
456
+ state.stepsUsed += 1;
457
+ state.lifecycle = "dispatched";
458
+ state.lastDispatch = { role: roleName, prompt, at: new Date().toISOString() };
459
+ await writeState(paths.stateFile, state);
460
+
461
+ const role = { ...state.roles[roleName] };
462
+ let result;
463
+ try {
464
+ result = await runChild({
465
+ agents,
466
+ role,
467
+ roleName,
468
+ prompt,
469
+ cwd: args.cwd,
470
+ timeout: state.timeout,
471
+ signal,
472
+ stepsUsed: state.stepsUsed,
473
+ onEvent,
474
+ });
475
+ } catch (err) {
476
+ const canceled = Boolean(err?.isCanceled);
477
+ state.lifecycle = canceled ? "interrupted" : "halted";
478
+ state.lastResult = {
479
+ role: roleName,
480
+ status: "error",
481
+ error: errorMessage(err),
482
+ at: new Date().toISOString(),
483
+ };
484
+ await writeState(paths.stateFile, state);
485
+ onEvent({
486
+ type: "result",
487
+ role: roleName,
488
+ result: { role: roleName, status: "error", error: errorMessage(err) },
489
+ stepsUsed: state.stepsUsed,
490
+ });
491
+ return {
492
+ exitCode: canceled ? 130 : 1,
493
+ payload: { role: roleName, status: "error", error: errorMessage(err) },
494
+ };
495
+ }
496
+
497
+ state.lifecycle = "active";
498
+ state.lastResult = { ...result, at: new Date().toISOString() };
499
+ if (role.sessionId) {
500
+ state.roles[roleName].sessionId = role.sessionId;
501
+ }
502
+ await writeState(paths.stateFile, state);
503
+
504
+ onEvent({ type: "result", role: roleName, result, stepsUsed: state.stepsUsed });
505
+
506
+ // The dispatch itself succeeded, but a child error result is still a
507
+ // non-zero command outcome for the calling parent.
508
+ return { exitCode: result.status === "ok" ? 0 : 1, payload: dispatchPayload(roleName, result) };
509
+ }
510
+
511
+ function errorMessage(err) {
512
+ return err?.message ?? String(err);
513
+ }
514
+
515
+ function dispatchPayload(roleName, result) {
516
+ if (result.status !== "ok") {
517
+ return { role: roleName, status: "error", error: result.error };
518
+ }
519
+ const report = parseReportBlock(result.response);
520
+ const payload = { role: roleName, status: "ok", report };
521
+ if (roleName === "reviewer") {
522
+ payload.verdict = parseVerdict(result.response);
523
+ }
524
+ if (!report) {
525
+ payload.raw = result.response.slice(-RAW_TAIL_LIMIT);
526
+ }
527
+ return payload;
528
+ }
529
+
530
+ /**
531
+ * `finish`: accepts the five-key summary as JSON on stdin, from active only.
532
+ */
533
+ async function finish(args, { stdin = readStdin }) {
534
+ if (args.role !== null) {
535
+ throw new RoleError("--role is only valid for dispatch.");
536
+ }
537
+
538
+ const paths = statePaths({ cwd: args.cwd });
539
+ return withStateLock(paths.lockFile, async () => {
540
+ const state = await loadExistingState(paths, args.cwd);
541
+ rejectInitFlagChanges(args, state);
542
+ if (TERMINAL_LIFECYCLES.has(state.lifecycle)) {
543
+ throw new RoleError(`Run is already ${state.lifecycle}.`);
544
+ }
545
+ if (state.lifecycle !== "active") {
546
+ throw new RoleError(`finish is accepted only from active; run is ${state.lifecycle}.`);
547
+ }
548
+
549
+ const text = await stdin(args);
550
+ let value;
551
+ try {
552
+ value = JSON.parse(text);
553
+ } catch {
554
+ throw new RoleError("finish summary must be a JSON object on stdin.");
555
+ }
556
+ const validated = validateAction({ action: "finish", summary: value });
557
+ if (!validated.ok) {
558
+ throw new RoleError(validated.error);
559
+ }
560
+
561
+ state.lifecycle = "finished";
562
+ state.summary = validated.value.summary;
563
+ await writeState(paths.stateFile, state);
564
+ logInfo(`run finished (${state.stepsUsed} steps used)`);
565
+ return { exitCode: 0, payload: { status: "ok", lifecycle: "finished" } };
566
+ });
567
+ }
568
+
569
+ /** `abort`: records the reason and ends the run; accepted from any non-terminal lifecycle. */
570
+ async function abort(args) {
571
+ if (args.role !== null) {
572
+ throw new RoleError("--role is only valid for dispatch.");
573
+ }
574
+ if (args.reason === null) {
575
+ throw new RoleError("abort requires --reason.");
576
+ }
577
+
578
+ const paths = statePaths({ cwd: args.cwd });
579
+ return withStateLock(paths.lockFile, async () => {
580
+ const state = await loadExistingState(paths, args.cwd);
581
+ rejectInitFlagChanges(args, state);
582
+ if (TERMINAL_LIFECYCLES.has(state.lifecycle)) {
583
+ throw new RoleError(`Run is already ${state.lifecycle}.`);
584
+ }
585
+ state.lifecycle = "aborted";
586
+ state.reason = args.reason;
587
+ await writeState(paths.stateFile, state);
588
+ logInfo(`run aborted: ${args.reason}`);
589
+ return { exitCode: 0, payload: { status: "ok", lifecycle: "aborted" } };
590
+ });
591
+ }
592
+
593
+ /**
594
+ * Executes one parsed role command. Returns `{ exitCode, payload }`; the
595
+ * payload is the JSON envelope. Unexpected failures become `status: "error"`
596
+ * envelopes instead of stack traces on stdout.
597
+ */
598
+ export async function executeRoleCommand(args, deps = {}) {
599
+ try {
600
+ switch (args.operation) {
601
+ case "dispatch":
602
+ return await dispatch(args, deps);
603
+ case "finish":
604
+ return await finish(args, deps);
605
+ case "abort":
606
+ return await abort(args, deps);
607
+ default:
608
+ throw new RoleError(`Unsupported operation: ${args.operation}`);
609
+ }
610
+ } catch (err) {
611
+ return { exitCode: 1, payload: { status: "error", error: errorMessage(err) } };
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Entry point for `agent-loop role ...`. Prints exactly one JSON envelope on
617
+ * stdout and sets the process exit code. All lifecycle logging goes to stderr.
618
+ */
619
+ export async function main(argv, { agents = defaultAgents } = {}) {
620
+ setLogsToStderr(true);
621
+
622
+ const controller = new AbortController();
623
+ const onSigInt = () => {
624
+ controller.abort();
625
+ };
626
+ process.once("SIGINT", onSigInt);
627
+
628
+ try {
629
+ let args;
630
+ try {
631
+ args = parseRoleArgs(argv);
632
+ setVerbose(args.verbose);
633
+ } catch (err) {
634
+ console.log(JSON.stringify({ status: "error", error: errorMessage(err) }));
635
+ process.exitCode = 1;
636
+ return;
637
+ }
638
+
639
+ let exitCode;
640
+ let payload;
641
+ try {
642
+ ({ exitCode, payload } = await executeRoleCommand(args, {
643
+ agents,
644
+ signal: controller.signal,
645
+ }));
646
+ } catch (err) {
647
+ if (err?.isCanceled) {
648
+ exitCode = 130;
649
+ payload = { status: "error", error: "Interrupted by SIGINT" };
650
+ } else {
651
+ exitCode = 1;
652
+ payload = { status: "error", error: errorMessage(err) };
653
+ }
654
+ }
655
+
656
+ console.log(JSON.stringify(payload));
657
+ process.exitCode = exitCode;
658
+ } finally {
659
+ process.removeListener("SIGINT", onSigInt);
660
+ }
661
+ }