agent-relay-orchestrator 0.17.0 → 0.18.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/api.ts +1 -4
- package/src/artifact-proxy.ts +2 -1
- package/src/control.ts +2 -4
- package/src/relay.ts +5 -42
- package/src/version.ts +5 -3
- package/src/workspace-probe.ts +74 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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.9"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/bun": "latest",
|
package/src/api.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { closeSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, statSync } from "node:fs";
|
|
2
|
+
import { stringValue } from "agent-relay-sdk";
|
|
2
3
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
4
|
import type { ServerWebSocket } from "bun";
|
|
4
5
|
import { proxyArtifactRequest } from "./artifact-proxy";
|
|
@@ -797,7 +798,3 @@ function cleanTerminalGuestInput(value: unknown): { agentId?: string; policyName
|
|
|
797
798
|
}
|
|
798
799
|
return result;
|
|
799
800
|
}
|
|
800
|
-
|
|
801
|
-
function stringValue(value: unknown): string | undefined {
|
|
802
|
-
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
803
|
-
}
|
package/src/artifact-proxy.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "no
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join, relative, resolve } from "node:path";
|
|
4
4
|
import type { OrchestratorConfig } from "./config";
|
|
5
|
+
import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
5
6
|
|
|
6
7
|
const SAFE_ARTIFACT_ID = /^[a-zA-Z0-9._-]{1,160}$/;
|
|
7
8
|
const CONTENT_ROUTE = /^\/api\/artifacts\/([^/]+)\/content$/;
|
|
@@ -77,7 +78,7 @@ function forwardHeaders(req: Request, config: OrchestratorConfig): Headers {
|
|
|
77
78
|
if (value) headers.set(name, value);
|
|
78
79
|
}
|
|
79
80
|
const token = config.token || req.headers.get("x-agent-relay-token");
|
|
80
|
-
if (token) headers.set(
|
|
81
|
+
if (token) headers.set(RELAY_TOKEN_HEADER, token);
|
|
81
82
|
return headers;
|
|
82
83
|
}
|
|
83
84
|
|
package/src/control.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isRecord } from "agent-relay-sdk";
|
|
1
2
|
import type { OrchestratorConfig } from "./config";
|
|
2
3
|
import type { ManagedAgentReport, RelayClient, RelayCommand } from "./relay";
|
|
3
4
|
import { handleSelfUpgrade } from "./self-upgrade";
|
|
@@ -115,6 +116,7 @@ export function createControlHandler(
|
|
|
115
116
|
baseSha: typeof command.params.baseSha === "string" ? command.params.baseSha : undefined,
|
|
116
117
|
strategy: command.params.strategy === "pr" || command.params.strategy === "rebase-ff" || command.params.strategy === "auto" ? command.params.strategy : undefined,
|
|
117
118
|
deleteBranch: command.params.deleteBranch !== false,
|
|
119
|
+
push: command.params.push !== false,
|
|
118
120
|
prTitle: typeof command.params.prTitle === "string" ? command.params.prTitle : undefined,
|
|
119
121
|
prBody: typeof command.params.prBody === "string" ? command.params.prBody : undefined,
|
|
120
122
|
});
|
|
@@ -245,7 +247,3 @@ function stringArray(value: unknown): string[] | undefined {
|
|
|
245
247
|
function workspaceMode(value: unknown): SpawnOptions["workspaceMode"] {
|
|
246
248
|
return value === "isolated" || value === "shared" || value === "inherit" ? value : undefined;
|
|
247
249
|
}
|
|
248
|
-
|
|
249
|
-
function isRecord(value: unknown): value is Record<string, any> {
|
|
250
|
-
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
251
|
-
}
|
package/src/relay.ts
CHANGED
|
@@ -2,7 +2,8 @@ import type { OrchestratorConfig } from "./config";
|
|
|
2
2
|
import type { ProviderProbeCache } from "./provider-probe";
|
|
3
3
|
import { detectSelfSupervision } from "./self-supervision";
|
|
4
4
|
import { GIT_SHA, ORCHESTRATOR_PROTOCOL_VERSION, VERSION, runtimeMetadata } from "./version";
|
|
5
|
-
import type { WorkspaceMetadata, WorkspaceMode } from "agent-relay-sdk";
|
|
5
|
+
import type { WorkspaceMetadata, WorkspaceMode, ManagedSessionExitDiagnostics as SdkManagedSessionExitDiagnostics } from "agent-relay-sdk";
|
|
6
|
+
import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
6
7
|
|
|
7
8
|
export interface RelayClient {
|
|
8
9
|
register(): Promise<void>;
|
|
@@ -46,46 +47,8 @@ export interface ManagedAgentReport {
|
|
|
46
47
|
startedAt: number;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
export
|
|
50
|
-
|
|
51
|
-
provider: "claude" | "codex";
|
|
52
|
-
workspaceMode?: WorkspaceMode;
|
|
53
|
-
workspace?: WorkspaceMetadata;
|
|
54
|
-
sessionName?: string;
|
|
55
|
-
tmuxSession: string;
|
|
56
|
-
cwd: string;
|
|
57
|
-
label?: string;
|
|
58
|
-
policyName?: string;
|
|
59
|
-
spawnRequestId?: string;
|
|
60
|
-
automationRunId?: string;
|
|
61
|
-
supervisor: "process" | "systemd" | "launchd" | "unknown";
|
|
62
|
-
systemdUnit?: string;
|
|
63
|
-
terminalSession?: string;
|
|
64
|
-
terminalAvailable?: boolean;
|
|
65
|
-
pid?: number;
|
|
66
|
-
currentPid?: number;
|
|
67
|
-
startedAt: number;
|
|
68
|
-
detectedAt: number;
|
|
69
|
-
runtimeMs: number;
|
|
70
|
-
logFile?: string;
|
|
71
|
-
logBytes?: number;
|
|
72
|
-
logEmpty?: boolean;
|
|
73
|
-
logTail?: string[];
|
|
74
|
-
runnerInfoFile?: string;
|
|
75
|
-
runnerInfoPresent?: boolean;
|
|
76
|
-
systemd?: {
|
|
77
|
-
unit: string;
|
|
78
|
-
activeState?: string;
|
|
79
|
-
subState?: string;
|
|
80
|
-
result?: string;
|
|
81
|
-
execMainCode?: string;
|
|
82
|
-
execMainStatus?: string;
|
|
83
|
-
mainPid?: number;
|
|
84
|
-
unavailable?: string;
|
|
85
|
-
};
|
|
86
|
-
unavailable?: string[];
|
|
87
|
-
lastError: string;
|
|
88
|
-
}
|
|
50
|
+
// Canonical shape lives in agent-relay-sdk — alias and re-export, never re-declare.
|
|
51
|
+
export type ManagedSessionExitDiagnostics = SdkManagedSessionExitDiagnostics;
|
|
89
52
|
|
|
90
53
|
export interface RelayCommand {
|
|
91
54
|
id: string;
|
|
@@ -133,7 +96,7 @@ export function createRelayClient(config: OrchestratorConfig, probeCache: Provid
|
|
|
133
96
|
function headers(): Record<string, string> {
|
|
134
97
|
const h: Record<string, string> = { "Content-Type": "application/json" };
|
|
135
98
|
const token = runtimeToken ?? config.token;
|
|
136
|
-
if (token) h[
|
|
99
|
+
if (token) h[RELAY_TOKEN_HEADER] = token;
|
|
137
100
|
return h;
|
|
138
101
|
}
|
|
139
102
|
|
package/src/version.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { CONTRACT_VERSIONS } from "agent-relay-sdk";
|
|
4
5
|
|
|
5
6
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
7
|
const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8")) as { name?: string; version?: string };
|
|
7
8
|
|
|
8
9
|
export const PACKAGE_NAME = pkg.name || "agent-relay-orchestrator";
|
|
9
10
|
export const VERSION = pkg.version || "0.0.0";
|
|
10
|
-
|
|
11
|
-
export const
|
|
12
|
-
export const
|
|
11
|
+
// Protocol versions are owned by the SDK (CONTRACT_VERSIONS) — derive, never redeclare.
|
|
12
|
+
export const ORCHESTRATOR_PROTOCOL_VERSION = CONTRACT_VERSIONS.orchestratorProtocol;
|
|
13
|
+
export const RUNNER_PROTOCOL_VERSION = CONTRACT_VERSIONS.runnerProtocol;
|
|
14
|
+
export const PROVIDER_PLUGIN_PROTOCOL_VERSION = CONTRACT_VERSIONS.providerPluginProtocol;
|
|
13
15
|
export const GIT_SHA = process.env.AGENT_RELAY_GIT_SHA || process.env.GIT_SHA || undefined;
|
|
14
16
|
|
|
15
17
|
export const CONTRACTS = {
|
package/src/workspace-probe.ts
CHANGED
|
@@ -394,6 +394,8 @@ export function workspaceGitState(input: { worktreePath?: string; baseRef?: stri
|
|
|
394
394
|
const dirtyCount = status.stdout ? status.stdout.split("\n").filter(Boolean).length : 0;
|
|
395
395
|
|
|
396
396
|
const state: WorkspaceGitState = { dirty: dirtyCount > 0, dirtyCount };
|
|
397
|
+
const liveBranch = shortBranch(git(["symbolic-ref", "--quiet", "--short", "HEAD"], path).stdout || undefined);
|
|
398
|
+
if (liveBranch) state.branch = liveBranch;
|
|
397
399
|
|
|
398
400
|
const log = git(["log", "-1", "--format=%H%x1f%ct%x1f%s"], path);
|
|
399
401
|
if (log.ok && log.stdout) {
|
|
@@ -552,10 +554,22 @@ interface WorkspaceMergeInput {
|
|
|
552
554
|
baseSha?: string;
|
|
553
555
|
strategy?: "pr" | "rebase-ff" | "auto";
|
|
554
556
|
deleteBranch?: boolean;
|
|
557
|
+
/** Push the advanced base to its upstream (origin) after a rebase-ff land.
|
|
558
|
+
* Defaults to true; auto-skipped when base has no upstream. Disable with
|
|
559
|
+
* AGENT_RELAY_WORKSPACE_PUSH=0. */
|
|
560
|
+
push?: boolean;
|
|
555
561
|
prTitle?: string;
|
|
556
562
|
prBody?: string;
|
|
557
563
|
}
|
|
558
564
|
|
|
565
|
+
/** Behind-count of HEAD relative to `base`, from inside `worktreePath`. */
|
|
566
|
+
function countBehind(worktreePath: string, base: string): number {
|
|
567
|
+
const counts = git(["rev-list", "--left-right", "--count", `${base}...HEAD`], worktreePath);
|
|
568
|
+
if (!counts.ok || !counts.stdout) return 0;
|
|
569
|
+
const behind = Number(counts.stdout.split(/\s+/)[0]);
|
|
570
|
+
return Number.isFinite(behind) ? behind : 0;
|
|
571
|
+
}
|
|
572
|
+
|
|
559
573
|
function hasOriginRemote(cwd: string): boolean {
|
|
560
574
|
return git(["remote", "get-url", "origin"], cwd).ok;
|
|
561
575
|
}
|
|
@@ -679,7 +693,8 @@ export function previewWorkspaceMerge(input: { worktreePath?: string; baseRef?:
|
|
|
679
693
|
/**
|
|
680
694
|
* Integrate a workspace's work back into its base branch. Two strategies:
|
|
681
695
|
* - rebase-ff: rebase the agent branch onto base, fast-forward base to it,
|
|
682
|
-
*
|
|
696
|
+
* push base to origin, then (unless deleteBranch is false) remove the worktree
|
|
697
|
+
* and delete the branch. Lands work locally and publishes it.
|
|
683
698
|
* - pr: push the branch to origin and open a PR via gh. Leaves the worktree
|
|
684
699
|
* and branch intact (the PR needs them).
|
|
685
700
|
* Refuses on a dirty worktree, predicted conflicts, or nothing to merge. Never
|
|
@@ -742,8 +757,24 @@ function mergeRebaseFf(
|
|
|
742
757
|
if (!base) return head({ status: "review_requested", error: "no base branch to merge into" });
|
|
743
758
|
if (!branch) return head({ status: "review_requested", error: "cannot determine agent branch" });
|
|
744
759
|
|
|
760
|
+
// Reconcile with origin before landing (#190/#203). When base tracks an
|
|
761
|
+
// upstream (e.g. main -> origin/main) and we'll push, fetch it and refuse if
|
|
762
|
+
// origin has moved ahead of local base: pushing would then be a non-fast-forward,
|
|
763
|
+
// and we won't rewrite published history or strand a local-only land. The
|
|
764
|
+
// refusal happens BEFORE we mutate anything, so a diverged base is a clean no-op.
|
|
765
|
+
const upstream = upstreamRef(worktreePath, base);
|
|
766
|
+
const slash = upstream ? upstream.indexOf("/") : -1;
|
|
767
|
+
const remote = slash > 0 ? upstream!.slice(0, slash) : undefined; // remote of a `remote/branch` upstream
|
|
768
|
+
const pushEnabled = input.push !== false && process.env.AGENT_RELAY_WORKSPACE_PUSH !== "0" && Boolean(remote);
|
|
769
|
+
if (upstream && remote && pushEnabled) {
|
|
770
|
+
git(["fetch", remote, base], worktreePath); // best-effort freshness; a stale ref can only under-detect divergence
|
|
771
|
+
if (!git(["merge-base", "--is-ancestor", upstream, base], worktreePath).ok) {
|
|
772
|
+
return head({ status: "review_requested", error: `origin moved ahead of local ${base}; sync ${base} with ${upstream} before landing` });
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
745
776
|
// Rebase the agent branch onto base so base can fast-forward to it.
|
|
746
|
-
if ((
|
|
777
|
+
if (countBehind(worktreePath, base) > 0) {
|
|
747
778
|
const rebase = git(["rebase", base], worktreePath);
|
|
748
779
|
if (!rebase.ok) {
|
|
749
780
|
git(["rebase", "--abort"], worktreePath);
|
|
@@ -765,16 +796,38 @@ function mergeRebaseFf(
|
|
|
765
796
|
if (!update.ok) return head({ status: "review_requested", error: update.stderr || "failed to advance base ref" });
|
|
766
797
|
}
|
|
767
798
|
|
|
768
|
-
//
|
|
799
|
+
// Publish the advanced base so local and origin converge (#190). We verified
|
|
800
|
+
// origin was an ancestor of base above, so this is a fast-forward; a failure
|
|
801
|
+
// means origin raced us — surface it instead of claiming an unpublished land.
|
|
802
|
+
let pushed = false;
|
|
803
|
+
if (upstream && remote && pushEnabled) {
|
|
804
|
+
const push = git(["push", remote, `${base}:${base}`], worktreePath);
|
|
805
|
+
if (!push.ok) return head({ status: "review_requested", mergedSha: headSha, error: push.stderr || `git push to ${remote}/${base} failed` });
|
|
806
|
+
pushed = true;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Work is landed (and published). Tear down only when the owner is gone:
|
|
810
|
+
// deleteBranch is false for a live owner (the relay sets it from owner liveness,
|
|
811
|
+
// #204). A live owner keeps its worktree and is recycled onto a fresh branch cut
|
|
812
|
+
// from the advanced base (#206) so it continues the next task from clean, current
|
|
813
|
+
// state in the same CWD; status returns to `active`. An absent owner gets the
|
|
814
|
+
// worktree reclaimed.
|
|
769
815
|
const deleteBranch = input.deleteBranch !== false;
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
816
|
+
if (!deleteBranch) {
|
|
817
|
+
const fresh = nextBranchName(repoRoot, branch);
|
|
818
|
+
const switched = git(["checkout", "-B", fresh, base], worktreePath);
|
|
819
|
+
if (switched.ok) {
|
|
820
|
+
// The old branch now equals base (just fast-forwarded) — drop the litter.
|
|
821
|
+
const oldDeleted = git(["branch", "-D", branch], repoRoot).ok;
|
|
822
|
+
return head({ merged: true, status: "active", mergedSha: headSha, worktreeRemoved: false, branch: fresh, newBranch: fresh, branchDeleted: oldDeleted, pushed, error: undefined });
|
|
823
|
+
}
|
|
824
|
+
// Recycle failed — keep the existing branch. Still landed, still active.
|
|
825
|
+
return head({ merged: true, status: "active", mergedSha: headSha, worktreeRemoved: false, branchDeleted: false, pushed, error: undefined });
|
|
776
826
|
}
|
|
777
|
-
|
|
827
|
+
const removed = git(["worktree", "remove", "--force", worktreePath], repoRoot);
|
|
828
|
+
const worktreeRemoved = removed.ok;
|
|
829
|
+
const branchDeleted = worktreeRemoved ? git(["branch", "-D", branch], repoRoot).ok : false;
|
|
830
|
+
return head({ merged: true, status: "merged", mergedSha: headSha, worktreeRemoved, branchDeleted, pushed, error: undefined });
|
|
778
831
|
}
|
|
779
832
|
|
|
780
833
|
async function availableBranch(repoRoot: string, base: string): Promise<string> {
|
|
@@ -796,6 +849,17 @@ function branchName(input: WorkspaceResolutionInput, id: string): string {
|
|
|
796
849
|
return `agent/${safeSegment(owner, 48)}/${safeSegment(id.replace(/^sp[_-]?/, ""), 24)}`;
|
|
797
850
|
}
|
|
798
851
|
|
|
852
|
+
/** Next free `<branch>-N` cycle name for a recycled worktree (#206). Strips any
|
|
853
|
+
* existing -N suffix so cycles increment instead of nesting. */
|
|
854
|
+
function nextBranchName(repoRoot: string, branch: string): string {
|
|
855
|
+
const stem = branch.replace(/-\d+$/, "");
|
|
856
|
+
for (let i = 2; i < 1000; i++) {
|
|
857
|
+
const candidate = `${stem}-${i}`;
|
|
858
|
+
if (!git(["show-ref", "--verify", "--quiet", `refs/heads/${candidate}`], repoRoot).ok) return candidate;
|
|
859
|
+
}
|
|
860
|
+
return `${stem}-${Date.now()}`;
|
|
861
|
+
}
|
|
862
|
+
|
|
799
863
|
function repoSlug(repoRoot: string): string {
|
|
800
864
|
const hash = createHash("sha1").update(resolve(repoRoot)).digest("hex").slice(0, 10);
|
|
801
865
|
return `${safeSegment(basename(repoRoot), 60)}-${hash}`;
|