Skip to content

Commit ea9cf98

Browse files
authored
Merge pull request #7832 from elizaOS/codex/capability-cloud-readiness-20260520
fix(agent): wait for cloud capability readiness
2 parents eeea686 + f0397a2 commit ea9cf98

3 files changed

Lines changed: 185 additions & 1 deletion

File tree

packages/agent/src/services/remote-capability-cloud-sandbox.cloud-smoke.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import {
1313
installRemoteCapabilityEndpoint,
1414
provisionCloudCapabilitySandbox,
15+
waitForCloudCapabilityEndpointAvailability,
1516
} from "./remote-capability-cloud-sandbox.ts";
1617
import { assertRemoteCapabilityEndpointConformance } from "./remote-capability-endpoint-conformance.ts";
1718
import {
@@ -32,8 +33,12 @@ const cloudProvisionTimeoutMs = readPositiveIntegerEnv(
3233
"ELIZA_REMOTE_CAPABILITY_CLOUD_PROVISION_TIMEOUT_MS",
3334
600_000,
3435
);
36+
const cloudAvailabilityTimeoutMs = readPositiveIntegerEnv(
37+
"ELIZA_REMOTE_CAPABILITY_CLOUD_AVAILABILITY_TIMEOUT_MS",
38+
300_000,
39+
);
3540
const cloudLiveTestTimeoutMs = Math.max(
36-
cloudProvisionTimeoutMs + 120_000,
41+
cloudProvisionTimeoutMs + cloudAvailabilityTimeoutMs + 120_000,
3742
720_000,
3843
);
3944
const registeredPluginNames: string[] = [];
@@ -77,6 +82,19 @@ describe("cloud capability sandbox live smoke", () => {
7782
});
7883
agentId = provisioned.agentId;
7984

85+
await waitForCloudCapabilityEndpointAvailability({
86+
endpoint: provisioned.endpoint,
87+
timeoutMs: cloudAvailabilityTimeoutMs,
88+
pollIntervalMs: 5_000,
89+
requestTimeoutMs: 60_000,
90+
onProgress: (detail) => {
91+
console.log(`[cloud-capability-live] availability: ${detail}`);
92+
},
93+
});
94+
console.log(
95+
`[cloud-capability-live] availability: endpoint ${provisioned.endpoint.id} reports plugin capability.`,
96+
);
97+
8098
installRemoteCapabilityEndpoint(runtime, {
8199
enabled: true,
82100
endpoints: [provisioned.endpoint],

packages/agent/src/services/remote-capability-cloud-sandbox.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
88
import {
99
connectCloudCapabilitySandbox,
1010
provisionCloudCapabilitySandbox,
11+
waitForCloudCapabilityEndpointAvailability,
1112
} from "./remote-capability-cloud-sandbox.ts";
1213
import type { RemoteCapabilityRouterService } from "./remote-capability-router.ts";
1314

@@ -151,6 +152,69 @@ describe("cloud capability sandbox provisioner", () => {
151152
});
152153
});
153154

155+
it("waits until a cloud capability endpoint reports plugin availability", async () => {
156+
const progress: string[] = [];
157+
const fetchMock = vi.fn(async () => {
158+
const attempt = fetchMock.mock.calls.length;
159+
if (attempt === 1) {
160+
return jsonResponse({
161+
available: false,
162+
capabilities: { plugin: false },
163+
});
164+
}
165+
return jsonResponse({ available: true, capabilities: { plugin: true } });
166+
});
167+
168+
await expect(
169+
waitForCloudCapabilityEndpointAvailability({
170+
endpoint: {
171+
id: "cloud-capability",
172+
baseUrl: "https://capability.example.test",
173+
token: "capability-token",
174+
},
175+
timeoutMs: 1_000,
176+
pollIntervalMs: 1,
177+
requestTimeoutMs: 1_000,
178+
fetch: fetchMock as unknown as typeof fetch,
179+
onProgress: (detail) => progress.push(detail),
180+
}),
181+
).resolves.toBeUndefined();
182+
183+
expect(fetchMock).toHaveBeenCalledTimes(2);
184+
expect(fetchMock.mock.calls[0]?.[0]).toEqual(
185+
new URL("https://capability.example.test/v1/capabilities"),
186+
);
187+
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
188+
method: "GET",
189+
headers: {
190+
accept: "application/json",
191+
authorization: "Bearer capability-token",
192+
},
193+
});
194+
expect(progress[0]).toContain("unexpected availability payload");
195+
});
196+
197+
it("reports the last readiness failure when cloud availability never starts", async () => {
198+
const fetchMock = vi.fn(async () =>
199+
jsonResponse({ error: "not ready" }, 503),
200+
);
201+
202+
await expect(
203+
waitForCloudCapabilityEndpointAvailability({
204+
endpoint: {
205+
id: "cloud-capability",
206+
baseUrl: "https://capability.example.test",
207+
},
208+
timeoutMs: 1,
209+
pollIntervalMs: 1,
210+
requestTimeoutMs: 1_000,
211+
fetch: fetchMock as unknown as typeof fetch,
212+
}),
213+
).rejects.toThrow(
214+
'Cloud capability endpoint cloud-capability did not report plugin availability within 1ms. Last error: HTTP 503: {"error":"not ready"}',
215+
);
216+
});
217+
154218
it("fails when provisioning completes without an endpoint", async () => {
155219
const fetchMock = vi.fn(async (url: string | URL) => {
156220
const href = String(url);

packages/agent/src/services/remote-capability-cloud-sandbox.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ export type CloudCapabilitySandboxProvisionResult = {
3434
jobId?: string;
3535
};
3636

37+
export type WaitForCloudCapabilityEndpointAvailabilityOptions = {
38+
endpoint: RemoteCapabilityEndpointConfig;
39+
timeoutMs?: number;
40+
pollIntervalMs?: number;
41+
requestTimeoutMs?: number;
42+
fetch?: typeof fetch;
43+
onProgress?: (detail: string) => void;
44+
};
45+
3746
export type ConnectCloudCapabilitySandboxOptions =
3847
CloudCapabilitySandboxProvisionOptions & {
3948
unloadMissing?: boolean;
@@ -246,6 +255,99 @@ export async function connectCloudCapabilitySandbox(
246255
};
247256
}
248257

258+
export async function waitForCloudCapabilityEndpointAvailability(
259+
options: WaitForCloudCapabilityEndpointAvailabilityOptions,
260+
): Promise<void> {
261+
const request = options.fetch ?? fetch;
262+
const timeoutMs = options.timeoutMs ?? 120_000;
263+
const pollIntervalMs = options.pollIntervalMs ?? 5_000;
264+
const requestTimeoutMs = options.requestTimeoutMs ?? 60_000;
265+
const deadline = Date.now() + timeoutMs;
266+
let lastError = "availability was not checked";
267+
268+
while (Date.now() < deadline) {
269+
try {
270+
const controller = new AbortController();
271+
const timeout = setTimeout(() => controller.abort(), requestTimeoutMs);
272+
try {
273+
const response = await request(
274+
new URL("/v1/capabilities", options.endpoint.baseUrl),
275+
{
276+
method: "GET",
277+
headers: {
278+
accept: "application/json",
279+
...(options.endpoint.token
280+
? { authorization: `Bearer ${options.endpoint.token}` }
281+
: {}),
282+
},
283+
signal: controller.signal,
284+
},
285+
);
286+
const text = await response.text();
287+
if (!response.ok) {
288+
lastError = `HTTP ${response.status}: ${text.slice(0, 500)}`;
289+
} else {
290+
const availability = JSON.parse(text) as {
291+
available?: unknown;
292+
capabilities?: { plugin?: unknown };
293+
};
294+
if (
295+
availability.available === true &&
296+
availability.capabilities?.plugin === true
297+
) {
298+
return;
299+
}
300+
lastError = `unexpected availability payload: ${text.slice(0, 500)}`;
301+
}
302+
} finally {
303+
clearTimeout(timeout);
304+
}
305+
} catch (error) {
306+
lastError = describeAvailabilityError(error);
307+
}
308+
options.onProgress?.(
309+
`waiting for endpoint ${options.endpoint.id}: ${lastError}`,
310+
);
311+
await sleep(pollIntervalMs);
312+
}
313+
314+
throw new Error(
315+
`Cloud capability endpoint ${options.endpoint.id} did not report plugin availability within ${timeoutMs}ms. Last error: ${lastError}`,
316+
);
317+
}
318+
319+
function describeAvailabilityError(error: unknown): string {
320+
if (!(error instanceof Error)) return String(error);
321+
const cause = (error as Error & { cause?: unknown }).cause;
322+
if (cause instanceof Error) {
323+
return `${error.message}: ${cause.message}`;
324+
}
325+
if (cause && typeof cause === "object") {
326+
const detail = cause as {
327+
code?: unknown;
328+
errno?: unknown;
329+
syscall?: unknown;
330+
hostname?: unknown;
331+
address?: unknown;
332+
port?: unknown;
333+
};
334+
const parts = [
335+
detail.code,
336+
detail.errno,
337+
detail.syscall,
338+
detail.hostname,
339+
detail.address,
340+
detail.port,
341+
]
342+
.filter((value) => typeof value === "string" || typeof value === "number")
343+
.map(String);
344+
if (parts.length > 0) {
345+
return `${error.message}: ${parts.join(" ")}`;
346+
}
347+
}
348+
return error.message;
349+
}
350+
249351
export {
250352
buildRemoteCapabilityEndpointTrustPolicy as buildEndpointTrustPolicy,
251353
installRemoteCapabilityEndpoint,

0 commit comments

Comments
 (0)