omp-conductor 0.20.1 → 0.20.3
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/schema/config.schema.json +24 -0
- package/src/commands/companion.ts +52 -16
- package/src/commands/daemon.ts +14 -25
- package/src/commands/drain.ts +12 -5
- package/src/commands/worker.ts +5 -0
- package/src/config-schema.ts +8 -0
- package/src/config.ts +17 -2
- package/src/daemon/drain.ts +33 -0
- package/src/daemon/http.ts +28 -0
- package/src/daemon/review.ts +11 -11
- package/src/daemon/supervision.ts +144 -1
- package/src/daemon/tick.ts +46 -15
- package/src/daemon/views.ts +17 -0
- package/src/daemon.ts +2 -1
- package/src/decisions.ts +20 -9
- package/src/diff-flags.ts +186 -31
- package/src/doctor.ts +320 -9
- package/src/escalate.ts +187 -15
- package/src/failure-class.ts +176 -4
- package/src/fleet.ts +444 -50
- package/src/graph-health.ts +17 -1
- package/src/groom.ts +11 -0
- package/src/knowledge.ts +75 -21
- package/src/lifecycle.ts +267 -5
- package/src/orchestrator-tick.ts +8 -2
- package/src/ready-gate.ts +100 -5
- package/src/reports.ts +4 -1
- package/src/settlement.ts +98 -7
- package/src/status-render.ts +27 -2
- package/src/store.ts +50 -13
- package/src/to-spec.ts +52 -0
- package/src/tracker/github.ts +132 -10
- package/src/types.ts +31 -0
- package/src/upgrade.ts +12 -5
- package/src/verbs/server.ts +16 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
|
|
@@ -92,6 +92,18 @@
|
|
|
92
92
|
"minimum": 0,
|
|
93
93
|
"description": "Wall-clock ceiling for one worker (default 5400000)"
|
|
94
94
|
},
|
|
95
|
+
"workerStallSilenceMs": {
|
|
96
|
+
"anyOf": [
|
|
97
|
+
{
|
|
98
|
+
"type": "number",
|
|
99
|
+
"minimum": 0
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"type": "null"
|
|
103
|
+
}
|
|
104
|
+
],
|
|
105
|
+
"description": "How long a live worker's transcript may stay unwritten before the daemon settles it as a progress stall; null derives a third of workerWallClockMs"
|
|
106
|
+
},
|
|
95
107
|
"maxAttemptsPerIssue": {
|
|
96
108
|
"type": "number",
|
|
97
109
|
"minimum": 0,
|
|
@@ -357,6 +369,18 @@
|
|
|
357
369
|
"minimum": 0,
|
|
358
370
|
"description": "Wall-clock ceiling for one worker (default 5400000)"
|
|
359
371
|
},
|
|
372
|
+
"workerStallSilenceMs": {
|
|
373
|
+
"anyOf": [
|
|
374
|
+
{
|
|
375
|
+
"type": "number",
|
|
376
|
+
"minimum": 0
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
"type": "null"
|
|
380
|
+
}
|
|
381
|
+
],
|
|
382
|
+
"description": "How long a live worker's transcript may stay unwritten before the daemon settles it as a progress stall; null derives a third of workerWallClockMs"
|
|
383
|
+
},
|
|
360
384
|
"maxAttemptsPerIssue": {
|
|
361
385
|
"type": "number",
|
|
362
386
|
"minimum": 0,
|
|
@@ -53,6 +53,52 @@ export function drawFrame(lines: readonly string[], previous: number): string {
|
|
|
53
53
|
return lines.length < previous ? `${HOME}${body}\n${ERASE_BELOW}` : `${HOME}${body}\n`;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* How the pane sleeps out the interval between renders. Production is
|
|
58
|
+
* {@link timerSleep}; tests inject a fake (#1085) so no real timer runs and
|
|
59
|
+
* the wait's scheduling is asserted directly.
|
|
60
|
+
*/
|
|
61
|
+
export type CompanionSleep = (ms: number, signal: AbortSignal) => Promise<void>;
|
|
62
|
+
|
|
63
|
+
/** The production sleep: one ref'd timeout for the whole interval, ended
|
|
64
|
+
* early by an abort. Ref'd deliberately (#1085): the pending deadline is the
|
|
65
|
+
* event loop's reason to sleep out the minute. The old wait unref'd this
|
|
66
|
+
* timer and polled `stopped` every 250 ms instead — with nothing ref'd left
|
|
67
|
+
* to wait on, the loop never slept, and the pane burned ~99% of a core
|
|
68
|
+
* between renders (~10k epoll wakes a second, measured). The unrefs' real
|
|
69
|
+
* job — never outlive the pane — belongs to cleanup, not to the handle flag:
|
|
70
|
+
* every exit path here clears the timer and detaches its listener, SIGINT
|
|
71
|
+
* and SIGTERM abort within microseconds of delivery, and a pane closed by
|
|
72
|
+
* its terminal still kills the process by signal regardless of all this.
|
|
73
|
+
* Same shape as `sleepUntilAbort` in commands/tail.ts and board.ts's
|
|
74
|
+
* `waitForInput`, which never had the spin because its deadline stayed
|
|
75
|
+
* ref'd. */
|
|
76
|
+
export const timerSleep: CompanionSleep = (ms, signal) => {
|
|
77
|
+
// An already-aborted signal can never fire its listener.
|
|
78
|
+
if (signal.aborted) return Promise.resolve();
|
|
79
|
+
return new Promise<void>((resolve) => {
|
|
80
|
+
const done = (): void => {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
signal.removeEventListener("abort", done);
|
|
83
|
+
resolve();
|
|
84
|
+
};
|
|
85
|
+
const timer = setTimeout(done, ms);
|
|
86
|
+
signal.addEventListener("abort", done, { once: true });
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
/** Wait out one refresh interval, ending early the moment `stopped` aborts.
|
|
90
|
+
* Exactly one sleep is scheduled per render — the poll this replaced
|
|
91
|
+
* scheduled four a second and spun the process hot between them (#1085).
|
|
92
|
+
* A stop that has already landed ends the wait before anything is scheduled.
|
|
93
|
+
* Tests drive the wait through an injected {@link CompanionSleep}. */
|
|
94
|
+
export async function waitOutRefreshInterval(
|
|
95
|
+
stopped: AbortSignal,
|
|
96
|
+
sleep: CompanionSleep = timerSleep,
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
if (stopped.aborted) return;
|
|
99
|
+
await sleep(COMPANION_REFRESH_SECONDS * 1_000, stopped);
|
|
100
|
+
}
|
|
101
|
+
|
|
56
102
|
export async function companionCommand(ctx: CommandContext): Promise<void> {
|
|
57
103
|
const sub = ctx.argv[1];
|
|
58
104
|
if (sub !== "decisions") {
|
|
@@ -65,14 +111,14 @@ export async function companionCommand(ctx: CommandContext): Promise<void> {
|
|
|
65
111
|
const once = ctx.argv.includes("--once");
|
|
66
112
|
|
|
67
113
|
let previous = 0;
|
|
68
|
-
|
|
114
|
+
const stopped = new AbortController();
|
|
69
115
|
const stop = (): void => {
|
|
70
|
-
stopped
|
|
116
|
+
stopped.abort();
|
|
71
117
|
};
|
|
72
118
|
process.on("SIGINT", stop);
|
|
73
119
|
process.on("SIGTERM", stop);
|
|
74
120
|
|
|
75
|
-
while (!stopped) {
|
|
121
|
+
while (!stopped.signal.aborted) {
|
|
76
122
|
// Opened per render, closed immediately: a companion pane lives for days,
|
|
77
123
|
// and a handle held that long across daemon restarts and db snapshots is a
|
|
78
124
|
// handle to a file that may no longer be the store.
|
|
@@ -86,18 +132,8 @@ export async function companionCommand(ctx: CommandContext): Promise<void> {
|
|
|
86
132
|
process.stdout.write(drawFrame(lines, previous));
|
|
87
133
|
previous = lines.length;
|
|
88
134
|
if (once) return;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// closing the pane, and a lingering timer would outlive it.
|
|
93
|
-
timer.unref?.();
|
|
94
|
-
const poll = setInterval(() => {
|
|
95
|
-
if (!stopped) return;
|
|
96
|
-
clearInterval(poll);
|
|
97
|
-
clearTimeout(timer);
|
|
98
|
-
resolve();
|
|
99
|
-
}, 250);
|
|
100
|
-
poll.unref?.();
|
|
101
|
-
});
|
|
135
|
+
// One ref'd sleep for the whole minute, aborted early on SIGINT/SIGTERM
|
|
136
|
+
// — see timerSleep above for why it must stay ref'd (#1085).
|
|
137
|
+
await waitOutRefreshInterval(stopped.signal);
|
|
102
138
|
}
|
|
103
139
|
}
|
package/src/commands/daemon.ts
CHANGED
|
@@ -8,15 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { CommandContext } from "./context.ts";
|
|
10
10
|
import { runDaemon } from "../daemon.ts";
|
|
11
|
-
import { clearRecord,
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* `DaemonRecord.logFile` for a daemon nobody spawned. The field is required and
|
|
15
|
-
* `status` prints it, so it has to say something true: a foreground daemon
|
|
16
|
-
* opened no log of its own — whoever started it owns its stdout, be that
|
|
17
|
-
* systemd's journal, a terminal, or a pane.
|
|
18
|
-
*/
|
|
19
|
-
const FOREGROUND_LOG = "<inherited stdout — started in the foreground>";
|
|
11
|
+
import { clearRecord, publishForegroundRecord, startRecordRepair } from "../lifecycle.ts";
|
|
20
12
|
|
|
21
13
|
export async function daemonCommand(ctx: CommandContext): Promise<void> {
|
|
22
14
|
// Until now only `lifecycle.startDaemon()` — the spawn path — wrote the
|
|
@@ -42,28 +34,25 @@ if (once) {
|
|
|
42
34
|
return;
|
|
43
35
|
}
|
|
44
36
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
37
|
+
// The boot half of pidfile ownership (#1114): a systemd-started or hand-run
|
|
38
|
+
// foreground daemon publishes its own record in lifecycle — refusing, having
|
|
39
|
+
// done nothing, while another live daemon owns the record.
|
|
40
|
+
const claimed = publishForegroundRecord({ port, project });
|
|
41
|
+
if (claimed.kind === "foreign-live") {
|
|
42
|
+
process.stderr.write(`omp-conductor: another daemon is alive (pid ${claimed.pid}); stop it first\n`);
|
|
48
43
|
process.exit(1);
|
|
49
44
|
}
|
|
50
45
|
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
writeRecord({
|
|
56
|
-
pid: process.pid,
|
|
57
|
-
port: port ?? DEFAULT_PORT,
|
|
58
|
-
startedAt: Date.now(),
|
|
59
|
-
logFile: FOREGROUND_LOG,
|
|
60
|
-
...(project === undefined ? {} : { project }),
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
46
|
+
// The running half (#1114): while this process is the daemon it keeps its own
|
|
47
|
+
// record alive, so an external sweep of the runtime directory cannot leave
|
|
48
|
+
// the single-owner guard disarmed until the next restart.
|
|
49
|
+
const repair = startRecordRepair({ port, project });
|
|
64
50
|
try {
|
|
65
51
|
await runDaemon({ once, port, project });
|
|
66
52
|
} finally {
|
|
53
|
+
// Stop repairing before clearing, so no interval tick can resurrect the
|
|
54
|
+
// file after the removal below.
|
|
55
|
+
repair.stop();
|
|
67
56
|
// The record names a pid that is about to stop existing. Leaving it
|
|
68
57
|
// behind makes the next reader probe a ghost before believing us.
|
|
69
58
|
clearRecord();
|
package/src/commands/drain.ts
CHANGED
|
@@ -24,13 +24,15 @@ usage:
|
|
|
24
24
|
omp-conductor drain cancel [--project NAME]
|
|
25
25
|
|
|
26
26
|
A drain is a durable, self-expiring admission fence: new claims pause while
|
|
27
|
-
existing runs settle,
|
|
27
|
+
existing runs settle, an emptied fleet OPENS the release window rather than
|
|
28
|
+
ending the drain (#1078), and admission resumes automatically at the absolute
|
|
28
29
|
deadline — even if the orchestrator crashes. start replaces any prior drain of
|
|
29
30
|
the project; --until takes an ISO instant or a relative duration (90s, 45m,
|
|
30
31
|
2h, 1d) that must be bounded and in the future. status reports the active
|
|
31
|
-
drain's creation time, absolute expiry, reason,
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
drain's creation time, absolute expiry, reason, remaining active runs, and
|
|
33
|
+
whether the window is still draining or already drained and holding. cancel
|
|
34
|
+
removes the project's drain and is idempotent. A drain never touches the
|
|
35
|
+
pause sentinel, the arm marker, or any queue label — it is the file record
|
|
34
36
|
that expires on its own.`;
|
|
35
37
|
|
|
36
38
|
/** The flags each drain subcommand accepts, after the subcommand itself. */
|
|
@@ -143,7 +145,7 @@ export async function drainCommand(ctx: CommandContext): Promise<void> {
|
|
|
143
145
|
);
|
|
144
146
|
process.stdout.write(
|
|
145
147
|
dim(
|
|
146
|
-
"the drain is a durable record: it survives crashes and resumes admission at the deadline on its own",
|
|
148
|
+
"the drain is a durable record: it survives crashes, holds the window open once the fleet empties, and resumes admission at the deadline on its own",
|
|
147
149
|
) + "\n",
|
|
148
150
|
);
|
|
149
151
|
return;
|
|
@@ -161,6 +163,11 @@ export async function drainCommand(ctx: CommandContext): Promise<void> {
|
|
|
161
163
|
` expires at ${new Date(drain.expiresAt).toISOString()}`,
|
|
162
164
|
...(drain.reason === undefined ? [] : [` reason ${drain.reason}`]),
|
|
163
165
|
` remaining ${drain.remainingRuns} active run${drain.remainingRuns === 1 ? "" : "s"}`,
|
|
166
|
+
...(drain.remainingRuns === 0
|
|
167
|
+
? [
|
|
168
|
+
` state drained and holding until ${new Date(drain.expiresAt).toISOString()} — admission resumes at the deadline`,
|
|
169
|
+
]
|
|
170
|
+
: [" state draining — the window holds while runs settle"]),
|
|
164
171
|
];
|
|
165
172
|
process.stdout.write(`${lines.join("\n")}\n`);
|
|
166
173
|
return;
|
package/src/commands/worker.ts
CHANGED
|
@@ -33,6 +33,10 @@ if (sub === "stop" && (reason === undefined || reason === "" || reason.length >
|
|
|
33
33
|
);
|
|
34
34
|
process.exit(2);
|
|
35
35
|
}
|
|
36
|
+
// A bare presence flag, parsed like every other boolean flag in this CLI:
|
|
37
|
+
// the daemon refuses a stop that would strand an open PR (#1101), and this is
|
|
38
|
+
// the operator's explicit record of choosing to do so anyway.
|
|
39
|
+
const allowOpenPr = sub === "stop" && ctx.argv.includes("--allow-open-pr");
|
|
36
40
|
const project = findProject(loadConfig(), ctx.projectFlag);
|
|
37
41
|
// The pidfile is authoritative when it names a live process; a missing or
|
|
38
42
|
// stale record falls back to the systemd unit, proved through its own
|
|
@@ -51,6 +55,7 @@ const response = await fetch(
|
|
|
51
55
|
project: project.name,
|
|
52
56
|
source: "cli",
|
|
53
57
|
...(reason === undefined ? {} : { reason }),
|
|
58
|
+
...(allowOpenPr ? { allowOpenPr: true } : {}),
|
|
54
59
|
}),
|
|
55
60
|
},
|
|
56
61
|
);
|
package/src/config-schema.ts
CHANGED
|
@@ -137,6 +137,14 @@ const capsSchema = z
|
|
|
137
137
|
workerMaxTurns: z.number().min(0).describe(`Turn ceiling for one worker (default ${DEFAULT_CAPS.workerMaxTurns})`),
|
|
138
138
|
workerMaxTurnsCeiling: z.number().min(0).describe(`Maximum turn budget assignable to one issue's next attempt`),
|
|
139
139
|
workerWallClockMs: z.number().min(0).describe(`Wall-clock ceiling for one worker (default ${DEFAULT_CAPS.workerWallClockMs})`),
|
|
140
|
+
workerStallSilenceMs: z
|
|
141
|
+
.number()
|
|
142
|
+
.min(0)
|
|
143
|
+
.nullable()
|
|
144
|
+
.describe(
|
|
145
|
+
"How long a live worker's transcript may stay unwritten before the daemon settles it as a " +
|
|
146
|
+
"progress stall; null derives a third of workerWallClockMs",
|
|
147
|
+
),
|
|
140
148
|
maxAttemptsPerIssue: z.number().min(0).describe(`Failed attempts allowed before escalation (default ${DEFAULT_CAPS.maxAttemptsPerIssue})`),
|
|
141
149
|
maxContinuationsPerIssue: z.number().min(0).describe(`Operational continuations allowed before crash/resume escalation (default ${DEFAULT_CAPS.maxContinuationsPerIssue})`),
|
|
142
150
|
})
|
package/src/config.ts
CHANGED
|
@@ -293,6 +293,11 @@ export function resolveCaps(p: ProjectConfig, defaults: Caps): Caps {
|
|
|
293
293
|
o.workerMaxTurnsCeiling ??
|
|
294
294
|
(o.workerMaxTurns === undefined ? defaults.workerMaxTurnsCeiling : workerMaxTurns * 2),
|
|
295
295
|
workerWallClockMs: o.workerWallClockMs ?? defaults.workerWallClockMs,
|
|
296
|
+
// Same `!== undefined` reading as `maxRunSpendUsd`: an explicit `null`
|
|
297
|
+
// means "derive from workerWallClockMs", not "inherit a global override"
|
|
298
|
+
// (#1086).
|
|
299
|
+
workerStallSilenceMs:
|
|
300
|
+
o.workerStallSilenceMs !== undefined ? o.workerStallSilenceMs : defaults.workerStallSilenceMs,
|
|
296
301
|
maxAttemptsPerIssue: o.maxAttemptsPerIssue ?? defaults.maxAttemptsPerIssue,
|
|
297
302
|
maxContinuationsPerIssue:
|
|
298
303
|
o.maxContinuationsPerIssue ?? defaults.maxContinuationsPerIssue,
|
|
@@ -1109,7 +1114,9 @@ function capProblem(key: string, found: string): string {
|
|
|
1109
1114
|
}
|
|
1110
1115
|
return key === "dailySpendUsd"
|
|
1111
1116
|
? `caps.dailySpendUsd must be a non-negative finite number or null (no cap), found ${found}`
|
|
1112
|
-
:
|
|
1117
|
+
: key === "workerStallSilenceMs"
|
|
1118
|
+
? `caps.workerStallSilenceMs must be a non-negative finite number or null (derive it from workerWallClockMs), found ${found}`
|
|
1119
|
+
: `caps.${key} must be a non-negative finite number, found ${found}`;
|
|
1113
1120
|
}
|
|
1114
1121
|
|
|
1115
1122
|
/** Quted fallback key lists for policy unknown-key errors. */
|
|
@@ -1729,10 +1736,18 @@ function reconcileCaps(
|
|
|
1729
1736
|
out.dailySpendUsd = null;
|
|
1730
1737
|
continue;
|
|
1731
1738
|
}
|
|
1739
|
+
if (key === "workerStallSilenceMs" && v === null) {
|
|
1740
|
+
// Null is the derive-it value (#1086): a third of the wall-clock ceiling,
|
|
1741
|
+
// computed where the window is read, so it passes through untouched.
|
|
1742
|
+
out.workerStallSilenceMs = null;
|
|
1743
|
+
continue;
|
|
1744
|
+
}
|
|
1732
1745
|
problems.push(
|
|
1733
1746
|
key === "dailySpendUsd"
|
|
1734
1747
|
? `${label}.${key} must be a non-negative finite number or null (no cap), found ${JSON.stringify(v)}`
|
|
1735
|
-
:
|
|
1748
|
+
: key === "workerStallSilenceMs"
|
|
1749
|
+
? `${label}.${key} must be a non-negative finite number or null (derive it from workerWallClockMs), found ${JSON.stringify(v)}`
|
|
1750
|
+
: `${label}.${key} must be a non-negative finite number, found ${JSON.stringify(v)}`,
|
|
1736
1751
|
);
|
|
1737
1752
|
}
|
|
1738
1753
|
|
package/src/daemon/drain.ts
CHANGED
|
@@ -35,6 +35,10 @@ export interface DrainRecord {
|
|
|
35
35
|
createdAt: string;
|
|
36
36
|
/** Absolute ISO deadline: admission resumes automatically at or after it. */
|
|
37
37
|
expiresAt: string;
|
|
38
|
+
/** ISO instant a dispatch pass first observed an empty active set under
|
|
39
|
+
* this drain — the moment the release window opened (#1078). Written by
|
|
40
|
+
* the tick's drain pass, never by the operator; absent until then. */
|
|
41
|
+
drainedAt?: string;
|
|
38
42
|
/** Purpose recorded at creation (release window, maintenance…). */
|
|
39
43
|
reason?: string;
|
|
40
44
|
}
|
|
@@ -54,6 +58,7 @@ export type DrainProblem =
|
|
|
54
58
|
| "invalid-project"
|
|
55
59
|
| "invalid-created-at"
|
|
56
60
|
| "invalid-expires-at"
|
|
61
|
+
| "invalid-drained-at"
|
|
57
62
|
| "expiry-not-future"
|
|
58
63
|
| "invalid-reason";
|
|
59
64
|
|
|
@@ -143,6 +148,13 @@ export function readDrain(project: string, now = Date.now()): DrainVerdict {
|
|
|
143
148
|
if (reason !== undefined && typeof reason !== "string") {
|
|
144
149
|
return { kind: "error", problem: "invalid-reason" };
|
|
145
150
|
}
|
|
151
|
+
const drainedAt = rec["drainedAt"];
|
|
152
|
+
if (
|
|
153
|
+
drainedAt !== undefined &&
|
|
154
|
+
(typeof drainedAt !== "string" || Number.isNaN(Date.parse(drainedAt)))
|
|
155
|
+
) {
|
|
156
|
+
return { kind: "error", problem: "invalid-drained-at" };
|
|
157
|
+
}
|
|
146
158
|
if (rec["project"] !== project) {
|
|
147
159
|
// A record persisted at this project's path but naming another project is
|
|
148
160
|
// either a copy or a rename mishap; it fences nobody (that project's drain
|
|
@@ -157,6 +169,7 @@ export function readDrain(project: string, now = Date.now()): DrainVerdict {
|
|
|
157
169
|
createdAt: rec["createdAt"],
|
|
158
170
|
expiresAt: rec["expiresAt"],
|
|
159
171
|
...(reason === undefined ? {} : { reason }),
|
|
172
|
+
...(drainedAt === undefined ? {} : { drainedAt }),
|
|
160
173
|
};
|
|
161
174
|
return { kind: "active", drain };
|
|
162
175
|
}
|
|
@@ -179,6 +192,26 @@ export function consumeDrain(project: string, now = Date.now()): DrainVerdict {
|
|
|
179
192
|
return verdict;
|
|
180
193
|
}
|
|
181
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Marks an active drain with the instant its active set first reached zero —
|
|
197
|
+
* the moment the release window opened (#1078). A no-op for anything that is
|
|
198
|
+
* not a fresh, unmarked fence: absent, expired, malformed and already-marked
|
|
199
|
+
* records are left exactly as they were, so only the opening transition ever
|
|
200
|
+
* writes. The rewrite is atomic (tmp + rename, exactly like
|
|
201
|
+
* {@link createDrain}), so a crash mid-write can never leave a half-record.
|
|
202
|
+
* The annotation is what makes the window's opening durable and keeps the
|
|
203
|
+
* tick's log line a once-per-drain event instead of once-per-pass noise.
|
|
204
|
+
*/
|
|
205
|
+
export function markDrained(project: string, now = Date.now()): void {
|
|
206
|
+
const verdict = readDrain(project, now);
|
|
207
|
+
if (verdict.kind !== "active" || verdict.drain.drainedAt !== undefined) return;
|
|
208
|
+
const marked: DrainRecord = { ...verdict.drain, drainedAt: new Date(now).toISOString() };
|
|
209
|
+
const path = drainPath(project);
|
|
210
|
+
const tmp = `${path}.${process.pid.toString(36)}.${Date.now().toString(36)}.tmp`;
|
|
211
|
+
writeFileSync(tmp, `${JSON.stringify(marked, null, 2)}\n`);
|
|
212
|
+
renameSync(tmp, path);
|
|
213
|
+
}
|
|
214
|
+
|
|
182
215
|
/** Removes this project's drain, idempotently — a second cancel is a no-op. */
|
|
183
216
|
export function cancelDrain(project: string): void {
|
|
184
217
|
rmSync(drainPath(project), { force: true });
|
package/src/daemon/http.ts
CHANGED
|
@@ -176,6 +176,34 @@ export async function workerControlResponse(
|
|
|
176
176
|
|
|
177
177
|
|
|
178
178
|
const issue = Number(match[1]);
|
|
179
|
+
// The #1101 front door: stopping a live run that has already pushed a PR
|
|
180
|
+
// strands that PR — the review verb could not return it and dispatch will
|
|
181
|
+
// not re-claim an issue with an open PR. Refuse and name the PR so the
|
|
182
|
+
// operator chooses knowingly; the explicit flag records that choice. A row
|
|
183
|
+
// that is not live settles as already-terminal below, where no strand is
|
|
184
|
+
// possible, and a run that never pushed has nothing to strand.
|
|
185
|
+
const rawAllowOpenPr = Reflect.get(body, "allowOpenPr");
|
|
186
|
+
if (rawAllowOpenPr !== undefined && typeof rawAllowOpenPr !== "boolean") {
|
|
187
|
+
return Response.json({ error: "allowOpenPr must be a boolean when present" }, { status: 400 });
|
|
188
|
+
}
|
|
189
|
+
const controlled = action === "stop" ? store.latestRun(project, issue) : undefined;
|
|
190
|
+
if (
|
|
191
|
+
action === "stop" &&
|
|
192
|
+
rawAllowOpenPr !== true &&
|
|
193
|
+
controlled !== undefined &&
|
|
194
|
+
controlled.prUrl !== undefined &&
|
|
195
|
+
LIVE_STATES.includes(controlled.state)
|
|
196
|
+
) {
|
|
197
|
+
return Response.json(
|
|
198
|
+
{
|
|
199
|
+
error:
|
|
200
|
+
`#${issue} run ${controlled.id} already pushed ${controlled.prUrl} — stopping now would strand it: ` +
|
|
201
|
+
"a stopped run cannot be returned for review, and dispatch will not re-claim an issue with an open PR. " +
|
|
202
|
+
"Pass allowOpenPr (worker stop --allow-open-pr) to stop it anyway.",
|
|
203
|
+
},
|
|
204
|
+
{ status: 409 },
|
|
205
|
+
);
|
|
206
|
+
}
|
|
179
207
|
const outcome =
|
|
180
208
|
action === "pause"
|
|
181
209
|
? await registry.pause(project, issue, source)
|
package/src/daemon/review.ts
CHANGED
|
@@ -62,15 +62,15 @@ export async function dispatchReviewRevisions(d: Deps, pool?: WorkerPool): Promi
|
|
|
62
62
|
);
|
|
63
63
|
break;
|
|
64
64
|
}
|
|
65
|
-
// A capped/failed run has no pushed-green settle sweep keeping its
|
|
66
|
-
// honest (#795 review round 1): nothing transitions a `failed`
|
|
67
|
-
// row when its PR merges or closes, so a PR that
|
|
68
|
-
// recorded the round would otherwise be claimed and
|
|
69
|
-
// against a dead PR. The settled-green origin keeps its
|
|
70
|
-
// (the settle sweep flips the row and the claim below
|
|
71
|
-
//
|
|
72
|
-
// round dispatches only while the PR is still open at the
|
|
73
|
-
// head.
|
|
65
|
+
// A capped/failed/stopped run has no pushed-green settle sweep keeping its
|
|
66
|
+
// row honest (#795 review round 1, #1101): nothing transitions a `failed`
|
|
67
|
+
// / `killed` / `stopped` row when its PR merges or closes, so a PR that
|
|
68
|
+
// changed after the verb recorded the round would otherwise be claimed and
|
|
69
|
+
// a worker resumed against a dead PR. The settled-green origin keeps its
|
|
70
|
+
// own interlock (the settle sweep flips the row and the claim below
|
|
71
|
+
// refuses it), so every other revisable origin re-reads the reviewed PR
|
|
72
|
+
// fact here: the round dispatches only while the PR is still open at the
|
|
73
|
+
// exact reviewed head.
|
|
74
74
|
//
|
|
75
75
|
// The skip decision is decisive-fact only (review round 2): a definitively
|
|
76
76
|
// missing PR (`GhPrMissingError` — a corroborated 404, #779), a PR that
|
|
@@ -86,7 +86,7 @@ export async function dispatchReviewRevisions(d: Deps, pool?: WorkerPool): Promi
|
|
|
86
86
|
// #1047's launch decision rests on are only readable here.
|
|
87
87
|
const originRun = d.store.getRun(revision.runId);
|
|
88
88
|
const origin = originRun?.state;
|
|
89
|
-
if (origin === "failed" || origin === "killed") {
|
|
89
|
+
if (origin === "failed" || origin === "killed" || origin === "stopped") {
|
|
90
90
|
let prState: PrState | undefined;
|
|
91
91
|
try {
|
|
92
92
|
prState = await d.tracker.prState(revision.prUrl);
|
|
@@ -161,7 +161,7 @@ export async function dispatchReviewRevisions(d: Deps, pool?: WorkerPool): Promi
|
|
|
161
161
|
if (!d.store.claimRunForReview(revision.runId)) {
|
|
162
162
|
d.store.settleReviewRevision(revision.id, "skipped", Date.now());
|
|
163
163
|
log(
|
|
164
|
-
`#${revision.issue} review round ${revision.round} skipped: run ${revision.runId} is no longer
|
|
164
|
+
`#${revision.issue} review round ${revision.round} skipped: run ${revision.runId} is ${origin ?? "gone"} — no longer reclaimable for review`,
|
|
165
165
|
);
|
|
166
166
|
continue;
|
|
167
167
|
}
|
|
@@ -14,12 +14,15 @@
|
|
|
14
14
|
* pass into the dispatch path's import graph, for two functions whose subject —
|
|
15
15
|
* "make the orchestrator look at this now" — is already this module's.
|
|
16
16
|
*/
|
|
17
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
import { stateDir } from "../config.ts";
|
|
20
|
+
import { stallSilenceMs } from "../failure-class.ts";
|
|
20
21
|
import { resolvePaneHaltPath, stopConductorPane } from "../fleet.ts";
|
|
21
22
|
import { errText, log, safeEscalate } from "../log.ts";
|
|
22
23
|
import { STALL_MARKER_FILE, readTickRequestReason, requestImmediateTick, resolveTickConfigCwd } from "../orchestrator-tick.ts";
|
|
24
|
+
import { LIVE_STATES } from "../store.ts";
|
|
25
|
+
import { wakeDispatch } from "../wake.ts";
|
|
23
26
|
import { NO_ISSUE, type Deps, type StallGate } from "./deps.ts";
|
|
24
27
|
import { markPaged } from "./integrity.ts";
|
|
25
28
|
|
|
@@ -436,3 +439,143 @@ export function wakeOrchestratorForBlockedRun(
|
|
|
436
439
|
);
|
|
437
440
|
}
|
|
438
441
|
}
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
// ----------------------------------------------------------------- worker progress
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Watching live workers for silence, and settling a run that stopped making
|
|
448
|
+
* progress (#1086).
|
|
449
|
+
*
|
|
450
|
+
* The only bounds a worker's own session enforces are the turn ceiling and the
|
|
451
|
+
* wall clock — neither fires until the whole budget is gone, so a session that
|
|
452
|
+
* hangs at minute 44 keeps its slot for another 46 minutes and then dies as a
|
|
453
|
+
* `wall-clock-cap-*` verdict that reads like an oversized slice about work that
|
|
454
|
+
* may already be merged. The signal that separates "thinking" from "gone" is
|
|
455
|
+
* already on disk: a live session writes its transcript continuously, so the
|
|
456
|
+
* file's own mtime is when the worker was last demonstrably alive.
|
|
457
|
+
*
|
|
458
|
+
* Deliberately transcript-only: the row's turn counter rides in the evidence,
|
|
459
|
+
* but it is never the trigger — one turn can legitimately run for minutes on a
|
|
460
|
+
* slow provider, and this fleet has measured 1.5 min/turn.
|
|
461
|
+
*
|
|
462
|
+
* A stall whose PR is already pushed and green settles `pushed-green` — the
|
|
463
|
+
* exact state any delivered run holds — so the settle sweep owns the merge and
|
|
464
|
+
* no continuation is charged for work that landed (#1086). Every other stall
|
|
465
|
+
* settles `killed` over the observed facts; the classifier turns those into
|
|
466
|
+
* `progress-stall`, whose recovery splits on artifacts exactly as the caps do.
|
|
467
|
+
*/
|
|
468
|
+
export async function watchWorkerProgress(
|
|
469
|
+
d: Pick<Deps, "project" | "caps" | "store" | "tracker" | "workerControls" | "escalate">,
|
|
470
|
+
now = Date.now(),
|
|
471
|
+
): Promise<void> {
|
|
472
|
+
const thresholdMs = stallSilenceMs(d.caps);
|
|
473
|
+
// A paused worker banks its wall clock and goes quiet on purpose (#938):
|
|
474
|
+
// silence under pause is the pause working, never a hang.
|
|
475
|
+
const pausedIssues = new Set(
|
|
476
|
+
d.workerControls
|
|
477
|
+
.snapshot(d.project.name)
|
|
478
|
+
.filter((w) => w.phase === "pausing" || w.phase === "paused")
|
|
479
|
+
.map((w) => w.issue),
|
|
480
|
+
);
|
|
481
|
+
for (const run of d.store.liveRuns(d.project.name)) {
|
|
482
|
+
if (pausedIssues.has(run.issue)) continue;
|
|
483
|
+
if (run.sessionFile === undefined) continue;
|
|
484
|
+
let wroteAt: number;
|
|
485
|
+
try {
|
|
486
|
+
wroteAt = Math.trunc(statSync(run.sessionFile).mtimeMs);
|
|
487
|
+
} catch {
|
|
488
|
+
// No transcript yet (claimed, session opening) or none readable: nothing
|
|
489
|
+
// to observe, which is never evidence of a stall.
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
// Record before judging: `status` renders this instant as the run's
|
|
493
|
+
// silence interval whether or not the threshold fires (#1086).
|
|
494
|
+
if (run.lastProgressAt !== wroteAt) {
|
|
495
|
+
d.store.updateRun(run.id, { lastProgressAt: wroteAt });
|
|
496
|
+
}
|
|
497
|
+
const silentMs = now - wroteAt;
|
|
498
|
+
if (silentMs < thresholdMs) continue;
|
|
499
|
+
|
|
500
|
+
// Re-read before settling: the session can finish between the stat above
|
|
501
|
+
// and here, and a terminal row must never be re-settled over.
|
|
502
|
+
const current = d.store.getRun(run.id);
|
|
503
|
+
if (current === undefined || !LIVE_STATES.includes(current.state)) continue;
|
|
504
|
+
const minutes = Math.round(silentMs / 60_000);
|
|
505
|
+
const silent =
|
|
506
|
+
minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h${minutes % 60 === 0 ? "" : `${minutes % 60}m`}`;
|
|
507
|
+
const why = `transcript silent ${silent} at turn ${current.turns}/${current.maxTurns}`;
|
|
508
|
+
log(`#${current.issue} ${why} — settling (silence window ${Math.round(thresholdMs / 60_000)}m)`);
|
|
509
|
+
|
|
510
|
+
// Best-effort abort through the run-control surface, so a session that can
|
|
511
|
+
// still answer exits through its own settlement path. One that cannot is
|
|
512
|
+
// exactly the case this pass exists for: the row settles below regardless,
|
|
513
|
+
// and the re-read guard keeps whichever writer lands second out.
|
|
514
|
+
try {
|
|
515
|
+
void d.workerControls.stop(d.project.name, current.issue, `progress stall: ${why}`).catch(() => {});
|
|
516
|
+
} catch {
|
|
517
|
+
// No live control for this run — the settle below is the only writer.
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Work that already landed is delivered work — but only verified green
|
|
521
|
+
// counts: landing `pushed-green` puts the row exactly where any delivered
|
|
522
|
+
// run sits, under the settle sweep's merge watch, with nothing charged.
|
|
523
|
+
let deliveredGreen = false;
|
|
524
|
+
if (current.prUrl !== undefined && current.headSha !== undefined) {
|
|
525
|
+
try {
|
|
526
|
+
deliveredGreen = (await d.tracker.verifyPr(current.prUrl, current.headSha))?.status === "green";
|
|
527
|
+
} catch {
|
|
528
|
+
deliveredGreen = false;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (deliveredGreen) {
|
|
532
|
+
d.store.updateRun(current.id, { state: "pushed-green", endedAt: now });
|
|
533
|
+
log(`#${current.issue} stalled but ${current.prUrl} was already green — settled as delivered`);
|
|
534
|
+
await safeEscalate(d, {
|
|
535
|
+
tier: 1,
|
|
536
|
+
project: d.project.name,
|
|
537
|
+
issue: current.issue,
|
|
538
|
+
runId: current.id,
|
|
539
|
+
summary: `#${current.issue} went silent (${why}) with its PR already green — settled as delivered`,
|
|
540
|
+
detail: [
|
|
541
|
+
current.prUrl ?? "(no PR URL)",
|
|
542
|
+
`Branch ${current.branch} at ${current.headSha}.`,
|
|
543
|
+
`Session: ${current.sessionFile ?? "(no transcript)"}`,
|
|
544
|
+
"",
|
|
545
|
+
"The worker never reported, so there is no settlement narrative — the PR's diff is the record.",
|
|
546
|
+
"The slot is released; the settle sweep watches the merge from here.",
|
|
547
|
+
].join("\n"),
|
|
548
|
+
});
|
|
549
|
+
await wakeDispatchLogged(d.project.name);
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
d.store.updateRun(current.id, {
|
|
554
|
+
state: "killed",
|
|
555
|
+
endedAt: now,
|
|
556
|
+
lastError: `progress-stall: ${why}`,
|
|
557
|
+
});
|
|
558
|
+
await safeEscalate(d, {
|
|
559
|
+
tier: 1,
|
|
560
|
+
project: d.project.name,
|
|
561
|
+
issue: current.issue,
|
|
562
|
+
runId: current.id,
|
|
563
|
+
summary: `[progress-stall] #${current.issue} attempt ${current.attempt}: ${why}`,
|
|
564
|
+
detail: [
|
|
565
|
+
`${current.prUrl === undefined ? "No PR" : `PR ${current.prUrl} not verified green`} — the classifier decides continue vs escalate from what attempt ${current.attempt} left.`,
|
|
566
|
+
`Branch ${current.branch}; worktree ${current.worktree === "" ? "(none)" : current.worktree}.`,
|
|
567
|
+
`Session: ${current.sessionFile ?? "(no transcript)"}`,
|
|
568
|
+
].join("\n"),
|
|
569
|
+
});
|
|
570
|
+
await wakeDispatchLogged(d.project.name);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Best-effort dispatch wake once a stall settle freed its slot (#1086). */
|
|
575
|
+
async function wakeDispatchLogged(projectName: string): Promise<void> {
|
|
576
|
+
try {
|
|
577
|
+
log(`dispatch wake after stall settle: ${await wakeDispatch(projectName)}`);
|
|
578
|
+
} catch (err) {
|
|
579
|
+
log(`dispatch wake after stall settle failed: ${errText(err)}`);
|
|
580
|
+
}
|
|
581
|
+
}
|