omp-conductor 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/types.ts CHANGED
@@ -42,6 +42,10 @@ export interface Caps {
42
42
  /** Parallel omp sessions. Two by default: on a small self-hosted runner pool
43
43
  * a third worker would starve its own PR checks. */
44
44
  maxConcurrentWorkers: number;
45
+ /** Max live workers per repository. 1 by default: the mirror, branch-protection
46
+ * staleness and shared CI egress are all per-repo collision domains (#186), so
47
+ * extra slots should land on other repos. */
48
+ maxConcurrentWorkersPerRepo: number;
45
49
  /**
46
50
  * Rolling-day spend ceiling. `null` means no spend gate (turns + wall-clock
47
51
  * still apply). `0` is a hard stop — deliberate, not "unset".
@@ -479,6 +483,10 @@ export interface ProjectConfig {
479
483
  tracker: { kind: "github"; repo: string };
480
484
  /** The one label that means "a human has signed this off as agent-ready". */
481
485
  queueLabel: string;
486
+ /** Routable-candidate count below which the tick prompt tells the orchestrator
487
+ * to groom the queue. Optional; defaults to {@link DEFAULT_GROOM_BELOW} in
488
+ * orchestrator-tick.ts. */
489
+ groomBelow?: number;
482
490
  /** Labels the dispatcher writes back so the tracker alone shows live state
483
491
  * to a human who never opens the daemon's logs. */
484
492
  stateLabels: { inProgress: string; blocked: string; failed: string };
@@ -686,6 +694,14 @@ export interface OpenCloser {
686
694
  */
687
695
  export interface Tracker {
688
696
  listReady(): Promise<ReadyIssue[]>;
697
+ /**
698
+ * Every open issue in the tracker repo (never PRs), labels included: one
699
+ * read that covers all lifecycle-label queries of a pass, so consumers
700
+ * needing several label sets pay one fetch instead of one per label, and
701
+ * openness itself is answerable without a per-issue lookup (#203).
702
+ * Complete — follows pagination to the end.
703
+ */
704
+ listOpenIssues(): Promise<ReadyIssue[]>;
689
705
  addLabel(issue: number, label: string): Promise<void>;
690
706
  removeLabel(issue: number, label: string): Promise<void>;
691
707
  comment(issue: number, body: string): Promise<void>;
@@ -903,6 +919,11 @@ export interface RunRecord {
903
919
  * exactly as an unflagged one does, and the flags are evidence for whoever
904
920
  * reviews the PR. Absent means the audit found nothing, or never ran. */
905
921
  settlementFlags?: SettlementFlag[];
922
+ /** The worker's settlement report text for this attempt, verbatim. Persisted
923
+ * on every terminal update so the next attempt can pool its disclosures
924
+ * (#199). Absent means the run predates the column, or was killed before the
925
+ * worker returned a report. */
926
+ report?: string;
906
927
  /**
907
928
  * Why this run ended badly, and what the daemon did about it (#132). Absent
908
929
  * means the sweep has not looked at the row yet — never "nothing was wrong":
@@ -923,6 +944,7 @@ export type AdmissionHoldReason =
923
944
  | "continuations"
924
945
  | "parent-lookup-error"
925
946
  | "sibling-active"
947
+ | "repo-active"
926
948
  | "open-pr-lookup-error"
927
949
  | "open-pr"
928
950
  | "unsalvaged-wip"
@@ -1134,10 +1156,28 @@ export interface DecisionDraft {
1134
1156
  at: number;
1135
1157
  }
1136
1158
 
1159
+ /** One decided GitHub label change waiting to be projected (#201). */
1160
+ export interface LabelOp {
1161
+ id: number;
1162
+ project: string;
1163
+ issue: number;
1164
+ op: "add" | "remove";
1165
+ label: string;
1166
+ createdAt: number;
1167
+ attempts: number;
1168
+ nextAttemptAt: number;
1169
+ lastError?: string;
1170
+ }
1171
+
1137
1172
  /**
1138
- * Bookkeeping only GitHub labels remain the source of truth. The store
1139
- * exists to answer cap questions cheaply and to survive a restart; if it is
1140
- * ever lost, the tracker can rebuild the world.
1173
+ * Dispatch state is store-authoritative; GitHub labels are a retried
1174
+ * write-behind projection of it (#201). `isEligible` still consults the label
1175
+ * set a candidate *effectively* carries physical labels overlaid with the
1176
+ * outbox's pending ops — so a label GitHub refuses to move cannot strand an
1177
+ * issue the daemon has already decided to move on. A store loss no longer
1178
+ * rebuilds the world: it also forgets what labels the tracker was told to
1179
+ * carry, so the operator reconciles by hand, which is the only safe move once
1180
+ * the two disagree.
1141
1181
  */
1142
1182
  export interface Store {
1143
1183
  createRun(r: Omit<RunRecord, "id">): RunRecord;
@@ -1170,6 +1210,11 @@ export interface Store {
1170
1210
  * tail` resolves an issue number to a transcript through this; the number is
1171
1211
  * what an operator has, the run id is not. */
1172
1212
  latestRun(project: string, issue: number): RunRecord | undefined;
1213
+ /** Every attempt of one issue that settled with a report, in attempt order.
1214
+ * The settlement audit pools these as prior disclosures when a later
1215
+ * attempt is reconciled (#199). Rows with no report (pre-#199, or killed
1216
+ * before the worker returned) are skipped. */
1217
+ attemptReports(project: string, issue: number): { attempt: number; report: string }[];
1173
1218
  runsStartedSince(project: string, sinceEpochMs: number): number;
1174
1219
  spendSince(project: string, sinceEpochMs: number): number;
1175
1220
  /** Idempotence guard so a retry loop cannot page a human repeatedly for the
@@ -1189,6 +1234,15 @@ export interface Store {
1189
1234
  /** Start the cooldown only after a tick carrying these signals was sent. */
1190
1235
  markFrictionSurfaced(project: string, kinds: readonly FrictionKind[], at: number): void;
1191
1236
  markNotified(key: string): void;
1237
+ /** Record one observed GitHub rate-limit refusal (the tracker's hook). Rows
1238
+ * older than 24h are pruned in the same write (#198). */
1239
+ recordGhRefusal?(at: number): void;
1240
+ /** Refusals observed at or after `sinceMs` — `status` reads the last 5m. */
1241
+ ghRefusalsSince?(sinceMs: number): { count: number; latestAt?: number };
1242
+ /** Count one daemon-held `gh` call for the UTC day (`YYYY-MM-DD`). */
1243
+ bumpGhCalls?(day: string, source: string): void;
1244
+ /** The tracked call counts for a UTC day, per source. */
1245
+ ghCallsToday?(day: string): { source: string; calls: number }[];
1192
1246
  /**
1193
1247
  * Persist a rendered report `pending`, before anything is sent — the whole
1194
1248
  * point of #123 is that an undelivered report is a queryable row rather than
@@ -1283,6 +1337,33 @@ export interface Store {
1283
1337
  releaseMergeLock(project: string, holder: string): void;
1284
1338
  /** The live lock, for `status` and for tests. */
1285
1339
  mergeLock(project: string): MergeLock | undefined;
1340
+ /**
1341
+ * Append decided GitHub label changes to the projection outbox (#201). One
1342
+ * transaction: either every op lands as a pending row or none do, so the
1343
+ * daemon's record of its own intent cannot disagree with itself. Ops for one
1344
+ * issue apply in enqueue (id) order, which is what makes a swap atomic — the
1345
+ * remove lands before the add exactly as enqueued.
1346
+ */
1347
+ enqueueLabelOps(
1348
+ project: string,
1349
+ ops: readonly { issue: number; op: "add" | "remove"; label: string }[],
1350
+ ): void;
1351
+ /** Unapplied ops whose retry backoff has elapsed, oldest first (#201). */
1352
+ pendingLabelOps(project: string, now: number): LabelOp[];
1353
+ /** Every unapplied op for one issue, enqueue order — the eligibility overlay
1354
+ * reads this so a stale GitHub label cannot outlive the daemon's intent
1355
+ * (#201). */
1356
+ pendingLabelOpsFor(project: string, issue: number): LabelOp[];
1357
+ /** The op applied; forget it. */
1358
+ settleLabelOp(id: number): void;
1359
+ /** The op failed; count the attempt and park it behind `nextAttemptAt`. Pass
1360
+ * `countAttempt: false` for a refusal that is not the op's fault (a shared
1361
+ * rate limit), so it does not burn the backoff escalation (#208). */
1362
+ deferLabelOp(id: number, error: string, nextAttemptAt: number, countAttempt?: boolean): void;
1363
+ /** Unapplied ops still owed — `status` shows the projection lag (#201). */
1364
+ countPendingLabelOps(project: string): number;
1365
+ /** `createdAt` of the oldest unapplied op, or `undefined` for none. */
1366
+ oldestPendingLabelOpAt(project: string): number | undefined;
1286
1367
  close(): void;
1287
1368
  }
1288
1369
 
@@ -1311,6 +1392,7 @@ export interface Escalation {
1311
1392
  */
1312
1393
  export const DEFAULT_CAPS: Caps = {
1313
1394
  maxConcurrentWorkers: 2,
1395
+ maxConcurrentWorkersPerRepo: 1,
1314
1396
  dailySpendUsd: 25,
1315
1397
  // Off unless an operator names a window. A default threshold would need a
1316
1398
  // default window id, and guessing which allowance a fleet lives on is how a
package/src/unblock.ts CHANGED
@@ -15,18 +15,27 @@
15
15
  * dispatcher writes labels with, and the brief's rule stays absolute. That
16
16
  * absoluteness is worth more than the exception it replaces: orphan detection
17
17
  * is only trustworthy while every state label on the tracker was written by
18
- * this package.
18
+ * this package. The re-queue is the same port and the same principle: the
19
+ * queue label goes back on by default (#184) so an answered block is
20
+ * dispatchable again, and it stays off under `--no-requeue` or while any run
21
+ * for the issue is still active.
19
22
  *
20
- * Nothing here writes to the store, and that is a decision rather than an
21
- * omission. `RunState` describes what a worker process did; an answer is the
23
+ * Nothing here writes to the run history, and that is a decision rather than
24
+ * an omission. `RunState` describes what a worker process did; an answer is the
22
25
  * one event that happens outside every run, so no member fits it — folding it
23
26
  * into `merged` or `killed` would make `status` describe a run that never
24
- * reached either. Eligibility is read off the tracker's labels and never off a
25
- * run row, so the store has nothing to say here. Leaving history alone keeps
26
- * both budgets honest: a block consumes an operational continuation, while a
27
- * real implementation failure consumes the separate failed-attempt budget.
27
+ * reached either. The one store write is the label projection outbox (#201):
28
+ * a decided label change is durable locally and projected with retry, so a 403
29
+ * defers it instead of throwing the verb, and the daemon's next tick applies it
30
+ * even if this process dies before it does. Eligibility is read off the label
31
+ * set an issue effectively carries — physical labels overlaid with that outbox —
32
+ * and never off a run row, so the store has nothing else to say here. Leaving
33
+ * history alone keeps both budgets honest: a block consumes an operational
34
+ * continuation, while a real implementation failure consumes the separate
35
+ * failed-attempt budget.
28
36
  */
29
37
 
38
+ import { projectLabels } from "./label-projection.ts";
30
39
  import { LIVE_STATES } from "./store.ts";
31
40
  import type { Caps, ProjectConfig, RunRecord, Store, Tracker } from "./types.ts";
32
41
 
@@ -44,11 +53,32 @@ export interface UnblockOutcome {
44
53
  * to "will the dispatcher hold this issue as issue-active?" that #178
45
54
  * found this verb guessing at. */
46
55
  active: boolean;
56
+ /** Set when a live worker (a claimed/running run) is on the issue. The
57
+ * dispatcher holds a worker-backed run as issue-active unconditionally,
58
+ * while worker-free pushed occupancy can bypass it (#175) — so this is
59
+ * the signal the queue label and the in-flight wording follow. */
60
+ live?: true;
47
61
  /** Set when nothing was cleared because the newest attempt's work exists
48
62
  * only in its worktree. Carries the salvage failure verbatim. */
49
63
  refused?: string;
50
64
  /** Set when `--force` recorded an operator's acceptance of that loss. */
51
65
  forced?: true;
66
+ /** Set when the queue label was re-added (the default); absent on
67
+ * --no-requeue, on the refusal path, and when a live worker (claimed or
68
+ * running) is still on the issue. A pushed-green/pushed-pending occupancy
69
+ * is worker-free, so the label is still restored there (#175 bypasses it;
70
+ * `isEligible` needs the label once the PR resolves). */
71
+ requeued?: true;
72
+ /** Set when `--no-requeue` skipped the queue-label re-add. */
73
+ requeueSkipped?: true;
74
+ /** Set when the tracker refused one or more of this verb's label ops, so
75
+ * the label sync is owed rather than done: the intended label state is
76
+ * durable in the store and the daemon retries it (#201). Safety is
77
+ * preserved either way, but the issue is only claimable once the queue
78
+ * label itself has landed — `listReady` reads the tracker by label, so a
79
+ * queue-label add that has not been applied is invisible to dispatch. The
80
+ * number is what is still pending. */
81
+ labelSyncQueued?: number;
52
82
  }
53
83
 
54
84
  /**
@@ -90,13 +120,24 @@ export async function unblockIssue(
90
120
  tracker: Tracker,
91
121
  store: Store,
92
122
  issue: number,
93
- opts: { force?: boolean } = {},
123
+ opts: { force?: boolean; requeue?: boolean } = {},
94
124
  ): Promise<UnblockOutcome> {
125
+ const requeue = opts.requeue !== false;
95
126
  // Read before any label is touched: terminality is the whole of the argument
96
127
  // for clearing in-progress, so the row that carries it decides the set.
97
128
  const latest = store.latestRun(project.name, issue);
98
129
  const terminal = latest !== undefined && !LIVE_STATES.includes(latest.state);
130
+ // Two different occupancies, and the label decisions each follow its own:
131
+ // `active` (ACTIVE_STATES) is what *occupies the issue* — a pushed-green or
132
+ // pushed-pending run has no worker process but its PR is live, so dispatch
133
+ // must not start a fresh attempt behind it. `live` (LIVE_STATES) is what
134
+ // *holds a worker process* (claimed/running). The queue label follows
135
+ // `live`: the dispatcher only holds a claim as issue-active for a
136
+ // worker-backed run (#175 bypasses worker-free pushed-green rows), so an
137
+ // issue whose occupancy is pushed-only is genuinely re-queueable and
138
+ // `isEligible` requires the label once that PR resolves closed-unmerged.
99
139
  const active = store.activeRuns(project.name).some((r) => r.issue === issue);
140
+ const live = store.liveRuns(project.name).some((r) => r.issue === issue);
100
141
  const counts = {
101
142
  attemptsUsed: store.attemptsFor(project.name, issue),
102
143
  failuresUsed: store.failuresFor(project.name, issue),
@@ -123,20 +164,66 @@ export async function unblockIssue(
123
164
  if (held !== undefined) store.updateRun(held.id, { salvageAckAt: Date.now() });
124
165
 
125
166
  const cleared: string[] = [];
167
+ const ops: { issue: number; op: "add" | "remove"; label: string }[] = [];
126
168
  for (const label of new Set([
127
169
  project.stateLabels.blocked,
128
170
  project.stateLabels.failed,
129
171
  ...(terminal ? [project.stateLabels.inProgress] : []),
130
172
  ])) {
131
- await tracker.removeLabel(issue, label);
173
+ ops.push({ issue, op: "remove", label });
132
174
  cleared.push(label);
133
175
  }
134
176
 
177
+ // The re-queue half of the verb. Clearing the state labels makes the issue
178
+ // *eligible*, but the dispatcher never sees an issue that does not carry the
179
+ // queue label — without this the unblock ends in the same alive-but-inert
180
+ // state it exists to end (#184). The label goes back on by default;
181
+ // `--no-requeue` is the "about to close it" case and leaves dispatch alone.
182
+ // Never under a *live worker*: the dispatcher holds a worker-backed run as
183
+ // issue-active anyway, and restoring the label under one is exactly the
184
+ // #178 inverted state. A pushed-green/pushed-pending run is worker-free —
185
+ // its PR is live but no process writes to its branch — so a pushed
186
+ // continuation still gets the label: the dispatcher bypasses worker-free
187
+ // pushed-green rows (#175), and `isEligible` requires the queue label once
188
+ // the PR resolves closed-unmerged.
189
+ let requeued: true | undefined;
190
+ let requeueSkipped: true | undefined;
191
+ let labelSyncQueued: number | undefined;
192
+ if (!live) {
193
+ if (requeue) {
194
+ ops.push({ issue, op: "add", label: project.queueLabel });
195
+ requeued = true;
196
+ } else {
197
+ requeueSkipped = true;
198
+ }
199
+ }
200
+
201
+ // Apply through the same projector the daemon drains (#201): the clears and
202
+ // the queue restore land in enqueue order, and a tracker that refuses (403,
203
+ // rate limit) defers them with backoff instead of throwing the verb — which
204
+ // is the concrete #184/#198 kill: `unblock` stops breaking on a 403. The
205
+ // intended label state is durable; the daemon converges the tracker. The
206
+ // issue only becomes claimable once the queue label itself lands —
207
+ // `listReady` queries by label, so a pending queue-label add is invisible to
208
+ // dispatch until projection succeeds.
209
+ if (ops.length > 0) {
210
+ store.enqueueLabelOps(project.name, ops);
211
+ await projectLabels(store, tracker, project);
212
+ // How much of THIS issue's sync is still owed (not the whole fleet's outbox):
213
+ // a backlog on other issues is not this verb's lag to report.
214
+ const stillPending = store.pendingLabelOpsFor(project.name, issue).length;
215
+ if (stillPending > 0) labelSyncQueued = stillPending;
216
+ }
217
+
135
218
  return {
136
219
  cleared,
137
220
  ...counts,
221
+ ...(live ? { live: true as const } : {}),
138
222
  ...(latest === undefined ? {} : { latest }),
139
223
  ...(held === undefined ? {} : { forced: true as const }),
224
+ ...(requeued === undefined ? {} : { requeued }),
225
+ ...(requeueSkipped === undefined ? {} : { requeueSkipped }),
226
+ ...(labelSyncQueued === undefined ? {} : { labelSyncQueued }),
140
227
  };
141
228
  }
142
229
 
@@ -175,6 +262,15 @@ export function formatUnblock(
175
262
  }
176
263
 
177
264
  const lines = [`#${issue}: cleared ${o.cleared.join(", ")}`];
265
+ // When the tracker refused the sync, NO label is provably applied — the
266
+ // "restored / eligible again" claims below would be a lie, so they branch to
267
+ // the queued/retry wording instead (#201).
268
+ const labelsPending = o.labelSyncQueued !== undefined;
269
+ if (o.labelSyncQueued !== undefined) {
270
+ lines.push(
271
+ ` label sync queued (${o.labelSyncQueued} pending) — the daemon retries; the intended labels are durable, and the issue is claimable once they land`,
272
+ );
273
+ }
178
274
  if (o.forced === true) {
179
275
  lines.push(
180
276
  ` forced the unsalvaged worktree was accepted as lost or already recovered by hand — ` +
@@ -191,9 +287,15 @@ export function formatUnblock(
191
287
  }
192
288
 
193
289
  if (latest !== undefined && LIVE_STATES.includes(latest.state)) {
290
+ // The newest run is live, so `active` is necessarily true and the queue
291
+ // label was NOT re-added (unblockIssue only re-queues when no run for the
292
+ // issue is active). Say that, like the sibling-active branch below does —
293
+ // the label outcome is part of the contract (#184), and a live newest run
294
+ // reaches this branch instead of `else if (o.active)`.
194
295
  lines.push(
195
296
  ` in flight attempt ${latest.attempt} is ${latest.state}, so the issue keeps ` +
196
297
  `"${project.stateLabels.inProgress}" until it ends — nothing is re-claimed before then`,
298
+ ` queue "${project.queueLabel}" not restored — a run is still active; re-run unblock once it settles`,
197
299
  );
198
300
  } else if (o.failuresUsed >= caps.maxAttemptsPerIssue) {
199
301
  lines.push(
@@ -218,25 +320,59 @@ export function formatUnblock(
218
320
  // instead of promising a re-claim the next tick withholds.
219
321
  lines.push(
220
322
  latest.prUrl === undefined
221
- ? ` next tick eligible again, as long as the issue still carries "${project.queueLabel}" ` +
222
- `(newest run is ${latest.state} with no recorded PR; the dispatcher still applies its open-PR check at claim time)`
323
+ ? labelsPending
324
+ ? ` next tick label sync queued (${o.labelSyncQueued} pending) the daemon retries; eligibility is recorded in the store, not yet on the tracker`
325
+ : o.requeued === true
326
+ ? ` next tick eligible again — "${project.queueLabel}" restored (no-op if it was already present; ` +
327
+ "the dispatcher still applies its open-PR check at claim time)"
328
+ : ` next tick eligible again, as long as the issue still carries "${project.queueLabel}" ` +
329
+ `(newest run is ${latest.state} with no recorded PR; the dispatcher still applies its open-PR check at claim time)`
223
330
  : ` next tick eligible as a continuation of ${latest.prUrl} — the pushed run stays active until ` +
224
331
  "that PR resolves; dispatch continues on its branch",
225
332
  );
226
333
  } else if (o.active) {
227
- // A run other than the newest is still active (e.g. a live sibling under a
228
- // terminal newest row), so the dispatcher will hold the issue as
229
- // issue-active whatever the labels now say. #178's misleading case.
230
- lines.push(
231
- ` in flight a run for this issue is still active, so the dispatcher holds the issue until it settles — ` +
232
- `nothing is re-claimed before then ("${project.stateLabels.inProgress}" stays unless already released)`,
233
- );
334
+ // A run other than the newest still occupies the issue. Two shapes hide
335
+ // under `active`, and they demand opposite reports: a *live worker*
336
+ // (claimed/running) holds the issue unconditionally #178's misleading
337
+ // case — while worker-free pushed runs (pushed-green/pushed-pending) are
338
+ // the continuation shape, because #175 lets the dispatcher admit
339
+ // pushed-green rows whose workers are gone. The queue label follows the
340
+ // same split: off under a live worker, restored under pushed-only
341
+ // occupancy.
342
+ if (o.live === true) {
343
+ lines.push(
344
+ ` in flight a live worker is still on this issue, so the dispatcher holds it until that run settles — ` +
345
+ `nothing is re-claimed before then ("${project.stateLabels.inProgress}" stays unless already released)`,
346
+ ` queue "${project.queueLabel}" not restored — a live worker is still on this issue; re-run unblock once it settles`,
347
+ );
348
+ } else {
349
+ lines.push(
350
+ ` next tick no live worker is on this issue — its active run(s) are worker-free pushes; ` +
351
+ "dispatch continues their branch once its holds and open-PR check clear",
352
+ );
353
+ if (labelsPending) {
354
+ lines.push(
355
+ ` queue label sync queued (${o.labelSyncQueued} pending) — the daemon retries; the intended labels are durable, and the issue is claimable once they land`,
356
+ );
357
+ } else if (o.requeued === true) {
358
+ lines.push(` queue "${project.queueLabel}" restored (no-op if it was already present)`);
359
+ }
360
+ }
234
361
  } else {
235
362
  lines.push(
236
- ` next tick eligible again, as long as the issue still carries "${project.queueLabel}" ` +
237
- "(the dispatcher still applies its open-PR check at claim time)",
363
+ labelsPending
364
+ ? ` next tick label sync queued (${o.labelSyncQueued} pending) — the daemon retries; the intended labels are durable, and the issue is claimable once they land`
365
+ : o.requeued === true
366
+ ? ` next tick eligible again — "${project.queueLabel}" restored (no-op if it was already present; ` +
367
+ "the dispatcher still applies its open-PR check at claim time)"
368
+ : ` next tick eligible again, as long as the issue still carries "${project.queueLabel}" ` +
369
+ "(the dispatcher still applies its open-PR check at claim time)",
238
370
  );
239
371
  }
240
372
 
373
+ if (o.requeueSkipped === true) {
374
+ lines.push(` queue "${project.queueLabel}" left untouched (--no-requeue)`);
375
+ }
376
+
241
377
  return lines.join("\n");
242
378
  }
package/src/upgrade.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
+ import { backupTimestamp, copyToUniqueBackup } from "./backups.ts";
2
3
  import { inspectBriefLayout, type BriefLayout } from "./brief-upgrade.ts";
3
4
  import { setPaused, statusSnapshot } from "./daemon.ts";
4
5
  import { fleetLayers, telegramStateDir, type DispatchLayer, type FleetLayers } from "./fleet.ts";
5
6
  import { livingDaemon, restartDaemon } from "./lifecycle.ts";
6
- import { configPath, findProject, loadConfig, resolveCaps, writeConfigRaw } from "./config.ts";
7
+ import { configBackupDir, configPath, findProject, loadConfig, resolveCaps, writeConfigRaw } from "./config.ts";
7
8
  import { renderBriefForProject } from "./setup.ts";
8
9
  import {
9
10
  STAGED_SERVICE_NAME,
@@ -68,7 +69,7 @@ async function runCommand(command: string, args: readonly string[]): Promise<Upg
68
69
  }
69
70
  }
70
71
 
71
- const DEFAULT_DEPS: UpgradeDeps = {
72
+ export const DEFAULT_DEPS: UpgradeDeps = {
72
73
  run: runCommand,
73
74
  snapshot: statusSnapshot,
74
75
  layers: fleetLayers,
@@ -84,7 +85,7 @@ const DEFAULT_DEPS: UpgradeDeps = {
84
85
  const daemon = livingDaemon();
85
86
  return daemon === undefined ? { running: false } : { running: true, project: daemon.project };
86
87
  },
87
- setPaused,
88
+ setPaused: (v) => setPaused(v, { source: "upgrade", reason: "upgrade, draining" }),
88
89
  restartDaemon: async () => {
89
90
  await restartDaemon({});
90
91
  },
@@ -213,17 +214,43 @@ function previousHerdrInstall(source: string): readonly [string, readonly string
213
214
  ];
214
215
  }
215
216
 
216
- async function waitForDrain(deps: UpgradeDeps, project?: string): Promise<void> {
217
+ async function waitForDrain(deps: UpgradeDeps, project?: string, deadlineAt?: number): Promise<void> {
217
218
  let last = -1;
218
219
  while (true) {
219
220
  const workers = deps.snapshot(project).liveWorkers;
220
221
  if (workers === 0) return;
222
+ if (deadlineAt !== undefined && Date.now() >= deadlineAt) {
223
+ throw new Error(
224
+ `drain timed out with ${workers} live worker(s) still running — nothing was restarted; dispatch remains paused (omp-conductor resume to lift it, or re-run restart to keep waiting)`,
225
+ );
226
+ }
221
227
  if (workers !== last) deps.log(`waiting for ${workers} live worker(s) to finish`);
222
228
  last = workers;
223
229
  await deps.sleep(DRAIN_POLL_MS);
224
230
  }
225
231
  }
226
232
 
233
+ /**
234
+ * Pause, drain, restart, restore — the trusted restart transaction, minus the
235
+ * install/verify steps of {@link upgradeConductor}.
236
+ *
237
+ * Mirrors the upgrade's fail-closed posture: on ANY throw after the pause the
238
+ * fleet stays paused (no resume in a catch) and the error rethrows, so a
239
+ * systemd-owned restart that fails or a drain that outlives `timeoutMs` cannot
240
+ * silently resume dispatch over a wedged daemon. The caller surfaces the message
241
+ * and exits nonzero.
242
+ */
243
+ export async function drainAndRestart(
244
+ deps: UpgradeDeps,
245
+ o: { project?: string; timeoutMs: number },
246
+ ): Promise<void> {
247
+ const initial = deps.layers(o.project);
248
+ if (!initial.paused) deps.setPaused(true);
249
+ await waitForDrain(deps, o.project, Date.now() + o.timeoutMs);
250
+ await deps.restartDaemon();
251
+ if (!initial.paused) deps.setPaused(false);
252
+ }
253
+
227
254
  function recoveryProblem(
228
255
  layers: FleetLayers,
229
256
  initial: FleetLayers,
@@ -301,6 +328,7 @@ async function rollbackUpgrade(
301
328
  herdrReloadStarted: boolean,
302
329
  daemonReloadStarted: boolean,
303
330
  configBefore?: string,
331
+ preUpgradeBackup?: string,
304
332
  ): Promise<void> {
305
333
  const failures: string[] = [];
306
334
 
@@ -318,12 +346,23 @@ async function rollbackUpgrade(
318
346
  const path = configPath();
319
347
  if (readFileSync(path, "utf8") !== configBefore) {
320
348
  deps.log("rollback: conductor config.json");
349
+ // Name the durable snapshot: the in-memory restore only runs while
350
+ // this process is alive, and an upgrade that died mid-flight leaves
351
+ // the operator needing exactly these bytes from disk.
352
+ deps.log(
353
+ preUpgradeBackup === undefined
354
+ ? "rollback: config.json changed during the upgrade; no durable snapshot was persisted"
355
+ : `rollback: config.json changed during the upgrade; pre-upgrade snapshot at ${preUpgradeBackup}`,
356
+ );
321
357
  // Exact bytes. `writeConfigFile(JSON.parse(...))` returns canonically
322
358
  // formatted output, which restores the keys but not the file.
323
359
  writeConfigRaw(configBefore);
324
360
  }
325
361
  } catch (err) {
326
- failures.push(`could not restore config.json: ${err instanceof Error ? err.message : String(err)}`);
362
+ failures.push(
363
+ `could not restore config.json: ${err instanceof Error ? err.message : String(err)}` +
364
+ (preUpgradeBackup === undefined ? "" : `; pre-upgrade snapshot at ${preUpgradeBackup}`),
365
+ );
327
366
  }
328
367
  }
329
368
  const restore = async (label: string, command: string, args: readonly string[]): Promise<void> => {
@@ -470,13 +509,23 @@ export async function upgradeConductor(
470
509
  // Snapshotted before anything is installed, because the new daemon starts
471
510
  // during this transaction and may migrate the file — see the restore in
472
511
  // `rollbackUpgrade`. Read as bytes, not through the loader: the point is to
473
- // put back exactly what was there, dialect and all.
512
+ // put back exactly what was there, dialect and all. The same bytes are also
513
+ // persisted under state, so a process death between here and the rollback
514
+ // cannot take the only pre-upgrade copy with it.
474
515
  let configBefore: string | undefined;
516
+ let preUpgradeBackup: string | undefined;
475
517
  try {
476
518
  configBefore = readFileSync(configPath(), "utf8");
519
+ preUpgradeBackup = copyToUniqueBackup(
520
+ configPath(),
521
+ configBackupDir(),
522
+ `config.json.pre-upgrade-${backupTimestamp()}`,
523
+ );
524
+ deps.log(`config backed up to ${preUpgradeBackup}`);
477
525
  } catch {
478
526
  // No readable config is not a reason to refuse an upgrade; there is simply
479
- // nothing to put back.
527
+ // nothing to put back. A snapshot that failed to persist is not fatal
528
+ // either — the in-memory restore below still covers a clean rollback.
480
529
  }
481
530
 
482
531
  if (brief.kind === "missing") throw new Error("no ORCHESTRATOR.md exists for the configured project");
@@ -582,6 +631,7 @@ export async function upgradeConductor(
582
631
  herdrReloadStarted,
583
632
  daemonReloadStarted,
584
633
  configBefore,
634
+ preUpgradeBackup,
585
635
  );
586
636
  } catch (rollbackErr) {
587
637
  const rollback = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
package/src/worktree.ts CHANGED
@@ -263,14 +263,22 @@ function refreshManagedExclude(worktree: string): void {
263
263
  }
264
264
  }
265
265
 
266
+ /**
267
+ * Serializes concurrent {@link ensureMirror} calls per mirror path so two
268
+ * dispatches cannot collide on git's ref locks for the same repo (#186). A
269
+ * rejected (failed) call does not poison the chain: the next caller waits on a
270
+ * settled promise.
271
+ */
272
+ const mirrorLocks = new Map<string, Promise<unknown>>();
273
+
266
274
  /**
267
275
  * Returns the path of the bare mirror for `repo`, cloning it on first use and
268
276
  * refreshing it otherwise.
269
277
  *
270
- * ponytail: no cross-process lock. Two dispatch loops that call this for the
271
- * same repo at the same instant can collide on git's ref locks and one will
272
- * throw; the run is retried rather than corrupted. Upgrade path is a lockfile
273
- * in `mirrorRoot` keyed by repo name.
278
+ * ponytail: the lock is per process, not a cross-process lockfile in
279
+ * `mirrorRoot`. Two *dispatch loops* can still collide on git's ref locks and
280
+ * one will throw; the run is retried rather than corrupted. Upgrade path is a
281
+ * lockfile keyed by repo name, for a future multi-daemon host.
274
282
  *
275
283
  * ponytail: if `repo.cloneUrl` embeds credentials, `git clone` persists them in
276
284
  * the mirror's config, exactly as it would for a hand-run clone. Prefer an SSH
@@ -280,6 +288,17 @@ export async function ensureMirror(
280
288
  repo: RepoTarget,
281
289
  mirrorRoot: string,
282
290
  ): Promise<string> {
291
+ const key = mirrorPathFor(repo, mirrorRoot);
292
+ const prev = mirrorLocks.get(key) ?? Promise.resolve();
293
+ const next = prev.catch(() => {}).then(() => ensureMirrorUnlocked(repo, mirrorRoot));
294
+ mirrorLocks.set(key, next);
295
+ void next.catch(() => {}).finally(() => {
296
+ if (mirrorLocks.get(key) === next) mirrorLocks.delete(key);
297
+ });
298
+ return next;
299
+ }
300
+
301
+ async function ensureMirrorUnlocked(repo: RepoTarget, mirrorRoot: string): Promise<string> {
283
302
  mkdirSync(mirrorRoot, { recursive: true });
284
303
  const mirrorPath = mirrorPathFor(repo, mirrorRoot);
285
304