agent-relay-orchestrator 0.121.0 → 0.121.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-orchestrator",
3
- "version": "0.121.0",
3
+ "version": "0.121.2",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "agent-relay-providers": "0.104.3",
20
- "agent-relay-sdk": "0.2.112",
20
+ "agent-relay-sdk": "0.2.113",
21
21
  "callmux": "0.23.0"
22
22
  },
23
23
  "devDependencies": {
package/src/control.ts CHANGED
@@ -7,6 +7,7 @@ import { readLocalProviderConfigs } from "./provider-config-migration";
7
7
  import { findSessionRecord, readRunnerInfo, spawnAgent, stopSession, type SpawnOptions } from "./spawn";
8
8
  import { cleanupWorkspace, discardRecoveryBranch, idleRefreshWorktree, mergeWorkspace, pruneWorktrees, reconcileWorkspace, refreshWorkspaceDeps, workspacesRoot } from "./workspace-probe";
9
9
  import { withMergePhaseTimeout } from "./workspace-probe/merge-timeouts";
10
+ import { withRepoLock } from "./repo-lock";
10
11
  import { armWorkspacePrAutoMerge, mergeWorkspacePr, refreshWorkspacePrBranch } from "./workspace-pr";
11
12
  import type { WorkspaceMergeResult } from "agent-relay-sdk";
12
13
  import { execProcess } from "./process";
@@ -21,6 +22,22 @@ export function mergeCommandStatus(result: WorkspaceMergeResult): "succeeded" |
21
22
  return result.error ? "failed" : "succeeded";
22
23
  }
23
24
 
25
+ // #1020 (B2) — command types that mutate a physical repo's git refs / worktrees. These take the
26
+ // per-repoRoot lock (see repo-lock.ts) so they can't interleave with the background spawn's
27
+ // acquisition + `git worktree add` on the same checkout. Read-only or worktree-filesystem-only
28
+ // commands (e.g. workspace.deps-refresh) are omitted so a slow dep install can't stall spawns.
29
+ const REPO_MUTATING_COMMANDS = new Set<string>([
30
+ "workspace.cleanup",
31
+ "workspace.reconcile",
32
+ "workspace.merge",
33
+ "workspace.pr-merge",
34
+ "workspace.pr-refresh",
35
+ "workspace.pr-arm-auto-merge",
36
+ "workspace.recovery-branch-discard",
37
+ "workspace.idle-refresh",
38
+ "workspace.prune",
39
+ ]);
40
+
24
41
  interface ControlHandler {
25
42
  handleCommand(command: RelayCommand): Promise<boolean>;
26
43
  getManagedAgents(): ManagedAgentReport[];
@@ -358,6 +375,18 @@ export function createControlHandler(
358
375
  }
359
376
 
360
377
  async function handleNonSpawnCommand(command: RelayCommand): Promise<boolean> {
378
+ // #1020 (B2) — a repo-root-mutating command takes the physical-repo lock so it can't interleave
379
+ // with the concurrently-dispatched background spawn (acquisition + `git worktree add`) on the
380
+ // same checkout. Non-spawn commands are already mutually serialized by the single-flight poll
381
+ // tick; this closes the one carve-out (agent.spawn). Commands without a repoRoot run unwrapped.
382
+ const repoRoot = typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined;
383
+ if (repoRoot && REPO_MUTATING_COMMANDS.has(command.type)) {
384
+ return withRepoLock(repoRoot, () => runNonSpawnCommand(command));
385
+ }
386
+ return runNonSpawnCommand(command);
387
+ }
388
+
389
+ async function runNonSpawnCommand(command: RelayCommand): Promise<boolean> {
361
390
  await relay.updateCommand(command.id, "accepted");
362
391
  await relay.updateCommand(command.id, "running");
363
392
  try {
@@ -0,0 +1,37 @@
1
+ import { resolve } from "node:path";
2
+
3
+ // #1020 (B2) — in-process serialization of repo-root-mutating git operations on ONE physical repo.
4
+ //
5
+ // The command poller serializes every command inline on the poll tick EXCEPT `agent.spawn`, which
6
+ // is dispatched to an unawaited background task (control.ts). That carve-out lets spawn-time
7
+ // acquisition (`git fetch` / `checkout` / `merge --ff-only`) and `git worktree add` run at the same
8
+ // wall-clock instant as an inline land's `merge --no-ff` / `push` / `worktree remove --force` in the
9
+ // SAME checkout — a `checkout` mid-land can yank HEAD out from under the merge, and concurrent
10
+ // worktree-admin ops race `.git/worktrees/` metadata.
11
+ //
12
+ // Every mutator of a given physical repoRoot takes this lock, so the background spawn and an inline
13
+ // land can no longer interleave on the same `.git`. A physical repo on a host is owned by exactly one
14
+ // orchestrator process, so an in-process mutex is sufficient for this same-host race (cross-process
15
+ // same-dir collisions are the separate #B3 concern). Distinct repoRoots use distinct keys, so
16
+ // spawns/lands on different repos still run fully in parallel.
17
+ const chains = new Map<string, Promise<unknown>>();
18
+
19
+ /**
20
+ * Run `fn` while holding the exclusive lock for `repoRoot`. Calls for the SAME resolved path run
21
+ * strictly one-at-a-time in FIFO order; calls for DIFFERENT paths run concurrently. A failure in one
22
+ * holder never poisons the queue — the next waiter runs regardless of how the prior one settled.
23
+ */
24
+ export function withRepoLock<T>(repoRoot: string, fn: () => Promise<T>): Promise<T> {
25
+ const key = resolve(repoRoot);
26
+ const prior = chains.get(key) ?? Promise.resolve();
27
+ // Chain onto the prior holder; run `fn` whether the prior resolved or rejected.
28
+ const run = prior.then(fn, fn);
29
+ // Tail the queue on an error-swallowed link so one holder's rejection can't break ordering.
30
+ const link = run.then(() => {}, () => {});
31
+ chains.set(key, link);
32
+ void link.then(() => {
33
+ // GC the map entry once this link is the tail and has settled.
34
+ if (chains.get(key) === link) chains.delete(key);
35
+ });
36
+ return run;
37
+ }
@@ -4,6 +4,7 @@ import { basename, dirname, join, resolve } from "node:path";
4
4
  import { errMessage, isPathWithinBase } from "agent-relay-sdk";
5
5
  import { git, requireGit } from "../git";
6
6
  import { execProcess } from "../process";
7
+ import { withRepoLock } from "../repo-lock";
7
8
  import type { ProjectAcquisitionManifest } from "./types";
8
9
 
9
10
  export type ProjectRootSyncAction = "synced" | "noop" | "skipped-dirty" | "skipped-diverged";
@@ -33,7 +34,11 @@ export async function applyProjectAcquisitionManifest(
33
34
  const rootPath = resolve(manifest.rootPath);
34
35
  const prior = inFlight.get(rootPath);
35
36
  if (prior) return prior;
36
- const run = withAcquisitionLock(rootPath, baseDir, () => applyManifestLocked(manifest, baseDir));
37
+ // #1020 (B2) take the physical-repo lock BEFORE the acquisition's git mutations so a land/cleanup
38
+ // on the same checkout (which also takes it) can't interleave. The file lock below still guards
39
+ // against a second orchestrator process; this in-process lock guards against the concurrent
40
+ // background spawn dispatch on THIS process.
41
+ const run = withRepoLock(rootPath, () => withAcquisitionLock(rootPath, baseDir, () => applyManifestLocked(manifest, baseDir)));
37
42
  inFlight.set(rootPath, run);
38
43
  try {
39
44
  return await run;
@@ -1007,7 +1007,13 @@ async function mergeRebaseFf(
1007
1007
  signal,
1008
1008
  );
1009
1009
  if (!merge.ok) {
1010
- await mergeGit(["merge", "--abort"], baseWorktree.path, "rebase", `workspace merge abort failed no-ff ${branch}`, signal);
1010
+ // #1020: run the abort-recovery WITHOUT the merge signal. When the no-ff merge failed
1011
+ // *because* the total-merge timeout signal fired (killing git mid-merge), reusing that
1012
+ // aborted signal would make `mergeGit` rethrow on its `throwIfMergeAborted` guard BEFORE
1013
+ // the abort runs, leaving the shared base checkout stuck mid-merge (MERGE_HEAD, conflicted
1014
+ // index). Same reasoning as restoreBaseWorktreeToSnapshot (#950) — this cleanup must run to
1015
+ // completion even under an aborted/timed-out merge.
1016
+ await mergeGit(["merge", "--abort"], baseWorktree.path, "cleanup", `workspace merge abort failed no-ff ${branch}`);
1011
1017
  return head({ conflict: true, status: "conflict", error: merge.stderr || "merge into base failed" });
1012
1018
  }
1013
1019
  baseTip = (await mergeGit(["rev-parse", "HEAD"], baseWorktree.path, "rebase", `workspace merge resolve ${base} after no-ff`, signal)).stdout;
@@ -4,6 +4,7 @@ import { join, resolve } from "node:path";
4
4
  import type { WorkspaceMode, WorkspaceProbe } from "agent-relay-sdk";
5
5
  import { errMessage } from "agent-relay-sdk";
6
6
  import { git, requireGit } from "../git";
7
+ import { withRepoLock } from "../repo-lock";
7
8
  import { provisionWorkspaceDeps, provisionWorkspaceSymlinks } from "./deps";
8
9
  import { availableBranch, branchName, terminalBaseRef, workspaceId, workspacesRoot, repoSlug } from "./names";
9
10
  import { parseWorktrees, shortBranch } from "./parse";
@@ -112,9 +113,16 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
112
113
  });
113
114
  }
114
115
 
115
- const branch = await availableBranch(repoRoot, branchName(input, id));
116
- mkdirSync(join(worktreePath, ".."), { recursive: true });
117
- await requireGit(["worktree", "add", "-b", branch, worktreePath, startSha], repoRoot);
116
+ // #1020 (B2) — serialize the branch resolve + `git worktree add` against a concurrent
117
+ // land/cleanup on the same repoRoot (both take this lock), so worktree-admin ops can't race
118
+ // `.git/worktrees/` metadata. Deps/symlinks provisioning below touches only the new worktree
119
+ // dir, so it stays outside the lock to keep the hold time short.
120
+ const branch = await withRepoLock(repoRoot, async () => {
121
+ const resolved = await availableBranch(repoRoot, branchName(input, id));
122
+ mkdirSync(join(worktreePath, ".."), { recursive: true });
123
+ await requireGit(["worktree", "add", "-b", resolved, worktreePath, startSha], repoRoot);
124
+ return resolved;
125
+ });
118
126
  const deps = await provisionWorkspaceDeps(repoRoot, worktreePath);
119
127
  const symlinks = provisionWorkspaceSymlinks(repoRoot, worktreePath, input.workspaceSymlinks ?? []);
120
128
  return {
@@ -158,9 +166,15 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
158
166
  });
159
167
  }
160
168
 
161
- const branch = await availableBranch(repoRoot, branchName(input, id));
162
- mkdirSync(join(worktreePath, ".."), { recursive: true });
163
- await requireGit(["worktree", "add", "-b", branch, worktreePath, baseSha], repoRoot);
169
+ // #1020 (B2) — serialize the branch resolve + `git worktree add` against a concurrent
170
+ // land/cleanup on the same repoRoot (both take this lock); provisioning below is worktree-local
171
+ // and stays outside the lock.
172
+ const branch = await withRepoLock(repoRoot, async () => {
173
+ const resolved = await availableBranch(repoRoot, branchName(input, id));
174
+ mkdirSync(join(worktreePath, ".."), { recursive: true });
175
+ await requireGit(["worktree", "add", "-b", resolved, worktreePath, baseSha], repoRoot);
176
+ return resolved;
177
+ });
164
178
 
165
179
  // A fresh worktree has no node_modules (git worktrees don't share
166
180
  // gitignored/untracked files). Provision deps before handing cwd to the