omp-conductor 0.20.0 → 0.20.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/schema/config.schema.json +24 -0
- 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/commands/companion.ts +52 -16
- package/src/commands/drain.ts +12 -5
- package/src/commands/worker.ts +5 -0
- package/src/config-schema.ts +8 -0
- package/src/config.ts +17 -2
- package/src/daemon/drain.ts +33 -0
- package/src/daemon/groom-pass.ts +16 -6
- package/src/daemon/http.ts +28 -0
- package/src/daemon/review.ts +11 -11
- package/src/daemon/runtime.ts +55 -3
- package/src/daemon/settle-pass.ts +19 -2
- package/src/daemon/supervision.ts +144 -1
- package/src/daemon/tick.ts +31 -14
- package/src/daemon/views.ts +17 -0
- package/src/daemon.ts +4 -3
- package/src/decisions.ts +20 -9
- package/src/diff-flags.ts +204 -28
- package/src/doctor.ts +27 -9
- package/src/escalate.ts +187 -15
- package/src/failure-class.ts +358 -5
- package/src/fleet.ts +55 -25
- package/src/graph-health.ts +17 -1
- package/src/groom.ts +11 -0
- package/src/orchestrator-tick.ts +302 -24
- package/src/reports.ts +4 -1
- package/src/settlement.ts +133 -12
- package/src/status-render.ts +38 -9
- package/src/store.ts +64 -15
- package/src/to-spec.ts +285 -24
- package/src/tracker/github.ts +132 -10
- package/src/types.ts +49 -3
- package/src/verbs/server.ts +16 -12
- package/src/worker.ts +149 -35
package/src/types.ts
CHANGED
|
@@ -90,6 +90,12 @@ export interface Caps {
|
|
|
90
90
|
/** Wall-clock ceiling for one worker (90 min): a session that is merely
|
|
91
91
|
* stuck spends no turns, so turns alone cannot detect it. */
|
|
92
92
|
workerWallClockMs: number;
|
|
93
|
+
/** How long a live worker's transcript may stay unwritten before the
|
|
94
|
+
* daemon settles it as a progress stall (#1086). `null` derives it from the
|
|
95
|
+
* wall-clock ceiling — one third, so a hung run is caught long before its
|
|
96
|
+
* clock runs out. A single turn can legitimately run for minutes on a slow
|
|
97
|
+
* provider, so this reads *transcript writes*, never the turn counter. */
|
|
98
|
+
workerStallSilenceMs: number | null;
|
|
93
99
|
/** Failed implementation/CI attempts allowed before escalation. Operational
|
|
94
100
|
* continuations do not consume this budget. */
|
|
95
101
|
maxAttemptsPerIssue: number;
|
|
@@ -1797,6 +1803,14 @@ export const FAILURE_CLASSES = [
|
|
|
1797
1803
|
"dispatch-infra",
|
|
1798
1804
|
"merge-conflict",
|
|
1799
1805
|
"question",
|
|
1806
|
+
/** A run that finished its work and stopped because it is waiting on an
|
|
1807
|
+
* observable condition — its settlement names `blockers:` (a PR whose
|
|
1808
|
+
* checks were still pending, say) and no question (#1068). Distinct from
|
|
1809
|
+
* `question` because the remedy is not a human: the recovery is the later
|
|
1810
|
+
* observation, which the settle sweep performs by re-offering the blocked
|
|
1811
|
+
* row until its PR resolves (this fleet's #1062 sat `blocked`/`question`
|
|
1812
|
+
* after its PR merged and its tick kept listing it for Duty 1 triage). */
|
|
1813
|
+
"awaiting-observation",
|
|
1800
1814
|
"orphan-clean",
|
|
1801
1815
|
"orphan-dirty",
|
|
1802
1816
|
"settlement-stuck",
|
|
@@ -1823,6 +1837,13 @@ export const FAILURE_CLASSES = [
|
|
|
1823
1837
|
* 2026-08-23 failures were this, at 100/180 and 54/180 turns, while a third
|
|
1824
1838
|
* run on the same model finished green in the same window. */
|
|
1825
1839
|
"model-empty-stop",
|
|
1840
|
+
/** A live session stopped writing its transcript for longer than the
|
|
1841
|
+
* configured silence window, and the daemon settled it (#1086). Not a size
|
|
1842
|
+
* verdict: the work did not run out of budget, the session simply never
|
|
1843
|
+
* came back — the evidence names how long it was silent and at which turn.
|
|
1844
|
+
* When the attempt had already pushed a green PR, the settle path lands the
|
|
1845
|
+
* row `pushed-green` instead, so landed work is never charged anything. */
|
|
1846
|
+
"progress-stall",
|
|
1826
1847
|
"unknown",
|
|
1827
1848
|
] as const;
|
|
1828
1849
|
|
|
@@ -1831,21 +1852,27 @@ export type FailureClass = (typeof FAILURE_CLASSES)[number];
|
|
|
1831
1852
|
/**
|
|
1832
1853
|
* What the daemon does about a class.
|
|
1833
1854
|
*
|
|
1834
|
-
*
|
|
1855
|
+
* Three of these perform nothing, and they are not the same nothing:
|
|
1835
1856
|
*
|
|
1857
|
+
* - `observe` — recorded and deliberately left alone, because the recovery IS
|
|
1858
|
+
* the later observation: `awaiting-observation` rows wait on something the
|
|
1859
|
+
* world will resolve (their PR), and the settle sweep re-offers the blocked
|
|
1860
|
+
* row until it does, settling it the way it settles `settlement-stuck` when
|
|
1861
|
+
* the PR merges. Like `hold` it never stamps `recoveredAt` — nothing is
|
|
1862
|
+
* recovered yet, by design (#1068).
|
|
1836
1863
|
* - `hold` — recorded and deliberately left alone, because acting would destroy
|
|
1837
1864
|
* something. `orphan-dirty` is the case: the tree is the only copy, so
|
|
1838
1865
|
* `unblock` refuses rather than re-claiming over it.
|
|
1839
1866
|
* - `none` — the classifier declining to name a failure at all. Two callers
|
|
1840
1867
|
* reach it, and neither is "unclassifiable": `classifyRun` returns it for a
|
|
1841
|
-
|
|
1868
|
+
* terminal row whose PR is open and fully green, where `classifyAndRecover`
|
|
1842
1869
|
* restores `pushed-green` *without* persisting a class (#766); and the settle
|
|
1843
1870
|
* sweep persists it beside `returned-for-revision` when a reviewer closes
|
|
1844
1871
|
* pushed work without merging, where the remedy is the queue label the sweep
|
|
1845
1872
|
* deliberately leaves on.
|
|
1846
1873
|
*
|
|
1847
1874
|
* An *unclassifiable* run is `unknown` → `escalate`, and never a silent retry.
|
|
1848
|
-
* This comment previously said nothing in the decision
|
|
1875
|
+
* This comment previously said nothing in the decision tree maps to `none`,
|
|
1849
1876
|
* which both callers above contradict; it cost a reading of two modules to
|
|
1850
1877
|
* discover, which is what a wrong comment costs (#132).
|
|
1851
1878
|
*/
|
|
@@ -1855,6 +1882,7 @@ export const RECOVERY_ACTIONS = [
|
|
|
1855
1882
|
"rerun-checks",
|
|
1856
1883
|
"settle",
|
|
1857
1884
|
"escalate",
|
|
1885
|
+
"observe",
|
|
1858
1886
|
"hold",
|
|
1859
1887
|
"none",
|
|
1860
1888
|
] as const;
|
|
@@ -2116,6 +2144,14 @@ export interface RunRecord {
|
|
|
2116
2144
|
autoCompactionCount?: number;
|
|
2117
2145
|
/** omp session transcript, so a human can read what the worker actually did. */
|
|
2118
2146
|
sessionFile?: string;
|
|
2147
|
+
/**
|
|
2148
|
+
* When the run's transcript was last observed to grow, as epoch ms (#1086).
|
|
2149
|
+
* Recorded by the daemon's progress watch from the file's own mtime — the
|
|
2150
|
+
* write instant, not the observation instant — and read by `status` to show
|
|
2151
|
+
* a silence interval while the row is live. Absent means the pass has not
|
|
2152
|
+
* looked at this run (or it has no transcript yet), never "no progress".
|
|
2153
|
+
*/
|
|
2154
|
+
lastProgressAt?: number;
|
|
2119
2155
|
/**
|
|
2120
2156
|
* The exact `session-host` pid this run's Herdr representation was reported
|
|
2121
2157
|
* for, and the pane Herdr knows it as (#842).
|
|
@@ -3357,6 +3393,11 @@ export interface Store {
|
|
|
3357
3393
|
* predicate pick the window. Read-only.
|
|
3358
3394
|
*/
|
|
3359
3395
|
recentSpendSamples(project: string, limit: number): { turns: number; spendUsd: number }[];
|
|
3396
|
+
/** The newest runs carrying a usable per-turn latency (#1063): terminal
|
|
3397
|
+
* rows with turns and elapsed time recorded, newest first. Bounded by
|
|
3398
|
+
* `limit` rows like `recentSpendSamples` — the caller over-samples and lets
|
|
3399
|
+
* its own model-attribution filter pick the window. Read-only. */
|
|
3400
|
+
recentLatencySamples(project: string, limit: number): RunRecord[];
|
|
3360
3401
|
/** Total tracked `gh` calls between two UTC day keys, inclusive (#198). */
|
|
3361
3402
|
ghCallsBetween(sinceDay: string, untilDay: string): number;
|
|
3362
3403
|
/** Idempotence guard so a retry loop cannot page a human repeatedly for the
|
|
@@ -4019,6 +4060,11 @@ export const DEFAULT_CAPS: Caps = {
|
|
|
4019
4060
|
workerMaxTurns: 120,
|
|
4020
4061
|
workerMaxTurnsCeiling: 240,
|
|
4021
4062
|
workerWallClockMs: 90 * 60 * 1000,
|
|
4063
|
+
// Derived as a third of the wall-clock ceiling unless an operator names the
|
|
4064
|
+
// window: a hung run is caught with most of its budget unspent, while a
|
|
4065
|
+
// slow-but-writing session (measured fleet latency has reached
|
|
4066
|
+
// 1.5 min/turn) never trips it (#1086).
|
|
4067
|
+
workerStallSilenceMs: null,
|
|
4022
4068
|
maxAttemptsPerIssue: 2,
|
|
4023
4069
|
maxContinuationsPerIssue: 2,
|
|
4024
4070
|
};
|
package/src/verbs/server.ts
CHANGED
|
@@ -426,7 +426,8 @@ export function releaseRequirementRefusal(
|
|
|
426
426
|
const drain =
|
|
427
427
|
". Settle the fleet with a bounded drain: " +
|
|
428
428
|
`omp-conductor drain start${facts.project === undefined ? "" : ` --project ${facts.project}`} --until <deadline>` +
|
|
429
|
-
" — claiming pauses
|
|
429
|
+
" — claiming pauses until the deadline even once the fleet goes quiet (an emptied fleet opens" +
|
|
430
|
+
" the window rather than ending the drain early), and admission resumes at the deadline on its own.";
|
|
430
431
|
return (
|
|
431
432
|
`policy.release.requires includes ${requirement} and ${facts.unsettledRuns} run(s) have not settled` +
|
|
432
433
|
(parts.length === 0 ? "" : ` — ${parts.join("; ")}`) +
|
|
@@ -2292,22 +2293,25 @@ async function prUpdateVerb(
|
|
|
2292
2293
|
*/
|
|
2293
2294
|
|
|
2294
2295
|
/**
|
|
2295
|
-
* The run states a review revision may start from (#795).
|
|
2296
|
+
* The run states a review revision may start from (#795, #1101).
|
|
2296
2297
|
*
|
|
2297
2298
|
* A revision round resumes the exact run whose row owns the PR, so the
|
|
2298
|
-
* revisable states are exactly the terminal runs that
|
|
2299
|
-
* `pushed-green` row,
|
|
2300
|
-
* failed *after* pushing a green PR
|
|
2301
|
-
*
|
|
2302
|
-
*
|
|
2303
|
-
*
|
|
2299
|
+
* revisable states are exactly the terminal runs that own the named PR: a
|
|
2300
|
+
* settled `pushed-green` row, a `failed` / `killed` row — a run that capped or
|
|
2301
|
+
* failed *after* pushing a green PR — and, since #1101, a `stopped` row that
|
|
2302
|
+
* had already pushed when the operator stopped it. Ownership is proved by the
|
|
2303
|
+
* selection itself (`runsForProjectPr` matches rows that recorded the PR), so
|
|
2304
|
+
* a stopped run that never pushed can never reach this gate. Without the
|
|
2305
|
+
* stopped leg a stopped-with-green-PR run was both unreviewable (this verb)
|
|
2306
|
+
* and unreplaceable (the open-PR dispatch guard) at once — closing the PR by
|
|
2307
|
+
* hand was the only exit, which is exactly what #1096 hit.
|
|
2304
2308
|
*
|
|
2305
2309
|
* Closed on purpose: a live row (`running` / `claimed`) is already doing its
|
|
2306
2310
|
* own work, a `pushed-pending` PR is not green yet, and a `blocked` /
|
|
2307
|
-
* `orphaned` / `
|
|
2308
|
-
*
|
|
2309
|
-
* the
|
|
2310
|
-
*
|
|
2311
|
+
* `orphaned` / `merged` row is not work returned for revision. The set itself
|
|
2312
|
+
* is the shared `REVISABLE_RUN_STATES` in `decisions.ts` — the one definition
|
|
2313
|
+
* the review verb and the `pr-review-ready` watch gate on, so the two can
|
|
2314
|
+
* never drift (#844).
|
|
2311
2315
|
*/
|
|
2312
2316
|
|
|
2313
2317
|
async function prReviewVerb(
|
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
|
/**
|