@yagni-app/code-staging 0.3.0-staging.1081.1 → 0.3.0-staging.1082.1
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/dist/cli.js +7 -1
- package/dist/extension/chipEditor.d.ts +9 -0
- package/dist/extension/chipEditor.js +32 -0
- package/dist/extension/pipeline/childRegistry.d.ts +41 -0
- package/dist/extension/pipeline/childRegistry.js +118 -0
- package/dist/extension/pipeline/finish.js +5 -1
- package/dist/extension/pipeline/goCommand.d.ts +1 -1
- package/dist/extension/pipeline/goCommand.js +35 -6
- package/dist/extension/pipeline/goStatusCommands.d.ts +10 -0
- package/dist/extension/pipeline/goStatusCommands.js +61 -1
- package/dist/extension/pipeline/runRegistry.d.ts +14 -0
- package/dist/extension/pipeline/runRegistry.js +35 -0
- package/dist/extension/pipeline/runner.js +4 -0
- package/dist/extension/pipeline/verify.d.ts +4 -0
- package/dist/extension/pipeline/verify.js +48 -26
- package/dist/signalForward.d.ts +60 -0
- package/dist/signalForward.js +130 -0
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -28,6 +28,7 @@ import { runDoctor } from "./doctor.js";
|
|
|
28
28
|
import { installProcessCrashHandlers } from "./crashReport.js";
|
|
29
29
|
import { currentCliVersion, maybeNudgeAndRefresh, upgradeCommand } from "./upgrade.js";
|
|
30
30
|
import { maybeRefreshAtLaunch } from "./refresh.js";
|
|
31
|
+
import { exitCodeFor, installSignalForwarding } from "./signalForward.js";
|
|
31
32
|
import { PAD_X } from "./padding.js";
|
|
32
33
|
import { ensureShadowPiPackage } from "./piPackage.js";
|
|
33
34
|
import { resolveExtensionPath, resolvePiCliPath, resolvePiPackageDir } from "./paths.js";
|
|
@@ -262,7 +263,12 @@ async function runDefault(passthroughArgs) {
|
|
|
262
263
|
stdio: "inherit",
|
|
263
264
|
env,
|
|
264
265
|
});
|
|
265
|
-
|
|
266
|
+
// Forward termination signals to pi instead of dying around it (see
|
|
267
|
+
// signalForward.ts for the policy: first signal graceful, second tree-kill).
|
|
268
|
+
installSignalForwarding(child);
|
|
269
|
+
// 128+n for a signal death (bash parity), so a cancelled/killed run never
|
|
270
|
+
// reads as success to scripts or CI.
|
|
271
|
+
child.on("exit", (code, signal) => resolve(exitCodeFor(code, signal)));
|
|
266
272
|
child.on("error", (err) => {
|
|
267
273
|
process.stderr.write(`Failed to start YAGNI Code: ${err.message}\n`);
|
|
268
274
|
resolve(1);
|
|
@@ -84,6 +84,15 @@ export declare class ChipEditor extends CustomEditor {
|
|
|
84
84
|
private stashed;
|
|
85
85
|
private chipCounter;
|
|
86
86
|
private readImage;
|
|
87
|
+
/**
|
|
88
|
+
* Fired when Ctrl+C lands on an EMPTY prompt. pi's own binding is
|
|
89
|
+
* clear-editor (Esc is the interrupt key), but with nothing typed that is a
|
|
90
|
+
* no-op, and every peer harness (Claude Code, Codex, Gemini, Aider) treats a
|
|
91
|
+
* single Ctrl+C as "stop what's happening". The hook lets the extension wire
|
|
92
|
+
* exactly that; the key still falls through to pi afterwards, so clear and
|
|
93
|
+
* the 500ms double-press exit window are untouched.
|
|
94
|
+
*/
|
|
95
|
+
onEmptyCtrlC?: () => void;
|
|
87
96
|
constructor(tui: TUI, theme: EditorTheme, keybindings: ConstructorParameters<typeof CustomEditor>[2], readImage?: ClipboardImageReader, options?: EditorOptions);
|
|
88
97
|
/**
|
|
89
98
|
* Paste-image must fire on Cmd+V too (Claude Code parity on macOS). Claude
|
|
@@ -29,6 +29,7 @@ import { tmpdir } from "node:os";
|
|
|
29
29
|
import { join, basename, isAbsolute } from "node:path";
|
|
30
30
|
import { randomUUID } from "node:crypto";
|
|
31
31
|
import { logImagePaste } from "./diagnostics.js";
|
|
32
|
+
import { cancelActiveRuns } from "./pipeline/runRegistry.js";
|
|
32
33
|
/** Matches the `[Image #N]` chip token in the editor text. */
|
|
33
34
|
const CHIP_RE = /\[Image #(\d+)\]/g;
|
|
34
35
|
/** Inverse-video wrap so the chip token reads as a chip, not plain text. */
|
|
@@ -285,6 +286,15 @@ export class ChipEditor extends CustomEditor {
|
|
|
285
286
|
stashed = [];
|
|
286
287
|
chipCounter = 0;
|
|
287
288
|
readImage;
|
|
289
|
+
/**
|
|
290
|
+
* Fired when Ctrl+C lands on an EMPTY prompt. pi's own binding is
|
|
291
|
+
* clear-editor (Esc is the interrupt key), but with nothing typed that is a
|
|
292
|
+
* no-op, and every peer harness (Claude Code, Codex, Gemini, Aider) treats a
|
|
293
|
+
* single Ctrl+C as "stop what's happening". The hook lets the extension wire
|
|
294
|
+
* exactly that; the key still falls through to pi afterwards, so clear and
|
|
295
|
+
* the 500ms double-press exit window are untouched.
|
|
296
|
+
*/
|
|
297
|
+
onEmptyCtrlC;
|
|
288
298
|
constructor(tui, theme, keybindings, readImage = defaultClipboardImageReader, options) {
|
|
289
299
|
super(tui, theme, keybindings, options);
|
|
290
300
|
this.readImage = readImage;
|
|
@@ -333,6 +343,14 @@ export class ChipEditor extends CustomEditor {
|
|
|
333
343
|
this.onPasteImage?.();
|
|
334
344
|
return;
|
|
335
345
|
}
|
|
346
|
+
// Ctrl+C on an empty prompt: fire the stop hook, then STILL hand the key to
|
|
347
|
+
// pi so its clear + double-press-exit tracking behave exactly as before.
|
|
348
|
+
if (matchesKey(data, "ctrl+c")) {
|
|
349
|
+
if (this.getText().length === 0)
|
|
350
|
+
this.onEmptyCtrlC?.();
|
|
351
|
+
super.handleInput(data);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
336
354
|
super.handleInput(data);
|
|
337
355
|
}
|
|
338
356
|
async pasteChip() {
|
|
@@ -428,6 +446,20 @@ export function registerChipEditor(pi) {
|
|
|
428
446
|
return;
|
|
429
447
|
ctx.ui.setEditorComponent((tui, theme, keybindings) => {
|
|
430
448
|
const editor = new ChipEditor(tui, theme, keybindings);
|
|
449
|
+
// Single Ctrl+C on an empty prompt = "stop what's happening" (peer-harness
|
|
450
|
+
// parity; pi's clear binding is a no-op with nothing typed): abort a
|
|
451
|
+
// streaming turn and cancel every in-flight /go run. pi still sees the
|
|
452
|
+
// key afterwards, so Ctrl+C twice within 500ms exits as always.
|
|
453
|
+
editor.onEmptyCtrlC = () => {
|
|
454
|
+
if (!ctx.isIdle())
|
|
455
|
+
ctx.abort();
|
|
456
|
+
const cancelled = cancelActiveRuns();
|
|
457
|
+
if (cancelled.length > 0) {
|
|
458
|
+
ctx.ui.notify(`Stopping ${cancelled.length} /go run${cancelled.length === 1 ? "" : "s"}: ${cancelled
|
|
459
|
+
.map((r) => `[${r.runId.slice(0, 8)}] ${r.ticket}`)
|
|
460
|
+
.join(", ")}. Work so far lands as WIP commits (see /go-status).`, "warning");
|
|
461
|
+
}
|
|
462
|
+
};
|
|
431
463
|
liveChipEditors.add(new WeakRef(editor));
|
|
432
464
|
return editor;
|
|
433
465
|
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live child-process registry + exit sweep (the "no orphaned workers" invariant).
|
|
3
|
+
*
|
|
4
|
+
* /go stage children and verify children are spawned with raw `node:child_process`
|
|
5
|
+
* and piped stdio, so nothing in pi tracks them: pi's own shutdown paths
|
|
6
|
+
* (`/exit`, double Ctrl+C, SIGTERM/SIGHUP) call `process.exit(0)` and would
|
|
7
|
+
* orphan them into the background, still editing the repo and burning tokens
|
|
8
|
+
* until their wall-clock timeout fires.
|
|
9
|
+
*
|
|
10
|
+
* Every long-lived child is registered here instead. A single `process.on("exit")`
|
|
11
|
+
* hook (installed lazily on first track) kills whatever is still alive —
|
|
12
|
+
* `"exit"` handlers must be synchronous, and the whole sweep (including the
|
|
13
|
+
* `ps`-based descendant walk) is, so this holds on every exit path short of the
|
|
14
|
+
* pi process itself being SIGKILLed.
|
|
15
|
+
*
|
|
16
|
+
* Kills are TREE kills: a verify child like `pnpm test` fans out its own
|
|
17
|
+
* grandchildren, and killing only the direct pid would orphan those instead.
|
|
18
|
+
*/
|
|
19
|
+
import { type ChildProcess } from "node:child_process";
|
|
20
|
+
/**
|
|
21
|
+
* PURE: given `ps -A -o pid=,ppid=` output lines, collect every descendant of
|
|
22
|
+
* the given roots (children, grandchildren, ...), breadth-first.
|
|
23
|
+
*/
|
|
24
|
+
export declare function descendantsOf(roots: number[], psLines: string[]): number[];
|
|
25
|
+
/**
|
|
26
|
+
* Synchronously SIGKILL a process AND its descendants. POSIX walks a `ps`
|
|
27
|
+
* snapshot (the tree-kill pattern); Windows has real tree kill via
|
|
28
|
+
* `taskkill /T /F`. Throw-proof and synchronous, so it is exit-hook safe.
|
|
29
|
+
* Fail-soft: with no readable snapshot the direct pid is still killed.
|
|
30
|
+
*/
|
|
31
|
+
export declare function killTreeSync(pid: number): void;
|
|
32
|
+
/**
|
|
33
|
+
* Register a spawned child so the exit sweep covers it. Deregisters itself on
|
|
34
|
+
* the child's real `close`/`error`, so the set only ever holds live processes.
|
|
35
|
+
*/
|
|
36
|
+
export declare function trackChild(proc: ChildProcess): void;
|
|
37
|
+
/** Test seam: how many children are currently registered. */
|
|
38
|
+
export declare function _liveChildCountForTest(): number;
|
|
39
|
+
/** Test seam: run the sweep as the exit hook would. */
|
|
40
|
+
export declare function _sweepForTest(): void;
|
|
41
|
+
//# sourceMappingURL=childRegistry.d.ts.map
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live child-process registry + exit sweep (the "no orphaned workers" invariant).
|
|
3
|
+
*
|
|
4
|
+
* /go stage children and verify children are spawned with raw `node:child_process`
|
|
5
|
+
* and piped stdio, so nothing in pi tracks them: pi's own shutdown paths
|
|
6
|
+
* (`/exit`, double Ctrl+C, SIGTERM/SIGHUP) call `process.exit(0)` and would
|
|
7
|
+
* orphan them into the background, still editing the repo and burning tokens
|
|
8
|
+
* until their wall-clock timeout fires.
|
|
9
|
+
*
|
|
10
|
+
* Every long-lived child is registered here instead. A single `process.on("exit")`
|
|
11
|
+
* hook (installed lazily on first track) kills whatever is still alive —
|
|
12
|
+
* `"exit"` handlers must be synchronous, and the whole sweep (including the
|
|
13
|
+
* `ps`-based descendant walk) is, so this holds on every exit path short of the
|
|
14
|
+
* pi process itself being SIGKILLed.
|
|
15
|
+
*
|
|
16
|
+
* Kills are TREE kills: a verify child like `pnpm test` fans out its own
|
|
17
|
+
* grandchildren, and killing only the direct pid would orphan those instead.
|
|
18
|
+
*/
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
const live = new Set();
|
|
21
|
+
let sweepInstalled = false;
|
|
22
|
+
/**
|
|
23
|
+
* PURE: given `ps -A -o pid=,ppid=` output lines, collect every descendant of
|
|
24
|
+
* the given roots (children, grandchildren, ...), breadth-first.
|
|
25
|
+
*/
|
|
26
|
+
export function descendantsOf(roots, psLines) {
|
|
27
|
+
const childrenByParent = new Map();
|
|
28
|
+
for (const line of psLines) {
|
|
29
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)$/);
|
|
30
|
+
if (!m)
|
|
31
|
+
continue;
|
|
32
|
+
const pid = Number(m[1]);
|
|
33
|
+
const ppid = Number(m[2]);
|
|
34
|
+
const list = childrenByParent.get(ppid) ?? [];
|
|
35
|
+
list.push(pid);
|
|
36
|
+
childrenByParent.set(ppid, list);
|
|
37
|
+
}
|
|
38
|
+
const found = [];
|
|
39
|
+
const queue = [...roots];
|
|
40
|
+
while (queue.length > 0) {
|
|
41
|
+
const next = queue.shift();
|
|
42
|
+
for (const child of childrenByParent.get(next) ?? []) {
|
|
43
|
+
found.push(child);
|
|
44
|
+
queue.push(child);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return found;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Synchronously SIGKILL a process AND its descendants. POSIX walks a `ps`
|
|
51
|
+
* snapshot (the tree-kill pattern); Windows has real tree kill via
|
|
52
|
+
* `taskkill /T /F`. Throw-proof and synchronous, so it is exit-hook safe.
|
|
53
|
+
* Fail-soft: with no readable snapshot the direct pid is still killed.
|
|
54
|
+
*/
|
|
55
|
+
export function killTreeSync(pid) {
|
|
56
|
+
if (process.platform === "win32") {
|
|
57
|
+
try {
|
|
58
|
+
spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* best effort */
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
let descendants = [];
|
|
66
|
+
try {
|
|
67
|
+
const ps = spawnSync("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8" });
|
|
68
|
+
if (ps.status === 0 && typeof ps.stdout === "string") {
|
|
69
|
+
descendants = descendantsOf([pid], ps.stdout.split("\n"));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
/* no snapshot: fall through to the direct kill */
|
|
74
|
+
}
|
|
75
|
+
// Root first so it cannot spawn replacements for the descendants we listed.
|
|
76
|
+
for (const target of [pid, ...descendants]) {
|
|
77
|
+
try {
|
|
78
|
+
process.kill(target, "SIGKILL");
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* already dead */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Kill every still-registered child's tree. Synchronous and throw-proof (exit-hook safe). */
|
|
86
|
+
function sweep() {
|
|
87
|
+
for (const proc of live) {
|
|
88
|
+
// pid guard: a child whose spawn FAILED has no pid; kill() would resolve
|
|
89
|
+
// that to pid 0 (the whole process group) and take the parent down too.
|
|
90
|
+
if (!proc.pid)
|
|
91
|
+
continue;
|
|
92
|
+
killTreeSync(proc.pid);
|
|
93
|
+
}
|
|
94
|
+
live.clear();
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Register a spawned child so the exit sweep covers it. Deregisters itself on
|
|
98
|
+
* the child's real `close`/`error`, so the set only ever holds live processes.
|
|
99
|
+
*/
|
|
100
|
+
export function trackChild(proc) {
|
|
101
|
+
if (!sweepInstalled) {
|
|
102
|
+
sweepInstalled = true;
|
|
103
|
+
process.on("exit", sweep);
|
|
104
|
+
}
|
|
105
|
+
live.add(proc);
|
|
106
|
+
const drop = () => live.delete(proc);
|
|
107
|
+
proc.once("close", drop);
|
|
108
|
+
proc.once("error", drop);
|
|
109
|
+
}
|
|
110
|
+
/** Test seam: how many children are currently registered. */
|
|
111
|
+
export function _liveChildCountForTest() {
|
|
112
|
+
return live.size;
|
|
113
|
+
}
|
|
114
|
+
/** Test seam: run the sweep as the exit hook would. */
|
|
115
|
+
export function _sweepForTest() {
|
|
116
|
+
sweep();
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=childRegistry.js.map
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* does not apply.
|
|
29
29
|
*/
|
|
30
30
|
import { execFile } from "node:child_process";
|
|
31
|
+
import { trackChild } from "./childRegistry.js";
|
|
31
32
|
import { scrubSecrets } from "./scrubSecrets.js";
|
|
32
33
|
import { parseChangedPaths } from "./verify.js";
|
|
33
34
|
/** Cap on the commit subject's title half (keeps `git log --oneline` readable). */
|
|
@@ -42,7 +43,7 @@ const NOTE_TAIL_MAX = 200;
|
|
|
42
43
|
* a spawn-level error (ENOENT / abort), which callers catch into a note.
|
|
43
44
|
*/
|
|
44
45
|
const defaultExec = (argv, cwd, signal) => new Promise((resolve, reject) => {
|
|
45
|
-
execFile(argv[0], argv.slice(1), { cwd, signal, maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
46
|
+
const child = execFile(argv[0], argv.slice(1), { cwd, signal, maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
46
47
|
const output = `${stdout ?? ""}${stderr ?? ""}`;
|
|
47
48
|
if (err) {
|
|
48
49
|
const code = err.code;
|
|
@@ -52,6 +53,9 @@ const defaultExec = (argv, cwd, signal) => new Promise((resolve, reject) => {
|
|
|
52
53
|
}
|
|
53
54
|
resolve({ code: 0, output });
|
|
54
55
|
});
|
|
56
|
+
// A push or PR create can run for a while; register it with the exit sweep
|
|
57
|
+
// so quitting pi mid-FINISH never leaves it running in the background.
|
|
58
|
+
trackChild(child);
|
|
55
59
|
});
|
|
56
60
|
// ---------------------------------------------------------------------------
|
|
57
61
|
// Pure message derivation
|
|
@@ -88,7 +88,7 @@ export interface RegisterGoDeps {
|
|
|
88
88
|
* without a network; a non-identifier arg short-circuits to null (raw arg passes
|
|
89
89
|
* through). Returns null on any miss / transport error (the honest blind fallback).
|
|
90
90
|
*/
|
|
91
|
-
resolveTicketBrief?: (rawArg: string) => Promise<string | null>;
|
|
91
|
+
resolveTicketBrief?: (rawArg: string, signal?: AbortSignal) => Promise<string | null>;
|
|
92
92
|
/** Injectable worktree creation (default: real `git worktree add -b … HEAD`). */
|
|
93
93
|
createWorktree?: typeof defaultCreateRunWorktree;
|
|
94
94
|
/** Injectable worktree bootstrap (default: best-effort `<pm> install --prefer-offline`). */
|
|
@@ -75,8 +75,9 @@ import { runFinish as defaultRunFinish, verifyTrailerValue, } from "./finish.js"
|
|
|
75
75
|
import { GO_USAGE, parseGoArgs } from "./goFlags.js";
|
|
76
76
|
import { registerGoStatusCommands } from "./goStatusCommands.js";
|
|
77
77
|
import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
|
|
78
|
+
import { composeAbortSignal } from "./resilience.js";
|
|
78
79
|
import { planResume } from "./resume.js";
|
|
79
|
-
import { activeRunCount, beginRun, classifyRunLiveness, findActiveRunByTicket, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows, MAX_CONCURRENT_RUNS, settleRun, trackRunPromise, worktreesDir, } from "./runRegistry.js";
|
|
80
|
+
import { activeRunCount, beginRun, classifyRunLiveness, findActiveRunByTicket, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows, MAX_CONCURRENT_RUNS, settleRun, trackRunAbort, trackRunPromise, worktreesDir, } from "./runRegistry.js";
|
|
80
81
|
import { makeRunSession as defaultMakeRunSession } from "./runSession.js";
|
|
81
82
|
import { recordSessionRun } from "../sessionRuns.js";
|
|
82
83
|
import { resolveTicketBrief as defaultResolveTicketBrief } from "./ticketResolution.js";
|
|
@@ -426,7 +427,11 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
426
427
|
// back to the local client estimate).
|
|
427
428
|
const fetchRunSpend = deps.fetchRunSpend;
|
|
428
429
|
const resolveTicketBrief = deps.resolveTicketBrief ??
|
|
429
|
-
((rawArg) => defaultResolveTicketBrief({
|
|
430
|
+
((rawArg, signal) => defaultResolveTicketBrief({
|
|
431
|
+
baseUrl: deps.baseUrl ?? resolveBaseUrl(),
|
|
432
|
+
getToken: deps.getToken ?? defaultGetToken,
|
|
433
|
+
...(signal ? { signal } : {}),
|
|
434
|
+
}, rawArg));
|
|
430
435
|
const reportCrash = deps.reportCrash ??
|
|
431
436
|
makeCrashReporter({
|
|
432
437
|
baseUrl: deps.baseUrl ?? resolveBaseUrl(),
|
|
@@ -830,6 +835,13 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
830
835
|
return undefined;
|
|
831
836
|
}
|
|
832
837
|
};
|
|
838
|
+
// The run's OWN abort controller. `ctx.signal` is undefined here (/go only
|
|
839
|
+
// starts while the agent is idle, and pi's signal is the active turn's), so
|
|
840
|
+
// without this the pipeline runs unstoppable until its wall-clock timeout.
|
|
841
|
+
// /stop fires it via the registry (trackRunAbort below); composed with
|
|
842
|
+
// ctx.signal for the resumed-command case where one does exist.
|
|
843
|
+
const runAbort = new AbortController();
|
|
844
|
+
const runSignal = composeAbortSignal(ctx.signal, runAbort.signal);
|
|
833
845
|
const runToCompletion = async () => {
|
|
834
846
|
try {
|
|
835
847
|
// Steady animation ticker: keeps the spinner + elapsed clock live between
|
|
@@ -843,7 +855,9 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
843
855
|
// the agents plan on the actual ticket, not the bare "YAG-234". Fail-soft:
|
|
844
856
|
// null keeps the raw arg (the honest blind fallback). The raw `ticket` stays
|
|
845
857
|
// the session key, the run row's arg, and the handoff/re-run copy.
|
|
846
|
-
|
|
858
|
+
// Signalled so /stop lands immediately instead of waiting out the
|
|
859
|
+
// resolver's retry/timeout budget (up to ~90s of network patience).
|
|
860
|
+
const ticketBrief = await resolveTicketBrief(ticket, runSignal);
|
|
847
861
|
// The plan stage's output, captured off the onStage boundary for the
|
|
848
862
|
// FINISH commit message's 2-3 sentence summary (absent on a resume).
|
|
849
863
|
let planText;
|
|
@@ -861,9 +875,9 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
861
875
|
catch {
|
|
862
876
|
/* fail-soft: leave baseline empty */
|
|
863
877
|
}
|
|
864
|
-
|
|
878
|
+
let result = await runPipeline(ticket, {
|
|
865
879
|
cwd: runCwd,
|
|
866
|
-
signal:
|
|
880
|
+
signal: runSignal,
|
|
867
881
|
...(ticketBrief ? { ticketBrief } : {}),
|
|
868
882
|
onProgress: (p) => {
|
|
869
883
|
feed?.applyProgress(p);
|
|
@@ -909,6 +923,12 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
909
923
|
checkpointMeta,
|
|
910
924
|
...(resumeFrom ? { resumeFrom } : {}),
|
|
911
925
|
});
|
|
926
|
+
// /stop can land in the race between the pipeline's last abort check
|
|
927
|
+
// and its clean return: honor it BEFORE FINISH, so a stopped run never
|
|
928
|
+
// starts committing and the promised WIP-preserve path runs instead.
|
|
929
|
+
if (result.stopReason === "clean" && runSignal.aborted) {
|
|
930
|
+
result = { ...result, stopReason: "aborted" };
|
|
931
|
+
}
|
|
912
932
|
// FINISH stage (spec §3c): ONLY on a clean stop. Commit the run's work
|
|
913
933
|
// (worktree always; --here only over a clean pre-run baseline) with the
|
|
914
934
|
// provenance trailer, and push + PR when --pr asked for it. Iron
|
|
@@ -939,7 +959,7 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
939
959
|
openPr: parsed.flags.pr,
|
|
940
960
|
remainingFindings: result.findings.filter((f) => f.severity === "medium" || f.severity === "low"),
|
|
941
961
|
...(finishBaselinePaths.length > 0 ? { baselinePaths: finishBaselinePaths } : {}),
|
|
942
|
-
|
|
962
|
+
signal: runSignal,
|
|
943
963
|
});
|
|
944
964
|
}
|
|
945
965
|
catch (err) {
|
|
@@ -972,6 +992,14 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
972
992
|
}
|
|
973
993
|
}
|
|
974
994
|
clearUI();
|
|
995
|
+
// /stop DURING FINISH: the abort degrades FINISH mid-way (its execs
|
|
996
|
+
// share runSignal). If no commit landed, the aborted status + WIP
|
|
997
|
+
// preservation the user was promised win over "clean"; a commit that
|
|
998
|
+
// DID land means the work is durable and the clean report stays honest.
|
|
999
|
+
if (result.stopReason === "clean" && runSignal.aborted && !finish?.commitSha) {
|
|
1000
|
+
result = { ...result, stopReason: "aborted" };
|
|
1001
|
+
finish = undefined;
|
|
1002
|
+
}
|
|
975
1003
|
// Fold what FINISH landed onto a NEW result (never mutate the pipeline's
|
|
976
1004
|
// return). `finish` is present ONLY when a commit really happened (§0.1).
|
|
977
1005
|
const finishBranch = finish?.branch ?? repoCtx.branch;
|
|
@@ -1049,6 +1077,7 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
1049
1077
|
}
|
|
1050
1078
|
};
|
|
1051
1079
|
beginRun(registryRow);
|
|
1080
|
+
trackRunAbort(runId, runAbort);
|
|
1052
1081
|
if (parsed.flags.fg) {
|
|
1053
1082
|
// --fg: the legacy blocking behavior — the prompt returns when the run does.
|
|
1054
1083
|
await runToCompletion();
|
|
@@ -44,7 +44,17 @@ export interface RegisterGoStatusDeps {
|
|
|
44
44
|
resolveRepo?: (cwd: string, signal?: AbortSignal) => Promise<string | undefined>;
|
|
45
45
|
exists?: (path: string) => boolean;
|
|
46
46
|
now?: () => number;
|
|
47
|
+
/** Injectable cancel seams for /stop (default: the module-scoped registry). */
|
|
48
|
+
activeRows?: () => RunRegistryRow[];
|
|
49
|
+
cancelOne?: (runId: string) => RunRegistryRow | undefined;
|
|
50
|
+
cancelAll?: () => RunRegistryRow[];
|
|
47
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* PURE: resolve a /stop argument against the in-flight rows. Matches the ticket
|
|
54
|
+
* (case-insensitive exact) first, then a runId prefix of at least 4 chars (the
|
|
55
|
+
* short id /go-status prints is 8).
|
|
56
|
+
*/
|
|
57
|
+
export declare function matchStopTarget(rows: RunRegistryRow[], arg: string): RunRegistryRow | undefined;
|
|
48
58
|
/**
|
|
49
59
|
* PURE: where an in-flight/interrupted run got to, read off its checkpoint
|
|
50
60
|
* journal. The journal is append-only, so the latest applicable boundary wins.
|
|
@@ -22,9 +22,25 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { existsSync } from "node:fs";
|
|
24
24
|
import { makeFileCheckpointStore } from "./checkpoint.js";
|
|
25
|
-
import { classifyRunLiveness, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows, settleRun, } from "./runRegistry.js";
|
|
25
|
+
import { activeRunRows, cancelActiveRuns, cancelRun, classifyRunLiveness, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows, settleRun, } from "./runRegistry.js";
|
|
26
26
|
import { removeWorktree as defaultRemoveWorktree } from "./worktree.js";
|
|
27
27
|
import { snapshotWorkspace as defaultSnapshotWorkspace } from "./workspace.js";
|
|
28
|
+
/**
|
|
29
|
+
* PURE: resolve a /stop argument against the in-flight rows. Matches the ticket
|
|
30
|
+
* (case-insensitive exact) first, then a runId prefix of at least 4 chars (the
|
|
31
|
+
* short id /go-status prints is 8).
|
|
32
|
+
*/
|
|
33
|
+
export function matchStopTarget(rows, arg) {
|
|
34
|
+
const needle = arg.trim().toLowerCase();
|
|
35
|
+
if (!needle)
|
|
36
|
+
return undefined;
|
|
37
|
+
const byTicket = rows.find((r) => r.ticket.toLowerCase() === needle);
|
|
38
|
+
if (byTicket)
|
|
39
|
+
return byTicket;
|
|
40
|
+
if (needle.length < 4)
|
|
41
|
+
return undefined;
|
|
42
|
+
return rows.find((r) => r.runId.toLowerCase().startsWith(needle));
|
|
43
|
+
}
|
|
28
44
|
/**
|
|
29
45
|
* PURE: where an in-flight/interrupted run got to, read off its checkpoint
|
|
30
46
|
* journal. The journal is append-only, so the latest applicable boundary wins.
|
|
@@ -90,6 +106,50 @@ export function registerGoStatusCommands(pi, deps = {}) {
|
|
|
90
106
|
const resolveRepo = deps.resolveRepo ?? (async () => undefined);
|
|
91
107
|
const exists = deps.exists ?? existsSync;
|
|
92
108
|
const now = deps.now ?? Date.now;
|
|
109
|
+
const activeRows = deps.activeRows ?? activeRunRows;
|
|
110
|
+
const cancelOne = deps.cancelOne ?? cancelRun;
|
|
111
|
+
const cancelAll = deps.cancelAll ?? cancelActiveRuns;
|
|
112
|
+
pi.registerCommand("stop", {
|
|
113
|
+
description: "Hard-stop /go runs in this session: /stop cancels them all, /stop <ticket|run id> cancels one. Work so far is preserved as a WIP commit.",
|
|
114
|
+
handler: async (args, ctx) => {
|
|
115
|
+
const say = async (message, type = "info") => {
|
|
116
|
+
if (ctx.hasUI)
|
|
117
|
+
ctx.ui.notify(message, type);
|
|
118
|
+
else
|
|
119
|
+
await pi.sendUserMessage(message);
|
|
120
|
+
};
|
|
121
|
+
const label = (row) => `[${row.runId.slice(0, 8)}] ${row.ticket}`;
|
|
122
|
+
const rows = activeRows();
|
|
123
|
+
if (rows.length === 0) {
|
|
124
|
+
await say("No /go runs are in flight in this session. (Esc interrupts the interactive agent; /go-status lists runs from other sessions.)");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const arg = args.trim();
|
|
128
|
+
if (arg) {
|
|
129
|
+
const target = matchStopTarget(rows, arg);
|
|
130
|
+
if (!target) {
|
|
131
|
+
await say(`No in-flight run matches "${arg}". Running here: ${rows.map(label).join(", ")}.`, "warning");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (!cancelOne(target.runId)) {
|
|
135
|
+
await say(`${label(target)} is still starting and cannot be signalled yet; try /stop again.`, "warning");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
await say(`Stopping ${label(target)}. Its workers are being killed; any work lands as a WIP commit and the handoff will report the aborted run.`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const cancelled = cancelAll();
|
|
142
|
+
if (cancelled.length === 0) {
|
|
143
|
+
// The window between beginRun and trackRunAbort is one tick wide, but be
|
|
144
|
+
// honest if we hit it rather than claiming a stop that did not fire.
|
|
145
|
+
await say("Could not signal any run yet (they are still starting); try /stop again.", "warning");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
await say(`Stopping ${cancelled.length} /go run${cancelled.length === 1 ? "" : "s"}: ${cancelled
|
|
149
|
+
.map(label)
|
|
150
|
+
.join(", ")}. Workers are being killed; work so far lands as WIP commits (see /go-status).`);
|
|
151
|
+
},
|
|
152
|
+
});
|
|
93
153
|
pi.registerCommand("go-status", {
|
|
94
154
|
description: "List /go runs: active (stage/round), finished (outcome + branch/commit/PR), and interrupted (resumable).",
|
|
95
155
|
handler: async (_args, ctx) => {
|
|
@@ -85,6 +85,20 @@ export declare function _resetRunRegistryForTest(): void;
|
|
|
85
85
|
export declare function beginRun(row: RunRegistryRow): void;
|
|
86
86
|
/** Attach the detached completion promise to an in-flight run (fail-soft no-op when absent). */
|
|
87
87
|
export declare function trackRunPromise(runId: string, promise: Promise<void>): void;
|
|
88
|
+
/** Attach the run's abort controller so /stop can cancel it (fail-soft no-op when absent). */
|
|
89
|
+
export declare function trackRunAbort(runId: string, abort: AbortController): void;
|
|
90
|
+
/**
|
|
91
|
+
* Cancel one in-flight run in THIS process: fire its abort controller and return
|
|
92
|
+
* its row. The run itself settles through the normal pipeline path (the abort
|
|
93
|
+
* propagates to every stage child as SIGTERM→SIGKILL, the orchestrator stops with
|
|
94
|
+
* an honest `aborted`, and runToCompletion preserves WIP + settles the registry).
|
|
95
|
+
* Returns undefined for an unknown/foreign runId or a run with no controller yet.
|
|
96
|
+
*/
|
|
97
|
+
export declare function cancelRun(runId: string): RunRegistryRow | undefined;
|
|
98
|
+
/** Cancel EVERY in-flight run in this process (the /stop no-arg path). Returns the rows fired. */
|
|
99
|
+
export declare function cancelActiveRuns(): RunRegistryRow[];
|
|
100
|
+
/** The in-flight rows in THIS process (newest first), for /stop's matching + messaging. */
|
|
101
|
+
export declare function activeRunRows(): RunRegistryRow[];
|
|
88
102
|
/**
|
|
89
103
|
* Settle an in-flight run: merge the terminal patch, drop it from the in-memory
|
|
90
104
|
* active set, and mirror the final row. Safe to call for an unknown runId: the
|
|
@@ -137,6 +137,41 @@ export function trackRunPromise(runId, promise) {
|
|
|
137
137
|
if (entry)
|
|
138
138
|
entry.promise = promise;
|
|
139
139
|
}
|
|
140
|
+
/** Attach the run's abort controller so /stop can cancel it (fail-soft no-op when absent). */
|
|
141
|
+
export function trackRunAbort(runId, abort) {
|
|
142
|
+
const entry = active.get(runId);
|
|
143
|
+
if (entry)
|
|
144
|
+
entry.abort = abort;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Cancel one in-flight run in THIS process: fire its abort controller and return
|
|
148
|
+
* its row. The run itself settles through the normal pipeline path (the abort
|
|
149
|
+
* propagates to every stage child as SIGTERM→SIGKILL, the orchestrator stops with
|
|
150
|
+
* an honest `aborted`, and runToCompletion preserves WIP + settles the registry).
|
|
151
|
+
* Returns undefined for an unknown/foreign runId or a run with no controller yet.
|
|
152
|
+
*/
|
|
153
|
+
export function cancelRun(runId) {
|
|
154
|
+
const entry = active.get(runId);
|
|
155
|
+
if (!entry?.abort)
|
|
156
|
+
return undefined;
|
|
157
|
+
entry.abort.abort();
|
|
158
|
+
return entry.row;
|
|
159
|
+
}
|
|
160
|
+
/** Cancel EVERY in-flight run in this process (the /stop no-arg path). Returns the rows fired. */
|
|
161
|
+
export function cancelActiveRuns() {
|
|
162
|
+
const cancelled = [];
|
|
163
|
+
for (const entry of active.values()) {
|
|
164
|
+
if (!entry.abort)
|
|
165
|
+
continue;
|
|
166
|
+
entry.abort.abort();
|
|
167
|
+
cancelled.push(entry.row);
|
|
168
|
+
}
|
|
169
|
+
return cancelled;
|
|
170
|
+
}
|
|
171
|
+
/** The in-flight rows in THIS process (newest first), for /stop's matching + messaging. */
|
|
172
|
+
export function activeRunRows() {
|
|
173
|
+
return [...active.values()].map((e) => e.row).sort((a, b) => b.startedAt - a.startedAt);
|
|
174
|
+
}
|
|
140
175
|
/**
|
|
141
176
|
* Settle an in-flight run: merge the terminal patch, drop it from the in-memory
|
|
142
177
|
* active set, and mirror the final row. Safe to call for an unknown runId: the
|
|
@@ -23,6 +23,7 @@ import * as fs from "node:fs";
|
|
|
23
23
|
import * as os from "node:os";
|
|
24
24
|
import * as path from "node:path";
|
|
25
25
|
import { fileURLToPath } from "node:url";
|
|
26
|
+
import { trackChild } from "./childRegistry.js";
|
|
26
27
|
import { finalOutputFrom, foldEvent, newEventAccumulator } from "./events.js";
|
|
27
28
|
import { buildStageInvocation, groundedChildArgv } from "./invocation.js";
|
|
28
29
|
import { personaBody } from "./personas.js";
|
|
@@ -151,6 +152,9 @@ export async function runStage(stage, ctx, deps) {
|
|
|
151
152
|
shell: false,
|
|
152
153
|
stdio: ["ignore", "pipe", "pipe"],
|
|
153
154
|
});
|
|
155
|
+
// Register with the exit sweep so quitting pi (/exit, double Ctrl+C,
|
|
156
|
+
// SIGTERM) can never orphan a stage child into the background.
|
|
157
|
+
trackChild(proc);
|
|
154
158
|
let buffer = "";
|
|
155
159
|
// `overlong` = we are mid-way through a line that already blew the cap; we
|
|
156
160
|
// discard incoming bytes until its terminating newline, then drop the line.
|
|
@@ -239,6 +239,10 @@ export declare function buildVerifyEnv(source?: NodeJS.ProcessEnv): NodeJS.Proce
|
|
|
239
239
|
* once instead of parking in watch mode until the 10-min cap kills it.
|
|
240
240
|
*/
|
|
241
241
|
export declare function buildTestEnv(source?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
242
|
+
export declare function makeDefaultExec(opts?: {
|
|
243
|
+
graceMs?: number;
|
|
244
|
+
killTree?: (pid: number) => void;
|
|
245
|
+
}): VerifyExec;
|
|
242
246
|
export interface MakeRunVerifyOpts {
|
|
243
247
|
detect?: (cwd: string, changedDirs: string[]) => VerifyCommand[];
|
|
244
248
|
/**
|
|
@@ -52,6 +52,7 @@ import { readFileSync } from "node:fs";
|
|
|
52
52
|
// backslashed strings that no longer compare equal to the git-derived repoRoot
|
|
53
53
|
// and would break the walk-up termination check.
|
|
54
54
|
import { basename, dirname, join } from "node:path/posix";
|
|
55
|
+
import { killTreeSync, trackChild } from "./childRegistry.js";
|
|
55
56
|
import { composeAbortSignal } from "./resilience.js";
|
|
56
57
|
import { scrubSecrets } from "./scrubSecrets.js";
|
|
57
58
|
import { snapshotWorkspace } from "./workspace.js";
|
|
@@ -595,33 +596,54 @@ export function buildTestEnv(source = process.env) {
|
|
|
595
596
|
// — signalling the WHOLE process group and killing the parent. Abort is wired
|
|
596
597
|
// manually with an explicit pid guard; a killed child reaches the callback with
|
|
597
598
|
// a code-less error, which stays the same fail-open rejection as before.
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
}
|
|
612
|
-
|
|
599
|
+
//
|
|
600
|
+
// Abort escalates: SIGTERM first (a test runner gets its grace to flush), then
|
|
601
|
+
// after `graceMs` a TREE SIGKILL — a stubborn runner (or a descendant it
|
|
602
|
+
// spawned that holds the stdio pipes open) would otherwise leave the promise
|
|
603
|
+
// pending forever, hanging /stop and the run with it.
|
|
604
|
+
export function makeDefaultExec(opts = {}) {
|
|
605
|
+
const graceMs = opts.graceMs ?? 5000;
|
|
606
|
+
const killTree = opts.killTree ?? killTreeSync;
|
|
607
|
+
return (argv, cwd, signal, env) => new Promise((resolve, reject) => {
|
|
608
|
+
if (signal.aborted)
|
|
609
|
+
return reject(new Error("verify aborted before start"));
|
|
610
|
+
let settled = false;
|
|
611
|
+
let killTimer;
|
|
612
|
+
const child = execFile(argv[0], argv.slice(1), { cwd, env: env ?? buildVerifyEnv(), maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
613
|
+
settled = true;
|
|
614
|
+
if (killTimer)
|
|
615
|
+
clearTimeout(killTimer);
|
|
616
|
+
signal.removeEventListener("abort", onAbort);
|
|
617
|
+
const output = `${stdout ?? ""}${stderr ?? ""}`;
|
|
618
|
+
if (err) {
|
|
619
|
+
// A non-zero EXIT is a verify failure (carries a numeric code); a spawn
|
|
620
|
+
// error (ENOENT) or an abort has no numeric code and is a fail-open.
|
|
621
|
+
const code = err.code;
|
|
622
|
+
if (typeof code === "number")
|
|
623
|
+
return resolve({ code, output });
|
|
624
|
+
return reject(err);
|
|
625
|
+
}
|
|
626
|
+
resolve({ code: 0, output });
|
|
627
|
+
});
|
|
628
|
+
trackChild(child);
|
|
629
|
+
const onAbort = () => {
|
|
630
|
+
try {
|
|
631
|
+
if (child.pid)
|
|
632
|
+
child.kill("SIGTERM");
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
/* ignore */
|
|
636
|
+
}
|
|
637
|
+
killTimer = setTimeout(() => {
|
|
638
|
+
if (!settled && child.pid)
|
|
639
|
+
killTree(child.pid);
|
|
640
|
+
}, graceMs);
|
|
641
|
+
killTimer.unref?.();
|
|
642
|
+
};
|
|
643
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
613
644
|
});
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
if (child.pid)
|
|
617
|
-
child.kill("SIGTERM");
|
|
618
|
-
}
|
|
619
|
-
catch {
|
|
620
|
-
/* ignore */
|
|
621
|
-
}
|
|
622
|
-
};
|
|
623
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
624
|
-
});
|
|
645
|
+
}
|
|
646
|
+
const defaultExec = makeDefaultExec();
|
|
625
647
|
/**
|
|
626
648
|
* Build the runVerify function injected into the orchestrator. It discovers the
|
|
627
649
|
* changed package(s) (scoped to this run's diff via `baselinePaths`), detects a
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Termination-signal forwarding: launcher → pi child.
|
|
3
|
+
*
|
|
4
|
+
* In the interactive TUI the tty is in raw mode, so Ctrl+C never raises
|
|
5
|
+
* SIGINT; this path covers `yagni -p`/piped/CI runs and an external
|
|
6
|
+
* `kill <launcher pid>`. Policy:
|
|
7
|
+
*
|
|
8
|
+
* - The FIRST signal asks pi to shut down cleanly. SIGINT is translated to
|
|
9
|
+
* SIGTERM because pi installs a graceful handler for SIGTERM (it kills its
|
|
10
|
+
* tracked children) but none for SIGINT — a raw SIGINT would drop pi on the
|
|
11
|
+
* default disposition with no cleanup.
|
|
12
|
+
* - Any SECOND signal hard-kills pi's whole process TREE. A bare SIGKILL to
|
|
13
|
+
* pi's pid would bypass its exit sweep (SIGKILL runs no handlers) and leave
|
|
14
|
+
* /go workers alive, so the launcher enumerates and kills the descendants
|
|
15
|
+
* itself: `ps` walk on POSIX, `taskkill /T /F` on Windows.
|
|
16
|
+
*/
|
|
17
|
+
/** The minimal child surface the forwarder needs (ChildProcess satisfies it). */
|
|
18
|
+
export interface KillableChild {
|
|
19
|
+
pid?: number | undefined;
|
|
20
|
+
kill(signal: NodeJS.Signals): boolean;
|
|
21
|
+
}
|
|
22
|
+
/** The launcher-terminating signals that are forwarded rather than obeyed. */
|
|
23
|
+
export declare const FORWARDED_SIGNALS: readonly NodeJS.Signals[];
|
|
24
|
+
/**
|
|
25
|
+
* PURE policy for the FIRST signal: which signal the child receives when the
|
|
26
|
+
* launcher gets `received` (SIGINT is translated, the rest pass through).
|
|
27
|
+
* Escalation is not expressed here — a second signal goes through the tree
|
|
28
|
+
* kill, not a forwarded signal.
|
|
29
|
+
*/
|
|
30
|
+
export declare function forwardedSignal(received: NodeJS.Signals): NodeJS.Signals;
|
|
31
|
+
/**
|
|
32
|
+
* PURE: the launcher's own exit code for the child's (code, signal) exit tuple.
|
|
33
|
+
* A signal-terminated child maps to the conventional 128+n (bash parity:
|
|
34
|
+
* SIGTERM→143, SIGKILL→137, SIGINT→130), so a cancelled run never reads as
|
|
35
|
+
* success to scripts or CI.
|
|
36
|
+
*/
|
|
37
|
+
export declare function exitCodeFor(code: number | null, signal: NodeJS.Signals | null): number;
|
|
38
|
+
/**
|
|
39
|
+
* PURE: given `ps -A -o pid=,ppid=` output lines, collect every descendant of
|
|
40
|
+
* the given roots (children, grandchildren, ...), breadth-first. Mirrored in
|
|
41
|
+
* pi-extension-yagni's childRegistry (the packages are intentionally
|
|
42
|
+
* independent); keep the two in sync.
|
|
43
|
+
*/
|
|
44
|
+
export declare function descendantsOf(roots: number[], psLines: string[]): number[];
|
|
45
|
+
/**
|
|
46
|
+
* Synchronously SIGKILL a process AND its descendants (POSIX `ps` walk /
|
|
47
|
+
* Windows `taskkill /T /F`). Throw-proof; fail-soft to a direct kill when no
|
|
48
|
+
* process snapshot is readable.
|
|
49
|
+
*/
|
|
50
|
+
export declare function killTreeSync(pid: number): void;
|
|
51
|
+
/**
|
|
52
|
+
* Subscribe the forwarding policy for every signal in {@link FORWARDED_SIGNALS}.
|
|
53
|
+
* `subscribe` and `killTree` default to the real process surfaces and are
|
|
54
|
+
* injectable so tests never install real handlers, raise real signals, or kill
|
|
55
|
+
* real processes. Returns the forward function itself (also for tests).
|
|
56
|
+
* pid guard: a child whose spawn failed has no pid, and `kill()` would resolve
|
|
57
|
+
* that to the whole process group.
|
|
58
|
+
*/
|
|
59
|
+
export declare function installSignalForwarding(child: KillableChild, subscribe?: (sig: NodeJS.Signals, handler: () => void) => void, killTree?: (pid: number) => void): (sig: NodeJS.Signals) => void;
|
|
60
|
+
//# sourceMappingURL=signalForward.d.ts.map
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Termination-signal forwarding: launcher → pi child.
|
|
3
|
+
*
|
|
4
|
+
* In the interactive TUI the tty is in raw mode, so Ctrl+C never raises
|
|
5
|
+
* SIGINT; this path covers `yagni -p`/piped/CI runs and an external
|
|
6
|
+
* `kill <launcher pid>`. Policy:
|
|
7
|
+
*
|
|
8
|
+
* - The FIRST signal asks pi to shut down cleanly. SIGINT is translated to
|
|
9
|
+
* SIGTERM because pi installs a graceful handler for SIGTERM (it kills its
|
|
10
|
+
* tracked children) but none for SIGINT — a raw SIGINT would drop pi on the
|
|
11
|
+
* default disposition with no cleanup.
|
|
12
|
+
* - Any SECOND signal hard-kills pi's whole process TREE. A bare SIGKILL to
|
|
13
|
+
* pi's pid would bypass its exit sweep (SIGKILL runs no handlers) and leave
|
|
14
|
+
* /go workers alive, so the launcher enumerates and kills the descendants
|
|
15
|
+
* itself: `ps` walk on POSIX, `taskkill /T /F` on Windows.
|
|
16
|
+
*/
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { constants as osConstants } from "node:os";
|
|
19
|
+
/** The launcher-terminating signals that are forwarded rather than obeyed. */
|
|
20
|
+
export const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
21
|
+
/**
|
|
22
|
+
* PURE policy for the FIRST signal: which signal the child receives when the
|
|
23
|
+
* launcher gets `received` (SIGINT is translated, the rest pass through).
|
|
24
|
+
* Escalation is not expressed here — a second signal goes through the tree
|
|
25
|
+
* kill, not a forwarded signal.
|
|
26
|
+
*/
|
|
27
|
+
export function forwardedSignal(received) {
|
|
28
|
+
return received === "SIGINT" ? "SIGTERM" : received;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* PURE: the launcher's own exit code for the child's (code, signal) exit tuple.
|
|
32
|
+
* A signal-terminated child maps to the conventional 128+n (bash parity:
|
|
33
|
+
* SIGTERM→143, SIGKILL→137, SIGINT→130), so a cancelled run never reads as
|
|
34
|
+
* success to scripts or CI.
|
|
35
|
+
*/
|
|
36
|
+
export function exitCodeFor(code, signal) {
|
|
37
|
+
if (code != null)
|
|
38
|
+
return code;
|
|
39
|
+
if (signal)
|
|
40
|
+
return 128 + (osConstants.signals[signal] ?? 15);
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* PURE: given `ps -A -o pid=,ppid=` output lines, collect every descendant of
|
|
45
|
+
* the given roots (children, grandchildren, ...), breadth-first. Mirrored in
|
|
46
|
+
* pi-extension-yagni's childRegistry (the packages are intentionally
|
|
47
|
+
* independent); keep the two in sync.
|
|
48
|
+
*/
|
|
49
|
+
export function descendantsOf(roots, psLines) {
|
|
50
|
+
const childrenByParent = new Map();
|
|
51
|
+
for (const line of psLines) {
|
|
52
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)$/);
|
|
53
|
+
if (!m)
|
|
54
|
+
continue;
|
|
55
|
+
const pid = Number(m[1]);
|
|
56
|
+
const ppid = Number(m[2]);
|
|
57
|
+
const list = childrenByParent.get(ppid) ?? [];
|
|
58
|
+
list.push(pid);
|
|
59
|
+
childrenByParent.set(ppid, list);
|
|
60
|
+
}
|
|
61
|
+
const found = [];
|
|
62
|
+
const queue = [...roots];
|
|
63
|
+
while (queue.length > 0) {
|
|
64
|
+
const next = queue.shift();
|
|
65
|
+
for (const child of childrenByParent.get(next) ?? []) {
|
|
66
|
+
found.push(child);
|
|
67
|
+
queue.push(child);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return found;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Synchronously SIGKILL a process AND its descendants (POSIX `ps` walk /
|
|
74
|
+
* Windows `taskkill /T /F`). Throw-proof; fail-soft to a direct kill when no
|
|
75
|
+
* process snapshot is readable.
|
|
76
|
+
*/
|
|
77
|
+
export function killTreeSync(pid) {
|
|
78
|
+
if (process.platform === "win32") {
|
|
79
|
+
try {
|
|
80
|
+
spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* best effort */
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
let descendants = [];
|
|
88
|
+
try {
|
|
89
|
+
const ps = spawnSync("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8" });
|
|
90
|
+
if (ps.status === 0 && typeof ps.stdout === "string") {
|
|
91
|
+
descendants = descendantsOf([pid], ps.stdout.split("\n"));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
/* no snapshot: fall through to the direct kill */
|
|
96
|
+
}
|
|
97
|
+
for (const target of [pid, ...descendants]) {
|
|
98
|
+
try {
|
|
99
|
+
process.kill(target, "SIGKILL");
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* already dead */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Subscribe the forwarding policy for every signal in {@link FORWARDED_SIGNALS}.
|
|
108
|
+
* `subscribe` and `killTree` default to the real process surfaces and are
|
|
109
|
+
* injectable so tests never install real handlers, raise real signals, or kill
|
|
110
|
+
* real processes. Returns the forward function itself (also for tests).
|
|
111
|
+
* pid guard: a child whose spawn failed has no pid, and `kill()` would resolve
|
|
112
|
+
* that to the whole process group.
|
|
113
|
+
*/
|
|
114
|
+
export function installSignalForwarding(child, subscribe = (sig, handler) => process.on(sig, handler), killTree = killTreeSync) {
|
|
115
|
+
let received = 0;
|
|
116
|
+
const forward = (sig) => {
|
|
117
|
+
const prior = received;
|
|
118
|
+
received += 1;
|
|
119
|
+
if (!child.pid)
|
|
120
|
+
return;
|
|
121
|
+
if (prior > 0)
|
|
122
|
+
killTree(child.pid);
|
|
123
|
+
else
|
|
124
|
+
child.kill(forwardedSignal(sig));
|
|
125
|
+
};
|
|
126
|
+
for (const sig of FORWARDED_SIGNALS)
|
|
127
|
+
subscribe(sig, () => forward(sig));
|
|
128
|
+
return forward;
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=signalForward.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "0.3.0-staging.
|
|
3
|
+
"version": "0.3.0-staging.1082.1",
|
|
4
4
|
"description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
|
|
@@ -38,5 +38,5 @@
|
|
|
38
38
|
"@earendil-works/pi-tui": "0.84.1",
|
|
39
39
|
"typebox": "^1.3.11"
|
|
40
40
|
},
|
|
41
|
-
"yagniSourceSha": "
|
|
41
|
+
"yagniSourceSha": "a1967e7624f741b033c68d83acd29b3a47262b7c"
|
|
42
42
|
}
|