agent-relay-orchestrator 0.106.0 → 0.107.0

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.106.0",
3
+ "version": "0.107.0",
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.0",
20
- "agent-relay-sdk": "0.2.92",
20
+ "agent-relay-sdk": "0.2.93",
21
21
  "callmux": "0.23.0"
22
22
  },
23
23
  "devDependencies": {
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
- import type { WorkspaceMergePreview, WorkspaceMergeResult } from "agent-relay-sdk";
3
+ import type { BaseWorktreeSyncResult, WorkspaceMergePreview, WorkspaceMergeResult } from "agent-relay-sdk";
4
4
  import { git, gitRaw } from "../git";
5
5
  import { prMergedState } from "../workspace-pr";
6
6
  import { refreshWorkspaceDeps } from "./deps";
@@ -100,16 +100,45 @@ function resetIndexPathsToHead(worktreePath: string, paths: string[]): boolean {
100
100
  return git(["reset", "-q", "HEAD", "--", ...paths], worktreePath).ok;
101
101
  }
102
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}`);
103
+ /** Landed paths whose working copy still differs from the advanced HEAD (ground truth, not
104
+ * exit codes): the checkout is mixed for these reads/builds/publishes see STALE files. */
105
+ function pathsDifferingFromHead(worktreePath: string, paths: string[]): string[] {
106
+ if (paths.length === 0) return [];
107
+ const diff = gitRaw(["diff", "--name-only", "-z", "HEAD", "--", ...paths], worktreePath);
108
+ // A failed diff can't prove the checkout is clean — treat every landed path as suspect.
109
+ return diff.ok ? splitNul(diff.stdout) : [...paths];
110
+ }
111
+
112
+ const RECONCILED: BaseWorktreeSyncResult = { reconciled: true };
113
+
114
+ /** Combine two heal outcomes from a single land (an upstream sync + the final land can each
115
+ * touch the dirty checkout). A mixed state from either wins; paths union. */
116
+ function mergeBaseSyncResults(a: BaseWorktreeSyncResult | undefined, b: BaseWorktreeSyncResult | undefined): BaseWorktreeSyncResult | undefined {
117
+ if (!a || a.reconciled) return b && !b.reconciled ? b : a ?? b;
118
+ if (!b || b.reconciled) return a;
119
+ const unsyncedPaths = [...new Set([...(a.unsyncedPaths ?? []), ...(b.unsyncedPaths ?? [])])];
120
+ const preservedWipPaths = [...new Set([...(a.preservedWipPaths ?? []), ...(b.preservedWipPaths ?? [])])];
121
+ return {
122
+ reconciled: false,
123
+ unsyncedPaths,
124
+ ...(preservedWipPaths.length ? { preservedWipPaths } : {}),
125
+ message: [a.message, b.message].filter(Boolean).join("; "),
126
+ };
105
127
  }
106
128
 
107
129
  /**
108
130
  * After a dirty checked-out base is advanced with ref plumbing, bring the checkout forward
109
131
  * 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.
132
+ * updates clean paths in the index and worktree and refuses (atomically) before overwriting
133
+ * local edits. If it refuses, preserve the land by leaving the ref advanced, force only the
134
+ * paths that were clean before the ref move to HEAD, and normalize conflicting landed paths
135
+ * (genuine human WIP) to unstaged edits so they surface rather than stage a reverse diff.
136
+ *
137
+ * Crucially (#824): the heal is best-effort, so it VERIFIES against ground truth afterwards.
138
+ * Any landed path whose working copy still differs from the advanced HEAD leaves the checkout
139
+ * in a MIXED state (HEAD moved, working file didn't). That is no longer swallowed as a log
140
+ * warning — it is returned so the relay surfaces a loud operator alert. We never blind-clobber
141
+ * a path with real human WIP to "fix" it; such paths are reported as `preservedWipPaths`.
113
142
  */
114
143
  function syncDirtyBaseWorktreeAfterRefAdvance(
115
144
  base: string,
@@ -117,24 +146,42 @@ function syncDirtyBaseWorktreeAfterRefAdvance(
117
146
  oldBaseTip: string,
118
147
  newBaseTip: string,
119
148
  dirtyBefore: Set<string> | undefined,
120
- ): void {
121
- if (!baseWorktree?.dirty || !dirtyBefore) return;
149
+ ): BaseWorktreeSyncResult {
150
+ if (!baseWorktree?.dirty || !dirtyBefore) return RECONCILED;
122
151
  const readTree = git(["read-tree", "-m", "-u", oldBaseTip, newBaseTip], baseWorktree.path);
123
- if (readTree.ok) return;
152
+ if (readTree.ok) return RECONCILED;
124
153
 
125
154
  const changedPaths = changedPathList(baseWorktree.path, oldBaseTip, newBaseTip);
126
155
  const cleanChangedPaths = changedPaths.filter((path) => !dirtyBefore.has(path));
127
156
  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("; "),
157
+ // Clean landed paths carry no human WIP — force them to HEAD (restores content AND mode).
158
+ restorePathsFromHead(baseWorktree.path, cleanChangedPaths);
159
+ // Landed paths the human also edited — preserve the WIP, normalize the index to HEAD so the
160
+ // difference surfaces as an unstaged modification (not a staged reverse diff). #681.
161
+ resetIndexPathsToHead(baseWorktree.path, dirtyChangedPaths);
162
+
163
+ // Ground-truth check (#824): re-derive which landed paths are STILL out of sync with the
164
+ // advanced HEAD rather than trusting the restore/reset exit codes. A clean landed path left
165
+ // stale here is the silent strand that shipped a stale file into the #823 bootstrap publish.
166
+ const unsyncedPaths = pathsDifferingFromHead(baseWorktree.path, changedPaths);
167
+ if (unsyncedPaths.length === 0) return RECONCILED;
168
+ const preservedWipPaths = unsyncedPaths.filter((path) => dirtyBefore.has(path));
169
+ return {
170
+ reconciled: false,
171
+ unsyncedPaths,
172
+ ...(preservedWipPaths.length ? { preservedWipPaths } : {}),
173
+ message: `advanced ${base} but ${unsyncedPaths.length} landed path(s) remain out of sync with HEAD in ${baseWorktree.path}: ${unsyncedPaths.join(", ")}`
174
+ + (preservedWipPaths.length ? ` (preserved human WIP — reconcile by hand: ${preservedWipPaths.join(", ")})` : ""),
175
+ };
176
+ }
177
+
178
+ /** Loud, operator-visible signal that a dirty base checkout was left in a mixed state. Replaces
179
+ * the old swallow-as-warning behavior (#824): a stale primary checkout must never be silent. */
180
+ function logBaseWorktreeStrand(base: string, worktreePath: string, sync: BaseWorktreeSyncResult): void {
181
+ console.error(
182
+ `[orchestrator] ALERT (#824): dirty base ${base} checkout left in a MIXED state after land — `
183
+ + `${worktreePath} working files are out of sync with the advanced HEAD. ${sync.message ?? ""} `
184
+ + "Reads/builds/publishes from this checkout will see STALE content until reconciled.",
138
185
  );
139
186
  }
140
187
 
@@ -511,7 +558,7 @@ function syncLocalBaseToUpstream(
511
558
  worktreePath: string,
512
559
  base: string,
513
560
  upstream: string,
514
- ): { ok: true } | { ok: false; error: string } {
561
+ ): { ok: true; baseSync?: BaseWorktreeSyncResult } | { ok: false; error: string } {
515
562
  const upstreamSha = git(["rev-parse", "--verify", upstream], worktreePath).stdout;
516
563
  if (!upstreamSha) return { ok: false, error: `cannot resolve ${upstream} to sync ${base}` };
517
564
  const baseWorktree = worktreeForBranch(repoRoot, base);
@@ -530,8 +577,8 @@ function syncLocalBaseToUpstream(
530
577
  const updateArgs = oldBaseTip ? ["update-ref", `refs/heads/${base}`, upstreamSha, oldBaseTip] : ["update-ref", `refs/heads/${base}`, upstreamSha];
531
578
  const update = git(updateArgs, repoRoot);
532
579
  if (!update.ok) return { ok: false, error: update.stderr || `failed to advance ${base} to ${upstream}` };
533
- if (oldBaseTip) syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, upstreamSha, dirtyBefore);
534
- return { ok: true };
580
+ const baseSync = oldBaseTip ? syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, upstreamSha, dirtyBefore) : undefined;
581
+ return { ok: true, baseSync };
535
582
  }
536
583
 
537
584
  function mergeRebaseFf(
@@ -560,6 +607,10 @@ function mergeRebaseFf(
560
607
  // - Refuse ONLY on genuine divergence: local base has commits not on origin, so
561
608
  // a sync would rewrite/discard published-or-local history. A real failure must
562
609
  // still set `error` so the relay's no-progress backoff (#638) can engage.
610
+ // Tracks whether the DIRTY base checkout caught up to the advanced HEAD across BOTH heal
611
+ // points (the upstream sync below and the final land). A mixed state from either is surfaced
612
+ // loudly rather than swallowed as a log warning (#824).
613
+ let baseSync: BaseWorktreeSyncResult | undefined;
563
614
  const upstream = upstreamRef(worktreePath, base);
564
615
  const slash = upstream ? upstream.indexOf("/") : -1;
565
616
  const remote = slash > 0 ? upstream!.slice(0, slash) : undefined; // remote of a `remote/branch` upstream
@@ -574,6 +625,7 @@ function mergeRebaseFf(
574
625
  }
575
626
  const synced = syncLocalBaseToUpstream(repoRoot, worktreePath, base, upstream);
576
627
  if (!synced.ok) return head({ status: "review_requested", error: synced.error });
628
+ baseSync = mergeBaseSyncResults(baseSync, synced.baseSync);
577
629
  }
578
630
  }
579
631
 
@@ -618,15 +670,23 @@ function mergeRebaseFf(
618
670
  ? git(["update-ref", `refs/heads/${base}`, headSha, oldBaseTip], repoRoot)
619
671
  : git(["update-ref", `refs/heads/${base}`, headSha], repoRoot);
620
672
  if (!update.ok) return head({ status: "review_requested", error: update.stderr || "failed to advance base ref" });
621
- if (oldBaseTip) syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, headSha, dirtyBasePathsBefore);
673
+ if (oldBaseTip) baseSync = mergeBaseSyncResults(baseSync, syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, headSha, dirtyBasePathsBefore));
622
674
  } else {
623
675
  const baseSha = git(["rev-parse", base], repoRoot).stdout;
624
676
  const merged = recordNoFfMerge(repoRoot, base, baseSha, headSha, landMergeMessage(branch, landedSubject));
625
677
  if (!merged.ok) return head(merged.conflict ? { conflict: true, status: "conflict", error: merged.error } : { status: "review_requested", error: merged.error });
626
678
  baseTip = merged.mergeSha;
627
- syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, baseSha, baseTip, dirtyBasePathsBefore);
679
+ baseSync = mergeBaseSyncResults(baseSync, syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, baseSha, baseTip, dirtyBasePathsBefore));
628
680
  }
629
681
 
682
+ // #824 — a dirty base checkout left out of sync with the advanced HEAD is a MIXED state:
683
+ // the land succeeded but the shared/primary checkout now serves STALE files for the landed
684
+ // paths. Surface it loudly (host log + a `baseWorktreeSync` field the relay raises as an
685
+ // operator alert) instead of swallowing it as a warning. We never blind-clobber human WIP to
686
+ // hide it — the report names which paths still need a manual reconcile.
687
+ const baseWorktreeSyncField = baseSync && !baseSync.reconciled ? { baseWorktreeSync: baseSync } : {};
688
+ if (baseSync && !baseSync.reconciled && baseWorktree) logBaseWorktreeStrand(base, baseWorktree.path, baseSync);
689
+
630
690
  // Publish the advanced base so local and origin converge (#190). We verified
631
691
  // origin was an ancestor of base above, so this is a fast-forward; a failure
632
692
  // means origin raced us — surface it instead of claiming an unpublished land.
@@ -656,13 +716,13 @@ function mergeRebaseFf(
656
716
  // continues from a buildable state (issue #51). No-op when nothing is stale.
657
717
  const depsRefresh = refreshWorkspaceDeps(repoRoot, worktreePath);
658
718
  const reportDeps = depsRefresh.refreshed || depsRefresh.stale || depsRefresh.error;
659
- return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branch: fresh, newBranch: fresh, branchDeleted: oldDeleted, pushed, ...(reportDeps ? { depsRefresh } : {}), error: undefined });
719
+ return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branch: fresh, newBranch: fresh, branchDeleted: oldDeleted, pushed, ...(reportDeps ? { depsRefresh } : {}), ...baseWorktreeSyncField, error: undefined });
660
720
  }
661
721
  // Recycle failed — keep the existing branch. Still landed, still active.
662
- return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branchDeleted: false, pushed, error: undefined });
722
+ return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branchDeleted: false, pushed, ...baseWorktreeSyncField, error: undefined });
663
723
  }
664
724
  const removed = git(["worktree", "remove", "--force", worktreePath], repoRoot);
665
725
  const worktreeRemoved = removed.ok;
666
726
  const branchDeleted = worktreeRemoved ? git(["branch", "-D", branch], repoRoot).ok : false;
667
- return head({ merged: true, status: "merged", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved, branchDeleted, pushed, error: undefined });
727
+ return head({ merged: true, status: "merged", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved, branchDeleted, pushed, ...baseWorktreeSyncField, error: undefined });
668
728
  }