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.
- package/REFERENCE.md +10 -1
- package/agents/to-spec.md +76 -9
- package/package.json +1 -1
- package/schema/config.schema.json +4 -0
- package/src/arm-challenge.ts +204 -85
- package/src/ask.ts +130 -615
- package/src/board.ts +7 -1
- package/src/brief-upgrade.ts +24 -0
- package/src/briefs/console.md +253 -0
- package/src/briefs/correction.md +203 -0
- package/src/briefs/orchestrator.md +167 -97
- package/src/briefs/policy.md +19 -16
- package/src/briefs/to-spec.md +76 -9
- package/src/briefs/worker.md +50 -16
- package/src/cli.ts +4 -0
- package/src/command-manifest.ts +54 -8
- package/src/commands/arm.ts +113 -49
- package/src/commands/console.ts +70 -0
- package/src/commands/context.ts +2 -0
- package/src/commands/epic.ts +132 -0
- package/src/commands/extend.ts +9 -1
- package/src/commands/intake.ts +44 -14
- package/src/commands/stats.ts +19 -4
- package/src/commands/worker.ts +9 -1
- package/src/config-schema.ts +13 -0
- package/src/config.ts +27 -0
- package/src/daemon/ack.ts +159 -0
- package/src/daemon/admission-pass.ts +135 -0
- package/src/daemon/brief.ts +461 -0
- package/src/daemon/deps.ts +539 -0
- package/src/daemon/dispatch.ts +1779 -0
- package/src/daemon/drain.ts +185 -0
- package/src/daemon/groom-pass.ts +412 -0
- package/src/daemon/http.ts +417 -0
- package/src/daemon/integrity.ts +108 -0
- package/src/daemon/panes.ts +180 -0
- package/src/daemon/review.ts +1888 -0
- package/src/daemon/runtime.ts +736 -0
- package/src/daemon/settle-pass.ts +589 -0
- package/src/daemon/supervision.ts +438 -0
- package/src/daemon/tick.ts +968 -0
- package/src/daemon/views.ts +751 -0
- package/src/daemon.ts +105 -7923
- package/src/dashboard/app.js +58 -0
- package/src/dashboard/controls.ts +22 -3
- package/src/dashboard/server.ts +4 -0
- package/src/diff-flags.ts +24 -3
- package/src/failure-class.ts +75 -1
- package/src/fleet.ts +290 -164
- package/src/groom.ts +461 -0
- package/src/http-token.ts +142 -0
- package/src/knowledge.ts +229 -0
- package/src/mining.ts +316 -0
- package/src/orchestrator-tick.ts +428 -1681
- package/src/ready-gate.ts +267 -0
- package/src/settlement.ts +72 -6
- package/src/setup-host.ts +32 -9
- package/src/setup-wizard.ts +55 -7
- package/src/setup.ts +229 -3
- package/src/stats.ts +257 -2
- package/src/status-render.ts +158 -7
- package/src/store.ts +604 -26
- package/src/to-spec.ts +194 -21
- package/src/tracker/github.ts +50 -0
- package/src/types.ts +416 -15
- package/src/verbs/protocol.ts +28 -0
- package/src/verbs/server.ts +330 -39
- package/src/wake.ts +19 -2
- 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
|
+
}
|