omp-conductor 0.19.7 → 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 +10 -1
- 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 -7923
- package/src/dashboard/app.js +58 -0
- package/src/dashboard/controls.ts +22 -3
- package/src/dashboard/server.ts +4 -0
- package/src/diff-flags.ts +24 -3
- package/src/failure-class.ts +75 -1
- package/src/fleet.ts +290 -164
- 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 +604 -26
- package/src/to-spec.ts +194 -21
- package/src/tracker/github.ts +50 -0
- package/src/types.ts +416 -15
- package/src/verbs/protocol.ts +28 -0
- package/src/verbs/server.ts +330 -39
- package/src/wake.ts +19 -2
- package/src/worker.ts +456 -1
package/src/worker.ts
CHANGED
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
|
|
13
13
|
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
+
import { knowledgeSection } from "./knowledge.ts";
|
|
15
16
|
import { createSession, disposeSession, SessionAdmissionClosedError, type AgentSessionLike } from "./omp.ts";
|
|
16
17
|
import type { GateShape, ReleaseBlockContext } from "./release-policy.ts";
|
|
18
|
+
import { TO_SPEC_SCHEMA } from "./to-spec.ts";
|
|
17
19
|
import type { Caps, GraphToolsObservation, ResolvedGrants, RunState } from "./types.ts";
|
|
18
20
|
|
|
19
21
|
/** Structured evidence fields from the worker's final report. */
|
|
@@ -53,6 +55,17 @@ export interface WorkerSettlement {
|
|
|
53
55
|
blockers?: string[];
|
|
54
56
|
/** Commands run as evidence, exactly as executed. */
|
|
55
57
|
proof?: string[];
|
|
58
|
+
/**
|
|
59
|
+
* Durable facts about the REPOSITORY this run learned — where a subsystem is
|
|
60
|
+
* wired, which command actually proves a change, a test that looks like it
|
|
61
|
+
* asserts something and does not (Phase 3 knowledge overlay). Appended to the
|
|
62
|
+
* fleet's per-repo overlay at settlement and shown to every later run in the
|
|
63
|
+
* same repo, which is why they must be about the repo and not about this
|
|
64
|
+
* task: "I fixed the parser" helps nobody, "the parser's only caller is
|
|
65
|
+
* cli.ts:route" saves the next run a search. Optional, and every settlement
|
|
66
|
+
* that predates it stays valid.
|
|
67
|
+
*/
|
|
68
|
+
discoveries?: string[];
|
|
56
69
|
}
|
|
57
70
|
|
|
58
71
|
/**
|
|
@@ -98,6 +111,16 @@ export const WORKER_SETTLEMENT_SCHEMA = {
|
|
|
98
111
|
items: { type: "string" },
|
|
99
112
|
description: "The commands you ran as evidence, exactly as executed.",
|
|
100
113
|
},
|
|
114
|
+
discoveries: {
|
|
115
|
+
type: "array",
|
|
116
|
+
items: { type: "string" },
|
|
117
|
+
description:
|
|
118
|
+
"Optional. Durable facts about THIS REPOSITORY that cost you turns to find and would cost " +
|
|
119
|
+
"the next run the same: where a subsystem is wired, which command actually proves a change, " +
|
|
120
|
+
"a test that looks like it asserts something and does not. Not a summary of your task — " +
|
|
121
|
+
"these are shown to every later run in this repo, so leave the field out rather than " +
|
|
122
|
+
"filling it with what you did.",
|
|
123
|
+
},
|
|
101
124
|
},
|
|
102
125
|
} as const;
|
|
103
126
|
|
|
@@ -172,6 +195,219 @@ export function renderReviewRevisionPrompt(findings: string, round: number): str
|
|
|
172
195
|
return `${REVIEW_REVISION_PROMPT}\n\nReview round ${round} — blocking findings:\n\n${findings}`;
|
|
173
196
|
}
|
|
174
197
|
|
|
198
|
+
/**
|
|
199
|
+
* The template one fresh correction's brief is rendered from.
|
|
200
|
+
*
|
|
201
|
+
* Read per correction rather than cached, exactly like the dispatcher's own
|
|
202
|
+
* `buildBrief`: editing the brief then takes effect on the next correction
|
|
203
|
+
* instead of needing a daemon restart.
|
|
204
|
+
*/
|
|
205
|
+
const CORRECTION_BRIEF_PATH = join(import.meta.dir, "briefs", "correction.md");
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* How much of the diff one fresh correction brief carries (#1046).
|
|
209
|
+
*
|
|
210
|
+
* The same bound, and the same reasoning, as {@link ADJUDICATION_DIFF_LIMIT}: a
|
|
211
|
+
* correction needs the change it is correcting, not every generated line, and an
|
|
212
|
+
* enormous PR must not crowd out the findings and criteria that follow it. The
|
|
213
|
+
* marker says exactly what was withheld, so the session reads the rest off the
|
|
214
|
+
* branch it already has checked out rather than assuming it saw everything.
|
|
215
|
+
*/
|
|
216
|
+
export const CORRECTION_DIFF_LIMIT = 60_000;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Everything one fresh correction is told (#1046).
|
|
220
|
+
*
|
|
221
|
+
* Assembled by the caller from durable rows and live tracker reads, never from a
|
|
222
|
+
* worker's own claims — and, unlike a resumed revision, this is ALL the session
|
|
223
|
+
* gets. The exhausted implementation transcript is not replayed, so a field
|
|
224
|
+
* missing here is context the correction simply does not have.
|
|
225
|
+
*/
|
|
226
|
+
export interface CorrectionEvidence {
|
|
227
|
+
/** Canonical `owner/repo` of the code repo, or the routing name when the
|
|
228
|
+
* clone URL yields none. Also the key the knowledge overlay is read under. */
|
|
229
|
+
repo: string;
|
|
230
|
+
issue: number;
|
|
231
|
+
issueTitle?: string;
|
|
232
|
+
/** The issue's acceptance criteria, verbatim. */
|
|
233
|
+
issueBody: string;
|
|
234
|
+
/** The pull request this correction must push onto. Never a second PR. */
|
|
235
|
+
prUrl: string;
|
|
236
|
+
/** The exact head the findings were written against. */
|
|
237
|
+
headSha: string;
|
|
238
|
+
/** The branch and worktree that already exist for this run. */
|
|
239
|
+
branch: string;
|
|
240
|
+
worktree: string;
|
|
241
|
+
/** Live check state at that head, as the tracker reported it. */
|
|
242
|
+
checks: string;
|
|
243
|
+
/** The diff at that head, bounded by {@link CORRECTION_DIFF_LIMIT}. */
|
|
244
|
+
diff: string;
|
|
245
|
+
round: number;
|
|
246
|
+
/** The review-round ceiling, when the caller knows it. */
|
|
247
|
+
maxRounds?: number;
|
|
248
|
+
/** This round's blocking findings, delivered verbatim. */
|
|
249
|
+
findings: string;
|
|
250
|
+
/** Earlier rounds and what became of them. A finding without its outcome
|
|
251
|
+
* reads as an outstanding complaint even when it was fixed, which is how a
|
|
252
|
+
* correction session re-does work that already landed. */
|
|
253
|
+
priorRounds: readonly AdjudicationRound[];
|
|
254
|
+
/**
|
|
255
|
+
* Why this round is a fresh session rather than a resume, in plain words —
|
|
256
|
+
* "the implementation session was killed by the turns cap", "the fleet's
|
|
257
|
+
* worker model changed after the rejected run". It goes in the brief because a
|
|
258
|
+
* session that does not know its transcript is gone will look for it: #1035's
|
|
259
|
+
* whole failure was correction rounds spent replaying an exhausted session,
|
|
260
|
+
* and a session told nothing behaves as if it were still that one.
|
|
261
|
+
*/
|
|
262
|
+
reason: string;
|
|
263
|
+
/** The gate commands this correction must run before pushing. */
|
|
264
|
+
proofCommands: readonly string[];
|
|
265
|
+
/**
|
|
266
|
+
* The fleet's accumulated repo knowledge, pre-rendered by
|
|
267
|
+
* {@link knowledgeSection}. Omitted, it is read here from {@link repo} — the
|
|
268
|
+
* overlay exists to reach every session launched against a repo, so it must
|
|
269
|
+
* not depend on each caller remembering to pass it. Pass `""` to render none.
|
|
270
|
+
*/
|
|
271
|
+
knowledge?: string;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The full opening prompt for one fresh correction round (#1046).
|
|
276
|
+
*
|
|
277
|
+
* Exported so a test can pin the exact payload without standing up a session:
|
|
278
|
+
* the brief IS the deliverable here. `REVIEW_REVISION_PROMPT` can be three
|
|
279
|
+
* sentences because the session it prompts already holds the issue, the plan and
|
|
280
|
+
* the diff; this one holds nothing, so every one of those facts has to arrive in
|
|
281
|
+
* the text — and a test that only asserted "a session was launched" would pass on
|
|
282
|
+
* an empty brief.
|
|
283
|
+
*/
|
|
284
|
+
export function renderCorrectionBrief(evidence: CorrectionEvidence): string {
|
|
285
|
+
const template = readFileSync(CORRECTION_BRIEF_PATH, "utf8");
|
|
286
|
+
const rounds =
|
|
287
|
+
evidence.priorRounds.length === 0
|
|
288
|
+
? "_(none recorded — this is the first review round of this run)_"
|
|
289
|
+
: evidence.priorRounds
|
|
290
|
+
.map((round) => `Round ${String(round.round)} (${round.outcome ?? "never settled"}):\n${round.findings.trim()}`)
|
|
291
|
+
.join("\n\n");
|
|
292
|
+
const diff =
|
|
293
|
+
evidence.diff.length <= CORRECTION_DIFF_LIMIT
|
|
294
|
+
? evidence.diff
|
|
295
|
+
: `${evidence.diff.slice(0, CORRECTION_DIFF_LIMIT)}\n` +
|
|
296
|
+
`[diff truncated at ${String(CORRECTION_DIFF_LIMIT)} of ${String(evidence.diff.length)} characters — ` +
|
|
297
|
+
"read the remainder from your own checkout with `git diff origin/HEAD...HEAD`]";
|
|
298
|
+
const proof =
|
|
299
|
+
evidence.proofCommands.length === 0
|
|
300
|
+
? "_The project names no gate commands. Run the repo's own tests and say exactly what you ran._"
|
|
301
|
+
: ["```sh", ...evidence.proofCommands, "```"].join("\n");
|
|
302
|
+
// The overlay's own trailing blank line separates it from the heading that
|
|
303
|
+
// follows; an absent overlay renders nothing at all, so the brief closes up
|
|
304
|
+
// around it rather than leaving an empty section behind.
|
|
305
|
+
const knowledge = evidence.knowledge ?? knowledgeSection(evidence.repo);
|
|
306
|
+
return renderBrief(template, {
|
|
307
|
+
ISSUE_NUMBER: String(evidence.issue),
|
|
308
|
+
ISSUE_TITLE: evidence.issueTitle ?? "(title unavailable at dispatch)",
|
|
309
|
+
REPO: evidence.repo,
|
|
310
|
+
PR_URL: evidence.prUrl,
|
|
311
|
+
HEAD_SHA: evidence.headSha,
|
|
312
|
+
CHECKS: evidence.checks,
|
|
313
|
+
WORKTREE: evidence.worktree,
|
|
314
|
+
BRANCH: evidence.branch,
|
|
315
|
+
ROUND:
|
|
316
|
+
evidence.maxRounds === undefined
|
|
317
|
+
? String(evidence.round)
|
|
318
|
+
: `${String(evidence.round)} of ${String(evidence.maxRounds)} allowed`,
|
|
319
|
+
REASON: evidence.reason,
|
|
320
|
+
FINDINGS: evidence.findings.trim(),
|
|
321
|
+
ACCEPTANCE_CRITERIA: evidence.issueBody.trim(),
|
|
322
|
+
PRIOR_ROUNDS: rounds,
|
|
323
|
+
DIFF: diff,
|
|
324
|
+
PROOF_COMMANDS: proof,
|
|
325
|
+
KNOWLEDGE: knowledge,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* What one fresh correction is launched with (#1046).
|
|
331
|
+
*
|
|
332
|
+
* Deliberately {@link WorkerOpts} minus two keys. `brief` is gone because this
|
|
333
|
+
* transport renders its own from `briefs/correction.md` — a caller that could
|
|
334
|
+
* pass a brief could pass the findings-only resume prompt, which is the failure
|
|
335
|
+
* this exists to end. `resume` is gone because it is not a decision available
|
|
336
|
+
* here: a "fresh correction" that resumes is #1046's named silent fake, and the
|
|
337
|
+
* cheapest way to make it unavailable is to make it inexpressible.
|
|
338
|
+
*
|
|
339
|
+
* Everything else — worktree, caps, sockets, grants, meters, pause control — is
|
|
340
|
+
* inherited unchanged, because a correction is an ordinary mutable worker run on
|
|
341
|
+
* an existing branch. The caller owns the run's identity and passes it through
|
|
342
|
+
* here: this transport creates no worktree, opens no pull request, and derives no
|
|
343
|
+
* issue or run of its own.
|
|
344
|
+
*/
|
|
345
|
+
export interface CorrectionWorkerOpts extends Omit<WorkerOpts, "brief" | "resume"> {
|
|
346
|
+
/** The evidence the brief is rendered from. */
|
|
347
|
+
evidence: CorrectionEvidence;
|
|
348
|
+
/**
|
|
349
|
+
* The transcript of the session this correction replaces, when the caller
|
|
350
|
+
* recorded one. Used for exactly one thing: proving the lineage actually
|
|
351
|
+
* changed (see {@link CorrectionResult.freshSession}).
|
|
352
|
+
*/
|
|
353
|
+
originSessionRef?: string;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** What one fresh correction produced (#1046). */
|
|
357
|
+
export interface CorrectionResult extends WorkerResult {
|
|
358
|
+
/**
|
|
359
|
+
* The transcript this correction opened — the lineage #1045 persists as
|
|
360
|
+
* `correctionSessionRef`. Absent when the session opened none.
|
|
361
|
+
*/
|
|
362
|
+
sessionRef?: string;
|
|
363
|
+
/**
|
|
364
|
+
* Whether this really was a new session. False when the harness handed back
|
|
365
|
+
* {@link CorrectionWorkerOpts.originSessionRef} — the exact silent fake #1046
|
|
366
|
+
* names, a new model string over the old session file — and false when no
|
|
367
|
+
* transcript was opened at all, because neither case proves a fresh lineage.
|
|
368
|
+
* A caller must not record a fresh-correction provenance it did not get.
|
|
369
|
+
*/
|
|
370
|
+
freshSession: boolean;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Launch one fresh correction on an existing branch, worktree and pull request
|
|
375
|
+
* (#1046).
|
|
376
|
+
*
|
|
377
|
+
* The failure this ends (#1035): a capped implementation session kept receiving
|
|
378
|
+
* correction rounds by `resume: true` after it had exhausted its turns and after
|
|
379
|
+
* the fleet had switched worker models, so every round was spent replaying a dead
|
|
380
|
+
* transcript under a model that was no longer configured.
|
|
381
|
+
*
|
|
382
|
+
* So this composes {@link runWorker} — the same session primitive, the same caps,
|
|
383
|
+
* the same verb surface — with exactly two things different: the brief is
|
|
384
|
+
* self-contained ({@link renderCorrectionBrief}) and `resume` is never passed, so
|
|
385
|
+
* the harness opens a new transcript under whatever model the caller resolved.
|
|
386
|
+
* A second worker runtime would be a second set of cap, settlement and pause
|
|
387
|
+
* semantics to keep in sync, which is how the two paths drift apart.
|
|
388
|
+
*
|
|
389
|
+
* What it deliberately does NOT do: nothing here touches the run's identity. The
|
|
390
|
+
* branch, worktree, pull request, issue, run id and verb channel all arrive from
|
|
391
|
+
* the caller and leave unchanged, so a correction cannot open a second PR, create
|
|
392
|
+
* a second worktree, or start a new attempt against the issue.
|
|
393
|
+
*/
|
|
394
|
+
export async function runCorrectionWorker(
|
|
395
|
+
o: CorrectionWorkerOpts,
|
|
396
|
+
deps: RunWorkerDeps = { createSession },
|
|
397
|
+
): Promise<CorrectionResult> {
|
|
398
|
+
const { evidence, originSessionRef, ...worker } = o;
|
|
399
|
+
// No `resume` key exists on `worker` to forward — the type forbids one, so
|
|
400
|
+
// `runWorker` takes its fresh-session path and the harness opens a new
|
|
401
|
+
// transcript in the run's own session directory.
|
|
402
|
+
const result = await runWorker({ ...worker, brief: renderCorrectionBrief(evidence) }, deps);
|
|
403
|
+
const sessionRef = result.sessionFile;
|
|
404
|
+
return {
|
|
405
|
+
...result,
|
|
406
|
+
...(sessionRef === undefined ? {} : { sessionRef }),
|
|
407
|
+
freshSession: sessionRef !== undefined && sessionRef !== originSessionRef,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
175
411
|
|
|
176
412
|
/**
|
|
177
413
|
* What the review-ceiling adjudicator is told (#877).
|
|
@@ -508,6 +744,19 @@ export interface WorkerResult {
|
|
|
508
744
|
* "the tool was missing" apart.
|
|
509
745
|
*/
|
|
510
746
|
graphTools?: GraphToolsObservation;
|
|
747
|
+
/**
|
|
748
|
+
* The repo facts this run reported in its structured settlement
|
|
749
|
+
* ({@link WorkerSettlement.discoveries}), verbatim and unjudged — the
|
|
750
|
+
* daemon appends them to the repo's knowledge overlay at settlement.
|
|
751
|
+
*
|
|
752
|
+
* Surfaced here rather than parsed back out of {@link report}: the rendered
|
|
753
|
+
* report is prose for a human, and re-deriving a list from it would be a
|
|
754
|
+
* second, weaker contract beside the schema the worker actually answered.
|
|
755
|
+
* Absent whenever the run yielded no structured settlement or named nothing
|
|
756
|
+
* — never an empty array, which would read as "the fleet learned nothing"
|
|
757
|
+
* rather than "the worker was not asked".
|
|
758
|
+
*/
|
|
759
|
+
discoveries?: string[];
|
|
511
760
|
}
|
|
512
761
|
|
|
513
762
|
/**
|
|
@@ -619,7 +868,7 @@ export function structuredSettlement(content: unknown): WorkerSettlement | undef
|
|
|
619
868
|
const value = record[key];
|
|
620
869
|
if (typeof value === "string" && value !== "") settlement[key] = value;
|
|
621
870
|
}
|
|
622
|
-
for (const key of ["blockers", "proof"] as const) {
|
|
871
|
+
for (const key of ["blockers", "proof", "discoveries"] as const) {
|
|
623
872
|
const value = record[key];
|
|
624
873
|
if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
|
|
625
874
|
settlement[key] = value;
|
|
@@ -807,6 +1056,199 @@ export async function runAdjudicator(
|
|
|
807
1056
|
};
|
|
808
1057
|
}
|
|
809
1058
|
|
|
1059
|
+
/**
|
|
1060
|
+
* The turn ceiling for one to-spec grooming scout (#1041).
|
|
1061
|
+
*
|
|
1062
|
+
* Half the adjudicator's 40, and for the opposite reason: an adjudication is
|
|
1063
|
+
* handed its whole evidence in the brief (issue, diff, checks, every prior
|
|
1064
|
+
* round) and spends its turns reasoning, while a scout is handed a candidate
|
|
1065
|
+
* and must go and READ — the source repo at a ref, the entry points, the
|
|
1066
|
+
* existing tests, the later work that may have retired the premise. That is
|
|
1067
|
+
* search, and search is where a session bounded only by a worker's budget
|
|
1068
|
+
* quietly spends a worker's budget. Twenty read/answer turns is enough for a
|
|
1069
|
+
* bounded repo walk plus the structured answer; a scout still going after
|
|
1070
|
+
* them has stopped grooming one candidate and started exploring the
|
|
1071
|
+
* repository, which is exactly the cost the mechanical selection bounds
|
|
1072
|
+
* everywhere else.
|
|
1073
|
+
*/
|
|
1074
|
+
export const TO_SPEC_MAX_TURNS = 20;
|
|
1075
|
+
|
|
1076
|
+
/** What one to-spec scout produced (#1041). */
|
|
1077
|
+
export interface ToSpecScoutResult {
|
|
1078
|
+
/**
|
|
1079
|
+
* The agent's answer, verbatim and unjudged — the structured payload it
|
|
1080
|
+
* yielded, serialized, or its own last text when it yielded nothing. This
|
|
1081
|
+
* string is `recordToSpecGrooming`'s `input`, and nothing here reads a field
|
|
1082
|
+
* of it: conductor re-validates every result through the strict parser at
|
|
1083
|
+
* persistence time, so a runner that pre-parsed would be a second, weaker
|
|
1084
|
+
* contract. Empty when the session produced neither.
|
|
1085
|
+
*/
|
|
1086
|
+
raw: string;
|
|
1087
|
+
/** 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. */
|
|
1089
|
+
report: string;
|
|
1090
|
+
/** What actually ran it, for the durable provenance (#875). */
|
|
1091
|
+
model?: string;
|
|
1092
|
+
provider?: string;
|
|
1093
|
+
turns: number;
|
|
1094
|
+
spendUsd: number;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
export interface ToSpecScoutOpts {
|
|
1098
|
+
/** The rendered `briefs/to-spec.md` for exactly one candidate. */
|
|
1099
|
+
brief: string;
|
|
1100
|
+
/** A directory the session may read: everything about the candidate is in
|
|
1101
|
+
* the brief, and a scout has no branch — so this is the session directory,
|
|
1102
|
+
* never a run worktree. */
|
|
1103
|
+
cwd: string;
|
|
1104
|
+
/** The model pattern `project.groomRole` resolved to, in omp's model/role
|
|
1105
|
+
* syntax. Omitted leaves the harness to choose. */
|
|
1106
|
+
model?: string;
|
|
1107
|
+
sessionDir?: string;
|
|
1108
|
+
ompSettingsFile?: string;
|
|
1109
|
+
socketPath?: string;
|
|
1110
|
+
onSpawn?: (pid: number) => void;
|
|
1111
|
+
onChildLog?: (line: string) => void;
|
|
1112
|
+
maySpawn?: () => boolean;
|
|
1113
|
+
/** Hard turn ceiling; the daemon passes {@link TO_SPEC_MAX_TURNS}. */
|
|
1114
|
+
maxTurns: number;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Run one bounded to-spec grooming pass (#1041).
|
|
1119
|
+
*
|
|
1120
|
+
* The adjudicator's twin, and read-only by the same construction: no
|
|
1121
|
+
* `verbSocketPath`, so no conductor mutation verb — no label change, no
|
|
1122
|
+
* promotion, no push — is reachable from this session. That absence is the
|
|
1123
|
+
* whole safety argument for letting the daemon launch grooming unattended:
|
|
1124
|
+
* the scout cannot queue the candidate it is grooming, only describe it. The
|
|
1125
|
+
* queue label is added later, by the daemon, and only through the ready gate.
|
|
1126
|
+
*
|
|
1127
|
+
* Two deliberate differences from {@link runAdjudicator}. It carries
|
|
1128
|
+
* {@link TO_SPEC_SCHEMA} with `outputSchemaMode: "strict"` — the to-spec
|
|
1129
|
+
* contract has always been invoked strictly (#772), and a permissive schema
|
|
1130
|
+
* would let a half-filled result through the harness only for the parser to
|
|
1131
|
+
* refuse it as malformed one layer later, spending the batch to learn nothing.
|
|
1132
|
+
* And it returns the answer UNPARSED: `recordToSpecGrooming` is the single
|
|
1133
|
+
* persistence path and the only validator, so a second decode here would be
|
|
1134
|
+
* a second contract to keep in sync.
|
|
1135
|
+
*/
|
|
1136
|
+
export async function runToSpecScout(
|
|
1137
|
+
o: ToSpecScoutOpts,
|
|
1138
|
+
deps: RunWorkerDeps = { createSession },
|
|
1139
|
+
): Promise<ToSpecScoutResult> {
|
|
1140
|
+
let session: AgentSessionLike;
|
|
1141
|
+
try {
|
|
1142
|
+
session = await deps.createSession({
|
|
1143
|
+
cwd: o.cwd,
|
|
1144
|
+
...(o.sessionDir === undefined ? {} : { sessionDir: o.sessionDir }),
|
|
1145
|
+
...(o.model === undefined ? {} : { model: o.model }),
|
|
1146
|
+
...(o.ompSettingsFile === undefined ? {} : { ompSettingsFile: o.ompSettingsFile }),
|
|
1147
|
+
...(o.socketPath === undefined ? {} : { socketPath: o.socketPath }),
|
|
1148
|
+
...(o.onSpawn === undefined ? {} : { onSpawn: o.onSpawn }),
|
|
1149
|
+
...(o.onChildLog === undefined ? {} : { onChildLog: o.onChildLog }),
|
|
1150
|
+
...(o.maySpawn === undefined ? {} : { maySpawn: o.maySpawn }),
|
|
1151
|
+
// Worker role, and deliberately no `verbSocketPath` — see the doc
|
|
1152
|
+
// comment: a scout reads and answers, it never mutates.
|
|
1153
|
+
role: "worker",
|
|
1154
|
+
outputSchema: TO_SPEC_SCHEMA,
|
|
1155
|
+
outputSchemaMode: "strict",
|
|
1156
|
+
requireYieldTool: true,
|
|
1157
|
+
});
|
|
1158
|
+
} catch (err) {
|
|
1159
|
+
// The pre-spawn admission gate closed, or the harness could not start.
|
|
1160
|
+
// Both are "no answer", and the caller settles the grooming row on the
|
|
1161
|
+
// failure text rather than leaving the in-flight marker to expire.
|
|
1162
|
+
return {
|
|
1163
|
+
raw: "",
|
|
1164
|
+
report: err instanceof Error ? err.message : String(err),
|
|
1165
|
+
turns: 0,
|
|
1166
|
+
spendUsd: 0,
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
let turns = 0;
|
|
1171
|
+
let spendUsd = 0;
|
|
1172
|
+
let report = "";
|
|
1173
|
+
let yielded = "";
|
|
1174
|
+
let model: string | undefined;
|
|
1175
|
+
let provider: string | undefined;
|
|
1176
|
+
const finished = Promise.withResolvers<void>();
|
|
1177
|
+
session.on("turn_start", () => {
|
|
1178
|
+
turns += 1;
|
|
1179
|
+
if (turns > o.maxTurns) session.abort();
|
|
1180
|
+
});
|
|
1181
|
+
session.on("message_end", (event) => {
|
|
1182
|
+
const message = field(event, "message");
|
|
1183
|
+
if (field(message, "role") !== "assistant") return;
|
|
1184
|
+
const content = field(message, "content");
|
|
1185
|
+
const payload = yieldedPayload(content);
|
|
1186
|
+
if (payload !== undefined) yielded = payload;
|
|
1187
|
+
const text = reportText(content);
|
|
1188
|
+
if (text !== "") report = text;
|
|
1189
|
+
const usage = field(message, "usage");
|
|
1190
|
+
const cost = usage === null ? null : field(usage, "cost");
|
|
1191
|
+
const total = cost === null ? null : field(cost, "total");
|
|
1192
|
+
if (typeof total === "number") spendUsd += total;
|
|
1193
|
+
const messageModel = field(message, "model");
|
|
1194
|
+
if (typeof messageModel === "string" && messageModel !== "") model = messageModel;
|
|
1195
|
+
const messageProvider = field(message, "provider");
|
|
1196
|
+
if (typeof messageProvider === "string" && messageProvider !== "") provider = messageProvider;
|
|
1197
|
+
});
|
|
1198
|
+
session.on("agent_end", (event) => {
|
|
1199
|
+
// Same rule as every other session runner: a non-terminal `agent_end`
|
|
1200
|
+
// means the harness will resume, so completing there would read a
|
|
1201
|
+
// mid-flight snapshot as the answer.
|
|
1202
|
+
if (shouldComplete(event as { isTerminal?: boolean })) finished.resolve();
|
|
1203
|
+
});
|
|
1204
|
+
session.on("session_exit", () => finished.resolve());
|
|
1205
|
+
try {
|
|
1206
|
+
await session.prompt(o.brief);
|
|
1207
|
+
await finished.promise;
|
|
1208
|
+
} finally {
|
|
1209
|
+
await disposeSession(session);
|
|
1210
|
+
}
|
|
1211
|
+
return {
|
|
1212
|
+
// The yielded payload first: under `requireYieldTool` that IS the answer,
|
|
1213
|
+
// and a scout's prose is usually a sentence about it rather than the
|
|
1214
|
+
// contract. The text stands in when nothing was yielded, so a session
|
|
1215
|
+
// that answered in a fenced JSON block still persists as a verdict and
|
|
1216
|
+
// one that only chatted persists as `blocked(malformed)` — which is the
|
|
1217
|
+
// honest record of a spent pass, not a discard.
|
|
1218
|
+
raw: yielded !== "" ? yielded : report,
|
|
1219
|
+
report,
|
|
1220
|
+
...(model === undefined ? {} : { model }),
|
|
1221
|
+
...(provider === undefined ? {} : { provider }),
|
|
1222
|
+
turns,
|
|
1223
|
+
spendUsd,
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* The `yield` call's payload, serialized, or `undefined` when this message
|
|
1229
|
+
* carried none (#1041).
|
|
1230
|
+
*
|
|
1231
|
+
* Transport, not decoding: it walks to `arguments.result.data` and stringifies
|
|
1232
|
+
* whatever object is there without reading one field of it. That is the
|
|
1233
|
+
* difference from {@link structuredVerdict} and {@link structuredSettlement},
|
|
1234
|
+
* which validate because their callers act on the parse — here the strict
|
|
1235
|
+
* to-spec parser at persistence time is the only validator, and a shape this
|
|
1236
|
+
* function rejected would be a verdict silently downgraded to "no answer".
|
|
1237
|
+
*/
|
|
1238
|
+
function yieldedPayload(content: unknown): string | undefined {
|
|
1239
|
+
if (!Array.isArray(content)) return undefined;
|
|
1240
|
+
let payload: string | undefined;
|
|
1241
|
+
for (const block of content) {
|
|
1242
|
+
if (field(block, "type") !== "toolCall") continue;
|
|
1243
|
+
if (field(block, "name") !== "yield") continue;
|
|
1244
|
+
const result = field(field(block, "arguments"), "result");
|
|
1245
|
+
const data = field(result, "data");
|
|
1246
|
+
if (data === null || data === undefined || typeof data !== "object") continue;
|
|
1247
|
+
payload = JSON.stringify(data);
|
|
1248
|
+
}
|
|
1249
|
+
return payload;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
810
1252
|
/**
|
|
811
1253
|
* Render a parsed {@link WorkerSettlement} as the run's stored report (#540).
|
|
812
1254
|
*
|
|
@@ -832,6 +1274,13 @@ export function renderSettlement(settlement: WorkerSettlement): string {
|
|
|
832
1274
|
lines.push("", "proof:");
|
|
833
1275
|
for (const item of settlement.proof) lines.push(` - ${item}`);
|
|
834
1276
|
}
|
|
1277
|
+
// Rendered like the other list fields so the stored report shows what was
|
|
1278
|
+
// appended to the repo's knowledge overlay: an entry a reader disagrees with
|
|
1279
|
+
// is traceable to the run that claimed it.
|
|
1280
|
+
if (settlement.discoveries !== undefined && settlement.discoveries.length > 0) {
|
|
1281
|
+
lines.push("", "discoveries:");
|
|
1282
|
+
for (const item of settlement.discoveries) lines.push(` - ${item}`);
|
|
1283
|
+
}
|
|
835
1284
|
return lines.join("\n");
|
|
836
1285
|
}
|
|
837
1286
|
|
|
@@ -1385,6 +1834,12 @@ export async function runWorker(
|
|
|
1385
1834
|
spendUsd,
|
|
1386
1835
|
provider429Count,
|
|
1387
1836
|
report: renderSettlement(structured),
|
|
1837
|
+
// The only exit that can carry them: a killed or unparseable run yielded
|
|
1838
|
+
// no settlement, and inventing an empty list there would tell the
|
|
1839
|
+
// knowledge overlay that the fleet asked and learned nothing.
|
|
1840
|
+
...(structured.discoveries === undefined || structured.discoveries.length === 0
|
|
1841
|
+
? {}
|
|
1842
|
+
: { discoveries: structured.discoveries }),
|
|
1388
1843
|
}));
|
|
1389
1844
|
}
|
|
1390
1845
|
|