omp-conductor 0.19.6 → 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.
- package/REFERENCE.md +27 -2
- package/agents/to-spec.md +76 -9
- package/package.json +1 -1
- package/schema/config.schema.json +4 -0
- package/src/arm-challenge.ts +204 -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 +253 -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 +113 -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 +412 -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 +736 -0
- package/src/daemon/settle-pass.ts +589 -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 -7832
- 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 +24 -3
- package/src/doctor.ts +17 -12
- package/src/escalate.ts +39 -21
- package/src/failure-class.ts +75 -1
- package/src/fleet.ts +1218 -304
- 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 +428 -1681
- package/src/ready-gate.ts +267 -0
- package/src/settlement.ts +72 -6
- 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 +158 -7
- package/src/store.ts +646 -26
- package/src/to-spec.ts +194 -21
- package/src/tracker/github.ts +50 -0
- package/src/types.ts +435 -15
- package/src/verbs/protocol.ts +28 -0
- package/src/verbs/server.ts +384 -12
- package/src/wake.ts +19 -2
- package/src/worker.ts +456 -1
package/src/groom.ts
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grooming candidate selection: the one shared rule set that answers "which
|
|
3
|
+
* backlog issues may a to-spec pass be spent on right now".
|
|
4
|
+
*
|
|
5
|
+
* #772 shipped the strict to-spec contract (`TO_SPEC_SCHEMA` +
|
|
6
|
+
* `recordToSpecGrooming`) and #777 shipped the launch half as a per-tick
|
|
7
|
+
* capability token redeemed by a `task` tool-call gate inside the tick
|
|
8
|
+
* extension. That made grooming tick-bound: one batch per low-queue tick, and
|
|
9
|
+
* an operator message that consumed the tick consumed the batch with it
|
|
10
|
+
* (#1040). This module is what remains when the token goes away — the
|
|
11
|
+
* mechanical selection itself, extracted so the dispatch daemon owns launching
|
|
12
|
+
* (#1041) and the tick is free to audit rather than authorize.
|
|
13
|
+
*
|
|
14
|
+
* Where each fact lives decides who enforces it, and every exclusion is
|
|
15
|
+
* enforced mechanically from authoritative data, never by prose:
|
|
16
|
+
*
|
|
17
|
+
* - Tracker facts — the open-issue pool, the park label, the parent/epic
|
|
18
|
+
* probe — are read through the existing Tracker adapter
|
|
19
|
+
* (`listOpenIssues`/`childrenOf`) via {@link ToSpecTrackerSeam}, and turned
|
|
20
|
+
* into the pool by {@link toSpecPoolFromSnapshot}. If the snapshot cannot be
|
|
21
|
+
* read, no batch is selected at all: the selection fails closed rather than
|
|
22
|
+
* trusting a model to self-filter parked/parent/epic candidates.
|
|
23
|
+
* - Store facts — durable grooming rows (#735), admission's lane/dependency
|
|
24
|
+
* holds, active runs, in-flight launches — are enforced by
|
|
25
|
+
* {@link toSpecCandidateExclusion}, the single predicate every reader of
|
|
26
|
+
* "is this issue already groomed?" goes through.
|
|
27
|
+
* - The live source ref for each item cannot be known synchronously without a
|
|
28
|
+
* fresh per-repo read, so the launcher stamps the observation time it
|
|
29
|
+
* launched at and the strict parser's 24h freshness ceiling re-vets it at
|
|
30
|
+
* persistence time — the boundary #772 set, tightened by #1000.
|
|
31
|
+
*
|
|
32
|
+
* The module deliberately imports nothing from the tick extension, the harness
|
|
33
|
+
* or the pi surface: both the daemon and the tick read it, and a selection rule
|
|
34
|
+
* that could only run inside a session is how grooming became tick-bound in the
|
|
35
|
+
* first place.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { repoSlugFor } from "./gitops.ts";
|
|
39
|
+
import {
|
|
40
|
+
parseToSpecEvidence,
|
|
41
|
+
parseToSpecFailureEvidence,
|
|
42
|
+
TO_SPEC_MAX_SOURCE_AGE_MS,
|
|
43
|
+
type ToSpecFailure,
|
|
44
|
+
type ToSpecResult,
|
|
45
|
+
} from "./to-spec.ts";
|
|
46
|
+
import type {
|
|
47
|
+
DispatchSummary,
|
|
48
|
+
GroomingRecord,
|
|
49
|
+
GroomTrigger,
|
|
50
|
+
IssueState,
|
|
51
|
+
ProjectConfig,
|
|
52
|
+
ReadyIssue,
|
|
53
|
+
} from "./types.ts";
|
|
54
|
+
|
|
55
|
+
/** Routable candidates below which the grooming duty fires (#181). */
|
|
56
|
+
export const DEFAULT_GROOM_BELOW = 4;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether the count-based half of the grooming duty fires for one dispatch
|
|
60
|
+
* row's routable count (#988): below a numeric threshold as always, while
|
|
61
|
+
* `"always"` leaves this gate open on purpose — the selectors then answer
|
|
62
|
+
* "is anything left to groom?" from candidate state (ungroomed, unrefused,
|
|
63
|
+
* unparked), and the caller renders the truthful finding either way. Never
|
|
64
|
+
* substitute a large number for `"always"`: a big queue must not silence the
|
|
65
|
+
* duty.
|
|
66
|
+
*/
|
|
67
|
+
export function groomingDue(routed: number, groomBelow: GroomTrigger): boolean {
|
|
68
|
+
return groomBelow === "always" || routed < groomBelow;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The `to-spec` agent every batch item runs under (shipped in
|
|
72
|
+
* `omp/agents/to-spec.md`, discovered through OMP's native task-agent
|
|
73
|
+
* discovery — never a custom process runtime). */
|
|
74
|
+
export const TO_SPEC_AGENT = "to-spec";
|
|
75
|
+
|
|
76
|
+
/** The maximum number of candidates one batch may carry (#679's "small
|
|
77
|
+
* per-tick candidate limit"; the daemon's own concurrency bounds the
|
|
78
|
+
* sessions underneath). */
|
|
79
|
+
export const TO_SPEC_BATCH_MAX = 3;
|
|
80
|
+
|
|
81
|
+
/** The grooming-table verdict row a launched-but-unfinished batch leaves behind
|
|
82
|
+
* (reason, on a `blocked` verdict): the durable in-flight marker that stops
|
|
83
|
+
* the next pass — or a restarted daemon — from re-launching the same item.
|
|
84
|
+
* `blocked` is deliberate: `recordToSpecGrooming` replaces the row when the
|
|
85
|
+
* result lands, and refusing-to-parse output must *not* be swallowed by the
|
|
86
|
+
* kept-prior path that protects prior `promotable`/`considered` verdicts. */
|
|
87
|
+
export const TO_SPEC_IN_FLIGHT_REASON = "in-flight";
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The grooming-row `reason` a `PROMOTABLE` verdict carries when the daemon's
|
|
91
|
+
* ready gate refused to queue it (#1041). The verdict itself stays
|
|
92
|
+
* `promotable` and `promotedAt`/`promotedBy` stay unset: the to-spec pass was
|
|
93
|
+
* valid and must not be re-spent, but the spec is not queueable yet. One
|
|
94
|
+
* constant, two writers-and-readers — the daemon stamps it, the tick's
|
|
95
|
+
* promotion audit renders it — because the last time this fleet hand-copied a
|
|
96
|
+
* reason string into a second file the two drifted apart (`status-render.ts`'s
|
|
97
|
+
* duplicate of {@link TO_SPEC_IN_FLIGHT_REASON}).
|
|
98
|
+
*/
|
|
99
|
+
export const TO_SPEC_GATE_REJECTED_REASON = "gate-rejected";
|
|
100
|
+
|
|
101
|
+
/** What the ready gate found missing, as the rejected row records it. */
|
|
102
|
+
export interface ReadyGateRejection {
|
|
103
|
+
/** The gate's own `missing[]`, verbatim and in order. */
|
|
104
|
+
missing: string[];
|
|
105
|
+
/** When the gate ran, so an audit can say how stale its finding is. */
|
|
106
|
+
checkedAt: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Recover a ready-gate rejection from a grooming row's evidence, or
|
|
111
|
+
* `undefined` when the row is not one. The daemon writes the rejection as one
|
|
112
|
+
* added top-level sibling of the untouched to-spec `result`
|
|
113
|
+
* (`{kind:"to-spec", result:{…}, readyGate:{missing, checkedAt}}`), so
|
|
114
|
+
* {@link toSpecDurableVerdict} still recovers the verdict and the candidate is
|
|
115
|
+
* not re-groomed — the spec is rejected, not ungroomed.
|
|
116
|
+
*
|
|
117
|
+
* Fails closed on anything it cannot read: an empty `missing` array is not a
|
|
118
|
+
* rejection (a gate that found nothing missing passed), and a non-string entry
|
|
119
|
+
* would render as `undefined` in a digest line an operator is asked to act on.
|
|
120
|
+
*/
|
|
121
|
+
export function parseReadyGateRejection(evidence: string): ReadyGateRejection | undefined {
|
|
122
|
+
let parsed: unknown;
|
|
123
|
+
try {
|
|
124
|
+
parsed = JSON.parse(evidence);
|
|
125
|
+
} catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
129
|
+
const row = parsed as Record<string, unknown>;
|
|
130
|
+
if (row["kind"] !== "to-spec") return undefined;
|
|
131
|
+
const gate = row["readyGate"];
|
|
132
|
+
if (gate === null || typeof gate !== "object" || Array.isArray(gate)) return undefined;
|
|
133
|
+
const { missing, checkedAt } = gate as { missing?: unknown; checkedAt?: unknown };
|
|
134
|
+
if (!Array.isArray(missing) || missing.length === 0) return undefined;
|
|
135
|
+
if (!missing.every((entry) => typeof entry === "string" && entry.length > 0)) return undefined;
|
|
136
|
+
return {
|
|
137
|
+
missing: missing as string[],
|
|
138
|
+
checkedAt: typeof checkedAt === "number" && Number.isFinite(checkedAt) ? checkedAt : 0,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* How long a launch row may sit before it is treated as a dead batch and the
|
|
144
|
+
* candidate becomes eligible again. A batch that dies with the process (a
|
|
145
|
+
* daemon stop between launch and settlement) must not park a candidate
|
|
146
|
+
* forever; the 24h ceiling matches the source-freshness ceiling, so a
|
|
147
|
+
* relaunched pass always reads new source evidence anyway.
|
|
148
|
+
*/
|
|
149
|
+
export const TO_SPEC_IN_FLIGHT_TTL_MS = 24 * 60 * 60 * 1_000;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* How long a refused pass parks its candidate before another batch may be
|
|
153
|
+
* spent on it. Deliberately the same 24h number as the source-freshness
|
|
154
|
+
* ceiling and the in-flight TTL — one granularity for this whole lifecycle,
|
|
155
|
+
* not a third threshold to keep in sync: within that window neither the
|
|
156
|
+
* authoritative source nor the issue has produced new evidence, so a retry
|
|
157
|
+
* re-runs the identical prompt and refuses the identical way.
|
|
158
|
+
*
|
|
159
|
+
* Without it, a candidate whose delegated pass returns malformed,
|
|
160
|
+
* source-less or stale output is immediately eligible again, so every
|
|
161
|
+
* low-queue pass spends a full delegated batch re-grooming it — measured on
|
|
162
|
+
* this fleet as five permanently-refused rows (#295, #296, #297, #679, #806)
|
|
163
|
+
* re-offered on every pass, and as #807 groomed twice seven minutes apart
|
|
164
|
+
* (#887).
|
|
165
|
+
*/
|
|
166
|
+
export const TO_SPEC_REFUSED_RETRY_COOLDOWN_MS = TO_SPEC_MAX_SOURCE_AGE_MS;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The one durability rule for a grooming row: the validated to-spec result it
|
|
170
|
+
* carries when that result is still fresh, or `undefined` when the row is not
|
|
171
|
+
* durable grooming at all (no to-spec payload — a hand-edited or pre-#772
|
|
172
|
+
* row — or a source observed past the freshness ceiling).
|
|
173
|
+
*
|
|
174
|
+
* Every reader of "is this issue already groomed?" MUST go through this:
|
|
175
|
+
* {@link toSpecCandidateExclusion} (selection), the daemon's promotion gate,
|
|
176
|
+
* and the queue digest's already-considered inventory. Two readers with two
|
|
177
|
+
* predicates is exactly the #887 defect — the digest told the orchestrator
|
|
178
|
+
* "never re-groom these" about rows the mechanical selection was
|
|
179
|
+
* simultaneously offering.
|
|
180
|
+
*/
|
|
181
|
+
export function toSpecDurableVerdict(row: GroomingRecord, now: number): ToSpecResult | undefined {
|
|
182
|
+
const result = parseToSpecEvidence(row.evidence);
|
|
183
|
+
if (result === undefined) return undefined;
|
|
184
|
+
return now - result.source.freshAt <= TO_SPEC_MAX_SOURCE_AGE_MS ? result : undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The refusal a row records when its pass produced nothing usable, while the
|
|
188
|
+
* cooldown above still holds it out of a new batch; `undefined` for any
|
|
189
|
+
* other row, including a refusal whose cooldown has expired. */
|
|
190
|
+
export function toSpecRefusalOnCooldown(row: GroomingRecord, now: number): ToSpecFailure | undefined {
|
|
191
|
+
const failure = parseToSpecFailureEvidence(row.evidence);
|
|
192
|
+
if (failure === undefined) return undefined;
|
|
193
|
+
return now - row.recordedAt <= TO_SPEC_REFUSED_RETRY_COOLDOWN_MS ? failure : undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** One backlog candidate the mechanical gate can judge. The tracker facts
|
|
197
|
+
* (labels, epics) are read from the authoritative open-issue snapshot at
|
|
198
|
+
* selection time; they travel in this view so the selector stays
|
|
199
|
+
* deterministic and testable. */
|
|
200
|
+
export interface ToSpecCandidateView {
|
|
201
|
+
issue: number;
|
|
202
|
+
title: string;
|
|
203
|
+
/** The routing target — a routed `owner/repo` (from the issue's one
|
|
204
|
+
* `routing.labelPrefix<key>` label, resolved through `routing.repos`). */
|
|
205
|
+
routing: string;
|
|
206
|
+
/** Operator-parked (`project.stateLabels.backlog`); read from tracker labels. */
|
|
207
|
+
parked?: boolean;
|
|
208
|
+
/** A parent/epic with no independently runnable slice; read from the
|
|
209
|
+
* tracker's sub-issue probe. */
|
|
210
|
+
parent?: boolean;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Why one candidate is not eligible for a batch right now, or undefined when
|
|
215
|
+
* it is. The single rule source for the selection helper and the launcher's
|
|
216
|
+
* own re-vet at dispatch time — one rule, every reader, so a candidate
|
|
217
|
+
* excluded in a digest line is excluded at launch for the same reason.
|
|
218
|
+
*
|
|
219
|
+
* - `in-flight`: a launch row recorded within the TTL (a dead batch's row
|
|
220
|
+
* expires and the candidate becomes eligible again);
|
|
221
|
+
* - `file-lane` / `depends-on`: admission's durable mechanical holds (#735);
|
|
222
|
+
* - a fresh valid `to-spec` result in the grooming table: the candidate was
|
|
223
|
+
* already groomed at an observed source within the freshness ceiling, so
|
|
224
|
+
* re-running it would recompute a verdict that is still valid. New source
|
|
225
|
+
* evidence reconsiders it: once the recorded `freshAt` crosses the ceiling
|
|
226
|
+
* the row no longer reads as groomed, and a fresh pass overrides it;
|
|
227
|
+
* - a refused pass inside {@link TO_SPEC_REFUSED_RETRY_COOLDOWN_MS}: a full
|
|
228
|
+
* delegated batch was already spent and produced nothing usable
|
|
229
|
+
* (malformed, source-less or stale output). Retrying inside the cooldown
|
|
230
|
+
* re-runs the identical prompt against the same source and refuses the
|
|
231
|
+
* same way, which is how one broken candidate consumed a batch on every
|
|
232
|
+
* low-queue tick (#887);
|
|
233
|
+
* - `active`: a run is in flight on the issue right now.
|
|
234
|
+
*/
|
|
235
|
+
export function toSpecCandidateExclusion(
|
|
236
|
+
candidate: { issue: number },
|
|
237
|
+
facts: { grooming: GroomingRecord | undefined; active: boolean },
|
|
238
|
+
now: number,
|
|
239
|
+
): string | undefined {
|
|
240
|
+
const row = facts.grooming;
|
|
241
|
+
if (row !== undefined) {
|
|
242
|
+
if (row.reason === TO_SPEC_IN_FLIGHT_REASON) {
|
|
243
|
+
if (now - row.recordedAt <= TO_SPEC_IN_FLIGHT_TTL_MS) {
|
|
244
|
+
return `#${candidate.issue} is already in a to-spec batch (launched ${new Date(row.recordedAt).toISOString()})`;
|
|
245
|
+
}
|
|
246
|
+
} else if (row.reason === "file-lane" || row.reason === "depends-on") {
|
|
247
|
+
return `#${candidate.issue} is mechanically blocked (${row.reason}) — the hold clears by itself`;
|
|
248
|
+
} else {
|
|
249
|
+
const durable = toSpecDurableVerdict(row, now);
|
|
250
|
+
if (durable !== undefined) {
|
|
251
|
+
return (
|
|
252
|
+
`#${candidate.issue} was already groomed ${durable.verdict} (source ${durable.source.name}@` +
|
|
253
|
+
`${durable.source.ref}, observed ${new Date(durable.source.freshAt).toISOString()}) — re-groom only ` +
|
|
254
|
+
"with new source evidence"
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
const refusal = toSpecRefusalOnCooldown(row, now);
|
|
258
|
+
if (refusal !== undefined) {
|
|
259
|
+
const retryAt = new Date(row.recordedAt + TO_SPEC_REFUSED_RETRY_COOLDOWN_MS).toISOString();
|
|
260
|
+
return (
|
|
261
|
+
`#${candidate.issue} already spent a to-spec batch that was refused as ${refusal.kind} ` +
|
|
262
|
+
`(${new Date(row.recordedAt).toISOString()}) — eligible again after ${retryAt}, or once the ` +
|
|
263
|
+
"issue or its source changes"
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (facts.active) return `#${candidate.issue} has a dispatched run in flight`;
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The mechanical half of grooming: deterministic, bounded selection of the
|
|
274
|
+
* eligible candidates, smallest issue numbers first, never more than
|
|
275
|
+
* {@link TO_SPEC_BATCH_MAX} per batch, nothing at/above a numeric grooming
|
|
276
|
+
* trigger (`"always"` opens that gate and lets candidate state decide, #988),
|
|
277
|
+
* nothing before the first dispatch summary exists (queue health unknown —
|
|
278
|
+
* the same gate the queue digest uses). Parked and parent views are honored
|
|
279
|
+
* when the caller supplies them.
|
|
280
|
+
*
|
|
281
|
+
* Production reaches this selector through
|
|
282
|
+
* {@link selectToSpecCandidates}, which fills the views from the
|
|
283
|
+
* authoritative tracker snapshot (park label from the open-issue labels,
|
|
284
|
+
* parent/epic from the sub-issue probe) and streams the store-side exclusions
|
|
285
|
+
* before the selector runs — so parked and parent exclusions are enforced on
|
|
286
|
+
* the live path, not only on test inputs.
|
|
287
|
+
*/
|
|
288
|
+
export function selectToSpecBatch(input: {
|
|
289
|
+
candidates: readonly ToSpecCandidateView[];
|
|
290
|
+
summary: DispatchSummary | undefined;
|
|
291
|
+
groomBelow: GroomTrigger;
|
|
292
|
+
grooming: readonly GroomingRecord[];
|
|
293
|
+
active: readonly { issue: number }[];
|
|
294
|
+
now: number;
|
|
295
|
+
}): ToSpecCandidateView[] {
|
|
296
|
+
if (input.summary === undefined) return [];
|
|
297
|
+
if (!groomingDue(input.summary.routed, input.groomBelow)) return [];
|
|
298
|
+
const groomingByIssue = new Map(input.grooming.map((row) => [row.issue, row]));
|
|
299
|
+
const activeIssues = new Set(input.active.map((run) => run.issue));
|
|
300
|
+
const selected: ToSpecCandidateView[] = [];
|
|
301
|
+
for (const candidate of [...input.candidates].sort((a, b) => a.issue - b.issue)) {
|
|
302
|
+
if (candidate.parked) continue;
|
|
303
|
+
if (candidate.parent) continue;
|
|
304
|
+
if (
|
|
305
|
+
toSpecCandidateExclusion(
|
|
306
|
+
{ issue: candidate.issue },
|
|
307
|
+
{ grooming: groomingByIssue.get(candidate.issue), active: activeIssues.has(candidate.issue) },
|
|
308
|
+
input.now,
|
|
309
|
+
) !== undefined
|
|
310
|
+
) {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
selected.push(candidate);
|
|
314
|
+
if (selected.length >= TO_SPEC_BATCH_MAX) break;
|
|
315
|
+
}
|
|
316
|
+
return selected;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The authoritative tracker surface the selection is built from (#777): the
|
|
321
|
+
* two reads through the existing Tracker adapter that answer "which open
|
|
322
|
+
* backlog issues are actually eligible right now". Production implements this
|
|
323
|
+
* with `makeTracker(...).listOpenIssues()` / `.childrenOf(...)`; tests inject
|
|
324
|
+
* deterministic fakes. `listOpenIssues` is the one open-issue snapshot with
|
|
325
|
+
* labels (#203) — it answers the park label and the routing label
|
|
326
|
+
* mechanically — and `childrenOf` answers whether a candidate is a parent/epic
|
|
327
|
+
* (its sub-issues exist, so it has no independently runnable slice of its
|
|
328
|
+
* own). Both fail closed: an unreadable snapshot means no batch is selected,
|
|
329
|
+
* while an unreadable parent probe skips that candidate because eligibility
|
|
330
|
+
* was not mechanically established.
|
|
331
|
+
*/
|
|
332
|
+
export interface ToSpecTrackerSeam {
|
|
333
|
+
/** Every open issue in the tracker repo, labels included
|
|
334
|
+
* (`Tracker.listOpenIssues`). Throws when the tracker cannot be read. */
|
|
335
|
+
listOpenIssues(project: ProjectConfig): Promise<ReadyIssue[]>;
|
|
336
|
+
/** Sub-issues of one issue (`Tracker.childrenOf`). */
|
|
337
|
+
childrenOf(project: ProjectConfig, issue: number): Promise<{ number: number; state: IssueState }[]>;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The tracker half of the candidate views: map one open-issue snapshot onto
|
|
342
|
+
* the pool the selector can judge. Issues still carrying the queue label are
|
|
343
|
+
* already queued — not backlog — and leave the pool; issues with zero or
|
|
344
|
+
* several `routing.labelPrefix` labels (or a label mapping to no configured
|
|
345
|
+
* repo) cannot name an authoritative source to read and leave the pool,
|
|
346
|
+
* exactly like admission's unroutable partition. The park label lands on the
|
|
347
|
+
* view for the selector to drop; the parent/epic probe is separate (one
|
|
348
|
+
* tracker read per candidate) and stays with {@link selectToSpecCandidates},
|
|
349
|
+
* which runs it only for candidates the store-side exclusions did not already
|
|
350
|
+
* reject.
|
|
351
|
+
*/
|
|
352
|
+
export function toSpecPoolFromSnapshot(
|
|
353
|
+
issues: readonly ReadyIssue[],
|
|
354
|
+
project: ProjectConfig,
|
|
355
|
+
): ToSpecCandidateView[] {
|
|
356
|
+
const queueLabel = project.queueLabel;
|
|
357
|
+
const parkLabel = project.stateLabels.backlog;
|
|
358
|
+
const { labelPrefix, repos } = project.routing;
|
|
359
|
+
const views: ToSpecCandidateView[] = [];
|
|
360
|
+
for (const issue of issues) {
|
|
361
|
+
if (issue.labels.includes(queueLabel)) continue;
|
|
362
|
+
const matched = [...new Set(issue.labels.filter((l) => l.startsWith(labelPrefix)))];
|
|
363
|
+
if (matched.length !== 1) continue;
|
|
364
|
+
const key = matched[0]!.slice(labelPrefix.length);
|
|
365
|
+
const target = Object.hasOwn(repos, key) ? repos[key]! : undefined;
|
|
366
|
+
if (target === undefined) continue;
|
|
367
|
+
views.push({
|
|
368
|
+
issue: issue.number,
|
|
369
|
+
title: issue.title,
|
|
370
|
+
routing: repoSlugFor(target),
|
|
371
|
+
parked: issue.labels.includes(parkLabel) ? true : undefined,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
views.sort((a, b) => a.issue - b.issue);
|
|
375
|
+
return views;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* The production selection for one grooming pass: the complete mechanical
|
|
380
|
+
* selection, from the authoritative tracker snapshot through every exclusion,
|
|
381
|
+
* ending in the candidates a launcher may spend to-spec sessions on. All of
|
|
382
|
+
* the following yield an empty batch:
|
|
383
|
+
*
|
|
384
|
+
* - no dispatch row yet, or the routable queue is at/above a numeric
|
|
385
|
+
* grooming trigger (`"always"` opens that gate; candidate state decides,
|
|
386
|
+
* #988 — the same gate the queue digest uses);
|
|
387
|
+
* - the snapshot cannot be read — the selection fails closed rather than
|
|
388
|
+
* letting an agent self-filter parked/parent/epic candidates;
|
|
389
|
+
* - nothing survives the exclusions (parked, parent/epic, already groomed,
|
|
390
|
+
* in-flight, lane/dependency holds, dispatched runs).
|
|
391
|
+
*
|
|
392
|
+
* The parent/epic probe runs only for candidates the store-side exclusions
|
|
393
|
+
* have not already rejected, in issue order, and stops as soon as
|
|
394
|
+
* {@link TO_SPEC_BATCH_MAX} candidates are collected — a bounded set of
|
|
395
|
+
* tracker reads per pass, never one per open issue.
|
|
396
|
+
*/
|
|
397
|
+
export async function selectToSpecCandidates(input: {
|
|
398
|
+
project: ProjectConfig;
|
|
399
|
+
seam: ToSpecTrackerSeam;
|
|
400
|
+
summary: DispatchSummary | undefined;
|
|
401
|
+
groomBelow: GroomTrigger;
|
|
402
|
+
grooming: readonly GroomingRecord[];
|
|
403
|
+
active: readonly { issue: number }[];
|
|
404
|
+
/** An open-issue snapshot the caller already read from the tracker. Shared
|
|
405
|
+
* so one pass cannot describe two queues (#848); absent when that read
|
|
406
|
+
* failed or was never made, in which case the selection reads its own
|
|
407
|
+
* snapshot and fails closed on the same terms. */
|
|
408
|
+
issues?: readonly ReadyIssue[];
|
|
409
|
+
now: number;
|
|
410
|
+
}): Promise<ToSpecCandidateView[]> {
|
|
411
|
+
if (input.summary === undefined) return [];
|
|
412
|
+
if (!groomingDue(input.summary.routed, input.groomBelow)) return [];
|
|
413
|
+
let issues = input.issues;
|
|
414
|
+
if (issues === undefined) {
|
|
415
|
+
try {
|
|
416
|
+
issues = await input.seam.listOpenIssues(input.project);
|
|
417
|
+
} catch {
|
|
418
|
+
// No authoritative snapshot, no batch: launching without one would make
|
|
419
|
+
// the agent the selector, which is exactly the defect this module
|
|
420
|
+
// removes. The next pass retries the read.
|
|
421
|
+
return [];
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
const views = toSpecPoolFromSnapshot(issues, input.project);
|
|
425
|
+
const groomingByIssue = new Map(input.grooming.map((row) => [row.issue, row]));
|
|
426
|
+
const activeIssues = new Set(input.active.map((run) => run.issue));
|
|
427
|
+
const pool: ToSpecCandidateView[] = [];
|
|
428
|
+
for (const view of views) {
|
|
429
|
+
if (view.parked) continue;
|
|
430
|
+
if (
|
|
431
|
+
toSpecCandidateExclusion(
|
|
432
|
+
{ issue: view.issue },
|
|
433
|
+
{ grooming: groomingByIssue.get(view.issue), active: activeIssues.has(view.issue) },
|
|
434
|
+
input.now,
|
|
435
|
+
) !== undefined
|
|
436
|
+
) {
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
let children: { number: number; state: IssueState }[];
|
|
440
|
+
try {
|
|
441
|
+
children = await input.seam.childrenOf(input.project, view.issue);
|
|
442
|
+
} catch {
|
|
443
|
+
// Eligibility was not mechanically established for this candidate, so it
|
|
444
|
+
// sits out this pass rather than being launched on an unproven fact.
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (children.length > 0) continue; // parent/epic with sub-issues: no runnable slice
|
|
448
|
+
pool.push(view);
|
|
449
|
+
if (pool.length >= TO_SPEC_BATCH_MAX) break;
|
|
450
|
+
}
|
|
451
|
+
// The one shared rule set re-runs on the tracker-vetted pool, so the pure
|
|
452
|
+
// selector — not a prose instruction — answers what may be launched.
|
|
453
|
+
return selectToSpecBatch({
|
|
454
|
+
candidates: pool,
|
|
455
|
+
summary: input.summary,
|
|
456
|
+
groomBelow: input.groomBelow,
|
|
457
|
+
grooming: input.grooming,
|
|
458
|
+
active: input.active,
|
|
459
|
+
now: input.now,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon HTTP surface's bearer token (Phase 4, chokepoints).
|
|
3
|
+
*
|
|
4
|
+
* The daemon's loopback port used to be deliberately unauthenticated: the
|
|
5
|
+
* comment above its `Bun.serve` called the surface loopback-only by design and
|
|
6
|
+
* left `POST /wake` and `PUT /runs/:issue/*` open. On a shared host that is a
|
|
7
|
+
* mutating surface any local process — any browser page that can reach
|
|
8
|
+
* 127.0.0.1, any other tenant's job — can drive: stop a live worker, raise a
|
|
9
|
+
* turn ceiling, wake dispatch in a loop. This module is the credential that
|
|
10
|
+
* closes it. `GET /healthz` stays open, because `requireDaemonControl` probes
|
|
11
|
+
* it to find a live unit-owned daemon *before* it has any reason to hold a
|
|
12
|
+
* token, and a health probe mutates nothing.
|
|
13
|
+
*
|
|
14
|
+
* ## Why not the dashboard's token
|
|
15
|
+
*
|
|
16
|
+
* The discipline here is copied verbatim from `dashboard/server.ts`'s
|
|
17
|
+
* `ensureDashboardToken` — 0700 state dir, 0600 temp file, atomic rename,
|
|
18
|
+
* explicit chmod, hash-then-compare — but the *token* is a separate file on
|
|
19
|
+
* purpose:
|
|
20
|
+
*
|
|
21
|
+
* - Different surface. The dashboard token guards a read-only HTTP server in
|
|
22
|
+
* its own process; this one guards the daemon's mutating run control.
|
|
23
|
+
* - Different lifetime. The dashboard mints on dashboard start; this one is
|
|
24
|
+
* minted by the daemon at start. Either can be rotated by deleting its file
|
|
25
|
+
* and restarting only that process.
|
|
26
|
+
* - Different blast radius. This token is handed to every CLI caller
|
|
27
|
+
* (`extend`, `worker`, `board`, `wake`) and to the dashboard process.
|
|
28
|
+
* Handing those the dashboard's token would mean any of them could also
|
|
29
|
+
* drive the dashboard's whole `/api/*` surface, and a leak from a CLI
|
|
30
|
+
* invocation would compromise both. One credential per surface keeps the
|
|
31
|
+
* failure contained.
|
|
32
|
+
*
|
|
33
|
+
* ## Minting is the daemon's job, never a client's
|
|
34
|
+
*
|
|
35
|
+
* {@link ensureHttpToken} is called by the daemon at start. Clients call
|
|
36
|
+
* {@link readHttpToken} and refuse when it is absent: an absent token means the
|
|
37
|
+
* daemon has never run under this `$OMP_CONDUCTOR_HOME`, so a client that
|
|
38
|
+
* minted one would be inventing a credential the daemon does not know and
|
|
39
|
+
* turning a clear "the daemon is not running" into a 401 nobody can explain.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
43
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
44
|
+
import { join } from "node:path";
|
|
45
|
+
import { stateDir } from "./config.ts";
|
|
46
|
+
|
|
47
|
+
/** The token's filename under `$OMP_CONDUCTOR_HOME`, beside `dashboard-token`. */
|
|
48
|
+
export const HTTP_TOKEN_FILE = "http-token";
|
|
49
|
+
|
|
50
|
+
/** Where the daemon's bearer token lives, next to config.json. */
|
|
51
|
+
export function httpTokenPath(): string {
|
|
52
|
+
return join(stateDir(), HTTP_TOKEN_FILE);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The stored token, or undefined when the daemon has never started here. */
|
|
56
|
+
export function readHttpToken(): string | undefined {
|
|
57
|
+
try {
|
|
58
|
+
const raw = readFileSync(httpTokenPath(), "utf8").trim();
|
|
59
|
+
return raw.length > 0 ? raw : undefined;
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hashToken(value: string): Buffer {
|
|
66
|
+
return createHash("sha256").update(value).digest();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Mints `<stateDir()>/http-token` on first daemon start; later starts reuse the
|
|
71
|
+
* existing token so every already-configured client keeps working across a
|
|
72
|
+
* restart. Write discipline is `writeConfigRaw`'s: the state dir is created
|
|
73
|
+
* 0700 (chmod only what this call created, so an operator-chosen mode
|
|
74
|
+
* survives), and the token is written 0600 through a same-directory temp file
|
|
75
|
+
* plus an atomic rename, so a crash mid-write leaves the previous token intact
|
|
76
|
+
* rather than a truncated one that authenticates nobody.
|
|
77
|
+
*/
|
|
78
|
+
export function ensureHttpToken(): string {
|
|
79
|
+
const existing = readHttpToken();
|
|
80
|
+
if (existing !== undefined) return existing;
|
|
81
|
+
|
|
82
|
+
const token = randomBytes(32).toString("base64url");
|
|
83
|
+
const dir = stateDir();
|
|
84
|
+
const created = mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
85
|
+
if (created !== undefined) chmodSync(dir, 0o700);
|
|
86
|
+
|
|
87
|
+
const target = httpTokenPath();
|
|
88
|
+
const tmp = join(dir, `.${HTTP_TOKEN_FILE}.${process.pid.toString(36)}.${Date.now().toString(36)}.tmp`);
|
|
89
|
+
try {
|
|
90
|
+
writeFileSync(tmp, `${token}\n`, { mode: 0o600 });
|
|
91
|
+
renameSync(tmp, target);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
rmSync(tmp, { force: true });
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
// The rename preserves the temp's 0600; state it anyway so the promise never
|
|
97
|
+
// leans on umask moods.
|
|
98
|
+
chmodSync(target, 0o600);
|
|
99
|
+
return token;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Constant-time `Authorization: Bearer <token>` check.
|
|
104
|
+
*
|
|
105
|
+
* Takes the raw header (or null, which is what `Headers.get` returns when it is
|
|
106
|
+
* absent) so every route checks the same way and no caller has to remember to
|
|
107
|
+
* strip the scheme. Both sides are hashed to a fixed 32 bytes before the
|
|
108
|
+
* compare, so a wrong-length candidate cannot leak the stored token's length
|
|
109
|
+
* through `timingSafeEqual`'s length check — the same reason
|
|
110
|
+
* `verifyDashboardToken` hashes first.
|
|
111
|
+
*/
|
|
112
|
+
export function verifyHttpToken(header: string | null, token: string): boolean {
|
|
113
|
+
if (header === null || token.length === 0) return false;
|
|
114
|
+
const match = /^Bearer[ \t]+(\S+)$/.exec(header.trim());
|
|
115
|
+
if (match === null) return false;
|
|
116
|
+
return timingSafeEqual(hashToken(match[1]!), hashToken(token));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The refusal every client prints when the token file is absent, worded once so
|
|
121
|
+
* `extend`, `worker`, `board`, `wake` and the dashboard proxy cannot drift.
|
|
122
|
+
* Names the file and the cause: the daemon mints it at start, so no file means
|
|
123
|
+
* no daemon has run here.
|
|
124
|
+
*/
|
|
125
|
+
export function missingHttpTokenMessage(): string {
|
|
126
|
+
return (
|
|
127
|
+
`no daemon HTTP token at ${httpTokenPath()} — the daemon mints it at start, ` +
|
|
128
|
+
"so this means no daemon has run under this OMP_CONDUCTOR_HOME. " +
|
|
129
|
+
"Start it with `omp-conductor start` and retry."
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The `Authorization` header every mutating client must send, or `undefined`
|
|
135
|
+
* when there is no token to send. Callers turn `undefined` into
|
|
136
|
+
* {@link missingHttpTokenMessage} — never into an unauthenticated request that
|
|
137
|
+
* would come back as a bare 401.
|
|
138
|
+
*/
|
|
139
|
+
export function httpAuthHeader(): { authorization: string } | undefined {
|
|
140
|
+
const token = readHttpToken();
|
|
141
|
+
return token === undefined ? undefined : { authorization: `Bearer ${token}` };
|
|
142
|
+
}
|