omp-conductor 0.7.1 → 0.8.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/upgrade.ts CHANGED
@@ -68,7 +68,7 @@ async function runCommand(command: string, args: readonly string[]): Promise<Upg
68
68
  }
69
69
  }
70
70
 
71
- const DEFAULT_DEPS: UpgradeDeps = {
71
+ export const DEFAULT_DEPS: UpgradeDeps = {
72
72
  run: runCommand,
73
73
  snapshot: statusSnapshot,
74
74
  layers: fleetLayers,
@@ -84,7 +84,7 @@ const DEFAULT_DEPS: UpgradeDeps = {
84
84
  const daemon = livingDaemon();
85
85
  return daemon === undefined ? { running: false } : { running: true, project: daemon.project };
86
86
  },
87
- setPaused,
87
+ setPaused: (v) => setPaused(v, { source: "upgrade", reason: "upgrade, draining" }),
88
88
  restartDaemon: async () => {
89
89
  await restartDaemon({});
90
90
  },
@@ -213,17 +213,43 @@ function previousHerdrInstall(source: string): readonly [string, readonly string
213
213
  ];
214
214
  }
215
215
 
216
- async function waitForDrain(deps: UpgradeDeps, project?: string): Promise<void> {
216
+ async function waitForDrain(deps: UpgradeDeps, project?: string, deadlineAt?: number): Promise<void> {
217
217
  let last = -1;
218
218
  while (true) {
219
219
  const workers = deps.snapshot(project).liveWorkers;
220
220
  if (workers === 0) return;
221
+ if (deadlineAt !== undefined && Date.now() >= deadlineAt) {
222
+ throw new Error(
223
+ `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)`,
224
+ );
225
+ }
221
226
  if (workers !== last) deps.log(`waiting for ${workers} live worker(s) to finish`);
222
227
  last = workers;
223
228
  await deps.sleep(DRAIN_POLL_MS);
224
229
  }
225
230
  }
226
231
 
232
+ /**
233
+ * Pause, drain, restart, restore — the trusted restart transaction, minus the
234
+ * install/verify steps of {@link upgradeConductor}.
235
+ *
236
+ * Mirrors the upgrade's fail-closed posture: on ANY throw after the pause the
237
+ * fleet stays paused (no resume in a catch) and the error rethrows, so a
238
+ * systemd-owned restart that fails or a drain that outlives `timeoutMs` cannot
239
+ * silently resume dispatch over a wedged daemon. The caller surfaces the message
240
+ * and exits nonzero.
241
+ */
242
+ export async function drainAndRestart(
243
+ deps: UpgradeDeps,
244
+ o: { project?: string; timeoutMs: number },
245
+ ): Promise<void> {
246
+ const initial = deps.layers(o.project);
247
+ if (!initial.paused) deps.setPaused(true);
248
+ await waitForDrain(deps, o.project, Date.now() + o.timeoutMs);
249
+ await deps.restartDaemon();
250
+ if (!initial.paused) deps.setPaused(false);
251
+ }
252
+
227
253
  function recoveryProblem(
228
254
  layers: FleetLayers,
229
255
  initial: FleetLayers,
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