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 +15 -5
- package/package.json +1 -1
- package/src/cli.ts +7 -2
- package/src/daemon.ts +146 -0
- package/src/gitops.ts +98 -16
- package/src/lifecycle.ts +149 -29
- package/src/omp.ts +34 -0
- package/src/types.ts +1 -0
- package/src/upgrade.ts +283 -52
- package/src/worker.ts +41 -15
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
+
"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
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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
|
|
125
|
-
*
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
/**
|