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,267 @@
1
+ /**
2
+ * The ready gate (#1040, #1041): the one pure answer to "may this durable
3
+ * PROMOTABLE verdict become a queue label without a human looking at it".
4
+ *
5
+ * Promotion used to be an orchestrator act, so the checks that made it safe
6
+ * lived wherever the act happened — the lane comparison inside the promotion
7
+ * verb (#1036), the rest in the orchestrator's own judgement. Phase 2 makes
8
+ * promotion mechanical: the daemon promotes a passing verdict itself and wakes
9
+ * dispatch, and the tick audits afterwards. A judgement that is about to run
10
+ * without a reader has to be one function, testable in isolation, and it has
11
+ * to name everything it refuses for — a gate that reports only the first
12
+ * problem turns one grooming pass into four.
13
+ *
14
+ * Pure and IO-free on purpose. The caller supplies the issue text, its comment
15
+ * thread and its labels; the gate reads nothing, fetches nothing and has no
16
+ * clock. That is what lets the same function run inside the promotion verb
17
+ * (which already read the issue for its lane echo) and inside the daemon's
18
+ * grooming pass (which has the snapshot in hand) without either growing a
19
+ * second copy of the policy.
20
+ *
21
+ * Fail-closed, unlike admission. Admission fails *open* on a lane it cannot
22
+ * parse — it must never refuse work for wanting a declaration, because a
23
+ * human queued that work deliberately. Nothing here was queued by a human, so
24
+ * every unknown is a refusal: an unreadable issue, a write-lane heading that
25
+ * parsed to nothing, a routing label that resolves nowhere. The failure mode
26
+ * of a fail-open promotion gate is dispatching a worker at an unverified
27
+ * brief, which is exactly the incident (#1036) that produced the lane
28
+ * comparison in the first place.
29
+ */
30
+
31
+ import { effectiveLane, writeLaneSectionHeading } from "./admission.ts";
32
+ import { repoSlugFor } from "./gitops.ts";
33
+ import type { ToSpecResult } from "./to-spec.ts";
34
+ import type { IssueComment, ProjectConfig } from "./types.ts";
35
+
36
+ /**
37
+ * Everything the gate judges, and nothing it could fetch itself.
38
+ *
39
+ * `comments` distinguishes "the thread is empty" from "the thread could not be
40
+ * read": behind a durable promotable verdict the second is a refusal, not an
41
+ * empty lane (#1036). `"unread"` means the whole read failed — body included —
42
+ * so the gate reports that and stops rather than judging a blank issue.
43
+ */
44
+ export interface ReadyGateInput {
45
+ result: ToSpecResult;
46
+ issueBody: string;
47
+ comments: readonly IssueComment[] | "unread";
48
+ labels: readonly string[];
49
+ project: ProjectConfig;
50
+ }
51
+
52
+ /** Pass, or every element that is missing — one short human-readable reason
53
+ * each, in a fixed order, so a refusal reads the same twice. */
54
+ export type ReadyGateVerdict = { ok: true } | { ok: false; missing: string[] };
55
+
56
+ /**
57
+ * The acceptance-criteria section of an issue: the heading plus the contiguous
58
+ * bullet run beneath it. Deliberately the same grammar as admission's
59
+ * write-lane section ({@link writeLaneSectionHeading}) — a `##`-to-`######`
60
+ * heading whose text is exactly the section name, optional trailing colon, and
61
+ * a bullet run that any non-bullet line ends. One discipline for both sections
62
+ * means an issue that renders correctly for one renders correctly for both.
63
+ *
64
+ * It lives here rather than in admission.ts because admission has no business
65
+ * with acceptance criteria: it gates dispatch on lanes and budgets, and a
66
+ * human-queued issue without checkable criteria is still dispatched. Only
67
+ * mechanical promotion needs to know.
68
+ *
69
+ * Returns `undefined` when there is no such heading, and an empty `criteria`
70
+ * when the heading is there but nothing checkable is under it — the two are
71
+ * different refusals.
72
+ */
73
+ export function acceptanceCriteriaSection(text: string): { heading: string; criteria: string[] } | undefined {
74
+ const lines = text.split("\n");
75
+ let start = -1;
76
+ for (let i = 0; i < lines.length; i++) {
77
+ if (/^[ \t]*#{1,6}[ \t]+acceptance[-\s]criteria[ \t]*[:.]?[ \t]*$/i.test(lines[i]!)) {
78
+ start = i;
79
+ break;
80
+ }
81
+ }
82
+ if (start < 0) return undefined;
83
+ const criteria: string[] = [];
84
+ for (let i = start + 1; i < lines.length; i++) {
85
+ const line = lines[i]!;
86
+ if (line.trim() === "") continue;
87
+ // A markdown bullet marker (`- `, `* `, `+ `, `1. `), with an optional task
88
+ // checkbox — the shape GitHub renders as a checklist. Anything else ends
89
+ // the run, so a caveat paragraph or the next section bounds the section.
90
+ const marker = line.match(/^[ \t]*(?:[-*+]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?/);
91
+ if (marker === null) break;
92
+ const rest = line.slice(marker[0].length).trim();
93
+ if (rest.length > 0) criteria.push(rest);
94
+ }
95
+ return { heading: lines[start]!.trim(), criteria };
96
+ }
97
+
98
+ /** Trimmed non-empty strings out of a value the type system claims is a string
99
+ * array. Defensive because the gate is the last thing between a hand-edited
100
+ * store row and a dispatched worker: a malformed field is a refusal, never a
101
+ * throw. */
102
+ function strings(value: unknown): string[] {
103
+ if (!Array.isArray(value)) return [];
104
+ const out: string[] = [];
105
+ for (const item of value) {
106
+ if (typeof item !== "string") continue;
107
+ const trimmed = item.trim();
108
+ if (trimmed.length > 0) out.push(trimmed);
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /**
114
+ * May this verdict be promoted mechanically?
115
+ *
116
+ * Every check is one named miss. The order is fixed — verdict, readability,
117
+ * acceptance criteria, write lane, proof commands, sizing evidence,
118
+ * dependencies, premise, routing, state labels — so the same input always
119
+ * produces the same list and a digest line is stable across ticks.
120
+ *
121
+ * Two checks short-circuit, because everything after them would be noise: a
122
+ * verdict that is not PROMOTABLE is not a partly-ready spec, and an unreadable
123
+ * issue tells you nothing about criteria, lanes or labels.
124
+ */
125
+ export function readyGate(input: ReadyGateInput): ReadyGateVerdict {
126
+ const { result, project } = input;
127
+
128
+ if (result.verdict !== "PROMOTABLE") {
129
+ return {
130
+ ok: false,
131
+ missing: [`the durable verdict is ${result.verdict}, and only PROMOTABLE may be promoted mechanically`],
132
+ };
133
+ }
134
+ if (input.comments === "unread") {
135
+ return {
136
+ ok: false,
137
+ missing: [
138
+ "the issue could not be read, so neither its acceptance criteria nor its write lane can be compared with the verdict",
139
+ ],
140
+ };
141
+ }
142
+
143
+ const body = typeof input.issueBody === "string" ? input.issueBody : "";
144
+ const comments = [...input.comments];
145
+ const missing: string[] = [];
146
+
147
+ // Acceptance criteria may arrive in the body or later in the thread — the
148
+ // same two surfaces the lane declaration reads. Presence is the bar: a gate
149
+ // cannot judge whether a criterion is a good one, only whether the worker
150
+ // will be handed something to check itself against.
151
+ const sections = [body, ...comments.map((comment) => comment.body)]
152
+ .map((text) => acceptanceCriteriaSection(text))
153
+ .filter((section) => section !== undefined);
154
+ // A later section with real bullets beats an earlier empty one; an empty one
155
+ // is still reported (as its own miss) when nothing better exists.
156
+ const criteria = sections.find((section) => section.criteria.length > 0) ?? sections[0];
157
+ if (criteria === undefined) {
158
+ missing.push(
159
+ "no acceptance-criteria section: add an `## Acceptance criteria` heading with one checkable bullet per criterion",
160
+ );
161
+ } else if (criteria.criteria.length === 0) {
162
+ missing.push(
163
+ `the acceptance-criteria section (${criteria.heading}) has no bullets under it, so the worker has nothing to check itself against`,
164
+ );
165
+ }
166
+
167
+ // The lane admission will enforce, against the lane the verdict was computed
168
+ // over — exact in both directions, order-insensitive (#1036). Both sides go
169
+ // through `strings` so they dedupe and trim in lockstep: a set comparison
170
+ // whose halves normalize differently lies silently.
171
+ //
172
+ // A write-lane heading that parsed nothing gets its own miss rather than
173
+ // reading as an empty lane. Admission fails open on exactly this shape
174
+ // (#825) because it must never refuse human-queued work; a mechanical
175
+ // promotion has no such excuse, and the actionable fact is the syntax, not
176
+ // the set difference.
177
+ const verdictLane = [...new Set(strings(result.fileLane))].sort();
178
+ const lane = effectiveLane(body, comments);
179
+ const heading =
180
+ lane !== undefined
181
+ ? undefined
182
+ : (writeLaneSectionHeading(body) ??
183
+ comments.map((comment) => writeLaneSectionHeading(comment.body)).find((found) => found !== undefined));
184
+ if (verdictLane.length === 0) {
185
+ // The schema requires at least one path, so this is a hand-edited or
186
+ // corrupt row. An empty lane on both sides would otherwise *match*, and
187
+ // match into a dispatch with nothing to serialise concurrent work on.
188
+ missing.push("the verdict names no file lane, so admission would have nothing to serialise concurrent work on");
189
+ } else if (heading !== undefined) {
190
+ missing.push(
191
+ `the write-lane section (${heading}) parsed no path-like files — name them as backticked bullets directly under the heading`,
192
+ );
193
+ } else {
194
+ const declared = lane === undefined ? [] : [...new Set(strings(lane.files))].sort();
195
+ if (declared.length !== verdictLane.length || declared.some((path, index) => path !== verdictLane[index])) {
196
+ missing.push(
197
+ `the issue's write lane [${declared.join(", ")}] disagrees with the verdict's file lane [${verdictLane.join(", ")}]`,
198
+ );
199
+ }
200
+ }
201
+
202
+ if (strings(result.proofCommands).length === 0) {
203
+ missing.push("the verdict names no proof commands, so nothing would prove the work");
204
+ }
205
+ if (typeof result.sizingEvidence !== "string" || result.sizingEvidence.trim().length === 0) {
206
+ missing.push(
207
+ "the verdict carries no sizingEvidence: the one-budget claim has nothing behind it that could falsify it (#1041)",
208
+ );
209
+ }
210
+
211
+ const dependencies = Array.isArray(result.dependencies)
212
+ ? result.dependencies.filter((dep) => typeof dep === "number" || (typeof dep === "string" && dep.trim().length > 0))
213
+ : [];
214
+ if (dependencies.length > 0) {
215
+ missing.push(`open prerequisites are still named: ${dependencies.join(", ")}`);
216
+ }
217
+ // A verdict that says later work retired the premise cannot also be a
218
+ // promotion: the schema allows the combination because a groomer may want to
219
+ // record what it found, and nothing else reads it (#883). Mechanically,
220
+ // promoting it dispatches a worker at work the verdict itself disowned.
221
+ if (result.laterWorkInvalidates) {
222
+ missing.push("the verdict reports that later work invalidated the candidate's premise, so it must be re-groomed, not promoted");
223
+ }
224
+
225
+ const { labelPrefix, repos } = project.routing;
226
+ const labels = strings(input.labels);
227
+ const matched = [...new Set(labels.filter((label) => label.startsWith(labelPrefix)))];
228
+ if (matched.length === 0) {
229
+ missing.push(`no ${labelPrefix}* routing label, so dispatch could not pick a checkout`);
230
+ } else if (matched.length > 1) {
231
+ missing.push(`${matched.length} routing labels (${matched.join(", ")}) — exactly one is routable`);
232
+ } else {
233
+ const key = matched[0]!.slice(labelPrefix.length);
234
+ // hasOwn, not truthiness: a `repo:constructor` label would otherwise
235
+ // resolve off Object.prototype and route work into a bogus target
236
+ // (routing.ts:route).
237
+ if (!Object.hasOwn(repos, key)) {
238
+ const known = Object.keys(repos).map((name) => `${labelPrefix}${name}`).join(", ");
239
+ missing.push(`routing label ${matched[0]} names no repository ${project.name} routes (${known || "none"})`);
240
+ } else {
241
+ const slug = repoSlugFor(repos[key]!);
242
+ if (slug !== result.routing) {
243
+ missing.push(
244
+ result.routing === "MULTI"
245
+ ? `the verdict routes to MULTI, a split across repositories that no single ${labelPrefix}* label can carry — file the split as children first`
246
+ : `routing label ${matched[0]} routes to ${slug}, but the verdict routed this work to ${result.routing}`,
247
+ );
248
+ }
249
+ }
250
+ }
251
+
252
+ // The queue label is about to land, so the gate owes the other half of
253
+ // eligibility (routing.ts:isEligible): a state label already on the issue
254
+ // would make the promotion a no-op the daemon silently skips, and the park
255
+ // label beats the queue label outright (#734).
256
+ const { inProgress, blocked, failed, backlog } = project.stateLabels;
257
+ const held = [inProgress, blocked, failed, backlog].filter(
258
+ // The typeof guard is runtime armour, not typing: an older config file can
259
+ // reach here without every state label set.
260
+ (label) => typeof label === "string" && label.length > 0 && labels.includes(label),
261
+ );
262
+ if (held.length > 0) {
263
+ missing.push(`the issue still carries ${held.join(", ")}, which would keep it ineligible once the queue label lands`);
264
+ }
265
+
266
+ return missing.length === 0 ? { ok: true } : { ok: false, missing };
267
+ }
package/src/settlement.ts CHANGED
@@ -19,6 +19,7 @@ import { join } from "node:path";
19
19
  import { homedir } from "node:os";
20
20
  import { log, errText, safeEscalate } from "./log.ts";
21
21
  import { hasContinuationBudget } from "./admission.ts";
22
+ import { appendKnowledge } from "./knowledge.ts";
22
23
  import {
23
24
  UNREADABLE_TREE_FLAG,
24
25
  analyseSettlement,
@@ -28,6 +29,7 @@ import { SPINNING_CAP_CLASSES, classifyRun, normalise, type ClassifyFacts } from
28
29
  import { GhPrMissingError } from "./tracker/github.ts";
29
30
  import { formatModelsTried, modelsTried } from "./model-fallback.ts";
30
31
  import { PR_LOOKUP_WINDOW_MS } from "./decisions.ts";
32
+ import { wakeDispatch } from "./wake.ts";
31
33
  import {
32
34
  removeWorktree,
33
35
  salvageWip,
@@ -86,6 +88,14 @@ export interface SettlementDeps {
86
88
  provenance: { source: string; reason?: string },
87
89
  project?: string,
88
90
  ): void;
91
+ /**
92
+ * The dispatch wake fired after a queue-label hand-back (#1041). Production
93
+ * leaves it absent and {@link swapToQueue} calls the real loopback client;
94
+ * a test injects a spy, which is the only way to prove the wake fires — the
95
+ * mutation it follows already committed, so nothing about the store or the
96
+ * outbox records whether dispatch was woken.
97
+ */
98
+ wake?: (projectName: string) => Promise<string>;
89
99
  }
90
100
 
91
101
  /** What the settlement audit of one green run produced: the advisory flags
@@ -582,6 +592,41 @@ export function recordOperatorStop(
582
592
  ]);
583
593
  }
584
594
 
595
+ /**
596
+ * File a settled worker's reported discoveries into the per-repo knowledge
597
+ * overlay (Phase 3 inner loop).
598
+ *
599
+ * The overlay exists because everything a worker learns about a repo — the real
600
+ * entry point, the gate that actually proves the change, the fake that looks
601
+ * like a test — used to die with its run row, so the next worker on the same
602
+ * repo rediscovered it at the cost of turns. `discoveries` is an optional field
603
+ * on the worker's own structured settlement, so a run that reports none appends
604
+ * nothing.
605
+ *
606
+ * A knowledge write can never fail a settlement, and that is the whole reason
607
+ * this is a function rather than an inline call: the settle path is what makes a
608
+ * run's outcome durable, and losing an outcome because an advisory markdown file
609
+ * could not be written would trade the fleet's record for a note. `appendKnowledge`
610
+ * already swallows its own IO faults; the catch here covers everything else
611
+ * (a path that cannot be resolved, a caller-supplied value that surprises it)
612
+ * so no future change to that module can reach back and break a settle.
613
+ */
614
+ export function recordWorkerDiscoveries(
615
+ repo: string,
616
+ issue: number,
617
+ discoveries: readonly string[] | undefined,
618
+ at = Date.now(),
619
+ ): void {
620
+ if (discoveries === undefined || discoveries.length === 0) return;
621
+ try {
622
+ appendKnowledge(repo, discoveries, { issue, at });
623
+ const n = discoveries.length;
624
+ log(`#${issue} recorded ${n} repo ${n === 1 ? "discovery" : "discoveries"} into the ${repo} knowledge overlay`);
625
+ } catch (err) {
626
+ log(`#${issue} repo knowledge for ${repo} was not updated: ${errText(err)}`);
627
+ }
628
+ }
629
+
585
630
  /**
586
631
  * What a salvage attempt contributes to the escalation: where the work went, or
587
632
  * that it went nowhere. Split from the effects below for the same reason
@@ -1631,7 +1676,7 @@ async function recoverRun(
1631
1676
  }
1632
1677
  const continuation = store.continuationsFor(project.name, run.issue);
1633
1678
  if (hasContinuationBudget(continuation, caps.maxContinuationsPerIssue)) {
1634
- swapToQueue(d, run.issue, project.stateLabels.failed);
1679
+ await swapToQueue(d, run.issue, project.stateLabels.failed);
1635
1680
  store.updateRun(run.id, { recoveredAt: Date.now() });
1636
1681
  log(`#${run.issue} requeued for a wall-clock continuation: ${evidence}`);
1637
1682
  } else {
@@ -1676,7 +1721,7 @@ async function recoverRun(
1676
1721
  }
1677
1722
  const continuation = store.continuationsFor(project.name, run.issue);
1678
1723
  if (hasContinuationBudget(continuation, caps.maxContinuationsPerIssue)) {
1679
- swapToQueue(d, run.issue, project.stateLabels.failed);
1724
+ await swapToQueue(d, run.issue, project.stateLabels.failed);
1680
1725
  store.updateRun(run.id, { recoveredAt: Date.now() });
1681
1726
  log(`#${run.issue} requeued after a provider empty-stop: ${evidence}`);
1682
1727
  return;
@@ -1707,7 +1752,7 @@ async function recoverRun(
1707
1752
  // recovery; the projector retries until the tracker takes the swap, and
1708
1753
  // while it is pending the eligibility overlay keeps the issue coherent
1709
1754
  // (#201).
1710
- swapToQueue(d, run.issue, inProgress);
1755
+ await swapToQueue(d, run.issue, inProgress);
1711
1756
  store.updateRun(run.id, {
1712
1757
  state: "killed",
1713
1758
  lastError:
@@ -1875,7 +1920,7 @@ async function recoverRun(
1875
1920
  }
1876
1921
  }
1877
1922
  const label = cls === "orphan-clean" ? inProgress : project.stateLabels.failed;
1878
- swapToQueue(d, run.issue, label);
1923
+ await swapToQueue(d, run.issue, label);
1879
1924
  store.updateRun(run.id, { recoveredAt: Date.now() });
1880
1925
  log(`#${run.issue} requeued from ${cls}: ${evidence}`);
1881
1926
  return;
@@ -2049,7 +2094,8 @@ async function escalateModelTier(
2049
2094
  }
2050
2095
 
2051
2096
  /**
2052
- * Enqueue a state-label → queue-label swap for projection (#201).
2097
+ * Enqueue a state-label → queue-label swap for projection (#201), then wake
2098
+ * dispatch (#1041).
2053
2099
  *
2054
2100
  * The swap is two ops in id order — remove first, then add — which is the
2055
2101
  * atomicity the projector guarantees: the issue never sits newly eligible
@@ -2059,12 +2105,32 @@ async function escalateModelTier(
2059
2105
  * immediately and the projector retries the swap until the tracker takes it —
2060
2106
  * that closes the 0.4.4 hole where a refused label swap stranded the row
2061
2107
  * permanently under a log line promising a retry.
2108
+ *
2109
+ * The wake is why this is the chokepoint every requeue goes through. A
2110
+ * continuation handed its queue label back and then waiting out the five-minute
2111
+ * interval reads, from outside, exactly like a stalled queue — the same
2112
+ * complaint #878 fixed for `unblock` and `resume`, on the paths that never
2113
+ * used it. It runs AFTER the enqueue, never before: the wake shortens a sleep
2114
+ * and the pass it prompts re-reads every gate, so a wake that arrives before
2115
+ * the durable write would simply find nothing. And it can never fail the
2116
+ * requeue — the label ops are already committed, the client answers a line for
2117
+ * every outcome including "no daemon", and a thrown seam is logged as the
2118
+ * degradation it is: the next scheduled pass claims.
2062
2119
  */
2063
- export function swapToQueue(d: Pick<SettlementDeps, "project" | "store">, issue: number, label: string): void {
2120
+ export async function swapToQueue(
2121
+ d: Pick<SettlementDeps, "project" | "store" | "wake">,
2122
+ issue: number,
2123
+ label: string,
2124
+ ): Promise<void> {
2064
2125
  d.store.enqueueLabelOps(d.project.name, [
2065
2126
  { issue, op: "remove", label },
2066
2127
  { issue, op: "add", label: d.project.queueLabel },
2067
2128
  ]);
2129
+ try {
2130
+ log(`#${issue} back on the queue — ${await (d.wake ?? wakeDispatch)(d.project.name)}`);
2131
+ } catch (err) {
2132
+ log(`#${issue} back on the queue but the dispatch wake failed (${errText(err)}) — the next scheduled pass will claim`);
2133
+ }
2068
2134
  }
2069
2135
 
2070
2136
 
package/src/setup-host.ts CHANGED
@@ -4,7 +4,8 @@ import { homedir, userInfo } from "node:os";
4
4
  import { dirname, join, relative, resolve, sep } from "node:path";
5
5
  import { configPath, loadConfig, resolveCaps, stateDir } from "./config.ts";
6
6
  import { isPaused, runDaemon, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
7
- import { ORCHESTRATOR_BRIEF_NAME } from "./brief-upgrade.ts";
7
+ import { AGENTS_BRIEF_NAME, CONSOLE_BRIEF_NAME, ORCHESTRATOR_BRIEF_NAME } from "./brief-upgrade.ts";
8
+ import { planConsole, writeConsole, type ConsolePlan } from "./setup.ts";
8
9
  import {
9
10
  DEFAULT_FLEET_AGENT_NAME,
10
11
  DEFAULT_HERDR_SESSION,
@@ -108,13 +109,6 @@ export interface HostRetirement {
108
109
  steps: readonly PrivilegedStep[];
109
110
  }
110
111
 
111
- /**
112
- * The symlink the session cwd loads as its brief. omp auto-loads `AGENTS.md`
113
- * from the session cwd, so the fleet pane's cwd needs a link at this name
114
- * resolving to the composed {@link ORCHESTRATOR_BRIEF_NAME}.
115
- */
116
- export const AGENTS_BRIEF_NAME = "AGENTS.md";
117
-
118
112
  export type PlannedWrite<T> = {
119
113
  path: string;
120
114
  action: "create" | "update" | "keep";
@@ -231,6 +225,14 @@ export interface HostRuntimePlan {
231
225
  tick?: PlannedWrite<TickConfig>;
232
226
  /** The `AGENTS.md` — composed-brief symlink — placed in the fleet cwd. */
233
227
  briefLink?: BriefLinkPlan;
228
+ /**
229
+ * The operator console's workspace: its own cwd under the state root, the
230
+ * rendered console floor, and the `AGENTS.md` link that makes the session
231
+ * load it. Planned per project like {@link briefLink}, and deliberately
232
+ * carrying no tick config — the console cwd must stay a directory the tick
233
+ * extension cannot activate in.
234
+ */
235
+ console?: ConsolePlan;
234
236
  /**
235
237
  * Set when an install ran with no project named (a host-global install):
236
238
  * the per-project tail was deliberately not written. Names the files and
@@ -1710,12 +1712,16 @@ export function planHostRuntime(
1710
1712
  // Host-global install: the per-project tail entities are not written,
1711
1713
  // and the plan says exactly which files and how to write them.
1712
1714
  noProject: {
1713
- skipped: [TICK_CONFIG_FILE, AGENTS_BRIEF_NAME],
1715
+ skipped: [TICK_CONFIG_FILE, AGENTS_BRIEF_NAME, CONSOLE_BRIEF_NAME],
1714
1716
  how: "re-run `omp-conductor setup host <NAME>` (or --project NAME) to write them for one project",
1715
1717
  },
1716
1718
  }
1717
1719
  : {
1718
1720
  briefLink: planBriefLink(project),
1721
+ // The console workspace is per-project like the brief link, and
1722
+ // planned for every project — embedded or external. An embedded
1723
+ // orchestrator still has an operator to answer.
1724
+ console: planConsole(project),
1719
1725
  ...(project.escalation.orchestrator === "external"
1720
1726
  ? (() => {
1721
1727
  const planned = planTick(project, telegramStateDir);
@@ -1802,6 +1808,14 @@ export function formatHostRuntimePlan(plan: HostRuntimePlan): string {
1802
1808
  : ` brief link ${plan.briefLink.action} ${plan.briefLink.path} -> ${plan.briefLink.target}`,
1803
1809
  );
1804
1810
  }
1811
+ if (plan.console !== undefined) {
1812
+ lines.push(
1813
+ ` console brief ${plan.console.brief.action} ${plan.console.brief.path}`,
1814
+ plan.console.link.action === "skip"
1815
+ ? ` console link ${plan.console.link.path} — ${plan.console.link.skippedReason}`
1816
+ : ` console link ${plan.console.link.action} ${plan.console.link.path} -> ${plan.console.link.target}`,
1817
+ );
1818
+ }
1805
1819
  lines.push(" install staged only; the final result prints the systemd install commands");
1806
1820
  return lines.join("\n");
1807
1821
  }
@@ -1894,6 +1908,15 @@ export function writeHostRuntime(plan: HostRuntimePlan): HostRuntimeWrite {
1894
1908
  warnings.push(`could not link ${path} -> ${target}: ${err instanceof Error ? err.message : String(err)}`);
1895
1909
  }
1896
1910
  }
1911
+ // One writer for the console workspace, shared with `omp-conductor console`
1912
+ // (see {@link writeConsole}): a second copy of the never-clobber link
1913
+ // discipline is a second place for it to be got wrong. Warnings come back
1914
+ // rather than throwing, exactly like the brief link above.
1915
+ if (plan.console !== undefined) {
1916
+ const written = writeConsole(plan.console);
1917
+ wrote.push(...written.wrote);
1918
+ warnings.push(...written.warnings);
1919
+ }
1897
1920
  return { wrote, warnings };
1898
1921
  }
1899
1922
 
@@ -2147,17 +2147,27 @@ export async function ensureSetupArm(
2147
2147
  projectName: string,
2148
2148
  arm: typeof armTicks = armTicks,
2149
2149
  /**
2150
- * Where the arm proof's pending heartbeat goes while it waits (#861). Setup
2151
- * holds dispatch under its own fence across this call, so a silent wait here
2152
- * is the whole incident: the operator sees a stalled wizard and cannot tell
2153
- * a live handshake from a dead process.
2150
+ * Where the ceremony's one line goes. Setup holds dispatch under its own
2151
+ * fence across this call, so the send receipt has to be visible: an operator
2152
+ * who sees nothing cannot tell a sent challenge from a stalled wizard (#861).
2154
2153
  */
2155
2154
  progress?: (line: string) => void,
2156
2155
  ): Promise<string> {
2157
- const armed = await arm(projectName, progress === undefined ? {} : { progress });
2156
+ const result = await arm(projectName, progress === undefined ? {} : { progress });
2157
+ // Arming is two steps now: the wizard can send the challenge, but only the
2158
+ // operator's reply arms the heartbeat, and that reply lands in the console
2159
+ // session rather than here. Reporting "armed" off a sent challenge would be a
2160
+ // lie the operator only discovers when no tick ever fires.
2161
+ if (result.outcome === "challenge-sent") {
2162
+ return (
2163
+ `arming challenge sent to owner ${result.owner}, valid for ${result.validFor} — NOT yet armed. ` +
2164
+ `Finish it with: ${result.followUp}`
2165
+ );
2166
+ }
2167
+ const armed = result.armed[0]!;
2158
2168
  return armed.alreadyArmed
2159
- ? `existing heartbeat arm revalidated for owner ${armed.owner} at ${armed.path}`
2160
- : `heartbeat armed for owner ${armed.owner} at ${armed.path}`;
2169
+ ? `existing heartbeat arm revalidated for owner ${result.owner} at ${armed.path}`
2170
+ : `heartbeat armed for owner ${result.owner} at ${armed.path}`;
2161
2171
  }
2162
2172
 
2163
2173
  /**
@@ -2608,6 +2618,12 @@ interface SetupInventory {
2608
2618
  runtime: CapturedPathState[];
2609
2619
  /** The AGENTS.md brief link the apply will create/update, when planned. */
2610
2620
  briefLink?: CapturedPathState;
2621
+ /** The console floor and its AGENTS.md link, when the apply will write them.
2622
+ * Captured separately from {@link runtime} because the link is a symlink and
2623
+ * restores as one — and because a half-applied console left behind after a
2624
+ * failed apply is a session pointing at a brief this transaction rolled back. */
2625
+ consoleBrief?: CapturedPathState;
2626
+ consoleLink?: CapturedPathState;
2611
2627
  /** The run store: byte-consistent snapshot when it exists, absence on a first install. */
2612
2628
  store: StoreCapture;
2613
2629
  /** The daemon pidfile record the smoke/restart legs write. */
@@ -2690,6 +2706,18 @@ function captureInventory(
2690
2706
  (plan.runtime.briefLink.action === "create" || plan.runtime.briefLink.action === "update")
2691
2707
  ? capturePathState(plan.runtime.briefLink.path)
2692
2708
  : undefined;
2709
+ // The console workspace: same "only what will be written" rule, and the link
2710
+ // is captured only when the plan will touch it — a `skip` is an operator's
2711
+ // own file the apply never writes, so it has no pre-entry state to restore.
2712
+ const consoleBrief =
2713
+ plan.runtime.console !== undefined && plan.runtime.console.brief.action !== "keep"
2714
+ ? capturePathState(plan.runtime.console.brief.path)
2715
+ : undefined;
2716
+ const consoleLink =
2717
+ plan.runtime.console !== undefined &&
2718
+ (plan.runtime.console.link.action === "create" || plan.runtime.console.link.action === "update")
2719
+ ? capturePathState(plan.runtime.console.link.path)
2720
+ : undefined;
2693
2721
  const record = capturePathState(recordPath());
2694
2722
  const armMarker =
2695
2723
  plan.project.escalation.orchestrator === "external"
@@ -2702,6 +2730,8 @@ function captureInventory(
2702
2730
  ...briefs,
2703
2731
  ...runtime,
2704
2732
  ...(briefLink === undefined ? [] : [briefLink]),
2733
+ ...(consoleBrief === undefined ? [] : [consoleBrief]),
2734
+ ...(consoleLink === undefined ? [] : [consoleLink]),
2705
2735
  record,
2706
2736
  ...(armMarker === undefined ? [] : [armMarker]),
2707
2737
  ];
@@ -2719,6 +2749,9 @@ function captureInventory(
2719
2749
  const dirs = new Set<string>();
2720
2750
  if (answers.writeOrchestratorBrief) dirs.add(dirname(briefPathForProject(plan.project)));
2721
2751
  if (briefLink !== undefined) dirs.add(dirname(briefLink.path));
2752
+ // The console cwd, created by the brief write or the link.
2753
+ if (consoleBrief !== undefined) dirs.add(dirname(consoleBrief.path));
2754
+ if (consoleLink !== undefined) dirs.add(dirname(consoleLink.path));
2722
2755
  if (plan.runtime.tick !== undefined && plan.runtime.tick.action !== "keep") {
2723
2756
  dirs.add(dirname(plan.runtime.tick.path));
2724
2757
  }
@@ -2729,6 +2762,8 @@ function captureInventory(
2729
2762
  briefs,
2730
2763
  runtime,
2731
2764
  ...(briefLink === undefined ? {} : { briefLink }),
2765
+ ...(consoleBrief === undefined ? {} : { consoleBrief }),
2766
+ ...(consoleLink === undefined ? {} : { consoleLink }),
2732
2767
  store: captureStore(dbPath()),
2733
2768
  record,
2734
2769
  ...(armMarker === undefined ? {} : { armMarker }),
@@ -2794,6 +2829,11 @@ async function restoreInventory(
2794
2829
  // operator's regular file that appeared mid-flight survives — reported as a
2795
2830
  // failure, because the prior state is then not coherent.
2796
2831
  restore(inventory.briefLink, { preserveRegularFile: true });
2832
+ // The console floor, then its link, on the same terms: the floor is an
2833
+ // ordinary file, and the link is a symlink that never displaces a regular
2834
+ // file that appeared mid-flight.
2835
+ restore(inventory.consoleBrief);
2836
+ restore(inventory.consoleLink, { preserveRegularFile: true });
2797
2837
 
2798
2838
  // The run store: replace from the byte-consistent snapshot (or remove it
2799
2839
  // entirely on a first-install rollback).
@@ -3195,6 +3235,14 @@ export async function setup(
3195
3235
  : d.runtime.briefLink.action === "keep"
3196
3236
  ? `Keeps the brief link at ${d.runtime.briefLink.path}.`
3197
3237
  : `${d.runtime.briefLink.action === "create" ? "Creates" : "Replaces"} the brief link at ${d.runtime.briefLink.path} -> ${d.runtime.briefLink.target}.`,
3238
+ // The console workspace is a mutation like the rest, so consent names it:
3239
+ // an operator who picked "Apply" must have been told a second session's
3240
+ // cwd and brief appear under the state root.
3241
+ d.runtime.console === undefined
3242
+ ? ""
3243
+ : d.runtime.console.brief.action === "keep"
3244
+ ? `Keeps the operator console brief at ${d.runtime.console.brief.path}.`
3245
+ : `${d.runtime.console.brief.action === "create" ? "Creates" : "Updates"} the operator console workspace at ${d.runtime.console.cwd} (its brief, and an AGENTS.md link to it).`,
3198
3246
  answers.writeOrchestratorBrief
3199
3247
  ? `Writes ${orchestratorBriefPath(answers)}, which is then yours to edit.`
3200
3248
  : "",