agent-relay-orchestrator 0.20.0 → 0.22.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/relay.ts +19 -30
- package/src/workspace-probe.ts +32 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.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.13"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/bun": "latest",
|
package/src/relay.ts
CHANGED
|
@@ -3,7 +3,7 @@ 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
5
|
import type { WorkspaceMetadata, WorkspaceMode, ManagedSessionExitDiagnostics as SdkManagedSessionExitDiagnostics } from "agent-relay-sdk";
|
|
6
|
-
import {
|
|
6
|
+
import { ReconnectionManager, RelayHttpClient } from "agent-relay-sdk";
|
|
7
7
|
|
|
8
8
|
export interface RelayClient {
|
|
9
9
|
register(): Promise<void>;
|
|
@@ -58,10 +58,11 @@ export interface RelayCommand {
|
|
|
58
58
|
status: string;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
61
|
+
// Reconnect backoff: 30s → 1m → 2m → … capped at 1h, with jitter. Replaces the
|
|
62
|
+
// former fixed BACKOFF_SCHEDULE_MS staircase — see SDK ReconnectionManager.
|
|
63
|
+
const RECONNECT_INITIAL_MS = 30_000;
|
|
64
|
+
const RECONNECT_MAX_MS = 3_600_000; // 1 hour
|
|
65
|
+
const RECONNECT_JITTER_MS = 1_000;
|
|
65
66
|
|
|
66
67
|
export function buildRegistrationMeta(
|
|
67
68
|
config: Pick<OrchestratorConfig, "tmuxPrefix">,
|
|
@@ -88,26 +89,17 @@ export function createRelayClient(config: OrchestratorConfig, probeCache: Provid
|
|
|
88
89
|
const agentId = `orchestrator-${config.id}`;
|
|
89
90
|
let heartbeatTimer: Timer | null = null;
|
|
90
91
|
let connected = false;
|
|
91
|
-
|
|
92
|
+
const reconnectMgr = new ReconnectionManager({ initialMs: RECONNECT_INITIAL_MS, maxMs: RECONNECT_MAX_MS, jitterMs: RECONNECT_JITTER_MS });
|
|
92
93
|
let cursorFloor = 0;
|
|
93
94
|
let apiUrl: string | undefined;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (token) h[RELAY_TOKEN_HEADER] = token;
|
|
100
|
-
return h;
|
|
101
|
-
}
|
|
95
|
+
// Shared transport: auth-header injection + timeout + typed errors. The
|
|
96
|
+
// orchestrator-control endpoints have no typed method, so route them through
|
|
97
|
+
// the generic request() escape hatch. setToken() swaps in the runtime token
|
|
98
|
+
// the relay mints at registration.
|
|
99
|
+
const http = new RelayHttpClient({ baseUrl: config.relayUrl, token: config.token });
|
|
102
100
|
|
|
103
101
|
async function apiCall(method: string, path: string, body?: unknown): Promise<Response> {
|
|
104
|
-
|
|
105
|
-
const res = await fetch(url, {
|
|
106
|
-
method,
|
|
107
|
-
headers: headers(),
|
|
108
|
-
body: body ? JSON.stringify(body) : undefined,
|
|
109
|
-
});
|
|
110
|
-
return res;
|
|
102
|
+
return http.request(method, `/api${path}`, body);
|
|
111
103
|
}
|
|
112
104
|
|
|
113
105
|
async function register(): Promise<void> {
|
|
@@ -135,9 +127,9 @@ export function createRelayClient(config: OrchestratorConfig, probeCache: Provid
|
|
|
135
127
|
throw new Error(`Failed to register orchestrator: ${res.status} ${err}`);
|
|
136
128
|
}
|
|
137
129
|
const registered = await res.json().catch(() => null) as { runtimeToken?: { token?: string } } | null;
|
|
138
|
-
if (registered?.runtimeToken?.token)
|
|
130
|
+
if (registered?.runtimeToken?.token) http.setToken(registered.runtimeToken.token);
|
|
139
131
|
connected = true;
|
|
140
|
-
|
|
132
|
+
reconnectMgr.reset();
|
|
141
133
|
|
|
142
134
|
// Bootstrap message cursor
|
|
143
135
|
const cursor = await apiCall("GET", "/messages/cursor");
|
|
@@ -168,7 +160,7 @@ export function createRelayClient(config: OrchestratorConfig, probeCache: Provid
|
|
|
168
160
|
if (!connected) {
|
|
169
161
|
console.error("[orchestrator] Reconnected to relay");
|
|
170
162
|
connected = true;
|
|
171
|
-
|
|
163
|
+
reconnectMgr.reset();
|
|
172
164
|
}
|
|
173
165
|
} catch (err) {
|
|
174
166
|
if (connected) {
|
|
@@ -180,12 +172,9 @@ export function createRelayClient(config: OrchestratorConfig, probeCache: Provid
|
|
|
180
172
|
}
|
|
181
173
|
|
|
182
174
|
async function reconnect(): Promise<void> {
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
backoffIndex = Math.min(backoffIndex + 1, BACKOFF_SCHEDULE_MS.length);
|
|
187
|
-
console.error(`[orchestrator] Reconnecting in ${Math.round(delay / 1000)}s...`);
|
|
188
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
175
|
+
const delayMs = reconnectMgr.nextDelay();
|
|
176
|
+
console.error(`[orchestrator] Reconnecting in ${Math.round(delayMs / 1000)}s...`);
|
|
177
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
189
178
|
try {
|
|
190
179
|
await register();
|
|
191
180
|
} catch {
|
package/src/workspace-probe.ts
CHANGED
|
@@ -789,7 +789,9 @@ export function previewWorkspaceMerge(input: { worktreePath?: string; baseRef?:
|
|
|
789
789
|
: gitState.landed
|
|
790
790
|
? "already merged into base (squash/cherry-pick)"
|
|
791
791
|
: "no commits to merge";
|
|
792
|
-
|
|
792
|
+
// Nothing to land and the worktree is clean — a no-op the land path resolves to
|
|
793
|
+
// a terminal state rather than parking forever in the steward queue (#230).
|
|
794
|
+
return { ...base, reason, noop: true };
|
|
793
795
|
}
|
|
794
796
|
if (gitState.baseRef && input.worktreePath) {
|
|
795
797
|
const conflict = predictConflict(resolve(input.worktreePath), gitState.baseRef);
|
|
@@ -820,6 +822,10 @@ export function mergeWorkspace(input: WorkspaceMergeInput): WorkspaceMergeResult
|
|
|
820
822
|
|
|
821
823
|
if (preview.missing) return head({ status: "cleaned", error: preview.reason });
|
|
822
824
|
if (preview.error) return head({ status: "review_requested", error: preview.error });
|
|
825
|
+
// Nothing to land (ahead=0, clean): the branch tree is already in base. Resolve it
|
|
826
|
+
// to a terminal state so it leaves the steward queue instead of looping forever in
|
|
827
|
+
// review_requested (#230). Reclaim the spent worktree/branch when the owner is gone.
|
|
828
|
+
if (preview.noop) return resolveNoopMerge(input, worktreePath, repoRoot, branch, head);
|
|
823
829
|
if (preview.reason) return head({ status: "review_requested", error: preview.reason });
|
|
824
830
|
if (preview.conflict) return head({ conflict: true, status: "conflict", error: "merge would conflict with base" });
|
|
825
831
|
|
|
@@ -827,6 +833,31 @@ export function mergeWorkspace(input: WorkspaceMergeInput): WorkspaceMergeResult
|
|
|
827
833
|
return mergeRebaseFf(input, worktreePath, repoRoot, branch, preview, head);
|
|
828
834
|
}
|
|
829
835
|
|
|
836
|
+
/**
|
|
837
|
+
* Resolve a no-op land (#230): ahead=0 with a clean worktree, so the branch's tree
|
|
838
|
+
* is already contained in base (never diverged, or already landed via
|
|
839
|
+
* squash/cherry-pick/PR). There's nothing to merge — mark the workspace terminal
|
|
840
|
+
* (`merged`, `noop: true`) so it leaves the steward queue. When the owner is gone
|
|
841
|
+
* (`deleteBranch !== false`) the spent worktree/branch are reclaimed too, mirroring a
|
|
842
|
+
* terminal land; a live owner keeps its worktree (cleanup reclaims it later). Safe:
|
|
843
|
+
* there is no unmerged work to lose.
|
|
844
|
+
*/
|
|
845
|
+
function resolveNoopMerge(
|
|
846
|
+
input: WorkspaceMergeInput,
|
|
847
|
+
worktreePath: string,
|
|
848
|
+
repoRoot: string,
|
|
849
|
+
branch: string | undefined,
|
|
850
|
+
head: (field: Partial<WorkspaceMergeResult>) => WorkspaceMergeResult,
|
|
851
|
+
): WorkspaceMergeResult {
|
|
852
|
+
if (input.deleteBranch !== false && branch) {
|
|
853
|
+
const removed = git(["worktree", "remove", "--force", worktreePath], repoRoot);
|
|
854
|
+
const worktreeRemoved = removed.ok;
|
|
855
|
+
const branchDeleted = worktreeRemoved ? git(["branch", "-D", branch], repoRoot).ok : false;
|
|
856
|
+
return head({ status: "merged", noop: true, worktreeRemoved, branchDeleted, error: undefined });
|
|
857
|
+
}
|
|
858
|
+
return head({ status: "merged", noop: true, error: undefined });
|
|
859
|
+
}
|
|
860
|
+
|
|
830
861
|
function mergePr(
|
|
831
862
|
input: WorkspaceMergeInput,
|
|
832
863
|
worktreePath: string,
|