omp-conductor 0.19.7 → 0.20.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 (71) 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/admission.ts +58 -14
  6. package/src/arm-challenge.ts +255 -85
  7. package/src/ask.ts +130 -615
  8. package/src/board.ts +7 -1
  9. package/src/brief-upgrade.ts +24 -0
  10. package/src/briefs/console.md +258 -0
  11. package/src/briefs/correction.md +203 -0
  12. package/src/briefs/orchestrator.md +167 -97
  13. package/src/briefs/policy.md +19 -16
  14. package/src/briefs/to-spec.md +76 -9
  15. package/src/briefs/worker.md +50 -16
  16. package/src/cli.ts +4 -0
  17. package/src/command-manifest.ts +54 -8
  18. package/src/commands/arm.ts +115 -49
  19. package/src/commands/console.ts +70 -0
  20. package/src/commands/context.ts +2 -0
  21. package/src/commands/epic.ts +132 -0
  22. package/src/commands/extend.ts +9 -1
  23. package/src/commands/intake.ts +44 -14
  24. package/src/commands/stats.ts +19 -4
  25. package/src/commands/worker.ts +9 -1
  26. package/src/config-schema.ts +13 -0
  27. package/src/config.ts +27 -0
  28. package/src/daemon/ack.ts +159 -0
  29. package/src/daemon/admission-pass.ts +135 -0
  30. package/src/daemon/brief.ts +461 -0
  31. package/src/daemon/deps.ts +539 -0
  32. package/src/daemon/dispatch.ts +1779 -0
  33. package/src/daemon/drain.ts +185 -0
  34. package/src/daemon/groom-pass.ts +422 -0
  35. package/src/daemon/http.ts +417 -0
  36. package/src/daemon/integrity.ts +108 -0
  37. package/src/daemon/panes.ts +180 -0
  38. package/src/daemon/review.ts +1888 -0
  39. package/src/daemon/runtime.ts +788 -0
  40. package/src/daemon/settle-pass.ts +606 -0
  41. package/src/daemon/supervision.ts +438 -0
  42. package/src/daemon/tick.ts +968 -0
  43. package/src/daemon/views.ts +751 -0
  44. package/src/daemon.ts +105 -7923
  45. package/src/dashboard/app.js +58 -0
  46. package/src/dashboard/controls.ts +22 -3
  47. package/src/dashboard/server.ts +4 -0
  48. package/src/diff-flags.ts +135 -9
  49. package/src/doctor.ts +2 -2
  50. package/src/failure-class.ts +257 -2
  51. package/src/fleet.ts +295 -176
  52. package/src/groom.ts +461 -0
  53. package/src/http-token.ts +142 -0
  54. package/src/knowledge.ts +229 -0
  55. package/src/mining.ts +316 -0
  56. package/src/orchestrator-tick.ts +689 -1670
  57. package/src/ready-gate.ts +267 -0
  58. package/src/settlement.ts +107 -11
  59. package/src/setup-host.ts +32 -9
  60. package/src/setup-wizard.ts +55 -7
  61. package/src/setup.ts +229 -3
  62. package/src/stats.ts +257 -2
  63. package/src/status-render.ts +169 -14
  64. package/src/store.ts +618 -28
  65. package/src/to-spec.ts +426 -44
  66. package/src/tracker/github.ts +50 -0
  67. package/src/types.ts +434 -18
  68. package/src/verbs/protocol.ts +28 -0
  69. package/src/verbs/server.ts +330 -39
  70. package/src/wake.ts +19 -2
  71. package/src/worker.ts +570 -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,15 +19,23 @@ 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,
25
26
  deriveChangedLine,
26
27
  } from "./diff-flags.ts";
27
- import { SPINNING_CAP_CLASSES, classifyRun, normalise, type ClassifyFacts } from "./failure-class.ts";
28
+ import {
29
+ SPINNING_CAP_CLASSES,
30
+ COMPOSE_DEPENDENCY_STARTUP_SIGNATURE,
31
+ classifyRun,
32
+ normalise,
33
+ type ClassifyFacts,
34
+ } from "./failure-class.ts";
28
35
  import { GhPrMissingError } from "./tracker/github.ts";
29
36
  import { formatModelsTried, modelsTried } from "./model-fallback.ts";
30
37
  import { PR_LOOKUP_WINDOW_MS } from "./decisions.ts";
38
+ import { wakeDispatch } from "./wake.ts";
31
39
  import {
32
40
  removeWorktree,
33
41
  salvageWip,
@@ -86,6 +94,14 @@ export interface SettlementDeps {
86
94
  provenance: { source: string; reason?: string },
87
95
  project?: string,
88
96
  ): void;
97
+ /**
98
+ * The dispatch wake fired after a queue-label hand-back (#1041). Production
99
+ * leaves it absent and {@link swapToQueue} calls the real loopback client;
100
+ * a test injects a spy, which is the only way to prove the wake fires — the
101
+ * mutation it follows already committed, so nothing about the store or the
102
+ * outbox records whether dispatch was woken.
103
+ */
104
+ wake?: (projectName: string) => Promise<string>;
89
105
  }
90
106
 
91
107
  /** What the settlement audit of one green run produced: the advisory flags
@@ -582,6 +598,41 @@ export function recordOperatorStop(
582
598
  ]);
583
599
  }
584
600
 
601
+ /**
602
+ * File a settled worker's reported discoveries into the per-repo knowledge
603
+ * overlay (Phase 3 inner loop).
604
+ *
605
+ * The overlay exists because everything a worker learns about a repo — the real
606
+ * entry point, the gate that actually proves the change, the fake that looks
607
+ * like a test — used to die with its run row, so the next worker on the same
608
+ * repo rediscovered it at the cost of turns. `discoveries` is an optional field
609
+ * on the worker's own structured settlement, so a run that reports none appends
610
+ * nothing.
611
+ *
612
+ * A knowledge write can never fail a settlement, and that is the whole reason
613
+ * this is a function rather than an inline call: the settle path is what makes a
614
+ * run's outcome durable, and losing an outcome because an advisory markdown file
615
+ * could not be written would trade the fleet's record for a note. `appendKnowledge`
616
+ * already swallows its own IO faults; the catch here covers everything else
617
+ * (a path that cannot be resolved, a caller-supplied value that surprises it)
618
+ * so no future change to that module can reach back and break a settle.
619
+ */
620
+ export function recordWorkerDiscoveries(
621
+ repo: string,
622
+ issue: number,
623
+ discoveries: readonly string[] | undefined,
624
+ at = Date.now(),
625
+ ): void {
626
+ if (discoveries === undefined || discoveries.length === 0) return;
627
+ try {
628
+ appendKnowledge(repo, discoveries, { issue, at });
629
+ const n = discoveries.length;
630
+ log(`#${issue} recorded ${n} repo ${n === 1 ? "discovery" : "discoveries"} into the ${repo} knowledge overlay`);
631
+ } catch (err) {
632
+ log(`#${issue} repo knowledge for ${repo} was not updated: ${errText(err)}`);
633
+ }
634
+ }
635
+
585
636
  /**
586
637
  * What a salvage attempt contributes to the escalation: where the work went, or
587
638
  * that it went nowhere. Split from the effects below for the same reason
@@ -1513,6 +1564,26 @@ export async function classifyAndRecover(d: SettlementDeps): Promise<number> {
1513
1564
  if (firstFailure?.link !== undefined) {
1514
1565
  facts.failingLog = await tracker.checkLog(firstFailure.link);
1515
1566
  }
1567
+ // A Compose dependency-startup failure ("docker compose up" aborted
1568
+ // because a dependency container failed its healthcheck) is only
1569
+ // outside the diff when the PR touched no container configuration —
1570
+ // a PR that breaks its own `docker-compose.yml` produces this exact
1571
+ // sentence and must still charge its attempt (#1059). So the
1572
+ // classifier needs the PR's changed-file list, derived from the
1573
+ // diff this settlement already knows how to fetch
1574
+ // (`Tracker.prDiff`, the same call the settlement audit uses) —
1575
+ // one fetch, and only for a row whose log actually carries
1576
+ // Compose's sentence; every other row pays nothing. A diff that
1577
+ // cannot be read or was cut short (`truncated`) stays undefined: an
1578
+ // unknown list must never waive an attempt.
1579
+ if (
1580
+ facts.failingLog !== undefined &&
1581
+ facts.failingLog.toLowerCase().includes(COMPOSE_DEPENDENCY_STARTUP_SIGNATURE)
1582
+ ) {
1583
+ const diff = await tracker.prDiff(run.prUrl);
1584
+ facts.changedFiles =
1585
+ diff === undefined || diff.truncated ? undefined : diff.files.map((f) => f.path);
1586
+ }
1516
1587
  }
1517
1588
  }
1518
1589
  } catch (err) {
@@ -1631,7 +1702,7 @@ async function recoverRun(
1631
1702
  }
1632
1703
  const continuation = store.continuationsFor(project.name, run.issue);
1633
1704
  if (hasContinuationBudget(continuation, caps.maxContinuationsPerIssue)) {
1634
- swapToQueue(d, run.issue, project.stateLabels.failed);
1705
+ await swapToQueue(d, run.issue, project.stateLabels.failed);
1635
1706
  store.updateRun(run.id, { recoveredAt: Date.now() });
1636
1707
  log(`#${run.issue} requeued for a wall-clock continuation: ${evidence}`);
1637
1708
  } else {
@@ -1676,7 +1747,7 @@ async function recoverRun(
1676
1747
  }
1677
1748
  const continuation = store.continuationsFor(project.name, run.issue);
1678
1749
  if (hasContinuationBudget(continuation, caps.maxContinuationsPerIssue)) {
1679
- swapToQueue(d, run.issue, project.stateLabels.failed);
1750
+ await swapToQueue(d, run.issue, project.stateLabels.failed);
1680
1751
  store.updateRun(run.id, { recoveredAt: Date.now() });
1681
1752
  log(`#${run.issue} requeued after a provider empty-stop: ${evidence}`);
1682
1753
  return;
@@ -1707,7 +1778,7 @@ async function recoverRun(
1707
1778
  // recovery; the projector retries until the tracker takes the swap, and
1708
1779
  // while it is pending the eligibility overlay keeps the issue coherent
1709
1780
  // (#201).
1710
- swapToQueue(d, run.issue, inProgress);
1781
+ await swapToQueue(d, run.issue, inProgress);
1711
1782
  store.updateRun(run.id, {
1712
1783
  state: "killed",
1713
1784
  lastError:
@@ -1875,7 +1946,7 @@ async function recoverRun(
1875
1946
  }
1876
1947
  }
1877
1948
  const label = cls === "orphan-clean" ? inProgress : project.stateLabels.failed;
1878
- swapToQueue(d, run.issue, label);
1949
+ await swapToQueue(d, run.issue, label);
1879
1950
  store.updateRun(run.id, { recoveredAt: Date.now() });
1880
1951
  log(`#${run.issue} requeued from ${cls}: ${evidence}`);
1881
1952
  return;
@@ -1945,10 +2016,14 @@ async function recoverRun(
1945
2016
  return;
1946
2017
  }
1947
2018
 
1948
- // `hold` (orphan-dirty) and `none`: recorded, nothing performed. The existing
1949
- // unsalvaged-WIP admission hold already fails dispatch closed until an
1950
- // operator acknowledges the tree, which is the only safe move when the
1951
- // worktree holds the only copy of real work.
2019
+ // `observe` (awaiting-observation), `hold` (orphan-dirty) and `none`:
2020
+ // recorded, nothing performed here. `observe`'s recovery IS the later observation —
2021
+ // the settle sweep re-offers the blocked row until its PR resolves and settles it
2022
+ // the way it settles `settlement-stuck`, so no action exists to perform today and
2023
+ // `recoveredAt` stays NULL by design (#1068). The existing unsalvaged-WIP
2024
+ // admission hold already fails dispatch closed until an operator acknowledges
2025
+ // the tree, which is the only safe move when the worktree holds the only copy
2026
+ // of real work.
1952
2027
  }
1953
2028
 
1954
2029
  /**
@@ -2049,7 +2124,8 @@ async function escalateModelTier(
2049
2124
  }
2050
2125
 
2051
2126
  /**
2052
- * Enqueue a state-label → queue-label swap for projection (#201).
2127
+ * Enqueue a state-label → queue-label swap for projection (#201), then wake
2128
+ * dispatch (#1041).
2053
2129
  *
2054
2130
  * The swap is two ops in id order — remove first, then add — which is the
2055
2131
  * atomicity the projector guarantees: the issue never sits newly eligible
@@ -2059,12 +2135,32 @@ async function escalateModelTier(
2059
2135
  * immediately and the projector retries the swap until the tracker takes it —
2060
2136
  * that closes the 0.4.4 hole where a refused label swap stranded the row
2061
2137
  * permanently under a log line promising a retry.
2138
+ *
2139
+ * The wake is why this is the chokepoint every requeue goes through. A
2140
+ * continuation handed its queue label back and then waiting out the five-minute
2141
+ * interval reads, from outside, exactly like a stalled queue — the same
2142
+ * complaint #878 fixed for `unblock` and `resume`, on the paths that never
2143
+ * used it. It runs AFTER the enqueue, never before: the wake shortens a sleep
2144
+ * and the pass it prompts re-reads every gate, so a wake that arrives before
2145
+ * the durable write would simply find nothing. And it can never fail the
2146
+ * requeue — the label ops are already committed, the client answers a line for
2147
+ * every outcome including "no daemon", and a thrown seam is logged as the
2148
+ * degradation it is: the next scheduled pass claims.
2062
2149
  */
2063
- export function swapToQueue(d: Pick<SettlementDeps, "project" | "store">, issue: number, label: string): void {
2150
+ export async function swapToQueue(
2151
+ d: Pick<SettlementDeps, "project" | "store" | "wake">,
2152
+ issue: number,
2153
+ label: string,
2154
+ ): Promise<void> {
2064
2155
  d.store.enqueueLabelOps(d.project.name, [
2065
2156
  { issue, op: "remove", label },
2066
2157
  { issue, op: "add", label: d.project.queueLabel },
2067
2158
  ]);
2159
+ try {
2160
+ log(`#${issue} back on the queue — ${await (d.wake ?? wakeDispatch)(d.project.name)}`);
2161
+ } catch (err) {
2162
+ log(`#${issue} back on the queue but the dispatch wake failed (${errText(err)}) — the next scheduled pass will claim`);
2163
+ }
2068
2164
  }
2069
2165
 
2070
2166
 
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