omp-conductor 0.17.0 → 0.18.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 (51) hide show
  1. package/REFERENCE.md +12 -8
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +40 -1
  4. package/src/admission.ts +263 -44
  5. package/src/ask.ts +39 -3
  6. package/src/availability.ts +27 -1
  7. package/src/backups.ts +2 -2
  8. package/src/briefs/orchestrator.md +1 -0
  9. package/src/briefs/worker.md +38 -19
  10. package/src/command-help.ts +8 -1
  11. package/src/command-manifest.ts +5 -2
  12. package/src/commands/arm.ts +6 -3
  13. package/src/commands/message.ts +32 -4
  14. package/src/commands/watch.ts +62 -3
  15. package/src/config-schema.ts +53 -0
  16. package/src/config.ts +97 -1
  17. package/src/daemon.ts +1479 -1483
  18. package/src/decisions.ts +51 -6
  19. package/src/depends-on.ts +261 -1
  20. package/src/diff-flags.ts +350 -0
  21. package/src/digest-schedule.ts +37 -0
  22. package/src/doctor.ts +310 -22
  23. package/src/escalate.ts +560 -57
  24. package/src/failure-class.ts +71 -15
  25. package/src/fleet.ts +189 -34
  26. package/src/gitops.ts +103 -24
  27. package/src/graph-health.ts +20 -7
  28. package/src/graph.ts +313 -68
  29. package/src/lifecycle.ts +43 -7
  30. package/src/omp.ts +42 -0
  31. package/src/orchestrator-tick.ts +430 -162
  32. package/src/release-policy.ts +177 -5
  33. package/src/routing.ts +11 -3
  34. package/src/session-host.ts +16 -0
  35. package/src/settlement.ts +1728 -0
  36. package/src/setup-host.ts +193 -4
  37. package/src/setup-install.ts +91 -30
  38. package/src/setup-wizard.ts +1257 -78
  39. package/src/setup.ts +153 -6
  40. package/src/status-render.ts +36 -4
  41. package/src/store.ts +411 -17
  42. package/src/tracker/github.ts +607 -12
  43. package/src/types.ts +331 -5
  44. package/src/upgrade.ts +50 -19
  45. package/src/verbs/actions.ts +66 -18
  46. package/src/verbs/protocol.ts +45 -0
  47. package/src/verbs/server.ts +270 -13
  48. package/src/worker.ts +239 -6
  49. package/src/worktree.ts +115 -8
  50. package/systemd/omp-conductor-recover.sh +73 -0
  51. package/systemd/recover-unit-test.sh +61 -0
package/src/decisions.ts CHANGED
@@ -161,6 +161,16 @@ export async function probeRateLimitReset(runGh: RateLimitGh = ghRateLimit): Pro
161
161
  * pass. A condition that could not be checked is simply not met this time, and
162
162
  * the next tick asks again for free.
163
163
  *
164
+ * A watch (#459) whose PR condition can no longer be observed — the PR merged
165
+ * or closed before the requested condition was seen — is withdrawn in this same
166
+ * pass rather than left rendering `condition:pending` forever: the mediated
167
+ * surfaces for checks and mergeability do not answer for a settled PR, so the
168
+ * row could only ever stay unmet, and a watch has deliberately no seven-day
169
+ * expiry to close it. The withdrawal is the same durable `state=withdrawn`
170
+ * resolution an operator's `watch withdraw` writes, so the audit trail of what
171
+ * was waiting and why it stopped survives. Operator questions are untouched:
172
+ * they answer to a human and expire on the seven-day clock.
173
+ *
164
174
  * Returns the rows that just became met, so the caller can log what changed
165
175
  * rather than a count.
166
176
  */
@@ -179,12 +189,29 @@ export async function evaluateDecisionConditions(
179
189
  let satisfied = false;
180
190
  try {
181
191
  if (condition.kind === "pr-merged") {
182
- satisfied = (await tracker.prState(condition.url)) === "merged";
192
+ const state = await tracker.prState(condition.url);
193
+ if (state === "merged") {
194
+ satisfied = true;
195
+ } else if (decision.kind === "watch" && state === "closed") {
196
+ // The PR closed without merging: `pr-merged` can never be observed.
197
+ store.resolveDecision(decision.id, "withdrawn", "PR closed without merging", now());
198
+ continue;
199
+ }
183
200
  } else if (condition.kind === "issue-closed") {
184
201
  satisfied = (await tracker.issueState(condition.issue)) === "closed";
185
202
  } else if (condition.kind === "npm-version") {
186
203
  satisfied = await probes.npm(condition.spec);
187
204
  } else if (condition.kind === "pr-checks-green") {
205
+ // A watch whose PR settled before its checks were seen green cannot
206
+ // ever be met: the checks surface does not answer for a merged or
207
+ // closed PR, so the row would render `pending` forever (#664).
208
+ if (decision.kind === "watch") {
209
+ const state = await tracker.prState(condition.url);
210
+ if (state === "merged" || state === "closed") {
211
+ store.resolveDecision(decision.id, "withdrawn", `PR ${state} before its checks were seen`, now());
212
+ continue;
213
+ }
214
+ }
188
215
  // The same conclusion values the daemon's failure classifier treats as
189
216
  // a green verdict (`success` / `neutral`, lowercased): a non-empty list
190
217
  // in which every check is terminally successful and none is failing or
@@ -193,6 +220,16 @@ export async function evaluateDecisionConditions(
193
220
  satisfied =
194
221
  checks.length > 0 && checks.every((c) => GREEN_CHECK_STATES[c.state.trim().toLowerCase()] === true);
195
222
  } else if (condition.kind === "pr-mergeable") {
223
+ // A watch whose PR settled before it was seen mergeable is the same
224
+ // unobservable case as checks: the mergeability literal exists only
225
+ // while the PR is open (#664).
226
+ if (decision.kind === "watch") {
227
+ const state = await tracker.prState(condition.url);
228
+ if (state === "merged" || state === "closed") {
229
+ store.resolveDecision(decision.id, "withdrawn", `PR ${state} before it was seen mergeable`, now());
230
+ continue;
231
+ }
232
+ }
196
233
  // `clean` is the tracker's "this PR can merge" literal; `unknown` and a
197
234
  // conflict are both unsatisfied (#189).
198
235
  satisfied = (await tracker.mergeable(condition.url)) === "clean";
@@ -242,7 +279,9 @@ function age(since: number, now: number): string {
242
279
  * watch heading with no instruction to resolve it, so a fleet at rest behind
243
280
  * GitHub's checks is never mistaken for a fleet that is waiting on its
244
281
  * operator. A met watch still surfaces to the orchestrator with its note and
245
- * the same `[CONDITION MET]` flag a met question gets.
282
+ * the same `[CONDITION MET]` flag a met question gets. Every watch line also
283
+ * names the command that closes it (`watch withdraw <id>`), because the
284
+ * surface that shows a watch is where the reader learns how to end one (#664).
246
285
  */
247
286
  export function formatDecisionDigest(open: readonly DecisionRecord[], now = Date.now()): string {
248
287
  const questions = open.filter((d) => d.kind !== "watch");
@@ -261,14 +300,20 @@ export function formatDecisionDigest(open: readonly DecisionRecord[], now = Date
261
300
  `Watches (${watches.length}) — conditions the orchestrator set for itself; no operator action needed:`,
262
301
  );
263
302
  for (const d of watches) {
264
- lines.push(decisionLine(d, now));
303
+ // Each line names the verb that closes it, so the orchestrator reading a
304
+ // digest does not have to know the decisions vocabulary (#664).
305
+ lines.push(decisionLine(d, now, `watch withdraw ${d.id}`));
265
306
  }
266
307
  }
267
308
  return lines.join("\n");
268
309
  }
269
310
 
270
- /** One digest row: id, age, what it blocks, the met flag, and the note. */
271
- function decisionLine(d: DecisionRecord, now: number): string {
311
+ /**
312
+ * One digest row: id, age, what it blocks, the met flag, and the note.
313
+ * `closing` names the command that ends the row, rendered after the note.
314
+ */
315
+ function decisionLine(d: DecisionRecord, now: number, closing?: string): string {
272
316
  const flag = d.conditionMetAt === undefined ? "" : " [CONDITION MET — act on this now]";
273
- return `- ${d.id} (${age(d.askedAt, now)}, blocks ${d.blocks ?? "nothing"})${flag}: ${d.question}`;
317
+ const end = closing === undefined ? "" : ` — end with: omp-conductor ${closing}`;
318
+ return `- ${d.id} (${age(d.askedAt, now)}, blocks ${d.blocks ?? "nothing"})${flag}: ${d.question}${end}`;
274
319
  }
package/src/depends-on.ts CHANGED
@@ -18,9 +18,70 @@
18
18
  * usable prerequisite, so it is reported as `malformed` for the grooming flag
19
19
  * rather than guessed at, and the declaration it belongs to is ignored.
20
20
  *
21
- * Graph cycles are a later slice (#421) and stay out of scope here.
21
+ * The module also owns the dependency-graph cycle pass (#421): {@link
22
+ * buildDependencyCycles} closes the graph over a pass's candidates and their
23
+ * reachable `Depends-on:` targets, and names every cycle so admission can hold
24
+ * the whole group rather than have an open first edge mask the cycle.
22
25
  */
23
26
 
27
+ import type { IssueState } from "./types.ts";
28
+
29
+ /** Canonical identity of one dependency-graph node: `owner/repo#n`. */
30
+ export interface DependencyNode {
31
+ readonly repo: string;
32
+ readonly issue: number;
33
+ }
34
+
35
+ /** Stable string key for {@link DependencyNode} — `owner/repo#n`. */
36
+ export function dependencyNodeKey(repo: string, issue: number): string {
37
+ return `${repo}#${issue}`;
38
+ }
39
+
40
+ /** One issue's live facts, enough to follow a dependency edge from it. */
41
+ export interface DependencyRead {
42
+ readonly state: IssueState;
43
+ readonly body: string;
44
+ }
45
+
46
+ /**
47
+ * Reads one issue's state + body in either the tracker repo or a routed repo,
48
+ * or undefined when the repo is not a resolvable dependency target or the read
49
+ * could not be completed. Both cases fail closed — no edge is added — rather
50
+ * than ever synthesising a false cycle (#421).
51
+ */
52
+ export type DependencyNodeReader = (
53
+ repo: string,
54
+ issue: number,
55
+ ) => Promise<DependencyRead | undefined>;
56
+
57
+ /** Where a cross-repo `owner/repo#n` target resolves: the tracker repo, a
58
+ * routed repo, or nowhere (bounds traversal to the fleet's own repos). */
59
+ export type DependencyRepoResolver = (ownerRepo: string) => "tracker" | "routed" | undefined;
60
+
61
+ /** A detected dependency cycle, canonicalised so rotation or queue order cannot
62
+ * change its identity (and therefore cannot emit a new material event). */
63
+ export interface DependencyCycle {
64
+ /** Member node keys (`owner/repo#n`), for membership checks. */
65
+ readonly members: readonly string[];
66
+ /** The cycle as an ordered path from the canonical start, closing on itself.
67
+ * The display is stable under rotation and queue order. */
68
+ readonly path: readonly DependencyNode[];
69
+ /** {@link path} rendered: same-repo nodes as `#n`, others as `owner/repo#n`. */
70
+ readonly display: string;
71
+ /** The tracker-repo issue this cycle is reported against: the lowest global
72
+ * tracker issue among the members. A cycle reachable only through routed
73
+ * nodes falls back to its lowest member issue. */
74
+ readonly anchorIssue: number;
75
+ }
76
+
77
+ /** The output of one cycle pass over a set of candidates. */
78
+ export interface DependencyGraphResult {
79
+ /** Node keys of every node on at least one detected cycle. */
80
+ readonly cycleNodes: ReadonlySet<string>;
81
+ /** The distinct cycles found, canonicalised and deduplicated. */
82
+ readonly cycles: readonly DependencyCycle[];
83
+ }
84
+
24
85
  export interface DependsOnDecl {
25
86
  /** Referenced same-repo prerequisite issue numbers, deduplicated,
26
87
  * first-seen order. */
@@ -120,3 +181,202 @@ export function parseDependsOn(body: string): DependsOnDecl {
120
181
  }
121
182
  return { refs, crossRefs, malformed };
122
183
  }
184
+
185
+ /**
186
+ * Enumerate every dependency cycle reachable from an admission pass's
187
+ * candidates. Built once per pass, before any per-candidate open-prerequisite
188
+ * hold, so a candidate sitting on a cycle is held as `dependency-cycle` rather
189
+ * than having its status masked by the first open edge (#421).
190
+ *
191
+ * The graph nodes are the candidates (bodies already parsed from `routed`,
192
+ * never a network read) plus every issue they reach through `Depends-on:`
193
+ * references — a bare `#n` resolves in the declaring node's own repo
194
+ * (tracker or routed), a cross-repo `owner/repo#n` in the tracker or a
195
+ * routed repo. Traversal is bounded in three ways:
196
+ * - references to a repo that is neither the tracker nor a routed repo are
197
+ * dropped before any read (they are the existing unroutable hold's job);
198
+ * - every node is expanded once, so each body/state read happens at most
199
+ * once per pass regardless of how many candidates reach it;
200
+ * - a closed node is terminal: it never propagates a cycle, because a
201
+ * dependency that is already done cannot hold anyone open.
202
+ * A node whose state or body cannot be read fails closed — it adds no edge and
203
+ * is never mistaken for a cycle; the per-candidate path then holds its
204
+ * originator on the existing dependency-unreadable route.
205
+ *
206
+ * Cycle membership is the graph's strongly connected components (Tarjan): a
207
+ * node sits on some directed cycle exactly when its component has more than
208
+ * one node or a self-edge, so overlapping cycles cannot hide a member behind
209
+ * an already-explored subtree. Each cyclic component is one cycle identity,
210
+ * with a deterministic simple path for status/digest.
211
+ */
212
+ export async function buildDependencyCycles(
213
+ candidates: ReadonlyArray<{ repo: string; issue: number; body: string }>,
214
+ trackerRepo: string,
215
+ resolveRepo: DependencyRepoResolver,
216
+ read: DependencyNodeReader,
217
+ ): Promise<DependencyGraphResult> {
218
+ const candidateBody = new Map<string, string>();
219
+ for (const c of candidates) {
220
+ candidateBody.set(dependencyNodeKey(c.repo, c.issue), c.body);
221
+ }
222
+
223
+ // Materialise the reachable graph once: node key -> outgoing targets. A key
224
+ // present in `adj` is expanded — a candidate carries its body, a
225
+ // non-candidate is read at most once, and an unreadable or closed node is
226
+ // terminal (present, with no edges), so it is never read or re-expanded.
227
+ const adj = new Map<string, DependencyNode[]>();
228
+ const nodeOf = new Map<string, DependencyNode>();
229
+
230
+ const expand = async (node: DependencyNode): Promise<void> => {
231
+ const key = dependencyNodeKey(node.repo, node.issue);
232
+ if (adj.has(key)) return;
233
+ let body = candidateBody.get(key);
234
+ if (body === undefined) {
235
+ // Non-candidate node: read it fresh (once), fail closed on any doubt.
236
+ const r = await read(node.repo, node.issue);
237
+ if (r === undefined || r.state === "closed") {
238
+ // Unreadable never synthesises a cycle; a closed dependency is terminal.
239
+ adj.set(key, []);
240
+ nodeOf.set(key, { repo: node.repo, issue: node.issue });
241
+ return;
242
+ }
243
+ body = r.body;
244
+ }
245
+
246
+ const decl = parseDependsOn(body);
247
+ const targets: DependencyNode[] = [];
248
+ // A bare `#n` resolves in the declaring node's OWN repo: a body read from
249
+ // `acme/web#7` declares `acme/web#n`, never the tracker repo's #n.
250
+ for (const ref of decl.refs) {
251
+ targets.push({ repo: node.repo, issue: ref });
252
+ }
253
+ for (const cref of decl.crossRefs) {
254
+ const where = resolveRepo(cref.repo);
255
+ if (where === "tracker") targets.push({ repo: trackerRepo, issue: cref.issue });
256
+ else if (where === "routed") targets.push({ repo: cref.repo, issue: cref.issue });
257
+ // unresolved repos are dropped before any read: bounds traversal to the
258
+ // fleet's own tracker/routed repos (#421).
259
+ }
260
+ adj.set(key, targets);
261
+ nodeOf.set(key, { repo: node.repo, issue: node.issue });
262
+ for (const t of targets) {
263
+ await expand(t);
264
+ }
265
+ };
266
+
267
+ for (const c of candidates) {
268
+ await expand({ repo: c.repo, issue: c.issue });
269
+ }
270
+
271
+ // Tarjan's strongly connected components over the materialised graph. Every
272
+ // node on some directed cycle is in a cyclic component, so membership is
273
+ // complete where a single DFS path would miss a member behind an
274
+ // already-explored subtree (1 -> {2,3}, 3 -> 2, 2 -> 1: 3 is on the cycle
275
+ // 1 -> 3 -> 2 -> 1 even though the direct 1 <-> 2 pair is found first).
276
+ let nextIndex = 0;
277
+ const index = new Map<string, number>();
278
+ const low = new Map<string, number>();
279
+ const onStack = new Set<string>();
280
+ const stack: string[] = [];
281
+ const components: string[][] = [];
282
+
283
+ const strongConnect = (key: string): void => {
284
+ index.set(key, nextIndex);
285
+ low.set(key, nextIndex);
286
+ nextIndex += 1;
287
+ stack.push(key);
288
+ onStack.add(key);
289
+ for (const t of adj.get(key) ?? []) {
290
+ const targetKey = dependencyNodeKey(t.repo, t.issue);
291
+ if (!index.has(targetKey)) {
292
+ strongConnect(targetKey);
293
+ low.set(key, Math.min(low.get(key)!, low.get(targetKey)!));
294
+ } else if (onStack.has(targetKey)) {
295
+ low.set(key, Math.min(low.get(key)!, index.get(targetKey)!));
296
+ }
297
+ }
298
+ if (low.get(key) === index.get(key)) {
299
+ const component: string[] = [];
300
+ let w: string;
301
+ do {
302
+ w = stack.pop()!;
303
+ onStack.delete(w);
304
+ component.push(w);
305
+ } while (w !== key);
306
+ components.push(component);
307
+ }
308
+ };
309
+ for (const key of adj.keys()) {
310
+ if (!index.has(key)) strongConnect(key);
311
+ }
312
+
313
+ /** A deterministic simple directed cycle inside one cyclic component: DFS
314
+ * from the smallest member with children in key order; the first back edge
315
+ * closes a path. A true cyclic component always yields one — every member
316
+ * reaches every other, so the search cannot finish without crossing an
317
+ * ancestor. */
318
+ const findCyclePath = (
319
+ start: DependencyNode,
320
+ inComponent: ReadonlySet<string>,
321
+ ): readonly DependencyNode[] | undefined => {
322
+ const path: DependencyNode[] = [start];
323
+ const position = new Map<string, number>([
324
+ [dependencyNodeKey(start.repo, start.issue), 0],
325
+ ]);
326
+ const descend = (cur: DependencyNode): readonly DependencyNode[] | undefined => {
327
+ const children = (adj.get(dependencyNodeKey(cur.repo, cur.issue)) ?? [])
328
+ .filter((t) => inComponent.has(dependencyNodeKey(t.repo, t.issue)))
329
+ .sort((a, b) => {
330
+ const ka = dependencyNodeKey(a.repo, a.issue);
331
+ const kb = dependencyNodeKey(b.repo, b.issue);
332
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
333
+ });
334
+ for (const child of children) {
335
+ const childKey = dependencyNodeKey(child.repo, child.issue);
336
+ const at = position.get(childKey);
337
+ if (at !== undefined) {
338
+ // Back edge: the path from the ancestor to here, closed on `child`.
339
+ return [...path.slice(at), child];
340
+ }
341
+ position.set(childKey, path.length);
342
+ path.push(child);
343
+ const found = descend(child);
344
+ if (found !== undefined) return found;
345
+ path.pop();
346
+ position.delete(childKey);
347
+ }
348
+ return undefined;
349
+ };
350
+ return descend(start);
351
+ };
352
+
353
+ const cycles: DependencyCycle[] = [];
354
+ for (const component of components) {
355
+ const members = [...component].sort();
356
+ const hasSelfEdge = members.some((k) =>
357
+ (adj.get(k) ?? []).some((t) => dependencyNodeKey(t.repo, t.issue) === k),
358
+ );
359
+ if (members.length === 1 && !hasSelfEdge) continue; // terminal or acyclic
360
+ // Canonical start: the smallest member by owner/repo#n spelling, so
361
+ // rotation and queue order never produce a different identity.
362
+ const path = findCyclePath(nodeOf.get(members[0]!)!, new Set(component));
363
+ if (path === undefined) continue; // unreachable for a cyclic component; never fabricate
364
+ const display = path
365
+ .map((n) => (n.repo === trackerRepo ? `#${n.issue}` : `${n.repo}#${n.issue}`))
366
+ .join(" -> ");
367
+ const trackerMembers = members
368
+ .map((k) => k.split("#") as [string, string])
369
+ .filter(([repo]) => repo === trackerRepo)
370
+ .map(([, issue]) => Number(issue));
371
+ const anchorIssue =
372
+ trackerMembers.length > 0
373
+ ? Math.min(...trackerMembers)
374
+ : Math.min(...members.map((k) => Number(k.split("#")[1])));
375
+
376
+ cycles.push({ members, path, display, anchorIssue });
377
+ }
378
+
379
+ const cycleNodes = new Set<string>();
380
+ for (const cyc of cycles) for (const m of cyc.members) cycleNodes.add(m);
381
+ return { cycleNodes, cycles };
382
+ }