omp-conductor 0.18.0 → 0.18.2
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/README.md +35 -1
- package/REFERENCE.md +61 -11
- package/agents/to-spec.md +94 -0
- package/package.json +2 -1
- package/schema/config.schema.json +35 -1
- package/src/admission.ts +204 -75
- package/src/arm-challenge.ts +250 -57
- package/src/ask.ts +268 -7
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +62 -21
- package/src/briefs/to-spec.md +88 -0
- package/src/briefs/worker.md +2 -1
- package/src/cli.ts +124 -1
- package/src/command-help.ts +11 -0
- package/src/command-manifest.ts +38 -5
- package/src/commands/arm.ts +1 -1
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/intake.ts +4 -19
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +51 -16
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +43 -6
- package/src/config.ts +65 -9
- package/src/daemon.ts +879 -41
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +243 -17
- package/src/diff-flags.ts +75 -1
- package/src/doctor.ts +60 -82
- package/src/escalate.ts +31 -14
- package/src/failure-class.ts +28 -2
- package/src/fleet.ts +239 -240
- package/src/gitops.ts +188 -81
- package/src/graph-health.ts +35 -1
- package/src/graph.ts +66 -1
- package/src/harness-loader.ts +59 -0
- package/src/host.ts +242 -2
- package/src/lifecycle.ts +122 -1
- package/src/omp-settings.ts +19 -0
- package/src/omp.ts +183 -21
- package/src/orchestrator-tick.ts +1591 -32
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/session-host.ts +65 -6
- package/src/settlement.ts +69 -17
- package/src/setup-host.ts +1225 -9
- package/src/setup-install.ts +28 -0
- package/src/setup-wizard.ts +154 -3
- package/src/setup.ts +83 -17
- package/src/shell.ts +15 -0
- package/src/status-render.ts +216 -12
- package/src/store.ts +443 -42
- package/src/to-spec.ts +408 -0
- package/src/tracker/github.ts +104 -14
- package/src/types.ts +405 -19
- package/src/upgrade-verify.ts +209 -2
- package/src/upgrade.ts +175 -1
- package/src/verbs/protocol.ts +39 -0
- package/src/verbs/server.ts +765 -56
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +12 -2
- package/src/worktree.ts +29 -12
package/src/to-spec.ts
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The to-spec grooming contract (#679, #772): the one structured result a
|
|
3
|
+
* groomer agent must answer in before anything it says may become a durable
|
|
4
|
+
* grooming verdict. #735 landed the durable grooming table, but no shipped
|
|
5
|
+
* agent contract, strict result parser, or persistence path. This module
|
|
6
|
+
* ships all three: the prompt lives in `briefs/to-spec.md`, the strict JSON
|
|
7
|
+
* Schema the task agent is invoked through is `TO_SPEC_SCHEMA`, and
|
|
8
|
+
* `recordToSpecGrooming` is the only persistence path — parse and validate
|
|
9
|
+
* first, then write through the existing `Store.upsertGrooming`.
|
|
10
|
+
*
|
|
11
|
+
* The store's verdict surface is `promotable | blocked | considered` (#735).
|
|
12
|
+
* The five contract verdicts map onto it — PROMOTABLE → `promotable`;
|
|
13
|
+
* ALREADY DONE and NEEDS DECOMPOSITION → `considered`; BLOCKED and NEEDS
|
|
14
|
+
* PRODUCT DECISION → `blocked` — and a result that cannot be trusted
|
|
15
|
+
* (malformed JSON, a schema violation, no authoritative source, or a stale
|
|
16
|
+
* source) persists as `blocked` and never as `promotable`/`considered`.
|
|
17
|
+
* Reprocessing the same candidate is deterministic: the same input at the
|
|
18
|
+
* same observation time produces the same row, and malformed output never
|
|
19
|
+
* erases a prior valid (promotable/considered) result. Stale or source-less
|
|
20
|
+
* output does replace it — as blocked, because a judgeable refusal is
|
|
21
|
+
* information: the latest pass could not ground a verdict, so the candidate
|
|
22
|
+
* must not keep reading as groomed.
|
|
23
|
+
*
|
|
24
|
+
* The structured result survives a process/tick restart inside the record's
|
|
25
|
+
* existing `evidence` field — a JSON payload with a `kind` discriminator
|
|
26
|
+
* (`to-spec` | `to-spec-failure`) — so no `GroomingRecord` extension and no
|
|
27
|
+
* second store is needed. Nothing here launches a task, edits an issue,
|
|
28
|
+
* creates decomposition children, or touches the queue label: the
|
|
29
|
+
* orchestrator remains the only promotion authority.
|
|
30
|
+
*
|
|
31
|
+
* The invocation contract is OMP's native task-agent output schema: the
|
|
32
|
+
* later tick passes `outputSchema: TO_SPEC_SCHEMA` with `schemaMode:
|
|
33
|
+
* "strict"` to the task tool. Conductor re-validates whatever the agent
|
|
34
|
+
* returned before persisting, because the harness is permissive by design
|
|
35
|
+
* and the store vets only what this module hands it.
|
|
36
|
+
*
|
|
37
|
+
* One contract, every surface (#883): this Zod schema is the single source of
|
|
38
|
+
* truth. `TO_SPEC_SCHEMA` generated from it stamps the native task launch,
|
|
39
|
+
* `parseToSpecResult` persists against it, and the drift test pins the
|
|
40
|
+
* rendered brief, the shipped agent role, and the floor's return contract to
|
|
41
|
+
* exactly these fields — a field the prose asks for that the schema refuses
|
|
42
|
+
* is a candidate recorded blocked for following instructions.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { z } from "zod";
|
|
46
|
+
|
|
47
|
+
import type { GroomingRecord, GroomingVerdict, Store } from "./types.ts";
|
|
48
|
+
|
|
49
|
+
/** The five verdicts a to-spec groomer may return (#772). */
|
|
50
|
+
export const TO_SPEC_VERDICTS = [
|
|
51
|
+
"ALREADY DONE",
|
|
52
|
+
"PROMOTABLE",
|
|
53
|
+
"NEEDS DECOMPOSITION",
|
|
54
|
+
"BLOCKED",
|
|
55
|
+
"NEEDS PRODUCT DECISION",
|
|
56
|
+
] as const;
|
|
57
|
+
|
|
58
|
+
export type ToSpecVerdict = (typeof TO_SPEC_VERDICTS)[number];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* How old an authoritative source may be before a result drawn from it no
|
|
62
|
+
* longer counts. A verdict computed over a default branch that has moved on
|
|
63
|
+
* may have missed a landing that invalidates it, so a result whose source was
|
|
64
|
+
* observed longer ago than this persists as `blocked(stale-source)` and can
|
|
65
|
+
* never read as promotable/considered. 24 hours: a groomed candidate must
|
|
66
|
+
* have looked at the code within a day, and a re-grooming pass a day later
|
|
67
|
+
* re-reads the live source anyway.
|
|
68
|
+
*/
|
|
69
|
+
export const TO_SPEC_MAX_SOURCE_AGE_MS = 24 * 60 * 60 * 1000;
|
|
70
|
+
|
|
71
|
+
/** `routing: "MULTI"` requires `routingSplit` naming one repo per slice. */
|
|
72
|
+
const MULTI_ROUTING = "MULTI" as const;
|
|
73
|
+
|
|
74
|
+
const ToSpecSourceSchema = z
|
|
75
|
+
.object({
|
|
76
|
+
name: z.string().trim().min(1).describe("The authoritative source that was read: repo or tracker, e.g. `TerrifiedBug/conductor`."),
|
|
77
|
+
ref: z.string().trim().min(1).describe("The exact ref (commit/branch/tag) that was reviewed."),
|
|
78
|
+
freshAt: z
|
|
79
|
+
.number()
|
|
80
|
+
.int()
|
|
81
|
+
.describe("Epoch milliseconds when the source was observed — required so staleness is judgeable without trusting prose."),
|
|
82
|
+
})
|
|
83
|
+
.strict()
|
|
84
|
+
.describe("The authoritative source/ref/freshness every verdict must stand on.");
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The one strict contract a grooming result must satisfy before it may
|
|
88
|
+
* persist as promotable or considered (and everything else maps onto blocked
|
|
89
|
+
* or is refused). `proposedBrief` and `reasonNotToPromote` are exactly one of
|
|
90
|
+
* the two, chosen by verdict: PROMOTABLE carries the brief, every other
|
|
91
|
+
* verdict states why it must not be promoted.
|
|
92
|
+
*/
|
|
93
|
+
const ToSpecResultSchema = z
|
|
94
|
+
.object({
|
|
95
|
+
verdict: z.enum(TO_SPEC_VERDICTS).describe("Exactly one of the five to-spec verdicts."),
|
|
96
|
+
routing: z
|
|
97
|
+
.string()
|
|
98
|
+
.trim()
|
|
99
|
+
.min(1)
|
|
100
|
+
.describe("Exactly one `owner/repo`, or `MULTI` with `routingSplit` naming the split."),
|
|
101
|
+
routingSplit: z
|
|
102
|
+
.record(z.string(), z.string())
|
|
103
|
+
.optional()
|
|
104
|
+
.describe("Required when `routing` is `MULTI`: what each slice goes to."),
|
|
105
|
+
source: ToSpecSourceSchema,
|
|
106
|
+
evidence: z
|
|
107
|
+
.array(z.string().trim().min(1))
|
|
108
|
+
.optional()
|
|
109
|
+
.describe(
|
|
110
|
+
"The files/symbols proving the verdict — required for ALREADY DONE (the code that already does the work, never a title match); welcome on every verdict.",
|
|
111
|
+
),
|
|
112
|
+
laterWorkInvalidates: z
|
|
113
|
+
.boolean()
|
|
114
|
+
.describe("Whether later work (an epic committed after this candidate was filed) invalidated its premise."),
|
|
115
|
+
laterWorkNote: z
|
|
116
|
+
.string()
|
|
117
|
+
.trim()
|
|
118
|
+
.min(1)
|
|
119
|
+
.describe("What was searched for the invalidating-later-work check and what was found — the check's evidence."),
|
|
120
|
+
entryPoints: z
|
|
121
|
+
.array(z.string().trim().min(1))
|
|
122
|
+
.min(3)
|
|
123
|
+
.max(6)
|
|
124
|
+
.describe("3-6 files to change or read first, the discovery a worker budget dies on when absent."),
|
|
125
|
+
existingTests: z
|
|
126
|
+
.array(z.string().trim().min(1))
|
|
127
|
+
.describe("Tests that already exercise the behaviour, by path; empty when none exist."),
|
|
128
|
+
likelySilentFake: z
|
|
129
|
+
.string()
|
|
130
|
+
.trim()
|
|
131
|
+
.min(1)
|
|
132
|
+
.describe("The single thing most likely to be silently faked while implementing and how to prove it is not."),
|
|
133
|
+
proofCommands: z
|
|
134
|
+
.array(z.string().trim().min(1))
|
|
135
|
+
.min(1)
|
|
136
|
+
.describe("Focused commands that prove the work, each with its cwd when relevant."),
|
|
137
|
+
fileLane: z
|
|
138
|
+
.array(z.string().trim().min(1))
|
|
139
|
+
.min(1)
|
|
140
|
+
.describe("The files/dirs this slice writes — the file lane that serialises concurrent work."),
|
|
141
|
+
dependencies: z
|
|
142
|
+
.array(z.union([z.string().trim().min(1), z.number().int().min(1)]))
|
|
143
|
+
.describe(
|
|
144
|
+
'Open prerequisites this work is blocked on, as bare issue numbers (875) or strings ("875"); empty when none.',
|
|
145
|
+
),
|
|
146
|
+
proposedBrief: z
|
|
147
|
+
.string()
|
|
148
|
+
.trim()
|
|
149
|
+
.min(1)
|
|
150
|
+
.optional()
|
|
151
|
+
.describe("Required for PROMOTABLE: the brief a worker would be dispatched with."),
|
|
152
|
+
reasonNotToPromote: z
|
|
153
|
+
.string()
|
|
154
|
+
.trim()
|
|
155
|
+
.min(1)
|
|
156
|
+
.optional()
|
|
157
|
+
.describe("Required for every verdict except PROMOTABLE: why this must not be promoted."),
|
|
158
|
+
})
|
|
159
|
+
.strict()
|
|
160
|
+
.superRefine((value, ctx) => {
|
|
161
|
+
const verdict = value.verdict;
|
|
162
|
+
if (verdict === "PROMOTABLE") {
|
|
163
|
+
if (value.proposedBrief === undefined) {
|
|
164
|
+
ctx.addIssue({ code: "custom", message: "PROMOTABLE requires proposedBrief" });
|
|
165
|
+
}
|
|
166
|
+
if (value.reasonNotToPromote !== undefined) {
|
|
167
|
+
ctx.addIssue({ code: "custom", message: "PROMOTABLE must not carry reasonNotToPromote" });
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
if (value.reasonNotToPromote === undefined) {
|
|
171
|
+
ctx.addIssue({ code: "custom", message: `${verdict} requires reasonNotToPromote` });
|
|
172
|
+
}
|
|
173
|
+
if (value.proposedBrief !== undefined) {
|
|
174
|
+
ctx.addIssue({ code: "custom", message: `${verdict} must not carry proposedBrief` });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (verdict === "ALREADY DONE" && (value.evidence === undefined || value.evidence.length === 0)) {
|
|
178
|
+
ctx.addIssue({
|
|
179
|
+
code: "custom",
|
|
180
|
+
message: "ALREADY DONE requires evidence naming the file/symbol that already does the work",
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (value.routing === MULTI_ROUTING && value.routingSplit === undefined) {
|
|
184
|
+
ctx.addIssue({ code: "custom", message: 'routing "MULTI" requires routingSplit' });
|
|
185
|
+
}
|
|
186
|
+
if (value.routing !== MULTI_ROUTING && value.routingSplit !== undefined) {
|
|
187
|
+
ctx.addIssue({ code: "custom", message: "routingSplit is only valid with routing `MULTI`" });
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
.describe("A complete, to-spec grooming result; nothing outside this shape is accepted.");
|
|
191
|
+
|
|
192
|
+
/** The strict JSON Schema the OMP task tool validates the agent's structured
|
|
193
|
+
* output with (`outputSchema` + `schemaMode: "strict"`, #772). */
|
|
194
|
+
export const TO_SPEC_SCHEMA: object = z.toJSONSchema(ToSpecResultSchema) as object;
|
|
195
|
+
|
|
196
|
+
export type ToSpecResult = z.infer<typeof ToSpecResultSchema>;
|
|
197
|
+
|
|
198
|
+
/** Why a result could not be trusted; each persists as a blocked record. */
|
|
199
|
+
export type ToSpecFailure =
|
|
200
|
+
| { kind: "malformed"; detail: string }
|
|
201
|
+
| { kind: "missing-source"; detail: string }
|
|
202
|
+
| { kind: "stale-source"; detail: string };
|
|
203
|
+
|
|
204
|
+
export type ParseToSpecOutcome = { ok: true; result: ToSpecResult } | { ok: false; failure: ToSpecFailure };
|
|
205
|
+
|
|
206
|
+
/** The row's `evidence` when a result passed validation. */
|
|
207
|
+
export interface ToSpecEvidence {
|
|
208
|
+
kind: "to-spec";
|
|
209
|
+
result: ToSpecResult;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The row's `evidence` when a result was refused. */
|
|
213
|
+
export interface ToSpecFailureEvidence {
|
|
214
|
+
kind: "to-spec-failure";
|
|
215
|
+
failure: ToSpecFailure;
|
|
216
|
+
/** The raw agent output, bounded, so a refusal is diagnosable. */
|
|
217
|
+
input: string;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** How much raw agent output a failure record may carry. */
|
|
221
|
+
export const TO_SPEC_INPUT_SAMPLE_MAX = 2_000;
|
|
222
|
+
|
|
223
|
+
/** Refused results always land on the same store surface, named by failure. */
|
|
224
|
+
const FAILURE_REASON: Record<ToSpecFailure["kind"], string> = {
|
|
225
|
+
malformed: "malformed",
|
|
226
|
+
"missing-source": "missing-source",
|
|
227
|
+
"stale-source": "stale-source",
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/** The five contract verdicts onto the store's three (#735). */
|
|
231
|
+
function mapStoreVerdict(verdict: ToSpecVerdict): GroomingVerdict {
|
|
232
|
+
switch (verdict) {
|
|
233
|
+
case "PROMOTABLE":
|
|
234
|
+
return "promotable";
|
|
235
|
+
case "ALREADY DONE":
|
|
236
|
+
case "NEEDS DECOMPOSITION":
|
|
237
|
+
return "considered";
|
|
238
|
+
case "BLOCKED":
|
|
239
|
+
case "NEEDS PRODUCT DECISION":
|
|
240
|
+
return "blocked";
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The groomer's own label the record carries (the #679 half of `reason`). */
|
|
245
|
+
function mapStoreReason(verdict: ToSpecVerdict): string {
|
|
246
|
+
switch (verdict) {
|
|
247
|
+
case "ALREADY DONE":
|
|
248
|
+
return "already-done";
|
|
249
|
+
case "PROMOTABLE":
|
|
250
|
+
return "promotable";
|
|
251
|
+
case "NEEDS DECOMPOSITION":
|
|
252
|
+
return "needs-decomposition";
|
|
253
|
+
case "BLOCKED":
|
|
254
|
+
return "blocked";
|
|
255
|
+
case "NEEDS PRODUCT DECISION":
|
|
256
|
+
return "needs-product-decision";
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** True for a JSON object value (null and arrays excluded). */
|
|
261
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
262
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Pulls one fenced JSON block out of an agent answer, exactly like the setup
|
|
267
|
+
* probes: a model's text, parsed as JSON. Anything unparseable is malformed —
|
|
268
|
+
* no seed at all, and the candidate persists as blocked rather than groomed.
|
|
269
|
+
*/
|
|
270
|
+
function extractJson(input: string): string {
|
|
271
|
+
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(input);
|
|
272
|
+
return (fenced?.[1] ?? input).trim();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Parse and validate a groomer's raw output against the to-spec contract.
|
|
277
|
+
* `now` is explicit so staleness and determinism are testable: the same
|
|
278
|
+
* input at the same observation time always yields the same outcome. On
|
|
279
|
+
* failure the outcome says *why* in one of the three refusal classes:
|
|
280
|
+
* unparseable/schema-breaking output is `malformed`, output that never names
|
|
281
|
+
* an authoritative source (or lacks name/ref/freshAt) is `missing-source`,
|
|
282
|
+
* and a source observed more than `TO_SPEC_MAX_SOURCE_AGE_MS` ago is
|
|
283
|
+
* `stale-source`. A `freshAt` in the future is a clock lie, hence malformed.
|
|
284
|
+
*/
|
|
285
|
+
export function parseToSpecResult(input: string, now: number): ParseToSpecOutcome {
|
|
286
|
+
const body = extractJson(input);
|
|
287
|
+
if (body.length === 0) {
|
|
288
|
+
return { ok: false, failure: { kind: "malformed", detail: "empty answer — no JSON found" } };
|
|
289
|
+
}
|
|
290
|
+
let raw: unknown;
|
|
291
|
+
try {
|
|
292
|
+
raw = JSON.parse(body);
|
|
293
|
+
} catch {
|
|
294
|
+
return { ok: false, failure: { kind: "malformed", detail: "output is not valid JSON" } };
|
|
295
|
+
}
|
|
296
|
+
if (!isObject(raw)) {
|
|
297
|
+
return { ok: false, failure: { kind: "malformed", detail: "output is not a JSON object" } };
|
|
298
|
+
}
|
|
299
|
+
const parsed = ToSpecResultSchema.safeParse(raw);
|
|
300
|
+
if (!parsed.success) {
|
|
301
|
+
const detail = parsed.error.issues[0]?.message ?? "schema violation";
|
|
302
|
+
const source = raw.source;
|
|
303
|
+
if (!isObject(source) || source.name === undefined || source.ref === undefined || source.freshAt === undefined) {
|
|
304
|
+
return { ok: false, failure: { kind: "missing-source", detail: `no authoritative source — ${detail}` } };
|
|
305
|
+
}
|
|
306
|
+
return { ok: false, failure: { kind: "malformed", detail } };
|
|
307
|
+
}
|
|
308
|
+
const freshAt = parsed.data.source.freshAt;
|
|
309
|
+
if (freshAt > now) {
|
|
310
|
+
return { ok: false, failure: { kind: "malformed", detail: `source freshAt ${freshAt} lies in the future of ${now}` } };
|
|
311
|
+
}
|
|
312
|
+
if (now - freshAt > TO_SPEC_MAX_SOURCE_AGE_MS) {
|
|
313
|
+
return {
|
|
314
|
+
ok: false,
|
|
315
|
+
failure: {
|
|
316
|
+
kind: "stale-source",
|
|
317
|
+
detail: `source observed at ${freshAt} is ${now - freshAt}ms old — older than the ${TO_SPEC_MAX_SOURCE_AGE_MS}ms ceiling`,
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
return { ok: true, result: parsed.data };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** What one grooming pass did to the row, so the caller can report it. */
|
|
325
|
+
export type ToSpecGroomingOutcome =
|
|
326
|
+
| { kind: "persisted"; record: GroomingRecord }
|
|
327
|
+
| { kind: "kept-prior"; record: GroomingRecord };
|
|
328
|
+
|
|
329
|
+
/** One candidate's grooming pass. */
|
|
330
|
+
export interface ToSpecGroomingRequest {
|
|
331
|
+
project: string;
|
|
332
|
+
issue: number;
|
|
333
|
+
/** The agent's raw output, exactly as returned. */
|
|
334
|
+
input: string;
|
|
335
|
+
/** Observation time for staleness and the recorded row; `Date.now()` when
|
|
336
|
+
* omitted (tests pass it to keep reprocessing deterministic). */
|
|
337
|
+
now?: number;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function failureEvidence(failure: ToSpecFailure, input: string): string {
|
|
341
|
+
const sample =
|
|
342
|
+
input.length > TO_SPEC_INPUT_SAMPLE_MAX ? `${input.slice(0, TO_SPEC_INPUT_SAMPLE_MAX)}…[truncated]` : input;
|
|
343
|
+
const payload: ToSpecFailureEvidence = { kind: "to-spec-failure", failure, input: sample };
|
|
344
|
+
return JSON.stringify(payload);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The only way a groomer's output becomes a durable grooming verdict: parse
|
|
349
|
+
* and validate, then upsert through the existing store — no second store, no
|
|
350
|
+
* `GroomingRecord` extension. Valid results map to the store's verdict
|
|
351
|
+
* surface with the full structured result serialized into `evidence`
|
|
352
|
+
* (`{"kind":"to-spec","result":…}`), which is what survives a restart. A
|
|
353
|
+
* refused result persists as `blocked` with the failure named in
|
|
354
|
+
* `evidence` (`to-spec-failure`) — never as promotable/considered — except
|
|
355
|
+
* when a prior valid result exists: malformed reprocessing then keeps that
|
|
356
|
+
* prior row instead of erasing it. Nothing here touches labels or issues.
|
|
357
|
+
*/
|
|
358
|
+
export function recordToSpecGrooming(store: Store, request: ToSpecGroomingRequest): ToSpecGroomingOutcome {
|
|
359
|
+
const now = request.now ?? Date.now();
|
|
360
|
+
const parsed = parseToSpecResult(request.input, now);
|
|
361
|
+
if (parsed.ok) {
|
|
362
|
+
const evidence: ToSpecEvidence = { kind: "to-spec", result: parsed.result };
|
|
363
|
+
store.upsertGrooming({
|
|
364
|
+
project: request.project,
|
|
365
|
+
issue: request.issue,
|
|
366
|
+
verdict: mapStoreVerdict(parsed.result.verdict),
|
|
367
|
+
reason: mapStoreReason(parsed.result.verdict),
|
|
368
|
+
evidence: JSON.stringify(evidence),
|
|
369
|
+
at: now,
|
|
370
|
+
});
|
|
371
|
+
return { kind: "persisted", record: store.grooming(request.project, request.issue)! };
|
|
372
|
+
}
|
|
373
|
+
const prior = store.grooming(request.project, request.issue);
|
|
374
|
+
if (
|
|
375
|
+
parsed.failure.kind === "malformed" &&
|
|
376
|
+
prior !== undefined &&
|
|
377
|
+
(prior.verdict === "promotable" || prior.verdict === "considered")
|
|
378
|
+
) {
|
|
379
|
+
return { kind: "kept-prior", record: prior };
|
|
380
|
+
}
|
|
381
|
+
store.upsertGrooming({
|
|
382
|
+
project: request.project,
|
|
383
|
+
issue: request.issue,
|
|
384
|
+
verdict: "blocked",
|
|
385
|
+
reason: FAILURE_REASON[parsed.failure.kind],
|
|
386
|
+
evidence: failureEvidence(parsed.failure, request.input),
|
|
387
|
+
at: now,
|
|
388
|
+
});
|
|
389
|
+
return { kind: "persisted", record: store.grooming(request.project, request.issue)! };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Recover the validated result from a record's evidence (the restart
|
|
394
|
+
* round-trip). Returns undefined for anything that is not a `to-spec`
|
|
395
|
+
* payload or fails validation again, so a hand-edited row cannot read back
|
|
396
|
+
* as groomed.
|
|
397
|
+
*/
|
|
398
|
+
export function parseToSpecEvidence(evidence: string): ToSpecResult | undefined {
|
|
399
|
+
let raw: unknown;
|
|
400
|
+
try {
|
|
401
|
+
raw = JSON.parse(evidence);
|
|
402
|
+
} catch {
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
if (!isObject(raw) || raw.kind !== "to-spec") return undefined;
|
|
406
|
+
const parsed = ToSpecResultSchema.safeParse(raw.result);
|
|
407
|
+
return parsed.success ? parsed.data : undefined;
|
|
408
|
+
}
|
package/src/tracker/github.ts
CHANGED
|
@@ -132,6 +132,30 @@ export class GhError extends Error {
|
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* The one classified failure {@link Tracker.prState} throws (#779): the named
|
|
137
|
+
* pull request definitively does not exist. It is thrown only for an
|
|
138
|
+
* individual REST 404 that a same-repository pulls-list read has corroborated
|
|
139
|
+
* (see {@link repoPullReadUnavailable}) — a deleted or never-created PR number
|
|
140
|
+
* in a repository the credential can still read. A repository hidden from the
|
|
141
|
+
* credential (private, lost token scope, SSO enforcement) also answers 404,
|
|
142
|
+
* so without that corroboration a 404 stays "could not tell".
|
|
143
|
+
*
|
|
144
|
+
* Distinct from a plain {@link GhError} because a corroborated 404 is an
|
|
145
|
+
* answer, not an outage: retrying can never conjure the PR, so the settle
|
|
146
|
+
* sweep releases the stranded row — over its preserved branch — instead of
|
|
147
|
+
* re-asking a nonexistent PR forever. Every other failure (a revoked token, a
|
|
148
|
+
* flaky network, a server 5xx, an opaque 404) still resolves to `undefined`
|
|
149
|
+
* from `prState`, and the port's "could not tell, ask again for free" callers
|
|
150
|
+
* never see it.
|
|
151
|
+
*/
|
|
152
|
+
export class GhPrMissingError extends GhError {
|
|
153
|
+
constructor(cause: GhError) {
|
|
154
|
+
super(cause.argv, cause.code, cause.stderr, cause.stdout);
|
|
155
|
+
this.name = "GhPrMissingError";
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
135
159
|
/**
|
|
136
160
|
* The single execution path for this adapter. `stdin` is written to the child
|
|
137
161
|
* and closed, which is how bodies containing newlines, backticks or a leading
|
|
@@ -1308,6 +1332,20 @@ export function isTransientServerError(err: unknown): boolean {
|
|
|
1308
1332
|
return err instanceof GhError && isTransientServer5xx(err.stderr);
|
|
1309
1333
|
}
|
|
1310
1334
|
|
|
1335
|
+
/**
|
|
1336
|
+
* True when a `gh` failure is the REST layer answering 404 for the requested
|
|
1337
|
+
* resource. The raw status is deliberately not the definitive verdict: GitHub
|
|
1338
|
+
* returns the same 404 for a PR number absent from a readable repository and
|
|
1339
|
+
* for a whole repository hidden from the credential, so the caller must
|
|
1340
|
+
* corroborate against a same-repository pull-read (see
|
|
1341
|
+
* {@link repoPullReadUnavailable}) before a 404 means "the PR is gone". What a
|
|
1342
|
+
* 404 does always mean is that it is not a 5xx ("ask later") and not a 401/403
|
|
1343
|
+
* credential refusal. gh's stderr renders the status as `HTTP 404: Not Found`.
|
|
1344
|
+
*/
|
|
1345
|
+
export function isMissingPr404(err: unknown): err is GhError {
|
|
1346
|
+
return err instanceof GhError && /HTTP 404\b/i.test(err.stderr);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1311
1349
|
/**
|
|
1312
1350
|
* True when a `gh` argv rides the GraphQL API surface rather than core REST.
|
|
1313
1351
|
*
|
|
@@ -1403,6 +1441,35 @@ export class GhRateLimitError extends GhError {
|
|
|
1403
1441
|
* rate-limit refusal opens and the GraphQL-only gate a 5xx opens. */
|
|
1404
1442
|
export const RATE_LIMIT_COOLDOWN_MS = 60_000;
|
|
1405
1443
|
|
|
1444
|
+
/**
|
|
1445
|
+
* The control that makes an individual PR read's 404 definitive (#781 review
|
|
1446
|
+
* round 1): whether the repository named by `parts` answers a Pull Requests
|
|
1447
|
+
* list read to the current credential.
|
|
1448
|
+
*
|
|
1449
|
+
* GitHub returns the same 404 bytes in two opposite situations — a PR number
|
|
1450
|
+
* that does not exist in a repository this credential can read, and a whole
|
|
1451
|
+
* repository hidden from the credential (private, lost token scope, SSO
|
|
1452
|
+
* enforcement) — so a bare 404 on the individual read says nothing. Asking
|
|
1453
|
+
* the same-repository pulls surface splits them: a 200, even `[]`, proves
|
|
1454
|
+
* pull-read access and makes the individual 404 a genuinely missing PR; a
|
|
1455
|
+
* control that is itself unavailable leaves the question open and the caller
|
|
1456
|
+
* must keep its row retryable. Returns true when pull-read access cannot be
|
|
1457
|
+
* proven.
|
|
1458
|
+
*/
|
|
1459
|
+
async function repoPullReadUnavailable(
|
|
1460
|
+
runGh: typeof gh,
|
|
1461
|
+
parts: { owner: string; repo: string },
|
|
1462
|
+
): Promise<boolean> {
|
|
1463
|
+
// `--jq length` bounds the payload to one integer while still exercising the
|
|
1464
|
+
// exact pull-read surface; `gh api` returns the first page only by default.
|
|
1465
|
+
try {
|
|
1466
|
+
await runGh(["api", `repos/${parts.owner}/${parts.repo}/pulls`, "--jq", "length"]);
|
|
1467
|
+
return false;
|
|
1468
|
+
} catch {
|
|
1469
|
+
return true;
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1406
1473
|
/** Instrumentation hooks the daemon binds the tracker to (#198). */
|
|
1407
1474
|
export interface TrackerHooks {
|
|
1408
1475
|
/** Fired immediately before each `gh` spawn, so the daemon can count its own
|
|
@@ -1734,12 +1801,41 @@ export function makeTracker(
|
|
|
1734
1801
|
"{state, merged_at}",
|
|
1735
1802
|
]),
|
|
1736
1803
|
);
|
|
1804
|
+
} catch (err) {
|
|
1805
|
+
// One classified throw, and only one: an individual REST 404 that the
|
|
1806
|
+
// same-repository pulls read corroborates. 404 alone is ambiguous —
|
|
1807
|
+
// GitHub hides a whole repository the credential cannot read as the
|
|
1808
|
+
// same 404, so a lost token scope or SSO change would otherwise
|
|
1809
|
+
// terminalize every real run (#781 review round 1). Only a PR number
|
|
1810
|
+
// that is genuinely gone while the repo still answers pull-read access
|
|
1811
|
+
// is definitively missing. Everything else — a hidden/private repo, a
|
|
1812
|
+
// revoked token, a flaky network, a server 5xx — stays undefined per
|
|
1813
|
+
// the port's fail-closed contract, and the row retries for free.
|
|
1814
|
+
if (isMissingPr404(err) && !(await repoPullReadUnavailable(runGh, parts))) {
|
|
1815
|
+
throw new GhPrMissingError(err);
|
|
1816
|
+
}
|
|
1817
|
+
return undefined;
|
|
1818
|
+
}
|
|
1819
|
+
},
|
|
1820
|
+
|
|
1821
|
+
async prHead(url: string): Promise<string | undefined> {
|
|
1822
|
+
if (!PR_URL.test(url)) return undefined;
|
|
1823
|
+
const parts = prUrlParts(url);
|
|
1824
|
+
if (parts === undefined) return undefined;
|
|
1825
|
+
try {
|
|
1826
|
+
// The REST pull-request projection carries the head at `.head.sha` —
|
|
1827
|
+
// there is no top-level `head_sha` (verified live on #812). The jq
|
|
1828
|
+
// renames it, the same idiom `verifyPrRest` uses, so the shared
|
|
1829
|
+
// `restPrHeadFrom` parser sees the shape it is tested against.
|
|
1830
|
+
return restPrHeadFrom(
|
|
1831
|
+
await runGh([
|
|
1832
|
+
"api",
|
|
1833
|
+
`repos/${parts.owner}/${parts.repo}/pulls/${parts.number}`,
|
|
1834
|
+
"--jq",
|
|
1835
|
+
"{head_sha: .head.sha}",
|
|
1836
|
+
]),
|
|
1837
|
+
);
|
|
1737
1838
|
} catch {
|
|
1738
|
-
// Never throws, per the port's contract. A deleted PR, a revoked token
|
|
1739
|
-
// and a flaky network all mean "could not tell", and the caller's whole
|
|
1740
|
-
// job is to leave the row alone on that — so classifying them here would
|
|
1741
|
-
// buy nothing but a way to get the classification wrong. The next tick
|
|
1742
|
-
// asks again for free.
|
|
1743
1839
|
return undefined;
|
|
1744
1840
|
}
|
|
1745
1841
|
},
|
|
@@ -2076,15 +2172,9 @@ export function makeTracker(
|
|
|
2076
2172
|
},
|
|
2077
2173
|
|
|
2078
2174
|
async childrenOf(issue: number): Promise<{ number: number; state: IssueState }[]> {
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
);
|
|
2083
|
-
} catch {
|
|
2084
|
-
// Also the answer on a repo without the sub-issues API, or an issue that
|
|
2085
|
-
// was never decomposed. All three mean the same thing to the caller.
|
|
2086
|
-
return [];
|
|
2087
|
-
}
|
|
2175
|
+
return labeledIssuesFrom(
|
|
2176
|
+
await runGh(["api", `repos/${repo}/issues/${issue}/sub_issues`, "--jq", "[.[] | {number, state}]"]),
|
|
2177
|
+
);
|
|
2088
2178
|
},
|
|
2089
2179
|
};
|
|
2090
2180
|
|