omp-conductor 0.17.1 → 0.18.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/src/worker.ts CHANGED
@@ -30,6 +30,77 @@ const GITHUB_PR_URL_PATTERN = /https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/pull\/
30
30
  /** `{{KEY}}` placeholders in a brief template. */
31
31
  const PLACEHOLDER_PATTERN = /\{\{([A-Za-z0-9_]+)\}\}/g;
32
32
 
33
+ /**
34
+ * The structured settlement a worker yields at the end of a run (#540).
35
+ *
36
+ * Fields decided here, not per project. The daemon stays the authority on
37
+ * green — it verifies the PR itself — so this is the worker's *claim*,
38
+ * cross-checked exactly as the prose report it replaces: a `green` claim
39
+ * must carry the PR URL and the head SHA the worker actually watched go
40
+ * green, and anything less fails closed.
41
+ */
42
+ export interface WorkerSettlement {
43
+ status: "green" | "blocked" | "failed";
44
+ /** The run's pull request URL. Required with `status: "green"`. */
45
+ prUrl?: string;
46
+ /** The branch the PR was pushed from. */
47
+ branch?: string;
48
+ /** The 40-hex head SHA the green CI verdict was observed at. Required with `status: "green"`. */
49
+ headSha?: string;
50
+ /** What changed and why — the narrative a reviewer reads. */
51
+ summary: string;
52
+ /** What a decision or credential this run needed (blocked runs). */
53
+ blockers?: string[];
54
+ /** Commands run as evidence, exactly as executed. */
55
+ proof?: string[];
56
+ }
57
+
58
+ /**
59
+ * The JSON Schema form of {@link WorkerSettlement}, handed to the harness as
60
+ * the session's `outputSchema` (#540).
61
+ *
62
+ * Used two ways, and both must stay honest to the same contract:
63
+ * `requireYieldTool` puts the `yield` tool in front of the worker with this
64
+ * schema as its description, and `outputSchemaMode: "permissive"` means a
65
+ * violation is a fallback, never a lost report — the worker's text path must
66
+ * still settle a run whose yield does not parse.
67
+ */
68
+ export const WORKER_SETTLEMENT_SCHEMA = {
69
+ $schema: "https://json-schema.org/draft/2020-12/schema",
70
+ type: "object",
71
+ description:
72
+ "The run's settlement: what this worker claims it achieved. The daemon verifies the PR itself; " +
73
+ "this is the claim, cross-checked like the settlement report it replaces.",
74
+ additionalProperties: false,
75
+ required: ["status", "summary"],
76
+ properties: {
77
+ status: {
78
+ type: "string",
79
+ enum: ["green", "blocked", "failed"],
80
+ description:
81
+ "green: pushed and the checks you watched are green. blocked: a decision, credential or " +
82
+ "repo fact is missing. failed: the run could not complete.",
83
+ },
84
+ prUrl: { type: "string", description: "The run's pull request URL — required with status green." },
85
+ branch: { type: "string", description: "The branch the PR was pushed from." },
86
+ headSha: {
87
+ type: "string",
88
+ description: "The 40-character head SHA you observed green — required with status green.",
89
+ },
90
+ summary: { type: "string", description: "One short paragraph: what changed and why." },
91
+ blockers: {
92
+ type: "array",
93
+ items: { type: "string" },
94
+ description: "What is missing or uncertain, one item per blocker (blocked runs).",
95
+ },
96
+ proof: {
97
+ type: "array",
98
+ items: { type: "string" },
99
+ description: "The commands you ran as evidence, exactly as executed.",
100
+ },
101
+ },
102
+ } as const;
103
+
33
104
  function scheduleWallClock(callback: () => void, delayMs: number): () => void {
34
105
  const timer = setTimeout(callback, delayMs);
35
106
  return () => clearTimeout(timer);
@@ -88,7 +159,7 @@ export const REVIEW_REVISION_PROMPT =
88
159
  "The orchestrator reviewed your green pull request and found blocking findings. Continue this same session " +
89
160
  "on this same run: same branch, same pull request — do not close or reopen it, and do not open another. " +
90
161
  "Address exactly the findings below, push with conductor_push, verify the checks with conductor_pr_status, " +
91
- "and finish with the same final-report contract as before (state: pushed-green, pr:, head:). " +
162
+ "and finish with the same final-report contract as before (yield your settlement: status green, prUrl, headSha). " +
92
163
  "Your original brief is already in this transcript; redo only what the findings implicate.";
93
164
 
94
165
  /**
@@ -205,6 +276,13 @@ export interface WorkerResult {
205
276
  headSha?: string;
206
277
  turns: number;
207
278
  spendUsd: number;
279
+ /**
280
+ * The run's settlement text. Today this is the worker's own last words; a
281
+ * run that yielded a structured {@link WorkerSettlement} instead carries the
282
+ * canonical rendering of that parsed object (#540) — the narrative lives in
283
+ * its `summary`, transcribed here field for field rather than regexed out of
284
+ * prose.
285
+ */
208
286
  report: string;
209
287
  /** In-session HTTP 429 responses the session recorded (stopReason "error",
210
288
  * errorStatus 429), counted as the messages streamed in. A healthy run
@@ -277,12 +355,34 @@ export function renderBrief(template: string, vars: Record<string, string>): str
277
355
  * must carry both a PR URL and the head SHA observed after CI; the daemon then
278
356
  * asks the tracker to verify those facts independently. Missing or malformed
279
357
  * evidence fails closed.
358
+ *
359
+ * A parsed {@link WorkerSettlement} wins over the text whenever it is present
360
+ * (#540): the schema is the contract now, and a misleadingly regular-looking
361
+ * prose block must not out-vote the object the worker actually yielded. The
362
+ * text path is unchanged underneath, so a run that never yields settles
363
+ * exactly as it did before.
280
364
  */
281
- export function deriveResult(report: string, repoSlug?: string): {
365
+ export function deriveResult(
366
+ report: string,
367
+ repoSlug?: string,
368
+ structured?: WorkerSettlement,
369
+ ): {
282
370
  state: RunState;
283
371
  prUrl?: string;
284
372
  headSha?: string;
285
373
  } {
374
+ if (structured !== undefined && (structured.status === "green" || structured.status === "blocked" || structured.status === "failed")) {
375
+ const prUrl = structured.prUrl;
376
+ const headSha = structured.headSha?.toLowerCase();
377
+ if (structured.status === "green" && prUrl !== undefined && headSha !== undefined) {
378
+ return { state: "pushed-green", prUrl, headSha };
379
+ }
380
+ return {
381
+ state: structured.status === "blocked" ? "blocked" : "failed",
382
+ ...(prUrl === undefined ? {} : { prUrl }),
383
+ ...(headSha === undefined ? {} : { headSha }),
384
+ };
385
+ }
286
386
  const structuredPrUrl = PR_URL_PATTERN.exec(report)?.[1];
287
387
  const headSha = HEAD_SHA_PATTERN.exec(report)?.[1]?.toLowerCase();
288
388
  if (PUSHED_GREEN_PATTERN.test(report) && structuredPrUrl !== undefined && headSha !== undefined) {
@@ -305,6 +405,81 @@ export function deriveResult(report: string, repoSlug?: string): {
305
405
  };
306
406
  }
307
407
 
408
+ /**
409
+ * Parse a worker's structured settlement out of one assistant message's
410
+ * content (#540).
411
+ *
412
+ * The worker calls the `yield` tool with `{ result: { data: <settlement> } }`
413
+ * and the harness records that call as a `toolCall` content block on the
414
+ * assistant message — the same content `reportText` flattens. Parsed
415
+ * permissively on purpose: `outputSchemaMode` is permissive, so a yield the
416
+ * schema rejected must still fall back to the text path rather than vanish.
417
+ * The acceptance bar mirrors the schema's own `required`: a recognizable
418
+ * `status` and a string `summary`; every other field is adopted when it has
419
+ * the right type and dropped otherwise. Exported so the run loop's precedence
420
+ * (yield over prose) is pinned by a unit test.
421
+ */
422
+ export function structuredSettlement(content: unknown): WorkerSettlement | undefined {
423
+ if (!Array.isArray(content)) return undefined;
424
+ let parsed: WorkerSettlement | undefined;
425
+ for (const block of content) {
426
+ if (field(block, "type") !== "toolCall") continue;
427
+ if (field(block, "name") !== "yield") continue;
428
+ const args = field(block, "arguments");
429
+ if (args === null || typeof args !== "object") continue;
430
+ const result = field(args, "result");
431
+ if (result === null || typeof result !== "object") continue;
432
+ const data = field(result, "data");
433
+ if (data === null || typeof data !== "object" || Array.isArray(data)) continue;
434
+ const record = data as Record<string, unknown>;
435
+ const status = record.status;
436
+ if (status !== "green" && status !== "blocked" && status !== "failed") continue;
437
+ const summary = record.summary;
438
+ if (typeof summary !== "string") continue;
439
+ const settlement: WorkerSettlement = { status, summary };
440
+ for (const key of ["prUrl", "branch", "headSha"] as const) {
441
+ const value = record[key];
442
+ if (typeof value === "string" && value !== "") settlement[key] = value;
443
+ }
444
+ for (const key of ["blockers", "proof"] as const) {
445
+ const value = record[key];
446
+ if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
447
+ settlement[key] = value;
448
+ }
449
+ }
450
+ parsed = settlement;
451
+ }
452
+ return parsed;
453
+ }
454
+
455
+ /**
456
+ * Render a parsed {@link WorkerSettlement} as the run's stored report (#540).
457
+ *
458
+ * A worker that yields structured output may write little or no prose after
459
+ * the yield call, and the prose shape is no longer the contract — so the
460
+ * report the store keeps for such a run is this canonical rendering of the
461
+ * parsed object itself: the same fields a reviewer would have had to regex
462
+ * out of prose, derived from the object, field for field. The daemon's own
463
+ * postfixes — the PR-diff `changed:` line and the reliability sentence — are
464
+ * appended to it exactly as they are to a prose report.
465
+ */
466
+ export function renderSettlement(settlement: WorkerSettlement): string {
467
+ const lines: string[] = [`status: ${settlement.status}`];
468
+ if (settlement.prUrl !== undefined) lines.push(`pr: ${settlement.prUrl}`);
469
+ if (settlement.branch !== undefined) lines.push(`branch: ${settlement.branch}`);
470
+ if (settlement.headSha !== undefined) lines.push(`head: ${settlement.headSha}`);
471
+ if (settlement.summary !== "") lines.push("", settlement.summary);
472
+ if (settlement.blockers !== undefined && settlement.blockers.length > 0) {
473
+ lines.push("", "blockers:");
474
+ for (const blocker of settlement.blockers) lines.push(` - ${blocker}`);
475
+ }
476
+ if (settlement.proof !== undefined && settlement.proof.length > 0) {
477
+ lines.push("", "proof:");
478
+ for (const item of settlement.proof) lines.push(` - ${item}`);
479
+ }
480
+ return lines.join("\n");
481
+ }
482
+
308
483
  /**
309
484
  * Did this report state a verdict at all?
310
485
  *
@@ -380,6 +555,13 @@ export async function runWorker(
380
555
  ...(o.verbSocketPath === undefined ? {} : { verbSocketPath: o.verbSocketPath }),
381
556
  ...(o.onChildLog === undefined ? {} : { onChildLog: o.onChildLog }),
382
557
  ...(o.maySpawn === undefined ? {} : { maySpawn: o.maySpawn }),
558
+ // The structured settlement contract (#540): the worker's `yield` tool
559
+ // validates its `data` payload against this schema, permissively — an
560
+ // invalid or absent yield falls back to the text path, never a lost
561
+ // report. Always-on for workers: one schema, and no per-project shape.
562
+ outputSchema: WORKER_SETTLEMENT_SCHEMA,
563
+ outputSchemaMode: "permissive",
564
+ requireYieldTool: true,
383
565
  });
384
566
  } catch (err) {
385
567
  // The pre-spawn gate closed (#374): a daemon stop landed while the session
@@ -473,6 +655,10 @@ export async function runWorker(
473
655
  let spendUsd = 0;
474
656
  let provider429Count = 0;
475
657
  let report = "";
658
+ // The newest structured settlement the worker yielded (#540). A terminal
659
+ // `yield` ends the run, so anything parsed here is the run's own claim, and
660
+ // any later message is stale-polling chatter, never a newer verdict.
661
+ let structured: WorkerSettlement | undefined;
476
662
  // Which model/provider actually wrote the newest assistant message. Last
477
663
  // assistant message wins: that is the durable answer even for a run that
478
664
  // never failed over (#535 slice 1, read off the message field which is where
@@ -629,15 +815,22 @@ export async function runWorker(
629
815
  session.on("message_end", (event) => {
630
816
  const message = field(event, "message");
631
817
  if (field(message, "role") !== "assistant") return;
818
+ const content = field(message, "content");
632
819
  // Keep the newest non-empty assistant text: whatever the worker said last
633
820
  // is its report, whether it finished cleanly or was cut off.
634
- const text = reportText(field(message, "content"));
821
+ const text = reportText(content);
635
822
  if (text !== "") {
636
823
  report = text;
637
- const stated = deriveResult(text, o.repoSlug);
638
- if (stated.state === "pushed-green" && stated.prUrl !== undefined && stated.headSha !== undefined) {
639
- claim = { prUrl: stated.prUrl, headSha: stated.headSha };
640
- }
824
+ }
825
+ // The structured settlement (#540): the `yield` tool call rides the same
826
+ // message's content as a `toolCall` block. Newest yield wins; the
827
+ // derivation itself prefers it over prose, so a yield and a misleadingly
828
+ // regular-looking text block on the same message agree in its favour.
829
+ const yielded = structuredSettlement(content);
830
+ if (yielded !== undefined) structured = yielded;
831
+ const stated = deriveResult(text, o.repoSlug, yielded);
832
+ if (stated.state === "pushed-green" && stated.prUrl !== undefined && stated.headSha !== undefined) {
833
+ claim = { prUrl: stated.prUrl, headSha: stated.headSha };
641
834
  }
642
835
 
643
836
  // Real cost lives on assistant messages as `usage.cost.total` (live hermes
@@ -802,6 +995,20 @@ export async function runWorker(
802
995
  }));
803
996
  }
804
997
 
998
+ // A structured yield is the run's settlement: it wins over the last text —
999
+ // the worker may have written nothing after the yield call, and the stored
1000
+ // report is the canonical rendering of the parsed object, not prose the
1001
+ // regex has to recover (#540).
1002
+ if (structured !== undefined) {
1003
+ return withMetrics(withSessionFacts({
1004
+ ...deriveResult(report, o.repoSlug, structured),
1005
+ turns,
1006
+ spendUsd,
1007
+ provider429Count,
1008
+ report: renderSettlement(structured),
1009
+ }));
1010
+ }
1011
+
805
1012
  // An explicit later verdict always wins: a worker that pushed green and then
806
1013
  // stopped to ask a question means the question. The earlier claim is only
807
1014
  // restored when the last thing said was not a verdict at all.
package/src/worktree.ts CHANGED
@@ -759,12 +759,87 @@ export async function removeWorktree(
759
759
 
760
760
  export type RetainedWorktreeCleanup =
761
761
  | { kind: "removed" }
762
- | { kind: "retained"; reason: "dirty" | "unpushed" | "unknown"; detail: string };
762
+ | {
763
+ kind: "retained";
764
+ reason: "dirty" | "unpushed" | "unknown" | "quarantined";
765
+ detail: string;
766
+ };
767
+
768
+ /**
769
+ * Repairs a run repository whose `objects/info/alternates` names a path that no
770
+ * longer exists, re-pointing the dangling entry at this project's current
771
+ * mirror. Worktrees created before mirrors moved into per-project roots keep
772
+ * the old flat path (`<home>/mirrors/<repo>.git`); the store they borrow from
773
+ * is gone, so any fetch against them dies on unresolved deltas (#728). The
774
+ * repair belongs here — in the code that discovers the stale entry — rather
775
+ * than in a one-off host edit, so the next layout migration heals itself too.
776
+ *
777
+ * Only missing entries are rewritten: a valid alternate is left alone and a
778
+ * worktree with no alternates file is already sound. When the store cannot be
779
+ * made sound — the git dir is unresolvable, the file cannot be read or
780
+ * written, or a missing path survives the rewrite — the caller must not fetch
781
+ * into the broken state; a quarantine detail is returned instead.
782
+ */
783
+ async function repairAlternates(
784
+ worktreePath: string,
785
+ mirrorPath: string,
786
+ ): Promise<{ kind: "ok" } | { kind: "quarantine"; detail: string }> {
787
+ const common = await runGit(["rev-parse", "--git-common-dir"], worktreePath);
788
+ if (common.code !== 0) {
789
+ return {
790
+ kind: "quarantine",
791
+ detail: `cannot resolve the git dir: ${common.stderr.trim() || common.stdout.trim() || "no output"}`,
792
+ };
793
+ }
794
+ const raw = common.stdout.trim();
795
+ if (raw === "") {
796
+ return { kind: "quarantine", detail: "cannot resolve the git dir: git printed nothing" };
797
+ }
798
+ // `--git-common-dir` is relative for linked worktrees; resolve against the
799
+ // tree so bare-mirror layouts and plain clones both land on the alternates.
800
+ const commonDir = raw.startsWith("/") ? raw : join(worktreePath, raw);
801
+ const alternates = join(commonDir, "objects", "info", "alternates");
802
+ if (!existsSync(alternates)) return { kind: "ok" };
803
+
804
+ const current = join(mirrorPath, "objects");
805
+ let content: string;
806
+ try {
807
+ content = readFileSync(alternates, "utf8");
808
+ } catch (err) {
809
+ return { kind: "quarantine", detail: `${alternates} cannot be read: ${err instanceof Error ? err.message : String(err)}` };
810
+ }
811
+
812
+ let changed = false;
813
+ const rewritten = content.split("\n").map((line) => {
814
+ const candidate = line.trim();
815
+ if (candidate === "" || candidate === current || existsSync(candidate)) return line;
816
+ changed = true;
817
+ return current;
818
+ });
819
+ if (changed) {
820
+ try {
821
+ writeFileSync(alternates, rewritten.join("\n"));
822
+ } catch (err) {
823
+ return { kind: "quarantine", detail: `${alternates} cannot be rewritten: ${err instanceof Error ? err.message : String(err)}` };
824
+ }
825
+ }
826
+
827
+ const dangling = rewritten.map((line) => line.trim()).filter((path) => path !== "" && !existsSync(path));
828
+ if (dangling.length > 0) {
829
+ return {
830
+ kind: "quarantine",
831
+ detail: `${alternates} still names missing path(s): ${dangling.join(", ")}`,
832
+ };
833
+ }
834
+ return { kind: "ok" };
835
+ }
763
836
 
764
837
  /**
765
838
  * Reap a terminal run's tree and local mirror branch without deleting the only
766
839
  * copy of work. Tracker state is proved by the caller; this function proves the
767
- * local half after refreshing remote refs. Any ambiguity retains everything.
840
+ * local half after refreshing remote refs. Any ambiguity retains everything;
841
+ * a remote read that failed is ambiguity, never a pushed/unpushed claim, and
842
+ * the unpushed verdicts below are reachable only from reads that succeeded.
768
843
  */
769
844
  export async function cleanupRetainedWorktree(
770
845
  mirrorPath: string,
@@ -779,6 +854,20 @@ export async function cleanupRetainedWorktree(
779
854
 
780
855
  try {
781
856
  if (existsSync(worktreePath)) {
857
+ // A run repo created before mirrors moved into per-project roots may
858
+ // borrow objects from a path that no longer exists. Fetching into it is
859
+ // a guaranteed failure, so repair the alternates first — and when the
860
+ // store cannot be made sound, quarantine the tree instead of fetching
861
+ // into the broken state.
862
+ const repair = await repairAlternates(worktreePath, mirrorPath);
863
+ if (repair.kind === "quarantine") {
864
+ return {
865
+ kind: "retained",
866
+ reason: "quarantined",
867
+ detail: `worktree quarantined: ${repair.detail}`,
868
+ };
869
+ }
870
+
782
871
  const dirty = await git(["status", "--porcelain"], worktreePath);
783
872
  if (dirty !== "") {
784
873
  return { kind: "retained", reason: "dirty", detail: "worktree has uncommitted changes" };
@@ -794,8 +883,18 @@ export async function cleanupRetainedWorktree(
794
883
  }
795
884
 
796
885
  // A deleted remote branch can make a pushed commit look local-only until
797
- // the default branch is fetched. Failure is ambiguity, never permission.
798
- await git(["fetch", "--prune", "origin"], mirrorPath);
886
+ // the default branch is fetched. A failed fetch is ambiguity, never
887
+ // permission: without fresh remote refs the unpushed verdicts below are
888
+ // unreachable and the run is retained on an explicit unknown.
889
+ try {
890
+ await git(["fetch", "--prune", "origin"], mirrorPath);
891
+ } catch (err) {
892
+ return {
893
+ kind: "retained",
894
+ reason: "unknown",
895
+ detail: `remote refs could not be refreshed: ${err instanceof Error ? err.message : String(err)}`,
896
+ };
897
+ }
799
898
 
800
899
  const ref = `refs/heads/${branch}`;
801
900
 
@@ -806,10 +905,18 @@ export async function cleanupRetainedWorktree(
806
905
  // data loss #121 exists to prevent, reintroduced by the move to per-run
807
906
  // repositories, so it is checked where the objects actually are.
808
907
  if (existsSync(worktreePath)) {
809
- await git(
810
- ["fetch", "--no-tags", mirrorPath, "+refs/remotes/origin/*:refs/remotes/origin/*"],
811
- worktreePath,
812
- );
908
+ try {
909
+ await git(
910
+ ["fetch", "--no-tags", mirrorPath, "+refs/remotes/origin/*:refs/remotes/origin/*"],
911
+ worktreePath,
912
+ );
913
+ } catch (err) {
914
+ return {
915
+ kind: "retained",
916
+ reason: "unknown",
917
+ detail: `the run repository's remote refs could not be read: ${err instanceof Error ? err.message : String(err)}`,
918
+ };
919
+ }
813
920
  const runUnique = await git(["rev-list", ref, "--not", "--remotes"], worktreePath);
814
921
  if (runUnique !== "") {
815
922
  return {