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