@yagni-app/code 0.2.1 → 0.3.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.d.ts +30 -0
- package/dist/cli.js +147 -3
- package/dist/connectClaudeCode.d.ts +77 -0
- package/dist/connectClaudeCode.js +228 -0
- package/dist/connectCodex.d.ts +75 -0
- package/dist/connectCodex.js +201 -0
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +1 -1
- package/dist/extension/advisor.d.ts +4 -4
- package/dist/extension/advisor.js +6 -7
- package/dist/extension/approvedPrefixes.d.ts +103 -0
- package/dist/extension/approvedPrefixes.js +282 -0
- package/dist/extension/askAdvisorTool.d.ts +20 -5
- package/dist/extension/askAdvisorTool.js +126 -20
- package/dist/extension/askYagniTool.d.ts +23 -0
- package/dist/extension/askYagniTool.js +91 -2
- package/dist/extension/branding.d.ts +34 -3
- package/dist/extension/branding.js +114 -13
- package/dist/extension/chipEditor.d.ts +30 -9
- package/dist/extension/chipEditor.js +173 -59
- package/dist/extension/claudeRules.d.ts +0 -2
- package/dist/extension/claudeRules.js +0 -8
- package/dist/extension/cmux/dispatcher.d.ts +25 -0
- package/dist/extension/cmux/dispatcher.js +266 -0
- package/dist/extension/cmux/hooks.d.ts +12 -0
- package/dist/extension/cmux/hooks.js +192 -0
- package/dist/extension/cmux/index.d.ts +3 -0
- package/dist/extension/cmux/index.js +155 -0
- package/dist/extension/cmux/naming.d.ts +5 -0
- package/dist/extension/cmux/naming.js +23 -0
- package/dist/extension/cmux/state.d.ts +33 -0
- package/dist/extension/cmux/state.js +142 -0
- package/dist/extension/config.d.ts +44 -1
- package/dist/extension/config.js +37 -4
- package/dist/extension/costHud.d.ts +16 -22
- package/dist/extension/costHud.js +8 -47
- package/dist/extension/crashReport.js +1 -3
- package/dist/extension/execPolicy.d.ts +135 -0
- package/dist/extension/execPolicy.js +936 -0
- package/dist/extension/flywheel.d.ts +44 -0
- package/dist/extension/flywheel.js +53 -0
- package/dist/extension/footer.d.ts +118 -0
- package/dist/extension/footer.js +308 -0
- package/dist/extension/guardian.d.ts +139 -0
- package/dist/extension/guardian.js +237 -0
- package/dist/extension/index.d.ts +35 -7
- package/dist/extension/index.js +338 -33
- package/dist/extension/mineBeat.d.ts +95 -0
- package/dist/extension/mineBeat.js +193 -0
- package/dist/extension/permission.d.ts +124 -10
- package/dist/extension/permission.js +603 -52
- 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 +40 -9
- package/dist/extension/pipeline/goStatusCommands.d.ts +10 -0
- package/dist/extension/pipeline/goStatusCommands.js +61 -1
- package/dist/extension/pipeline/personas.js +26 -1
- package/dist/extension/pipeline/resilience.d.ts +2 -1
- package/dist/extension/pipeline/resilience.js +21 -2
- package/dist/extension/pipeline/runRegistry.d.ts +23 -1
- package/dist/extension/pipeline/runRegistry.js +57 -1
- 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/extension/recordDecisionTool.d.ts +8 -0
- package/dist/extension/recordDecisionTool.js +24 -0
- package/dist/extension/redact.d.ts +20 -0
- package/dist/extension/redact.js +64 -0
- package/dist/extension/rerouteNotice.d.ts +3 -4
- package/dist/extension/rerouteNotice.js +20 -11
- package/dist/extension/subagentRender.d.ts +129 -0
- package/dist/extension/subagentRender.js +441 -0
- package/dist/extension/subagents.d.ts +11 -8
- package/dist/extension/subagents.js +162 -37
- package/dist/extension/ticketTools.d.ts +37 -0
- package/dist/extension/ticketTools.js +117 -0
- package/dist/extension/todos.d.ts +28 -1
- package/dist/extension/todos.js +76 -1
- package/dist/extension/tokenProvider.js +46 -5
- package/dist/extension/ultra.d.ts +27 -0
- package/dist/extension/ultra.js +76 -0
- package/dist/launch.d.ts +7 -0
- package/dist/launch.js +24 -12
- package/dist/login.d.ts +4 -2
- package/dist/login.js +19 -4
- package/dist/padding.d.ts +22 -0
- package/dist/padding.js +25 -0
- package/dist/promptEnrichment.d.ts +40 -0
- package/dist/promptEnrichment.js +85 -0
- package/dist/signalForward.d.ts +60 -0
- package/dist/signalForward.js +130 -0
- package/dist/token.d.ts +25 -0
- package/dist/token.js +45 -0
- package/package.json +6 -5
- package/dist/extension/boostCommand.d.ts +0 -144
- package/dist/extension/boostCommand.js +0 -263
|
@@ -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,
|
|
80
|
+
import { activeRunCount, beginRun, classifyRunLiveness, findActiveRunByTicket, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows, resolveMaxConcurrentRuns, 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(),
|
|
@@ -455,13 +460,15 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
455
460
|
return;
|
|
456
461
|
}
|
|
457
462
|
// In-flight guards: the same ticket never runs twice at once in this
|
|
458
|
-
// process, and at most
|
|
463
|
+
// process, and at most resolveMaxConcurrentRuns() runs are in flight
|
|
464
|
+
// (default 3; fleet operators raise it via YAGNI_MAX_CONCURRENT_RUNS).
|
|
459
465
|
if (findActiveRunByTicket(ticket)) {
|
|
460
466
|
notify(`/go ${ticket} is already running - see /go-status.`, "warning");
|
|
461
467
|
return;
|
|
462
468
|
}
|
|
463
|
-
|
|
464
|
-
|
|
469
|
+
const maxConcurrentRuns = resolveMaxConcurrentRuns();
|
|
470
|
+
if (activeRunCount() >= maxConcurrentRuns) {
|
|
471
|
+
notify(`${maxConcurrentRuns} /go runs are already in flight; wait for one to finish (see /go-status) or raise YAGNI_MAX_CONCURRENT_RUNS.`, "warning");
|
|
465
472
|
return;
|
|
466
473
|
}
|
|
467
474
|
// --- Run tree resolution: worktree by default; --here = legacy in-place.
|
|
@@ -830,6 +837,13 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
830
837
|
return undefined;
|
|
831
838
|
}
|
|
832
839
|
};
|
|
840
|
+
// The run's OWN abort controller. `ctx.signal` is undefined here (/go only
|
|
841
|
+
// starts while the agent is idle, and pi's signal is the active turn's), so
|
|
842
|
+
// without this the pipeline runs unstoppable until its wall-clock timeout.
|
|
843
|
+
// /stop fires it via the registry (trackRunAbort below); composed with
|
|
844
|
+
// ctx.signal for the resumed-command case where one does exist.
|
|
845
|
+
const runAbort = new AbortController();
|
|
846
|
+
const runSignal = composeAbortSignal(ctx.signal, runAbort.signal);
|
|
833
847
|
const runToCompletion = async () => {
|
|
834
848
|
try {
|
|
835
849
|
// Steady animation ticker: keeps the spinner + elapsed clock live between
|
|
@@ -843,7 +857,9 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
843
857
|
// the agents plan on the actual ticket, not the bare "YAG-234". Fail-soft:
|
|
844
858
|
// null keeps the raw arg (the honest blind fallback). The raw `ticket` stays
|
|
845
859
|
// the session key, the run row's arg, and the handoff/re-run copy.
|
|
846
|
-
|
|
860
|
+
// Signalled so /stop lands immediately instead of waiting out the
|
|
861
|
+
// resolver's retry/timeout budget (up to ~90s of network patience).
|
|
862
|
+
const ticketBrief = await resolveTicketBrief(ticket, runSignal);
|
|
847
863
|
// The plan stage's output, captured off the onStage boundary for the
|
|
848
864
|
// FINISH commit message's 2-3 sentence summary (absent on a resume).
|
|
849
865
|
let planText;
|
|
@@ -861,9 +877,9 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
861
877
|
catch {
|
|
862
878
|
/* fail-soft: leave baseline empty */
|
|
863
879
|
}
|
|
864
|
-
|
|
880
|
+
let result = await runPipeline(ticket, {
|
|
865
881
|
cwd: runCwd,
|
|
866
|
-
signal:
|
|
882
|
+
signal: runSignal,
|
|
867
883
|
...(ticketBrief ? { ticketBrief } : {}),
|
|
868
884
|
onProgress: (p) => {
|
|
869
885
|
feed?.applyProgress(p);
|
|
@@ -909,6 +925,12 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
909
925
|
checkpointMeta,
|
|
910
926
|
...(resumeFrom ? { resumeFrom } : {}),
|
|
911
927
|
});
|
|
928
|
+
// /stop can land in the race between the pipeline's last abort check
|
|
929
|
+
// and its clean return: honor it BEFORE FINISH, so a stopped run never
|
|
930
|
+
// starts committing and the promised WIP-preserve path runs instead.
|
|
931
|
+
if (result.stopReason === "clean" && runSignal.aborted) {
|
|
932
|
+
result = { ...result, stopReason: "aborted" };
|
|
933
|
+
}
|
|
912
934
|
// FINISH stage (spec §3c): ONLY on a clean stop. Commit the run's work
|
|
913
935
|
// (worktree always; --here only over a clean pre-run baseline) with the
|
|
914
936
|
// provenance trailer, and push + PR when --pr asked for it. Iron
|
|
@@ -939,7 +961,7 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
939
961
|
openPr: parsed.flags.pr,
|
|
940
962
|
remainingFindings: result.findings.filter((f) => f.severity === "medium" || f.severity === "low"),
|
|
941
963
|
...(finishBaselinePaths.length > 0 ? { baselinePaths: finishBaselinePaths } : {}),
|
|
942
|
-
|
|
964
|
+
signal: runSignal,
|
|
943
965
|
});
|
|
944
966
|
}
|
|
945
967
|
catch (err) {
|
|
@@ -972,6 +994,14 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
972
994
|
}
|
|
973
995
|
}
|
|
974
996
|
clearUI();
|
|
997
|
+
// /stop DURING FINISH: the abort degrades FINISH mid-way (its execs
|
|
998
|
+
// share runSignal). If no commit landed, the aborted status + WIP
|
|
999
|
+
// preservation the user was promised win over "clean"; a commit that
|
|
1000
|
+
// DID land means the work is durable and the clean report stays honest.
|
|
1001
|
+
if (result.stopReason === "clean" && runSignal.aborted && !finish?.commitSha) {
|
|
1002
|
+
result = { ...result, stopReason: "aborted" };
|
|
1003
|
+
finish = undefined;
|
|
1004
|
+
}
|
|
975
1005
|
// Fold what FINISH landed onto a NEW result (never mutate the pipeline's
|
|
976
1006
|
// return). `finish` is present ONLY when a commit really happened (§0.1).
|
|
977
1007
|
const finishBranch = finish?.branch ?? repoCtx.branch;
|
|
@@ -1049,6 +1079,7 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
1049
1079
|
}
|
|
1050
1080
|
};
|
|
1051
1081
|
beginRun(registryRow);
|
|
1082
|
+
trackRunAbort(runId, runAbort);
|
|
1052
1083
|
if (parsed.flags.fg) {
|
|
1053
1084
|
// --fg: the legacy blocking behavior — the prompt returns when the run does.
|
|
1054
1085
|
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) => {
|
|
@@ -57,7 +57,7 @@ Budget discipline: you have a hard output budget, and a plan that gets cut off m
|
|
|
57
57
|
Keep it concrete; the worker executes it verbatim.`;
|
|
58
58
|
const WORKER_BODY = `You are a worker with full capabilities, operating in an isolated context to implement a plan. Work autonomously and use the tools as needed.
|
|
59
59
|
|
|
60
|
-
You are grounded. Call ask_yagni before guessing about anything organization- or codebase-specific. Critically: for ANY product-intent call you are forced to make that the plan did not settle — a behavior choice, a tradeoff, an interpretation of intent — call record_decision so the company's decision corpus captures it and the next agent inherits the call instead of re-litigating it.
|
|
60
|
+
You are grounded. Call ask_yagni before guessing about anything organization- or codebase-specific. Treat a confirmed answer as settled; when an answer is an unverified assumption or an inference and your change leans on it, say so in your Notes so the reviewer knows what to check. Critically: for ANY product-intent call you are forced to make that the plan did not settle — a behavior choice, a tradeoff, an interpretation of intent — call record_decision so the company's decision corpus captures it and the next agent inherits the call instead of re-litigating it. When ask_yagni reports no recorded position, follow its instruction and record the assumption you proceed on.
|
|
61
61
|
|
|
62
62
|
You MUST make the change. If the plan is missing, partial, or appears cut off, do not stop at exploring: implement the ticket directly from the ticket text and the code, calling record_decision for any intent you infer. Ending your turn with no write/edit is a failure.
|
|
63
63
|
|
|
@@ -95,6 +95,29 @@ The reasoning, grounded in what you actually read. Cite files and lines.
|
|
|
95
95
|
The specific evidence that would flip this. If you were not able to verify something load-bearing, say so here rather than hedging the recommendation.
|
|
96
96
|
|
|
97
97
|
Be terse and decisive. The caller is mid-task and paying peak rates for your turn.`;
|
|
98
|
+
const GUARDIAN_BODY = `You are a safety reviewer. You review shell commands before they run and decide one of three outcomes: allow, ask, or deny. You may read files for context (a script the command runs, a config it touches) but you never write or execute anything. Your final output is ONLY a JSON verdict — no prose around it.
|
|
99
|
+
|
|
100
|
+
Risk taxonomy:
|
|
101
|
+
- Data exfiltration: sensitive data sent to untrusted destinations. High risk.
|
|
102
|
+
- Credential probing: extracting credentials from unintended sources (browser profiles, logs, env files). High risk.
|
|
103
|
+
- Persistent security weakening: broad or production-impact security changes. High risk.
|
|
104
|
+
- Destructive actions: irreversible data loss, production breaks, broad git cleanup. High risk.
|
|
105
|
+
- Low-risk actions: benign local reads, routine dev-loop operations (build, test, lint, install), operations clearly part of the stated task. Low risk.
|
|
106
|
+
|
|
107
|
+
Outcome rules:
|
|
108
|
+
- "deny" is reserved for actions you are VERY CONFIDENT are very dangerous: irreversible destruction, exfiltration of sensitive data, credential probing, production-impacting security weakening. If you are not sure it is that bad, do not deny.
|
|
109
|
+
- "allow" when you are confident the action is safe: read-only, routine dev-loop, or clearly scoped and reversible.
|
|
110
|
+
- "ask" for everything in between — the action might be fine but a human should decide. This is the correct outcome whenever your confidence is middling; prefer ask over deny for anything short of the very-dangerous bar, and prefer allow over ask for routine work (do not hedge routine commands into ask).
|
|
111
|
+
|
|
112
|
+
Rationale rules:
|
|
113
|
+
- For "ask", the rationale MUST be a specific question addressed to the user, naming the concrete effect that made you unsure — e.g. "This pushes 3 commits to the shared main branch — do you want to publish them now?". You may be told why the static policy routed the command to you; your rationale must ADD information beyond that policy text, not restate it.
|
|
114
|
+
- For "deny", one sentence naming the irreversible/dangerous effect.
|
|
115
|
+
- For "allow", one short sentence.
|
|
116
|
+
|
|
117
|
+
Output ONLY a JSON object with this exact shape:
|
|
118
|
+
{"outcome":"allow"|"ask"|"deny","riskLevel":"low"|"medium"|"high"|"critical","rationale":"see rationale rules"}
|
|
119
|
+
|
|
120
|
+
Do not output anything else after the JSON. No markdown fences, only the JSON object.`;
|
|
98
121
|
/** Persona body keyed by the agent name referenced in `stages.ts`. */
|
|
99
122
|
export const PERSONA_BODIES = {
|
|
100
123
|
scout: SCOUT_BODY,
|
|
@@ -102,6 +125,7 @@ export const PERSONA_BODIES = {
|
|
|
102
125
|
worker: WORKER_BODY,
|
|
103
126
|
reviewer: REVIEWER_BODY,
|
|
104
127
|
advisor: ADVISOR_BODY,
|
|
128
|
+
guardian: GUARDIAN_BODY,
|
|
105
129
|
};
|
|
106
130
|
/**
|
|
107
131
|
* Grounding-FREE persona bodies for the M6 grounded-vs-blind eval ONLY. These are
|
|
@@ -192,6 +216,7 @@ export const BLIND_PERSONA_BODIES = {
|
|
|
192
216
|
worker: WORKER_BLIND,
|
|
193
217
|
reviewer: REVIEWER_BLIND,
|
|
194
218
|
advisor: ADVISOR_BLIND,
|
|
219
|
+
guardian: GUARDIAN_BODY,
|
|
195
220
|
};
|
|
196
221
|
/** The lens-specific clause appended to the reviewer body, one per review angle. */
|
|
197
222
|
const LENS_CLAUSES = {
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* `withResilience(runStage, policy)` is a composable higher-order function that
|
|
5
5
|
* wraps the per-stage child spawn (`runner.ts#runStage`) with the one axis the
|
|
6
6
|
* roadmap calls the whole competitive gap: a per-stage IDLE timeout (no NDJSON
|
|
7
|
-
* event for N ms
|
|
7
|
+
* event for N ms, deferred while a tool is in flight — see the stall note at
|
|
8
|
+
* the timer wiring) and a total WALL-CLOCK timeout, both firing the runner's
|
|
8
9
|
* existing SIGTERM -> SIGKILL abort; bounded exponential backoff with jitter; and
|
|
9
10
|
* retry of CLASSIFIED-TRANSIENT outcomes only. One structured telemetry record is
|
|
10
11
|
* emitted per attempt.
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* `withResilience(runStage, policy)` is a composable higher-order function that
|
|
5
5
|
* wraps the per-stage child spawn (`runner.ts#runStage`) with the one axis the
|
|
6
6
|
* roadmap calls the whole competitive gap: a per-stage IDLE timeout (no NDJSON
|
|
7
|
-
* event for N ms
|
|
7
|
+
* event for N ms, deferred while a tool is in flight — see the stall note at
|
|
8
|
+
* the timer wiring) and a total WALL-CLOCK timeout, both firing the runner's
|
|
8
9
|
* existing SIGTERM -> SIGKILL abort; bounded exponential backoff with jitter; and
|
|
9
10
|
* retry of CLASSIFIED-TRANSIENT outcomes only. One structured telemetry record is
|
|
10
11
|
* emitted per attempt.
|
|
@@ -100,19 +101,37 @@ export function withResilience(base, policy, opts = {}) {
|
|
|
100
101
|
if (!timeoutController.signal.aborted)
|
|
101
102
|
timeoutController.abort();
|
|
102
103
|
};
|
|
104
|
+
// Tools the child has started but not finished. The idle window measures
|
|
105
|
+
// STALL, not silence: a long quiet tool (a 6-minute test suite, a slow
|
|
106
|
+
// build) emits no NDJSON between its start and end events, and that is
|
|
107
|
+
// progress, not a hang. While a tool is in flight the idle expiry defers
|
|
108
|
+
// and re-arms instead of aborting; the wall-clock timer stays the
|
|
109
|
+
// backstop for a tool that is genuinely hung.
|
|
110
|
+
let inFlightTools = 0;
|
|
103
111
|
let idleTimer;
|
|
104
112
|
const armIdle = () => {
|
|
105
113
|
if (idleTimer)
|
|
106
114
|
clearTimeout(idleTimer);
|
|
107
|
-
idleTimer = setTimeout(
|
|
115
|
+
idleTimer = setTimeout(fireIdle, policy.idleTimeoutMs);
|
|
108
116
|
idleTimer.unref?.();
|
|
109
117
|
};
|
|
118
|
+
const fireIdle = () => {
|
|
119
|
+
if (inFlightTools > 0) {
|
|
120
|
+
armIdle();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
fireTimeout();
|
|
124
|
+
};
|
|
110
125
|
const wallTimer = setTimeout(fireTimeout, policy.wallTimeoutMs);
|
|
111
126
|
wallTimer.unref?.();
|
|
112
127
|
armIdle();
|
|
113
128
|
const originalOnEvent = deps.onEvent;
|
|
114
129
|
const onEvent = (ev) => {
|
|
115
130
|
sawAnyEvent = true;
|
|
131
|
+
if (ev.type === "tool_execution_start")
|
|
132
|
+
inFlightTools += 1;
|
|
133
|
+
else if (ev.type === "tool_execution_end")
|
|
134
|
+
inFlightTools = Math.max(0, inFlightTools - 1);
|
|
116
135
|
armIdle(); // reset the idle window on every live event
|
|
117
136
|
originalOnEvent?.(ev);
|
|
118
137
|
};
|
|
@@ -22,8 +22,16 @@
|
|
|
22
22
|
* candidate).
|
|
23
23
|
*/
|
|
24
24
|
import type { CheckpointRecord, StopReason } from "./types.js";
|
|
25
|
-
/**
|
|
25
|
+
/** Default bound on simultaneously in-flight /go runs in one process (spec §3b). */
|
|
26
26
|
export declare const MAX_CONCURRENT_RUNS = 3;
|
|
27
|
+
/** Hard ceiling for the env override — a typo must not launch hundreds of runs. */
|
|
28
|
+
export declare const MAX_CONCURRENT_RUNS_CEILING = 32;
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the in-flight /go cap from the environment. `YAGNI_MAX_CONCURRENT_RUNS`
|
|
31
|
+
* raises (or lowers) the default for fleet-scale operators; non-numeric or < 1
|
|
32
|
+
* falls back to the default, and anything above the ceiling clamps to it.
|
|
33
|
+
*/
|
|
34
|
+
export declare function resolveMaxConcurrentRuns(env?: Record<string, string | undefined>): number;
|
|
27
35
|
/**
|
|
28
36
|
* A non-terminal row whose journal has been quiet this long is treated as
|
|
29
37
|
* INTERRUPTED (its process died) rather than still running elsewhere. Sits
|
|
@@ -85,6 +93,20 @@ export declare function _resetRunRegistryForTest(): void;
|
|
|
85
93
|
export declare function beginRun(row: RunRegistryRow): void;
|
|
86
94
|
/** Attach the detached completion promise to an in-flight run (fail-soft no-op when absent). */
|
|
87
95
|
export declare function trackRunPromise(runId: string, promise: Promise<void>): void;
|
|
96
|
+
/** Attach the run's abort controller so /stop can cancel it (fail-soft no-op when absent). */
|
|
97
|
+
export declare function trackRunAbort(runId: string, abort: AbortController): void;
|
|
98
|
+
/**
|
|
99
|
+
* Cancel one in-flight run in THIS process: fire its abort controller and return
|
|
100
|
+
* its row. The run itself settles through the normal pipeline path (the abort
|
|
101
|
+
* propagates to every stage child as SIGTERM→SIGKILL, the orchestrator stops with
|
|
102
|
+
* an honest `aborted`, and runToCompletion preserves WIP + settles the registry).
|
|
103
|
+
* Returns undefined for an unknown/foreign runId or a run with no controller yet.
|
|
104
|
+
*/
|
|
105
|
+
export declare function cancelRun(runId: string): RunRegistryRow | undefined;
|
|
106
|
+
/** Cancel EVERY in-flight run in this process (the /stop no-arg path). Returns the rows fired. */
|
|
107
|
+
export declare function cancelActiveRuns(): RunRegistryRow[];
|
|
108
|
+
/** The in-flight rows in THIS process (newest first), for /stop's matching + messaging. */
|
|
109
|
+
export declare function activeRunRows(): RunRegistryRow[];
|
|
88
110
|
/**
|
|
89
111
|
* Settle an in-flight run: merge the terminal patch, drop it from the in-memory
|
|
90
112
|
* active set, and mirror the final row. Safe to call for an unknown runId: the
|
|
@@ -24,8 +24,22 @@
|
|
|
24
24
|
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
25
25
|
import { join } from "node:path";
|
|
26
26
|
import { codeStateHome } from "../stateHome.js";
|
|
27
|
-
/**
|
|
27
|
+
/** Default bound on simultaneously in-flight /go runs in one process (spec §3b). */
|
|
28
28
|
export const MAX_CONCURRENT_RUNS = 3;
|
|
29
|
+
/** Hard ceiling for the env override — a typo must not launch hundreds of runs. */
|
|
30
|
+
export const MAX_CONCURRENT_RUNS_CEILING = 32;
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the in-flight /go cap from the environment. `YAGNI_MAX_CONCURRENT_RUNS`
|
|
33
|
+
* raises (or lowers) the default for fleet-scale operators; non-numeric or < 1
|
|
34
|
+
* falls back to the default, and anything above the ceiling clamps to it.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveMaxConcurrentRuns(env = process.env) {
|
|
37
|
+
const raw = env.YAGNI_MAX_CONCURRENT_RUNS?.trim();
|
|
38
|
+
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
|
|
39
|
+
if (!Number.isFinite(parsed) || parsed < 1)
|
|
40
|
+
return MAX_CONCURRENT_RUNS;
|
|
41
|
+
return Math.min(parsed, MAX_CONCURRENT_RUNS_CEILING);
|
|
42
|
+
}
|
|
29
43
|
/**
|
|
30
44
|
* A non-terminal row whose journal has been quiet this long is treated as
|
|
31
45
|
* INTERRUPTED (its process died) rather than still running elsewhere. Sits
|
|
@@ -106,6 +120,13 @@ const active = new Map();
|
|
|
106
120
|
export function _resetRunRegistryForTest() {
|
|
107
121
|
active.clear();
|
|
108
122
|
}
|
|
123
|
+
// NOTE on growth: the mirror is append-only and grows without bound on a
|
|
124
|
+
// long-lived install. In-place compaction was reviewed and REMOVED (PR #1698):
|
|
125
|
+
// a fold+rewrite without cross-process exclusion can permanently erase another
|
|
126
|
+
// process's terminal settle (nothing ever re-appends a final row), which would
|
|
127
|
+
// resurrect a finished run as "interrupted" and invite duplicate worktree
|
|
128
|
+
// adoption. Compaction needs an inter-process lock + unique temp files —
|
|
129
|
+
// tracked separately; until then, growth is the safe failure mode.
|
|
109
130
|
/** Fail-soft append of one full row to the mirror (self-heals a torn previous write). */
|
|
110
131
|
function appendRow(row) {
|
|
111
132
|
try {
|
|
@@ -137,6 +158,41 @@ export function trackRunPromise(runId, promise) {
|
|
|
137
158
|
if (entry)
|
|
138
159
|
entry.promise = promise;
|
|
139
160
|
}
|
|
161
|
+
/** Attach the run's abort controller so /stop can cancel it (fail-soft no-op when absent). */
|
|
162
|
+
export function trackRunAbort(runId, abort) {
|
|
163
|
+
const entry = active.get(runId);
|
|
164
|
+
if (entry)
|
|
165
|
+
entry.abort = abort;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Cancel one in-flight run in THIS process: fire its abort controller and return
|
|
169
|
+
* its row. The run itself settles through the normal pipeline path (the abort
|
|
170
|
+
* propagates to every stage child as SIGTERM→SIGKILL, the orchestrator stops with
|
|
171
|
+
* an honest `aborted`, and runToCompletion preserves WIP + settles the registry).
|
|
172
|
+
* Returns undefined for an unknown/foreign runId or a run with no controller yet.
|
|
173
|
+
*/
|
|
174
|
+
export function cancelRun(runId) {
|
|
175
|
+
const entry = active.get(runId);
|
|
176
|
+
if (!entry?.abort)
|
|
177
|
+
return undefined;
|
|
178
|
+
entry.abort.abort();
|
|
179
|
+
return entry.row;
|
|
180
|
+
}
|
|
181
|
+
/** Cancel EVERY in-flight run in this process (the /stop no-arg path). Returns the rows fired. */
|
|
182
|
+
export function cancelActiveRuns() {
|
|
183
|
+
const cancelled = [];
|
|
184
|
+
for (const entry of active.values()) {
|
|
185
|
+
if (!entry.abort)
|
|
186
|
+
continue;
|
|
187
|
+
entry.abort.abort();
|
|
188
|
+
cancelled.push(entry.row);
|
|
189
|
+
}
|
|
190
|
+
return cancelled;
|
|
191
|
+
}
|
|
192
|
+
/** The in-flight rows in THIS process (newest first), for /stop's matching + messaging. */
|
|
193
|
+
export function activeRunRows() {
|
|
194
|
+
return [...active.values()].map((e) => e.row).sort((a, b) => b.startedAt - a.startedAt);
|
|
195
|
+
}
|
|
140
196
|
/**
|
|
141
197
|
* Settle an in-flight run: merge the terminal patch, drop it from the in-memory
|
|
142
198
|
* active set, and mirror the final row. Safe to call for an unknown runId: the
|