omp-conductor 0.3.7 → 0.3.11

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.
@@ -94,6 +94,14 @@ const RETRY_OWNERSHIP_MS = 60_000;
94
94
  export const STALL_MARKER_FILE = ".conductor-stalled";
95
95
  export const STALL_TICKS = 2;
96
96
 
97
+ /**
98
+ * Written by herdr-conductor `recover.sh` *before* `agent start`, so a resumed
99
+ * fleet can reconcile orphans without waiting a full `intervalSeconds`. Cleared
100
+ * only after a tick is actually sent — a disarmed or channel-down fleet keeps
101
+ * the request until gates pass (or a human removes the file).
102
+ */
103
+ export const TICK_REQUESTED_FILE = ".conductor-tick-requested";
104
+
97
105
  /**
98
106
  * The marker's one line after its ISO timestamp, and the middle of the error
99
107
  * log. Shared so the file and the log can never describe different failures.
@@ -834,6 +842,23 @@ function clearStallMarker(pi: TickApi, cwd: string): void {
834
842
  }
835
843
  }
836
844
 
845
+ /**
846
+ * Best-effort, same posture as {@link clearStallMarker}. Leaving the file on a
847
+ * failed unlink means the next successful send retries the clear; that is
848
+ * preferable to treating a recover poke as fire-and-forget when the tick did
849
+ * land.
850
+ */
851
+ function clearTickRequest(pi: TickApi, cwd: string): void {
852
+ const path = join(cwd, TICK_REQUESTED_FILE);
853
+ if (!existsSync(path)) return;
854
+ try {
855
+ rmSync(path, { force: true });
856
+ pi.logger.info("[omp-conductor] recover tick request cleared: a tick was sent");
857
+ } catch (err) {
858
+ pi.logger.error(`[omp-conductor] could not remove ${path}: ${err instanceof Error ? err.message : String(err)}`);
859
+ }
860
+ }
861
+
837
862
  /** Everything one tick remembers for the next. */
838
863
  interface TickSession {
839
864
  /**
@@ -900,6 +925,21 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
900
925
  // this is the only place either the counter or the marker is cleared.
901
926
  session.pendingSkips = 0;
902
927
  clearStallMarker(pi, ctx.cwd);
928
+ // Recover poke is consumed only on a real send — gates still apply above.
929
+ clearTickRequest(pi, ctx.cwd);
930
+ }
931
+
932
+ /**
933
+ * Arm the interval heartbeat, then honour a recover poke if one is waiting.
934
+ * Extracted so the ownership-retry path and the immediate-accept path cannot
935
+ * drift: both must fire the same "do not wait a full interval after resume"
936
+ * behaviour.
937
+ */
938
+ function armTickHeartbeat(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSession): void {
939
+ ctx.setInterval(() => tick(pi, ctx, config, session), config.intervalSeconds * 1000);
940
+ if (!existsSync(join(ctx.cwd, TICK_REQUESTED_FILE))) return;
941
+ pi.logger.info("[omp-conductor] tick requested by recover — firing without waiting for the interval");
942
+ tick(pi, ctx, config, session);
903
943
  }
904
944
 
905
945
  export default function orchestratorTickExtension(pi: TickApi): void {
@@ -995,7 +1035,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
995
1035
  return;
996
1036
  }
997
1037
  if (next.note !== undefined) pi.logger.error(`[omp-conductor] tick ownership: ${next.note}`);
998
- ctx.setInterval(() => tick(pi, ctx, config, session), config.intervalSeconds * 1000);
1038
+ armTickHeartbeat(pi, ctx, config, session);
999
1039
  pi.logger.info(`[omp-conductor] orchestrator tick active: ownership resolved on retry`, { agentName });
1000
1040
  }, retryMs);
1001
1041
  decided = true;
@@ -1008,7 +1048,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1008
1048
  return;
1009
1049
  }
1010
1050
  if (ownership.note !== undefined) pi.logger.error(`[omp-conductor] tick ownership: ${ownership.note}`);
1011
- ctx.setInterval(() => tick(pi, ctx, config, session), config.intervalSeconds * 1000);
1051
+ armTickHeartbeat(pi, ctx, config, session);
1012
1052
  decided = true;
1013
1053
  // Both gates are named at startup: "why is it not ticking?" is answered by
1014
1054
  // looking at the files this line lists, and an unset channel gate on a fleet
package/src/plugin.ts CHANGED
@@ -193,6 +193,30 @@ async function askNumber(ctx: CommandContext, title: string, fallback: number):
193
193
  return value;
194
194
  }
195
195
 
196
+ /**
197
+ * Daily spend ceiling. Blank / "none" / "off" → null (no gate). Unparseable
198
+ * non-empty input keeps the current value. Distinct from askNumber so operators
199
+ * can turn the money brake off without writing a magic 0 (which is a hard stop).
200
+ */
201
+ async function askSpendCap(
202
+ ctx: CommandContext,
203
+ title: string,
204
+ fallback: number | null,
205
+ ): Promise<number | null> {
206
+ const seed = fallback === null ? "" : String(fallback);
207
+ const raw = (await ask(ctx, title, seed)).trim().toLowerCase();
208
+ if (raw === "" || raw === "none" || raw === "off" || raw === "null") return null;
209
+ const value = Number(raw);
210
+ if (!Number.isFinite(value) || value < 0) {
211
+ ctx.ui.notify(
212
+ `"${raw}" is not a non-negative number or blank — keeping ${fallback === null ? "no cap" : fallback}.`,
213
+ "warning",
214
+ );
215
+ return fallback;
216
+ }
217
+ return value;
218
+ }
219
+
196
220
  /**
197
221
  * Pre-push gates as one comma-separated line, `cmd @ cwd` for a subdirectory:
198
222
  * `bun run check, bun test @ server`. Shown through `formatGates`, the same
@@ -500,10 +524,12 @@ const askGraph: AreaAsker = async (ctx, a) => {
500
524
  * answer for anyone who has not measured their own runners. */
501
525
  const askCaps: AreaAsker = async (ctx, a) => {
502
526
  const caps: Partial<Caps> = { ...a.caps };
527
+ const spendLabel =
528
+ DEFAULT_CAPS.dailySpendUsd === null ? "no spend cap" : `$${DEFAULT_CAPS.dailySpendUsd}/day`;
503
529
  const tuneCaps = await ctx.ui.confirm(
504
530
  "Caps",
505
531
  `Defaults: ${DEFAULT_CAPS.maxConcurrentWorkers} workers, ` +
506
- `$${DEFAULT_CAPS.dailySpendUsd}/day, ${DEFAULT_CAPS.workerMaxTurns} turns and ` +
532
+ `${spendLabel}, ${DEFAULT_CAPS.workerMaxTurns} turns and ` +
507
533
  `${Math.round(DEFAULT_CAPS.workerWallClockMs / 60000)} min per worker, ` +
508
534
  `${DEFAULT_CAPS.maxAttemptsPerIssue} attempts per issue. Change them?`,
509
535
  );
@@ -516,7 +542,11 @@ const askCaps: AreaAsker = async (ctx, a) => {
516
542
  "Max concurrent workers",
517
543
  caps.maxConcurrentWorkers ?? DEFAULT_CAPS.maxConcurrentWorkers,
518
544
  );
519
- caps.dailySpendUsd = await askNumber(ctx, "Spend ceiling per rolling day (USD)", caps.dailySpendUsd ?? DEFAULT_CAPS.dailySpendUsd);
545
+ caps.dailySpendUsd = await askSpendCap(
546
+ ctx,
547
+ "Spend ceiling per rolling day (USD) — blank = no spend cap",
548
+ caps.dailySpendUsd !== undefined ? caps.dailySpendUsd : DEFAULT_CAPS.dailySpendUsd,
549
+ );
520
550
  caps.workerMaxTurns = await askNumber(ctx, "Turn ceiling per worker", caps.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns);
521
551
  caps.workerWallClockMs = await askNumber(
522
552
  ctx,
package/src/setup.ts CHANGED
@@ -954,9 +954,11 @@ export const AMEND_AREAS: {
954
954
  describe: (p) => {
955
955
  const c = resolveCaps(p, DEFAULT_CAPS);
956
956
  const answered = Object.keys(p.caps).length > 0;
957
+ const spend =
958
+ c.dailySpendUsd === null ? "no spend cap" : `$${c.dailySpendUsd}/day`;
957
959
  return (
958
960
  `${c.maxConcurrentWorkers} workers, ${c.workerMaxTurns} turns, ` +
959
- `${Math.round(c.workerWallClockMs / 60000)}m, $${c.dailySpendUsd}/day, ` +
961
+ `${Math.round(c.workerWallClockMs / 60000)}m, ${spend}, ` +
960
962
  `${c.maxAttemptsPerIssue} attempts${answered ? "" : " (all defaults)"} — ` +
961
963
  `${p.workerModel === undefined ? "harness default model" : `model ${p.workerModel}`}`
962
964
  );
package/src/types.ts CHANGED
@@ -16,8 +16,11 @@ export interface Caps {
16
16
  /** Parallel omp sessions. Two by default: on a small self-hosted runner pool
17
17
  * a third worker would starve its own PR checks. */
18
18
  maxConcurrentWorkers: number;
19
- /** Rolling-day spend ceiling; the loop stops claiming work once it is hit. */
20
- dailySpendUsd: number;
19
+ /**
20
+ * Rolling-day spend ceiling. `null` means no spend gate (turns + wall-clock
21
+ * still apply). `0` is a hard stop — deliberate, not "unset".
22
+ */
23
+ dailySpendUsd: number | null;
21
24
  /** Turn ceiling for one worker — catches loops that are burning tokens
22
25
  * without converging. */
23
26
  workerMaxTurns: number;
package/src/worker.ts CHANGED
@@ -216,15 +216,22 @@ export async function runWorker(
216
216
  // is its report, whether it finished cleanly or was cut off.
217
217
  const text = reportText(field(message, "content"));
218
218
  if (text !== "") report = text;
219
+
220
+ // Real cost lives on assistant messages as `usage.cost.total` (live hermes
221
+ // transcripts, 2026-08-07). The earlier agent_end.telemetry path never
222
+ // fired, so every run recorded $0 and the daily cap was theater (#46).
223
+ const cost = costUsdFromMessage(message);
224
+ if (cost !== undefined) spendUsd += cost;
219
225
  });
220
226
 
221
227
  session.on("agent_end", (event) => {
222
- // ponytail: cost only arrives when the harness run carries telemetry, so
223
- // spend can legitimately read 0 and the daily-spend cap then leans on the
224
- // turn and wall-clock ceilings. Upgrade path: pass a telemetry config
225
- // through `createSession` once the harness exposes it on the SDK options.
228
+ // Fallback for harnesses that only attach cost on the terminal event.
226
229
  const estimated = field(field(field(event, "telemetry"), "cost"), "estimatedUsd");
227
- if (typeof estimated === "number" && Number.isFinite(estimated)) spendUsd += estimated;
230
+ if (typeof estimated === "number" && Number.isFinite(estimated) && estimated > 0) {
231
+ // Prefer message totals when both exist — do not double-count a run that
232
+ // already accumulated per-message costs.
233
+ if (spendUsd === 0) spendUsd += estimated;
234
+ }
228
235
 
229
236
  // Anything that is not literally `false` — including garbage or nothing at
230
237
  // all — is a finished run.
@@ -282,6 +289,31 @@ export async function runWorker(
282
289
  );
283
290
  }
284
291
 
292
+ /**
293
+ * USD cost from one assistant message's `usage.cost` block.
294
+ *
295
+ * Prefer `total` when present; otherwise sum the component fields the live
296
+ * harness emits (input/output/cacheRead/cacheWrite). Exported so a unit test
297
+ * can pin the shape without standing up a session.
298
+ */
299
+ export function costUsdFromMessage(message: unknown): number | undefined {
300
+ const usage = field(message, "usage");
301
+ const cost = field(usage, "cost");
302
+ if (cost === null || typeof cost !== "object") return undefined;
303
+ const total = field(cost, "total");
304
+ if (typeof total === "number" && Number.isFinite(total) && total >= 0) return total;
305
+ let sum = 0;
306
+ let any = false;
307
+ for (const key of ["input", "output", "cacheRead", "cacheWrite"] as const) {
308
+ const v = field(cost, key);
309
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0) {
310
+ sum += v;
311
+ any = true;
312
+ }
313
+ }
314
+ return any ? sum : undefined;
315
+ }
316
+
285
317
  /**
286
318
  * Read one property off an unvalidated harness event. The event union lives in
287
319
  * the peer dependency, so the worker narrows the handful of fields it reads
package/src/worktree.ts CHANGED
@@ -194,6 +194,40 @@ async function configureMirror(mirrorPath: string): Promise<void> {
194
194
  writeFileSync(exclude, mergeExclude(existsSync(exclude) ? readFileSync(exclude, "utf8") : ""));
195
195
  }
196
196
 
197
+ /**
198
+ * Rewrites this worktree's common `info/exclude` managed block to the package's
199
+ * current list. Salvage must do this itself: it is the path that runs after a
200
+ * package swap, when the mirror may still carry a previous release's patterns,
201
+ * and `git add -A` would otherwise silently skip legitimate new files the old
202
+ * list happened to name (dogfood 2026-08-07 / #44).
203
+ *
204
+ * Failures are swallowed — a missing common dir is "no exclude to heal", and
205
+ * the salvage dirty check still runs. Never throws into the salvage outcome.
206
+ */
207
+ function refreshManagedExclude(worktree: string): void {
208
+ try {
209
+ // `--git-common-dir` is relative for linked worktrees; resolve against the
210
+ // tree so bare-mirror layouts and plain clones both land on info/exclude.
211
+ const common = Bun.spawnSync(["git", "rev-parse", "--git-common-dir"], {
212
+ cwd: worktree,
213
+ stdin: "ignore",
214
+ stdout: "pipe",
215
+ stderr: "pipe",
216
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
217
+ });
218
+ if (common.exitCode !== 0) return;
219
+ const raw = common.stdout.toString().trim();
220
+ if (raw === "") return;
221
+ const commonDir = raw.startsWith("/") ? raw : join(worktree, raw);
222
+ const exclude = join(commonDir, "info", "exclude");
223
+ mkdirSync(dirname(exclude), { recursive: true });
224
+ writeFileSync(exclude, mergeExclude(existsSync(exclude) ? readFileSync(exclude, "utf8") : ""));
225
+ } catch {
226
+ // ponytail: exclude heal is best-effort; salvage still prefers a commit of
227
+ // whatever git can see over failing the whole orphan path.
228
+ }
229
+ }
230
+
197
231
  /**
198
232
  * Returns the path of the bare mirror for `repo`, cloning it on first use and
199
233
  * refreshing it otherwise.
@@ -251,7 +285,7 @@ export async function addWorktree(
251
285
  workspaceRoot: string,
252
286
  issue: number,
253
287
  branch: string,
254
- ): Promise<string> {
288
+ ): Promise<{ path: string; reattached: boolean }> {
255
289
  const mirrorPath = await ensureMirror(repo, mirrorRoot);
256
290
  mkdirSync(workspaceRoot, { recursive: true });
257
291
 
@@ -335,7 +369,7 @@ export async function addWorktree(
335
369
  }
336
370
  }
337
371
 
338
- return worktreePath;
372
+ return { path: worktreePath, reattached: branchExists };
339
373
  }
340
374
 
341
375
  /**
@@ -345,7 +379,17 @@ export async function addWorktree(
345
379
  * none of that may be skipped by an exception from the last-ditch step.
346
380
  */
347
381
  export type SalvageOutcome =
348
- | { kind: "salvaged"; sha: string; branch: string; pushed: boolean; pushError?: string }
382
+ | {
383
+ kind: "salvaged";
384
+ sha: string;
385
+ branch: string;
386
+ pushed: boolean;
387
+ pushError?: string;
388
+ /** Paths in the salvage commit (count = length). */
389
+ files: string[];
390
+ /** Subset that were untracked before salvage (`A` in the commit). */
391
+ newPaths: string[];
392
+ }
349
393
  /** Nothing uncommitted was there to save — a clean tree, or no tree at all. */
350
394
  | { kind: "nothing" }
351
395
  /** There was work and git would not commit it. This is the loud one. */
@@ -369,6 +413,51 @@ const SALVAGE_COMMIT_CONFIG = [
369
413
  "commit.gpgsign=false",
370
414
  ];
371
415
 
416
+ /**
417
+ * Subject stays the historical one-liner so status greps keep working; the
418
+ * body is the manifest (#38). Cap the new-path list so a runaway tree cannot
419
+ * push a multi-kilobyte commit message into every escalation.
420
+ */
421
+ export function salvageCommitMessage(
422
+ issue: number,
423
+ attempt: number,
424
+ reason: string,
425
+ files: string[],
426
+ newPaths: string[],
427
+ ): { subject: string; body: string } {
428
+ const subject = `wip(#${issue}): attempt ${attempt} killed by ${reason} — auto-salvaged`;
429
+ const n = files.length;
430
+ const count = `${n} file${n === 1 ? "" : "s"}`;
431
+ if (newPaths.length === 0) {
432
+ return { subject, body: `${count} (all modifications to tracked paths).` };
433
+ }
434
+ const cap = 30;
435
+ const shown = newPaths.slice(0, cap);
436
+ const more = newPaths.length - shown.length;
437
+ const list = shown.map((f) => ` ${f}`).join("\n");
438
+ const suffix = more > 0 ? `\n … and ${more} more` : "";
439
+ return {
440
+ subject,
441
+ body: `${count}. New (untracked before salvage):\n${list}${suffix}`,
442
+ };
443
+ }
444
+
445
+ function parseCachedNameStatus(raw: string): { files: string[]; newPaths: string[] } {
446
+ const files: string[] = [];
447
+ const newPaths: string[] = [];
448
+ for (const line of raw.split("\n")) {
449
+ if (line === "") continue;
450
+ // name-status: "<status>\t<path>" or rename "R100\t<old>\t<new>"
451
+ const parts = line.split("\t");
452
+ const status = parts[0] ?? "";
453
+ const path = parts.length >= 3 ? (parts[2] ?? parts[1] ?? "") : (parts[1] ?? "");
454
+ if (path === "") continue;
455
+ files.push(path);
456
+ if (status.startsWith("A")) newPaths.push(path);
457
+ }
458
+ return { files, newPaths };
459
+ }
460
+
372
461
  /**
373
462
  * Commits a dead run's uncommitted work to the run's own branch and pushes it,
374
463
  * so that the tree the next attempt destroys is no longer the only copy.
@@ -406,6 +495,12 @@ export async function salvageWip(
406
495
  // code, and "no tree" is not a salvage failure worth alarming anyone with.
407
496
  if (!existsSync(worktree)) return { kind: "nothing" };
408
497
 
498
+ // Heal the managed ignore *before* status/add. A mirror left by an older
499
+ // build may still list patterns this release dropped; without this, an
500
+ // untracked deliverable matching the stale list is invisible to salvage
501
+ // and dies with the next `worktree remove --force` (#44).
502
+ refreshManagedExclude(worktree);
503
+
409
504
  if ((await git(["status", "--porcelain"], worktree)) === "") return { kind: "nothing" };
410
505
 
411
506
  // The tree's own branch, not one the caller believes it should be on: this
@@ -432,40 +527,42 @@ export async function salvageWip(
432
527
  // only changes were ignored scratch had work by that test and none by this.
433
528
  // Without this, `commit` exits non-zero on an empty index and a tree
434
529
  // holding nothing worth keeping gets reported as a salvage *failure*.
435
- if ((await git(["diff", "--cached", "--name-only"], worktree)) === "") {
530
+ const cached = await git(["diff", "--cached", "--name-status"], worktree);
531
+ if (cached === "") {
436
532
  return { kind: "nothing" };
437
533
  }
534
+ const { files, newPaths } = parseCachedNameStatus(cached);
535
+ const msg = salvageCommitMessage(issue, attempt, reason, files, newPaths);
438
536
  await git(
439
537
  [
440
538
  ...SALVAGE_COMMIT_CONFIG,
441
539
  "commit",
442
540
  "--no-verify",
443
541
  "-m",
444
- `wip(#${issue}): attempt ${attempt} killed by ${reason} — auto-salvaged`,
542
+ msg.subject,
543
+ "-m",
544
+ msg.body,
445
545
  ],
446
546
  worktree,
447
547
  );
448
548
  const sha = await git(["rev-parse", "HEAD"], worktree);
549
+ const salvaged = { kind: "salvaged" as const, sha, branch, files, newPaths };
449
550
 
450
551
  if (branch === "HEAD") {
451
552
  // Detached: the commit is real but reachable only by sha, and pushing
452
553
  // `HEAD` from here would publish a branch literally named HEAD.
453
554
  return {
454
- kind: "salvaged",
455
- sha,
456
- branch,
555
+ ...salvaged,
457
556
  pushed: false,
458
557
  pushError: "detached HEAD — no branch to push",
459
558
  };
460
559
  }
461
560
 
462
561
  const push = await runGit(["push", "origin", `HEAD:refs/heads/${branch}`], worktree);
463
- if (push.code === 0) return { kind: "salvaged", sha, branch, pushed: true };
562
+ if (push.code === 0) return { ...salvaged, pushed: true };
464
563
 
465
564
  return {
466
- kind: "salvaged",
467
- sha,
468
- branch,
565
+ ...salvaged,
469
566
  pushed: false,
470
567
  pushError: (
471
568
  push.stderr.trim() ||