agent-relay-orchestrator 0.120.6 → 0.121.1
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 +2 -2
- package/src/control.ts +29 -0
- package/src/repo-lock.ts +37 -0
- package/src/spawn/acquisition.ts +6 -1
- package/src/workspace-probe/merge.ts +7 -1
- package/src/workspace-probe/probe.ts +20 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.121.1",
|
|
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.
|
|
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 {
|
package/src/repo-lock.ts
ADDED
|
@@ -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
|
+
}
|
package/src/spawn/acquisition.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|