agent-relay-orchestrator 0.91.2 → 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 +1 -1
- package/src/git.ts +14 -0
- package/src/shared-callmux.ts +3 -0
- package/src/terminal-stream.ts +6 -0
- package/src/workspace-probe/merge.ts +90 -9
package/package.json
CHANGED
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);
|
package/src/shared-callmux.ts
CHANGED
|
@@ -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 });
|
package/src/terminal-stream.ts
CHANGED
|
@@ -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
|
|
445
|
-
//
|
|
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
|
|
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,
|
|
516
|
-
//
|
|
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
|
|
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
|