agent-relay-orchestrator 0.29.0 → 0.30.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/terminal-stream.ts +24 -8
- package/src/workspace-probe.ts +102 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"test": "bun test"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"agent-relay-sdk": "0.2.
|
|
19
|
+
"agent-relay-sdk": "0.2.19"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/bun": "latest",
|
package/src/terminal-stream.ts
CHANGED
|
@@ -144,12 +144,28 @@ export function decodeControlOutput(data: string): Uint8Array {
|
|
|
144
144
|
return Uint8Array.from(out);
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
147
|
+
// Byte-faithful single-byte decode (true ISO-8859-1: byte N → U+00NN). We CANNOT use
|
|
148
|
+
// `new TextDecoder("latin1")` — per the WHATWG encoding standard "latin1" is an alias for
|
|
149
|
+
// windows-1252, which remaps 0x80–0x9F to printable code points (e.g. 0x96 → U+2013). Those
|
|
150
|
+
// bytes are exactly the UTF-8 continuation/lead octets of box-drawing & powerline glyphs
|
|
151
|
+
// (▐ = U+2590 = E2 96 90), so a windows-1252 read is NOT reversible via `charCodeAt & 0xff`
|
|
152
|
+
// and corrupts every multi-byte glyph (#270 regression). fromCharCode maps each byte 1:1, so
|
|
153
|
+
// the round-trip below is exact. Chunked to keep fromCharCode.apply off the arg-count limit.
|
|
154
|
+
export function decodeLatin1(bytes: Uint8Array): string {
|
|
155
|
+
let out = "";
|
|
156
|
+
const CHUNK = 0x8000;
|
|
157
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
158
|
+
out += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK) as unknown as number[]);
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The control stream is read byte-faithfully (see decodeLatin1) so the `%output` octal path
|
|
164
|
+
// above sees raw bytes. But command-reply blocks (capture-pane grid rows) arrive as raw
|
|
165
|
+
// UTF-8, so their lines reach us as the byte-faithful latin1 representation. Re-decode them
|
|
166
|
+
// to real UTF-8 here, or multi-byte glyphs (box-drawing, powerline) double-encode on the
|
|
167
|
+
// next repaint and render as mojibake (#270). Safe to do per line: no UTF-8 continuation
|
|
168
|
+
// byte is 0x0A/0x0D, so the newline split never lands mid-sequence.
|
|
153
169
|
const REPLY_UTF8_DECODER = new TextDecoder("utf-8");
|
|
154
170
|
export function latin1LineToUtf8(line: string): string {
|
|
155
171
|
const bytes = new Uint8Array(line.length);
|
|
@@ -383,12 +399,12 @@ class SessionStream {
|
|
|
383
399
|
const proc = this.proc;
|
|
384
400
|
if (!proc?.stdout || typeof proc.stdout === "number") return;
|
|
385
401
|
const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
|
|
386
|
-
const decoder = new TextDecoder("latin1");
|
|
387
402
|
try {
|
|
388
403
|
for (;;) {
|
|
389
404
|
const { done, value } = await reader.read();
|
|
390
405
|
if (done) break;
|
|
391
|
-
|
|
406
|
+
// True ISO-8859-1, NOT TextDecoder("latin1") (= windows-1252, lossy in 0x80–0x9F).
|
|
407
|
+
this.lineBuf += decodeLatin1(value);
|
|
392
408
|
let nl: number;
|
|
393
409
|
while ((nl = this.lineBuf.indexOf("\n")) !== -1) {
|
|
394
410
|
const line = this.lineBuf.slice(0, nl).replace(/\r$/, "");
|
package/src/workspace-probe.ts
CHANGED
|
@@ -125,6 +125,7 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
|
|
|
125
125
|
|
|
126
126
|
const id = workspaceId(input);
|
|
127
127
|
const repoRoot = probe.repoRoot;
|
|
128
|
+
const baseRef = terminalBaseRef(repoRoot, probe.branch);
|
|
128
129
|
const baseSha = probe.headSha ?? requireGit(["rev-parse", "HEAD"], repoRoot);
|
|
129
130
|
const branch = await availableBranch(repoRoot, branchName(input, id));
|
|
130
131
|
const workspaceRoot = input.workspaceRoot ? resolve(input.workspaceRoot) : workspacesRoot(homedir());
|
|
@@ -151,7 +152,7 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
|
|
|
151
152
|
sourceCwd,
|
|
152
153
|
worktreePath,
|
|
153
154
|
branch,
|
|
154
|
-
baseRef
|
|
155
|
+
baseRef,
|
|
155
156
|
baseSha,
|
|
156
157
|
status: "active",
|
|
157
158
|
deps,
|
|
@@ -977,6 +978,44 @@ function mergePr(
|
|
|
977
978
|
return head({ status: "merge_planned", prUrl, error: undefined });
|
|
978
979
|
}
|
|
979
980
|
|
|
981
|
+
// Identity stamped on the merge commit a no-ff land records (#287). The merge is a
|
|
982
|
+
// relay/orchestrator action, not the author's — attribute it clearly so `git log`
|
|
983
|
+
// shows who tied the branch in, without impersonating the agent's commits (whose
|
|
984
|
+
// original author and SHA are preserved underneath as the merge's second parent).
|
|
985
|
+
const LAND_COMMITTER = { name: "Agent Relay", email: "agent-relay@noreply" } as const;
|
|
986
|
+
|
|
987
|
+
function landMergeMessage(branch: string | undefined, subject: string | undefined): string {
|
|
988
|
+
const name = shortBranch(branch) ?? branch ?? "branch";
|
|
989
|
+
return subject ? `Merge ${name}: ${subject}` : `Merge ${name}`;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Record a no-ff merge of `branchSha` into `base` when base is NOT checked out in any
|
|
993
|
+
// worktree (#287). We can't run a working-tree merge, so synthesize the merge commit
|
|
994
|
+
// with plumbing: compute the merged tree, commit it with both parents (base first, so
|
|
995
|
+
// `--first-parent` still reads as base's mainline), then advance the ref with a CAS on
|
|
996
|
+
// the old value. Preserves the branch's commit SHAs without a working tree.
|
|
997
|
+
function recordNoFfMerge(
|
|
998
|
+
repoRoot: string,
|
|
999
|
+
base: string,
|
|
1000
|
+
baseSha: string,
|
|
1001
|
+
branchSha: string,
|
|
1002
|
+
message: string,
|
|
1003
|
+
): { ok: true; mergeSha: string } | { ok: false; conflict?: boolean; error: string } {
|
|
1004
|
+
const tree = git(["merge-tree", "--write-tree", baseSha, branchSha], repoRoot);
|
|
1005
|
+
if (!tree.ok) return { ok: false, conflict: true, error: tree.stdout || tree.stderr || "merge conflict computing tree" };
|
|
1006
|
+
const treeOid = tree.stdout.split("\n")[0]?.trim();
|
|
1007
|
+
if (!treeOid) return { ok: false, error: "merge-tree produced no tree oid" };
|
|
1008
|
+
const commit = git(
|
|
1009
|
+
["-c", `user.name=${LAND_COMMITTER.name}`, "-c", `user.email=${LAND_COMMITTER.email}`, "commit-tree", treeOid, "-p", baseSha, "-p", branchSha, "-m", message],
|
|
1010
|
+
repoRoot,
|
|
1011
|
+
);
|
|
1012
|
+
if (!commit.ok || !commit.stdout) return { ok: false, error: commit.stderr || "commit-tree failed" };
|
|
1013
|
+
const mergeSha = commit.stdout;
|
|
1014
|
+
const update = git(["update-ref", `refs/heads/${base}`, mergeSha, baseSha], repoRoot);
|
|
1015
|
+
if (!update.ok) return { ok: false, error: update.stderr || "failed to advance base ref" };
|
|
1016
|
+
return { ok: true, mergeSha };
|
|
1017
|
+
}
|
|
1018
|
+
|
|
980
1019
|
function mergeRebaseFf(
|
|
981
1020
|
input: WorkspaceMergeInput,
|
|
982
1021
|
worktreePath: string,
|
|
@@ -1005,30 +1044,46 @@ function mergeRebaseFf(
|
|
|
1005
1044
|
}
|
|
1006
1045
|
}
|
|
1007
1046
|
|
|
1008
|
-
//
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
git(["rebase", "--abort"], worktreePath);
|
|
1013
|
-
return head({ conflict: true, status: "conflict", error: rebase.stderr || "rebase onto base failed" });
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1047
|
+
// SHA preservation (#287): never rebase the agent branch — rewriting its commits
|
|
1048
|
+
// gives them new SHAs and breaks traceability (the branch.landed SHA must exist on
|
|
1049
|
+
// base verbatim). headSha is the preserved landed commit; when base has advanced we
|
|
1050
|
+
// tie the branch in with a no-ff merge so the agent's commits keep their identity.
|
|
1016
1051
|
const headSha = git(["rev-parse", "HEAD"], worktreePath).stdout;
|
|
1017
1052
|
// Subject of the landed commit for the relay's branch.landed notice (#239). Best-effort:
|
|
1018
1053
|
// an empty/failed read just omits it from the message body.
|
|
1019
1054
|
const landedSubject = git(["log", "-1", "--format=%s", headSha], worktreePath).stdout || undefined;
|
|
1055
|
+
const behind = countBehind(worktreePath, base);
|
|
1020
1056
|
|
|
1021
|
-
// Advance base
|
|
1022
|
-
//
|
|
1023
|
-
//
|
|
1057
|
+
// Advance base. `baseTip` is base's new tip after the land: it equals headSha on a
|
|
1058
|
+
// clean fast-forward, or the merge commit on a no-ff merge. If base is checked out
|
|
1059
|
+
// somewhere, operate in that worktree so its working tree stays consistent; otherwise
|
|
1060
|
+
// move/synthesize the ref directly. Refuse if the base worktree has uncommitted changes.
|
|
1061
|
+
let baseTip = headSha;
|
|
1024
1062
|
const baseWorktree = worktreeForBranch(repoRoot, base);
|
|
1025
1063
|
if (baseWorktree) {
|
|
1026
1064
|
if (baseWorktree.dirty) return head({ status: "review_requested", error: `base branch '${base}' has uncommitted changes in ${baseWorktree.path}` });
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1065
|
+
if (behind === 0) {
|
|
1066
|
+
const ff = git(["merge", "--ff-only", branch], baseWorktree.path);
|
|
1067
|
+
if (!ff.ok) return head({ status: "review_requested", error: ff.stderr || "fast-forward into base failed" });
|
|
1068
|
+
} else {
|
|
1069
|
+
const merge = git(
|
|
1070
|
+
["-c", `user.name=${LAND_COMMITTER.name}`, "-c", `user.email=${LAND_COMMITTER.email}`, "merge", "--no-ff", "-m", landMergeMessage(branch, landedSubject), branch],
|
|
1071
|
+
baseWorktree.path,
|
|
1072
|
+
);
|
|
1073
|
+
if (!merge.ok) {
|
|
1074
|
+
git(["merge", "--abort"], baseWorktree.path);
|
|
1075
|
+
return head({ conflict: true, status: "conflict", error: merge.stderr || "merge into base failed" });
|
|
1076
|
+
}
|
|
1077
|
+
baseTip = git(["rev-parse", "HEAD"], baseWorktree.path).stdout;
|
|
1078
|
+
}
|
|
1079
|
+
} else if (behind === 0) {
|
|
1030
1080
|
const update = git(["update-ref", `refs/heads/${base}`, headSha], repoRoot);
|
|
1031
1081
|
if (!update.ok) return head({ status: "review_requested", error: update.stderr || "failed to advance base ref" });
|
|
1082
|
+
} else {
|
|
1083
|
+
const baseSha = git(["rev-parse", base], repoRoot).stdout;
|
|
1084
|
+
const merged = recordNoFfMerge(repoRoot, base, baseSha, headSha, landMergeMessage(branch, landedSubject));
|
|
1085
|
+
if (!merged.ok) return head(merged.conflict ? { conflict: true, status: "conflict", error: merged.error } : { status: "review_requested", error: merged.error });
|
|
1086
|
+
baseTip = merged.mergeSha;
|
|
1032
1087
|
}
|
|
1033
1088
|
|
|
1034
1089
|
// Publish the advanced base so local and origin converge (#190). We verified
|
|
@@ -1052,22 +1107,23 @@ function mergeRebaseFf(
|
|
|
1052
1107
|
const fresh = nextBranchName(repoRoot, branch);
|
|
1053
1108
|
const switched = git(["checkout", "-B", fresh, base], worktreePath);
|
|
1054
1109
|
if (switched.ok) {
|
|
1055
|
-
// The old branch now
|
|
1110
|
+
// The old branch is now fully contained in base (fast-forwarded, or merged in
|
|
1111
|
+
// as the no-ff merge's second parent) — drop the litter.
|
|
1056
1112
|
const oldDeleted = git(["branch", "-D", branch], repoRoot).ok;
|
|
1057
1113
|
// The worktree just moved onto the advanced base, which may declare deps the
|
|
1058
1114
|
// shared (symlinked) node_modules lacks. Re-provision so the recycled session
|
|
1059
1115
|
// continues from a buildable state (issue #51). No-op when nothing is stale.
|
|
1060
1116
|
const depsRefresh = refreshWorkspaceDeps(repoRoot, worktreePath);
|
|
1061
1117
|
const reportDeps = depsRefresh.refreshed || depsRefresh.stale || depsRefresh.error;
|
|
1062
|
-
return head({ merged: true, status: "active", mergedSha: headSha, subject: landedSubject, worktreeRemoved: false, branch: fresh, newBranch: fresh, branchDeleted: oldDeleted, pushed, ...(reportDeps ? { depsRefresh } : {}), error: undefined });
|
|
1118
|
+
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 });
|
|
1063
1119
|
}
|
|
1064
1120
|
// Recycle failed — keep the existing branch. Still landed, still active.
|
|
1065
|
-
return head({ merged: true, status: "active", mergedSha: headSha, subject: landedSubject, worktreeRemoved: false, branchDeleted: false, pushed, error: undefined });
|
|
1121
|
+
return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branchDeleted: false, pushed, error: undefined });
|
|
1066
1122
|
}
|
|
1067
1123
|
const removed = git(["worktree", "remove", "--force", worktreePath], repoRoot);
|
|
1068
1124
|
const worktreeRemoved = removed.ok;
|
|
1069
1125
|
const branchDeleted = worktreeRemoved ? git(["branch", "-D", branch], repoRoot).ok : false;
|
|
1070
|
-
return head({ merged: true, status: "merged", mergedSha: headSha, subject: landedSubject, worktreeRemoved, branchDeleted, pushed, error: undefined });
|
|
1126
|
+
return head({ merged: true, status: "merged", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved, branchDeleted, pushed, error: undefined });
|
|
1071
1127
|
}
|
|
1072
1128
|
|
|
1073
1129
|
async function availableBranch(repoRoot: string, base: string): Promise<string> {
|
|
@@ -1084,6 +1140,33 @@ function workspaceId(input: WorkspaceResolutionInput): string {
|
|
|
1084
1140
|
return safeSegment(raw, 80);
|
|
1085
1141
|
}
|
|
1086
1142
|
|
|
1143
|
+
// The branch a workspace lands INTO. Normally the source checkout's current branch
|
|
1144
|
+
// (`main`, or a deliberate feature branch). But when an agent is (re-)spawned from inside
|
|
1145
|
+
// a managed worktree — e.g. one recycled onto `agent/<owner>/<id>-N` after a land — the
|
|
1146
|
+
// source HEAD is that transient session branch. Chaining a new workspace onto it strands
|
|
1147
|
+
// the work one hop short of the real base: an `agent/...` branch has no remote upstream,
|
|
1148
|
+
// so the land advances a local-only branch that never reaches origin/main while still
|
|
1149
|
+
// reporting "✅ landed" (#285). Resolve to the repo's terminal base in that case; only fall
|
|
1150
|
+
// back to the session branch if no default base can be found (no worse than before).
|
|
1151
|
+
function terminalBaseRef(repoRoot: string, currentBranch: string | undefined): string | undefined {
|
|
1152
|
+
if (!currentBranch || !currentBranch.startsWith("agent/")) return currentBranch;
|
|
1153
|
+
return repoDefaultBranch(repoRoot) ?? currentBranch;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// The repo's mainline: origin/HEAD's target when set (and present locally), else local
|
|
1157
|
+
// `main`/`master`. Returns undefined when none resolve.
|
|
1158
|
+
function repoDefaultBranch(repoRoot: string): string | undefined {
|
|
1159
|
+
const head = git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], repoRoot);
|
|
1160
|
+
if (head.ok && head.stdout) {
|
|
1161
|
+
const name = head.stdout.replace(/^[^/]+\//, ""); // strip the `origin/` remote prefix
|
|
1162
|
+
if (name && git(["show-ref", "--verify", "--quiet", `refs/heads/${name}`], repoRoot).ok) return name;
|
|
1163
|
+
}
|
|
1164
|
+
for (const candidate of ["main", "master"]) {
|
|
1165
|
+
if (git(["show-ref", "--verify", "--quiet", `refs/heads/${candidate}`], repoRoot).ok) return candidate;
|
|
1166
|
+
}
|
|
1167
|
+
return undefined;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1087
1170
|
function branchName(input: WorkspaceResolutionInput, id: string): string {
|
|
1088
1171
|
const owner = input.policyName || input.label || input.automationId || "manual";
|
|
1089
1172
|
// 40 fits a full UUID (36) plus the `sp_`-stripped slack. A tighter cap (was 24)
|