@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 +1 -1
- package/src/commands/group.ts +56 -16
- package/src/commands/run.ts +8 -4
package/package.json
CHANGED
package/src/commands/group.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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 {
|
package/src/commands/run.ts
CHANGED
|
@@ -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
|
-
/**
|
|
12
|
-
|
|
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
|
|
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.
|