omp-conductor 0.17.1 → 0.18.1

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 (65) hide show
  1. package/README.md +34 -0
  2. package/REFERENCE.md +71 -17
  3. package/agents/to-spec.md +90 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +53 -1
  6. package/src/admission.ts +308 -76
  7. package/src/ask.ts +307 -10
  8. package/src/backups.ts +2 -2
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +43 -14
  11. package/src/briefs/to-spec.md +84 -0
  12. package/src/briefs/worker.md +37 -19
  13. package/src/cli.ts +2 -0
  14. package/src/command-help.ts +19 -1
  15. package/src/command-manifest.ts +27 -2
  16. package/src/commands/context.ts +1 -0
  17. package/src/commands/drain.ts +176 -0
  18. package/src/commands/extend.ts +6 -10
  19. package/src/commands/status.ts +5 -1
  20. package/src/commands/watch.ts +110 -3
  21. package/src/commands/worker.ts +9 -10
  22. package/src/config-schema.ts +57 -0
  23. package/src/config.ts +102 -2
  24. package/src/daemon.ts +1220 -1517
  25. package/src/dashboard/app.js +4 -1
  26. package/src/dashboard/server.ts +5 -2
  27. package/src/decisions.ts +279 -16
  28. package/src/depends-on.ts +261 -1
  29. package/src/diff-flags.ts +425 -1
  30. package/src/digest-schedule.ts +37 -0
  31. package/src/doctor.ts +52 -0
  32. package/src/escalate.ts +9 -3
  33. package/src/failure-class.ts +43 -4
  34. package/src/fleet.ts +166 -24
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +55 -8
  37. package/src/graph.ts +379 -69
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +567 -2
  40. package/src/lifecycle.ts +158 -6
  41. package/src/omp.ts +269 -20
  42. package/src/orchestrator-tick.ts +1489 -26
  43. package/src/orchestrator.ts +12 -0
  44. package/src/privileged.ts +1 -4
  45. package/src/release-policy.ts +503 -9
  46. package/src/routing.ts +11 -3
  47. package/src/session-host.ts +115 -5
  48. package/src/settlement.ts +1780 -0
  49. package/src/setup-host.ts +1205 -6
  50. package/src/setup-install.ts +119 -30
  51. package/src/setup-wizard.ts +88 -2
  52. package/src/setup.ts +119 -13
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +100 -11
  55. package/src/store.ts +519 -45
  56. package/src/to-spec.ts +387 -0
  57. package/src/tracker/github.ts +150 -14
  58. package/src/types.ts +470 -16
  59. package/src/upgrade-verify.ts +209 -2
  60. package/src/upgrade.ts +175 -1
  61. package/src/verbs/protocol.ts +39 -0
  62. package/src/verbs/server.ts +770 -40
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +239 -9
  65. package/src/worktree.ts +142 -18
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
+ }