omp-conductor 0.19.7 → 0.20.0

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.
Files changed (69) hide show
  1. package/REFERENCE.md +10 -1
  2. package/agents/to-spec.md +76 -9
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +4 -0
  5. package/src/arm-challenge.ts +204 -85
  6. package/src/ask.ts +130 -615
  7. package/src/board.ts +7 -1
  8. package/src/brief-upgrade.ts +24 -0
  9. package/src/briefs/console.md +253 -0
  10. package/src/briefs/correction.md +203 -0
  11. package/src/briefs/orchestrator.md +167 -97
  12. package/src/briefs/policy.md +19 -16
  13. package/src/briefs/to-spec.md +76 -9
  14. package/src/briefs/worker.md +50 -16
  15. package/src/cli.ts +4 -0
  16. package/src/command-manifest.ts +54 -8
  17. package/src/commands/arm.ts +113 -49
  18. package/src/commands/console.ts +70 -0
  19. package/src/commands/context.ts +2 -0
  20. package/src/commands/epic.ts +132 -0
  21. package/src/commands/extend.ts +9 -1
  22. package/src/commands/intake.ts +44 -14
  23. package/src/commands/stats.ts +19 -4
  24. package/src/commands/worker.ts +9 -1
  25. package/src/config-schema.ts +13 -0
  26. package/src/config.ts +27 -0
  27. package/src/daemon/ack.ts +159 -0
  28. package/src/daemon/admission-pass.ts +135 -0
  29. package/src/daemon/brief.ts +461 -0
  30. package/src/daemon/deps.ts +539 -0
  31. package/src/daemon/dispatch.ts +1779 -0
  32. package/src/daemon/drain.ts +185 -0
  33. package/src/daemon/groom-pass.ts +412 -0
  34. package/src/daemon/http.ts +417 -0
  35. package/src/daemon/integrity.ts +108 -0
  36. package/src/daemon/panes.ts +180 -0
  37. package/src/daemon/review.ts +1888 -0
  38. package/src/daemon/runtime.ts +736 -0
  39. package/src/daemon/settle-pass.ts +589 -0
  40. package/src/daemon/supervision.ts +438 -0
  41. package/src/daemon/tick.ts +968 -0
  42. package/src/daemon/views.ts +751 -0
  43. package/src/daemon.ts +105 -7923
  44. package/src/dashboard/app.js +58 -0
  45. package/src/dashboard/controls.ts +22 -3
  46. package/src/dashboard/server.ts +4 -0
  47. package/src/diff-flags.ts +24 -3
  48. package/src/failure-class.ts +75 -1
  49. package/src/fleet.ts +290 -164
  50. package/src/groom.ts +461 -0
  51. package/src/http-token.ts +142 -0
  52. package/src/knowledge.ts +229 -0
  53. package/src/mining.ts +316 -0
  54. package/src/orchestrator-tick.ts +428 -1681
  55. package/src/ready-gate.ts +267 -0
  56. package/src/settlement.ts +72 -6
  57. package/src/setup-host.ts +32 -9
  58. package/src/setup-wizard.ts +55 -7
  59. package/src/setup.ts +229 -3
  60. package/src/stats.ts +257 -2
  61. package/src/status-render.ts +158 -7
  62. package/src/store.ts +604 -26
  63. package/src/to-spec.ts +194 -21
  64. package/src/tracker/github.ts +50 -0
  65. package/src/types.ts +416 -15
  66. package/src/verbs/protocol.ts +28 -0
  67. package/src/verbs/server.ts +330 -39
  68. package/src/wake.ts +19 -2
  69. package/src/worker.ts +456 -1
@@ -0,0 +1,438 @@
1
+ /**
2
+ * Watching the orchestrator, and the two ways of getting its attention.
3
+ *
4
+ * The dispatcher's tier-2 escalation target is another agent session, so the
5
+ * dispatcher owns a liveness problem: a session that stopped consuming ticks
6
+ * looks exactly like a quiet fleet until something needs a decision. This module
7
+ * is the whole of that concern — the stall gate and its once-per-hour renotify,
8
+ * the bounded exact-identity restart, and the escalation lines that describe
9
+ * what was attempted.
10
+ *
11
+ * The two `wakeOrchestrator*` helpers live here rather than in `tick.ts` because
12
+ * a blocked run wakes the orchestrator from inside `handleIssue` and
13
+ * `handleReviewRevision` too. Parked in the tick they would drag the whole tick
14
+ * pass into the dispatch path's import graph, for two functions whose subject —
15
+ * "make the orchestrator look at this now" — is already this module's.
16
+ */
17
+ import { existsSync, readFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { stateDir } from "../config.ts";
20
+ import { resolvePaneHaltPath, stopConductorPane } from "../fleet.ts";
21
+ import { errText, log, safeEscalate } from "../log.ts";
22
+ import { STALL_MARKER_FILE, readTickRequestReason, requestImmediateTick, resolveTickConfigCwd } from "../orchestrator-tick.ts";
23
+ import { NO_ISSUE, type Deps, type StallGate } from "./deps.ts";
24
+ import { markPaged } from "./integrity.ts";
25
+
26
+ /** No more than one reminder per hour while the same marker remains. */
27
+ export const STALL_RENOTIFY_MS = 60 * 60_000;
28
+
29
+ export interface StallVerdict {
30
+ /** The marker's own line, when one is there. */
31
+ since?: string;
32
+ /** Count written by the stalled tick producer, when its marker is readable. */
33
+ unconsumedTicks?: number;
34
+ /** Whole hours elapsed since the marker timestamp, when it is parseable. */
35
+ stalledHours?: number;
36
+ page: boolean;
37
+ }
38
+
39
+ /**
40
+ * Reads the orchestrator's stall marker and decides whether this tick pages.
41
+ *
42
+ * The marker is written by the tick extension inside the orchestrator session
43
+ * ({@link STALL_MARKER_FILE}) when two of its own prompts go unconsumed — the
44
+ * one signal that separates "the process is alive" from "the loop is reading
45
+ * its queue". Every other guard in this system reads healthy through a wedge:
46
+ * the herdr recovery plugin tests for a live process and an agent label, both
47
+ * of which survive it, and `/healthz` describes this daemon, which is a
48
+ * different process entirely.
49
+ *
50
+ * The daemon is the natural watcher precisely because it is that different
51
+ * process: it already wakes every five minutes, it owns a working escalation
52
+ * path, and nothing about its health depends on the session that is stuck. A
53
+ * wedged loop cannot page for itself, and the herdr plugin only runs on session
54
+ * lifecycle events — a session that stays alive and stops working emits none.
55
+ *
56
+ * Resets when the marker disappears, so a second stall days later pages again.
57
+ */
58
+ export function checkStall(gate: StallGate, marker: string, now = Date.now()): StallVerdict {
59
+ if (!existsSync(marker)) {
60
+ gate.paged = false;
61
+ delete gate.lastPagedAt;
62
+ return { page: false };
63
+ }
64
+ const page =
65
+ !gate.paged ||
66
+ gate.lastPagedAt === undefined ||
67
+ now - gate.lastPagedAt >= STALL_RENOTIFY_MS;
68
+ let since: string | undefined;
69
+ let unconsumedTicks: number | undefined;
70
+ let stalledHours: number | undefined;
71
+ try {
72
+ const body = readFileSync(marker, "utf8").split("\n")[0]?.trim();
73
+ if (body !== undefined && body !== "") {
74
+ since = body;
75
+ const ticks = /\b(\d+) ticks queued unconsumed\b/.exec(body)?.[1];
76
+ if (ticks !== undefined) unconsumedTicks = Number(ticks);
77
+ const startedAt = Date.parse(body.split(/\s+/, 1)[0] ?? "");
78
+ if (Number.isFinite(startedAt)) {
79
+ stalledHours = Math.max(0, Math.floor((now - startedAt) / STALL_RENOTIFY_MS));
80
+ }
81
+ }
82
+ } catch {
83
+ // An unreadable marker still means stalled; its evidence is a nicety.
84
+ }
85
+ return {
86
+ ...(since === undefined ? {} : { since }),
87
+ ...(unconsumedTicks === undefined ? {} : { unconsumedTicks }),
88
+ ...(stalledHours === undefined ? {} : { stalledHours }),
89
+ page,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * At most three automatic restarts in six hours, per project (Phase 4).
95
+ *
96
+ * Three, because the failure this bound exists for is a wedge whose cause the
97
+ * restart does not remove: a session that gets stuck, gets resumed, and gets
98
+ * stuck again on the same turn. Two restarts can be bad luck; the third
99
+ * consecutive one is a diagnosis nobody has made, and continuing to resume it
100
+ * automatically would replace an operator's page with an invisible loop. Beyond
101
+ * the bound the hourly page is the whole behaviour, exactly as before.
102
+ *
103
+ * Six hours, because the page it falls back to renotifies hourly
104
+ * ({@link STALL_RENOTIFY_MS}) and a restart is only ever attempted on a tick
105
+ * that pages — so the window holds three attempts and then at least three
106
+ * hours of pages before the counter can drain.
107
+ */
108
+ export const ORCHESTRATOR_RESTART_LIMIT = 3;
109
+ export const ORCHESTRATOR_RESTART_WINDOW_MS = 6 * 60 * 60_000;
110
+
111
+ /**
112
+ * What one automatic-restart attempt did, for the escalation to state verbatim.
113
+ *
114
+ * Every variant is a fact about this daemon's own action, not a guess about the
115
+ * session: the page has to be readable by an operator who will decide whether
116
+ * to intervene, and "the daemon tried X and X said Y" is the only thing that
117
+ * lets them skip re-deriving it.
118
+ */
119
+ export type OrchestratorRestartOutcome =
120
+ /** The wedged omp process was signalled; herdr's hook owns the resume now.
121
+ * `attempt` is this restart's ordinal inside the 6h window, so the page says
122
+ * "2 of 3" rather than leaving the operator to count pages. */
123
+ | { kind: "signalled"; detail: string; attempt: number }
124
+ /** Nothing was running under the configured identity — nothing to restart. */
125
+ | { kind: "already-gone"; detail: string; attempt: number }
126
+ /** The bound is spent; these are the attempts inside the window. */
127
+ | { kind: "bounded"; attempts: readonly { at: number; reason: string }[] }
128
+ /** A precondition says an automatic restart cannot work here. */
129
+ | { kind: "declined"; why: string }
130
+ /** The attempt was made and refused to report success. */
131
+ | { kind: "failed"; error: string; attempt: number };
132
+
133
+ /**
134
+ * Restart a wedged orchestrator by exact identity, bounded (Phase 4).
135
+ *
136
+ * ## Why this mechanism and not `agent start --resume=<ref>`
137
+ *
138
+ * `recover.sh:start_fleet` resumes the fleet with
139
+ * `agent start <name> --kind omp --pane <pane> -- --resume=<ref>`. The daemon
140
+ * cannot issue that: it knows neither the pane nor the session ref. Both live in
141
+ * herdr's own saved identity, written by `remember_identity` at the last
142
+ * successful recovery, and `agent start` submits omp *into* a pane's shell — run
143
+ * against the wrong pane it would collide with a live session.
144
+ *
145
+ * So the daemon does the one half it can do exactly, and lets herdr do the half
146
+ * it owns: SIGTERM the wedged omp process, leaving its shell alive, so herdr's
147
+ * `pane.exited` hook fires `recover.sh`, which resumes the SAVED ref
148
+ * (`recover-test.sh` case 9b is precisely "dead omp, surviving shell"). That is
149
+ * also, word for word, what today's escalation already tells the operator to do
150
+ * by hand — this makes the daemon do it three times before asking.
151
+ *
152
+ * It is never a fresh session: nothing here supplies a ref, so if herdr has no
153
+ * saved identity it pages instead of inventing one.
154
+ *
155
+ * ## Preconditions, both of which must be read rather than assumed
156
+ *
157
+ * - **External orchestrator only.** An embedded session is a child of this
158
+ * daemon with no herdr pane and no saved ref; killing it would produce a
159
+ * fresh session or none. A dead embedded session is
160
+ * {@link reconcileOrchestratorDown}'s machine, with its own incident rows —
161
+ * this must not page or act against it.
162
+ * - **Recovery not pinned.** `omp-conductor stop --pane` leaves a pin file that
163
+ * `recover.sh` obeys by refusing to resume. Signalling through a pin would
164
+ * convert a wedged-but-running session into a stopped one with nothing
165
+ * coming back, which is strictly worse than the page.
166
+ *
167
+ * The counter row is written BEFORE the signal, deliberately: a crash between
168
+ * the two must not leave the restart uncounted, because an uncounted restart is
169
+ * how a bound of three becomes unbounded.
170
+ */
171
+ export async function restartWedgedOrchestrator(
172
+ d: Pick<Deps, "project" | "store" | "restartOrchestrator">,
173
+ now: number,
174
+ ): Promise<OrchestratorRestartOutcome> {
175
+ if (d.project.escalation.orchestrator !== "external") {
176
+ return {
177
+ kind: "declined",
178
+ why:
179
+ `this project runs an embedded orchestrator, which has no herdr pane and no saved ` +
180
+ `session ref — there is nothing to resume by exact identity, and a crashed embedded ` +
181
+ `session is the orchestrator-down incident's business, not this watch's`,
182
+ };
183
+ }
184
+ // Read the pin, never assume it: an unresolvable pin path means this host has
185
+ // no tick config where herdr expects one, and then the identity a restart
186
+ // would act on is a guess — the same refusal `stopConductorPane` makes.
187
+ const pin = resolvePaneHaltPath(d.project.name);
188
+ if (pin.kind === "unresolved") {
189
+ return {
190
+ kind: "declined",
191
+ why: `the pane recovery pin could not be located (${pin.reason}), so whether herdr would resume is unknown`,
192
+ };
193
+ }
194
+ if (existsSync(pin.path)) {
195
+ return {
196
+ kind: "declined",
197
+ why:
198
+ `pane recovery is pinned at ${pin.path} (\`omp-conductor stop --pane\`), so herdr would ` +
199
+ `not resume the session this would stop — clear it with \`omp-conductor resume\` first`,
200
+ };
201
+ }
202
+
203
+ const prior = d.store.orchestratorRestartsSince(
204
+ d.project.name,
205
+ now - ORCHESTRATOR_RESTART_WINDOW_MS,
206
+ );
207
+ if (prior.length >= ORCHESTRATOR_RESTART_LIMIT) return { kind: "bounded", attempts: prior };
208
+
209
+ const attempt = prior.length + 1;
210
+ const reason = "wedged: stall marker present on a paging tick";
211
+ d.store.recordOrchestratorRestart(d.project.name, now, reason);
212
+ try {
213
+ // `stopConductorPane` is the verified exact-identity path: it resolves the
214
+ // agent name from the tick config, refuses on an invalid config, on a
215
+ // non-unique name, on a pane whose agent is not omp, and on any liveness
216
+ // probe or signal it cannot prove — then SIGTERMs (SIGKILL after a 10s
217
+ // grace). It does NOT pin recovery; only `haltWithPane` does, which is what
218
+ // makes it usable here. Its herdr reads are `spawnSync`, so this blocks the
219
+ // loop for seconds — acceptable at most three times per six hours, and the
220
+ // alternative is a second, unverified implementation of pane identity.
221
+ const stopped = await (d.restartOrchestrator ?? ((project: string) => stopConductorPane(project)))(
222
+ d.project.name,
223
+ );
224
+ return stopped.stopped === "already-gone"
225
+ ? { kind: "already-gone", detail: stopped.detail, attempt }
226
+ : { kind: "signalled", detail: stopped.detail, attempt };
227
+ } catch (err) {
228
+ return { kind: "failed", error: errText(err), attempt };
229
+ }
230
+ }
231
+
232
+ /** The escalation's account of the restart: what was tried, and what happened. */
233
+ export function restartLines(outcome: OrchestratorRestartOutcome): string[] {
234
+ switch (outcome.kind) {
235
+ case "signalled":
236
+ return [
237
+ `Automatic restart ${outcome.attempt} of ${ORCHESTRATOR_RESTART_LIMIT} per 6h: the daemon`,
238
+ `SIGTERMed the wedged omp process (${outcome.detail}). Its shell survives, so herdr's`,
239
+ "pane-exited hook runs recover.sh, which resumes the SAVED session ref — never a fresh",
240
+ "session. If the session is back and consuming ticks, this page is the record of that,",
241
+ "not a request.",
242
+ ];
243
+ case "already-gone":
244
+ return [
245
+ `Automatic restart ${outcome.attempt} of ${ORCHESTRATOR_RESTART_LIMIT} per 6h: nothing was`,
246
+ `running under the configured identity (${outcome.detail}), so there was nothing to`,
247
+ "signal. herdr's recovery owns bringing it back.",
248
+ ];
249
+ case "bounded":
250
+ return [
251
+ `Automatic restart NOT attempted: the bound of ${ORCHESTRATOR_RESTART_LIMIT} per 6h is spent.`,
252
+ "It tried, and each of these was followed by another wedge:",
253
+ // The store answers newest-first; read oldest-first here, because the
254
+ // point of the list is the sequence that failed to fix anything.
255
+ ...[...outcome.attempts]
256
+ .sort((a, b) => a.at - b.at)
257
+ .map((attempt) => `- ${new Date(attempt.at).toISOString()} ${attempt.reason}`),
258
+ `${ORCHESTRATOR_RESTART_LIMIT} resumes did not fix it, so the cause is not the session —`,
259
+ "attach and diagnose.",
260
+ ];
261
+ case "declined":
262
+ return [`Automatic restart NOT attempted: ${outcome.why}.`];
263
+ case "failed":
264
+ return [
265
+ `Automatic restart ${outcome.attempt} of ${ORCHESTRATOR_RESTART_LIMIT} FAILED: ${outcome.error}`,
266
+ "It is counted against the 6h bound anyway — a failed attempt is an attempt, and a",
267
+ "restart that cannot prove it worked must not be retried freely.",
268
+ ];
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Restarts a wedged orchestrator, bounded, then pages tier 2 either way.
274
+ *
275
+ * It used to only page, on the reasoning that a wedge lands mid-turn and this
276
+ * process cannot tell a half-applied edit from an idle loop. That reasoning was
277
+ * right about the danger and wrong about the remedy: the page's own instruction
278
+ * was "SIGTERM the omp process; herdr-conductor resumes it by exact identity",
279
+ * so the operator's correct action was mechanical, and an unattended fleet spent
280
+ * hours wedged waiting for somebody to perform it. Phase 4 has the daemon
281
+ * perform it — at most three times per six hours, by exact ref only, never a
282
+ * fresh session — and keeps the page, now carrying what it did.
283
+ *
284
+ * The page still goes out on a successful restart. That is deliberate: the
285
+ * marker is written by the wedged session and cleared by the resumed one's first
286
+ * consumed tick, so "restarted" is a claim about the signal, not about recovery.
287
+ * The operator gets the record, and the next tick's silence is the confirmation.
288
+ *
289
+ * The restart is attempted only on a tick that pages, which is what bounds its
290
+ * frequency to the hourly renotify — so the 6h counter can hold three attempts
291
+ * with hours of pages between them, rather than three inside fifteen minutes.
292
+ */
293
+ export async function watchOrchestrator(d: Deps, now = Date.now()): Promise<void> {
294
+ const marker = join(stateDir(), STALL_MARKER_FILE);
295
+ const repeat = d.stall.paged;
296
+ const verdict = checkStall(d.stall, marker, now);
297
+ if (!verdict.page) return;
298
+
299
+ // Before the page, so the page can state what was done rather than describing
300
+ // a remedy that has in fact already been applied. Never throws: every failure
301
+ // mode is an outcome variant the escalation renders, because a restart that
302
+ // blew up must not also swallow the page that says the session is wedged.
303
+ const restart = await restartWedgedOrchestrator(d, now);
304
+
305
+ const greenRuns = d.store.activeRuns(d.project.name).filter((run) => run.state === "pushed-green");
306
+ const repeatSuffix = repeat
307
+ ? ` — still stalled (${
308
+ verdict.stalledHours === undefined
309
+ ? `${new Date(now).toISOString().slice(0, 13)}Z`
310
+ : `${verdict.stalledHours}h`
311
+ })`
312
+ : "";
313
+
314
+ log(`ERROR: the orchestrator session is not draining its queue — ${verdict.since ?? "no timestamp"}`);
315
+ for (const line of restartLines(restart)) log(line);
316
+ const delivered = await safeEscalate(d, {
317
+ tier: 2,
318
+ category: "confirmed-failure",
319
+ urgent: true,
320
+ project: d.project.name,
321
+ issue: NO_ISSUE,
322
+ // Keyed on the marker's own timestamp, not the date. The dedup ledger keys
323
+ // on this summary, and two wedges in one day is not a hypothetical — the
324
+ // failure mode is a session that gets stuck, gets restarted, and gets stuck
325
+ // again on the same cause an hour later. The hourly suffix makes bounded
326
+ // reminders distinct without allowing every five-minute tick through.
327
+ summary:
328
+ `Orchestrator session wedged (${verdict.since ?? `marker at ${marker}`}) — ` +
329
+ `it has stopped reading its queue (${d.project.name})${repeatSuffix}`,
330
+ detail: [
331
+ verdict.since ?? "Marker present with no readable timestamp.",
332
+ `Unconsumed ticks: ${verdict.unconsumedTicks ?? "unknown"}`,
333
+ `Open green worker PRs: ${greenRuns.length}`,
334
+ ...greenRuns.map((run) => `- ${run.prUrl ?? `#${run.issue} (PR URL unavailable)`}`),
335
+ `Marker: ${marker}`,
336
+ "",
337
+ "Its process and its herdr agent label are both healthy, which is why nothing else noticed:",
338
+ "the loop is alive and consuming nothing, so ticks and your messages queue behind it unread.",
339
+ "",
340
+ ...restartLines(restart),
341
+ "",
342
+ // Only worth saying while the daemon has not already done it. Telling an
343
+ // operator to SIGTERM a process the daemon just SIGTERMed is how a page
344
+ // stops being read.
345
+ ...(restart.kind === "signalled" || restart.kind === "already-gone"
346
+ ? ["Attach and look before you act further — a wedge lands mid-turn.", ""]
347
+ : [
348
+ "Attach and look before you act — a wedge lands mid-turn. Then SIGTERM the omp process:",
349
+ "herdr-conductor resumes it by exact identity, and the first consumed tick clears this marker.",
350
+ "",
351
+ ]),
352
+ "Dispatch is unaffected: workers keep running. What stops is drain, groom, report and merge.",
353
+ ].join("\n"),
354
+ });
355
+ markPaged(d.stall, delivered, now);
356
+ }
357
+
358
+ /**
359
+ * After a false→true condition transition, poke the orchestrator heartbeat so
360
+ * it does not wait a full interval (#329). Best-effort: missing tick config or
361
+ * a failed write leaves the row flagged in the ledger for the next heartbeat.
362
+ */
363
+ export function wakeOrchestratorForMetConditions(
364
+ projectName: string,
365
+ met: readonly { id: string; condition?: string }[],
366
+ writeLog: (line: string) => void = log,
367
+ ): void {
368
+ if (met.length === 0) return;
369
+ for (const decision of met) {
370
+ writeLog(
371
+ `decision ${decision.id} condition met (${decision.condition ?? "?"}) — requesting orchestrator tick`,
372
+ );
373
+ }
374
+ const tickCwd = resolveTickConfigCwd(projectName);
375
+ if (tickCwd === undefined) {
376
+ writeLog(
377
+ `decision condition met for ${met.map((m) => m.id).join(", ")} but no tick config cwd — heartbeat will surface them on its next interval`,
378
+ );
379
+ return;
380
+ }
381
+ const ids = met.map((m) => m.id).join(",");
382
+ const reason = `condition-met ${ids}`;
383
+ if (requestImmediateTick(tickCwd, reason)) {
384
+ writeLog(`requested immediate tick at ${tickCwd}: ${reason}`);
385
+ } else {
386
+ writeLog(
387
+ `could not write tick request under ${tickCwd}; conditions ${ids} wait for the next heartbeat`,
388
+ );
389
+ }
390
+ }
391
+
392
+
393
+ /**
394
+ * Wake the orchestrator because a worker stopped to ask a question (#990).
395
+ *
396
+ * `question` is the third-largest class in this ledger — 58 of 839 runs — and
397
+ * every one of them is a parked worker holding a slot until someone reads the
398
+ * question. Nothing woke the orchestrator for it: the only wake was a decision
399
+ * condition being met, so on a fleet with a 1800s heartbeat a tier-1 question
400
+ * whose answer is one comment could sit unread for half an hour.
401
+ *
402
+ * Deliberately narrow. Only the blocked path wakes: a failed run, a cap kill
403
+ * and a clean settle are the next scheduled tick's business, and waking on
404
+ * every terminal state turns the heartbeat into a busy loop — which would be a
405
+ * worse fleet than the one that waits.
406
+ *
407
+ * Debounced by the marker itself rather than by a counter: an unconsumed
408
+ * request already asks for exactly the wake this call wants, so three workers
409
+ * blocking in one pass write one request and the *first* question's number
410
+ * survives as the reason. Best effort throughout — a marker that cannot be
411
+ * written is logged, and the settlement it belongs to is never affected.
412
+ */
413
+ export function wakeOrchestratorForBlockedRun(
414
+ projectName: string,
415
+ issue: number,
416
+ writeLog: (line: string) => void = log,
417
+ ): void {
418
+ const tickCwd = resolveTickConfigCwd(projectName);
419
+ if (tickCwd === undefined) {
420
+ writeLog(
421
+ `#${issue} is blocked but no tick config cwd — the question waits for the next heartbeat`,
422
+ );
423
+ return;
424
+ }
425
+ const pending = readTickRequestReason(tickCwd);
426
+ if (pending !== undefined) {
427
+ writeLog(`#${issue} is blocked; a tick request is already pending (${pending})`);
428
+ return;
429
+ }
430
+ const reason = `tier1-question #${issue}`;
431
+ if (requestImmediateTick(tickCwd, reason)) {
432
+ writeLog(`requested immediate tick at ${tickCwd}: ${reason}`);
433
+ } else {
434
+ writeLog(
435
+ `could not write tick request under ${tickCwd}; #${issue}'s question waits for the next heartbeat`,
436
+ );
437
+ }
438
+ }