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 +2 -2
- package/src/workspace-probe/merge.ts +87 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "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.
|
|
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
|
-
|
|
104
|
-
|
|
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
|
|
111
|
-
* If it refuses, preserve the land by leaving the ref advanced,
|
|
112
|
-
* clean before the ref move, and normalize conflicting landed paths
|
|
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
|
-
):
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
}
|