agent-relay-orchestrator 0.120.2 → 0.120.4

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.120.2",
3
+ "version": "0.120.4",
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.108",
20
+ "agent-relay-sdk": "0.2.109",
21
21
  "callmux": "0.23.0"
22
22
  },
23
23
  "devDependencies": {
@@ -6,13 +6,19 @@ import { git, requireGit } from "../git";
6
6
  import { execProcess } from "../process";
7
7
  import type { ProjectAcquisitionManifest } from "./types";
8
8
 
9
+ export type ProjectRootSyncAction = "synced" | "noop" | "skipped-dirty" | "skipped-diverged";
10
+
9
11
  export interface ProjectAcquisitionResult {
10
12
  applied: boolean;
11
13
  action: "cloned" | "synced" | "noop";
14
+ syncAction: ProjectRootSyncAction;
12
15
  rootPath: string;
13
16
  remoteUrl: string;
14
17
  ref?: string;
15
18
  headSha?: string;
19
+ baseRef?: string;
20
+ baseSha?: string;
21
+ syncTarget?: string;
16
22
  }
17
23
 
18
24
  const inFlight = new Map<string, Promise<ProjectAcquisitionResult>>();
@@ -68,15 +74,19 @@ async function applyManifestLocked(manifest: ProjectAcquisitionManifest, baseDir
68
74
  await cloneRoot(manifest, baseDir);
69
75
  action = "cloned";
70
76
  }
71
- const syncAction = await syncRoot(manifest);
72
- if (syncAction === "synced" && action !== "cloned") action = "synced";
77
+ const sync = await syncRoot(manifest);
78
+ if (sync.action === "synced" && action !== "cloned") action = "synced";
73
79
  return {
74
80
  applied: true,
75
81
  action,
82
+ syncAction: sync.action,
76
83
  rootPath,
77
84
  remoteUrl,
78
85
  ...(manifest.ref ? { ref: manifest.ref } : {}),
79
86
  headSha: (await git(["rev-parse", "HEAD"], rootPath)).stdout || undefined,
87
+ ...(sync.baseRef ? { baseRef: sync.baseRef } : {}),
88
+ ...(sync.baseSha ? { baseSha: sync.baseSha } : {}),
89
+ ...(sync.target ? { syncTarget: sync.target } : {}),
80
90
  };
81
91
  }
82
92
 
@@ -114,26 +124,48 @@ async function cloneRoot(manifest: ProjectAcquisitionManifest, baseDir: string):
114
124
  }
115
125
  }
116
126
 
117
- async function syncRoot(manifest: ProjectAcquisitionManifest): Promise<"synced" | "noop"> {
127
+ interface SyncTarget {
128
+ target?: string;
129
+ branch?: string;
130
+ baseRef?: string;
131
+ }
132
+
133
+ interface RootSyncResult extends SyncTarget {
134
+ action: ProjectRootSyncAction;
135
+ baseSha?: string;
136
+ }
137
+
138
+ async function syncRoot(manifest: ProjectAcquisitionManifest): Promise<RootSyncResult> {
118
139
  const rootPath = resolve(manifest.rootPath);
119
140
  await assertExistingGitRoot(rootPath);
120
141
  await assertRemote(rootPath, manifest.remoteUrl.trim());
121
- const status = await git(["status", "--porcelain"], rootPath);
122
- if (!status.ok) throw new Error(`git status failed for ${rootPath}: ${status.stderr}`);
123
- if (status.stdout.trim()) throw new Error(`project root ${rootPath} has local changes; refusing ff-only sync before spawn`);
124
142
  const fetch = await git(["fetch", "--prune", "origin"], rootPath);
125
143
  if (!fetch.ok) throw new Error(`git fetch failed for ${rootPath}: ${fetch.stderr || fetch.stdout}`);
126
- const target = await checkoutSyncTarget(rootPath, manifest.ref);
127
- if (!target) return "noop";
128
- const head = await requireGit(["rev-parse", "HEAD"], rootPath);
129
- const targetHead = await requireGit(["rev-parse", target], rootPath);
130
- if (head === targetHead) return "noop";
131
- if (!(await git(["merge-base", "--is-ancestor", "HEAD", target], rootPath)).ok) {
132
- throw new Error(`project root ${rootPath} has diverged from ${target}; refusing non-fast-forward sync`);
144
+ const target = await resolveSyncTarget(rootPath, manifest.ref);
145
+ const baseSha = target.target ? await requireGit(["rev-parse", target.target], rootPath) : undefined;
146
+ const status = await git(["status", "--porcelain"], rootPath);
147
+ if (!status.ok) throw new Error(`git status failed for ${rootPath}: ${status.stderr}`);
148
+ if (status.stdout.trim()) {
149
+ warnSyncSkipped(rootPath, target.target, "skipped-dirty", "local changes");
150
+ return { ...target, action: "skipped-dirty", ...(baseSha ? { baseSha } : {}) };
151
+ }
152
+ if (!target.target) return { ...target, action: "noop", ...(baseSha ? { baseSha } : {}) };
153
+
154
+ const localRef = target.branch && await localBranchExists(rootPath, target.branch) ? `refs/heads/${target.branch}` : "HEAD";
155
+ const head = await requireGit(["rev-parse", localRef], rootPath);
156
+ if (!baseSha || head === baseSha) return { ...target, action: "noop", ...(baseSha ? { baseSha } : {}) };
157
+ if (!(await git(["merge-base", "--is-ancestor", localRef, target.target], rootPath)).ok) {
158
+ warnSyncSkipped(rootPath, target.target, "skipped-diverged", `${localRef} is ahead of or diverged from target`);
159
+ return { ...target, action: "skipped-diverged", baseSha };
133
160
  }
134
- const merged = await git(["merge", "--ff-only", target], rootPath);
161
+
162
+ if (target.branch) {
163
+ const checked = await checkoutBranchForSync(rootPath, target.branch, target.target);
164
+ if (!checked.ok) throw new Error(`git checkout ${target.branch} failed for ${rootPath}: ${checked.stderr || checked.stdout}`);
165
+ }
166
+ const merged = await git(["merge", "--ff-only", target.target], rootPath);
135
167
  if (!merged.ok) throw new Error(`git ff-only sync failed for ${rootPath}: ${merged.stderr || merged.stdout}`);
136
- return "synced";
168
+ return { ...target, action: "synced", baseSha };
137
169
  }
138
170
 
139
171
  async function assertExistingGitRoot(rootPath: string): Promise<void> {
@@ -156,34 +188,44 @@ async function assertRemote(rootPath: string, remoteUrl: string): Promise<void>
156
188
  }
157
189
  }
158
190
 
159
- async function checkoutSyncTarget(rootPath: string, ref: string | undefined): Promise<string | undefined> {
191
+ async function resolveSyncTarget(rootPath: string, ref: string | undefined): Promise<SyncTarget> {
160
192
  if (ref?.trim()) {
161
193
  const name = ref.trim();
162
194
  const remoteBranch = `refs/remotes/origin/${name}`;
163
195
  if ((await git(["show-ref", "--verify", "--quiet", remoteBranch], rootPath)).ok) {
164
- if ((await git(["show-ref", "--verify", "--quiet", `refs/heads/${name}`], rootPath)).ok) {
165
- const checked = await git(["checkout", name], rootPath);
166
- if (!checked.ok) throw new Error(`git checkout ${name} failed for ${rootPath}: ${checked.stderr || checked.stdout}`);
167
- } else {
168
- const checked = await git(["checkout", "-B", name, `origin/${name}`], rootPath);
169
- if (!checked.ok) throw new Error(`git checkout ${name} failed for ${rootPath}: ${checked.stderr || checked.stdout}`);
170
- }
171
- return `origin/${name}`;
196
+ return { target: `origin/${name}`, branch: name, baseRef: name };
172
197
  }
173
198
  if ((await git(["rev-parse", "--verify", name], rootPath)).ok) {
174
- const checked = await git(["checkout", name], rootPath);
175
- if (!checked.ok) throw new Error(`git checkout ${name} failed for ${rootPath}: ${checked.stderr || checked.stdout}`);
176
- return undefined;
199
+ return {};
177
200
  }
178
201
  throw new Error(`project acquisition ref "${name}" not found on origin for ${rootPath}`);
179
202
  }
180
203
  const upstream = await git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], rootPath);
181
- if (upstream.ok && upstream.stdout) return upstream.stdout;
204
+ if (upstream.ok && upstream.stdout) return { target: upstream.stdout, baseRef: remoteBranchName(upstream.stdout) };
182
205
  const originHead = await git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], rootPath);
183
- if (originHead.ok && originHead.stdout) return originHead.stdout;
206
+ if (originHead.ok && originHead.stdout) return { target: originHead.stdout, baseRef: remoteBranchName(originHead.stdout) };
184
207
  throw new Error(`project root ${rootPath} has no upstream or origin/HEAD for ff-only sync`);
185
208
  }
186
209
 
210
+ async function localBranchExists(rootPath: string, branch: string): Promise<boolean> {
211
+ return (await git(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], rootPath)).ok;
212
+ }
213
+
214
+ async function checkoutBranchForSync(rootPath: string, branch: string, target: string): Promise<{ ok: boolean; stdout: string; stderr: string }> {
215
+ if (await localBranchExists(rootPath, branch)) return await git(["checkout", branch], rootPath);
216
+ return await git(["checkout", "-B", branch, target], rootPath);
217
+ }
218
+
219
+ function remoteBranchName(ref: string | undefined): string | undefined {
220
+ const trimmed = ref?.trim();
221
+ if (!trimmed) return undefined;
222
+ return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed;
223
+ }
224
+
225
+ function warnSyncSkipped(rootPath: string, target: string | undefined, action: ProjectRootSyncAction, reason: string): void {
226
+ console.warn(`[orchestrator] project acquisition root sync ${action} for ${rootPath}${target ? ` -> ${target}` : ""}: ${reason}`);
227
+ }
228
+
187
229
  async function runGit(args: string[], cwd: string): Promise<{ ok: boolean; stdout: string; stderr: string }> {
188
230
  return await execProcess(["git", ...args], { cwd });
189
231
  }
@@ -52,7 +52,7 @@ export async function spawnAgent(
52
52
  if (!isWithinBaseDir(opts.cwd, config.baseDir)) {
53
53
  throw new Error(`cwd must be within base directory: ${config.baseDir}`);
54
54
  }
55
- await d.applyProjectAcquisitionManifest(opts.acquisition, config.baseDir);
55
+ const acquisition = await d.applyProjectAcquisitionManifest(opts.acquisition, config.baseDir);
56
56
  if (!existsSync(opts.cwd)) {
57
57
  throw new Error(`cwd does not exist: ${opts.cwd}`);
58
58
  }
@@ -63,6 +63,7 @@ export async function spawnAgent(
63
63
  ...opts,
64
64
  label,
65
65
  workspaceSymlinks: opts.workspaceSymlinks,
66
+ ...(acquisition?.baseRef && acquisition.baseSha ? { baseRef: acquisition.baseRef, baseSha: acquisition.baseSha } : {}),
66
67
  workspaceRoot: workspacesRoot(config.baseDir),
67
68
  });
68
69
  if (resolvedWorkspace.reusedExisting) {
@@ -139,8 +139,8 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
139
139
 
140
140
  const id = workspaceId(input);
141
141
  const repoRoot = probe.repoRoot;
142
- const baseRef = await terminalBaseRef(repoRoot, probe.branch);
143
- const baseSha = probe.headSha ?? await requireGit(["rev-parse", "HEAD"], repoRoot);
142
+ const baseRef = input.baseRef ?? await terminalBaseRef(repoRoot, probe.branch);
143
+ const baseSha = input.baseSha ?? probe.headSha ?? await requireGit(["rev-parse", "HEAD"], repoRoot);
144
144
  const workspaceRoot = input.workspaceRoot ? resolve(input.workspaceRoot) : workspacesRoot(homedir());
145
145
  const worktreePath = join(workspaceRoot, repoSlug(repoRoot), id);
146
146
  const existing = await existingRegisteredWorktree(repoRoot, worktreePath);
@@ -24,6 +24,9 @@ export interface WorkspaceResolutionInput {
24
24
  workspaceMode?: WorkspaceMode;
25
25
  workspaceRoot?: string;
26
26
  workspaceSymlinks?: string[];
27
+ /** Preferred base for a fresh isolated worktree, e.g. fetched origin/main after project acquisition. */
28
+ baseRef?: string;
29
+ baseSha?: string;
27
30
  automationId?: string;
28
31
  automationRunId?: string;
29
32
  /** #635 — attach to or branch off an existing worktree instead of creating a fresh one. */