omp-conductor 0.19.7 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/REFERENCE.md +10 -1
- package/agents/to-spec.md +76 -9
- package/package.json +1 -1
- package/schema/config.schema.json +4 -0
- package/src/admission.ts +58 -14
- package/src/arm-challenge.ts +255 -85
- package/src/ask.ts +130 -615
- package/src/board.ts +7 -1
- package/src/brief-upgrade.ts +24 -0
- package/src/briefs/console.md +258 -0
- package/src/briefs/correction.md +203 -0
- package/src/briefs/orchestrator.md +167 -97
- package/src/briefs/policy.md +19 -16
- package/src/briefs/to-spec.md +76 -9
- package/src/briefs/worker.md +50 -16
- package/src/cli.ts +4 -0
- package/src/command-manifest.ts +54 -8
- package/src/commands/arm.ts +115 -49
- package/src/commands/console.ts +70 -0
- package/src/commands/context.ts +2 -0
- package/src/commands/epic.ts +132 -0
- package/src/commands/extend.ts +9 -1
- package/src/commands/intake.ts +44 -14
- package/src/commands/stats.ts +19 -4
- package/src/commands/worker.ts +9 -1
- package/src/config-schema.ts +13 -0
- package/src/config.ts +27 -0
- package/src/daemon/ack.ts +159 -0
- package/src/daemon/admission-pass.ts +135 -0
- package/src/daemon/brief.ts +461 -0
- package/src/daemon/deps.ts +539 -0
- package/src/daemon/dispatch.ts +1779 -0
- package/src/daemon/drain.ts +185 -0
- package/src/daemon/groom-pass.ts +422 -0
- package/src/daemon/http.ts +417 -0
- package/src/daemon/integrity.ts +108 -0
- package/src/daemon/panes.ts +180 -0
- package/src/daemon/review.ts +1888 -0
- package/src/daemon/runtime.ts +788 -0
- package/src/daemon/settle-pass.ts +606 -0
- package/src/daemon/supervision.ts +438 -0
- package/src/daemon/tick.ts +968 -0
- package/src/daemon/views.ts +751 -0
- package/src/daemon.ts +105 -7923
- package/src/dashboard/app.js +58 -0
- package/src/dashboard/controls.ts +22 -3
- package/src/dashboard/server.ts +4 -0
- package/src/diff-flags.ts +135 -9
- package/src/doctor.ts +2 -2
- package/src/failure-class.ts +257 -2
- package/src/fleet.ts +295 -176
- package/src/groom.ts +461 -0
- package/src/http-token.ts +142 -0
- package/src/knowledge.ts +229 -0
- package/src/mining.ts +316 -0
- package/src/orchestrator-tick.ts +689 -1670
- package/src/ready-gate.ts +267 -0
- package/src/settlement.ts +107 -11
- package/src/setup-host.ts +32 -9
- package/src/setup-wizard.ts +55 -7
- package/src/setup.ts +229 -3
- package/src/stats.ts +257 -2
- package/src/status-render.ts +169 -14
- package/src/store.ts +618 -28
- package/src/to-spec.ts +426 -44
- package/src/tracker/github.ts +50 -0
- package/src/types.ts +434 -18
- package/src/verbs/protocol.ts +28 -0
- package/src/verbs/server.ts +330 -39
- package/src/wake.ts +19 -2
- package/src/worker.ts +570 -1
package/src/to-spec.ts
CHANGED
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
* The five contract verdicts map onto it — PROMOTABLE → `promotable`;
|
|
13
13
|
* ALREADY DONE and NEEDS DECOMPOSITION → `considered`; BLOCKED and NEEDS
|
|
14
14
|
* PRODUCT DECISION → `blocked` — and a result that cannot be trusted
|
|
15
|
-
* (malformed JSON, a schema violation, no authoritative
|
|
16
|
-
* source) persists as `blocked` and never as
|
|
15
|
+
* (no answer at all, malformed JSON, a schema violation, no authoritative
|
|
16
|
+
* source, or a stale source) persists as `blocked` and never as
|
|
17
|
+
* `promotable`/`considered`.
|
|
17
18
|
* Reprocessing the same candidate is deterministic: the same input at the
|
|
18
19
|
* same observation time produces the same row, and malformed output never
|
|
19
20
|
* erases a prior valid (promotable/considered) result. Stale or source-less
|
|
@@ -34,6 +35,28 @@
|
|
|
34
35
|
* returned before persisting, because the harness is permissive by design
|
|
35
36
|
* and the store vets only what this module hands it.
|
|
36
37
|
*
|
|
38
|
+
* Who stamps the observation time (#1000): the dispatcher, never the agent.
|
|
39
|
+
* A to-spec groomer runs with reading tools only and therefore has no clock,
|
|
40
|
+
* so the `source.freshAt` it used to be asked for was always a guess — and a
|
|
41
|
+
* guess landing ahead of the daemon's clock discarded the whole verdict as a
|
|
42
|
+
* clock lie, twice in one afternoon on verdicts that were otherwise correct.
|
|
43
|
+
* The schema still accepts the key (it is `.strict()`, so refusing an older
|
|
44
|
+
* prompt's output would recreate exactly that discard) but ignores its value:
|
|
45
|
+
* `parseToSpecResult` stamps the batch's launch time — or the settle time
|
|
46
|
+
* when no launch time is known — and that stamp is what persists.
|
|
47
|
+
*
|
|
48
|
+
* Sizing is evidence, not a feeling (#1041): a `PROMOTABLE` verdict carries
|
|
49
|
+
* `sizingEvidence`, and a `NEEDS DECOMPOSITION` verdict carries an ordered
|
|
50
|
+
* `decomposition`. #1035 was persisted `PROMOTABLE` with nine acceptance
|
|
51
|
+
* criteria across four lifecycle files totalling 28,812 lines; attempt 1
|
|
52
|
+
* exhausted 181 of its 180 turns. The verdict had asserted one-budget fit —
|
|
53
|
+
* what was missing was anything that could falsify the assertion. So the
|
|
54
|
+
* contract now asks for the falsifier itself (distinct behaviours, the
|
|
55
|
+
* production modules and test surfaces each needs, shared-file sequencing)
|
|
56
|
+
* and there is deliberately no numeric threshold anywhere: a line or
|
|
57
|
+
* criterion count that decided verdicts would be gamed by splitting prose,
|
|
58
|
+
* and the judgement is the groomer's.
|
|
59
|
+
*
|
|
37
60
|
* One contract, every surface (#883): this Zod schema is the single source of
|
|
38
61
|
* truth. `TO_SPEC_SCHEMA` generated from it stamps the native task launch,
|
|
39
62
|
* `parseToSpecResult` persists against it, and the drift test pins the
|
|
@@ -65,6 +88,11 @@ export type ToSpecVerdict = (typeof TO_SPEC_VERDICTS)[number];
|
|
|
65
88
|
* never read as promotable/considered. 24 hours: a groomed candidate must
|
|
66
89
|
* have looked at the code within a day, and a re-grooming pass a day later
|
|
67
90
|
* re-reads the live source anyway.
|
|
91
|
+
*
|
|
92
|
+
* Since #1000 the observation time is the dispatcher's own stamp, so this
|
|
93
|
+
* ceiling can only fire for a batch that genuinely ran longer than a day —
|
|
94
|
+
* a real signal about the batch rather than a verdict on the agent's
|
|
95
|
+
* arithmetic.
|
|
68
96
|
*/
|
|
69
97
|
export const TO_SPEC_MAX_SOURCE_AGE_MS = 24 * 60 * 60 * 1000;
|
|
70
98
|
|
|
@@ -78,10 +106,47 @@ const ToSpecSourceSchema = z
|
|
|
78
106
|
freshAt: z
|
|
79
107
|
.number()
|
|
80
108
|
.int()
|
|
81
|
-
.
|
|
109
|
+
.optional()
|
|
110
|
+
.describe(
|
|
111
|
+
"Ignored — do not send it. Conductor stamps the observation time itself from the batch window (#1000); a groomer has reading tools only and no clock, so any value here is a guess. The key is still accepted so an older prompt's output is never refused for sending one.",
|
|
112
|
+
),
|
|
82
113
|
})
|
|
83
114
|
.strict()
|
|
84
|
-
.describe("The authoritative source
|
|
115
|
+
.describe("The authoritative source and ref every verdict must stand on; conductor stamps its freshness (#1000).");
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* One ordered child of a decomposition proposal (#1041). A `NEEDS
|
|
119
|
+
* DECOMPOSITION` verdict used to be free to say "split this up" in prose,
|
|
120
|
+
* which is a verdict nobody can act on: the operator still has to do the
|
|
121
|
+
* decomposition. Each child therefore carries exactly what filing it as an
|
|
122
|
+
* issue needs — a title, the exact write lane it owns, what it waits on, the
|
|
123
|
+
* silent fake it invites, and the commands that prove it — so the proposal is
|
|
124
|
+
* a filing instruction rather than an opinion.
|
|
125
|
+
*/
|
|
126
|
+
const ToSpecDecompositionChildSchema = z
|
|
127
|
+
.object({
|
|
128
|
+
title: z.string().trim().min(1).describe("The child issue's title — one slice, stated as the behaviour it lands."),
|
|
129
|
+
writeLane: z
|
|
130
|
+
.array(z.string().trim().min(1))
|
|
131
|
+
.min(1)
|
|
132
|
+
.describe("The files/dirs this child alone writes — its `## Exact write lane`."),
|
|
133
|
+
dependsOn: z
|
|
134
|
+
.array(z.union([z.string().trim().min(1), z.number().int().min(1)]))
|
|
135
|
+
.describe(
|
|
136
|
+
"What this child waits on: an earlier child's title, or an existing issue number. Children sharing a file in their write lanes must be serialised here — two concurrent slices writing one core module is the collision the file lane exists to prevent. `[]` for the first child.",
|
|
137
|
+
),
|
|
138
|
+
likelySilentFake: z
|
|
139
|
+
.string()
|
|
140
|
+
.trim()
|
|
141
|
+
.min(1)
|
|
142
|
+
.describe("The one thing most likely to be silently faked in this child, and how to prove it is not."),
|
|
143
|
+
proofCommands: z
|
|
144
|
+
.array(z.string().trim().min(1))
|
|
145
|
+
.min(1)
|
|
146
|
+
.describe("The focused commands that prove this child, each with its cwd when it matters."),
|
|
147
|
+
})
|
|
148
|
+
.strict()
|
|
149
|
+
.describe("One ordered child slice a decomposition proposal names (#1041).");
|
|
85
150
|
|
|
86
151
|
/**
|
|
87
152
|
* The one strict contract a grooming result must satisfy before it may
|
|
@@ -143,6 +208,21 @@ const ToSpecResultSchema = z
|
|
|
143
208
|
.describe(
|
|
144
209
|
'Open prerequisites this work is blocked on, as bare issue numbers (875) or strings ("875"); empty when none.',
|
|
145
210
|
),
|
|
211
|
+
sizingEvidence: z
|
|
212
|
+
.string()
|
|
213
|
+
.trim()
|
|
214
|
+
.min(1)
|
|
215
|
+
.optional()
|
|
216
|
+
.describe(
|
|
217
|
+
"Required for PROMOTABLE: the source-backed one-budget analysis — the distinct behaviours/state transitions this slice introduces, the production modules and focused test surfaces each of them needs, the shared-file sequencing between them, and why that fits one configured worker attempt. A small file count is not this evidence when the files are high-fanout lifecycle modules with large integration suites (#1041).",
|
|
218
|
+
),
|
|
219
|
+
decomposition: z
|
|
220
|
+
.array(ToSpecDecompositionChildSchema)
|
|
221
|
+
.min(2)
|
|
222
|
+
.optional()
|
|
223
|
+
.describe(
|
|
224
|
+
"Required for NEEDS DECOMPOSITION and accepted on no other verdict: the ordered children this candidate splits into, first to last, each naming its title, write lane, dependencies, silent fake and proof commands. Children sharing a write-lane path must serialise through `dependsOn` (#1041).",
|
|
225
|
+
),
|
|
146
226
|
proposedBrief: z
|
|
147
227
|
.string()
|
|
148
228
|
.trim()
|
|
@@ -166,6 +246,15 @@ const ToSpecResultSchema = z
|
|
|
166
246
|
if (value.reasonNotToPromote !== undefined) {
|
|
167
247
|
ctx.addIssue({ code: "custom", message: "PROMOTABLE must not carry reasonNotToPromote" });
|
|
168
248
|
}
|
|
249
|
+
// #1041: the one-budget claim is only a claim until something could
|
|
250
|
+
// falsify it. #1035 asserted it and burned 181 of 180 turns.
|
|
251
|
+
if (value.sizingEvidence === undefined) {
|
|
252
|
+
ctx.addIssue({
|
|
253
|
+
code: "custom",
|
|
254
|
+
message:
|
|
255
|
+
"PROMOTABLE requires sizingEvidence: the distinct behaviours, their production modules and test surfaces, the shared-file sequencing, and why that fits one worker attempt",
|
|
256
|
+
});
|
|
257
|
+
}
|
|
169
258
|
} else {
|
|
170
259
|
if (value.reasonNotToPromote === undefined) {
|
|
171
260
|
ctx.addIssue({ code: "custom", message: `${verdict} requires reasonNotToPromote` });
|
|
@@ -180,6 +269,40 @@ const ToSpecResultSchema = z
|
|
|
180
269
|
message: "ALREADY DONE requires evidence naming the file/symbol that already does the work",
|
|
181
270
|
});
|
|
182
271
|
}
|
|
272
|
+
// A decomposition proposal is a filing instruction or it is nothing
|
|
273
|
+
// (#1041): NEEDS DECOMPOSITION owes ordered children, and every other
|
|
274
|
+
// verdict owes none — children beside PROMOTABLE would mean the verdict
|
|
275
|
+
// contradicts itself.
|
|
276
|
+
if (verdict === "NEEDS DECOMPOSITION") {
|
|
277
|
+
if (value.decomposition === undefined) {
|
|
278
|
+
ctx.addIssue({
|
|
279
|
+
code: "custom",
|
|
280
|
+
message:
|
|
281
|
+
"NEEDS DECOMPOSITION requires decomposition: the ordered children, each with its title, write lane, dependsOn, likelySilentFake and proofCommands",
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
} else if (value.decomposition !== undefined) {
|
|
285
|
+
ctx.addIssue({ code: "custom", message: `decomposition is only valid with verdict "NEEDS DECOMPOSITION", not ${verdict}` });
|
|
286
|
+
}
|
|
287
|
+
// Two children writing one core module concurrently is the collision the
|
|
288
|
+
// file lane exists to prevent, so the later of any file-sharing pair must
|
|
289
|
+
// name what it waits on. Order is the array's order — child n may only
|
|
290
|
+
// depend on something before it.
|
|
291
|
+
const children = value.decomposition ?? [];
|
|
292
|
+
for (let i = 1; i < children.length; i++) {
|
|
293
|
+
const child = children[i]!;
|
|
294
|
+
if (child.dependsOn.length > 0) continue;
|
|
295
|
+
const own = new Set(child.writeLane.map((path) => path.trim()));
|
|
296
|
+
for (let j = 0; j < i; j++) {
|
|
297
|
+
const shared = children[j]!.writeLane.map((path) => path.trim()).find((path) => own.has(path));
|
|
298
|
+
if (shared === undefined) continue;
|
|
299
|
+
ctx.addIssue({
|
|
300
|
+
code: "custom",
|
|
301
|
+
message: `decomposition child ${i + 1} shares ${shared} with child ${j + 1} and must serialise on it through dependsOn`,
|
|
302
|
+
});
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
183
306
|
if (value.routing === MULTI_ROUTING && value.routingSplit === undefined) {
|
|
184
307
|
ctx.addIssue({ code: "custom", message: 'routing "MULTI" requires routingSplit' });
|
|
185
308
|
}
|
|
@@ -193,11 +316,27 @@ const ToSpecResultSchema = z
|
|
|
193
316
|
* output with (`outputSchema` + `schemaMode: "strict"`, #772). */
|
|
194
317
|
export const TO_SPEC_SCHEMA: object = z.toJSONSchema(ToSpecResultSchema) as object;
|
|
195
318
|
|
|
196
|
-
|
|
319
|
+
/**
|
|
320
|
+
* The validated result every reader gets. `source.freshAt` is a required
|
|
321
|
+
* number here even though the schema no longer asks the agent for one:
|
|
322
|
+
* {@link parseToSpecResult} stamps the dispatcher's own observation time onto
|
|
323
|
+
* every result it returns (#1000), so a persisted verdict always carries a
|
|
324
|
+
* freshness witness — just never the agent's guess.
|
|
325
|
+
*/
|
|
326
|
+
export type ToSpecResult = Omit<z.infer<typeof ToSpecResultSchema>, "source"> & {
|
|
327
|
+
source: { name: string; ref: string; freshAt: number };
|
|
328
|
+
};
|
|
197
329
|
|
|
198
|
-
/** Why a result could not be trusted; each persists as a blocked record.
|
|
330
|
+
/** Why a result could not be trusted; each persists as a blocked record.
|
|
331
|
+
*
|
|
332
|
+
* `no-answer` is the session-level refusal (#1064): the pass produced no
|
|
333
|
+
* payload at all — the scout was killed at its turn ceiling, or the session
|
|
334
|
+
* ended without ever yielding. It is a property of the session, never of
|
|
335
|
+
* output, so it is decided by the pass before anything is parsed and is never
|
|
336
|
+
* `malformed`. */
|
|
199
337
|
export type ToSpecFailure =
|
|
200
338
|
| { kind: "malformed"; detail: string }
|
|
339
|
+
| { kind: "no-answer"; detail: string }
|
|
201
340
|
| { kind: "missing-source"; detail: string }
|
|
202
341
|
| { kind: "stale-source"; detail: string };
|
|
203
342
|
|
|
@@ -207,6 +346,28 @@ export type ParseToSpecOutcome = { ok: true; result: ToSpecResult } | { ok: fals
|
|
|
207
346
|
export interface ToSpecEvidence {
|
|
208
347
|
kind: "to-spec";
|
|
209
348
|
result: ToSpecResult;
|
|
349
|
+
/**
|
|
350
|
+
* How the answer arrived (#1064): through the `yield` tool, or recovered
|
|
351
|
+
* from the session's text because it never yielded. `"text"` is the record's
|
|
352
|
+
* admission that the verdict did not come through the yield contract — the
|
|
353
|
+
* answer was complete and valid, so it stands, but a later reader can tell
|
|
354
|
+
* the two apart. Absent (or `"yield"`) means it came through the contract.
|
|
355
|
+
*/
|
|
356
|
+
via?: "yield" | "text";
|
|
357
|
+
/**
|
|
358
|
+
* Whether the one bounded repair round produced the answer (#1064): the
|
|
359
|
+
* first answer parsed but violated the contract, the validation error was
|
|
360
|
+
* handed back, and the re-answer is what this record carries.
|
|
361
|
+
*/
|
|
362
|
+
repaired?: boolean;
|
|
363
|
+
/**
|
|
364
|
+
* What the ready gate found missing when a valid PROMOTABLE verdict was
|
|
365
|
+
* refused mechanical promotion (#1041). A sibling of `result`, never a
|
|
366
|
+
* replacement for it: the verdict stays recoverable, so the candidate reads
|
|
367
|
+
* as groomed-but-rejected rather than ungroomed, and the tick can name what
|
|
368
|
+
* an operator must fix. Absent on every promoted or unjudged row.
|
|
369
|
+
*/
|
|
370
|
+
readyGate?: { missing: string[]; checkedAt: number };
|
|
210
371
|
}
|
|
211
372
|
|
|
212
373
|
/** The row's `evidence` when a result was refused. */
|
|
@@ -217,12 +378,21 @@ export interface ToSpecFailureEvidence {
|
|
|
217
378
|
input: string;
|
|
218
379
|
}
|
|
219
380
|
|
|
220
|
-
/**
|
|
221
|
-
|
|
381
|
+
/**
|
|
382
|
+
* How much raw agent output a failure record may carry (#1064).
|
|
383
|
+
*
|
|
384
|
+
* Sized for the whole refused payload, not a sample of it: a complete verdict
|
|
385
|
+
* with a proposed brief and sizing analysis routinely exceeds 2 KB, and a
|
|
386
|
+
* discarded verdict should be recoverable from the row rather than
|
|
387
|
+
* re-derived at full session cost. 8 KB holds a full payload with headroom
|
|
388
|
+
* while still keeping a single refused row bounded.
|
|
389
|
+
*/
|
|
390
|
+
export const TO_SPEC_INPUT_SAMPLE_MAX = 8_000;
|
|
222
391
|
|
|
223
392
|
/** Refused results always land on the same store surface, named by failure. */
|
|
224
393
|
const FAILURE_REASON: Record<ToSpecFailure["kind"], string> = {
|
|
225
394
|
malformed: "malformed",
|
|
395
|
+
"no-answer": "no-answer",
|
|
226
396
|
"missing-source": "missing-source",
|
|
227
397
|
"stale-source": "stale-source",
|
|
228
398
|
};
|
|
@@ -272,17 +442,82 @@ function extractJson(input: string): string {
|
|
|
272
442
|
return (fenced?.[1] ?? input).trim();
|
|
273
443
|
}
|
|
274
444
|
|
|
445
|
+
/**
|
|
446
|
+
* The three-way gate the scout runner decides a payload against (#1064):
|
|
447
|
+
* whether a string is a payload at all, and whether that payload already
|
|
448
|
+
* conforms to the contract or is a single re-answer away from it.
|
|
449
|
+
*
|
|
450
|
+
* - `conforming` — a JSON object that satisfies the strict contract. For
|
|
451
|
+
* text-recovered blocks this is the criterion that separates "a complete
|
|
452
|
+
* verdict written outside the yield tool" from mere narration.
|
|
453
|
+
* - `repairable` — a JSON object that violates the contract (the #1062
|
|
454
|
+
* shape: every required field present, one key misnamed). The failure
|
|
455
|
+
* carries the first validation issue, which is what the repair round hands
|
|
456
|
+
* back to the scout.
|
|
457
|
+
* - `not-a-payload` — prose, truncated JSON, a non-object: nothing a
|
|
458
|
+
* re-answer can repair, so the pass records no answer rather than grading
|
|
459
|
+
* it.
|
|
460
|
+
*
|
|
461
|
+
* Same parser as {@link parseToSpecResult}, deliberately: the runner probes
|
|
462
|
+
* with the one contract so its repair decision can never drift from what the
|
|
463
|
+
* persistence layer will accept — this is the one validator used twice, not a
|
|
464
|
+
* second, weaker one.
|
|
465
|
+
*/
|
|
466
|
+
export type ToSpecPayloadGate =
|
|
467
|
+
| { state: "conforming"; body: string }
|
|
468
|
+
| { state: "repairable"; body: string; failure: ToSpecFailure }
|
|
469
|
+
| { state: "not-a-payload" };
|
|
470
|
+
|
|
471
|
+
export function gateToSpecPayload(payload: string): ToSpecPayloadGate {
|
|
472
|
+
const body = extractJson(payload);
|
|
473
|
+
if (body.length === 0) return { state: "not-a-payload" };
|
|
474
|
+
let raw: unknown;
|
|
475
|
+
try {
|
|
476
|
+
raw = JSON.parse(body);
|
|
477
|
+
} catch {
|
|
478
|
+
return { state: "not-a-payload" };
|
|
479
|
+
}
|
|
480
|
+
if (!isObject(raw)) return { state: "not-a-payload" };
|
|
481
|
+
const parsed = ToSpecResultSchema.safeParse(raw);
|
|
482
|
+
if (!parsed.success) {
|
|
483
|
+
// The field path rides in front of the message: zod says "Required" for a
|
|
484
|
+
// missing field and "Unrecognized key(s)…" for an extra one, and the
|
|
485
|
+
// repair round's whole value is that the scout sees WHICH field failed.
|
|
486
|
+
const first = parsed.error.issues[0];
|
|
487
|
+
const detail =
|
|
488
|
+
first === undefined
|
|
489
|
+
? "schema violation"
|
|
490
|
+
: first.path.length === 0
|
|
491
|
+
? first.message
|
|
492
|
+
: `${first.path.join(".")}: ${first.message}`;
|
|
493
|
+
return { state: "repairable", body, failure: { kind: "malformed", detail } };
|
|
494
|
+
}
|
|
495
|
+
return { state: "conforming", body };
|
|
496
|
+
}
|
|
497
|
+
|
|
275
498
|
/**
|
|
276
499
|
* Parse and validate a groomer's raw output against the to-spec contract.
|
|
277
|
-
* `now` is explicit so staleness and determinism are
|
|
278
|
-
* input
|
|
279
|
-
* failure the outcome says *why* in one of the three refusal
|
|
280
|
-
* unparseable/schema-breaking output is `malformed`, output that
|
|
281
|
-
* an authoritative source (or lacks name/ref
|
|
282
|
-
* and a
|
|
283
|
-
* `stale-source`.
|
|
500
|
+
* `now` is the settle time, explicit so staleness and determinism are
|
|
501
|
+
* testable: the same input over the same batch window always yields the same
|
|
502
|
+
* outcome. On failure the outcome says *why* in one of the three refusal
|
|
503
|
+
* classes: unparseable/schema-breaking output is `malformed`, output that
|
|
504
|
+
* never names an authoritative source (or lacks name/ref) is
|
|
505
|
+
* `missing-source`, and a batch whose window is wider than
|
|
506
|
+
* `TO_SPEC_MAX_SOURCE_AGE_MS` is `stale-source`.
|
|
507
|
+
*
|
|
508
|
+
* The observation time is the dispatcher's, never the agent's (#1000).
|
|
509
|
+
* `opts.launchedAt` is when the batch was launched — the conservative (older)
|
|
510
|
+
* end of its wall-clock window, so staleness is never understated — and
|
|
511
|
+
* `now` stands in when no launch time is known. Whatever the agent put in
|
|
512
|
+
* `source.freshAt` is overwritten by that stamp before the result is
|
|
513
|
+
* returned, so the persisted evidence carries the dispatcher's own witness
|
|
514
|
+
* and a guessed timestamp can no longer discard a correct verdict.
|
|
284
515
|
*/
|
|
285
|
-
export function parseToSpecResult(
|
|
516
|
+
export function parseToSpecResult(
|
|
517
|
+
input: string,
|
|
518
|
+
now: number,
|
|
519
|
+
opts?: { launchedAt?: number },
|
|
520
|
+
): ParseToSpecOutcome {
|
|
286
521
|
const body = extractJson(input);
|
|
287
522
|
if (body.length === 0) {
|
|
288
523
|
return { ok: false, failure: { kind: "malformed", detail: "empty answer — no JSON found" } };
|
|
@@ -300,25 +535,31 @@ export function parseToSpecResult(input: string, now: number): ParseToSpecOutcom
|
|
|
300
535
|
if (!parsed.success) {
|
|
301
536
|
const detail = parsed.error.issues[0]?.message ?? "schema violation";
|
|
302
537
|
const source = raw.source;
|
|
303
|
-
|
|
538
|
+
// `freshAt` is no longer part of this test: the dispatcher stamps it, so
|
|
539
|
+
// a source is complete when it names what was read and at which ref.
|
|
540
|
+
if (!isObject(source) || source.name === undefined || source.ref === undefined) {
|
|
304
541
|
return { ok: false, failure: { kind: "missing-source", detail: `no authoritative source — ${detail}` } };
|
|
305
542
|
}
|
|
306
543
|
return { ok: false, failure: { kind: "malformed", detail } };
|
|
307
544
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
545
|
+
// The launch bound when the dispatcher knows it and it precedes the settle
|
|
546
|
+
// time — a launch stamped after `now` is clock skew, not evidence, so the
|
|
547
|
+
// settle time stands in rather than producing a negative age (#1000).
|
|
548
|
+
const launchedAt = opts?.launchedAt;
|
|
549
|
+
const observedAt =
|
|
550
|
+
launchedAt !== undefined && Number.isFinite(launchedAt) && launchedAt <= now ? launchedAt : now;
|
|
551
|
+
if (now - observedAt > TO_SPEC_MAX_SOURCE_AGE_MS) {
|
|
313
552
|
return {
|
|
314
553
|
ok: false,
|
|
315
554
|
failure: {
|
|
316
555
|
kind: "stale-source",
|
|
317
|
-
detail:
|
|
556
|
+
detail:
|
|
557
|
+
`the batch that read the source launched at ${observedAt}, ${now - observedAt}ms before this item ` +
|
|
558
|
+
`settled — older than the ${TO_SPEC_MAX_SOURCE_AGE_MS}ms ceiling`,
|
|
318
559
|
},
|
|
319
560
|
};
|
|
320
561
|
}
|
|
321
|
-
return { ok: true, result: parsed.data };
|
|
562
|
+
return { ok: true, result: { ...parsed.data, source: { ...parsed.data.source, freshAt: observedAt } } };
|
|
322
563
|
}
|
|
323
564
|
|
|
324
565
|
/** What one grooming pass did to the row, so the caller can report it. */
|
|
@@ -330,11 +571,38 @@ export type ToSpecGroomingOutcome =
|
|
|
330
571
|
export interface ToSpecGroomingRequest {
|
|
331
572
|
project: string;
|
|
332
573
|
issue: number;
|
|
333
|
-
/** The agent's
|
|
574
|
+
/** The agent's structured answer, exactly as returned: the yield payload,
|
|
575
|
+
* or a conforming block recovered from its text. Empty when the pass
|
|
576
|
+
* produced no answer at all — which is its own refusal class, decided here
|
|
577
|
+
* before anything is parsed (#1064). */
|
|
334
578
|
input: string;
|
|
579
|
+
/** How the answer arrived: through the yield tool, or recovered from the
|
|
580
|
+
* session's text because it never yielded. `"text"` marks the record so it
|
|
581
|
+
* stays visible that the verdict did not come through the yield contract;
|
|
582
|
+
* omitted reads as a yield, and an empty `input` with no `via` reads as no
|
|
583
|
+
* answer. */
|
|
584
|
+
via?: "yield" | "text";
|
|
585
|
+
/** The session's last narration, recorded as the refusal's context when no
|
|
586
|
+
* answer arrived — never parsed as a verdict (#1064). */
|
|
587
|
+
report?: string;
|
|
588
|
+
/** Whether the one bounded repair round produced the carried answer
|
|
589
|
+
* (#1064); recorded on the row so a repaired verdict is distinguishable. */
|
|
590
|
+
repaired?: boolean;
|
|
591
|
+
/** Whether the session was killed at its turn ceiling before it answered
|
|
592
|
+
* (#1064) — half of the no-answer shape naming. */
|
|
593
|
+
killedAtCeiling?: boolean;
|
|
594
|
+
/** The turn ceiling the pass ran under, and the turns it took: the other
|
|
595
|
+
* half of the no-answer shape naming (`turns >= maxTurns` reads as a cap
|
|
596
|
+
* kill even when the runner did not flag it). */
|
|
597
|
+
maxTurns?: number;
|
|
598
|
+
turns?: number;
|
|
335
599
|
/** Observation time for staleness and the recorded row; `Date.now()` when
|
|
336
600
|
* omitted (tests pass it to keep reprocessing deterministic). */
|
|
337
601
|
now?: number;
|
|
602
|
+
/** When the batch that produced this output was launched, on the
|
|
603
|
+
* dispatcher's own clock (#1000). It becomes the source observation time in
|
|
604
|
+
* preference to `now`; omit it and the settle time stands in. */
|
|
605
|
+
launchedAt?: number;
|
|
338
606
|
}
|
|
339
607
|
|
|
340
608
|
function failureEvidence(failure: ToSpecFailure, input: string): string {
|
|
@@ -354,25 +622,56 @@ function failureEvidence(failure: ToSpecFailure, input: string): string {
|
|
|
354
622
|
* `evidence` (`to-spec-failure`) — never as promotable/considered — except
|
|
355
623
|
* when a prior valid result exists: malformed reprocessing then keeps that
|
|
356
624
|
* prior row instead of erasing it. Nothing here touches labels or issues.
|
|
625
|
+
*
|
|
626
|
+
* `request.launchedAt` is the dispatcher's launch stamp for the batch (#1000);
|
|
627
|
+
* it, not anything the agent wrote, becomes the recorded source observation
|
|
628
|
+
* time.
|
|
357
629
|
*/
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
630
|
+
/**
|
|
631
|
+
* The reason a via-text verdict carries: the groomer's own label with a
|
|
632
|
+
* visible marker that the answer arrived outside the yield contract (#1064),
|
|
633
|
+
* so `status` reads `#13 promotable via text` instead of hiding the
|
|
634
|
+
* distinction in an evidence string. Yielded verdicts keep the bare label.
|
|
635
|
+
*/
|
|
636
|
+
function mapStoreReasonWithVia(verdict: ToSpecVerdict, via: "yield" | "text" | undefined): string {
|
|
637
|
+
const label = mapStoreReason(verdict);
|
|
638
|
+
return via === "text" ? `${label} via text` : label;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* The no-answer detail names which of the two session shapes produced it
|
|
643
|
+
* (#1064): a turn-ceiling kill (`killedAtCeiling`, or `turns >= maxTurns`
|
|
644
|
+
* when the runner did not flag it) or a session that ended without ever
|
|
645
|
+
* yielding.
|
|
646
|
+
*/
|
|
647
|
+
function noAnswerDetail(request: ToSpecGroomingRequest): string {
|
|
648
|
+
const killed =
|
|
649
|
+
request.killedAtCeiling === true ||
|
|
650
|
+
(request.maxTurns !== undefined && request.turns !== undefined && request.turns >= request.maxTurns);
|
|
651
|
+
const turns = request.turns === undefined ? "" : ` after ${request.turns} turn(s)`;
|
|
652
|
+
if (killed) {
|
|
653
|
+
const ceiling = request.maxTurns === undefined ? "its turn ceiling" : `its ${request.maxTurns}-turn ceiling`;
|
|
654
|
+
return `the scout was killed at ${ceiling}${turns} and never yielded an answer`;
|
|
372
655
|
}
|
|
656
|
+
return `the scout session ended${turns} without yielding an answer`;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Persist one refusal, or keep a prior valid verdict when the new pass
|
|
661
|
+
* produced something no-answer-like or malformed. `input` is what the failure
|
|
662
|
+
* record carries: the raw payload of the refused pass — or, for a no-answer
|
|
663
|
+
* pass, the session's narration as context (#1064).
|
|
664
|
+
*/
|
|
665
|
+
function persistGroomingRefusal(
|
|
666
|
+
store: Store,
|
|
667
|
+
request: ToSpecGroomingRequest,
|
|
668
|
+
failure: ToSpecFailure,
|
|
669
|
+
input: string,
|
|
670
|
+
now: number,
|
|
671
|
+
): ToSpecGroomingOutcome {
|
|
373
672
|
const prior = store.grooming(request.project, request.issue);
|
|
374
673
|
if (
|
|
375
|
-
|
|
674
|
+
(failure.kind === "malformed" || failure.kind === "no-answer") &&
|
|
376
675
|
prior !== undefined &&
|
|
377
676
|
(prior.verdict === "promotable" || prior.verdict === "considered")
|
|
378
677
|
) {
|
|
@@ -382,13 +681,83 @@ export function recordToSpecGrooming(store: Store, request: ToSpecGroomingReques
|
|
|
382
681
|
project: request.project,
|
|
383
682
|
issue: request.issue,
|
|
384
683
|
verdict: "blocked",
|
|
385
|
-
reason: FAILURE_REASON[
|
|
386
|
-
evidence: failureEvidence(
|
|
684
|
+
reason: FAILURE_REASON[failure.kind],
|
|
685
|
+
evidence: failureEvidence(failure, input),
|
|
387
686
|
at: now,
|
|
388
687
|
});
|
|
389
688
|
return { kind: "persisted", record: store.grooming(request.project, request.issue)! };
|
|
390
689
|
}
|
|
391
690
|
|
|
691
|
+
/**
|
|
692
|
+
* The only way a groomer's output becomes a durable grooming verdict: parse
|
|
693
|
+
* and validate, then upsert through the existing store — no second store, no
|
|
694
|
+
* `GroomingRecord` extension. Valid results map to the store's verdict
|
|
695
|
+
* surface with the full structured result serialized into `evidence`
|
|
696
|
+
* (`{"kind":"to-spec","result":…}`), which is what survives a restart. A
|
|
697
|
+
* refused result persists as `blocked` with the failure named in
|
|
698
|
+
* `evidence` (`to-spec-failure`) — never as promotable/considered — except
|
|
699
|
+
* when a prior valid result exists: malformed and no-answer reprocessing then
|
|
700
|
+
* keep that prior row instead of erasing it. Nothing here touches labels or
|
|
701
|
+
* issues.
|
|
702
|
+
*
|
|
703
|
+
* The refusal classes come from the whole pass, not just its output (#1064):
|
|
704
|
+
*
|
|
705
|
+
* - `no-answer` — the session produced no payload at all (no yield, no
|
|
706
|
+
* conforming text block): a turn-ceiling kill or a session end without a
|
|
707
|
+
* yield, recorded by shape, never as `malformed`. The narration is kept as
|
|
708
|
+
* context only. A text-carried block that fails validation is also
|
|
709
|
+
* `no-answer` — such a session never yielded, and the block is not an
|
|
710
|
+
* answer — except a batch-window staleness, which is a property of the
|
|
711
|
+
* pass rather than the answer.
|
|
712
|
+
* - `malformed` / `missing-source` — a *yielded* payload (or an unjudged
|
|
713
|
+
* one) that violates the contract, with the raw payload persisted so the
|
|
714
|
+
* discarded verdict stays recoverable.
|
|
715
|
+
* - `stale-source` — the batch window outlived the freshness ceiling.
|
|
716
|
+
*
|
|
717
|
+
* `request.launchedAt` is the dispatcher's launch stamp for the batch (#1000);
|
|
718
|
+
* it, not anything the agent wrote, becomes the recorded source observation
|
|
719
|
+
* time.
|
|
720
|
+
*/
|
|
721
|
+
export function recordToSpecGrooming(store: Store, request: ToSpecGroomingRequest): ToSpecGroomingOutcome {
|
|
722
|
+
const now = request.now ?? Date.now();
|
|
723
|
+
if (request.input === "") {
|
|
724
|
+
return persistGroomingRefusal(
|
|
725
|
+
store,
|
|
726
|
+
request,
|
|
727
|
+
{ kind: "no-answer", detail: noAnswerDetail(request) },
|
|
728
|
+
request.report ?? "",
|
|
729
|
+
now,
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
const parsed = parseToSpecResult(request.input, now, { launchedAt: request.launchedAt });
|
|
733
|
+
if (parsed.ok) {
|
|
734
|
+
const evidence: ToSpecEvidence = {
|
|
735
|
+
kind: "to-spec",
|
|
736
|
+
result: parsed.result,
|
|
737
|
+
...(request.via === undefined ? {} : { via: request.via }),
|
|
738
|
+
...(request.repaired === true ? { repaired: true } : {}),
|
|
739
|
+
};
|
|
740
|
+
store.upsertGrooming({
|
|
741
|
+
project: request.project,
|
|
742
|
+
issue: request.issue,
|
|
743
|
+
verdict: mapStoreVerdict(parsed.result.verdict),
|
|
744
|
+
reason: mapStoreReasonWithVia(parsed.result.verdict, request.via),
|
|
745
|
+
evidence: JSON.stringify(evidence),
|
|
746
|
+
at: now,
|
|
747
|
+
});
|
|
748
|
+
return { kind: "persisted", record: store.grooming(request.project, request.issue)! };
|
|
749
|
+
}
|
|
750
|
+
// A text-carried answer that fails validation was never yielded and does
|
|
751
|
+
// not conform: that is a no-answer pass, with the block kept as the
|
|
752
|
+
// failure's context — never malformed (#1064). The one exception is a
|
|
753
|
+
// batch-window staleness, whose class is about the pass, not the answer.
|
|
754
|
+
const failure =
|
|
755
|
+
request.via === "text" && parsed.failure.kind !== "stale-source"
|
|
756
|
+
? { kind: "no-answer" as const, detail: noAnswerDetail(request) }
|
|
757
|
+
: parsed.failure;
|
|
758
|
+
return persistGroomingRefusal(store, request, failure, request.input, now);
|
|
759
|
+
}
|
|
760
|
+
|
|
392
761
|
/**
|
|
393
762
|
* Recover the validated result from a record's evidence (the restart
|
|
394
763
|
* round-trip). Returns undefined for anything that is not a `to-spec`
|
|
@@ -404,7 +773,15 @@ export function parseToSpecEvidence(evidence: string): ToSpecResult | undefined
|
|
|
404
773
|
}
|
|
405
774
|
if (!isObject(raw) || raw.kind !== "to-spec") return undefined;
|
|
406
775
|
const parsed = ToSpecResultSchema.safeParse(raw.result);
|
|
407
|
-
|
|
776
|
+
if (!parsed.success) return undefined;
|
|
777
|
+
const freshAt = parsed.data.source.freshAt;
|
|
778
|
+
// A row with no stamped observation time cannot have its staleness judged,
|
|
779
|
+
// and freshness is precisely the field nobody may forge (#1000) — so the row
|
|
780
|
+
// reads as not-durably-groomed and becomes re-groomable, rather than
|
|
781
|
+
// carrying a fabricated timestamp that would read fresh forever. Every row
|
|
782
|
+
// `recordToSpecGrooming` writes is stamped; this is the hand-edited case.
|
|
783
|
+
if (freshAt === undefined) return undefined;
|
|
784
|
+
return { ...parsed.data, source: { ...parsed.data.source, freshAt } };
|
|
408
785
|
}
|
|
409
786
|
|
|
410
787
|
/**
|
|
@@ -428,7 +805,12 @@ export function parseToSpecFailureEvidence(evidence: string): ToSpecFailure | un
|
|
|
428
805
|
if (!isObject(raw) || raw.kind !== "to-spec-failure") return undefined;
|
|
429
806
|
const failure = raw.failure;
|
|
430
807
|
if (!isObject(failure) || typeof failure.detail !== "string") return undefined;
|
|
431
|
-
if (
|
|
808
|
+
if (
|
|
809
|
+
failure.kind !== "malformed" &&
|
|
810
|
+
failure.kind !== "no-answer" &&
|
|
811
|
+
failure.kind !== "missing-source" &&
|
|
812
|
+
failure.kind !== "stale-source"
|
|
813
|
+
) {
|
|
432
814
|
return undefined;
|
|
433
815
|
}
|
|
434
816
|
return { kind: failure.kind, detail: failure.detail };
|