omp-conductor 0.20.1 → 0.20.2

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.2",
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
  }
@@ -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
+ }
@@ -42,13 +42,13 @@ import { writeAdmissionAck } from "./ack.ts";
42
42
  import { dispatchAdmissions, summarizeDispatch, summarizeHeldPass, type WorkerPool } from "./admission-pass.ts";
43
43
  import { NO_ISSUE, PACKAGE_SRC_DIR, UNROUTABLE_TEXT, type Deps } from "./deps.ts";
44
44
  import { handleIssue } from "./dispatch.ts";
45
- import { cancelDrain, consumeDrain } from "./drain.ts";
45
+ import { consumeDrain, markDrained } from "./drain.ts";
46
46
  import { dispatchToSpecGrooming } from "./groom-pass.ts";
47
47
  import { INTEGRITY_SAMPLE, checkIntegrity, markPaged, packageManifest } from "./integrity.ts";
48
48
  import { reconcilePanes } from "./panes.ts";
49
49
  import { applyAdjudicationDispositions, dispatchReviewAdjudications, dispatchReviewRevisions } from "./review.ts";
50
50
  import { cleanupRetainedRuns, watchBaseHealth, watchMergedBase } from "./settle-pass.ts";
51
- import { wakeOrchestratorForMetConditions, watchOrchestrator } from "./supervision.ts";
51
+ import { wakeOrchestratorForMetConditions, watchOrchestrator, watchWorkerProgress } from "./supervision.ts";
52
52
 
53
53
  /**
54
54
  * Record what this host has installed, once per dispatch pass (#919).
@@ -388,6 +388,16 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
388
388
  // failure happened. A pause silences claiming, not the operator's right to
389
389
  // know their supervising session stopped reading its queue.
390
390
  await watchOrchestrator(d);
391
+ // The worker-side sibling, and above the pause gate with it: a stalled run
392
+ // holds its slot on a parked fleet exactly as hard as on a busy one, and the
393
+ // #1086 incident was an upgrade drain blocked for 26 minutes by one silent
394
+ // session. Paused workers are skipped inside the pass — their silence is
395
+ // the pause working (#938) — so this gate costs nothing there.
396
+ try {
397
+ await watchWorkerProgress(d);
398
+ } catch (err) {
399
+ log(`worker-progress sweep failed: ${errText(err)}`);
400
+ }
391
401
  // The down incident is reconciled the same place and for the same reason: a
392
402
  // session that has actually died is as much the operator's concern as one
393
403
  // that is wedged, and restarting it is the daemon's restart either way. This
@@ -584,11 +594,16 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
584
594
  // the same admission boundary — settlement above it, nothing claimed below —
585
595
  // but the intent is durable (it survives orchestrator loss) and bounded (the
586
596
  // record carries an absolute deadline, so a crash can never strand
587
- // admission). Three shapes, three behaviours:
597
+ // admission). Reaching an empty active set SUCCEEDS the drain; it does not
598
+ // end it (#1078): the release window the drain exists to create opens at
599
+ // that moment and holds claims down until the deadline — exactly the quiet
600
+ // interval a tick-driven orchestrator needs to cut a release, which the old
601
+ // eight-second auto-clear destroyed before any tick could use it. Four
602
+ // shapes, four behaviours:
588
603
  // - fresh drain with live runs → a held pass, exactly like a pause;
589
- // - fresh drain with nothing left to wait for → the drain is satisfied
590
- // and clears itself, so a completed drain never needs a second operator
591
- // action and this pass proceeds normally;
604
+ // - fresh drain whose active set just reached zero → the opening is marked
605
+ // on the record once and logged once, and this pass admits nothing;
606
+ // - fresh drain already marked drained → the same held pass, silent;
592
607
  // - malformed record → fails closed for THIS pass (it might be a fresh
593
608
  // fence we cannot read), and the same consume removed it, so it can
594
609
  // never become an unbounded permanent drain.
@@ -596,15 +611,17 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
596
611
  if (drain.kind === "active") {
597
612
  // Completion is the ACTIVE set, not the live-worker set: pushed-pending
598
613
  // and pushed-green PRs still make the `runs-settled` release gate fail, so
599
- // a drain that cleared while one remained would admit work on top of a
600
- // batch the releases still see as unfinished (#776 review #2).
601
- if (d.store.activeRuns(d.project.name).length === 0) {
602
- cancelDrain(d.project.name);
603
- log("project drain completed: no active runs remain — drain cleared");
604
- } else {
605
- d.store.recordDispatch(d.project.name, summarizeHeldPass(settled));
606
- return;
614
+ // a window declared open over a remaining run would admit work on top of
615
+ // a batch the releases still see as unfinished (#776 review #2).
616
+ if (d.store.activeRuns(d.project.name).length === 0 && drain.drain.drainedAt === undefined) {
617
+ markDrained(d.project.name);
618
+ log(
619
+ `project drain: fleet drained — release window open until ${drain.drain.expiresAt}` +
620
+ " (claims stay paused; admission resumes at the deadline or on drain cancel)",
621
+ );
607
622
  }
623
+ d.store.recordDispatch(d.project.name, summarizeHeldPass(settled));
624
+ return;
608
625
  } else if (drain.kind === "error") {
609
626
  log(`project drain record invalid (${drain.problem}) — removed; this pass admits nothing`);
610
627
  d.store.recordDispatch(d.project.name, summarizeHeldPass(settled));