omp-conductor 0.20.0 → 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/package.json +1 -1
- package/src/admission.ts +58 -14
- package/src/arm-challenge.ts +54 -3
- package/src/briefs/console.md +10 -5
- package/src/commands/arm.ts +7 -5
- package/src/daemon/groom-pass.ts +16 -6
- package/src/daemon/runtime.ts +55 -3
- package/src/daemon/settle-pass.ts +19 -2
- package/src/daemon.ts +2 -2
- package/src/diff-flags.ts +111 -6
- package/src/doctor.ts +2 -2
- package/src/failure-class.ts +182 -1
- package/src/fleet.ts +13 -20
- package/src/orchestrator-tick.ts +296 -24
- package/src/settlement.ts +35 -5
- package/src/status-render.ts +11 -7
- package/src/store.ts +14 -2
- package/src/to-spec.ts +233 -24
- package/src/types.ts +18 -3
- package/src/worker.ts +149 -35
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
|
|
@@ -326,9 +327,16 @@ export type ToSpecResult = Omit<z.infer<typeof ToSpecResultSchema>, "source"> &
|
|
|
326
327
|
source: { name: string; ref: string; freshAt: number };
|
|
327
328
|
};
|
|
328
329
|
|
|
329
|
-
/** 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`. */
|
|
330
337
|
export type ToSpecFailure =
|
|
331
338
|
| { kind: "malformed"; detail: string }
|
|
339
|
+
| { kind: "no-answer"; detail: string }
|
|
332
340
|
| { kind: "missing-source"; detail: string }
|
|
333
341
|
| { kind: "stale-source"; detail: string };
|
|
334
342
|
|
|
@@ -338,6 +346,20 @@ export type ParseToSpecOutcome = { ok: true; result: ToSpecResult } | { ok: fals
|
|
|
338
346
|
export interface ToSpecEvidence {
|
|
339
347
|
kind: "to-spec";
|
|
340
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;
|
|
341
363
|
/**
|
|
342
364
|
* What the ready gate found missing when a valid PROMOTABLE verdict was
|
|
343
365
|
* refused mechanical promotion (#1041). A sibling of `result`, never a
|
|
@@ -356,12 +378,21 @@ export interface ToSpecFailureEvidence {
|
|
|
356
378
|
input: string;
|
|
357
379
|
}
|
|
358
380
|
|
|
359
|
-
/**
|
|
360
|
-
|
|
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;
|
|
361
391
|
|
|
362
392
|
/** Refused results always land on the same store surface, named by failure. */
|
|
363
393
|
const FAILURE_REASON: Record<ToSpecFailure["kind"], string> = {
|
|
364
394
|
malformed: "malformed",
|
|
395
|
+
"no-answer": "no-answer",
|
|
365
396
|
"missing-source": "missing-source",
|
|
366
397
|
"stale-source": "stale-source",
|
|
367
398
|
};
|
|
@@ -411,6 +442,59 @@ function extractJson(input: string): string {
|
|
|
411
442
|
return (fenced?.[1] ?? input).trim();
|
|
412
443
|
}
|
|
413
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
|
+
|
|
414
498
|
/**
|
|
415
499
|
* Parse and validate a groomer's raw output against the to-spec contract.
|
|
416
500
|
* `now` is the settle time, explicit so staleness and determinism are
|
|
@@ -487,8 +571,31 @@ export type ToSpecGroomingOutcome =
|
|
|
487
571
|
export interface ToSpecGroomingRequest {
|
|
488
572
|
project: string;
|
|
489
573
|
issue: number;
|
|
490
|
-
/** 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). */
|
|
491
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;
|
|
492
599
|
/** Observation time for staleness and the recorded row; `Date.now()` when
|
|
493
600
|
* omitted (tests pass it to keep reprocessing deterministic). */
|
|
494
601
|
now?: number;
|
|
@@ -520,24 +627,51 @@ function failureEvidence(failure: ToSpecFailure, input: string): string {
|
|
|
520
627
|
* it, not anything the agent wrote, becomes the recorded source observation
|
|
521
628
|
* time.
|
|
522
629
|
*/
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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`;
|
|
537
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 {
|
|
538
672
|
const prior = store.grooming(request.project, request.issue);
|
|
539
673
|
if (
|
|
540
|
-
|
|
674
|
+
(failure.kind === "malformed" || failure.kind === "no-answer") &&
|
|
541
675
|
prior !== undefined &&
|
|
542
676
|
(prior.verdict === "promotable" || prior.verdict === "considered")
|
|
543
677
|
) {
|
|
@@ -547,13 +681,83 @@ export function recordToSpecGrooming(store: Store, request: ToSpecGroomingReques
|
|
|
547
681
|
project: request.project,
|
|
548
682
|
issue: request.issue,
|
|
549
683
|
verdict: "blocked",
|
|
550
|
-
reason: FAILURE_REASON[
|
|
551
|
-
evidence: failureEvidence(
|
|
684
|
+
reason: FAILURE_REASON[failure.kind],
|
|
685
|
+
evidence: failureEvidence(failure, input),
|
|
552
686
|
at: now,
|
|
553
687
|
});
|
|
554
688
|
return { kind: "persisted", record: store.grooming(request.project, request.issue)! };
|
|
555
689
|
}
|
|
556
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
|
+
|
|
557
761
|
/**
|
|
558
762
|
* Recover the validated result from a record's evidence (the restart
|
|
559
763
|
* round-trip). Returns undefined for anything that is not a `to-spec`
|
|
@@ -601,7 +805,12 @@ export function parseToSpecFailureEvidence(evidence: string): ToSpecFailure | un
|
|
|
601
805
|
if (!isObject(raw) || raw.kind !== "to-spec-failure") return undefined;
|
|
602
806
|
const failure = raw.failure;
|
|
603
807
|
if (!isObject(failure) || typeof failure.detail !== "string") return undefined;
|
|
604
|
-
if (
|
|
808
|
+
if (
|
|
809
|
+
failure.kind !== "malformed" &&
|
|
810
|
+
failure.kind !== "no-answer" &&
|
|
811
|
+
failure.kind !== "missing-source" &&
|
|
812
|
+
failure.kind !== "stale-source"
|
|
813
|
+
) {
|
|
605
814
|
return undefined;
|
|
606
815
|
}
|
|
607
816
|
return { kind: failure.kind, detail: failure.detail };
|
package/src/types.ts
CHANGED
|
@@ -1797,6 +1797,14 @@ export const FAILURE_CLASSES = [
|
|
|
1797
1797
|
"dispatch-infra",
|
|
1798
1798
|
"merge-conflict",
|
|
1799
1799
|
"question",
|
|
1800
|
+
/** A run that finished its work and stopped because it is waiting on an
|
|
1801
|
+
* observable condition — its settlement names `blockers:` (a PR whose
|
|
1802
|
+
* checks were still pending, say) and no question (#1068). Distinct from
|
|
1803
|
+
* `question` because the remedy is not a human: the recovery is the later
|
|
1804
|
+
* observation, which the settle sweep performs by re-offering the blocked
|
|
1805
|
+
* row until its PR resolves (this fleet's #1062 sat `blocked`/`question`
|
|
1806
|
+
* after its PR merged and its tick kept listing it for Duty 1 triage). */
|
|
1807
|
+
"awaiting-observation",
|
|
1800
1808
|
"orphan-clean",
|
|
1801
1809
|
"orphan-dirty",
|
|
1802
1810
|
"settlement-stuck",
|
|
@@ -1831,21 +1839,27 @@ export type FailureClass = (typeof FAILURE_CLASSES)[number];
|
|
|
1831
1839
|
/**
|
|
1832
1840
|
* What the daemon does about a class.
|
|
1833
1841
|
*
|
|
1834
|
-
*
|
|
1842
|
+
* Three of these perform nothing, and they are not the same nothing:
|
|
1835
1843
|
*
|
|
1844
|
+
* - `observe` — recorded and deliberately left alone, because the recovery IS
|
|
1845
|
+
* the later observation: `awaiting-observation` rows wait on something the
|
|
1846
|
+
* world will resolve (their PR), and the settle sweep re-offers the blocked
|
|
1847
|
+
* row until it does, settling it the way it settles `settlement-stuck` when
|
|
1848
|
+
* the PR merges. Like `hold` it never stamps `recoveredAt` — nothing is
|
|
1849
|
+
* recovered yet, by design (#1068).
|
|
1836
1850
|
* - `hold` — recorded and deliberately left alone, because acting would destroy
|
|
1837
1851
|
* something. `orphan-dirty` is the case: the tree is the only copy, so
|
|
1838
1852
|
* `unblock` refuses rather than re-claiming over it.
|
|
1839
1853
|
* - `none` — the classifier declining to name a failure at all. Two callers
|
|
1840
1854
|
* reach it, and neither is "unclassifiable": `classifyRun` returns it for a
|
|
1841
|
-
|
|
1855
|
+
* terminal row whose PR is open and fully green, where `classifyAndRecover`
|
|
1842
1856
|
* restores `pushed-green` *without* persisting a class (#766); and the settle
|
|
1843
1857
|
* sweep persists it beside `returned-for-revision` when a reviewer closes
|
|
1844
1858
|
* pushed work without merging, where the remedy is the queue label the sweep
|
|
1845
1859
|
* deliberately leaves on.
|
|
1846
1860
|
*
|
|
1847
1861
|
* An *unclassifiable* run is `unknown` → `escalate`, and never a silent retry.
|
|
1848
|
-
* This comment previously said nothing in the decision
|
|
1862
|
+
* This comment previously said nothing in the decision tree maps to `none`,
|
|
1849
1863
|
* which both callers above contradict; it cost a reading of two modules to
|
|
1850
1864
|
* discover, which is what a wrong comment costs (#132).
|
|
1851
1865
|
*/
|
|
@@ -1855,6 +1869,7 @@ export const RECOVERY_ACTIONS = [
|
|
|
1855
1869
|
"rerun-checks",
|
|
1856
1870
|
"settle",
|
|
1857
1871
|
"escalate",
|
|
1872
|
+
"observe",
|
|
1858
1873
|
"hold",
|
|
1859
1874
|
"none",
|
|
1860
1875
|
] as const;
|
package/src/worker.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { join } from "node:path";
|
|
|
15
15
|
import { knowledgeSection } from "./knowledge.ts";
|
|
16
16
|
import { createSession, disposeSession, SessionAdmissionClosedError, type AgentSessionLike } from "./omp.ts";
|
|
17
17
|
import type { GateShape, ReleaseBlockContext } from "./release-policy.ts";
|
|
18
|
-
import { TO_SPEC_SCHEMA } from "./to-spec.ts";
|
|
18
|
+
import { gateToSpecPayload, TO_SPEC_SCHEMA, type ToSpecFailure } from "./to-spec.ts";
|
|
19
19
|
import type { Caps, GraphToolsObservation, ResolvedGrants, RunState } from "./types.ts";
|
|
20
20
|
|
|
21
21
|
/** Structured evidence fields from the worker's final report. */
|
|
@@ -1057,7 +1057,18 @@ export async function runAdjudicator(
|
|
|
1057
1057
|
}
|
|
1058
1058
|
|
|
1059
1059
|
/**
|
|
1060
|
-
* The turn
|
|
1060
|
+
* The highest turn count any to-spec pass that finished used, measured across
|
|
1061
|
+
* both projects' grooming ledger on 2026-08-25 (#1064): of the fourteen
|
|
1062
|
+
* groom sessions ever run, the eight that finished verdicts used 10–19 turns
|
|
1063
|
+
* (BLOCKED ×6, PROMOTABLE ×1, NEEDS DECOMPOSITION ×1), while every session
|
|
1064
|
+
* killed at the ceiling was 21+ turns in when the abort fired. The ceiling
|
|
1065
|
+
* and its measurement live here, in one place: the ceiling is the measured
|
|
1066
|
+
* max plus one turn of headroom.
|
|
1067
|
+
*/
|
|
1068
|
+
const TO_SPEC_MEASURED_FINISHED_MAX_TURNS = 19;
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* The turn ceiling for one to-spec grooming scout (#1041, #1064).
|
|
1061
1072
|
*
|
|
1062
1073
|
* Half the adjudicator's 40, and for the opposite reason: an adjudication is
|
|
1063
1074
|
* handed its whole evidence in the brief (issue, diff, checks, every prior
|
|
@@ -1065,28 +1076,48 @@ export async function runAdjudicator(
|
|
|
1065
1076
|
* and must go and READ — the source repo at a ref, the entry points, the
|
|
1066
1077
|
* existing tests, the later work that may have retired the premise. That is
|
|
1067
1078
|
* search, and search is where a session bounded only by a worker's budget
|
|
1068
|
-
* quietly spends a worker's budget.
|
|
1069
|
-
* bounded repo walk plus the structured
|
|
1070
|
-
* them has stopped grooming one candidate
|
|
1071
|
-
* repository, which is exactly the cost the
|
|
1072
|
-
* everywhere else.
|
|
1079
|
+
* quietly spends a worker's budget. One turn above the highest measured
|
|
1080
|
+
* finishing pass is enough for a bounded repo walk plus the structured
|
|
1081
|
+
* answer; a scout still going after them has stopped grooming one candidate
|
|
1082
|
+
* and started exploring the repository, which is exactly the cost the
|
|
1083
|
+
* mechanical selection bounds everywhere else.
|
|
1073
1084
|
*/
|
|
1074
|
-
export const TO_SPEC_MAX_TURNS =
|
|
1085
|
+
export const TO_SPEC_MAX_TURNS = TO_SPEC_MEASURED_FINISHED_MAX_TURNS + 1;
|
|
1075
1086
|
|
|
1076
1087
|
/** What one to-spec scout produced (#1041). */
|
|
1077
1088
|
export interface ToSpecScoutResult {
|
|
1078
1089
|
/**
|
|
1079
1090
|
* The agent's answer, verbatim and unjudged — the structured payload it
|
|
1080
|
-
* yielded
|
|
1081
|
-
*
|
|
1082
|
-
*
|
|
1083
|
-
*
|
|
1084
|
-
*
|
|
1091
|
+
* yielded (through the repair round when the first answer failed), or a
|
|
1092
|
+
* fully conforming block recovered from its own text when it never yielded
|
|
1093
|
+
* (#1064). This string is `recordToSpecGrooming`'s `input`, and nothing
|
|
1094
|
+
* here reads a field of it beyond what the repair round must probe:
|
|
1095
|
+
* conductor re-validates every result through the strict parser at
|
|
1096
|
+
* persistence time, so a runner that judged would be a second, weaker
|
|
1097
|
+
* contract. Empty when the session produced no answer at all — the
|
|
1098
|
+
* narration stays in `report`, and the pass records `no-answer`.
|
|
1085
1099
|
*/
|
|
1086
1100
|
raw: string;
|
|
1087
1101
|
/** The session's last words, kept whether or not it answered: for a scout
|
|
1088
|
-
* that produced nothing this text IS the evidence of what went wrong
|
|
1102
|
+
* that produced nothing this text IS the evidence of what went wrong, and
|
|
1103
|
+
* it is recorded as the refusal's context — never parsed as a verdict
|
|
1104
|
+
* (#1064). */
|
|
1089
1105
|
report: string;
|
|
1106
|
+
/**
|
|
1107
|
+
* How `raw` arrived (#1064): through the `yield` tool, or recovered from
|
|
1108
|
+
* the session's text because it never yielded (`"text"` is the record's own
|
|
1109
|
+
* admission that the answer did not come through the contract), or `"none"`
|
|
1110
|
+
* when the pass produced no answer at all. Absent on results written before
|
|
1111
|
+
* #1064; callers read a non-empty `raw` as a yield then.
|
|
1112
|
+
*/
|
|
1113
|
+
via?: "yield" | "text" | "none";
|
|
1114
|
+
/** Whether the one bounded repair round produced the carried answer
|
|
1115
|
+
* (#1064): the first answer parsed but violated the contract, the
|
|
1116
|
+
* validation error was handed back, and this raw is the re-answer. */
|
|
1117
|
+
repaired?: boolean;
|
|
1118
|
+
/** Whether the turn ceiling aborted the session before it finished — the
|
|
1119
|
+
* cap-kill shape of the no-answer class (#1064). */
|
|
1120
|
+
killedAtCeiling?: boolean;
|
|
1090
1121
|
/** What actually ran it, for the durable provenance (#875). */
|
|
1091
1122
|
model?: string;
|
|
1092
1123
|
provider?: string;
|
|
@@ -1115,7 +1146,7 @@ export interface ToSpecScoutOpts {
|
|
|
1115
1146
|
}
|
|
1116
1147
|
|
|
1117
1148
|
/**
|
|
1118
|
-
* Run one bounded to-spec grooming pass (#1041).
|
|
1149
|
+
* Run one bounded to-spec grooming pass (#1041, #1064).
|
|
1119
1150
|
*
|
|
1120
1151
|
* The adjudicator's twin, and read-only by the same construction: no
|
|
1121
1152
|
* `verbSocketPath`, so no conductor mutation verb — no label change, no
|
|
@@ -1124,14 +1155,31 @@ export interface ToSpecScoutOpts {
|
|
|
1124
1155
|
* the scout cannot queue the candidate it is grooming, only describe it. The
|
|
1125
1156
|
* queue label is added later, by the daemon, and only through the ready gate.
|
|
1126
1157
|
*
|
|
1127
|
-
*
|
|
1158
|
+
* Deliberate differences from {@link runAdjudicator}. It carries
|
|
1128
1159
|
* {@link TO_SPEC_SCHEMA} with `outputSchemaMode: "strict"` — the to-spec
|
|
1129
1160
|
* contract has always been invoked strictly (#772), and a permissive schema
|
|
1130
1161
|
* would let a half-filled result through the harness only for the parser to
|
|
1131
1162
|
* refuse it as malformed one layer later, spending the batch to learn nothing.
|
|
1132
|
-
* And
|
|
1133
|
-
* persistence path and the only validator
|
|
1134
|
-
* a
|
|
1163
|
+
* And the answer is carried UNJUDGED: `recordToSpecGrooming` is the single
|
|
1164
|
+
* persistence path and the only validator. The one exception is the repair
|
|
1165
|
+
* round (#1064): a payload that parses but violates the contract is probed
|
|
1166
|
+
* with the same collector parser (`gateToSpecPayload`), the validation error
|
|
1167
|
+
* is prompted back into the session, and the re-answer is carried instead —
|
|
1168
|
+
* a complete, source-verified verdict must not be discarded over a key name.
|
|
1169
|
+
* A probe that refuses a payload is stale narration, never a verdict.
|
|
1170
|
+
*
|
|
1171
|
+
* What comes back goes beyond a plain string:
|
|
1172
|
+
*
|
|
1173
|
+
* - `raw` is ONLY the structured answer — the yield payload, or a fully
|
|
1174
|
+
* conforming block recovered from the session's text when it never
|
|
1175
|
+
* yielded. The runner never substitutes narration for the answer, so a
|
|
1176
|
+
* session that produced no payload returns `raw: ""` and the pass records
|
|
1177
|
+
* `no-answer` rather than grading prose as JSON.
|
|
1178
|
+
* - `via` says how the answer arrived; a text-recovery carries `"text"` so
|
|
1179
|
+
* the durable record admits the answer did not come through the yield
|
|
1180
|
+
* contract even though its verdict may be used.
|
|
1181
|
+
* - `killedAtCeiling` names the turn-ceiling kill, which is what makes the
|
|
1182
|
+
* no-answer shape legible instead of a silent failure.
|
|
1135
1183
|
*/
|
|
1136
1184
|
export async function runToSpecScout(
|
|
1137
1185
|
o: ToSpecScoutOpts,
|
|
@@ -1162,6 +1210,7 @@ export async function runToSpecScout(
|
|
|
1162
1210
|
return {
|
|
1163
1211
|
raw: "",
|
|
1164
1212
|
report: err instanceof Error ? err.message : String(err),
|
|
1213
|
+
via: "none",
|
|
1165
1214
|
turns: 0,
|
|
1166
1215
|
spendUsd: 0,
|
|
1167
1216
|
};
|
|
@@ -1173,10 +1222,16 @@ export async function runToSpecScout(
|
|
|
1173
1222
|
let yielded = "";
|
|
1174
1223
|
let model: string | undefined;
|
|
1175
1224
|
let provider: string | undefined;
|
|
1176
|
-
|
|
1225
|
+
let killedAtCeiling = false;
|
|
1226
|
+
// Re-armed for the repair round: the completion resolvers serve one prompt
|
|
1227
|
+
// at a time, and the repair prompt completes its own (turn-bounded) window.
|
|
1228
|
+
let finished = Promise.withResolvers<void>();
|
|
1177
1229
|
session.on("turn_start", () => {
|
|
1178
1230
|
turns += 1;
|
|
1179
|
-
if (turns > o.maxTurns)
|
|
1231
|
+
if (turns > o.maxTurns) {
|
|
1232
|
+
killedAtCeiling = true;
|
|
1233
|
+
session.abort();
|
|
1234
|
+
}
|
|
1180
1235
|
});
|
|
1181
1236
|
session.on("message_end", (event) => {
|
|
1182
1237
|
const message = field(event, "message");
|
|
@@ -1202,26 +1257,85 @@ export async function runToSpecScout(
|
|
|
1202
1257
|
if (shouldComplete(event as { isTerminal?: boolean })) finished.resolve();
|
|
1203
1258
|
});
|
|
1204
1259
|
session.on("session_exit", () => finished.resolve());
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* What the session produced after one prompt, and whether that answer is a
|
|
1263
|
+
* single re-answer away from the contract (#1064). The newest yield wins
|
|
1264
|
+
* and is carried unjudged; without a yield, a fully conforming fenced block
|
|
1265
|
+
* from the text is carried as the answer (marked `via: "text"`), a
|
|
1266
|
+
* repairable block is NOT carried but its failure is reported so the repair
|
|
1267
|
+
* round can run, and prose is never carried — narration is context, not an
|
|
1268
|
+
* answer.
|
|
1269
|
+
*/
|
|
1270
|
+
const inspect = (): {
|
|
1271
|
+
payload: string;
|
|
1272
|
+
via: "yield" | "text" | "none";
|
|
1273
|
+
failure: ToSpecFailure | undefined;
|
|
1274
|
+
} => {
|
|
1275
|
+
if (yielded !== "") {
|
|
1276
|
+
const gate = gateToSpecPayload(yielded);
|
|
1277
|
+
return {
|
|
1278
|
+
payload: yielded,
|
|
1279
|
+
via: "yield",
|
|
1280
|
+
failure: gate.state === "repairable" ? gate.failure : undefined,
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
const gate = gateToSpecPayload(report);
|
|
1284
|
+
if (gate.state === "conforming") return { payload: gate.body, via: "text", failure: undefined };
|
|
1285
|
+
if (gate.state === "repairable") return { payload: "", via: "none", failure: gate.failure };
|
|
1286
|
+
return { payload: "", via: "none", failure: undefined };
|
|
1287
|
+
};
|
|
1288
|
+
|
|
1289
|
+
let repaired = false;
|
|
1205
1290
|
try {
|
|
1206
1291
|
await session.prompt(o.brief);
|
|
1207
1292
|
await finished.promise;
|
|
1293
|
+
let answer = inspect();
|
|
1294
|
+
// One bounded repair round (#1064): a payload that parsed but violated
|
|
1295
|
+
// the contract is handed back once with the validation error before any
|
|
1296
|
+
// refusal is persisted. The same turn ceiling still bounds the session —
|
|
1297
|
+
// a cap kill means the session is gone and there is nothing to prompt.
|
|
1298
|
+
if (answer.failure !== undefined && !killedAtCeiling) {
|
|
1299
|
+
repaired = true;
|
|
1300
|
+
finished = Promise.withResolvers<void>();
|
|
1301
|
+
try {
|
|
1302
|
+
await session.prompt(toSpecRepairPrompt(answer.failure.detail));
|
|
1303
|
+
await finished.promise;
|
|
1304
|
+
answer = inspect();
|
|
1305
|
+
} catch {
|
|
1306
|
+
// The session could not take a repair prompt (it had already
|
|
1307
|
+
// exited). Whatever the first round produced stands, unjudged; the
|
|
1308
|
+
// persistence layer refuses it, with the payload kept in the row.
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
return {
|
|
1312
|
+
raw: answer.payload,
|
|
1313
|
+
report,
|
|
1314
|
+
via: answer.via,
|
|
1315
|
+
...(repaired ? { repaired: true } : {}),
|
|
1316
|
+
...(killedAtCeiling ? { killedAtCeiling: true } : {}),
|
|
1317
|
+
...(model === undefined ? {} : { model }),
|
|
1318
|
+
...(provider === undefined ? {} : { provider }),
|
|
1319
|
+
turns,
|
|
1320
|
+
spendUsd,
|
|
1321
|
+
};
|
|
1208
1322
|
} finally {
|
|
1209
1323
|
await disposeSession(session);
|
|
1210
1324
|
}
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/**
|
|
1328
|
+
* The one repair round's prompt (#1064): what failed and which field, plus
|
|
1329
|
+
* the yield contract the corrected answer must come through. Bounded by the
|
|
1330
|
+
* same remaining turn ceiling as the original pass.
|
|
1331
|
+
*/
|
|
1332
|
+
function toSpecRepairPrompt(detail: string): string {
|
|
1333
|
+
return (
|
|
1334
|
+
"Your grooming verdict was received but failed validation and was NOT recorded.\n\n" +
|
|
1335
|
+
`Validation error: ${detail}\n\n` +
|
|
1336
|
+
"This is your one repair round. Yield the complete, corrected verdict payload through the yield tool — " +
|
|
1337
|
+
"an answer written as plain text is not an answer."
|
|
1338
|
+
);
|
|
1225
1339
|
}
|
|
1226
1340
|
|
|
1227
1341
|
/**
|