omp-conductor 0.3.9 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
@@ -18,9 +18,11 @@ written out as the one you chose — ask an omp session to read
18
18
 
19
19
  ---
20
20
 
21
- You are the orchestrator for **{{PROJECT}}**. You do not write product code and
22
- you do not touch a worker's branch. You keep the queue moving, and you are the
23
- first responder when a worker gets stuck.
21
+ You are the orchestrator for **{{PROJECT}}**. You do not write product code
22
+ **Hard boundaries** below names the exact acts that are out (checkout / commit /
23
+ push inside a worker's worktree; inventing a commit identity) and the one that
24
+ is in (`gh pr update-branch` for a green PR that fell behind). You keep the
25
+ queue moving, and you are the first responder when a worker gets stuck.
24
26
 
25
27
  You are prompted on a timer. Each tick: do the three duties below, then stop.
26
28
 
@@ -225,8 +227,12 @@ The protocol, in order:
225
227
  big for that, send the one-sentence version of each change and say the
226
228
  full text lands in the file on yes — the diff stays in your transcript for
227
229
  anyone who wants it verbatim.
228
- 3. **On yes, apply it** by editing this file yourself. On no, or on no answer at
229
- all, drop it and do not re-ask that amendment.
230
+ 3. **On yes, apply it** by editing this file yourself. **On explicit no, drop
231
+ it** forever and do not re-ask that amendment. **On cancel, timeout, or no
232
+ answer**, park it — that means "not now", not "never": mention it once in the
233
+ next report as `pending amendment: <one-liner> — say 'apply it' or 'drop it'`,
234
+ never re-open the yes/no dialog, and drop it if still unanswered after 7 days.
235
+ A cancelled dialog is not a permanent rejection.
230
236
  4. **Log it.** Append one line to **Amendments** at the bottom of this file: the
231
237
  date, what triggered it, a one-sentence summary.
232
238
  5. **Offer general fixes upstream.** Ask one question of the amendment you just
package/src/daemon.ts CHANGED
@@ -11,7 +11,7 @@ import { createHash } from "node:crypto";
11
11
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
12
  import { dirname, join, relative } from "node:path";
13
13
  import { configPath, findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
14
- import { createEscalator } from "./escalate.ts";
14
+ import { createEscalator, escalationIssueRef } from "./escalate.ts";
15
15
  import { graphHint } from "./graph.ts";
16
16
  import { livingDaemon } from "./lifecycle.ts";
17
17
  import { STALL_MARKER_FILE } from "./orchestrator-tick.ts";
@@ -369,7 +369,7 @@ async function safeEscalate(d: Pick<Deps, "escalate">, e: Escalation): Promise<b
369
369
  await d.escalate(e);
370
370
  return true;
371
371
  } catch (err) {
372
- log(`escalation for #${e.issue} could not be delivered: ${errText(err)}`);
372
+ log(`escalation for ${escalationIssueRef(e.issue)} could not be delivered: ${errText(err)}`);
373
373
  return false;
374
374
  }
375
375
  }
@@ -393,13 +393,22 @@ export function salvageLines(outcome: SalvageOutcome, worktree: string): string[
393
393
  ];
394
394
  }
395
395
 
396
- return [
396
+ const where =
397
397
  `WIP committed to ${outcome.branch} @ ${outcome.sha}` +
398
- (outcome.pushed
399
- ? " and pushed — the work outlives this worktree"
400
- : ` but NOT pushed (${outcome.pushError ?? "no reason given"}) — it lives only in this host's mirror`),
401
- kept,
402
- ];
398
+ (outcome.pushed
399
+ ? " and pushed — the work outlives this worktree"
400
+ : ` but NOT pushed (${outcome.pushError ?? "no reason given"}) — it lives only in this host's mirror`);
401
+ // Manifest belongs in the escalation too: opening the commit is how the
402
+ // orchestrator talked itself into scrubbing a worker tree (#38).
403
+ const n = outcome.files.length;
404
+ const count = `${n} file${n === 1 ? "" : "s"}`;
405
+ const manifest =
406
+ outcome.newPaths.length === 0
407
+ ? `${count} (all modifications to tracked paths)`
408
+ : `${count}; new: ${outcome.newPaths.slice(0, 12).join(", ")}${
409
+ outcome.newPaths.length > 12 ? `, … +${outcome.newPaths.length - 12} more` : ""
410
+ }`;
411
+ return [where, manifest, kept];
403
412
  }
404
413
 
405
414
  /**
package/src/escalate.ts CHANGED
@@ -45,11 +45,21 @@ export interface Escalator {
45
45
  * send, turning a cosmetic problem into a lost escalation. Plain text is also
46
46
  * valid Markdown, so the same string renders fine as an issue comment.
47
47
  */
48
+ /**
49
+ * Fleet-scoped pages (integrity tripwire, spend-cap halt, stall) use issue `0`
50
+ * as a sentinel — there is no tracker issue. Rendering that as `#0` made
51
+ * Telegram pages and daemon logs look like a bug (#52). Keep the sentinel in
52
+ * the typed field; only the human-facing label changes here.
53
+ */
54
+ export function escalationIssueRef(issue: number): string {
55
+ return issue === 0 ? "fleet" : `#${issue}`;
56
+ }
57
+
48
58
  export function formatEscalation(e: Escalation, project: string): string {
49
59
  const lines = [
50
60
  `omp-conductor · tier ${e.tier} escalation`,
51
61
  `project: ${project}`,
52
- `issue: #${e.issue}`,
62
+ `issue: ${escalationIssueRef(e.issue)}`,
53
63
  `summary: ${e.summary}`,
54
64
  ];
55
65
  if (e.detail) lines.push(`detail: ${e.detail}`);
@@ -99,18 +109,21 @@ export function createEscalator(
99
109
  const onTurnFailed = async (cause: unknown): Promise<void> => {
100
110
  if (!p.escalation.fallbackToIssueComment) {
101
111
  warn(
102
- `tier 1 escalation on issue #${e.issue} was accepted by the orchestrator but its ` +
112
+ `tier 1 escalation on ${escalationIssueRef(e.issue)} was accepted by the orchestrator but its ` +
103
113
  `turn failed (${errText(cause)}), and no fallback is configured — left unmarked ` +
104
114
  `so the next tick retries`,
105
115
  );
106
116
  return;
107
117
  }
108
118
  try {
119
+ if (e.issue === 0) {
120
+ throw new Error("fleet-scoped escalation has no issue to comment on");
121
+ }
109
122
  await tracker.comment(e.issue, text);
110
123
  store.markNotified(key);
111
124
  } catch (err) {
112
125
  warn(
113
- `tier 1 escalation on issue #${e.issue} failed after acceptance ` +
126
+ `tier 1 escalation on ${escalationIssueRef(e.issue)} failed after acceptance ` +
114
127
  `(${errText(cause)}) and its issue-comment fallback failed too ` +
115
128
  `(${errText(err)}) — left unmarked so the next tick retries`,
116
129
  );
@@ -154,10 +167,19 @@ export function createEscalator(
154
167
  if (!p.escalation.fallbackToIssueComment) {
155
168
  throw new Error(
156
169
  `no escalation transport configured for project "${p.name}": tier ${e.tier} ` +
157
- `escalation on issue #${e.issue} (${e.summary}) could not be delivered — ` +
170
+ `escalation on ${escalationIssueRef(e.issue)} (${e.summary}) could not be delivered — ` +
158
171
  `set escalation.telegramChatId or escalation.fallbackToIssueComment`,
159
172
  );
160
173
  }
174
+ // Fleet pages (issue 0) have nowhere to comment. Falling through here used
175
+ // to call `issues/0/comments` and surface as a confusing "#0" delivery
176
+ // failure after Telegram had already been tried (#52).
177
+ if (e.issue === 0) {
178
+ throw new Error(
179
+ `fleet-scoped tier ${e.tier} escalation (${e.summary}) has no issue to comment on — ` +
180
+ `configure escalation.telegramChatId so integrity/spend/stall pages can reach an operator`,
181
+ );
182
+ }
161
183
  await tracker.comment(e.issue, text);
162
184
  store.markNotified(key);
163
185
  },
@@ -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/worktree.ts CHANGED
@@ -379,7 +379,17 @@ export async function addWorktree(
379
379
  * none of that may be skipped by an exception from the last-ditch step.
380
380
  */
381
381
  export type SalvageOutcome =
382
- | { 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
+ }
383
393
  /** Nothing uncommitted was there to save — a clean tree, or no tree at all. */
384
394
  | { kind: "nothing" }
385
395
  /** There was work and git would not commit it. This is the loud one. */
@@ -403,6 +413,51 @@ const SALVAGE_COMMIT_CONFIG = [
403
413
  "commit.gpgsign=false",
404
414
  ];
405
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
+
406
461
  /**
407
462
  * Commits a dead run's uncommitted work to the run's own branch and pushes it,
408
463
  * so that the tree the next attempt destroys is no longer the only copy.
@@ -472,40 +527,42 @@ export async function salvageWip(
472
527
  // only changes were ignored scratch had work by that test and none by this.
473
528
  // Without this, `commit` exits non-zero on an empty index and a tree
474
529
  // holding nothing worth keeping gets reported as a salvage *failure*.
475
- if ((await git(["diff", "--cached", "--name-only"], worktree)) === "") {
530
+ const cached = await git(["diff", "--cached", "--name-status"], worktree);
531
+ if (cached === "") {
476
532
  return { kind: "nothing" };
477
533
  }
534
+ const { files, newPaths } = parseCachedNameStatus(cached);
535
+ const msg = salvageCommitMessage(issue, attempt, reason, files, newPaths);
478
536
  await git(
479
537
  [
480
538
  ...SALVAGE_COMMIT_CONFIG,
481
539
  "commit",
482
540
  "--no-verify",
483
541
  "-m",
484
- `wip(#${issue}): attempt ${attempt} killed by ${reason} — auto-salvaged`,
542
+ msg.subject,
543
+ "-m",
544
+ msg.body,
485
545
  ],
486
546
  worktree,
487
547
  );
488
548
  const sha = await git(["rev-parse", "HEAD"], worktree);
549
+ const salvaged = { kind: "salvaged" as const, sha, branch, files, newPaths };
489
550
 
490
551
  if (branch === "HEAD") {
491
552
  // Detached: the commit is real but reachable only by sha, and pushing
492
553
  // `HEAD` from here would publish a branch literally named HEAD.
493
554
  return {
494
- kind: "salvaged",
495
- sha,
496
- branch,
555
+ ...salvaged,
497
556
  pushed: false,
498
557
  pushError: "detached HEAD — no branch to push",
499
558
  };
500
559
  }
501
560
 
502
561
  const push = await runGit(["push", "origin", `HEAD:refs/heads/${branch}`], worktree);
503
- if (push.code === 0) return { kind: "salvaged", sha, branch, pushed: true };
562
+ if (push.code === 0) return { ...salvaged, pushed: true };
504
563
 
505
564
  return {
506
- kind: "salvaged",
507
- sha,
508
- branch,
565
+ ...salvaged,
509
566
  pushed: false,
510
567
  pushError: (
511
568
  push.stderr.trim() ||