agent-relay-orchestrator 0.91.1 → 0.91.3

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.1",
3
+ "version": "0.91.3",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
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);
@@ -63,6 +63,7 @@ export function sharedCallmuxOptionsFromEnv(env: Record<string, string | undefin
63
63
  const port = numberEnv(env[SHARED_CALLMUX_PORT_ENV]) ?? parsed?.port ?? DEFAULT_SHARED_CALLMUX_PORT;
64
64
  const url = explicitUrl ?? `http://${host}:${port}/mcp`;
65
65
  return {
66
+ // PATH/env-resolved callmux must be >=0.21.0; generated config uses exposeMetaTools.
66
67
  command: env[SHARED_CALLMUX_COMMAND_ENV] || "callmux",
67
68
  host,
68
69
  port,
@@ -91,6 +92,8 @@ export function writeSharedCallmuxConfig(opts: Pick<SharedCallmuxOptions, "confi
91
92
  maxConcurrency: numberFromRecord(source, "maxConcurrency") ?? 20,
92
93
  callTimeoutMs: numberFromRecord(source, "callTimeoutMs") ?? 180_000,
93
94
  outputFormat: stringFromRecord(source, "outputFormat") ?? "auto",
95
+ // Relay workers consume only proxied tokenlean+github tools; suppress callmux meta-tools.
96
+ exposeMetaTools: false,
94
97
  };
95
98
  mkdirSync(dirname(opts.configPath), { recursive: true });
96
99
  writeFileSync(opts.configPath, JSON.stringify(generated, null, 2) + "\n", { mode: 0o600 });
@@ -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