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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.20.1",
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
- let stopped = false;
114
+ const stopped = new AbortController();
69
115
  const stop = (): void => {
70
- stopped = true;
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
- await new Promise<void>((resolve) => {
90
- const timer = setTimeout(resolve, COMPANION_REFRESH_SECONDS * 1_000);
91
- // Never hold the process open past a signal: the pane is closed by
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
  }
@@ -8,15 +8,7 @@
8
8
 
9
9
  import type { CommandContext } from "./context.ts";
10
10
  import { runDaemon } from "../daemon.ts";
11
- import { clearRecord, DEFAULT_PORT, livingDaemon, writeRecord } from "../lifecycle.ts";
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
- const running = livingDaemon();
46
- if (running !== undefined && running.pid !== process.pid) {
47
- process.stderr.write(`omp-conductor: another daemon is alive (pid ${running.pid}); stop it first\n`);
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
- // A living record that already names this pid was written by the `start`
52
- // that spawned us, and it knows the log file our stdout is really going
53
- // to. Replacing it with a guess would be a downgrade.
54
- if (running === undefined) {
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();
@@ -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, and admission resumes automatically at the absolute
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, and remaining active runs.
32
- cancel removes the project's drain and is idempotent. A drain never touches
33
- the pause sentinel, the arm marker, or any queue label it is the file record
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;
@@ -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
  );
@@ -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
- : `caps.${key} must be a non-negative finite number, found ${found}`;
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
- : `${label}.${key} must be a non-negative finite number, found ${JSON.stringify(v)}`,
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
 
@@ -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 });
@@ -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)
@@ -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 row
66
- // honest (#795 review round 1): nothing transitions a `failed` / `killed`
67
- // row when its PR merges or closes, so a PR that changed after the verb
68
- // recorded the round would otherwise be claimed and a worker resumed
69
- // against a dead PR. The settled-green origin keeps its own interlock
70
- // (the settle sweep flips the row and the claim below refuses it), so
71
- // only terminal-origin rounds re-read the reviewed PR fact here: the
72
- // round dispatches only while the PR is still open at the exact reviewed
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 pushed-green`,
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
+ }