omp-conductor 0.18.2 → 0.19.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/README.md +105 -40
- package/REFERENCE.md +865 -30
- package/package.json +1 -1
- package/schema/config.schema.json +26 -0
- package/src/admission.ts +212 -26
- package/src/ask.ts +288 -1
- package/src/briefs/orchestrator.md +6 -5
- package/src/cli.ts +5 -1
- package/src/command-help.ts +9 -1
- package/src/command-manifest.ts +36 -3
- package/src/commands/arm.ts +5 -1
- package/src/commands/context.ts +2 -0
- package/src/commands/message.ts +26 -2
- package/src/commands/reconcile-units.ts +104 -0
- package/src/commands/release-composition.ts +232 -0
- package/src/commands/resume.ts +2 -27
- package/src/commands/setup.ts +101 -16
- package/src/commands/stats.ts +11 -30
- package/src/commands/tail.ts +31 -1
- package/src/commands/upgrade.ts +20 -3
- package/src/commands/verb.ts +2 -1
- package/src/config-schema.ts +19 -0
- package/src/config.ts +80 -0
- package/src/credential-class.ts +366 -0
- package/src/daemon.ts +1218 -288
- package/src/dashboard/app.js +504 -2
- package/src/dashboard/controls.ts +336 -0
- package/src/dashboard/index.html +30 -0
- package/src/dashboard/server.ts +271 -30
- package/src/dashboard/style.css +116 -0
- package/src/dashboard/transcript.ts +173 -0
- package/src/doctor.ts +379 -22
- package/src/failure-class.ts +59 -0
- package/src/fleet.ts +511 -101
- package/src/host.ts +6 -130
- package/src/omp.ts +29 -0
- package/src/orchestrator-tick.ts +343 -88
- package/src/pause.ts +233 -0
- package/src/settlement.ts +159 -2
- package/src/setup-answers.ts +97 -0
- package/src/setup-host.ts +325 -1159
- package/src/setup-install.ts +204 -27
- package/src/setup-wizard.ts +111 -50
- package/src/setup.ts +33 -0
- package/src/spend-telemetry.ts +117 -0
- package/src/stats.ts +35 -0
- package/src/status-render.ts +348 -19
- package/src/store.ts +1229 -55
- package/src/telegram-freshness.ts +269 -0
- package/src/to-spec.ts +27 -0
- package/src/types.ts +697 -4
- package/src/unblock.ts +22 -0
- package/src/unit-reconcile.ts +303 -0
- package/src/upgrade-verify.ts +8 -1
- package/src/upgrade.ts +326 -47
- package/src/verbs/actions.ts +124 -10
- package/src/verbs/protocol.ts +70 -2
- package/src/verbs/server.ts +447 -8
- package/src/wake.ts +48 -0
- package/src/worker.ts +403 -3
package/src/worker.ts
CHANGED
|
@@ -108,10 +108,11 @@ function scheduleWallClock(callback: () => void, delayMs: number): () => void {
|
|
|
108
108
|
|
|
109
109
|
/**
|
|
110
110
|
* Which ceiling stopped a run. Only ever set alongside `state: "killed"`: the
|
|
111
|
-
* turn counter caught a loop,
|
|
112
|
-
* without spending turns
|
|
111
|
+
* turn counter caught a loop, the wall clock caught a session that was stuck
|
|
112
|
+
* without spending turns, or the run reached the spend allowance admission
|
|
113
|
+
* reserved for it (#851).
|
|
113
114
|
*/
|
|
114
|
-
export type KilledBy = "turns" | "wallclock";
|
|
115
|
+
export type KilledBy = "turns" | "wallclock" | "spend";
|
|
115
116
|
|
|
116
117
|
export type WorkerPausePhase = "running" | "pausing" | "paused";
|
|
117
118
|
|
|
@@ -171,6 +172,149 @@ export function renderReviewRevisionPrompt(findings: string, round: number): str
|
|
|
171
172
|
return `${REVIEW_REVISION_PROMPT}\n\nReview round ${round} — blocking findings:\n\n${findings}`;
|
|
172
173
|
}
|
|
173
174
|
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* What the review-ceiling adjudicator is told (#877).
|
|
178
|
+
*
|
|
179
|
+
* Deliberately NOT a continuation of anything. `REVIEW_REVISION_PROMPT` resumes
|
|
180
|
+
* the implementation session to change code; this opens a FRESH session, under a
|
|
181
|
+
* stronger model role, to make one decision about code it did not write. A
|
|
182
|
+
* relabelled resume of the same session and model is the named silent fake of
|
|
183
|
+
* #873: a fourth nominal opinion from the author is not an adjudication.
|
|
184
|
+
*
|
|
185
|
+
* So the contract is a verdict, not a diff: it may not push, may not edit, and
|
|
186
|
+
* ends by yielding `cleared` or `rejected` with reasons. One literal so a test
|
|
187
|
+
* can pin the exact handoff.
|
|
188
|
+
*/
|
|
189
|
+
export const REVIEW_ADJUDICATION_PROMPT =
|
|
190
|
+
"You are the final adjudicator for a pull request that has exhausted its review rounds. You did not write this " +
|
|
191
|
+
"code and you are not fixing it: your entire job is one decision. Read the acceptance criteria, the diff, the " +
|
|
192
|
+
"check state and every prior review finding below, then judge whether THIS head satisfies the issue. " +
|
|
193
|
+
"Do not edit files, do not push, do not open or close a pull request, and do not ask for another review round — " +
|
|
194
|
+
"there are none left. Finish by yielding your verdict: `cleared` if this head should merge as it stands, " +
|
|
195
|
+
"`rejected` if it should not, with the specific reasons that decided it. Judge the code in front of you, not the " +
|
|
196
|
+
"process that produced it.";
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The adjudicator's structured verdict (#877). Permissive like the worker
|
|
200
|
+
* settlement schema: an invalid or absent yield falls back to the text path
|
|
201
|
+
* rather than losing the decision, and the daemon settles the durable row from
|
|
202
|
+
* the parsed object when there is one.
|
|
203
|
+
*/
|
|
204
|
+
export const ADJUDICATION_VERDICT_SCHEMA = {
|
|
205
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
206
|
+
type: "object",
|
|
207
|
+
description:
|
|
208
|
+
"The adjudicator's terminal decision about one exact pull-request head. There is no third option and no " +
|
|
209
|
+
"further review round: `cleared` sends this head to the ordinary merge path, `rejected` sends it to the " +
|
|
210
|
+
"conductor's autonomous disposition.",
|
|
211
|
+
additionalProperties: false,
|
|
212
|
+
required: ["verdict", "reasons"],
|
|
213
|
+
properties: {
|
|
214
|
+
verdict: {
|
|
215
|
+
type: "string",
|
|
216
|
+
enum: ["cleared", "rejected"],
|
|
217
|
+
description: "cleared: this head should merge as it stands. rejected: it should not.",
|
|
218
|
+
},
|
|
219
|
+
reasons: {
|
|
220
|
+
type: "string",
|
|
221
|
+
description:
|
|
222
|
+
"Why, specifically: the criteria met or unmet, and the evidence in the diff or checks that decided it.",
|
|
223
|
+
},
|
|
224
|
+
headSha: {
|
|
225
|
+
type: "string",
|
|
226
|
+
description: "The 40-character head you judged — echoed back so a moved head is detectable.",
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
} as const;
|
|
230
|
+
|
|
231
|
+
/** One prior review round as the adjudicator must see it (#877): what was asked
|
|
232
|
+
* for, and what became of it. A finding without its outcome reads as an
|
|
233
|
+
* outstanding complaint even when the worker fixed it, which is how an
|
|
234
|
+
* adjudicator rejects work that was already corrected. */
|
|
235
|
+
export interface AdjudicationRound {
|
|
236
|
+
round: number;
|
|
237
|
+
findings: string;
|
|
238
|
+
/** The revision's settled outcome (`revised`, `failed`, `skipped`, …), or
|
|
239
|
+
* absent for a round that never settled. */
|
|
240
|
+
outcome?: string;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Everything the adjudicator is given (#877). Assembled by the caller from
|
|
244
|
+
* durable rows and live tracker reads, never from the worker's own claims. */
|
|
245
|
+
export interface AdjudicationEvidence {
|
|
246
|
+
issue: number;
|
|
247
|
+
issueTitle?: string;
|
|
248
|
+
/** The issue body verbatim: the acceptance criteria are the standard being
|
|
249
|
+
* judged, so they are never summarised. */
|
|
250
|
+
issueBody: string;
|
|
251
|
+
prUrl: string;
|
|
252
|
+
/** The exact head being judged. */
|
|
253
|
+
headSha: string;
|
|
254
|
+
/** The live check verdict at that head, as the tracker reported it. */
|
|
255
|
+
checks: string;
|
|
256
|
+
/** The PR's diff at that head. Bounded — see {@link renderAdjudicationBrief}. */
|
|
257
|
+
diff: string;
|
|
258
|
+
/** Every prior review round, oldest first. */
|
|
259
|
+
rounds: readonly AdjudicationRound[];
|
|
260
|
+
/** The review ceiling that was reached, so the adjudicator knows why it is
|
|
261
|
+
* the last word rather than one more opinion. */
|
|
262
|
+
maxRounds: number;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** How much of the diff one adjudication brief carries. A decision needs the
|
|
266
|
+
* change, not every generated line: a bound keeps one enormous PR from
|
|
267
|
+
* crowding out the findings and criteria that follow it, and the marker says
|
|
268
|
+
* exactly what was withheld so the adjudicator can ask for it rather than
|
|
269
|
+
* assuming it saw everything. */
|
|
270
|
+
export const ADJUDICATION_DIFF_LIMIT = 60_000;
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The full opening prompt for one adjudication: the pinned contract plus the
|
|
274
|
+
* bounded evidence, in the order a decision needs it — what was asked for, what
|
|
275
|
+
* is on the head now, what previous reviews said, and what happened to it.
|
|
276
|
+
*
|
|
277
|
+
* Exported so a test can pin the exact payload without standing up a session,
|
|
278
|
+
* which is #877's requirement: the brief is the deliverable here, and a test
|
|
279
|
+
* that only asserts a stored flag would pass on an empty one.
|
|
280
|
+
*/
|
|
281
|
+
export function renderAdjudicationBrief(evidence: AdjudicationEvidence): string {
|
|
282
|
+
const rounds =
|
|
283
|
+
evidence.rounds.length === 0
|
|
284
|
+
? ["(none recorded — this adjudication was admitted without prior rounds)"]
|
|
285
|
+
: evidence.rounds.map(
|
|
286
|
+
(round) =>
|
|
287
|
+
`Round ${round.round} (${round.outcome ?? "never settled"}):\n${round.findings.trim()}`,
|
|
288
|
+
);
|
|
289
|
+
const diff =
|
|
290
|
+
evidence.diff.length <= ADJUDICATION_DIFF_LIMIT
|
|
291
|
+
? evidence.diff
|
|
292
|
+
: `${evidence.diff.slice(0, ADJUDICATION_DIFF_LIMIT)}\n` +
|
|
293
|
+
`[diff truncated at ${ADJUDICATION_DIFF_LIMIT} of ${evidence.diff.length} characters — ` +
|
|
294
|
+
"read the remainder from the pull request if the decision turns on it]";
|
|
295
|
+
return [
|
|
296
|
+
REVIEW_ADJUDICATION_PROMPT,
|
|
297
|
+
"",
|
|
298
|
+
`Issue #${evidence.issue}${evidence.issueTitle === undefined ? "" : `: ${evidence.issueTitle}`}`,
|
|
299
|
+
`Pull request: ${evidence.prUrl}`,
|
|
300
|
+
`Head under adjudication: ${evidence.headSha}`,
|
|
301
|
+
`Checks at that head: ${evidence.checks}`,
|
|
302
|
+
`Review rounds exhausted: ${evidence.rounds.length} of ${evidence.maxRounds} allowed`,
|
|
303
|
+
"",
|
|
304
|
+
"## Acceptance criteria (the issue, verbatim)",
|
|
305
|
+
"",
|
|
306
|
+
evidence.issueBody.trim(),
|
|
307
|
+
"",
|
|
308
|
+
"## Prior review findings, and what became of them",
|
|
309
|
+
"",
|
|
310
|
+
rounds.join("\n\n"),
|
|
311
|
+
"",
|
|
312
|
+
"## The diff at this head",
|
|
313
|
+
"",
|
|
314
|
+
diff,
|
|
315
|
+
].join("\n");
|
|
316
|
+
}
|
|
317
|
+
|
|
174
318
|
export interface WorkerOpts {
|
|
175
319
|
brief: string;
|
|
176
320
|
cwd: string;
|
|
@@ -217,6 +361,15 @@ export interface WorkerOpts {
|
|
|
217
361
|
onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
|
|
218
362
|
/** The child's pid, the instant it exists. See {@link VerbListener.bindPid}. */
|
|
219
363
|
onSpawn?: (pid: number) => void;
|
|
364
|
+
/**
|
|
365
|
+
* The worker's Herdr representation (#840): opened from the exact child pid,
|
|
366
|
+
* released when that child is gone.
|
|
367
|
+
*
|
|
368
|
+
* Passed straight through to `createSession`, and deliberately not built here
|
|
369
|
+
* — a worker run knows its issue and attempt, but which workspace a pane
|
|
370
|
+
* belongs in, and whether one should exist at all, is the daemon's to decide.
|
|
371
|
+
*/
|
|
372
|
+
pane?: { open(pid: number): void; release(): void };
|
|
220
373
|
/** Control socket for that child, beside the run's own session directory. */
|
|
221
374
|
socketPath?: string;
|
|
222
375
|
/**
|
|
@@ -232,11 +385,27 @@ export interface WorkerOpts {
|
|
|
232
385
|
* startup cap remains fixed for the run.
|
|
233
386
|
*/
|
|
234
387
|
maxTurns?: () => number;
|
|
388
|
+
/**
|
|
389
|
+
* Estimated-USD ceiling for this session, from the allowance admission
|
|
390
|
+
* reserved for the run (#851). Checked as each cost-bearing message lands,
|
|
391
|
+
* because that is the only moment a response's cost is known — so the
|
|
392
|
+
* declared overshoot is exactly one completed model response, never zero.
|
|
393
|
+
* Omitted means the fleet has no spend cap and nothing to enforce.
|
|
394
|
+
*/
|
|
395
|
+
maxSpendUsd?: number;
|
|
235
396
|
/** Synchronous cap latch; called before the session abort begins. */
|
|
236
397
|
onKilled?: (by: KilledBy) => void;
|
|
237
398
|
onTurn?: (n: number) => void;
|
|
238
399
|
/** Cumulative USD spend, reported as each cost-bearing message finishes. */
|
|
239
400
|
onSpend?: (usd: number) => void;
|
|
401
|
+
/**
|
|
402
|
+
* Cumulative output and reasoning tokens for this run (#518).
|
|
403
|
+
*
|
|
404
|
+
* Reported alongside the spend it is derived from, so a reader can tell a run
|
|
405
|
+
* deliberating expensively in wall clock but cheaply in dollars from one that
|
|
406
|
+
* is idle or stuck — the difference that decides whether to intervene.
|
|
407
|
+
*/
|
|
408
|
+
onTokens?: (tokens: { output: number; reasoning: number }) => void;
|
|
240
409
|
/**
|
|
241
410
|
* The transcript path, handed over the moment the session opens it rather
|
|
242
411
|
* than at the end with {@link WorkerResult.sessionFile}. Both report the same
|
|
@@ -461,6 +630,183 @@ export function structuredSettlement(content: unknown): WorkerSettlement | undef
|
|
|
461
630
|
return parsed;
|
|
462
631
|
}
|
|
463
632
|
|
|
633
|
+
/**
|
|
634
|
+
* The adjudicator's parsed verdict (#877), or `undefined` when it yielded
|
|
635
|
+
* nothing usable.
|
|
636
|
+
*
|
|
637
|
+
* Same shape of parse as {@link structuredSettlement}, and separate from it on
|
|
638
|
+
* purpose: an adjudication is a decision, not a settlement, and letting the two
|
|
639
|
+
* share a parser would let a worker-shaped yield read as a verdict.
|
|
640
|
+
*/
|
|
641
|
+
export function structuredVerdict(content: unknown): AdjudicationVerdict | undefined {
|
|
642
|
+
if (!Array.isArray(content)) return undefined;
|
|
643
|
+
let parsed: AdjudicationVerdict | undefined;
|
|
644
|
+
for (const block of content) {
|
|
645
|
+
if (field(block, "type") !== "toolCall") continue;
|
|
646
|
+
if (field(block, "name") !== "yield") continue;
|
|
647
|
+
const args = field(block, "arguments");
|
|
648
|
+
if (args === null || typeof args !== "object") continue;
|
|
649
|
+
const result = field(args, "result");
|
|
650
|
+
if (result === null || typeof result !== "object") continue;
|
|
651
|
+
const data = field(result, "data");
|
|
652
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) continue;
|
|
653
|
+
const record = data as Record<string, unknown>;
|
|
654
|
+
const verdict = record.verdict;
|
|
655
|
+
if (verdict !== "cleared" && verdict !== "rejected") continue;
|
|
656
|
+
const reasons = record.reasons;
|
|
657
|
+
if (typeof reasons !== "string" || reasons.trim() === "") continue;
|
|
658
|
+
const decided: AdjudicationVerdict = { verdict, reasons };
|
|
659
|
+
const headSha = record.headSha;
|
|
660
|
+
if (typeof headSha === "string" && headSha !== "") decided.headSha = headSha;
|
|
661
|
+
parsed = decided;
|
|
662
|
+
}
|
|
663
|
+
return parsed;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** One adjudicator's decision as the daemon receives it (#877). */
|
|
667
|
+
export interface AdjudicationVerdict {
|
|
668
|
+
verdict: "cleared" | "rejected";
|
|
669
|
+
reasons: string;
|
|
670
|
+
/** The head the adjudicator says it judged, when it echoed one back. The
|
|
671
|
+
* daemon compares it against the head it dispatched: a mismatch is a
|
|
672
|
+
* stale-head settle, never a merge. */
|
|
673
|
+
headSha?: string;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** What one adjudication run produced (#877). */
|
|
677
|
+
export interface AdjudicationResult {
|
|
678
|
+
/** The parsed verdict, absent when the session ended without yielding one —
|
|
679
|
+
* which settles the durable row `failed`, never `cleared`. */
|
|
680
|
+
verdict?: AdjudicationVerdict;
|
|
681
|
+
/** The session's last words, kept whether or not a verdict parsed: for a
|
|
682
|
+
* failed adjudication this text IS the evidence. */
|
|
683
|
+
report: string;
|
|
684
|
+
/** What actually ran it, for the durable provenance (#875). */
|
|
685
|
+
model?: string;
|
|
686
|
+
provider?: string;
|
|
687
|
+
turns: number;
|
|
688
|
+
spendUsd: number;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
export interface AdjudicatorOpts {
|
|
692
|
+
/** The rendered brief from {@link renderAdjudicationBrief}. */
|
|
693
|
+
brief: string;
|
|
694
|
+
/** A directory the session may read: everything it needs is in the brief, so
|
|
695
|
+
* this is deliberately not a run worktree — an adjudicator has no branch. */
|
|
696
|
+
cwd: string;
|
|
697
|
+
/** The model pattern the configured adjudicator role resolved to, in omp's
|
|
698
|
+
* model/role syntax. Omitted leaves the harness to choose, which is what an
|
|
699
|
+
* unconfigured project gets. */
|
|
700
|
+
model?: string;
|
|
701
|
+
sessionDir?: string;
|
|
702
|
+
ompSettingsFile?: string;
|
|
703
|
+
socketPath?: string;
|
|
704
|
+
onSpawn?: (pid: number) => void;
|
|
705
|
+
onChildLog?: (line: string) => void;
|
|
706
|
+
maySpawn?: () => boolean;
|
|
707
|
+
/** Hard turn ceiling for the decision. A verdict is one read and one answer;
|
|
708
|
+
* a session still going after this is not deciding. */
|
|
709
|
+
maxTurns: number;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Run one bounded adjudication (#877).
|
|
714
|
+
*
|
|
715
|
+
* A FRESH session under the configured role — never a resume of the
|
|
716
|
+
* implementation worker, which is the whole point of escalating at the ceiling:
|
|
717
|
+
* a fourth opinion from the same session and model is not an adjudication.
|
|
718
|
+
*
|
|
719
|
+
* Read-only by construction, and that is enforced by what is NOT passed:
|
|
720
|
+
* no `verbSocketPath`, so the conductor mutation verbs (`conductor_push`,
|
|
721
|
+
* `conductor_pr_create`, everything downstream) are unreachable from this
|
|
722
|
+
* session — it cannot push, open, close or merge anything. No release grants
|
|
723
|
+
* either. Its only output is the verdict it yields.
|
|
724
|
+
*/
|
|
725
|
+
export async function runAdjudicator(
|
|
726
|
+
o: AdjudicatorOpts,
|
|
727
|
+
deps: RunWorkerDeps = { createSession },
|
|
728
|
+
): Promise<AdjudicationResult> {
|
|
729
|
+
let session: AgentSessionLike;
|
|
730
|
+
try {
|
|
731
|
+
session = await deps.createSession({
|
|
732
|
+
cwd: o.cwd,
|
|
733
|
+
...(o.sessionDir === undefined ? {} : { sessionDir: o.sessionDir }),
|
|
734
|
+
...(o.model === undefined ? {} : { model: o.model }),
|
|
735
|
+
...(o.ompSettingsFile === undefined ? {} : { ompSettingsFile: o.ompSettingsFile }),
|
|
736
|
+
...(o.socketPath === undefined ? {} : { socketPath: o.socketPath }),
|
|
737
|
+
...(o.onSpawn === undefined ? {} : { onSpawn: o.onSpawn }),
|
|
738
|
+
...(o.onChildLog === undefined ? {} : { onChildLog: o.onChildLog }),
|
|
739
|
+
...(o.maySpawn === undefined ? {} : { maySpawn: o.maySpawn }),
|
|
740
|
+
// Worker role: structured file tools stay inside `cwd` and no release
|
|
741
|
+
// grant can reach this session. Deliberately no `verbSocketPath` — see
|
|
742
|
+
// the doc comment: an adjudicator decides, it never mutates.
|
|
743
|
+
role: "worker",
|
|
744
|
+
outputSchema: ADJUDICATION_VERDICT_SCHEMA,
|
|
745
|
+
outputSchemaMode: "permissive",
|
|
746
|
+
requireYieldTool: true,
|
|
747
|
+
});
|
|
748
|
+
} catch (err) {
|
|
749
|
+
// The pre-spawn admission gate closed, or the harness could not start. Both
|
|
750
|
+
// are "no verdict", and the caller settles the durable row accordingly —
|
|
751
|
+
// never as a clear.
|
|
752
|
+
return {
|
|
753
|
+
report: err instanceof Error ? err.message : String(err),
|
|
754
|
+
turns: 0,
|
|
755
|
+
spendUsd: 0,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
let turns = 0;
|
|
760
|
+
let spendUsd = 0;
|
|
761
|
+
let report = "";
|
|
762
|
+
let verdict: AdjudicationVerdict | undefined;
|
|
763
|
+
let model: string | undefined;
|
|
764
|
+
let provider: string | undefined;
|
|
765
|
+
const finished = Promise.withResolvers<void>();
|
|
766
|
+
session.on("turn_start", () => {
|
|
767
|
+
turns += 1;
|
|
768
|
+
if (turns > o.maxTurns) session.abort();
|
|
769
|
+
});
|
|
770
|
+
session.on("message_end", (event) => {
|
|
771
|
+
const message = field(event, "message");
|
|
772
|
+
if (field(message, "role") !== "assistant") return;
|
|
773
|
+
const content = field(message, "content");
|
|
774
|
+
const parsed = structuredVerdict(content);
|
|
775
|
+
if (parsed !== undefined) verdict = parsed;
|
|
776
|
+
const text = reportText(content);
|
|
777
|
+
if (text !== "") report = text;
|
|
778
|
+
const usage = field(message, "usage");
|
|
779
|
+
const cost = usage === null ? null : field(usage, "cost");
|
|
780
|
+
const total = cost === null ? null : field(cost, "total");
|
|
781
|
+
if (typeof total === "number") spendUsd += total;
|
|
782
|
+
const messageModel = field(message, "model");
|
|
783
|
+
if (typeof messageModel === "string" && messageModel !== "") model = messageModel;
|
|
784
|
+
const messageProvider = field(message, "provider");
|
|
785
|
+
if (typeof messageProvider === "string" && messageProvider !== "") provider = messageProvider;
|
|
786
|
+
});
|
|
787
|
+
session.on("agent_end", (event) => {
|
|
788
|
+
// Same rule as the worker path: a non-terminal `agent_end` means the
|
|
789
|
+
// harness will resume the session, so completing there would read a
|
|
790
|
+
// mid-flight snapshot as the verdict.
|
|
791
|
+
if (shouldComplete(event as { isTerminal?: boolean })) finished.resolve();
|
|
792
|
+
});
|
|
793
|
+
session.on("session_exit", () => finished.resolve());
|
|
794
|
+
try {
|
|
795
|
+
await session.prompt(o.brief);
|
|
796
|
+
await finished.promise;
|
|
797
|
+
} finally {
|
|
798
|
+
await disposeSession(session);
|
|
799
|
+
}
|
|
800
|
+
return {
|
|
801
|
+
...(verdict === undefined ? {} : { verdict }),
|
|
802
|
+
report,
|
|
803
|
+
...(model === undefined ? {} : { model }),
|
|
804
|
+
...(provider === undefined ? {} : { provider }),
|
|
805
|
+
turns,
|
|
806
|
+
spendUsd,
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
|
|
464
810
|
/**
|
|
465
811
|
* Render a parsed {@link WorkerSettlement} as the run's stored report (#540).
|
|
466
812
|
*
|
|
@@ -560,6 +906,7 @@ export async function runWorker(
|
|
|
560
906
|
...(o.releaseGrants === undefined ? {} : { releaseGrants: o.releaseGrants }),
|
|
561
907
|
...(o.onReleaseBlocked === undefined ? {} : { onReleaseBlocked: o.onReleaseBlocked }),
|
|
562
908
|
...(o.onSpawn === undefined ? {} : { onSpawn: o.onSpawn }),
|
|
909
|
+
...(o.pane === undefined ? {} : { pane: o.pane }),
|
|
563
910
|
...(o.socketPath === undefined ? {} : { socketPath: o.socketPath }),
|
|
564
911
|
...(o.verbSocketPath === undefined ? {} : { verbSocketPath: o.verbSocketPath }),
|
|
565
912
|
...(o.onChildLog === undefined ? {} : { onChildLog: o.onChildLog }),
|
|
@@ -663,6 +1010,11 @@ export async function runWorker(
|
|
|
663
1010
|
|
|
664
1011
|
let turns = 0;
|
|
665
1012
|
let spendUsd = 0;
|
|
1013
|
+
// Cumulative output/reasoning tokens (#518), beside the spend they are read
|
|
1014
|
+
// from: the share of one against the other is what makes an expensive-in-time,
|
|
1015
|
+
// cheap-in-dollars run legible instead of looking barely started.
|
|
1016
|
+
let outputTokens = 0;
|
|
1017
|
+
let reasoningTokens = 0;
|
|
666
1018
|
let provider429Count = 0;
|
|
667
1019
|
let report = "";
|
|
668
1020
|
// The newest structured settlement the worker yielded (#540). A terminal
|
|
@@ -846,10 +1198,27 @@ export async function runWorker(
|
|
|
846
1198
|
// Real cost lives on assistant messages as `usage.cost.total` (live hermes
|
|
847
1199
|
// transcripts, 2026-08-07). The earlier agent_end.telemetry path never
|
|
848
1200
|
// fired, so every run recorded $0 and the daily cap was theater (#46).
|
|
1201
|
+
// Tokens ride the same usage block as the cost, so this is one read of a
|
|
1202
|
+
// field already in the transcript (#518) — no new call, no polling.
|
|
1203
|
+
const tokens = outputTokensFromMessage(message);
|
|
1204
|
+
if (tokens !== undefined) {
|
|
1205
|
+
outputTokens += tokens.output;
|
|
1206
|
+
reasoningTokens += tokens.reasoning;
|
|
1207
|
+
o.onTokens?.({ output: outputTokens, reasoning: reasoningTokens });
|
|
1208
|
+
}
|
|
1209
|
+
|
|
849
1210
|
const cost = costUsdFromMessage(message);
|
|
850
1211
|
if (cost !== undefined) {
|
|
851
1212
|
spendUsd += cost;
|
|
852
1213
|
o.onSpend?.(spendUsd);
|
|
1214
|
+
// The live half of the spend bound (#851). The daily cap alone stopped
|
|
1215
|
+
// the fleet on the NEXT dispatch pass, so one run could cross it by its
|
|
1216
|
+
// whole cost before anything objected — on 2026-08-21 a single run spent
|
|
1217
|
+
// an estimated $25.16 against a $25.00 daily cap unopposed. A run that
|
|
1218
|
+
// reaches the allowance reserved for it is killed here, through the same
|
|
1219
|
+
// latch the turn and wall-clock ceilings use, so it settles through the
|
|
1220
|
+
// ordinary salvage path with its work preserved.
|
|
1221
|
+
if (o.maxSpendUsd !== undefined && spendUsd >= o.maxSpendUsd) kill("spend");
|
|
853
1222
|
}
|
|
854
1223
|
|
|
855
1224
|
// Count the provider rate limits the harness retried in-session (#573). A
|
|
@@ -1059,6 +1428,37 @@ export function costUsdFromMessage(message: unknown): number | undefined {
|
|
|
1059
1428
|
return any ? sum : undefined;
|
|
1060
1429
|
}
|
|
1061
1430
|
|
|
1431
|
+
/**
|
|
1432
|
+
* Output and reasoning token counts from one assistant message (#518).
|
|
1433
|
+
*
|
|
1434
|
+
* Both come off the same `usage` block the cost is already read from, so this
|
|
1435
|
+
* adds no provider call and no polling — it reads a field the transcript
|
|
1436
|
+
* already carries. Absent or unparseable counts return `undefined` rather than
|
|
1437
|
+
* zero, because "this provider reported no tokens" and "this turn produced no
|
|
1438
|
+
* output" invite opposite readings, and a zero would make the share arithmetic
|
|
1439
|
+
* quietly wrong.
|
|
1440
|
+
*
|
|
1441
|
+
* `reasoning` is counted as part of `output` by every provider that reports it,
|
|
1442
|
+
* which is what makes the share meaningful: 96% reasoning on run 42f73f52 was
|
|
1443
|
+
* the whole answer to "why has this been working 42 minutes for $0.05", and it
|
|
1444
|
+
* was recorded and unshown.
|
|
1445
|
+
*/
|
|
1446
|
+
export function outputTokensFromMessage(
|
|
1447
|
+
message: unknown,
|
|
1448
|
+
): { output: number; reasoning: number } | undefined {
|
|
1449
|
+
const tokens = field(field(message, "usage"), "tokens");
|
|
1450
|
+
if (tokens === null || typeof tokens !== "object") return undefined;
|
|
1451
|
+
const count = (key: string): number | undefined => {
|
|
1452
|
+
const v = field(tokens, key);
|
|
1453
|
+
return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : undefined;
|
|
1454
|
+
};
|
|
1455
|
+
const output = count("output");
|
|
1456
|
+
if (output === undefined) return undefined;
|
|
1457
|
+
// A provider that reports no reasoning is reporting zero reasoning, which is
|
|
1458
|
+
// a true share of 0% — distinct from an absent output count above.
|
|
1459
|
+
return { output, reasoning: count("reasoning") ?? 0 };
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1062
1462
|
/**
|
|
1063
1463
|
* Is this assistant message a provider HTTP 429 the harness recorded mid-run?
|
|
1064
1464
|
*
|