omp-conductor 0.15.3 → 0.15.5

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/README.md CHANGED
@@ -251,10 +251,20 @@ command exits without changing anything when all three surfaces already use that
251
251
  release, the Herdr plugin is pinned to its exact `gitHead`, and the brief is
252
252
  current.
253
253
 
254
- When a daemon is live, the command drains and restarts its recorded project.
255
- An explicit `--project` that names a different project is rejected before pause
256
- or installation, so an operator cannot drain one queue and kill another queue's
257
- workers.
254
+ The upgrade is host-wide, because everything it replaces is: one daemon serves
255
+ every configured project, so its restart lands on all of them at once. A bare
256
+ `omp-conductor upgrade` therefore drains **every** project's workers and
257
+ refreshes **every** project's brief, and pauses them with the fleet-wide
258
+ sentinel — which leaves any per-project `hold` you set standing when it
259
+ restores dispatch.
260
+
261
+ `--project` narrows that only when it is truthful to do so. When a live daemon
262
+ recorded a single project, the command drains and restarts that project, and an
263
+ explicit `--project` naming a different one is rejected before pause or
264
+ installation. When the daemon serves every configured project and there is more
265
+ than one, `--project` is rejected too: draining one queue and then restarting
266
+ the shared daemon would kill another queue's workers without ever counting
267
+ them. Re-run without the flag.
258
268
 
259
269
  Ticks remain in their existing armed or disarmed state, so an ordinary update
260
270
  does not halt the exact pane or require another Telegram arm challenge. Progress
@@ -2334,7 +2344,7 @@ omp-conductor help
2334
2344
  | `start` | Start `herdr-fleet.service` when that optional unit is installed, clearing a previous pane-recovery pin, then spawn the dispatch loop in the background and wait until it answers `GET /healthz` on `:8787`. Without systemd or that unit it keeps the standalone daemon behaviour. It never clears pause or arms ticks. Refuses if a daemon is already live, naming its pid; if the process dies or never serves, it cleans up and quotes the tail of `daemon.log`. |
2335
2345
  | `stop` | Prefer `systemctl stop omp-conductor.service` when that unit's MainPID is the live daemon — systemd then owns the stop and will not schedule a restart for the exit it just requested. Otherwise `SIGTERM`, then `SIGKILL` after a 10-second grace period. Prints `not running` when there is nothing to stop, and tags the confirmation with `(via systemctl)` when the unit path was used. |
2336
2346
  | `restart [--now] [--timeout SECONDS] [--port N] [--project NAME]` | Drains the fleet by default: pause new claims, wait until live workers reach `0 / N` (bounded by `--timeout SECONDS`, default 1800 = 30 min), restart, then restore the prior dispatch state. Prefer `systemctl restart` when the unit owns the live pid so the replacement stays supervised; otherwise `stop` then `start`, inheriting the running daemon's port and project unless a flag overrides them. `--now` skips the drain and restarts immediately, orphaning any live runs (old behaviour). A drain that hits `--timeout` restarts nothing and leaves dispatch paused — `omp-conductor resume` lifts it, or re-run `restart` to keep waiting. The new process **salvages dirty live worktrees before orphaning** those rows — see [Deploying a new package onto a busy fleet](#deploying-a-new-package-onto-a-busy-fleet). |
2337
- | `upgrade [--to VERSION] [--project NAME]` | Deterministically update the Bun-global CLI, omp plugin, Herdr recovery plugin, and managed brief as one release. Resolves the npm version and exact `gitHead`, pauses only new claims, drains active workers, installs all surfaces, reloads Herdr and the daemon, waits for pane recovery, verifies identities and fleet health twice, then restores the original dispatch state. A no-op when already current. Failure leaves dispatch paused. Must run outside a Herdr-managed session. |
2347
+ | `upgrade [--to VERSION] [--project NAME]` | Deterministically update the Bun-global CLI, omp plugin, Herdr recovery plugin, and managed brief as one release. Resolves the npm version and exact `gitHead`, pauses only new claims, drains active workers, installs all surfaces, reloads Herdr and the daemon, waits for pane recovery, verifies identities and fleet health twice, then restores the original dispatch state. Host-wide by default: one daemon serves every configured project, so a bare run drains all of them and refreshes every brief. `--project` is rejected when the live daemon serves several projects — draining one queue and restarting the shared daemon would kill another's workers. A no-op when already current. Failure leaves dispatch paused. Must run outside a Herdr-managed session. |
2338
2348
  | `status [--project NAME]` | Layered fleet report first: `dispatch` / `ticks` / next scheduled tick / `pane` / `recovery` / `herdr` / `telegram` / `brief` / `decisions` / optional `failure classes` and `code graph` / `daemon`, then the project body. The project body includes the latest completed dispatch timestamp, ready/routed/admitted counts, bounded hold groups, and the GitHub API budget (`graphql` / `core` remaining and reset, in the caps block); API failures are marked `DEGRADED` so queue starvation cannot look idle. Active-run lines overlay cooperative worker `paused`/`pausing` from `/healthz` without changing SQLite `running` state or the live worker count. The next tick comes from the live heartbeat process, not a guess from log timestamps. Telegram health uses `getMe` to prove API authentication without sending a message and reports inbound bridge configuration separately. Configured graphs report prerequisites, indexed repos, timer state, and refresh freshness without blocking dispatch. A `reports` block lists everything the outbox has not delivered, with its age, and prints `pending` (nobody has it) differently from `SENDING` (outcome unknown, it may already have arrived) — see [Report delivery](#report-delivery-the-outbox). The daemon block includes `rss` from `/healthz`; live workers add a busy-deploy warning. A `.conductor-stalled` marker adds an `orchestrator STALLED since …` line. |
2339
2349
  | `ledger [--issue N] [--limit N]` | The action audit: every [mediated-verb](#the-mediated-verbs-126) mutation and every next-attempt turn budget. Verb entries include the arguments, decision, named refusal, and resulting SHA. Turn-budget entries remain after an override is replaced or consumed. Reads (`conductor_pr_status`) are absent so polling cannot bury the signal. `--issue` narrows both histories; `--limit` defaults to 50. Recent verb refusals and pending turn overrides also appear in `status`. |
2340
2350
  | `board [--project NAME]` | Live keyboard-driven kanban over the same SQLite and `/healthz` truth as `status`, plus the tracker's current labels: Queue, Claimed, Running, Green, Blocked, Failed, Orphaned, the last 24 hours of Merged and Settled, and Parked (an issue the tracker has not confirmed closed — still open, or a label read that failed — so nothing dispatches it until a human labels it). Columns are mutually exclusive and describe current state, not the newest run row, so a requeued issue is queued rather than failed and a closed issue is neither. Refreshes run/spend/turn values every second, and health plus the label read every ten seconds. `Enter` follows the selected transcript in place; `u` invokes the existing unblock workflow on a Blocked, Failed, or Orphaned card; `i` / `p` open the issue / PR; `r` refreshes health; `?` shows all keys. Requires an interactive terminal of at least 50×20. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.15.3",
3
+ "version": "0.15.5",
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.",
package/src/cli.ts CHANGED
@@ -155,7 +155,9 @@ usage:
155
155
  that hits --timeout restarts nothing and leaves dispatch paused. On boot
156
156
  the new process salvages dirty live worktrees before orphaning those
157
157
  rows — see README "Deploying a new package onto a busy fleet". Goes
158
- through systemctl when the unit owns the live pid.
158
+ through systemctl when the unit owns the live pid; a failed
159
+ installed unit is reset and restarted through systemd, never
160
+ replaced by an unmanaged daemon.
159
161
  status layered fleet report: dispatch (running|paused|stopped), ticks and
160
162
  next due time, pane, herdr, Telegram bot/API health, daemon, caps
161
163
  and active runs.
@@ -682,7 +684,10 @@ try {
682
684
  // Inherit the running daemon's port and project: a restart that quietly
683
685
  // moved to the default port would leave every existing health check
684
686
  // pointing at nothing. When the unit owns the live pid, restartDaemon
685
- // goes through systemctl so the replacement stays supervised.
687
+ // goes through systemctl so the replacement stays supervised; a failed
688
+ // installed unit is reset and started through systemd the same way and
689
+ // is never replaced by an unmanaged daemon. Success is only reported
690
+ // after the manager is proven to own the reported pid.
686
691
  const { previous, record, via } = await restartDaemon({
687
692
  port: portFlag(argv),
688
693
  project: projectFlag,
package/src/daemon.ts CHANGED
@@ -171,11 +171,25 @@ export interface DaemonOpts {
171
171
  * model or caps — `handleIssue` destructures them at dispatch time. The rest
172
172
  * are resolved once at startup so a tick never re-reads config mid-flight and
173
173
  * changes its own limits underneath itself. */
174
+ export interface DrainSignal {
175
+ draining: boolean;
176
+ }
177
+
174
178
  interface Deps {
175
179
  project: ProjectConfig;
176
180
  caps: Caps;
177
181
  tracker: Tracker;
178
182
  store: Store;
183
+ /**
184
+ * Set true the moment a daemon stop (SIGTERM/SIGINT) begins. The run loop
185
+ * only sees `stopping` between whole ticks, so a pass that was already in
186
+ * flight when the stop landed must re-check this flag itself — before it
187
+ * claims and before it launches — or it creates exactly the work the
188
+ * shutdown is about to wait for and then lose to the stop timeout (#374).
189
+ * Optional only so tests that never exercise shutdown can omit it;
190
+ * `runDaemon` always wires the real one.
191
+ */
192
+ drain?: DrainSignal;
179
193
  /** False after a live config reload fails; autonomous delivery then holds
180
194
  * fail-closed until a later tick validates the config again. */
181
195
  deliveryPolicyValid?: boolean;
@@ -444,6 +458,40 @@ export function pauseProvenance(
444
458
  }
445
459
  }
446
460
 
461
+ /**
462
+ * One pause sentinel read as a single identity: who set it, why, and the
463
+ * creation instant, all from the SAME file that was selected. Unlike pairing
464
+ * {@link pauseProvenance} with {@link pausedAt} — which can describe
465
+ * different files when a project pause coexists with the legacy global
466
+ * sentinel, letting a stale global timestamp mask a recreated project pause —
467
+ * this reads provenance and timestamp from one sentinel, so a caller can prove
468
+ * "the pause I set still exists" instead of "some pause with the same labels
469
+ * still exists" (#377). Per-project sentinel wins, like {@link pauseProvenance}.
470
+ */
471
+ export function pauseInstance(
472
+ project?: string,
473
+ ): { source: string; reason?: string; since: number } | undefined {
474
+ const paths =
475
+ project === undefined
476
+ ? [pausedPath()]
477
+ : [pausedPath(project), pausedPath()];
478
+ const path = paths.find((candidate) => existsSync(candidate));
479
+ if (path === undefined) return undefined;
480
+ try {
481
+ const [line1, line2] = readFileSync(path, "utf8").split("\n");
482
+ const since = Date.parse(line1?.trim() ?? "");
483
+ if (!Number.isFinite(since)) return undefined;
484
+ if (line2 === undefined) return undefined;
485
+ const match = /^source=(\S+)(?: reason="(.*)")?$/.exec(line2.trim());
486
+ if (match === null) return undefined;
487
+ const source = match[1]!;
488
+ const reason = match[2];
489
+ return { source, since, ...(reason === undefined ? {} : { reason }) };
490
+ } catch {
491
+ return undefined;
492
+ }
493
+ }
494
+
447
495
  export function setPaused(
448
496
  v: boolean,
449
497
  why?: { source: string; reason?: string },
@@ -1309,7 +1357,65 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
1309
1357
  return true;
1310
1358
  };
1311
1359
 
1360
+ /**
1361
+ * The daemon-wide stop fence can land after the claim but before `runWorker`
1362
+ * exposes its controller — the same window `settleStopBeforeSession` closes
1363
+ * for a per-run operator stop, entered from the shutdown path instead of the
1364
+ * pause control. Settle identically (close the claim, never launch) so the
1365
+ * drain waits only for workers admitted before the stop request, and the
1366
+ * shutdown cannot extend its own workload (#374).
1367
+ */
1368
+ const settleDrainBeforeSession = async (): Promise<boolean> => {
1369
+ if (d.drain?.draining !== true || run === undefined || workerSessionInstalled) return false;
1370
+ turnLimit?.close();
1371
+ turnLimit = undefined;
1372
+ const settlement =
1373
+ worktreePath === undefined
1374
+ ? undefined
1375
+ : await settleWorktree({
1376
+ issue,
1377
+ attempt,
1378
+ ending: "daemon shutdown began while the run was being prepared",
1379
+ worktree: worktreePath,
1380
+ branch,
1381
+ publish,
1382
+ tree: "remove",
1383
+ mirrorPath,
1384
+ });
1385
+ recordOperatorStop(store, {
1386
+ project: project.name,
1387
+ issue,
1388
+ runId: run.id,
1389
+ inProgress,
1390
+ reason: "daemon shutdown began after the claim",
1391
+ patch: {
1392
+ endedAt: Date.now(),
1393
+ turns: run.turns,
1394
+ spendUsd: run.spendUsd,
1395
+ worktree: worktreePath ?? run.worktree,
1396
+ report: [
1397
+ "Daemon shutdown began while this run was still being prepared; the worker",
1398
+ "never started and the claim was closed so the shutdown could complete.",
1399
+ ...(settlement?.lines ?? []),
1400
+ ].join("\n"),
1401
+ ...settlement?.patch,
1402
+ },
1403
+ });
1404
+ log(`#${issue} not launched: daemon shutdown began after the claim`);
1405
+ return true;
1406
+ };
1407
+
1312
1408
  try {
1409
+ // The claim-side of the stop fence (#374): the run row is the boundary the
1410
+ // shutdown drain waits on, so the claim itself refuses once the daemon is
1411
+ // draining. The tick re-checks before the dispatch tail; this covers the
1412
+ // same fence for any caller that reaches `handleIssue` without one, and
1413
+ // makes "no post-stop run rows" a property of the claim, not of its caller.
1414
+ if (d.drain?.draining === true) {
1415
+ log(`#${issue} not claimed: daemon is draining`);
1416
+ return;
1417
+ }
1418
+
1313
1419
  // Claim on the STORE first, before anything that can fail. The run row —
1314
1420
  // not the label — is the crash-safe guard against double dispatch: rows
1315
1421
  // are local, written before any network call, and the startup orphan
@@ -1351,6 +1457,7 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
1351
1457
  turnLimit = d.turnLimits.open(project.name, issue, runId, maxTurns);
1352
1458
  workerControl = d.workerControls.open(project.name, issue, runId);
1353
1459
  if (await settleStopBeforeSession()) return;
1460
+ if (await settleDrainBeforeSession()) return;
1354
1461
 
1355
1462
  // A run's tree is <workspaceRoot>/<issue> and addRunRepo refuses to reuse
1356
1463
  // an existing path, so a retry — or a tree kept from a failed attempt — has
@@ -1360,6 +1467,7 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
1360
1467
  // would cost a second network fetch per attempt.
1361
1468
  await removeWorktree(mirrorPath, worktreePathFor(project.workspaceRoot, issue));
1362
1469
  if (await settleStopBeforeSession()) return;
1470
+ if (await settleDrainBeforeSession()) return;
1363
1471
  const provisioned = await addRunRepo(
1364
1472
  r.repo,
1365
1473
  project.mirrorRoot,
@@ -1370,6 +1478,7 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
1370
1478
  worktreePath = provisioned.path;
1371
1479
  runRepo = { repo: r.repo, runRepoPath: worktreePath, branch };
1372
1480
  if (await settleStopBeforeSession()) return;
1481
+ if (await settleDrainBeforeSession()) return;
1373
1482
 
1374
1483
  // The SDK names the transcript itself, so the daemon supplies the parent
1375
1484
  // directory and learns the real path back from the result. Inventing one
@@ -1409,6 +1518,7 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
1409
1518
  { ...(d.verbPeerReader === undefined ? {} : { peerReader: d.verbPeerReader }) },
1410
1519
  );
1411
1520
  if (await settleStopBeforeSession()) return;
1521
+ if (await settleDrainBeforeSession()) return;
1412
1522
 
1413
1523
  store.updateRun(runId, { worktree: worktreePath, state: "running" });
1414
1524
 
@@ -1425,6 +1535,7 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
1425
1535
  : {}),
1426
1536
  });
1427
1537
  if (await settleStopBeforeSession()) return;
1538
+ if (await settleDrainBeforeSession()) return;
1428
1539
 
1429
1540
  const repoSlug = githubRepo(r.repo.cloneUrl);
1430
1541
 
@@ -1470,6 +1581,12 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
1470
1581
  // and a path written at completion is a path nobody can follow live. The
1471
1582
  // completion-time update below writes the same value again, harmlessly.
1472
1583
  onSessionFile: (f) => store.updateRun(runId, { sessionFile: f }),
1584
+ // The last fence (#374): every pre-launch settle check above has
1585
+ // passed, but the stop can still land while the session socket is
1586
+ // binding inside `createSession`. This gate is re-checked there,
1587
+ // immediately before the child spawn, and closes the run as stopped
1588
+ // instead of launching a worker the shutdown would have to wait for.
1589
+ maySpawn: () => d.drain?.draining !== true,
1473
1590
  }, d.workerDeps);
1474
1591
  } finally {
1475
1592
  // This is the authoritative settlement edge for `extend`: close before
@@ -3273,6 +3390,21 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
3273
3390
  }
3274
3391
 
3275
3392
  const pass = await admitCandidates(d, routed, slots);
3393
+
3394
+ // The stop fence (#374): the run loop only re-reads `stopping` between
3395
+ // whole ticks, so a pass already in flight when SIGTERM/SIGINT landed must
3396
+ // re-check the drain signal here — after candidate admission, before any
3397
+ // claim or launch — or the shutdown path admits exactly the work it is
3398
+ // about to wait for and then loses to the stop timeout. The candidates keep
3399
+ // their queue labels; the next daemon start re-dispatches them.
3400
+ if (d.drain?.draining === true) {
3401
+ recordDispatch(0, [
3402
+ ...routingHolds,
3403
+ ...pass.holds,
3404
+ ...pass.admitted.map((a) => ({ issue: a.r.issue.number, reason: "shutting-down" as const })),
3405
+ ]);
3406
+ return;
3407
+ }
3276
3408
  recordDispatch(pass.admitted.length, [...routingHolds, ...pass.holds]);
3277
3409
 
3278
3410
  if (pass.admitted.length === 0) return;
@@ -4553,6 +4685,14 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
4553
4685
  const alive = livingDaemon();
4554
4686
  const runtimes: ProjectRuntime[] = [];
4555
4687
 
4688
+ // The stop fence (#374): one signal shared by every project's tick.
4689
+ // `stopping` gates the run loop between whole ticks; `drain.draining` is
4690
+ // the same event visible *inside* a tick already in flight, so a pass that
4691
+ // was mid-admission when the stop landed re-checks it before claiming or
4692
+ // launching. Created here, before the loop, so `stop` below and every
4693
+ // project's `Deps` reference the same object.
4694
+ const drain: DrainSignal = { draining: false };
4695
+
4556
4696
  log(`verb transport: ${transportBanner(verbDir, verbPeerReader)}`);
4557
4697
  log(`package integrity baseline: ${integrity.baseline.size} files under ${import.meta.dir}`);
4558
4698
 
@@ -4675,6 +4815,7 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
4675
4815
  caps,
4676
4816
  tracker,
4677
4817
  store,
4818
+ drain,
4678
4819
  deliveryPolicyValid: false,
4679
4820
  usage,
4680
4821
  escalate: (event) => escalator.escalate(event),
@@ -4754,6 +4895,11 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
4754
4895
  const stop = (): void => {
4755
4896
  if (stopping) return;
4756
4897
  stopping = true;
4898
+ // Close the admission fence before the loop is told, so a tick already in
4899
+ // flight — parked at an await when the signal landed — sees `draining`
4900
+ // when it resumes and cannot create the work this shutdown is about to
4901
+ // wait for (#374).
4902
+ drain.draining = true;
4757
4903
  log("shutting down after active workers finish");
4758
4904
  wake?.();
4759
4905
  };
package/src/gitops.ts CHANGED
@@ -112,17 +112,30 @@ export function repoSlugFor(repo: RepoTarget): string {
112
112
  * hop that touches the network happens here, so the dispatcher observes the
113
113
  * exact sha it recorded.
114
114
  *
115
- * Both hops are fast-forward only and there is no force path anywhere in this
116
- * package. The fetch refspec deliberately carries **no leading `+`** that
117
- * single character is the difference between "advance the mirror's copy of this
118
- * branch" and "make the mirror's copy whatever the run says it is", and a run
119
- * repo is the one place in this system that model-executed code fully controls.
120
- * The push carries no `--force` and no `--force-with-lease`: a lease is still a
121
- * force, and a rejected push is a decision for a human, not a retry.
115
+ * The run-repo hop carries **no leading `+`** that single character is the
116
+ * difference between "advance the mirror's copy of this branch" and "make the
117
+ * mirror's copy whatever the run says it is", and a run repo is the one place
118
+ * in this system that model-executed code fully controls. The push carries no
119
+ * `--force` and no `--force-with-lease`: a lease is still a force, and a
120
+ * rejected push is a decision for a human, not a retry.
121
+ *
122
+ * The one exception is narrower than it looks. The mirror's copy of a run
123
+ * branch can be stale in a way that is not the run's fault:
124
+ * `conductor_pr_update_branch` moves — and on a rewritten history, replaces —
125
+ * the remote branch server-side after a run's last push, so a continuation
126
+ * that incorporated the reviewed baseline can propose a head the *live remote*
127
+ * is an ancestor of while the mirror's old copy is not. That is a legitimate
128
+ * fast-forward, not a divergence, so when the plain copy is refused this push
129
+ * reconciles the mirror's remote-tracking ref of the run branch with the live
130
+ * remote and asks git whether the live head is an ancestor of the proposed
131
+ * head. Only that proof lets the mirror's copy be refreshed, and the push hop
132
+ * still re-checks the ancestor relation against the live remote itself.
133
+ * Everything else is refused carrying the exact proposed and live SHAs so a
134
+ * real divergence is diagnosable.
122
135
  *
123
136
  * A rejection — non-fast-forward, protected ref, unauthorised — comes back as a
124
- * value carrying git's stderr verbatim, because the caller settles the run
125
- * `failed` on it and a paraphrase is worthless in that report.
137
+ * value carrying the reason, because the caller settles the run `failed` on it
138
+ * and a paraphrase is worthless in that report.
126
139
  */
127
140
  export async function pushRunBranch(
128
141
  project: Pick<ProjectConfig, "mirrorRoot">,
@@ -131,24 +144,93 @@ export async function pushRunBranch(
131
144
  ): Promise<PushOutcome> {
132
145
  const mirror = mirrorPath(project, run.repo);
133
146
  const ref = `refs/heads/${run.branch}`;
147
+ const tracked = `refs/remotes/origin/${run.branch}`;
134
148
  const env = credentialedEnv();
135
149
 
150
+ // Copy the run's branch into the mirror, fast-forward only.
136
151
  const fetched = await exec(["git", "-C", mirror, "fetch", "--no-tags", run.runRepoPath, `${ref}:${ref}`], { env });
137
- if (fetched.code !== 0) {
138
- return { ok: false, stderr: scrubUserinfo(fetched.stderr.trim() || fetched.stdout.trim() || `git fetch exited ${String(fetched.code)}`) };
152
+
153
+ // The proposed head is read from the run repo itself: when the copy above
154
+ // was refused, the mirror's copy of the branch is exactly the stale one.
155
+ const proposedRun = await exec(["git", "-C", run.runRepoPath, "rev-parse", ref], { env });
156
+ if (proposedRun.code !== 0) {
157
+ return { ok: false, stderr: scrubUserinfo(proposedRun.stderr.trim() || `git rev-parse ${ref} exited ${String(proposedRun.code)}`) };
158
+ }
159
+ const proposed = proposedRun.stdout.trim();
160
+
161
+ // Reconcile with the live remote before enforcing anything: the ancestry
162
+ // that decides a fast-forward is the live remote's, not the mirror's copy's
163
+ // (see the docstring above). ls-remote answers "is the branch published at
164
+ // all" and "what does GitHub hold" in one call.
165
+ const liveListed = await exec(["git", "-C", mirror, "ls-remote", "origin", ref], { env });
166
+ if (liveListed.code !== 0) {
167
+ return { ok: false, stderr: scrubUserinfo(liveListed.stderr.trim() || `git ls-remote origin ${ref} exited ${String(liveListed.code)}`) };
168
+ }
169
+ const live = liveListed.stdout
170
+ .split("\n")
171
+ .map((line) => line.trimEnd())
172
+ .find((line) => line.endsWith(`\t${ref}`))
173
+ ?.split(/\s+/, 1)[0];
174
+
175
+ if (live !== undefined && live !== proposed) {
176
+ // The branch is published and the run proposes something different. Pull
177
+ // the live branch's history into the mirror once (which also keeps the
178
+ // tracked ref a reattach reads fresh), then prove the fast-forward.
179
+ const reconciled = await exec(["git", "-C", mirror, "fetch", "--no-tags", "origin", `+${ref}:${tracked}`], { env });
180
+ if (reconciled.code !== 0) {
181
+ return { ok: false, stderr: scrubUserinfo(reconciled.stderr.trim() || reconciled.stdout.trim() || `git fetch origin exited ${String(reconciled.code)}`) };
182
+ }
183
+
184
+ const isAncestor = await exec(["git", "-C", mirror, "merge-base", "--is-ancestor", live, proposed], { env });
185
+ if (isAncestor.code === 128) {
186
+ // git could not perform the check at all (an object it was asked to
187
+ // resolve is missing, not merely unrelated). That is a failed
188
+ // verification, not a divergence verdict: refuse with git's own words,
189
+ // still naming both SHAs so the report carries the mismatch.
190
+ return {
191
+ ok: false,
192
+ stderr: scrubUserinfo(
193
+ isAncestor.stderr.trim() ||
194
+ isAncestor.stdout.trim() ||
195
+ `git merge-base --is-ancestor ${live} ${proposed} exited ${String(isAncestor.code)}`,
196
+ ),
197
+ };
198
+ }
199
+ if (isAncestor.code !== 0) {
200
+ // A real divergence: no fast-forward exists, and the mirror is left
201
+ // exactly where it was. Both SHAs are named so the mismatch is
202
+ // diagnosable instead of a bare "non-fast-forward".
203
+ return {
204
+ ok: false,
205
+ stderr:
206
+ `refusing non-fast-forward push of ${run.branch}: the live remote tip ${live} is not an ancestor of ` +
207
+ `the proposed head ${proposed}. Fetch the live branch and rebase or merge it before pushing again.`,
208
+ };
209
+ }
139
210
  }
140
211
 
141
- const resolved = await exec(["git", "-C", mirror, "rev-parse", ref], { env });
142
- if (resolved.code !== 0) {
143
- return { ok: false, stderr: scrubUserinfo(resolved.stderr.trim() || `git rev-parse ${ref} exited ${String(resolved.code)}`) };
212
+ if (fetched.code !== 0 && live === undefined) {
213
+ // The mirror refused the plain copy and there is no published branch to
214
+ // validate the run's head against: a rewritten branch that was never
215
+ // published must not rewrite the mirror either. Refuse with git's words.
216
+ return { ok: false, stderr: scrubUserinfo(fetched.stderr.trim() || fetched.stdout.trim() || `git fetch exited ${String(fetched.code)}`) };
217
+ }
218
+ if (fetched.code !== 0) {
219
+ // The proposed head is a legitimate fast-forward over the live remote (it
220
+ // equals the live head or the ancestor test above passed), so the only
221
+ // thing the plain copy refused on was the mirror's own stale copy.
222
+ // Refresh it; the push below still re-checks against the live remote.
223
+ const refreshed = await exec(["git", "-C", mirror, "fetch", "--no-tags", run.runRepoPath, `+${ref}:${ref}`], { env });
224
+ if (refreshed.code !== 0) {
225
+ return { ok: false, stderr: scrubUserinfo(refreshed.stderr.trim() || refreshed.stdout.trim() || `git fetch exited ${String(refreshed.code)}`) };
226
+ }
144
227
  }
145
- const sha = resolved.stdout.trim();
146
228
 
147
229
  const pushed = await exec(["git", "-C", mirror, "push", "origin", `${ref}:${ref}`], { env });
148
230
  if (pushed.code !== 0) {
149
231
  return { ok: false, stderr: scrubUserinfo(pushed.stderr.trim() || pushed.stdout.trim() || `git push exited ${String(pushed.code)}`) };
150
232
  }
151
- return { ok: true, sha };
233
+ return { ok: true, sha: proposed };
152
234
  }
153
235
 
154
236
  /**