omp-conductor 0.3.18 → 0.3.20

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
@@ -11,10 +11,14 @@
11
11
  */
12
12
 
13
13
  import { createSession, disposeSession } from "./omp.ts";
14
- import type { Caps, RunState } from "./types.ts";
14
+ import type { ReleaseShape } from "./release-policy.ts";
15
+ import type { Caps, ReleasePolicy, RunState } from "./types.ts";
15
16
 
16
- /** A PR link the worker pushed, recognised anywhere in its report. */
17
- const PR_URL_PATTERN = /https:\/\/github\.com\/\S+\/pull\/\d+/;
17
+ /** Structured evidence fields from the worker's final report. */
18
+ const PR_URL_PATTERN = /^pr:\s*(https:\/\/github\.com\/\S+\/pull\/\d+)\s*$/im;
19
+ const HEAD_SHA_PATTERN = /^head:\s*([0-9a-f]{40})\s*$/im;
20
+ const PUSHED_GREEN_PATTERN = /^state:\s*pushed-green\s*$/im;
21
+ const BLOCKED_PATTERN = /^state:\s*blocked\s*$/im;
18
22
 
19
23
  /** `{{KEY}}` placeholders in a brief template. */
20
24
  const PLACEHOLDER_PATTERN = /\{\{([A-Za-z0-9_]+)\}\}/g;
@@ -42,7 +46,20 @@ export interface WorkerOpts {
42
46
  * the harness to pick, which is what an unconfigured project wants.
43
47
  */
44
48
  model?: string;
49
+ /** Effective release/deploy gate for this session. */
50
+ releasePolicy?: ReleasePolicy;
51
+ /** Durable audit sink for rejected release/deploy calls. */
52
+ onReleaseBlocked?: (shape: ReleaseShape) => void;
53
+ /**
54
+ * Reads the effective ceiling at each turn boundary. Omitted, the configured
55
+ * startup cap remains fixed for the run.
56
+ */
57
+ maxTurns?: () => number;
58
+ /** Synchronous cap latch; called before the session abort begins. */
59
+ onKilled?: (by: KilledBy) => void;
45
60
  onTurn?: (n: number) => void;
61
+ /** Cumulative USD spend, reported as each cost-bearing message finishes. */
62
+ onSpend?: (usd: number) => void;
46
63
  /**
47
64
  * The transcript path, handed over the moment the session opens it rather
48
65
  * than at the end with {@link WorkerResult.sessionFile}. Both report the same
@@ -66,6 +83,7 @@ export interface RunWorkerDeps {
66
83
  export interface WorkerResult {
67
84
  state: RunState;
68
85
  prUrl?: string;
86
+ headSha?: string;
69
87
  turns: number;
70
88
  spendUsd: number;
71
89
  report: string;
@@ -101,23 +119,30 @@ export function renderBrief(template: string, vars: Record<string, string>): str
101
119
  }
102
120
 
103
121
  /**
104
- * Read the run's outcome out of the worker's final report.
122
+ * Read the run's structured outcome evidence from its final report.
105
123
  *
106
- * Success has to be claimed explicitly (`pushed-green`); everything else,
107
- * including an empty or unparseable report, is a failure. Defaulting the other
108
- * way would let a session that died mid-thought be reported as merge-ready.
124
+ * A textual `pushed-green` claim is not success by itself. The exact state line
125
+ * must carry both a PR URL and the head SHA observed after CI; the daemon then
126
+ * asks the tracker to verify those facts independently. Missing or malformed
127
+ * evidence fails closed.
109
128
  */
110
- export function deriveResult(report: string): { state: RunState; prUrl?: string } {
111
- const haystack = report.toLowerCase();
112
- const state: RunState = haystack.includes("pushed-green")
113
- ? "pushed-green"
114
- : haystack.includes("ci-red")
115
- ? "failed"
116
- : haystack.includes("blocked")
117
- ? "blocked"
118
- : "failed";
119
- const prUrl = PR_URL_PATTERN.exec(report)?.[0];
120
- return prUrl === undefined ? { state } : { state, prUrl };
129
+ export function deriveResult(report: string): {
130
+ state: RunState;
131
+ prUrl?: string;
132
+ headSha?: string;
133
+ } {
134
+ const prUrl = PR_URL_PATTERN.exec(report)?.[1];
135
+ const headSha = HEAD_SHA_PATTERN.exec(report)?.[1]?.toLowerCase();
136
+ if (PUSHED_GREEN_PATTERN.test(report) && prUrl !== undefined && headSha !== undefined) {
137
+ return { state: "pushed-green", prUrl, headSha };
138
+ }
139
+
140
+ const state: RunState = BLOCKED_PATTERN.test(report) ? "blocked" : "failed";
141
+ return {
142
+ state,
143
+ ...(prUrl === undefined ? {} : { prUrl }),
144
+ ...(headSha === undefined ? {} : { headSha }),
145
+ };
121
146
  }
122
147
 
123
148
  /**
@@ -145,8 +170,8 @@ export async function runWorker(
145
170
  o: WorkerOpts,
146
171
  deps: RunWorkerDeps = { createSession },
147
172
  ): Promise<WorkerResult> {
148
- // Read the caps once, by value: `o.caps` belongs to the caller's config.
149
- const { workerMaxTurns, workerWallClockMs } = o.caps;
173
+ const { workerWallClockMs } = o.caps;
174
+ const maxTurns = o.maxTurns ?? (() => o.caps.workerMaxTurns);
150
175
 
151
176
  const session = await deps.createSession({
152
177
  cwd: o.cwd,
@@ -154,6 +179,8 @@ export async function runWorker(
154
179
  ...(o.model === undefined ? {} : { model: o.model }),
155
180
  // Prevention half of #24: structured file tools cannot leave this worktree.
156
181
  confineToCwd: true,
182
+ ...(o.releasePolicy === undefined ? {} : { releasePolicy: o.releasePolicy }),
183
+ ...(o.onReleaseBlocked === undefined ? {} : { onReleaseBlocked: o.onReleaseBlocked }),
157
184
  });
158
185
 
159
186
  // Before the first turn, not after the last: a caller that only learns the
@@ -195,6 +222,7 @@ export async function runWorker(
195
222
  const kill = (by: KilledBy) => {
196
223
  if (killedBy !== undefined) return;
197
224
  killedBy = by;
225
+ o.onKilled?.(by);
198
226
  clearWallClock();
199
227
  session.abort();
200
228
  // An aborted session may never reach a terminal `agent_end`. The cap is the
@@ -208,7 +236,7 @@ export async function runWorker(
208
236
  // `message_end`s and would burn the cap on a run that is behaving.
209
237
  turns += 1;
210
238
  o.onTurn?.(turns);
211
- if (turns > workerMaxTurns) kill("turns");
239
+ if (turns > maxTurns()) kill("turns");
212
240
  });
213
241
 
214
242
  session.on("message_end", (event) => {
@@ -223,16 +251,25 @@ export async function runWorker(
223
251
  // transcripts, 2026-08-07). The earlier agent_end.telemetry path never
224
252
  // fired, so every run recorded $0 and the daily cap was theater (#46).
225
253
  const cost = costUsdFromMessage(message);
226
- if (cost !== undefined) spendUsd += cost;
254
+ if (cost !== undefined) {
255
+ spendUsd += cost;
256
+ o.onSpend?.(spendUsd);
257
+ }
227
258
  });
228
259
 
229
260
  session.on("agent_end", (event) => {
230
261
  // Fallback for harnesses that only attach cost on the terminal event.
231
262
  const estimated = field(field(field(event, "telemetry"), "cost"), "estimatedUsd");
232
- if (typeof estimated === "number" && Number.isFinite(estimated) && estimated > 0) {
263
+ if (
264
+ spendUsd === 0 &&
265
+ typeof estimated === "number" &&
266
+ Number.isFinite(estimated) &&
267
+ estimated > 0
268
+ ) {
233
269
  // Prefer message totals when both exist — do not double-count a run that
234
270
  // already accumulated per-message costs.
235
- if (spendUsd === 0) spendUsd += estimated;
271
+ spendUsd = estimated;
272
+ o.onSpend?.(spendUsd);
236
273
  }
237
274
 
238
275
  // Anything that is not literally `false` — including garbage or nothing at
@@ -283,12 +320,12 @@ export async function runWorker(
283
320
  return withSessionFacts({ state: "killed", turns, spendUsd, report, killedBy });
284
321
  }
285
322
 
286
- const { state, prUrl } = deriveResult(report);
287
- return withSessionFacts(
288
- prUrl === undefined
289
- ? { state, turns, spendUsd, report }
290
- : { state, prUrl, turns, spendUsd, report },
291
- );
323
+ return withSessionFacts({
324
+ ...deriveResult(report),
325
+ turns,
326
+ spendUsd,
327
+ report,
328
+ });
292
329
  }
293
330
 
294
331
  /**
package/src/worktree.ts CHANGED
@@ -608,3 +608,69 @@ export async function removeWorktree(
608
608
 
609
609
  await git(["worktree", "prune"], mirrorPath);
610
610
  }
611
+
612
+ export type RetainedWorktreeCleanup =
613
+ | { kind: "removed" }
614
+ | { kind: "retained"; reason: "dirty" | "unpushed" | "unknown"; detail: string };
615
+
616
+ /**
617
+ * Reap a terminal run's tree and local mirror branch without deleting the only
618
+ * copy of work. Tracker state is proved by the caller; this function proves the
619
+ * local half after refreshing remote refs. Any ambiguity retains everything.
620
+ */
621
+ export async function cleanupRetainedWorktree(
622
+ mirrorPath: string,
623
+ worktreePath: string,
624
+ branch: string,
625
+ ): Promise<RetainedWorktreeCleanup> {
626
+ if (!existsSync(mirrorPath)) {
627
+ return existsSync(worktreePath)
628
+ ? { kind: "retained", reason: "unknown", detail: "mirror is missing" }
629
+ : { kind: "removed" };
630
+ }
631
+
632
+ try {
633
+ if (existsSync(worktreePath)) {
634
+ const dirty = await git(["status", "--porcelain"], worktreePath);
635
+ if (dirty !== "") {
636
+ return { kind: "retained", reason: "dirty", detail: "worktree has uncommitted changes" };
637
+ }
638
+ const actual = await git(["rev-parse", "--abbrev-ref", "HEAD"], worktreePath);
639
+ if (actual !== branch) {
640
+ return {
641
+ kind: "retained",
642
+ reason: "unknown",
643
+ detail: `worktree is on ${actual}, expected ${branch}`,
644
+ };
645
+ }
646
+ }
647
+
648
+ // A deleted remote branch can make a pushed commit look local-only until
649
+ // the default branch is fetched. Failure is ambiguity, never permission.
650
+ await git(["fetch", "--prune", "origin"], mirrorPath);
651
+
652
+ const ref = `refs/heads/${branch}`;
653
+ if (await gitSucceeds(["show-ref", "--verify", "--quiet", ref], mirrorPath)) {
654
+ const unique = await git(["rev-list", ref, "--not", "--remotes"], mirrorPath);
655
+ if (unique !== "") {
656
+ return {
657
+ kind: "retained",
658
+ reason: "unpushed",
659
+ detail: `${branch} has commits absent from every remote ref`,
660
+ };
661
+ }
662
+ }
663
+
664
+ await removeWorktree(mirrorPath, worktreePath);
665
+ if (await gitSucceeds(["show-ref", "--verify", "--quiet", ref], mirrorPath)) {
666
+ await git(["branch", "-D", branch], mirrorPath);
667
+ }
668
+ return { kind: "removed" };
669
+ } catch (err) {
670
+ return {
671
+ kind: "retained",
672
+ reason: "unknown",
673
+ detail: err instanceof Error ? err.message : String(err),
674
+ };
675
+ }
676
+ }
@@ -16,8 +16,10 @@
16
16
  # fleet on a ≤8 GB box.
17
17
  #
18
18
  # Install:
19
- # sudo cp omp-conductor.service.example /etc/systemd/system/omp-conductor.service
20
- # sudo systemctl daemon-reload && sudo systemctl enable --now omp-conductor
19
+ # sudo install -m 0644 omp-conductor.service.example /etc/systemd/system/omp-conductor.service
20
+ # sudo systemctl daemon-reload
21
+ # sudo systemctl enable omp-conductor.service
22
+ # sudo systemctl restart omp-conductor.service
21
23
 
22
24
  [Unit]
23
25
  Description=omp-conductor dispatch daemon
@@ -38,7 +40,7 @@ WorkingDirectory=/home/fleet
38
40
 
39
41
  # Foreground daemon so systemd tracks MainPID. `omp-conductor start` backgrounds;
40
42
  # under a unit, call `daemon` directly.
41
- ExecStart=/home/fleet/.local/bin/omp-conductor daemon --port 7432
43
+ ExecStart=/home/fleet/.local/bin/omp-conductor daemon --port 8787
42
44
 
43
45
  Restart=on-failure
44
46
  # Handled SIGTERM exits 143; without this, Restart=on-failure brings the unit