@a2f0/agent-tool 0.1.44 → 0.1.45

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a2f0/agent-tool",
3
- "version": "0.1.44",
3
+ "version": "0.1.45",
4
4
  "packageManager": "bun@1.4.4",
5
5
  "type": "module",
6
6
  "description": "Portable cross-agent review, PR workflows, and shared skills",
@@ -1,8 +1,9 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, writeFileSync } from "node:fs";
4
+ import { constants } from "node:os";
4
5
  import path from "node:path";
5
- import { runCommand, type RunReceipt } from "./run";
6
+ import { FORWARDED_SIGNALS, runCommand, type RunReceipt } from "./run";
6
7
  import { assert, git, head, oid, option, output } from "../workflows/core";
7
8
  import { gitEnvironment } from "../git/environment";
8
9
 
@@ -60,6 +61,7 @@ export type GroupEvent =
60
61
  | { type: "stepStarted"; index: number }
61
62
  | { type: "stepFinished"; index: number; receipt: RunReceipt }
62
63
  | { type: "failed"; error: string }
64
+ | { type: "interrupted"; signal: NodeJS.Signals }
63
65
  | { type: "finished"; passed: boolean; endedAt: string; durationMs: number }
64
66
  | { type: "settled"; after: SourceState }
65
67
  | { type: "settleFailed"; error: string };
@@ -69,7 +71,11 @@ export type GroupEvent =
69
71
  * ignored: a step starts only from pending, finishes only while running, and
70
72
  * nothing but the final source read changes a finished group. A failure while
71
73
  * a step runs (its runner could not start) finishes that step as failed with
72
- * no receipt, so a finished report never shows a step still running.
74
+ * no receipt, so a finished report never shows a step still running. An
75
+ * interruption counts only while the group runs: it records its signal as the
76
+ * error (unless one is already recorded), lets the running step finish with its
77
+ * receipt, and keeps the group from passing. A finished group ignores it, so
78
+ * its report and exit status stand.
73
79
  */
74
80
  export function groupTransition(record: GroupRecord, event: GroupEvent): GroupRecord {
75
81
  const step = (index: number, next: GroupStep) => ({ ...record, steps: record.steps.map((item, at) => at === index ? next : item) });
@@ -85,8 +91,10 @@ export function groupTransition(record: GroupRecord, event: GroupEvent): GroupRe
85
91
  case "failed":
86
92
  if (record.status !== "running") return record;
87
93
  return { ...record, steps: record.steps.map(item => item.status === "running" ? { label: item.label, status: "failed", receipt: null } : item), error: event.error };
94
+ case "interrupted":
95
+ return record.status === "running" && record.error === null ? { ...record, error: `Interrupted by ${event.signal}.` } : record;
88
96
  case "finished":
89
- return record.status === "running" ? { ...record, status: event.passed ? "passed" : "failed", endedAt: event.endedAt, durationMs: event.durationMs } : record;
97
+ return record.status === "running" ? { ...record, status: event.passed && record.error === null ? "passed" : "failed", endedAt: event.endedAt, durationMs: event.durationMs } : record;
90
98
  case "settled":
91
99
  return record.status !== "running" && record.after === null ? { ...record, after: event.after } : record;
92
100
  case "settleFailed":
@@ -108,21 +116,53 @@ export async function runGroup(args: string[]): Promise<number> {
108
116
  const started = performance.now();
109
117
  let record: GroupRecord = { schemaVersion: 1, id, label: plan.label, startedAt: new Date().toISOString(), endedAt: null, durationMs: 0, status: "running", before, after: null, steps: plan.steps.map(step => ({ label: step.label, status: "pending", receipt: null })), error: null };
110
118
  const send = (event: GroupEvent) => { record = groupTransition(record, event); };
111
- save(report, record);
119
+ // A signal must not end the process before the report is finished: the step's runner forwards it
120
+ // to the step and returns, the group stops, and the signal is raised again once the report is saved.
121
+ // Only a running group is interrupted; a signal handled after it finished changes nothing.
122
+ const interrupt: { signal: NodeJS.Signals | null } = { signal: null };
123
+ const handlers = FORWARDED_SIGNALS.map(value => [value, () => {
124
+ if (record.status !== "running") return;
125
+ interrupt.signal ??= value;
126
+ send({ type: "interrupted", signal: value });
127
+ }] as const);
128
+ for (const [value, handler] of handlers) process.on(value, handler);
129
+ // Handlers run only between tasks, and a signal still queued when they are removed is lost.
130
+ // Fingerprinting and saving are synchronous, so yield to let a pending signal apply first.
131
+ const handleSignals = () => new Promise(resolve => setImmediate(resolve));
112
132
  let code = 0;
113
133
  try {
114
- for (const [index, step] of plan.steps.entries()) {
115
- send({ type: "stepStarted", index }); save(report, record);
116
- code = await runCommand(["--label", step.label, "--", ...step.command], receipt => { send({ type: "stepFinished", index, receipt }); save(report, record); }, { quiet: true, reraiseChildSignal: false });
117
- const after = sourceState();
118
- assert(JSON.stringify(after) === JSON.stringify(before), "Source state changed during validation; results do not establish the current checkout.");
119
- if (code !== 0) break;
120
- }
121
- } catch (error) { code = 1; send({ type: "failed", error: (error as Error).message }); }
122
- send({ type: "finished", passed: code === 0, endedAt: new Date().toISOString(), durationMs: Math.round(performance.now() - started) });
123
- try { send({ type: "settled", after: sourceState() }); } catch (error) { code = 1; send({ type: "settleFailed", error: (error as Error).message }); }
124
- save(report, record);
125
- output({ ...record, report }); return code;
134
+ save(report, record);
135
+ try {
136
+ for (const [index, step] of plan.steps.entries()) {
137
+ await handleSignals();
138
+ if (interrupt.signal) break;
139
+ send({ type: "stepStarted", index }); save(report, record);
140
+ code = await runCommand(["--label", step.label, "--", ...step.command], receipt => { send({ type: "stepFinished", index, receipt }); save(report, record); }, { quiet: true, reraiseChildSignal: false, reraiseInterrupt: false });
141
+ const after = sourceState();
142
+ assert(JSON.stringify(after) === JSON.stringify(before), "Source state changed during validation; results do not establish the current checkout.");
143
+ if (code !== 0) break;
144
+ }
145
+ } catch (error) { code = 1; send({ type: "failed", error: (error as Error).message }); }
146
+ const endedAt = new Date().toISOString(), durationMs = Math.round(performance.now() - started);
147
+ let settle: GroupEvent;
148
+ try { settle = { type: "settled", after: sourceState() }; } catch (error) { code = 1; settle = { type: "settleFailed", error: (error as Error).message }; }
149
+ await handleSignals();
150
+ send({ type: "finished", passed: code === 0, endedAt, durationMs }); send(settle);
151
+ save(report, record);
152
+ output({ ...record, report });
153
+ // Signals that arrived while publishing reach the handlers, which ignore them now.
154
+ await handleSignals();
155
+ } finally {
156
+ for (const [value, handler] of handlers) process.removeListener(value, handler);
157
+ }
158
+ const { signal } = interrupt;
159
+ if (signal) {
160
+ // Preserve signal termination for calling shells and supervisors, as `run` does.
161
+ process.kill(process.pid, signal);
162
+ await new Promise(resolve => setTimeout(resolve, 100));
163
+ return 128 + (constants.signals[signal] ?? 0);
164
+ }
165
+ return code;
126
166
  }
127
167
 
128
168
  export function listRuns(args: string[]): number {
@@ -4,12 +4,16 @@ import { constants } from "node:os";
4
4
  import { DEFAULT_LIMITS, outputLimit, plainText, printLogTail } from "./output";
5
5
  import { createLog } from "./logFile";
6
6
 
7
- const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
7
+ export const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
8
8
 
9
9
  export interface RunReceipt { status: string; label?: string; exitCode: number; signal: string | null; durationMs: number; outputBytes: number; log: string; leftoverProcessGroup?: number }
10
10
 
11
- /** Run directly into a private file: child output never accumulates in memory. */
12
- export async function runCommand(args: string[], record?: (receipt: RunReceipt) => void, options: { quiet?: boolean; reraiseChildSignal?: boolean } = {}): Promise<number> {
11
+ /**
12
+ * Run directly into a private file: child output never accumulates in memory.
13
+ * A caller that must record the interruption before exiting passes
14
+ * `reraiseInterrupt: false` and re-raises the signal itself.
15
+ */
16
+ export async function runCommand(args: string[], record?: (receipt: RunReceipt) => void, options: { quiet?: boolean; reraiseChildSignal?: boolean; reraiseInterrupt?: boolean } = {}): Promise<number> {
13
17
  const limits = { ...DEFAULT_LIMITS };
14
18
  let label: string | undefined;
15
19
  while (args[0] !== "--" && args.length) {
@@ -99,7 +103,7 @@ export async function runCommand(args: string[], record?: (receipt: RunReceipt)
99
103
  }
100
104
  // Preserve signal termination for calling shells/supervisors, after the receipt.
101
105
  if (signal) {
102
- if (interrupted || options.reraiseChildSignal !== false) {
106
+ if (interrupted ? options.reraiseInterrupt !== false : options.reraiseChildSignal !== false) {
103
107
  process.kill(process.pid, signal);
104
108
  // Let the runtime deliver its signal before main sets process.exitCode
105
109
  // can turn signal termination into an ordinary numeric exit under load.