omp-conductor 0.19.6 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/REFERENCE.md +27 -2
- package/agents/to-spec.md +76 -9
- package/package.json +1 -1
- package/schema/config.schema.json +4 -0
- package/src/arm-challenge.ts +204 -85
- package/src/ask.ts +130 -615
- package/src/board.ts +7 -1
- package/src/brief-upgrade.ts +24 -0
- package/src/briefs/console.md +253 -0
- package/src/briefs/correction.md +203 -0
- package/src/briefs/orchestrator.md +167 -97
- package/src/briefs/policy.md +19 -16
- package/src/briefs/to-spec.md +76 -9
- package/src/briefs/worker.md +50 -16
- package/src/cli.ts +4 -0
- package/src/command-manifest.ts +54 -8
- package/src/commands/arm.ts +113 -49
- package/src/commands/console.ts +70 -0
- package/src/commands/context.ts +2 -0
- package/src/commands/epic.ts +132 -0
- package/src/commands/extend.ts +9 -1
- package/src/commands/intake.ts +44 -14
- package/src/commands/stats.ts +19 -4
- package/src/commands/worker.ts +9 -1
- package/src/config-schema.ts +13 -0
- package/src/config.ts +27 -0
- package/src/daemon/ack.ts +159 -0
- package/src/daemon/admission-pass.ts +135 -0
- package/src/daemon/brief.ts +461 -0
- package/src/daemon/deps.ts +539 -0
- package/src/daemon/dispatch.ts +1779 -0
- package/src/daemon/drain.ts +185 -0
- package/src/daemon/groom-pass.ts +412 -0
- package/src/daemon/http.ts +417 -0
- package/src/daemon/integrity.ts +108 -0
- package/src/daemon/panes.ts +180 -0
- package/src/daemon/review.ts +1888 -0
- package/src/daemon/runtime.ts +736 -0
- package/src/daemon/settle-pass.ts +589 -0
- package/src/daemon/supervision.ts +438 -0
- package/src/daemon/tick.ts +968 -0
- package/src/daemon/views.ts +751 -0
- package/src/daemon.ts +105 -7832
- package/src/dashboard/app.js +58 -0
- package/src/dashboard/controls.ts +22 -3
- package/src/dashboard/server.ts +4 -0
- package/src/diff-flags.ts +24 -3
- package/src/doctor.ts +17 -12
- package/src/escalate.ts +39 -21
- package/src/failure-class.ts +75 -1
- package/src/fleet.ts +1218 -304
- package/src/groom.ts +461 -0
- package/src/http-token.ts +142 -0
- package/src/knowledge.ts +229 -0
- package/src/mining.ts +316 -0
- package/src/orchestrator-tick.ts +428 -1681
- package/src/ready-gate.ts +267 -0
- package/src/settlement.ts +72 -6
- package/src/setup-host.ts +32 -9
- package/src/setup-wizard.ts +55 -7
- package/src/setup.ts +229 -3
- package/src/stats.ts +257 -2
- package/src/status-render.ts +158 -7
- package/src/store.ts +646 -26
- package/src/to-spec.ts +194 -21
- package/src/tracker/github.ts +50 -0
- package/src/types.ts +435 -15
- package/src/verbs/protocol.ts +28 -0
- package/src/verbs/server.ts +384 -12
- package/src/wake.ts +19 -2
- package/src/worker.ts +456 -1
package/src/to-spec.ts
CHANGED
|
@@ -34,6 +34,28 @@
|
|
|
34
34
|
* returned before persisting, because the harness is permissive by design
|
|
35
35
|
* and the store vets only what this module hands it.
|
|
36
36
|
*
|
|
37
|
+
* Who stamps the observation time (#1000): the dispatcher, never the agent.
|
|
38
|
+
* A to-spec groomer runs with reading tools only and therefore has no clock,
|
|
39
|
+
* so the `source.freshAt` it used to be asked for was always a guess — and a
|
|
40
|
+
* guess landing ahead of the daemon's clock discarded the whole verdict as a
|
|
41
|
+
* clock lie, twice in one afternoon on verdicts that were otherwise correct.
|
|
42
|
+
* The schema still accepts the key (it is `.strict()`, so refusing an older
|
|
43
|
+
* prompt's output would recreate exactly that discard) but ignores its value:
|
|
44
|
+
* `parseToSpecResult` stamps the batch's launch time — or the settle time
|
|
45
|
+
* when no launch time is known — and that stamp is what persists.
|
|
46
|
+
*
|
|
47
|
+
* Sizing is evidence, not a feeling (#1041): a `PROMOTABLE` verdict carries
|
|
48
|
+
* `sizingEvidence`, and a `NEEDS DECOMPOSITION` verdict carries an ordered
|
|
49
|
+
* `decomposition`. #1035 was persisted `PROMOTABLE` with nine acceptance
|
|
50
|
+
* criteria across four lifecycle files totalling 28,812 lines; attempt 1
|
|
51
|
+
* exhausted 181 of its 180 turns. The verdict had asserted one-budget fit —
|
|
52
|
+
* what was missing was anything that could falsify the assertion. So the
|
|
53
|
+
* contract now asks for the falsifier itself (distinct behaviours, the
|
|
54
|
+
* production modules and test surfaces each needs, shared-file sequencing)
|
|
55
|
+
* and there is deliberately no numeric threshold anywhere: a line or
|
|
56
|
+
* criterion count that decided verdicts would be gamed by splitting prose,
|
|
57
|
+
* and the judgement is the groomer's.
|
|
58
|
+
*
|
|
37
59
|
* One contract, every surface (#883): this Zod schema is the single source of
|
|
38
60
|
* truth. `TO_SPEC_SCHEMA` generated from it stamps the native task launch,
|
|
39
61
|
* `parseToSpecResult` persists against it, and the drift test pins the
|
|
@@ -65,6 +87,11 @@ export type ToSpecVerdict = (typeof TO_SPEC_VERDICTS)[number];
|
|
|
65
87
|
* never read as promotable/considered. 24 hours: a groomed candidate must
|
|
66
88
|
* have looked at the code within a day, and a re-grooming pass a day later
|
|
67
89
|
* re-reads the live source anyway.
|
|
90
|
+
*
|
|
91
|
+
* Since #1000 the observation time is the dispatcher's own stamp, so this
|
|
92
|
+
* ceiling can only fire for a batch that genuinely ran longer than a day —
|
|
93
|
+
* a real signal about the batch rather than a verdict on the agent's
|
|
94
|
+
* arithmetic.
|
|
68
95
|
*/
|
|
69
96
|
export const TO_SPEC_MAX_SOURCE_AGE_MS = 24 * 60 * 60 * 1000;
|
|
70
97
|
|
|
@@ -78,10 +105,47 @@ const ToSpecSourceSchema = z
|
|
|
78
105
|
freshAt: z
|
|
79
106
|
.number()
|
|
80
107
|
.int()
|
|
81
|
-
.
|
|
108
|
+
.optional()
|
|
109
|
+
.describe(
|
|
110
|
+
"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.",
|
|
111
|
+
),
|
|
112
|
+
})
|
|
113
|
+
.strict()
|
|
114
|
+
.describe("The authoritative source and ref every verdict must stand on; conductor stamps its freshness (#1000).");
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* One ordered child of a decomposition proposal (#1041). A `NEEDS
|
|
118
|
+
* DECOMPOSITION` verdict used to be free to say "split this up" in prose,
|
|
119
|
+
* which is a verdict nobody can act on: the operator still has to do the
|
|
120
|
+
* decomposition. Each child therefore carries exactly what filing it as an
|
|
121
|
+
* issue needs — a title, the exact write lane it owns, what it waits on, the
|
|
122
|
+
* silent fake it invites, and the commands that prove it — so the proposal is
|
|
123
|
+
* a filing instruction rather than an opinion.
|
|
124
|
+
*/
|
|
125
|
+
const ToSpecDecompositionChildSchema = z
|
|
126
|
+
.object({
|
|
127
|
+
title: z.string().trim().min(1).describe("The child issue's title — one slice, stated as the behaviour it lands."),
|
|
128
|
+
writeLane: z
|
|
129
|
+
.array(z.string().trim().min(1))
|
|
130
|
+
.min(1)
|
|
131
|
+
.describe("The files/dirs this child alone writes — its `## Exact write lane`."),
|
|
132
|
+
dependsOn: z
|
|
133
|
+
.array(z.union([z.string().trim().min(1), z.number().int().min(1)]))
|
|
134
|
+
.describe(
|
|
135
|
+
"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.",
|
|
136
|
+
),
|
|
137
|
+
likelySilentFake: z
|
|
138
|
+
.string()
|
|
139
|
+
.trim()
|
|
140
|
+
.min(1)
|
|
141
|
+
.describe("The one thing most likely to be silently faked in this child, and how to prove it is not."),
|
|
142
|
+
proofCommands: z
|
|
143
|
+
.array(z.string().trim().min(1))
|
|
144
|
+
.min(1)
|
|
145
|
+
.describe("The focused commands that prove this child, each with its cwd when it matters."),
|
|
82
146
|
})
|
|
83
147
|
.strict()
|
|
84
|
-
.describe("
|
|
148
|
+
.describe("One ordered child slice a decomposition proposal names (#1041).");
|
|
85
149
|
|
|
86
150
|
/**
|
|
87
151
|
* The one strict contract a grooming result must satisfy before it may
|
|
@@ -143,6 +207,21 @@ const ToSpecResultSchema = z
|
|
|
143
207
|
.describe(
|
|
144
208
|
'Open prerequisites this work is blocked on, as bare issue numbers (875) or strings ("875"); empty when none.',
|
|
145
209
|
),
|
|
210
|
+
sizingEvidence: z
|
|
211
|
+
.string()
|
|
212
|
+
.trim()
|
|
213
|
+
.min(1)
|
|
214
|
+
.optional()
|
|
215
|
+
.describe(
|
|
216
|
+
"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).",
|
|
217
|
+
),
|
|
218
|
+
decomposition: z
|
|
219
|
+
.array(ToSpecDecompositionChildSchema)
|
|
220
|
+
.min(2)
|
|
221
|
+
.optional()
|
|
222
|
+
.describe(
|
|
223
|
+
"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).",
|
|
224
|
+
),
|
|
146
225
|
proposedBrief: z
|
|
147
226
|
.string()
|
|
148
227
|
.trim()
|
|
@@ -166,6 +245,15 @@ const ToSpecResultSchema = z
|
|
|
166
245
|
if (value.reasonNotToPromote !== undefined) {
|
|
167
246
|
ctx.addIssue({ code: "custom", message: "PROMOTABLE must not carry reasonNotToPromote" });
|
|
168
247
|
}
|
|
248
|
+
// #1041: the one-budget claim is only a claim until something could
|
|
249
|
+
// falsify it. #1035 asserted it and burned 181 of 180 turns.
|
|
250
|
+
if (value.sizingEvidence === undefined) {
|
|
251
|
+
ctx.addIssue({
|
|
252
|
+
code: "custom",
|
|
253
|
+
message:
|
|
254
|
+
"PROMOTABLE requires sizingEvidence: the distinct behaviours, their production modules and test surfaces, the shared-file sequencing, and why that fits one worker attempt",
|
|
255
|
+
});
|
|
256
|
+
}
|
|
169
257
|
} else {
|
|
170
258
|
if (value.reasonNotToPromote === undefined) {
|
|
171
259
|
ctx.addIssue({ code: "custom", message: `${verdict} requires reasonNotToPromote` });
|
|
@@ -180,6 +268,40 @@ const ToSpecResultSchema = z
|
|
|
180
268
|
message: "ALREADY DONE requires evidence naming the file/symbol that already does the work",
|
|
181
269
|
});
|
|
182
270
|
}
|
|
271
|
+
// A decomposition proposal is a filing instruction or it is nothing
|
|
272
|
+
// (#1041): NEEDS DECOMPOSITION owes ordered children, and every other
|
|
273
|
+
// verdict owes none — children beside PROMOTABLE would mean the verdict
|
|
274
|
+
// contradicts itself.
|
|
275
|
+
if (verdict === "NEEDS DECOMPOSITION") {
|
|
276
|
+
if (value.decomposition === undefined) {
|
|
277
|
+
ctx.addIssue({
|
|
278
|
+
code: "custom",
|
|
279
|
+
message:
|
|
280
|
+
"NEEDS DECOMPOSITION requires decomposition: the ordered children, each with its title, write lane, dependsOn, likelySilentFake and proofCommands",
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
} else if (value.decomposition !== undefined) {
|
|
284
|
+
ctx.addIssue({ code: "custom", message: `decomposition is only valid with verdict "NEEDS DECOMPOSITION", not ${verdict}` });
|
|
285
|
+
}
|
|
286
|
+
// Two children writing one core module concurrently is the collision the
|
|
287
|
+
// file lane exists to prevent, so the later of any file-sharing pair must
|
|
288
|
+
// name what it waits on. Order is the array's order — child n may only
|
|
289
|
+
// depend on something before it.
|
|
290
|
+
const children = value.decomposition ?? [];
|
|
291
|
+
for (let i = 1; i < children.length; i++) {
|
|
292
|
+
const child = children[i]!;
|
|
293
|
+
if (child.dependsOn.length > 0) continue;
|
|
294
|
+
const own = new Set(child.writeLane.map((path) => path.trim()));
|
|
295
|
+
for (let j = 0; j < i; j++) {
|
|
296
|
+
const shared = children[j]!.writeLane.map((path) => path.trim()).find((path) => own.has(path));
|
|
297
|
+
if (shared === undefined) continue;
|
|
298
|
+
ctx.addIssue({
|
|
299
|
+
code: "custom",
|
|
300
|
+
message: `decomposition child ${i + 1} shares ${shared} with child ${j + 1} and must serialise on it through dependsOn`,
|
|
301
|
+
});
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
183
305
|
if (value.routing === MULTI_ROUTING && value.routingSplit === undefined) {
|
|
184
306
|
ctx.addIssue({ code: "custom", message: 'routing "MULTI" requires routingSplit' });
|
|
185
307
|
}
|
|
@@ -193,7 +315,16 @@ const ToSpecResultSchema = z
|
|
|
193
315
|
* output with (`outputSchema` + `schemaMode: "strict"`, #772). */
|
|
194
316
|
export const TO_SPEC_SCHEMA: object = z.toJSONSchema(ToSpecResultSchema) as object;
|
|
195
317
|
|
|
196
|
-
|
|
318
|
+
/**
|
|
319
|
+
* The validated result every reader gets. `source.freshAt` is a required
|
|
320
|
+
* number here even though the schema no longer asks the agent for one:
|
|
321
|
+
* {@link parseToSpecResult} stamps the dispatcher's own observation time onto
|
|
322
|
+
* every result it returns (#1000), so a persisted verdict always carries a
|
|
323
|
+
* freshness witness — just never the agent's guess.
|
|
324
|
+
*/
|
|
325
|
+
export type ToSpecResult = Omit<z.infer<typeof ToSpecResultSchema>, "source"> & {
|
|
326
|
+
source: { name: string; ref: string; freshAt: number };
|
|
327
|
+
};
|
|
197
328
|
|
|
198
329
|
/** Why a result could not be trusted; each persists as a blocked record. */
|
|
199
330
|
export type ToSpecFailure =
|
|
@@ -207,6 +338,14 @@ export type ParseToSpecOutcome = { ok: true; result: ToSpecResult } | { ok: fals
|
|
|
207
338
|
export interface ToSpecEvidence {
|
|
208
339
|
kind: "to-spec";
|
|
209
340
|
result: ToSpecResult;
|
|
341
|
+
/**
|
|
342
|
+
* What the ready gate found missing when a valid PROMOTABLE verdict was
|
|
343
|
+
* refused mechanical promotion (#1041). A sibling of `result`, never a
|
|
344
|
+
* replacement for it: the verdict stays recoverable, so the candidate reads
|
|
345
|
+
* as groomed-but-rejected rather than ungroomed, and the tick can name what
|
|
346
|
+
* an operator must fix. Absent on every promoted or unjudged row.
|
|
347
|
+
*/
|
|
348
|
+
readyGate?: { missing: string[]; checkedAt: number };
|
|
210
349
|
}
|
|
211
350
|
|
|
212
351
|
/** The row's `evidence` when a result was refused. */
|
|
@@ -274,15 +413,27 @@ function extractJson(input: string): string {
|
|
|
274
413
|
|
|
275
414
|
/**
|
|
276
415
|
* 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`.
|
|
416
|
+
* `now` is the settle time, explicit so staleness and determinism are
|
|
417
|
+
* testable: the same input over the same batch window always yields the same
|
|
418
|
+
* outcome. On failure the outcome says *why* in one of the three refusal
|
|
419
|
+
* classes: unparseable/schema-breaking output is `malformed`, output that
|
|
420
|
+
* never names an authoritative source (or lacks name/ref) is
|
|
421
|
+
* `missing-source`, and a batch whose window is wider than
|
|
422
|
+
* `TO_SPEC_MAX_SOURCE_AGE_MS` is `stale-source`.
|
|
423
|
+
*
|
|
424
|
+
* The observation time is the dispatcher's, never the agent's (#1000).
|
|
425
|
+
* `opts.launchedAt` is when the batch was launched — the conservative (older)
|
|
426
|
+
* end of its wall-clock window, so staleness is never understated — and
|
|
427
|
+
* `now` stands in when no launch time is known. Whatever the agent put in
|
|
428
|
+
* `source.freshAt` is overwritten by that stamp before the result is
|
|
429
|
+
* returned, so the persisted evidence carries the dispatcher's own witness
|
|
430
|
+
* and a guessed timestamp can no longer discard a correct verdict.
|
|
284
431
|
*/
|
|
285
|
-
export function parseToSpecResult(
|
|
432
|
+
export function parseToSpecResult(
|
|
433
|
+
input: string,
|
|
434
|
+
now: number,
|
|
435
|
+
opts?: { launchedAt?: number },
|
|
436
|
+
): ParseToSpecOutcome {
|
|
286
437
|
const body = extractJson(input);
|
|
287
438
|
if (body.length === 0) {
|
|
288
439
|
return { ok: false, failure: { kind: "malformed", detail: "empty answer — no JSON found" } };
|
|
@@ -300,25 +451,31 @@ export function parseToSpecResult(input: string, now: number): ParseToSpecOutcom
|
|
|
300
451
|
if (!parsed.success) {
|
|
301
452
|
const detail = parsed.error.issues[0]?.message ?? "schema violation";
|
|
302
453
|
const source = raw.source;
|
|
303
|
-
|
|
454
|
+
// `freshAt` is no longer part of this test: the dispatcher stamps it, so
|
|
455
|
+
// a source is complete when it names what was read and at which ref.
|
|
456
|
+
if (!isObject(source) || source.name === undefined || source.ref === undefined) {
|
|
304
457
|
return { ok: false, failure: { kind: "missing-source", detail: `no authoritative source — ${detail}` } };
|
|
305
458
|
}
|
|
306
459
|
return { ok: false, failure: { kind: "malformed", detail } };
|
|
307
460
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
461
|
+
// The launch bound when the dispatcher knows it and it precedes the settle
|
|
462
|
+
// time — a launch stamped after `now` is clock skew, not evidence, so the
|
|
463
|
+
// settle time stands in rather than producing a negative age (#1000).
|
|
464
|
+
const launchedAt = opts?.launchedAt;
|
|
465
|
+
const observedAt =
|
|
466
|
+
launchedAt !== undefined && Number.isFinite(launchedAt) && launchedAt <= now ? launchedAt : now;
|
|
467
|
+
if (now - observedAt > TO_SPEC_MAX_SOURCE_AGE_MS) {
|
|
313
468
|
return {
|
|
314
469
|
ok: false,
|
|
315
470
|
failure: {
|
|
316
471
|
kind: "stale-source",
|
|
317
|
-
detail:
|
|
472
|
+
detail:
|
|
473
|
+
`the batch that read the source launched at ${observedAt}, ${now - observedAt}ms before this item ` +
|
|
474
|
+
`settled — older than the ${TO_SPEC_MAX_SOURCE_AGE_MS}ms ceiling`,
|
|
318
475
|
},
|
|
319
476
|
};
|
|
320
477
|
}
|
|
321
|
-
return { ok: true, result: parsed.data };
|
|
478
|
+
return { ok: true, result: { ...parsed.data, source: { ...parsed.data.source, freshAt: observedAt } } };
|
|
322
479
|
}
|
|
323
480
|
|
|
324
481
|
/** What one grooming pass did to the row, so the caller can report it. */
|
|
@@ -335,6 +492,10 @@ export interface ToSpecGroomingRequest {
|
|
|
335
492
|
/** Observation time for staleness and the recorded row; `Date.now()` when
|
|
336
493
|
* omitted (tests pass it to keep reprocessing deterministic). */
|
|
337
494
|
now?: number;
|
|
495
|
+
/** When the batch that produced this output was launched, on the
|
|
496
|
+
* dispatcher's own clock (#1000). It becomes the source observation time in
|
|
497
|
+
* preference to `now`; omit it and the settle time stands in. */
|
|
498
|
+
launchedAt?: number;
|
|
338
499
|
}
|
|
339
500
|
|
|
340
501
|
function failureEvidence(failure: ToSpecFailure, input: string): string {
|
|
@@ -354,10 +515,14 @@ function failureEvidence(failure: ToSpecFailure, input: string): string {
|
|
|
354
515
|
* `evidence` (`to-spec-failure`) — never as promotable/considered — except
|
|
355
516
|
* when a prior valid result exists: malformed reprocessing then keeps that
|
|
356
517
|
* prior row instead of erasing it. Nothing here touches labels or issues.
|
|
518
|
+
*
|
|
519
|
+
* `request.launchedAt` is the dispatcher's launch stamp for the batch (#1000);
|
|
520
|
+
* it, not anything the agent wrote, becomes the recorded source observation
|
|
521
|
+
* time.
|
|
357
522
|
*/
|
|
358
523
|
export function recordToSpecGrooming(store: Store, request: ToSpecGroomingRequest): ToSpecGroomingOutcome {
|
|
359
524
|
const now = request.now ?? Date.now();
|
|
360
|
-
const parsed = parseToSpecResult(request.input, now);
|
|
525
|
+
const parsed = parseToSpecResult(request.input, now, { launchedAt: request.launchedAt });
|
|
361
526
|
if (parsed.ok) {
|
|
362
527
|
const evidence: ToSpecEvidence = { kind: "to-spec", result: parsed.result };
|
|
363
528
|
store.upsertGrooming({
|
|
@@ -404,7 +569,15 @@ export function parseToSpecEvidence(evidence: string): ToSpecResult | undefined
|
|
|
404
569
|
}
|
|
405
570
|
if (!isObject(raw) || raw.kind !== "to-spec") return undefined;
|
|
406
571
|
const parsed = ToSpecResultSchema.safeParse(raw.result);
|
|
407
|
-
|
|
572
|
+
if (!parsed.success) return undefined;
|
|
573
|
+
const freshAt = parsed.data.source.freshAt;
|
|
574
|
+
// A row with no stamped observation time cannot have its staleness judged,
|
|
575
|
+
// and freshness is precisely the field nobody may forge (#1000) — so the row
|
|
576
|
+
// reads as not-durably-groomed and becomes re-groomable, rather than
|
|
577
|
+
// carrying a fabricated timestamp that would read fresh forever. Every row
|
|
578
|
+
// `recordToSpecGrooming` writes is stamped; this is the hand-edited case.
|
|
579
|
+
if (freshAt === undefined) return undefined;
|
|
580
|
+
return { ...parsed.data, source: { ...parsed.data.source, freshAt } };
|
|
408
581
|
}
|
|
409
582
|
|
|
410
583
|
/**
|
package/src/tracker/github.ts
CHANGED
|
@@ -486,6 +486,40 @@ export function prVerificationFrom(
|
|
|
486
486
|
return { status: "green", reason: `${checks.length} checks succeeded or were skipped`, headSha };
|
|
487
487
|
}
|
|
488
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Whether one raw `gh pr view --json ...statusCheckRollup` payload collected
|
|
491
|
+
* zero rollup contexts (#999).
|
|
492
|
+
*
|
|
493
|
+
* {@link prVerificationFrom} maps zero collected checks to `pending` — "GitHub
|
|
494
|
+
* has not reported any checks yet" — which is the right refusal when it is
|
|
495
|
+
* true, and a silent stall when it is not. It is not always true: a head whose
|
|
496
|
+
* PR was opened by `github-actions[bot]` carries a second, empty
|
|
497
|
+
* `action_required` check suite beside the completed one, and the rollup read
|
|
498
|
+
* can resolve that empty suite and collect nothing while 18 check-runs
|
|
499
|
+
* (one of them failed) sit on the same SHA. So the caller uses this to treat
|
|
500
|
+
* an empty rollup as *unresolvable* and re-prove from the REST plane, which
|
|
501
|
+
* flattens every page of both `check_runs` and `statuses` for the exact head
|
|
502
|
+
* and sees all of them.
|
|
503
|
+
*
|
|
504
|
+
* A missing rollup counts as zero collected, exactly as
|
|
505
|
+
* {@link prVerificationFrom}'s `?? []` does. A body this predicate cannot read
|
|
506
|
+
* — unparseable, not a JSON object (a bare list is not a `pr view` payload),
|
|
507
|
+
* or a rollup that is not a list — is not its problem: it answers false so the
|
|
508
|
+
* existing verdict stands unchanged.
|
|
509
|
+
*/
|
|
510
|
+
export function rollupCollectedNoChecks(raw: string): boolean {
|
|
511
|
+
let parsed: unknown;
|
|
512
|
+
try {
|
|
513
|
+
parsed = JSON.parse(raw) as unknown;
|
|
514
|
+
} catch {
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return false;
|
|
518
|
+
const rollup = (parsed as GhPrVerification).statusCheckRollup;
|
|
519
|
+
if (rollup === undefined || rollup === null) return true;
|
|
520
|
+
return Array.isArray(rollup) && rollup.length === 0;
|
|
521
|
+
}
|
|
522
|
+
|
|
489
523
|
function failedCheck(raw: string): GhCheck | undefined {
|
|
490
524
|
const checks = (JSON.parse(raw) as GhPrVerification).statusCheckRollup ?? [];
|
|
491
525
|
return checks.find((check) => checkVerdict(check) === "failed");
|
|
@@ -2024,6 +2058,22 @@ export function makeTracker(
|
|
|
2024
2058
|
"state,isDraft,headRefOid,statusCheckRollup",
|
|
2025
2059
|
]);
|
|
2026
2060
|
const verification = prVerificationFrom(raw, expectedHead, opts);
|
|
2061
|
+
if (verification.status === "pending" && rollupCollectedNoChecks(raw)) {
|
|
2062
|
+
// An empty rollup is not proof that the head has no checks (#999):
|
|
2063
|
+
// a PR opened by `github-actions[bot]` carries a second, empty
|
|
2064
|
+
// `action_required` suite beside the completed one, the rollup read
|
|
2065
|
+
// resolves that suite, collects nothing, and this verdict reads
|
|
2066
|
+
// "GitHub has not reported any checks yet" over a head with 18
|
|
2067
|
+
// check-runs and a failure among them. A commit that carries an
|
|
2068
|
+
// empty suite beside a completed one is not a commit with no
|
|
2069
|
+
// checks, so zero collected contexts is unresolvable rather than
|
|
2070
|
+
// decisive: re-prove from REST, which flattens every page of both
|
|
2071
|
+
// `check_runs` and `statuses` for the exact head. REST that cannot
|
|
2072
|
+
// answer leaves this pending exactly as it stands — fail-closed,
|
|
2073
|
+
// never green by omission.
|
|
2074
|
+
const rest = await verifyPrRest(runGh, cache, url, expectedHead, opts, hooks.onNotModified);
|
|
2075
|
+
return rest ?? verification;
|
|
2076
|
+
}
|
|
2027
2077
|
if (verification.status !== "failed") return verification;
|
|
2028
2078
|
|
|
2029
2079
|
const detailsUrl = failedCheck(raw)?.detailsUrl;
|