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.
- package/REFERENCE.md +10 -1
- package/agents/to-spec.md +76 -9
- package/package.json +1 -1
- package/schema/config.schema.json +4 -0
- package/src/admission.ts +58 -14
- package/src/arm-challenge.ts +255 -85
- package/src/ask.ts +130 -615
- package/src/board.ts +7 -1
- package/src/brief-upgrade.ts +24 -0
- package/src/briefs/console.md +258 -0
- package/src/briefs/correction.md +203 -0
- package/src/briefs/orchestrator.md +167 -97
- package/src/briefs/policy.md +19 -16
- package/src/briefs/to-spec.md +76 -9
- package/src/briefs/worker.md +50 -16
- package/src/cli.ts +4 -0
- package/src/command-manifest.ts +54 -8
- package/src/commands/arm.ts +115 -49
- package/src/commands/console.ts +70 -0
- package/src/commands/context.ts +2 -0
- package/src/commands/epic.ts +132 -0
- package/src/commands/extend.ts +9 -1
- package/src/commands/intake.ts +44 -14
- package/src/commands/stats.ts +19 -4
- package/src/commands/worker.ts +9 -1
- package/src/config-schema.ts +13 -0
- package/src/config.ts +27 -0
- package/src/daemon/ack.ts +159 -0
- package/src/daemon/admission-pass.ts +135 -0
- package/src/daemon/brief.ts +461 -0
- package/src/daemon/deps.ts +539 -0
- package/src/daemon/dispatch.ts +1779 -0
- package/src/daemon/drain.ts +185 -0
- package/src/daemon/groom-pass.ts +422 -0
- package/src/daemon/http.ts +417 -0
- package/src/daemon/integrity.ts +108 -0
- package/src/daemon/panes.ts +180 -0
- package/src/daemon/review.ts +1888 -0
- package/src/daemon/runtime.ts +788 -0
- package/src/daemon/settle-pass.ts +606 -0
- package/src/daemon/supervision.ts +438 -0
- package/src/daemon/tick.ts +968 -0
- package/src/daemon/views.ts +751 -0
- package/src/daemon.ts +105 -7923
- package/src/dashboard/app.js +58 -0
- package/src/dashboard/controls.ts +22 -3
- package/src/dashboard/server.ts +4 -0
- package/src/diff-flags.ts +135 -9
- package/src/doctor.ts +2 -2
- package/src/failure-class.ts +257 -2
- package/src/fleet.ts +295 -176
- package/src/groom.ts +461 -0
- package/src/http-token.ts +142 -0
- package/src/knowledge.ts +229 -0
- package/src/mining.ts +316 -0
- package/src/orchestrator-tick.ts +689 -1670
- package/src/ready-gate.ts +267 -0
- package/src/settlement.ts +107 -11
- package/src/setup-host.ts +32 -9
- package/src/setup-wizard.ts +55 -7
- package/src/setup.ts +229 -3
- package/src/stats.ts +257 -2
- package/src/status-render.ts +169 -14
- package/src/store.ts +618 -28
- package/src/to-spec.ts +426 -44
- package/src/tracker/github.ts +50 -0
- package/src/types.ts +434 -18
- package/src/verbs/protocol.ts +28 -0
- package/src/verbs/server.ts +330 -39
- package/src/wake.ts +19 -2
- 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 {
|
|
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`:
|
|
1949
|
-
//
|
|
1950
|
-
//
|
|
1951
|
-
//
|
|
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(
|
|
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
|
|