omp-conductor 0.19.7 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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 -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 +24 -3
- package/src/failure-class.ts +75 -1
- package/src/fleet.ts +290 -164
- 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 +604 -26
- package/src/to-spec.ts +194 -21
- package/src/tracker/github.ts +50 -0
- package/src/types.ts +416 -15
- package/src/verbs/protocol.ts +28 -0
- package/src/verbs/server.ts +330 -39
- package/src/wake.ts +19 -2
- package/src/worker.ts +456 -1
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operator drain sentinel: one JSON file under the state root that says a
|
|
3
|
+
* human asked this fleet to stop taking new work and finish what it has.
|
|
4
|
+
*
|
|
5
|
+
* It is a file rather than a store row because the things that read it are not
|
|
6
|
+
* all the daemon — `omp-conductor drain`, the status render and `upgrade`'s
|
|
7
|
+
* pre-flight all answer without opening the database, and an upgrade that has
|
|
8
|
+
* just replaced the schema must still be able to read the drain it set. That
|
|
9
|
+
* also fixes the boundary: everything here is pure file I/O plus a verdict, no
|
|
10
|
+
* `Deps`, so it is the leaf the dispatch pass and the status projection both
|
|
11
|
+
* read rather than a pass either of them has to import.
|
|
12
|
+
*
|
|
13
|
+
* Not to be confused with `DrainSignal` in `deps.ts`: that one is the in-process
|
|
14
|
+
* "SIGTERM has landed" flag a tick re-checks mid-flight. This is the durable
|
|
15
|
+
* operator intent that outlives the process.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { stateDir } from "../config.ts";
|
|
20
|
+
|
|
21
|
+
// (#484 slice 1) A project drain is a durable, self-expiring admission fence:
|
|
22
|
+
// the same boundary as a pause — settlement above it, nothing claimed below —
|
|
23
|
+
// but recorded with an absolute deadline, so an orchestrator crash can never
|
|
24
|
+
// strand admission. The record is a JSON file under the state directory (like
|
|
25
|
+
// the pause sentinel), scoped to exactly one configured project, and replaced
|
|
26
|
+
// atomically on create. Human CLI wording and release-verb coupling are later
|
|
27
|
+
// #484 children.
|
|
28
|
+
|
|
29
|
+
/** The persisted shape of one project drain. Every field is validated when a
|
|
30
|
+
* record is read — a record that cannot be trusted is never a fence. */
|
|
31
|
+
export interface DrainRecord {
|
|
32
|
+
/** The configured project this drain fences; must match the reader. */
|
|
33
|
+
project: string;
|
|
34
|
+
/** ISO instant the drain intent was recorded. */
|
|
35
|
+
createdAt: string;
|
|
36
|
+
/** Absolute ISO deadline: admission resumes automatically at or after it. */
|
|
37
|
+
expiresAt: string;
|
|
38
|
+
/** Purpose recorded at creation (release window, maintenance…). */
|
|
39
|
+
reason?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Create-time options for {@link createDrain}. */
|
|
43
|
+
export interface CreateDrainOptions {
|
|
44
|
+
/** Absolute epoch-ms deadline — a drain must always expire on its own. */
|
|
45
|
+
expiresAt: number;
|
|
46
|
+
/** Purpose, persisted on the record and shown in structured status. */
|
|
47
|
+
reason?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Why a drain record could not be trusted, named deterministically. */
|
|
51
|
+
export type DrainProblem =
|
|
52
|
+
| "unparseable-json"
|
|
53
|
+
| "invalid-record"
|
|
54
|
+
| "invalid-project"
|
|
55
|
+
| "invalid-created-at"
|
|
56
|
+
| "invalid-expires-at"
|
|
57
|
+
| "expiry-not-future"
|
|
58
|
+
| "invalid-reason";
|
|
59
|
+
|
|
60
|
+
/** The verdict of one drain read. `error` is the malformed-record case: the
|
|
61
|
+
* caller may fail its pass closed, and the dispatch-side
|
|
62
|
+
* {@link consumeDrain} removes the record in the same transition, so it can
|
|
63
|
+
* never become a permanent drain. */
|
|
64
|
+
export type DrainVerdict =
|
|
65
|
+
| { kind: "active"; drain: DrainRecord }
|
|
66
|
+
| { kind: "inactive" }
|
|
67
|
+
| { kind: "error"; problem: DrainProblem };
|
|
68
|
+
|
|
69
|
+
/** Where this project's drain record lives. Project-scoped by construction:
|
|
70
|
+
* a record present at one project's path never fences another project. */
|
|
71
|
+
export function drainPath(project: string): string {
|
|
72
|
+
return join(stateDir(), `drain-${project}.json`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Records a bounded drain intent for `project`, replacing any prior drain of
|
|
77
|
+
* the same project atomically (tmp + rename, exactly like the admission ack).
|
|
78
|
+
* The record is a file, so it survives orchestrator and daemon loss; the
|
|
79
|
+
* absolute `expiresAt` is what stops it from ever stranding admission.
|
|
80
|
+
*/
|
|
81
|
+
export function createDrain(
|
|
82
|
+
project: string,
|
|
83
|
+
opts: CreateDrainOptions,
|
|
84
|
+
now = Date.now(),
|
|
85
|
+
): void {
|
|
86
|
+
if (project === "") {
|
|
87
|
+
throw new Error("drain project must not be empty");
|
|
88
|
+
}
|
|
89
|
+
if (!Number.isFinite(opts.expiresAt) || opts.expiresAt <= now) {
|
|
90
|
+
throw new Error("drain expiresAt must be a finite epoch-ms timestamp in the future");
|
|
91
|
+
}
|
|
92
|
+
if (opts.reason !== undefined && typeof opts.reason !== "string") {
|
|
93
|
+
throw new Error("drain reason must be a string");
|
|
94
|
+
}
|
|
95
|
+
const record: DrainRecord = {
|
|
96
|
+
project,
|
|
97
|
+
createdAt: new Date(now).toISOString(),
|
|
98
|
+
expiresAt: new Date(opts.expiresAt).toISOString(),
|
|
99
|
+
...(opts.reason === undefined ? {} : { reason: opts.reason }),
|
|
100
|
+
};
|
|
101
|
+
const path = drainPath(project);
|
|
102
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
103
|
+
const tmp = `${path}.${process.pid.toString(36)}.${Date.now().toString(36)}.tmp`;
|
|
104
|
+
writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`);
|
|
105
|
+
renameSync(tmp, path);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* One drained state read with no side effects — this never touches the record
|
|
110
|
+
* on disk. That purity is what lets observational surfaces live off it: a
|
|
111
|
+
* status/dashboard/health read must not consume a malformed record before a
|
|
112
|
+
* dispatch pass fails closed on it, or the pass would read absent and admit.
|
|
113
|
+
* The dispatch side performs the actual cleanup transition through
|
|
114
|
+
* {@link consumeDrain}. Callers that only need to *see* the state (including
|
|
115
|
+
* the claim path, which refuses but must not unbind its own pass) use this.
|
|
116
|
+
*/
|
|
117
|
+
export function readDrain(project: string, now = Date.now()): DrainVerdict {
|
|
118
|
+
const path = drainPath(project);
|
|
119
|
+
if (!existsSync(path)) return { kind: "inactive" };
|
|
120
|
+
let raw: unknown;
|
|
121
|
+
try {
|
|
122
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
123
|
+
} catch {
|
|
124
|
+
return { kind: "error", problem: "unparseable-json" };
|
|
125
|
+
}
|
|
126
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
127
|
+
return { kind: "error", problem: "invalid-record" };
|
|
128
|
+
}
|
|
129
|
+
const rec = raw as Record<string, unknown>;
|
|
130
|
+
if (typeof rec["project"] !== "string" || rec["project"] === "") {
|
|
131
|
+
return { kind: "error", problem: "invalid-project" };
|
|
132
|
+
}
|
|
133
|
+
if (typeof rec["createdAt"] !== "string" || Number.isNaN(Date.parse(rec["createdAt"]))) {
|
|
134
|
+
return { kind: "error", problem: "invalid-created-at" };
|
|
135
|
+
}
|
|
136
|
+
if (typeof rec["expiresAt"] !== "string" || Number.isNaN(Date.parse(rec["expiresAt"]))) {
|
|
137
|
+
return { kind: "error", problem: "invalid-expires-at" };
|
|
138
|
+
}
|
|
139
|
+
if (Date.parse(rec["expiresAt"] as string) <= Date.parse(rec["createdAt"] as string)) {
|
|
140
|
+
return { kind: "error", problem: "expiry-not-future" };
|
|
141
|
+
}
|
|
142
|
+
const reason = rec["reason"];
|
|
143
|
+
if (reason !== undefined && typeof reason !== "string") {
|
|
144
|
+
return { kind: "error", problem: "invalid-reason" };
|
|
145
|
+
}
|
|
146
|
+
if (rec["project"] !== project) {
|
|
147
|
+
// A record persisted at this project's path but naming another project is
|
|
148
|
+
// either a copy or a rename mishap; it fences nobody (that project's drain
|
|
149
|
+
// lives at its own path).
|
|
150
|
+
return { kind: "inactive" };
|
|
151
|
+
}
|
|
152
|
+
if (Date.parse(rec["expiresAt"] as string) <= now) {
|
|
153
|
+
return { kind: "inactive" };
|
|
154
|
+
}
|
|
155
|
+
const drain: DrainRecord = {
|
|
156
|
+
project: rec["project"],
|
|
157
|
+
createdAt: rec["createdAt"],
|
|
158
|
+
expiresAt: rec["expiresAt"],
|
|
159
|
+
...(reason === undefined ? {} : { reason }),
|
|
160
|
+
};
|
|
161
|
+
return { kind: "active", drain };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The dispatch-side drain read: the same verdict as {@link readDrain}, and the
|
|
166
|
+
* one place a stale or untrustworthy record is cleared as a side effect —
|
|
167
|
+
* expired and wrong-project records are removed (bounded stale-state cleanup
|
|
168
|
+
* on the next pass), and a malformed record is removed in the very transition
|
|
169
|
+
* that fails the pass closed, so it can never become an unbounded permanent
|
|
170
|
+
* drain. Only dispatch callers use this: an observational read here would let
|
|
171
|
+
* a status/health reader consume the malformed marker before the pass that
|
|
172
|
+
* must fail closed on it ever ran.
|
|
173
|
+
*/
|
|
174
|
+
export function consumeDrain(project: string, now = Date.now()): DrainVerdict {
|
|
175
|
+
const verdict = readDrain(project, now);
|
|
176
|
+
if (verdict.kind !== "active") {
|
|
177
|
+
rmSync(drainPath(project), { force: true });
|
|
178
|
+
}
|
|
179
|
+
return verdict;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Removes this project's drain, idempotently — a second cancel is a no-op. */
|
|
183
|
+
export function cancelDrain(project: string): void {
|
|
184
|
+
rmSync(drainPath(project), { force: true });
|
|
185
|
+
}
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's half of grooming: launch to-spec scouts when the queue runs low,
|
|
3
|
+
* persist what they answer, and promote what passes the ready gate.
|
|
4
|
+
*
|
|
5
|
+
* Named `groom-pass` against the top-level `groom.ts` because the two halves
|
|
6
|
+
* must stay apart. `groom.ts` is the rule set — selection, exclusions, the
|
|
7
|
+
* in-flight reasons — pure and shared, and the only place a candidate is ever
|
|
8
|
+
* ruled in or out. This file is the pass: sessions, durable rows, label ops and
|
|
9
|
+
* the wake. A rule that drifted in here would be a second answer to "should this
|
|
10
|
+
* issue be groomed", which is precisely the failure the mechanical pipeline was
|
|
11
|
+
* built to remove.
|
|
12
|
+
*
|
|
13
|
+
* `TO_SPEC_BRIEF_PATH` resolves through `PACKAGE_SRC_DIR` for the same reason
|
|
14
|
+
* the worker template does: the prompt stayed in `src/briefs/` when this pass
|
|
15
|
+
* moved a directory deeper.
|
|
16
|
+
*/
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { mkdirSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { resolveGroomRole, stateDir } from "../config.ts";
|
|
21
|
+
import { DEFAULT_GROOM_BELOW, TO_SPEC_AGENT, TO_SPEC_GATE_REJECTED_REASON, TO_SPEC_IN_FLIGHT_REASON, TO_SPEC_IN_FLIGHT_TTL_MS, groomingDue, selectToSpecCandidates, toSpecDurableVerdict, type ToSpecCandidateView } from "../groom.ts";
|
|
22
|
+
import { errText, log } from "../log.ts";
|
|
23
|
+
import { materializeOmpSettings } from "../omp-settings.ts";
|
|
24
|
+
import { readyGate } from "../ready-gate.ts";
|
|
25
|
+
import { recordToSpecGrooming, type ToSpecEvidence } from "../to-spec.ts";
|
|
26
|
+
import type { GroomingRecord, IssueComment, ProjectConfig, ReadyIssue } from "../types.ts";
|
|
27
|
+
import { wakeDispatch } from "../wake.ts";
|
|
28
|
+
import { TO_SPEC_MAX_TURNS, renderBrief, runToSpecScout } from "../worker.ts";
|
|
29
|
+
import type { WorkerPool } from "./admission-pass.ts";
|
|
30
|
+
import { PACKAGE_SRC_DIR, recordDaemonSessionSpend, repoSlug, type Deps } from "./deps.ts";
|
|
31
|
+
|
|
32
|
+
// ==================================================== daemon-driven grooming
|
|
33
|
+
// (#1041, #1040). The dispatcher watches its own queue depth, launches to-spec
|
|
34
|
+
// scouts when it runs low, and promotes what passes the ready gate — the three
|
|
35
|
+
// steps that used to need an orchestrator turn each, and therefore used to wait
|
|
36
|
+
// up to a whole tick interval apiece while the queue sat empty.
|
|
37
|
+
//
|
|
38
|
+
// The division of labour, and why each half is where it is:
|
|
39
|
+
//
|
|
40
|
+
// - SELECTION is `groom.ts`: pure, shared, and the only rule set. The daemon
|
|
41
|
+
// supplies the authoritative reads (its own routable count, the open-issue
|
|
42
|
+
// snapshot, the durable grooming rows, its live runs) and takes back a
|
|
43
|
+
// bounded candidate list. Nothing here re-derives an exclusion.
|
|
44
|
+
// - LAUNCH is this module: a read-only session per candidate, the
|
|
45
|
+
// `handleReviewAdjudication` shape exactly — per-item session directory
|
|
46
|
+
// under the state root, staged omp settings, a socket, the configured role,
|
|
47
|
+
// and a durable settle on every branch including "no answer".
|
|
48
|
+
// - PERSISTENCE is `recordToSpecGrooming`: still the only validator, and it
|
|
49
|
+
// stamps the daemon's own launch time as the observation window (#1000).
|
|
50
|
+
// - PROMOTION is `readyGate` plus the epic-scope read below it, and it is the
|
|
51
|
+
// one place a queue label is added: provenance first (the latch), then the
|
|
52
|
+
// label op, then the wake.
|
|
53
|
+
//
|
|
54
|
+
// The in-flight grooming row is the single-flight latch for the batch. It is
|
|
55
|
+
// written BEFORE any session starts, so a second pass in the same window and a
|
|
56
|
+
// restart mid-batch both read the claim; a launch that dies with the process
|
|
57
|
+
// leaves the row to expire on {@link TO_SPEC_IN_FLIGHT_TTL_MS} rather than
|
|
58
|
+
// parking its candidate forever.
|
|
59
|
+
|
|
60
|
+
/** The grooming prompt, read per launch so an edit applies to the next batch
|
|
61
|
+
* rather than the next daemon restart — the same contract `buildBrief` has. */
|
|
62
|
+
export const TO_SPEC_BRIEF_PATH = join(PACKAGE_SRC_DIR, "briefs", "to-spec.md");
|
|
63
|
+
|
|
64
|
+
/** One batch's identity, carried into every item of it. */
|
|
65
|
+
export interface ToSpecBatch {
|
|
66
|
+
/** Short opaque id: it names the batch in logs, in the in-flight evidence and
|
|
67
|
+
* in each item's session directory, so a spend line, a durable row and a
|
|
68
|
+
* transcript can be tied together after the fact. */
|
|
69
|
+
id: string;
|
|
70
|
+
/** The daemon's own launch stamp. It — never anything the agent wrote — is
|
|
71
|
+
* the source observation time every verdict in this batch persists with
|
|
72
|
+
* (#1000). */
|
|
73
|
+
launchedAt: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Launch the to-spec grooming batch this project's queue depth calls for
|
|
78
|
+
* (#1041).
|
|
79
|
+
*
|
|
80
|
+
* `routable` is the count this pass just measured, not a stored one: the whole
|
|
81
|
+
* point of moving grooming into the daemon is that the drought and the response
|
|
82
|
+
* happen in the same pass. The durable dispatch row still has to exist, because
|
|
83
|
+
* its absence means no pass has ever completed here and the shared selector
|
|
84
|
+
* refuses to judge a queue nobody has routed.
|
|
85
|
+
*
|
|
86
|
+
* Never blocks the tick: with a worker pool the launches ride it exactly like
|
|
87
|
+
* adjudications, so a batch that takes minutes cannot delay the dispatch that
|
|
88
|
+
* follows it. Without one (a `--once` pass, a test) it awaits, which is what
|
|
89
|
+
* makes the whole pipeline assertable in one call.
|
|
90
|
+
*/
|
|
91
|
+
export async function dispatchToSpecGrooming(
|
|
92
|
+
d: Deps,
|
|
93
|
+
routable: number,
|
|
94
|
+
pool?: WorkerPool,
|
|
95
|
+
): Promise<void> {
|
|
96
|
+
const project = d.project;
|
|
97
|
+
const groomBelow = project.groomBelow ?? DEFAULT_GROOM_BELOW;
|
|
98
|
+
if (!groomingDue(routable, groomBelow)) return;
|
|
99
|
+
// The stop fence (#374), for the same reason every other launch honours it:
|
|
100
|
+
// a batch started into a draining daemon is spend with nowhere to land — the
|
|
101
|
+
// sessions die with the process and their rows expire unanswered.
|
|
102
|
+
if (d.drain?.draining === true) {
|
|
103
|
+
log("grooming held: the daemon is draining");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const prior = d.store.latestDispatch(project.name);
|
|
107
|
+
if (prior === undefined) return;
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
const grooming = d.store.groomingVerdicts(project.name);
|
|
110
|
+
// One batch at a time. The per-candidate exclusion inside the selector stops
|
|
111
|
+
// one issue being groomed twice; this stops a second batch existing at all,
|
|
112
|
+
// which is the bound on what an unattended dispatcher may spend per window.
|
|
113
|
+
const inFlight = grooming.filter(
|
|
114
|
+
(row) => row.reason === TO_SPEC_IN_FLIGHT_REASON && now - row.recordedAt <= TO_SPEC_IN_FLIGHT_TTL_MS,
|
|
115
|
+
);
|
|
116
|
+
if (inFlight.length > 0) {
|
|
117
|
+
log(
|
|
118
|
+
`grooming held: ${inFlight.length} candidate(s) still in a launched batch ` +
|
|
119
|
+
`(${inFlight.map((row) => `#${row.issue}`).join(" ")})`,
|
|
120
|
+
);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
let candidates: ToSpecCandidateView[];
|
|
124
|
+
try {
|
|
125
|
+
candidates = await selectToSpecCandidates({
|
|
126
|
+
project,
|
|
127
|
+
seam: {
|
|
128
|
+
listOpenIssues: () => d.tracker.listOpenIssues(),
|
|
129
|
+
childrenOf: (_project, issue) => d.tracker.childrenOf(issue),
|
|
130
|
+
},
|
|
131
|
+
// The selector reads two things off this: that a pass has completed here
|
|
132
|
+
// at all, and the routable depth. The depth is this pass's own
|
|
133
|
+
// measurement — the row supplies the rest of the shape and is never
|
|
134
|
+
// written back.
|
|
135
|
+
summary: { ...prior, routed: routable },
|
|
136
|
+
groomBelow,
|
|
137
|
+
grooming,
|
|
138
|
+
active: d.store.liveRuns(project.name),
|
|
139
|
+
now,
|
|
140
|
+
});
|
|
141
|
+
} catch (err) {
|
|
142
|
+
log(`grooming selection failed: ${errText(err)} — retrying next pass`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (candidates.length === 0) return;
|
|
146
|
+
|
|
147
|
+
const batch: ToSpecBatch = { id: randomUUID().slice(0, 8), launchedAt: now };
|
|
148
|
+
const claim = JSON.stringify({
|
|
149
|
+
kind: "to-spec-in-flight",
|
|
150
|
+
launchedAt: batch.launchedAt,
|
|
151
|
+
batch: batch.id,
|
|
152
|
+
agent: TO_SPEC_AGENT,
|
|
153
|
+
});
|
|
154
|
+
// Every claim before any launch, deliberately: a session that starts while a
|
|
155
|
+
// sibling's claim is still unwritten is a candidate a concurrent pass can
|
|
156
|
+
// select twice.
|
|
157
|
+
for (const candidate of candidates) {
|
|
158
|
+
d.store.upsertGrooming({
|
|
159
|
+
project: project.name,
|
|
160
|
+
issue: candidate.issue,
|
|
161
|
+
verdict: "blocked",
|
|
162
|
+
reason: TO_SPEC_IN_FLIGHT_REASON,
|
|
163
|
+
evidence: claim,
|
|
164
|
+
at: batch.launchedAt,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
log(
|
|
168
|
+
`grooming batch ${batch.id}: routable ${routable} below ${String(groomBelow)} — ` +
|
|
169
|
+
`launching ${candidates.map((c) => `#${c.issue}`).join(" ")} as ${resolveGroomRole(project)}`,
|
|
170
|
+
);
|
|
171
|
+
const launches = candidates.map((candidate) => handleToSpecGrooming(d, candidate, batch));
|
|
172
|
+
if (pool !== undefined) {
|
|
173
|
+
for (const launch of launches) pool.launch(launch);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
await Promise.allSettled(launches);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Groom one candidate to a durable verdict, and promote it when it earns that
|
|
181
|
+
* (#1041).
|
|
182
|
+
*
|
|
183
|
+
* Every branch settles the row. That is the whole reason the in-flight marker
|
|
184
|
+
* can be a latch rather than a lease: a scout that answered, one that answered
|
|
185
|
+
* garbage, and one whose session never started all replace their own claim, so
|
|
186
|
+
* the only way a candidate stays parked is the daemon dying mid-flight — which
|
|
187
|
+
* the TTL covers.
|
|
188
|
+
*/
|
|
189
|
+
export async function handleToSpecGrooming(
|
|
190
|
+
d: Deps,
|
|
191
|
+
candidate: ToSpecCandidateView,
|
|
192
|
+
batch: ToSpecBatch,
|
|
193
|
+
): Promise<void> {
|
|
194
|
+
const project = d.project;
|
|
195
|
+
const issue = candidate.issue;
|
|
196
|
+
// The body verbatim, read now rather than carried from the selection
|
|
197
|
+
// snapshot: the brief's whole premise is that the groomer judges the issue as
|
|
198
|
+
// it stands against the source as it stands. An unreadable body is said out
|
|
199
|
+
// loud in the brief — a scout that silently groomed a blank issue would
|
|
200
|
+
// produce a confident verdict about nothing.
|
|
201
|
+
let body: string | undefined;
|
|
202
|
+
try {
|
|
203
|
+
body = (await d.tracker.getIssue(issue))?.body;
|
|
204
|
+
} catch {
|
|
205
|
+
body = undefined;
|
|
206
|
+
}
|
|
207
|
+
const brief = renderBrief(await Bun.file(TO_SPEC_BRIEF_PATH).text(), {
|
|
208
|
+
TRACKER_REPO: project.tracker.repo,
|
|
209
|
+
ISSUE_NUMBER: String(issue),
|
|
210
|
+
CANDIDATE_TITLE: candidate.title,
|
|
211
|
+
ISSUE_BODY:
|
|
212
|
+
body === undefined || body.trim() === ""
|
|
213
|
+
? "(the issue body could not be read — say so in reasonNotToPromote rather than guessing what it asked for)"
|
|
214
|
+
: body,
|
|
215
|
+
SOURCE: candidate.routing,
|
|
216
|
+
SOURCE_REF: groomSourceRef(project, candidate.routing),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const sessionDir = join(stateDir(), "sessions", `to-spec-${batch.id}-${issue}`);
|
|
220
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
221
|
+
const ompSettingsFile = materializeOmpSettings(project, sessionDir);
|
|
222
|
+
const result = await (d.runToSpecScoutImpl ?? runToSpecScout)({
|
|
223
|
+
brief,
|
|
224
|
+
// No worktree: a scout has no branch, and everything about the candidate is
|
|
225
|
+
// in the brief. The session directory doubles as its confined cwd.
|
|
226
|
+
cwd: sessionDir,
|
|
227
|
+
sessionDir,
|
|
228
|
+
model: resolveGroomRole(project),
|
|
229
|
+
...(ompSettingsFile === undefined ? {} : { ompSettingsFile }),
|
|
230
|
+
socketPath: join(sessionDir, "ipc.sock"),
|
|
231
|
+
maxTurns: TO_SPEC_MAX_TURNS,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Recorded before the verdict is validated: an unparseable answer cost
|
|
235
|
+
// whatever it cost, and a refusal that is invisible in the accounting is how
|
|
236
|
+
// grooming spend became unattributable in the first place.
|
|
237
|
+
const groomRole = resolveGroomRole(project);
|
|
238
|
+
recordDaemonSessionSpend(d, {
|
|
239
|
+
role: "groom",
|
|
240
|
+
issue,
|
|
241
|
+
...(groomRole === undefined ? {} : { model: groomRole }),
|
|
242
|
+
...(result.model === undefined ? {} : { resolvedModel: result.model }),
|
|
243
|
+
turns: result.turns,
|
|
244
|
+
spendUsd: result.spendUsd,
|
|
245
|
+
at: Date.now(),
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// The scout's answer, unjudged, through the one validator. Its report stands
|
|
249
|
+
// in when it produced no answer at all, so the refusal names what went wrong
|
|
250
|
+
// (an unresolvable role, a harness that would not start) instead of recording
|
|
251
|
+
// an empty string nobody can diagnose.
|
|
252
|
+
const outcome = recordToSpecGrooming(d.store, {
|
|
253
|
+
project: project.name,
|
|
254
|
+
issue,
|
|
255
|
+
input: result.raw === "" ? result.report : result.raw,
|
|
256
|
+
launchedAt: batch.launchedAt,
|
|
257
|
+
});
|
|
258
|
+
const ran = result.model === undefined ? "" : ` by ${result.model}`;
|
|
259
|
+
// "$0.00" for a session that took turns and reported nothing is the misleading
|
|
260
|
+
// zero #46 named: say "unmetered" instead, matching what the durable row now
|
|
261
|
+
// stores and what the stats lane renders.
|
|
262
|
+
const cost =
|
|
263
|
+
result.turns > 0 && result.spendUsd === 0 ? "unmetered" : `$${result.spendUsd.toFixed(2)}`;
|
|
264
|
+
log(
|
|
265
|
+
`#${issue} groomed ${outcome.record.verdict}(${outcome.record.reason})${ran} in batch ${batch.id} — ` +
|
|
266
|
+
`${result.turns} turn(s), ${cost}`,
|
|
267
|
+
);
|
|
268
|
+
if (outcome.kind !== "persisted" || outcome.record.verdict !== "promotable") return;
|
|
269
|
+
await promoteGroomedVerdict(d, outcome.record);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The ref a grooming brief tells its scout to read the source at (#1041).
|
|
274
|
+
*
|
|
275
|
+
* The routed repository's configured default branch — a name, not a sha,
|
|
276
|
+
* because conductor no longer needs the scout to witness freshness: the
|
|
277
|
+
* dispatcher stamps the observation window itself (#1000), so resolving a head
|
|
278
|
+
* here would cost a network read per candidate to produce a fact nothing reads.
|
|
279
|
+
* `HEAD` is the honest fallback for a routing target this config cannot
|
|
280
|
+
* resolve: it names the repository's own default branch without claiming to
|
|
281
|
+
* know which branch that is.
|
|
282
|
+
*/
|
|
283
|
+
export function groomSourceRef(project: ProjectConfig, routing: string): string {
|
|
284
|
+
const target = Object.values(project.routing.repos).find((repo) => repoSlug(repo) === routing);
|
|
285
|
+
return target?.defaultBranch ?? "HEAD";
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Promote one fresh `promotable` verdict, or record exactly why not (#1041,
|
|
290
|
+
* #1040).
|
|
291
|
+
*
|
|
292
|
+
* Mechanical promotion is the phase's whole point and also its whole risk, so
|
|
293
|
+
* the shape is: judge with a pure function, refuse with a durable list, and
|
|
294
|
+
* mutate through the outbox only after the provenance stamp has been won.
|
|
295
|
+
*
|
|
296
|
+
* - The judgement is {@link readyGate} over the issue as it stands right now.
|
|
297
|
+
* A tracker read that fails becomes `"unread"`, which the gate refuses
|
|
298
|
+
* rather than treating as an empty issue: nothing here was queued by a
|
|
299
|
+
* human, so every unknown is a refusal.
|
|
300
|
+
* - The epic-scope check is this function's own, because the gate is pure and
|
|
301
|
+
* approval is a store fact. A child of an unapproved epic is not promotable
|
|
302
|
+
* however complete its brief: an orchestrator can file a decomposition, but
|
|
303
|
+
* only an operator agrees to its scope. No decision row is created here —
|
|
304
|
+
* net-new scope stays the orchestrator's proposal to make.
|
|
305
|
+
* - A refusal re-records the SAME verdict with `readyGate.missing` beside it,
|
|
306
|
+
* under {@link TO_SPEC_GATE_REJECTED_REASON}. The verdict was valid; the
|
|
307
|
+
* spec is simply not queueable, and the tick's promotion audit renders the
|
|
308
|
+
* whole list so one pass fixes every problem instead of four passes fixing
|
|
309
|
+
* one each.
|
|
310
|
+
* - A pass stamps provenance FIRST. `markGroomingPromoted` returning false is
|
|
311
|
+
* the latch: another pass already promoted this verdict, so this one adds no
|
|
312
|
+
* label and fires no wake, and a restart mid-promotion cannot queue an issue
|
|
313
|
+
* twice.
|
|
314
|
+
*
|
|
315
|
+
* Returns whether this call promoted, for the caller's own log; the durable
|
|
316
|
+
* facts are the row and the label op.
|
|
317
|
+
*/
|
|
318
|
+
export async function promoteGroomedVerdict(d: Deps, row: GroomingRecord): Promise<boolean> {
|
|
319
|
+
const project = d.project;
|
|
320
|
+
const issue = row.issue;
|
|
321
|
+
const now = Date.now();
|
|
322
|
+
const result = toSpecDurableVerdict(row, now);
|
|
323
|
+
if (result === undefined) {
|
|
324
|
+
// Not durable grooming: no recoverable to-spec payload, or a source
|
|
325
|
+
// observed past the freshness ceiling. Either way there is nothing to
|
|
326
|
+
// promote ON, and the candidate is re-groomable by the same rule.
|
|
327
|
+
log(`#${issue} not promoted: its verdict carries no fresh to-spec result`);
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
let snapshot: ReadyIssue | undefined;
|
|
331
|
+
try {
|
|
332
|
+
snapshot = await d.tracker.getIssue(issue);
|
|
333
|
+
} catch {
|
|
334
|
+
snapshot = undefined;
|
|
335
|
+
}
|
|
336
|
+
let comments: IssueComment[] | "unread" = "unread";
|
|
337
|
+
if (snapshot !== undefined) {
|
|
338
|
+
try {
|
|
339
|
+
comments = await d.tracker.listComments(issue);
|
|
340
|
+
} catch {
|
|
341
|
+
comments = "unread";
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const verdict = readyGate({
|
|
345
|
+
result,
|
|
346
|
+
issueBody: snapshot?.body ?? "",
|
|
347
|
+
comments: snapshot === undefined ? "unread" : comments,
|
|
348
|
+
labels: snapshot?.labels ?? [],
|
|
349
|
+
project,
|
|
350
|
+
});
|
|
351
|
+
const missing = [...(verdict.ok ? [] : verdict.missing), ...(await epicScopeMisses(d, issue))];
|
|
352
|
+
if (missing.length > 0) {
|
|
353
|
+
const evidence: ToSpecEvidence = { kind: "to-spec", result, readyGate: { missing, checkedAt: now } };
|
|
354
|
+
d.store.upsertGrooming({
|
|
355
|
+
project: project.name,
|
|
356
|
+
issue,
|
|
357
|
+
verdict: row.verdict,
|
|
358
|
+
reason: TO_SPEC_GATE_REJECTED_REASON,
|
|
359
|
+
evidence: JSON.stringify(evidence),
|
|
360
|
+
// The verdict's own recording time is preserved: the gate ran now, but
|
|
361
|
+
// the judgement is the one the scout made, and its freshness window is
|
|
362
|
+
// what decides re-grooming.
|
|
363
|
+
at: row.recordedAt,
|
|
364
|
+
});
|
|
365
|
+
log(`#${issue} not promoted (${missing.length} gate refusal(s)): ${missing[0]}`);
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
if (!d.store.markGroomingPromoted(project.name, issue, now, "daemon")) {
|
|
369
|
+
log(`#${issue} promotion skipped: this verdict already carries a promotion`);
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
// The outbox owns labels — never a direct tracker write. The projector
|
|
373
|
+
// applies it and retries until GitHub takes it, and the eligibility overlay
|
|
374
|
+
// keeps this pass coherent in the meantime (#201).
|
|
375
|
+
d.store.enqueueLabelOps(project.name, [{ issue, op: "add", label: project.queueLabel }]);
|
|
376
|
+
try {
|
|
377
|
+
log(`#${issue} promoted by the ready gate — ${await (d.wake ?? wakeDispatch)(project.name)}`);
|
|
378
|
+
} catch (err) {
|
|
379
|
+
log(`#${issue} promoted but the dispatch wake failed (${errText(err)}) — the next scheduled pass will claim`);
|
|
380
|
+
}
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The epic-scope half of promotion: nothing, or the one reason this slice's
|
|
386
|
+
* scope has not been agreed to (#1041).
|
|
387
|
+
*
|
|
388
|
+
* An issue with no parent is not epic-serialized work and needs no approval —
|
|
389
|
+
* it exists in the tracker, which means somebody filed it. A child does: a
|
|
390
|
+
* decomposition can be proposed without its scope being accepted, and
|
|
391
|
+
* mechanically queueing children of an unapproved epic is how an orchestrator's
|
|
392
|
+
* proposal becomes a fleet's week of work without anyone saying yes.
|
|
393
|
+
*
|
|
394
|
+
* Fails closed on an unreadable parent, matching every other epic read in this
|
|
395
|
+
* package: "the tracker could not say" is not evidence of standalone work.
|
|
396
|
+
*/
|
|
397
|
+
export async function epicScopeMisses(d: Deps, issue: number): Promise<string[]> {
|
|
398
|
+
let parent: number | undefined;
|
|
399
|
+
try {
|
|
400
|
+
parent = await d.tracker.parentOf(issue);
|
|
401
|
+
} catch (err) {
|
|
402
|
+
return [
|
|
403
|
+
`the parent/epic lookup failed (${errText(err)}), so the scope this slice belongs to could not be established`,
|
|
404
|
+
];
|
|
405
|
+
}
|
|
406
|
+
if (parent === undefined) return [];
|
|
407
|
+
if (d.store.epicApproval(d.project.name, parent) !== undefined) return [];
|
|
408
|
+
return [
|
|
409
|
+
`it is a child of epic #${parent}, whose scope nobody approved — ` +
|
|
410
|
+
`\`omp-conductor epic approve ${parent}\` once the operator agrees to it`,
|
|
411
|
+
];
|
|
412
|
+
}
|