agent-relay-orchestrator 0.91.2 → 0.91.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.91.2",
3
+ "version": "0.91.4",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "src/**/*.ts",
11
11
  "!src/**/*.test.ts",
12
+ "vendor/**",
12
13
  "README.md"
13
14
  ],
14
15
  "scripts": {
@@ -16,7 +17,7 @@
16
17
  "test": "bun test"
17
18
  },
18
19
  "dependencies": {
19
- "agent-relay-sdk": "0.2.70"
20
+ "agent-relay-sdk": "0.2.71"
20
21
  },
21
22
  "devDependencies": {
22
23
  "@types/bun": "latest",
package/src/git.ts CHANGED
@@ -22,6 +22,20 @@ export function git(args: string[], cwd: string): GitResult {
22
22
  };
23
23
  }
24
24
 
25
+ /** Run `git -C cwd <args>` and preserve stdout exactly for path-safe parsers. */
26
+ export function gitRaw(args: string[], cwd: string): GitResult {
27
+ const proc = Bun.spawnSync(["git", "-C", cwd, ...args], {
28
+ stdin: "ignore",
29
+ stdout: "pipe",
30
+ stderr: "pipe",
31
+ });
32
+ return {
33
+ ok: proc.exitCode === 0,
34
+ stdout: proc.stdout.toString(),
35
+ stderr: proc.stderr.toString().trim(),
36
+ };
37
+ }
38
+
25
39
  /** Like {@link git} but throws on a non-zero exit, returning stdout on success. */
26
40
  export function requireGit(args: string[], cwd: string): string {
27
41
  const result = git(args, cwd);
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { dirname, isAbsolute, join } from "node:path";
3
+ import { dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { errMessage, isRecord } from "agent-relay-sdk";
5
5
  import type { OrchestratorConfig } from "./config";
6
6
  import { agentRelayHome } from "./config";
@@ -54,6 +54,15 @@ export interface SharedCallmuxSupervisorDeps {
54
54
  setTimeout(fn: () => void, ms: number): Timer;
55
55
  clearTimeout(timer: Timer): void;
56
56
  log(message: string): void;
57
+ report(snapshot: SharedCallmuxHealthSnapshot): void;
58
+ }
59
+
60
+ export interface SharedCallmuxHealthSnapshot {
61
+ state: "disabled" | "missing" | "starting" | "running" | "unhealthy" | "restarting" | "stopped";
62
+ url: string;
63
+ command?: string;
64
+ reason?: string;
65
+ pid?: number;
57
66
  }
58
67
 
59
68
  export function sharedCallmuxOptionsFromEnv(env: Record<string, string | undefined> = process.env): SharedCallmuxOptions {
@@ -63,13 +72,13 @@ export function sharedCallmuxOptionsFromEnv(env: Record<string, string | undefin
63
72
  const port = numberEnv(env[SHARED_CALLMUX_PORT_ENV]) ?? parsed?.port ?? DEFAULT_SHARED_CALLMUX_PORT;
64
73
  const url = explicitUrl ?? `http://${host}:${port}/mcp`;
65
74
  return {
66
- command: env[SHARED_CALLMUX_COMMAND_ENV] || "callmux",
75
+ command: env[SHARED_CALLMUX_COMMAND_ENV] || bundledCallmuxCommand() || "callmux",
67
76
  host,
68
77
  port,
69
78
  url,
70
79
  configPath: env[SHARED_CALLMUX_CONFIG_ENV] || join(agentRelayHome(), "callmux", "shared-listener.json"),
71
80
  sourceConfigPath: env[SHARED_CALLMUX_SOURCE_CONFIG_ENV] || env.CALLMUX_CONFIG || join(homedir(), ".config", "callmux", "config.json"),
72
- enabled: env[SHARED_CALLMUX_ENABLE_ENV] === "1",
81
+ enabled: !envOff(env[SHARED_CALLMUX_ENABLE_ENV]),
73
82
  };
74
83
  }
75
84
 
@@ -91,6 +100,8 @@ export function writeSharedCallmuxConfig(opts: Pick<SharedCallmuxOptions, "confi
91
100
  maxConcurrency: numberFromRecord(source, "maxConcurrency") ?? 20,
92
101
  callTimeoutMs: numberFromRecord(source, "callTimeoutMs") ?? 180_000,
93
102
  outputFormat: stringFromRecord(source, "outputFormat") ?? "auto",
103
+ // Relay workers consume only proxied tokenlean+github tools; suppress callmux meta-tools.
104
+ exposeMetaTools: false,
94
105
  };
95
106
  mkdirSync(dirname(opts.configPath), { recursive: true });
96
107
  writeFileSync(opts.configPath, JSON.stringify(generated, null, 2) + "\n", { mode: 0o600 });
@@ -115,14 +126,17 @@ export class SharedCallmuxSupervisor {
115
126
 
116
127
  start(): void {
117
128
  if (!this.opts.enabled) {
118
- this.deps.log("[orchestrator] shared callmux listener disabled (set AGENT_RELAY_SHARED_CALLMUX_ENABLE=1 to enable)");
129
+ this.deps.log("[orchestrator] shared callmux listener disabled by AGENT_RELAY_SHARED_CALLMUX_ENABLE=0");
130
+ this.report("disabled", "global kill-switch");
119
131
  return;
120
132
  }
121
133
  this.deps.log(`[orchestrator] Shared callmux listener: ${this.opts.url}`);
122
134
  if (!this.deps.which(this.opts.command)) {
123
135
  this.deps.log("[orchestrator] shared callmux not found — shared listener dormant");
136
+ this.report("missing", "command not found");
124
137
  return;
125
138
  }
139
+ this.report("starting");
126
140
  this.spawn();
127
141
  this.healthTimer = this.deps.setInterval(() => {
128
142
  void this.checkHealth();
@@ -153,9 +167,11 @@ export class SharedCallmuxSupervisor {
153
167
  }
154
168
  if (ok) {
155
169
  this.backoffMs = this.timing.restartBaseMs ?? 1_000;
170
+ this.report("running", undefined, this.proc?.pid);
156
171
  return true;
157
172
  }
158
173
  this.deps.log(`[orchestrator] Shared callmux readiness failed at ${readyUrl}; restarting`);
174
+ this.report("unhealthy", "readiness failed", this.proc?.pid);
159
175
  this.restart("readiness failed");
160
176
  return false;
161
177
  }
@@ -174,20 +190,24 @@ export class SharedCallmuxSupervisor {
174
190
  proc = this.deps.spawn(this.opts.command, args, { env, cwd: homedir() });
175
191
  } catch (err) {
176
192
  this.deps.log(`[orchestrator] Shared callmux listener failed to start: ${errMessage(err)}; scheduling restart`);
193
+ this.report("restarting", errMessage(err));
177
194
  this.scheduleRestart();
178
195
  return;
179
196
  }
180
197
  this.proc = proc;
181
198
  this.deps.log(`[orchestrator] Started shared callmux listener pid=${proc.pid ?? "unknown"}`);
199
+ this.report("running", undefined, proc.pid);
182
200
  proc.exited.then((code) => {
183
201
  if (this.proc !== proc || this.stopping) return;
184
202
  this.proc = null;
185
203
  this.deps.log(`[orchestrator] Shared callmux listener exited (${code ?? "signal"}); scheduling restart`);
204
+ this.report("restarting", `exited ${code ?? "signal"}`);
186
205
  this.scheduleRestart();
187
206
  }).catch((err) => {
188
207
  if (this.proc !== proc || this.stopping) return;
189
208
  this.proc = null;
190
209
  this.deps.log(`[orchestrator] Shared callmux listener exit watcher failed: ${err}`);
210
+ this.report("restarting", errMessage(err));
191
211
  this.scheduleRestart();
192
212
  });
193
213
  }
@@ -198,9 +218,20 @@ export class SharedCallmuxSupervisor {
198
218
  this.proc.kill("SIGTERM");
199
219
  this.proc = null;
200
220
  }
221
+ this.report("restarting", reason);
201
222
  this.scheduleRestart();
202
223
  }
203
224
 
225
+ private report(state: SharedCallmuxHealthSnapshot["state"], reason?: string, pid?: number): void {
226
+ this.deps.report({
227
+ state,
228
+ url: this.opts.url,
229
+ command: this.opts.command,
230
+ ...(reason ? { reason } : {}),
231
+ ...(pid ? { pid } : {}),
232
+ });
233
+ }
234
+
204
235
  private scheduleRestart(): void {
205
236
  if (this.stopping || this.restartTimer) return;
206
237
  const delay = this.backoffMs;
@@ -235,15 +266,26 @@ function defaultDeps(): SharedCallmuxSupervisorDeps {
235
266
  setTimeout: (fn, ms) => setTimeout(fn, ms),
236
267
  clearTimeout: (timer) => clearTimeout(timer),
237
268
  log: (message) => console.error(message),
269
+ report: (snapshot) => console.error(`[orchestrator] shared callmux status ${snapshot.state}${snapshot.reason ? `: ${snapshot.reason}` : ""}`),
238
270
  };
239
271
  }
240
272
 
273
+ export function bundledCallmuxCommand(): string | null {
274
+ const candidate = resolve(import.meta.dir, "../vendor/callmux/bin/callmux.js");
275
+ return existsSync(candidate) ? candidate : null;
276
+ }
277
+
241
278
  function resolveCommand(command: string): string | null {
242
279
  if (isAbsolute(command)) return existsSync(command) ? command : null;
243
280
  if (command.includes("/")) return existsSync(command) ? command : null;
244
281
  return Bun.which(command);
245
282
  }
246
283
 
284
+ function envOff(value: string | undefined): boolean {
285
+ if (value === undefined || value === null || value === "") return false;
286
+ return ["0", "false", "off", "no"].includes(value.trim().toLowerCase());
287
+ }
288
+
247
289
  function readJsonObject(path: string): Record<string, unknown> {
248
290
  if (!existsSync(path)) return {};
249
291
  const parsed = JSON.parse(readFileSync(path, "utf8"));
@@ -603,6 +603,12 @@ class SessionStream {
603
603
  // can't re-apply on top of it (the duplicate-text race). %output that arrived AFTER the
604
604
  // capture stayed in `pending` and applies on top of the repaint via the trailing flush.
605
605
  this.drainPreCaptureDeltas();
606
+ if (!forceAbort && this.broadcastState !== "ground") {
607
+ this.resyncDirty = true;
608
+ this.flush(); // let the sequence tail complete normally; retry with a fresh capture
609
+ if (!this.resyncTimer) this.resyncTimer = setTimeout(() => void this.doResync(), RESYNC_GROUND_RETRY_MS);
610
+ return;
611
+ }
606
612
  if (!repaint || this.closed || this.subscribers.size === 0) {
607
613
  this.flush(); // release any post-capture deltas normally (no repaint to inject)
608
614
  return;
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import type { WorkspaceMergePreview, WorkspaceMergeResult } from "agent-relay-sdk";
4
- import { git } from "../git";
4
+ import { git, gitRaw } from "../git";
5
5
  import { prMergedState } from "../workspace-pr";
6
6
  import { refreshWorkspaceDeps } from "./deps";
7
7
  import { populateMergeState, resolveBranchRef, syncBaseFromOrigin, upstreamRef, workspaceGitState } from "./git-state";
@@ -65,6 +65,79 @@ function worktreeForBranch(repoRoot: string, branch: string): { path: string; di
65
65
  return { path: match.path, dirty: status.ok ? status.stdout.length > 0 : true };
66
66
  }
67
67
 
68
+ function splitNul(stdout: string): string[] {
69
+ return stdout.split("\0").filter(Boolean);
70
+ }
71
+
72
+ function dirtyPathSet(worktreePath: string): Set<string> {
73
+ const status = gitRaw(["status", "--porcelain=v1", "-z", "--untracked-files=all"], worktreePath);
74
+ if (!status.ok) return new Set<string>();
75
+ const paths = new Set<string>();
76
+ const entries = splitNul(status.stdout);
77
+ for (let i = 0; i < entries.length; i += 1) {
78
+ const entry = entries[i] ?? "";
79
+ if (entry.length < 4) continue;
80
+ const code = entry.slice(0, 2);
81
+ const path = entry.slice(3);
82
+ paths.add(path);
83
+ if (code[0] === "R" || code[0] === "C") i += 1;
84
+ }
85
+ return paths;
86
+ }
87
+
88
+ function changedPathList(worktreePath: string, oldBaseTip: string, newBaseTip: string): string[] {
89
+ const diff = gitRaw(["diff", "--name-only", "-z", oldBaseTip, newBaseTip], worktreePath);
90
+ return diff.ok ? splitNul(diff.stdout) : [];
91
+ }
92
+
93
+ function restorePathsFromHead(worktreePath: string, paths: string[]): boolean {
94
+ if (paths.length === 0) return true;
95
+ return git(["restore", "--staged", "--worktree", "--", ...paths], worktreePath).ok;
96
+ }
97
+
98
+ function resetIndexPathsToHead(worktreePath: string, paths: string[]): boolean {
99
+ if (paths.length === 0) return true;
100
+ return git(["reset", "-q", "HEAD", "--", ...paths], worktreePath).ok;
101
+ }
102
+
103
+ function logBaseWorktreeSyncWarning(base: string, worktreePath: string, message: string): void {
104
+ console.error(`[orchestrator] Warning: advanced ${base} but could not fully sync dirty base worktree ${worktreePath}: ${message}`);
105
+ }
106
+
107
+ /**
108
+ * After a dirty checked-out base is advanced with ref plumbing, bring the checkout forward
109
+ * without clobbering human WIP. `read-tree -m -u old new` is the primary mechanism: it
110
+ * updates clean paths in the index and worktree and refuses before overwriting local edits.
111
+ * If it refuses, preserve the land by leaving the ref advanced, restore only paths that were
112
+ * clean before the ref move, and normalize conflicting landed paths to unstaged edits.
113
+ */
114
+ function syncDirtyBaseWorktreeAfterRefAdvance(
115
+ base: string,
116
+ baseWorktree: { path: string; dirty: boolean } | undefined,
117
+ oldBaseTip: string,
118
+ newBaseTip: string,
119
+ dirtyBefore: Set<string> | undefined,
120
+ ): void {
121
+ if (!baseWorktree?.dirty || !dirtyBefore) return;
122
+ const readTree = git(["read-tree", "-m", "-u", oldBaseTip, newBaseTip], baseWorktree.path);
123
+ if (readTree.ok) return;
124
+
125
+ const changedPaths = changedPathList(baseWorktree.path, oldBaseTip, newBaseTip);
126
+ const cleanChangedPaths = changedPaths.filter((path) => !dirtyBefore.has(path));
127
+ const dirtyChangedPaths = changedPaths.filter((path) => dirtyBefore.has(path));
128
+ const restoredClean = restorePathsFromHead(baseWorktree.path, cleanChangedPaths);
129
+ const resetDirtyIndex = resetIndexPathsToHead(baseWorktree.path, dirtyChangedPaths);
130
+ logBaseWorktreeSyncWarning(
131
+ base,
132
+ baseWorktree.path,
133
+ [
134
+ readTree.stderr || "read-tree refused local changes",
135
+ restoredClean ? undefined : "failed to restore clean landed paths",
136
+ resetDirtyIndex ? undefined : "failed to reset conflicting path index",
137
+ ].filter(Boolean).join("; "),
138
+ );
139
+ }
140
+
68
141
  // Populate a preview's PR fields from `gh` ground truth; returns whether the PR is merged
69
142
  // (#168/#304). One home for the PR-landing rule so the worktree- and repoRoot-based previews
70
143
  // can't drift. On a merge it stamps the merge SHA so the relay can finalize a parked pr land.
@@ -441,16 +514,19 @@ function syncLocalBaseToUpstream(
441
514
  // Sync IN the base worktree only when it's clean — that keeps its working tree consistent
442
515
  // with the advanced ref (the pristine home-repo checkout). When the base worktree is dirty
443
516
  // (#644: a human's WIP in the shared checkout) we must NOT refuse and stall the whole repo's
444
- // lands: advance the ref directly with update-ref instead. This is immune to the checkout's
445
- // state and never reads or writes the human's working tree — their uncommitted work is left
446
- // exactly as-is (the ref moves underneath; their files on disk are untouched).
517
+ // lands: advance the ref directly with update-ref, then best-effort sync the checked-out
518
+ // index/worktree forward for paths that are not human-modified (#681).
447
519
  if (baseWorktree && !baseWorktree.dirty) {
448
520
  const ff = git(["merge", "--ff-only", upstream], baseWorktree.path);
449
521
  if (!ff.ok) return { ok: false, error: ff.stderr || `failed to fast-forward ${base} to ${upstream}` };
450
522
  return { ok: true };
451
523
  }
452
- const update = git(["update-ref", `refs/heads/${base}`, upstreamSha], repoRoot);
524
+ const oldBaseTip = git(["rev-parse", base], repoRoot).stdout;
525
+ const dirtyBefore = baseWorktree?.dirty ? dirtyPathSet(baseWorktree.path) : undefined;
526
+ const updateArgs = oldBaseTip ? ["update-ref", `refs/heads/${base}`, upstreamSha, oldBaseTip] : ["update-ref", `refs/heads/${base}`, upstreamSha];
527
+ const update = git(updateArgs, repoRoot);
453
528
  if (!update.ok) return { ok: false, error: update.stderr || `failed to advance ${base} to ${upstream}` };
529
+ if (oldBaseTip) syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, upstreamSha, dirtyBefore);
454
530
  return { ok: true };
455
531
  }
456
532
 
@@ -512,11 +588,11 @@ function mergeRebaseFf(
512
588
  // only when it exists AND is clean — that keeps its working tree consistent with the
513
589
  // advanced ref (the pristine home-repo checkout). When the base worktree is dirty
514
590
  // (#644: a human's WIP in the shared checkout) we must NOT refuse and stall every lane's
515
- // land: fall through to ref-plumbing (update-ref / synthesized no-ff merge) below, which
516
- // advances refs/heads/<base> without reading or touching that working tree. The human's
517
- // uncommitted work is left exactly as-is — the ref moves underneath, their files untouched.
591
+ // land: fall through to ref-plumbing (update-ref / synthesized no-ff merge) below, then
592
+ // best-effort sync clean landed paths in the dirty checkout while preserving WIP (#681).
518
593
  let baseTip = headSha;
519
594
  const baseWorktree = worktreeForBranch(repoRoot, base);
595
+ const dirtyBasePathsBefore = baseWorktree?.dirty ? dirtyPathSet(baseWorktree.path) : undefined;
520
596
  if (baseWorktree && !baseWorktree.dirty) {
521
597
  if (behind === 0) {
522
598
  const ff = git(["merge", "--ff-only", branch], baseWorktree.path);
@@ -533,13 +609,18 @@ function mergeRebaseFf(
533
609
  baseTip = git(["rev-parse", "HEAD"], baseWorktree.path).stdout;
534
610
  }
535
611
  } else if (behind === 0) {
536
- const update = git(["update-ref", `refs/heads/${base}`, headSha], repoRoot);
612
+ const oldBaseTip = git(["rev-parse", base], repoRoot).stdout;
613
+ const update = oldBaseTip
614
+ ? git(["update-ref", `refs/heads/${base}`, headSha, oldBaseTip], repoRoot)
615
+ : git(["update-ref", `refs/heads/${base}`, headSha], repoRoot);
537
616
  if (!update.ok) return head({ status: "review_requested", error: update.stderr || "failed to advance base ref" });
617
+ if (oldBaseTip) syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, headSha, dirtyBasePathsBefore);
538
618
  } else {
539
619
  const baseSha = git(["rev-parse", base], repoRoot).stdout;
540
620
  const merged = recordNoFfMerge(repoRoot, base, baseSha, headSha, landMergeMessage(branch, landedSubject));
541
621
  if (!merged.ok) return head(merged.conflict ? { conflict: true, status: "conflict", error: merged.error } : { status: "review_requested", error: merged.error });
542
622
  baseTip = merged.mergeSha;
623
+ syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, baseSha, baseTip, dirtyBasePathsBefore);
543
624
  }
544
625
 
545
626
  // Publish the advanced base so local and origin converge (#190). We verified