refactor(gateway): split worker environment service by ownership (#122138)

* refactor(gateway): split worker environment service by ownership

* refactor(gateway): derive environment intent at the contract owner

* chore(lint): ratchet max-lines baseline after worker-service split

* style(gateway): prune unused symbols after worker-service split

* refactor(gateway): narrow worker-service seam exports

* chore(plugin-sdk): refresh API baseline
This commit is contained in:
Peter Steinberger
2026-08-11 11:48:28 -07:00
committed by GitHub
parent 5179e12352
commit 2d6a5d7356
36 changed files with 5582 additions and 4977 deletions
-2
View File
@@ -732,8 +732,6 @@ src/gateway/tools-invoke-http.test.ts
src/gateway/watch-node-http.ts
src/gateway/worker-environments/bootstrap.ts
src/gateway/worker-environments/inference-runtime.ts
src/gateway/worker-environments/service.test.ts
src/gateway/worker-environments/service.ts
src/gateway/worker-environments/store.ts
src/gateway/worker-environments/worker-turn-launcher.test.ts
src/hooks/install.ts
@@ -1 +1 @@
{"contentHash":"66cc8d088b99fc543585ebc69c2149ec31cde693d580926faeeb56a756002ce0","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
{"contentHash":"935472a542772d2a003ced8e4ff4b21082d4a7ceb87162605eb3f081f3134c9e","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"98f0954479848e96da80f4fe4dec84580ea02eb3c99c4bfb8b8650f560852b8a","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
{"contentHash":"3012ea8e6338549d7523326c5cf006e0cf6bcaef8cf0cfbd3b36001d18a00a79","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"f4818fe0f464f65b8c564d6d14ef0867c7ad933a888827acf97944c18789a15c","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
{"contentHash":"dee0293611efcd65fa6ada3b091a8cd4cc4f9fc08322ee09f66b86d06c7c0da7","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
@@ -1 +1 @@
{"contentHash":"c704b85a419f1d4cb581c4a17e8d30ad2893cd1e177f46245c33e8b83be70edf","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
{"contentHash":"db8f468e891f92ca526beea61a7d31aa4ec3c274869418a97f107e880b3b0d43","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
@@ -1 +1 @@
{"contentHash":"0d442cf852cb35129f2eccfee5c4031946404fc691bb3690b7aa54dd4c7d550d","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
{"contentHash":"76a2a221a6a6f2295037d6ee58cac1d82adac001c6dc0467724c41ca1487d555","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
@@ -1 +1 @@
{"contentHash":"3a937d8039d8ebe37f71b7fce57251adc0e3eb9c5aa6b138cd695a39350b1991","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
{"contentHash":"a288e638fab5e190ba81e2312d52a2fb4fbbfe09a764ce8ae71aead70c1a1f22","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
@@ -1 +1 @@
{"contentHash":"3965e6960af8ac5f36f2132c218b5c38e4aa5274c96bc2e659ec2929d137beca","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
{"contentHash":"fc7650d41aa0ee4feeb7d4a8c624f00666e5a280c0e400c2637dc4c90639f406","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"4d030c8d32ee935043063a15692eba9e15020182144f9770a76b520351d53b9e","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
{"contentHash":"b7ef17f9a6a055f6ccc6082ecfad2b3e14e54eb2538d8bcae2b14d48c38efc02","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"a3d10effa80491699a93aabbc6cb3ac404913fde87b52a9566f1360a679a4e03","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
{"contentHash":"13b35ecb4af318bc7688c39b790fa90c145886358e3256cfe24a70412a20e70f","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
@@ -1 +1 @@
{"contentHash":"31e609e0fda8b6561856f788419ca055659a19fbe724397d8e12510408339ca9","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
{"contentHash":"bc04e864df9f24ba9d6436e27030679e2e8ad0143e6f9f4012118c6526a43395","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
@@ -1 +1 @@
{"contentHash":"65291a0f9266f239812fd732535c0e63f21eb723527e6e930398067b85283607","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
{"contentHash":"b4e87ccdd939708f75d8b5807a7faf341ae1be06300cd6ad26b79173d1ba17a6","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"d929e7a1c19c0403bb260610c881e9a98b3ce7b307729d3c40f72b16476a06c9","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
{"contentHash":"83527106b0a04b3f7b812c47001498bd005fd9911f8dd0b97acd35d4d7f61f1f","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"eef16bc3eca8d5d9cea265e4b09b3cb0a78b08d5238cea9e3df1c6901b46ad37","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
{"contentHash":"e326d4d2b59155856361367d5aadebf7962baaebe8ef91155cedb636e0871f92","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
@@ -1 +1 @@
{"contentHash":"70229ab9b920b19dd7715d54418294f4071a870d23325b9e6e8623895cdaf5e5","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
{"contentHash":"f2d93ae0543dda1166f7f56c6a2f61910dd35ba4f539e4f31a89004020d04e18","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"c9e8af2c4fb225217736662fb3b82900523d3e7b1eba766cc1f96d9ef4d2279a","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
{"contentHash":"0fa1302f61d179c11329b49a6d7e1d242f8de7ab73c4e515667343b789a39e7d","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
@@ -1 +1 @@
{"contentHash":"d34db3cba2e1fd488996b6820ba6e422d17472cde8d4f8077704234e8e96d7d8","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
{"contentHash":"3d1a41189c7b6a7d0318c9d262737190bdfcf375a7cd12869f90ad906cb2cfcc","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
@@ -22,10 +22,10 @@ import { bootstrapWorker } from "./worker-environments/bootstrap.js";
import type { WorkerInstallationArtifact } from "./worker-environments/bundle.js";
import { createWorkerPlacementDispatchService } from "./worker-environments/placement-dispatch.js";
import { createWorkerSessionPlacementStore } from "./worker-environments/placement-store.js";
import { deriveEnvironmentIntent } from "./worker-environments/service-contract.js";
import {
createWorkerEnvironmentService,
type WorkerEnvironmentService,
workerEnvironmentIdForIdempotencyKey,
} from "./worker-environments/service.js";
import { createWorkerEnvironmentStore } from "./worker-environments/store.js";
import type { WorkerSshProcess, WorkerSshRunner } from "./worker-environments/tunnel-ssh-runner.js";
@@ -40,7 +40,7 @@ const FALLBACK_PORT = 22;
const SESSION_ID = "session-original-order";
const SESSION_KEY = "agent:main:original-order";
const PROFILE_ID = "development";
const ENVIRONMENT_ID = workerEnvironmentIdForIdempotencyKey(`session-dispatch:${SESSION_ID}:1`);
const ENVIRONMENT_ID = deriveEnvironmentIntent(`session-dispatch:${SESSION_ID}:1`).environmentId;
const BUNDLE_HASH = "a".repeat(64);
const RECEIPT = {
bundleHash: BUNDLE_HASH,
@@ -0,0 +1,226 @@
import { describe, expect, it, vi } from "vitest";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
} from "../../state/openclaw-state-db.js";
import * as support from "./service.test-support.js";
import { createWorkerEnvironmentStore } from "./store.js";
import type { WorkerTunnelManager } from "./tunnel.js";
describe("worker environment service", () => {
support.setupWorkerEnvironmentServiceSuite();
it("repairs duplicate session owners", async () => {
const sessionId = "legacy";
const older = support.seedAttachedIdentity("legacy-a", sessionId);
const newer = support.seedAttachedIdentity("legacy-b", "other");
support.testState.stateDb.db.exec(`
UPDATE worker_environments SET attached_session_ids_json = '["legacy"]'
WHERE environment_id = 'legacy-b';
UPDATE worker_environment_credentials SET session_id = 'legacy'
WHERE environment_id = 'legacy-b';
`);
closeOpenClawStateDatabaseForTest();
support.testState.stateDb = openOpenClawStateDatabase({
env: { OPENCLAW_STATE_DIR: support.testState.root },
});
support.testState.store = createWorkerEnvironmentStore({
database: support.testState.stateDb,
now: () => support.testState.nowMs,
});
const liveEvents = support.createLiveEvents();
const workerService = support.createService(support.createProvider(), { liveEvents });
const event = { ...support.LIVE_EVENT, runEpoch: newer.ownerEpoch };
await expect(workerService.pushLiveEvent(older, event)).resolves.toEqual({
ok: false,
closeReason: "credential-replaced",
});
await workerService.pushLiveEvent({ ...newer, sessionId }, event);
expect(liveEvents.apply).toHaveBeenCalledOnce();
});
it("rejects attach before current bootstrap", async () => {
const staleId = "worker-stale-attach";
const bootstrapping = support.seedBootstrapping(staleId);
support.testState.store.transition({
environmentId: staleId,
from: bootstrapping.state,
to: "ready",
patch: support.readyPatch(staleId, {
...support.BOOTSTRAP_RECEIPT,
bundleHash: "c".repeat(64),
}),
});
const workerService = support.createService(support.createProvider());
await expect(
workerService.attachSession({
environmentId: staleId,
ownerEpoch: 1,
sessionId: "session-1",
}),
).rejects.toThrow("must bootstrap the current build");
expect(support.testState.store.get(staleId)).toMatchObject({
state: "ready",
attachedSessionIds: [],
});
});
it("returns a bounded error when another worker owns the session", async () => {
const firstId = "worker-session-owner";
const secondId = "worker-session-contender";
support.seedReady(firstId);
support.seedReady(secondId);
const workerService = support.createService(support.createProvider());
await workerService.attachSession({
environmentId: firstId,
ownerEpoch: 1,
sessionId: "session-owned",
});
await expect(
workerService.attachSession({
environmentId: secondId,
ownerEpoch: 1,
sessionId: "session-owned",
}),
).rejects.toMatchObject({
code: "invalid_state",
message:
"Session session-owned is already attached to worker environment worker-session-owner",
});
expect(support.testState.store.get(secondId)).toMatchObject({
state: "ready",
attachedSessionIds: [],
});
});
it("requires session reclaim before operator destruction of an attached worker", async () => {
const environmentId = "worker-session-reclaim";
support.seedReady(environmentId);
const workerService = support.createService(support.createProvider());
await workerService.attachSession({
environmentId,
ownerEpoch: 1,
sessionId: "session-reclaim",
});
await expect(workerService.destroyUnattached(environmentId)).rejects.toMatchObject({
code: "invalid_state",
message: "Attached cloud workers must be stopped through sessions.reclaim",
});
expect(support.testState.store.get(environmentId)).toMatchObject({
state: "attached",
attachedSessionIds: ["session-reclaim"],
});
});
it("stops the tunnel after live binding rollback", async () => {
const environmentId = "live-bind-fail";
support.seedReady(environmentId);
const liveEvents = support.createLiveEvents({
bindSession: vi.fn(() => {
throw new Error("bind failed");
}),
});
const tunnelManager = {
stop: vi.fn(async () => {}),
stopAll: vi.fn(async () => {}),
} as unknown as WorkerTunnelManager;
const workerService = support.createService(support.createProvider(), {
liveEvents,
tunnelManager,
});
await expect(
workerService.attachSession({ environmentId, ownerEpoch: 1, sessionId: "session-live" }),
).rejects.toThrow("Attached session target is unavailable");
expect(tunnelManager.stop).toHaveBeenCalledWith(environmentId, 1);
expect(support.testState.store.get(environmentId)).toMatchObject({
state: "idle",
attachedSessionIds: [],
});
});
it("renews in place and binds delivery acknowledgement to the exact grant", async () => {
const environmentId = "worker-credential-replacement";
support.seedReady(environmentId);
let credentialSequence = 0;
const workerService = support.createService(support.createProvider(), {
generateWorkerCredential: () => [support.CREDENTIAL, String(++credentialSequence)].join("-"),
workerCredentialTtlMs: 100,
});
const binding = { environmentId, ownerEpoch: 1, sessionId: null };
await workerService.reconcileOnce();
const previous = workerService.takeMintedCredential(binding)!;
support.testState.nowMs += 100;
expect(workerService.takeMintedCredential(binding)).toBeUndefined();
await workerService.reconcileOnce();
const renewal = workerService.takeMintedCredential(binding)!;
expect(renewal).toMatchObject({ ownerEpoch: 1, sessionId: null });
expect(support.testState.store.get(environmentId)?.ownerEpoch).toBe(1);
expect(workerService.acknowledgeCredentialDelivery(previous)).toBe(false);
expect(workerService.takeMintedCredential(binding)).toMatchObject({
deliveryId: renewal.deliveryId,
});
expect(workerService.acknowledgeCredentialDelivery(renewal)).toBe(true);
});
it("recovers an undelivered atomic session credential after restart without changing owner", async () => {
const environmentId = "worker-attach-restart";
support.seedReady(environmentId);
let credentialSequence = 0;
const stopTunnel = vi.fn(async () => {
throw new Error("tunnel stop interrupted");
});
const tunnelManager = {
start: vi.fn(),
stop: stopTunnel,
stopAll: vi.fn(async () => {}),
status: () => "stopped" as const,
desktop: {
acquire: vi.fn(),
attachObserver: vi.fn(),
stop: vi.fn(async () => {}),
stopAll: vi.fn(async () => {}),
},
} as unknown as WorkerTunnelManager;
const options = {
generateWorkerCredential: () => [support.CREDENTIAL, String(++credentialSequence)].join("-"),
tunnelManager,
};
const first = support.createService(support.createProvider(), options);
await first.reconcileOnce();
await expect(
first.attachSession({ environmentId, ownerEpoch: 1, sessionId: "session-1" }),
).rejects.toThrow("tunnel stop interrupted");
const binding = { environmentId, ownerEpoch: 2, sessionId: "session-1" };
const lostHash = support.testState.store.getCredential(environmentId)?.credentialHash;
expect(stopTunnel).toHaveBeenCalledWith(environmentId, 1);
expect(support.testState.store.get(environmentId)).toMatchObject({
state: "attached",
ownerEpoch: 2,
});
expect(first.takeMintedCredential(binding)).toBeUndefined();
await first.stop();
const restarted = support.createService(support.createProvider(), options);
await restarted.reconcileOnce();
const recovered = restarted.takeMintedCredential(binding);
expect(recovered?.deliveryId).not.toBe(lostHash);
expect(restarted.acknowledgeCredentialDelivery(recovered!)).toBe(true);
const deliveredHash = support.testState.store.getCredential(environmentId)?.credentialHash;
await restarted.stop();
const deliveredRestart = support.createService(support.createProvider(), options);
await deliveredRestart.reconcileOnce();
expect(deliveredRestart.takeMintedCredential(binding)).toBeUndefined();
expect(support.testState.store.getCredential(environmentId)?.credentialHash).toBe(
deliveredHash,
);
});
});
@@ -0,0 +1,351 @@
import { WORKER_RPC_SET_VERSION } from "../../../packages/gateway-protocol/src/schema/worker-admission.js";
import { verifyWorkerAdmissionHandshake } from "./admission.js";
import type { WorkerInstallationArtifact } from "./bundle.js";
import {
createWorkerCredentialMaterial,
WORKER_CREDENTIAL_TTL_MS,
type MintedWorkerCredential,
type WorkerCredentialBinding,
type WorkerCredentialDeliveryClaim,
} from "./credential.js";
import type { WorkerLiveEventReceiver } from "./live-events.js";
import type { WorkerSessionPlacementGate } from "./placement-worker-gate.js";
import type { WorkerEnvironmentState } from "./state.js";
import {
type WorkerEnvironmentRecord,
type WorkerEnvironmentStore,
WorkerSessionAlreadyAttachedError,
} from "./store.js";
import type { WorkerTunnelManager } from "./tunnel.js";
type WorkerCredentialBrokerOptions = {
store: WorkerEnvironmentStore;
prepareInstallation: (
install: WorkerInstallationArtifact["install"],
) => Promise<WorkerInstallationArtifact>;
tunnelManager?: WorkerTunnelManager;
workerCredentialTtlMs?: number;
generateWorkerCredential?: (bytes: number) => string;
liveEvents?: Pick<WorkerLiveEventReceiver, "bindSession" | "rotateCredential">;
placementStore?: WorkerSessionPlacementGate;
now: () => number;
isStopping: () => boolean;
cancelInferenceEnvironment: (environmentId: string) => void;
inState: (record: WorkerEnvironmentRecord, ...states: WorkerEnvironmentState[]) => boolean;
move: (
record: WorkerEnvironmentRecord,
to: WorkerEnvironmentState,
patch?: Parameters<WorkerEnvironmentStore["transition"]>[0]["patch"],
) => WorkerEnvironmentRecord;
serviceError: (code: "environment_not_found" | "invalid_state", message: string) => Error;
withLock: <T>(environmentId: string, task: () => Promise<T>) => Promise<T>;
};
export function createWorkerCredentialBroker(options: WorkerCredentialBrokerOptions) {
const { store } = options;
const tunnels = options.tunnelManager;
const now = options.now;
const inference = { cancelEnvironment: options.cancelInferenceEnvironment };
const inState = options.inState;
const move = options.move;
const serviceError = options.serviceError;
const withLock = options.withLock;
const pendingCredentials = new Map<string, MintedWorkerCredential>();
const credentialExpiry = () => {
const ttlMs = options.workerCredentialTtlMs ?? WORKER_CREDENTIAL_TTL_MS;
if (!Number.isSafeInteger(ttlMs) || ttlMs < 1) {
throw serviceError("invalid_state", "Worker credential lifetime is invalid");
}
const expiresAtMs = now() + ttlMs;
if (!Number.isSafeInteger(expiresAtMs)) {
throw serviceError("invalid_state", "Worker credential expiry is out of range");
}
return expiresAtMs;
};
const credentialMaterial = () => createWorkerCredentialMaterial(options.generateWorkerCredential);
const grantFrom = (params: {
credential: string;
record: ReturnType<WorkerEnvironmentStore["getCredential"]>;
}): MintedWorkerCredential => {
const record = params.record;
if (!record) {
throw serviceError("invalid_state", "Worker credential persistence failed");
}
return {
credential: params.credential,
deliveryId: record.credentialHash,
environmentId: record.environmentId,
bundleHash: record.bundleHash,
sessionId: record.sessionId,
rpcSetVersion: record.rpcSetVersion,
ownerEpoch: record.ownerEpoch,
expiresAtMs: record.expiresAtMs,
};
};
const mintCredentialLocked = (
request: WorkerCredentialBinding,
): { credentialHash: string; grant: MintedWorkerCredential } => {
const previous = store.getCredential(request.environmentId);
if (previous) {
inference.cancelEnvironment(request.environmentId);
}
const material = credentialMaterial();
const credential = {
environmentId: request.environmentId,
expectedOwnerEpoch: request.ownerEpoch,
credentialHash: material.credentialHash,
sessionId: request.sessionId,
rpcSetVersion: WORKER_RPC_SET_VERSION,
expiresAtMs: credentialExpiry(),
};
const record = store.renewCredential(credential);
return {
credentialHash: material.credentialHash,
grant: grantFrom({ credential: material.credential, record }),
};
};
const stageCredential = (grant: MintedWorkerCredential): MintedWorkerCredential => {
pendingCredentials.set(grant.environmentId, grant);
return grant;
};
const ensurePendingCredential = (record: WorkerEnvironmentRecord, sessionId: string | null) => {
const credential = store.getCredential(record.environmentId);
const pending = pendingCredentials.get(record.environmentId);
const credentialHasDurableTurn =
credential?.deliveredAtMs !== null &&
credential?.ownerEpoch === record.ownerEpoch &&
credential.sessionId === sessionId &&
sessionId !== null &&
options.placementStore?.hasWorkerTurn({
sessionId,
environmentId: record.environmentId,
ownerEpoch: record.ownerEpoch,
}) === true;
const credentialIsCurrent =
credential?.ownerEpoch === record.ownerEpoch &&
credential.sessionId === sessionId &&
(credential.expiresAtMs > now() || credentialHasDurableTurn);
const pendingIsCurrent =
credentialIsCurrent &&
pending?.deliveryId === credential.credentialHash &&
pending.ownerEpoch === record.ownerEpoch &&
pending.sessionId === sessionId;
if (credentialIsCurrent && credential.deliveredAtMs !== null) {
pendingCredentials.delete(record.environmentId);
return;
}
if (pendingIsCurrent) {
return;
}
pendingCredentials.delete(record.environmentId);
const minted = mintCredentialLocked({
environmentId: record.environmentId,
ownerEpoch: record.ownerEpoch,
sessionId,
});
stageCredential(minted.grant);
if (sessionId && credential?.ownerEpoch === record.ownerEpoch) {
options.liveEvents?.rotateCredential({
credentialHash: minted.credentialHash,
environmentId: record.environmentId,
previousCredentialHash: credential.credentialHash,
runEpoch: record.ownerEpoch,
sessionId,
});
}
};
const attachSession = async (
request: WorkerCredentialBinding & { sessionId: string },
): Promise<MintedWorkerCredential> => {
let stopping = options.isStopping();
if (stopping) {
throw serviceError("invalid_state", "Worker environment service is stopping");
}
return withLock(request.environmentId, async () => {
stopping = options.isStopping();
if (stopping) {
throw serviceError("invalid_state", "Worker environment service is stopping");
}
const current = store.get(request.environmentId);
if (!current) {
throw serviceError(
"environment_not_found",
`Unknown worker environment: ${request.environmentId}`,
);
}
if (current.state !== "ready" && current.state !== "idle") {
throw serviceError("invalid_state", `Cannot attach worker in state: ${current.state}`);
}
let currentBuild: WorkerInstallationArtifact;
try {
currentBuild = await options.prepareInstallation("bundle");
} catch {
throw serviceError("invalid_state", "Current worker build identity is unavailable");
}
if (
!current.bootstrapReceipt ||
!verifyWorkerAdmissionHandshake(current.bootstrapReceipt, currentBuild)
) {
throw serviceError(
"invalid_state",
"Worker must bootstrap the current build before attach",
);
}
const material = credentialMaterial();
let attached: WorkerEnvironmentRecord;
try {
attached = store.transition({
environmentId: request.environmentId,
from: current.state,
to: "attached",
expectedOwnerEpoch: request.ownerEpoch,
patch: {
attachedSessionIds: [request.sessionId],
credential: {
credentialHash: material.credentialHash,
sessionId: request.sessionId,
rpcSetVersion: WORKER_RPC_SET_VERSION,
expiresAtMs: credentialExpiry(),
},
},
});
} catch (error) {
if (error instanceof WorkerSessionAlreadyAttachedError) {
throw serviceError("invalid_state", error.message);
}
throw error;
}
if (options.liveEvents) {
let liveSessionBound: boolean;
try {
liveSessionBound = options.liveEvents.bindSession({
environmentId: attached.environmentId,
runEpoch: attached.ownerEpoch,
sessionId: request.sessionId,
});
} catch {
liveSessionBound = false;
}
if (!liveSessionBound) {
move(attached, "idle");
// Preserve the bounded attachment error after rollback fences the old worker.
await tunnels?.stop(request.environmentId, current.ownerEpoch).catch(() => undefined);
throw serviceError("invalid_state", "Attached session target is unavailable");
}
}
pendingCredentials.delete(request.environmentId);
await tunnels?.stop(request.environmentId, current.ownerEpoch);
return stageCredential(
grantFrom({
credential: material.credential,
record: store.getCredential(request.environmentId),
}),
);
});
};
const readPendingCredential = (binding: WorkerCredentialBinding) => {
const stopping = options.isStopping();
if (stopping) {
return undefined;
}
const grant = pendingCredentials.get(binding.environmentId);
if (
!grant ||
grant.ownerEpoch !== binding.ownerEpoch ||
grant.sessionId !== binding.sessionId
) {
return undefined;
}
const environment = store.get(binding.environmentId);
const credential = store.getCredential(binding.environmentId);
const credentialHash = grant.deliveryId;
const checkedAtMs = now();
if (
!environment ||
!inState(environment, "ready", "idle", "attached") ||
environment.destroyRequestedAtMs !== null ||
environment.ownerEpoch !== binding.ownerEpoch ||
!credential ||
credential.credentialHash !== credentialHash ||
credential.ownerEpoch !== binding.ownerEpoch ||
credential.sessionId !== binding.sessionId ||
credential.deliveredAtMs !== null ||
credential.expiresAtMs <= checkedAtMs
) {
return undefined;
}
return { checkedAtMs, credentialHash, grant };
};
const acquireTurnCredential = (binding: WorkerCredentialBinding & { sessionId: string }) =>
withLock(binding.environmentId, async () => {
const pending = readPendingCredential(binding)?.grant;
if (pending) {
return pending;
}
const environment = store.get(binding.environmentId);
if (
!environment ||
environment.state !== "attached" ||
environment.ownerEpoch !== binding.ownerEpoch ||
environment.attachedSessionIds.length !== 1 ||
environment.attachedSessionIds[0] !== binding.sessionId
) {
throw serviceError("invalid_state", "Worker session credential owner is not attached");
}
const previous = store.getCredential(binding.environmentId);
const minted = mintCredentialLocked(binding);
const grant = stageCredential(minted.grant);
if (previous?.sessionId === binding.sessionId) {
options.liveEvents?.rotateCredential({
credentialHash: minted.credentialHash,
environmentId: binding.environmentId,
newProcessTurn: true,
previousCredentialHash: previous.credentialHash,
runEpoch: binding.ownerEpoch,
sessionId: binding.sessionId,
});
}
return grant;
});
const acknowledgeCredentialDelivery = (claim: WorkerCredentialDeliveryClaim): boolean => {
const pending = readPendingCredential(claim);
if (!pending || pending.grant.deliveryId !== claim.deliveryId) {
return false;
}
store.markCredentialDelivered({
...claim,
credentialHash: pending.credentialHash,
deliveredAtMs: pending.checkedAtMs,
});
pendingCredentials.delete(claim.environmentId);
return true;
};
return {
acknowledgeCredentialDelivery,
acquireTurnCredential,
attachSession,
clear: () => pendingCredentials.clear(),
clearEnvironment: (environmentId: string) => pendingCredentials.delete(environmentId),
credentialExpiry,
credentialMaterial,
ensurePendingCredential,
grantFrom,
readPendingCredential,
stageCredential,
takeMintedCredential: (binding: WorkerCredentialBinding) =>
readPendingCredential(binding)?.grant,
};
}
export type WorkerCredentialBroker = ReturnType<typeof createWorkerCredentialBroker>;
@@ -0,0 +1,440 @@
import { describe, expect, it, vi } from "vitest";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
} from "../../state/openclaw-state-db.js";
import * as support from "./service.test-support.js";
import { createWorkerEnvironmentStore } from "./store.js";
import type { WorkerTunnelManager } from "./tunnel.js";
type WorkerEnvironmentServiceError = support.WorkerEnvironmentServiceError;
describe("worker environment service", () => {
support.setupWorkerEnvironmentServiceSuite();
it("projects live tunnel status and fences the tunnel before provider teardown", async () => {
support.seedReady("worker-tunnel", undefined, true);
const order: string[] = [];
let tunnelStatus: "stopped" | "connected" = "stopped";
const tunnelManager = {
status: () => tunnelStatus,
start: vi.fn(async (request) => {
tunnelStatus = "connected";
return {
environmentId: request.environmentId,
ownerEpoch: request.ownerEpoch,
remoteSocketPath: "/tmp/worker/gateway.sock",
runWorkspaceCommand: vi.fn(),
syncWorkspace: vi.fn(),
stop: async () => {},
};
}),
stop: vi.fn(async () => {
tunnelStatus = "stopped";
order.push("tunnel-stop");
}),
stopAll: vi.fn(async () => {}),
} as unknown as WorkerTunnelManager;
const provider = support.createProvider({
destroy: async () => {
order.push("provider-destroy");
},
});
const workerService = support.createService(provider, { tunnelManager });
await expect(
workerService.startTunnel({ environmentId: "worker-tunnel", ownerEpoch: 0 }),
).rejects.toThrow("owner credential is not current");
expect(tunnelManager.start).not.toHaveBeenCalled();
await workerService.startTunnel({
environmentId: "worker-tunnel",
ownerEpoch: 1,
});
expect(tunnelManager.start).toHaveBeenCalledWith(
expect.objectContaining({
bundleHash: support.BUNDLE_HASH,
gateway: { host: "127.0.0.1", port: 18_789 },
sharedHost: true,
}),
);
expect(workerService.get("worker-tunnel")).toMatchObject({ tunnelStatus: "connected" });
await workerService.destroy("worker-tunnel");
expect(order).toEqual(["tunnel-stop", "provider-destroy"]);
expect(workerService.get("worker-tunnel")).toMatchObject({
state: "destroyed",
tunnelStatus: "stopped",
});
});
it("reconciles shared-host isolation for a persisted lease before tunnel startup", async () => {
support.seedReady("worker-legacy-shared");
support.testState.stateDb.db
.prepare("UPDATE worker_environments SET shared_host = NULL WHERE environment_id = ?")
.run("worker-legacy-shared");
closeOpenClawStateDatabaseForTest();
support.testState.stateDb = openOpenClawStateDatabase({
env: { OPENCLAW_STATE_DIR: support.testState.root },
});
support.testState.store = createWorkerEnvironmentStore({
database: support.testState.stateDb,
now: () => support.testState.nowMs,
});
const tunnelManager = {
status: () => "stopped" as const,
start: vi.fn(async (request: Parameters<WorkerTunnelManager["start"]>[0]) => ({
environmentId: request.environmentId,
ownerEpoch: request.ownerEpoch,
remoteSocketPath: "/tmp/worker/gateway.sock",
runWorkspaceCommand: vi.fn(),
syncWorkspace: vi.fn(),
stop: async () => {},
})),
stop: vi.fn(async () => {}),
stopAll: vi.fn(async () => {}),
} as unknown as WorkerTunnelManager;
let inspectionFails = true;
const provider = support.createProvider({
inspect: async () => {
if (inspectionFails) {
throw new Error("provider unavailable");
}
return { status: "active", sharedHost: true };
},
});
const workerService = support.createService(provider, { tunnelManager });
expect(support.testState.store.get("worker-legacy-shared")?.sharedHost).toBeNull();
await workerService.reconcileOnce();
await expect(
workerService.startTunnel({ environmentId: "worker-legacy-shared", ownerEpoch: 1 }),
).rejects.toThrow("isolation is not reconciled");
expect(tunnelManager.start).not.toHaveBeenCalled();
inspectionFails = false;
await workerService.reconcileOnce();
expect(support.testState.store.get("worker-legacy-shared")?.sharedHost).toBe(true);
await workerService.startTunnel({ environmentId: "worker-legacy-shared", ownerEpoch: 1 });
expect(tunnelManager.start).toHaveBeenCalledWith(expect.objectContaining({ sharedHost: true }));
});
it("fences an existing tunnel before changing its shared-host isolation", async () => {
support.seedReady("worker-isolation-change");
const stop = vi.fn(async () => {
expect(support.testState.store.get("worker-isolation-change")?.sharedHost).toBe(false);
});
const tunnelManager = {
status: () => "connected" as const,
start: vi.fn(),
stop,
stopAll: vi.fn(async () => {}),
} as unknown as WorkerTunnelManager;
const provider = support.createProvider({
inspect: async () => ({ status: "active", sharedHost: true }),
});
await support.createService(provider, { tunnelManager }).reconcileOnce();
expect(stop).toHaveBeenCalledWith("worker-isolation-change");
expect(support.testState.store.get("worker-isolation-change")?.sharedHost).toBe(true);
});
it("projects desktop availability only while a desktop lease is observable", () => {
const ready = support.seedReadyDesktop("worker-desktop-projection");
const workerService = support.createService(support.createProvider());
expect(workerService.get(ready.environmentId)).toMatchObject({
desktopAvailable: true,
desktopApps: ["browser", "terminal"],
});
support.testState.store.transition({
environmentId: ready.environmentId,
from: ready.state,
to: "draining",
});
expect(workerService.get(ready.environmentId)).toMatchObject({
desktopAvailable: false,
desktopApps: [],
});
});
it("launches only an advertised desktop app through the pinned SSH runtime", async () => {
const record = support.seedReadyDesktop("worker-desktop-launch");
const launchApp = vi.fn(async () => {});
const tunnelManager = {
desktop: {
acquire: vi.fn(),
attachObserver: vi.fn(),
launchApp,
stop: vi.fn(async () => {}),
stopAll: vi.fn(async () => {}),
},
status: () => "stopped" as const,
start: vi.fn(),
stop: vi.fn(async () => {}),
stopAll: vi.fn(async () => {}),
} as unknown as WorkerTunnelManager;
const workerService = support.createService(support.createProvider(), { tunnelManager });
await expect(
workerService.launchDesktopApp({ environmentId: record.environmentId, app: "browser" }),
).resolves.toEqual({ app: "browser", status: "ready" });
expect(launchApp).toHaveBeenCalledExactlyOnceWith({
environmentId: record.environmentId,
ownerEpoch: record.ownerEpoch,
ssh: support.SSH_ENDPOINT,
app: support.DESKTOP.apps?.[0],
resolveIdentity: expect.any(Function),
});
});
it("rejects missing desktop apps and maps launcher runtime failures to typed errors", async () => {
const record = support.seedReadyDesktop("worker-desktop-launch-errors");
const launchApp = vi.fn(async () => {
throw new Error("private SSH launcher detail");
});
const tunnelManager = {
desktop: {
acquire: vi.fn(),
attachObserver: vi.fn(),
launchApp,
stop: vi.fn(async () => {}),
stopAll: vi.fn(async () => {}),
},
status: () => "stopped" as const,
start: vi.fn(),
stop: vi.fn(async () => {}),
stopAll: vi.fn(async () => {}),
} as unknown as WorkerTunnelManager;
const workerService = support.createService(support.createProvider(), { tunnelManager });
await expect(
workerService.launchDesktopApp({ environmentId: record.environmentId, app: "browser" }),
).rejects.toMatchObject({
code: "launcher_failure",
message: "worker desktop browser launcher failed; verify the app is installed and retry",
});
support.testState.store.transition({
environmentId: record.environmentId,
from: record.state,
to: "draining",
});
await expect(
workerService.launchDesktopApp({ environmentId: record.environmentId, app: "terminal" }),
).rejects.toMatchObject({ code: "invalid_state" });
const browserOnly = support.seedReadyDesktop("worker-desktop-browser-only", {
...support.DESKTOP,
apps: [support.DESKTOP.apps![0]!],
});
await expect(
workerService.launchDesktopApp({
environmentId: browserOnly.environmentId,
app: "terminal",
}),
).rejects.toMatchObject({
code: "desktop_app_not_found",
message: "environment does not advertise desktop app: terminal",
});
});
it("acquires a desktop tunnel and mints a one-shot websocket path", async () => {
const record = support.seedReadyDesktop("worker-desktop-observe");
const desktopPassword = ["desktop", String.fromCharCode(45), "secret"].join("");
const acquire = vi.fn(async () => ({
localSocketPath: "/tmp/worker-desktop.sock",
vncPassword: desktopPassword,
}));
const tunnelManager = {
desktop: {
acquire,
attachObserver: vi.fn(),
stop: vi.fn(async () => {}),
stopAll: vi.fn(async () => {}),
},
status: () => "stopped" as const,
start: vi.fn(),
stop: vi.fn(async () => {}),
stopAll: vi.fn(async () => {}),
} as unknown as WorkerTunnelManager;
const workerService = support.createService(support.createProvider(), { tunnelManager });
await expect(
workerService.observeDesktop({ environmentId: record.environmentId, control: true }),
).resolves.toMatchObject({
transport: "rfb",
wsPath: expect.stringMatching(/^\/worker-desktop\/observe\?token=[a-f0-9]{48}$/u),
expiresAtMs: support.testState.nowMs + 60_000,
control: true,
vncPassword: desktopPassword,
});
expect(acquire).toHaveBeenCalledWith(
expect.objectContaining({
environmentId: record.environmentId,
ownerEpoch: record.ownerEpoch,
desktop: support.DESKTOP,
ssh: support.SSH_ENDPOINT,
resolveIdentity: expect.any(Function),
}),
);
});
it("rejects desktop observe for invalid lifecycle gates and a stopped service", async () => {
const tunnelManager = {
desktop: {
acquire: vi.fn(),
attachObserver: vi.fn(),
stop: vi.fn(async () => {}),
stopAll: vi.fn(async () => {}),
},
status: () => "stopped" as const,
start: vi.fn(),
stop: vi.fn(async () => {}),
stopAll: vi.fn(async () => {}),
} as unknown as WorkerTunnelManager;
const workerService = support.createService(support.createProvider(), { tunnelManager });
const requested = support.testState.store.createIntent({
environmentId: "worker-desktop-requested",
providerId: "fake",
profileId: "development",
profileSnapshot: { settings: { region: "test" } },
provisionOperationId: "provision:worker-desktop-requested",
});
support.seedReady("worker-desktop-missing");
const destroying = support.seedReadyDesktop("worker-desktop-destroy-requested");
support.testState.store.requestDestroy({
environmentId: destroying.environmentId,
state: destroying.state,
});
support.testState.config.cloudWorkers!.desktop = false;
await expect(
workerService.observeDesktop({ environmentId: requested.environmentId, control: false }),
).rejects.toMatchObject({
code: "invalid_state",
message:
"worker desktop observe is disabled; enable the Desktop lab in Control UI Settings -> Labs (config: cloudWorkers.desktop)",
});
support.testState.config.cloudWorkers!.desktop = true;
for (const environmentId of [
requested.environmentId,
"worker-desktop-missing",
destroying.environmentId,
]) {
await expect(
workerService.observeDesktop({ environmentId, control: false }),
).rejects.toMatchObject({
code: "invalid_state",
message: "environment has no desktop; desktop is a warm-time capability of the profile",
});
}
await expect(
workerService.observeDesktop({ environmentId: "worker-desktop-unknown", control: false }),
).rejects.toMatchObject({ code: "environment_not_found" });
await workerService.stop();
await expect(
workerService.observeDesktop({ environmentId: destroying.environmentId, control: false }),
).rejects.toMatchObject({
code: "invalid_state",
message: "Worker environment service is stopping",
});
expect(tunnelManager.desktop.acquire).not.toHaveBeenCalled();
});
it("fences a draining tunnel before reporting an unavailable provider", async () => {
support.seedReady("worker-provider-missing");
const tunnelManager = {
status: () => "connected" as const,
start: vi.fn(),
stop: vi.fn(async () => {}),
stopAll: vi.fn(async () => {}),
} as unknown as WorkerTunnelManager;
const workerService = support.createService(support.createProvider(), { tunnelManager });
support.testState.providersEnabled = false;
await expect(workerService.destroy("worker-provider-missing")).rejects.toMatchObject({
code: "provider_not_found",
} satisfies Partial<WorkerEnvironmentServiceError>);
expect(tunnelManager.stop).toHaveBeenCalledWith("worker-provider-missing");
expect(support.testState.store.get("worker-provider-missing")).toMatchObject({
state: "draining",
destroyRequestedAtMs: expect.any(Number),
});
});
it("does not hold the environment lock while a tunnel is connecting", async () => {
support.seedReady("worker-tunnel-pending");
let rejectStart: ((error: Error) => void) | undefined;
const pendingStart = new Promise<never>((_resolve, reject) => {
rejectStart = reject;
});
const order: string[] = [];
const tunnelManager = {
status: () => "connecting" as const,
start: vi.fn(() => pendingStart),
stop: vi.fn(async () => {
order.push("tunnel-stop");
rejectStart?.(new Error("tunnel stopped"));
}),
stopAll: vi.fn(async () => {}),
} as unknown as WorkerTunnelManager;
const provider = support.createProvider({
destroy: async () => {
order.push("provider-destroy");
},
});
const workerService = support.createService(provider, { tunnelManager });
const starting = workerService.startTunnel({
environmentId: "worker-tunnel-pending",
ownerEpoch: 1,
});
const rejectedStart = expect(starting).rejects.toThrow("tunnel stopped");
await support.waitForFast(() => expect(tunnelManager.start).toHaveBeenCalledOnce());
await workerService.destroy("worker-tunnel-pending");
await rejectedStart;
expect(order).toEqual(["tunnel-stop", "provider-destroy"]);
});
it("stops a poisoned tunnel start and returns a typed deadline error", async () => {
vi.useFakeTimers();
support.seedReady("worker-tunnel-timeout");
let signalStarted!: () => void;
const started = new Promise<void>((resolve) => {
signalStarted = resolve;
});
let rejectStart!: (error: Error) => void;
const pendingStart = new Promise<never>((_resolve, reject) => {
rejectStart = reject;
});
const tunnelManager = {
status: () => "connecting" as const,
start: vi.fn(() => {
signalStarted();
return pendingStart;
}),
stop: vi.fn(async () => {
rejectStart(new Error("tunnel stopped"));
}),
stopAll: vi.fn(async () => {}),
} as unknown as WorkerTunnelManager;
const workerService = support.createService(support.createProvider(), { tunnelManager });
const starting = workerService.startTunnel({
environmentId: "worker-tunnel-timeout",
ownerEpoch: 1,
});
const rejected = expect(starting).rejects.toMatchObject({
code: "provider_failure",
message: expect.stringContaining("did not connect within 3 minutes"),
} satisfies Partial<WorkerEnvironmentServiceError>);
await started;
await vi.advanceTimersByTimeAsync(3 * 60_000);
await rejected;
expect(tunnelManager.stop).toHaveBeenCalledWith("worker-tunnel-timeout", 1);
});
});
@@ -0,0 +1,347 @@
import type { OpenClawConfig } from "../../config/types.js";
import { withTimeout } from "../../infra/fs-safe.js";
import type { WorkerProvider } from "../../plugins/types.js";
import type { WorkerDesktopLaunchResult, WorkerDesktopObserveResult } from "./service-contract.js";
import type { WorkerEnvironmentState } from "./state.js";
import type { WorkerEnvironmentRecord, WorkerEnvironmentStore } from "./store.js";
import type { WorkerTunnelRequest } from "./tunnel-contract.js";
import type { WorkerTunnelHandle, WorkerTunnelManager } from "./tunnel.js";
import { boundedWorkerError as boundedError } from "./worker-error.js";
const TUNNEL_START_TIMEOUT_MS = 3 * 60_000;
type WorkerEnvironmentAccessOptions = {
store: WorkerEnvironmentStore;
getConfig: () => OpenClawConfig;
tunnelManager?: WorkerTunnelManager;
resolveWorkerGateway?: () => { host: "127.0.0.1" | "::1"; port: number } | undefined;
now: () => number;
identityResolverFor: (
record: WorkerEnvironmentRecord,
provider: WorkerProvider,
leaseId: string,
) => Parameters<WorkerTunnelManager["start"]>[0]["resolveIdentity"];
inState: (record: WorkerEnvironmentRecord, ...states: WorkerEnvironmentState[]) => boolean;
isStopping: () => boolean;
providerFor: (providerId: string) => WorkerProvider;
serviceError: (
code:
| "desktop_app_not_found"
| "environment_not_found"
| "invalid_state"
| "launcher_failure"
| "provider_failure"
| "unsupported_platform",
message: string,
) => Error;
withLock: <T>(environmentId: string, task: () => Promise<T>) => Promise<T>;
};
export function createWorkerEnvironmentAccess(options: WorkerEnvironmentAccessOptions) {
const { store } = options;
const tunnels = options.tunnelManager;
const now = options.now;
const inState = options.inState;
const providerFor = options.providerFor;
const identityResolverFor = options.identityResolverFor;
const serviceError = options.serviceError;
const withLock = options.withLock;
const project = (record: WorkerEnvironmentRecord) => {
const desktopAvailable =
inState(record, "ready", "idle", "attached") && record.desktop !== null;
return {
...record,
...((record.state === "failed" || record.state === "orphaned") && record.lastError
? { error: boundedError(record.lastError) }
: {}),
desktopAvailable,
desktopApps: desktopAvailable
? (record.desktop?.apps?.map((app) => app.id).toSorted() ?? [])
: [],
tunnelStatus: tunnels?.status(record.environmentId) ?? ("stopped" as const),
};
};
const startTunnel = async (request: WorkerTunnelRequest): Promise<WorkerTunnelHandle> => {
let stopping = options.isStopping();
if (stopping) {
throw serviceError("invalid_state", "Worker environment service is stopping");
}
if (!tunnels) {
throw serviceError("invalid_state", "Worker tunnel runtime is unavailable");
}
let startup: Promise<WorkerTunnelHandle> | undefined;
await withLock(request.environmentId, async () => {
stopping = options.isStopping();
if (stopping) {
throw serviceError("invalid_state", "Worker environment service is stopping");
}
const record = store.get(request.environmentId);
if (!record) {
throw serviceError(
"environment_not_found",
`Unknown worker environment: ${request.environmentId}`,
);
}
if (
!inState(record, "ready", "idle", "attached") ||
record.destroyRequestedAtMs !== null ||
!record.leaseId ||
!record.sshEndpoint ||
!record.bootstrapReceipt
) {
throw serviceError("invalid_state", `Cannot start tunnel in state: ${record.state}`);
}
if (record.sharedHost === null) {
throw serviceError(
"provider_failure",
"Worker lease isolation is not reconciled; retry after provider inspection",
);
}
const credential = store.getCredential(request.environmentId);
if (
!credential ||
credential.ownerEpoch !== request.ownerEpoch ||
credential.expiresAtMs <= now()
) {
throw serviceError("invalid_state", "Worker tunnel owner credential is not current");
}
const gateway = options.resolveWorkerGateway?.();
if (!gateway) {
throw serviceError("invalid_state", "Worker gateway ingress is unavailable");
}
const provider = providerFor(record.providerId);
// Tunnel ownership is registered synchronously by the manager. Release the durable-state
// lock while SSH connects so drain/destroy can fence an indefinitely reconnecting start.
startup = tunnels.start({
...request,
bundleHash: record.bootstrapReceipt.bundleHash,
gateway,
ssh: record.sshEndpoint,
sharedHost: record.sharedHost,
resolveIdentity: identityResolverFor(record, provider, record.leaseId),
});
});
if (!startup) {
throw serviceError("invalid_state", "Worker tunnel failed to start");
}
const timeoutError = serviceError(
"provider_failure",
"Worker tunnel did not connect within 3 minutes; check worker SSH reachability and retry",
);
try {
return await withTimeout(startup, TUNNEL_START_TIMEOUT_MS, {
createError: () => timeoutError,
});
} catch (error) {
if (error !== timeoutError) {
throw error;
}
// Stop can itself block on an unkillable SSH child; detach it (rejection observed,
// entry stays manager-tracked) so the deadline error is returned on time. Epoch-fenced
// so a stale timed-out attempt can never tear down a newer owner's tunnel.
void tunnels.stop(request.environmentId, request.ownerEpoch).catch(() => undefined);
throw timeoutError;
}
};
const observeDesktop = async (request: {
environmentId: string;
control: boolean;
}): Promise<WorkerDesktopObserveResult> => {
let stopping = options.isStopping();
if (options.getConfig().cloudWorkers?.desktop !== true) {
throw serviceError(
"invalid_state",
"worker desktop observe is disabled; enable the Desktop lab in Control UI Settings -> Labs (config: cloudWorkers.desktop)",
);
}
if (stopping) {
throw serviceError("invalid_state", "Worker environment service is stopping");
}
if (!tunnels) {
throw serviceError("invalid_state", "Worker tunnel runtime is unavailable");
}
let startup: Promise<{ localSocketPath: string; vncPassword?: string }> | undefined;
let ownerEpoch: number | undefined;
await withLock(request.environmentId, async () => {
stopping = options.isStopping();
if (stopping) {
throw serviceError("invalid_state", "Worker environment service is stopping");
}
const record = store.get(request.environmentId);
if (!record) {
throw serviceError(
"environment_not_found",
`Unknown worker environment: ${request.environmentId}`,
);
}
if (
!inState(record, "ready", "idle", "attached") ||
record.destroyRequestedAtMs !== null ||
!record.leaseId ||
!record.sshEndpoint ||
!record.desktop
) {
throw serviceError(
"invalid_state",
"environment has no desktop; desktop is a warm-time capability of the profile",
);
}
const provider = providerFor(record.providerId);
ownerEpoch = record.ownerEpoch;
startup = tunnels.desktop.acquire({
environmentId: record.environmentId,
ownerEpoch: record.ownerEpoch,
ssh: record.sshEndpoint,
desktop: record.desktop,
resolveIdentity: identityResolverFor(record, provider, record.leaseId),
});
});
if (!startup || ownerEpoch === undefined) {
throw serviceError("invalid_state", "Worker desktop tunnel failed to start");
}
const acquired = await startup;
const { WORKER_DESKTOP_OBSERVE_PATH, mintWorkerDesktopObserverToken } =
await import("./desktop-observe.js");
const minted = mintWorkerDesktopObserverToken({
environmentId: request.environmentId,
ownerEpoch,
control: request.control,
localSocketPath: acquired.localSocketPath,
nowMs: now(),
});
return {
transport: "rfb",
wsPath: `${WORKER_DESKTOP_OBSERVE_PATH}?token=${minted.token}`,
expiresAtMs: minted.expiresAtMs,
control: request.control,
...(acquired.vncPassword ? { vncPassword: acquired.vncPassword } : {}),
};
};
const launchDesktopApp = async (request: {
environmentId: string;
app: "browser" | "terminal";
}): Promise<WorkerDesktopLaunchResult> => {
let stopping = options.isStopping();
if (options.getConfig().cloudWorkers?.desktop !== true) {
throw serviceError(
"invalid_state",
"worker desktop launch is disabled; enable the Desktop lab in Control UI Settings -> Labs (config: cloudWorkers.desktop)",
);
}
if (stopping) {
throw serviceError("invalid_state", "Worker environment service is stopping");
}
if (!tunnels) {
throw serviceError("invalid_state", "Worker tunnel runtime is unavailable");
}
const requireLaunchable = () => {
stopping = options.isStopping();
if (stopping) {
throw serviceError("invalid_state", "Worker environment service is stopping");
}
const record = store.get(request.environmentId);
if (!record) {
throw serviceError(
"environment_not_found",
`Unknown worker environment: ${request.environmentId}`,
);
}
if (
!inState(record, "ready", "idle", "attached") ||
record.destroyRequestedAtMs !== null ||
!record.leaseId ||
!record.sshEndpoint ||
!record.desktop
) {
throw serviceError(
"invalid_state",
"environment has no desktop; desktop is a warm-time capability of the profile",
);
}
const app = record.desktop.apps?.find((candidate) => candidate.id === request.app);
if (!app) {
throw serviceError(
"desktop_app_not_found",
`environment does not advertise desktop app: ${request.app}`,
);
}
return { app, record };
};
let startup: Promise<void> | undefined;
let launchEpoch: number | undefined;
await withLock(request.environmentId, async () => {
const { app, record } = requireLaunchable();
const provider = providerFor(record.providerId);
launchEpoch = record.ownerEpoch;
startup = tunnels.desktop.launchApp({
environmentId: record.environmentId,
ownerEpoch: record.ownerEpoch,
ssh: record.sshEndpoint,
app,
resolveIdentity: identityResolverFor(record, provider, record.leaseId),
});
});
if (!startup || launchEpoch === undefined) {
throw serviceError("launcher_failure", "Worker desktop app launcher failed to start");
}
try {
await startup;
} catch (error) {
if (
error &&
typeof error === "object" &&
"code" in error &&
error.code === "unsupported_platform"
) {
throw serviceError(
"unsupported_platform",
"desktop app launch is not supported on Windows gateway hosts",
);
}
// A teardown aborts the SSH child before mutating the durable row. Wait for the
// environment lock, then report the authoritative lifecycle state instead of a launch error.
await withLock(request.environmentId, async () => {
const { record } = requireLaunchable();
if (record.ownerEpoch !== launchEpoch) {
throw serviceError("invalid_state", "Worker desktop app launch owner changed");
}
});
throw serviceError(
"launcher_failure",
`worker desktop ${request.app} launcher failed; verify the app is installed and retry`,
);
}
await withLock(request.environmentId, async () => {
const { record } = requireLaunchable();
if (record.ownerEpoch !== launchEpoch) {
throw serviceError("invalid_state", "Worker desktop app launch owner changed");
}
});
return { app: request.app, status: "ready" };
};
const stopTunnel = async (environmentId: string, ownerEpoch?: number): Promise<void> => {
await withLock(environmentId, async () => {
await tunnels?.stop(environmentId, ownerEpoch);
});
};
return {
get: (environmentId: string) => {
const record = store.get(environmentId);
return record ? project(record) : undefined;
},
launchDesktopApp,
list: () => store.list().map(project),
observeDesktop,
project,
startTunnel,
stopAllTunnels: () => tunnels?.stopAll() ?? Promise.resolve(),
stopTunnel,
};
}
@@ -9,7 +9,7 @@ import {
createWorkerSessionPlacementStore,
type WorkerSessionPlacementRecord,
} from "./placement-store.js";
import { workerEnvironmentIdForIdempotencyKey } from "./service.js";
import { deriveEnvironmentIntent } from "./service-contract.js";
type WorkerDispatchRequest = Parameters<
ReturnType<typeof createWorkerPlacementDispatchService>["dispatch"]
@@ -90,9 +90,9 @@ export function seedActivePlacement(
}
export function createDispatchEnvironmentFixtures(generation = 1) {
const environmentId = workerEnvironmentIdForIdempotencyKey(
const environmentId = deriveEnvironmentIntent(
`session-dispatch:${REQUEST.sessionId}:${generation}`,
);
).environmentId;
const profileSnapshot: WorkerProfile = { settings: { region: "test" } };
const bootstrapReceipt: WorkerAdmissionHandshake = {
bundleHash: BUNDLE_HASH,
@@ -17,7 +17,7 @@ import {
} from "./placement-dispatch-test-fixtures.js";
import { createHarness } from "./placement-dispatch-test-harness.js";
import { createWorkerSessionPlacementStore } from "./placement-store.js";
import { workerEnvironmentIdForIdempotencyKey } from "./service.js";
import { deriveEnvironmentIntent } from "./service-contract.js";
describe("worker placement dispatch", () => {
let root: string;
@@ -566,9 +566,9 @@ describe("worker placement dispatch", () => {
to: "provisioning",
expectedGeneration: interrupted.generation,
patch: {
environmentId: workerEnvironmentIdForIdempotencyKey(
environmentId: deriveEnvironmentIntent(
`session-dispatch:${REQUEST.sessionId}:${interrupted.generation}`,
),
).environmentId,
},
});
}
@@ -15,7 +15,8 @@ import type {
WorkerPlacementDispatchRequest,
WorkerPlacementReclaimRequest,
} from "./service-contract.js";
import { type WorkerEnvironmentService, workerEnvironmentIdForIdempotencyKey } from "./service.js";
import { deriveEnvironmentIntent } from "./service-contract.js";
import type { WorkerEnvironmentService } from "./service.js";
import { WorkerTunnelOwnerDisconnectedError } from "./tunnel-contract.js";
import type { WorkerWorkspaceResultConflict } from "./workspace-conflicts.js";
import {
@@ -156,7 +157,7 @@ export function createWorkerPlacementDispatchService(options: WorkerPlacementDis
});
const localPath = await options.resolveWorkspacePath(request);
const idempotencyKey = `session-dispatch:${request.sessionId}:${placement.generation}`;
const expectedEnvironmentId = workerEnvironmentIdForIdempotencyKey(idempotencyKey);
const expectedEnvironmentId = deriveEnvironmentIntent(idempotencyKey).environmentId;
placement = placements.transition({
sessionId: request.sessionId,
from: "requested",
@@ -3,11 +3,30 @@ import type {
WorkerSessionPlacementStore,
WorkerSessionTurnClaim,
} from "./placement-store.js";
import type {
WorkerPlacementBinding,
WorkerPlacementTurnBinding,
WorkerSessionPlacementGate,
} from "./service.js";
type WorkerPlacementBinding = Readonly<{
sessionId: string;
environmentId: string;
ownerEpoch: number;
}>;
export type WorkerPlacementTurnBinding = WorkerPlacementBinding &
Readonly<{
runId: string;
}>;
export type WorkerSessionPlacementGate = {
hasWorkerTurn(binding: WorkerPlacementBinding): boolean;
validateWorkerTurn(binding: WorkerPlacementTurnBinding): boolean;
isWorkerTurnToolAuthorized(binding: WorkerPlacementTurnBinding, toolName: string): boolean;
updateAckCursors(
binding: WorkerPlacementTurnBinding & {
transcriptSeq?: number;
liveSeq?: number;
workspaceResultPending?: boolean;
},
): void;
};
function claimForBinding(
record: WorkerSessionPlacementRecord | undefined,
@@ -0,0 +1,661 @@
import { isDeepStrictEqual } from "node:util";
import { expectDefined } from "@openclaw/normalization-core";
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
import {
type WorkerAdmissionHandshake,
WORKER_RPC_SET_VERSION,
} from "../../../packages/gateway-protocol/src/schema/worker-admission.js";
import type { OpenClawConfig } from "../../config/types.js";
import type { SecretRef } from "../../config/types.secrets.js";
import { validateCloudWorkerProfileSettings } from "../../config/zod-schema.cloud-workers.js";
import { normalizeCapabilityProviderId } from "../../plugins/provider-registry-shared.js";
import {
WorkerProviderError,
type WorkerLease,
type WorkerProfile,
type WorkerProvider,
type WorkerSshEndpoint,
type WorkerSshIdentity,
} from "../../plugins/types.js";
import { verifyWorkerAdmissionHandshake } from "./admission.js";
import type { WorkerInstallationArtifact } from "./bundle.js";
import type { WorkerCredentialBroker } from "./credential-broker.js";
import { deriveEnvironmentIntent } from "./service-contract.js";
import { requireWorkerLease, requireWorkerLeaseStatus } from "./service-validation.js";
import type { WorkerEnvironmentState } from "./state.js";
import type {
WorkerEnvironmentRecord,
WorkerEnvironmentStore,
WorkerEnvironmentTransitionPatch as TransitionPatch,
} from "./store.js";
import type { WorkerTunnelManager } from "./tunnel.js";
import { boundedWorkerError as boundedError } from "./worker-error.js";
const ORPHANED_LEASE_ERROR = "Worker provider no longer recognizes the lease";
type WorkerProviderLifecycleOptions = {
store: WorkerEnvironmentStore;
getConfig: () => OpenClawConfig;
resolveProvider: (providerId: string) => WorkerProvider | undefined;
prepareInstallation: (
install: WorkerInstallationArtifact["install"],
) => Promise<WorkerInstallationArtifact>;
bootstrapWorker: (params: {
operationId: string;
sshEndpoint: WorkerSshEndpoint;
installation: WorkerInstallationArtifact;
resolveIdentity: (keyRef: SecretRef) => Promise<WorkerSshIdentity>;
signal: AbortSignal;
}) => Promise<WorkerAdmissionHandshake>;
resolveSshIdentity?: (params: {
provider: WorkerProvider;
leaseId: string;
profile: WorkerProfile;
keyRef: SecretRef;
}) => Promise<WorkerSshIdentity>;
providerCallTimeoutMs?: number;
tunnelManager?: WorkerTunnelManager;
credentialBroker: WorkerCredentialBroker;
callBootstrap: <T>(run: (signal: AbortSignal) => Promise<T>) => Promise<T>;
callProvider: <T>(environmentId: string, run: () => Promise<T>, timeoutMs?: number) => Promise<T>;
inState: (record: WorkerEnvironmentRecord, ...states: WorkerEnvironmentState[]) => boolean;
isServiceError: (error: unknown, code: string) => boolean;
isStopping: () => boolean;
move: (
record: WorkerEnvironmentRecord,
to: WorkerEnvironmentState,
patch?: TransitionPatch,
) => WorkerEnvironmentRecord;
saveError: (record: WorkerEnvironmentRecord, error: unknown) => WorkerEnvironmentRecord;
serviceError: (
code:
| "bootstrap_failure"
| "environment_not_found"
| "invalid_profile"
| "invalid_state"
| "profile_not_found"
| "provider_failure"
| "provider_not_found",
message: string,
) => Error;
withLock: <T>(environmentId: string, task: () => Promise<T>) => Promise<T>;
};
function requireProviderProvisionTimeoutMs(timeoutMs: number | undefined): number | undefined {
if (timeoutMs === undefined) {
return undefined;
}
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMER_TIMEOUT_MS) {
throw new WorkerProviderError(
`Worker provider provision timeout must be an integer from 1 through ${MAX_TIMER_TIMEOUT_MS}ms`,
);
}
return timeoutMs;
}
export function createWorkerProviderLifecycle(options: WorkerProviderLifecycleOptions) {
const { store } = options;
const tunnels = options.tunnelManager;
const callBootstrap = options.callBootstrap;
const callProvider = options.callProvider;
const inState = options.inState;
const move = options.move;
const saveError = options.saveError;
const serviceError = options.serviceError;
const withLock = options.withLock;
const {
credentialExpiry,
credentialMaterial,
ensurePendingCredential,
grantFrom,
stageCredential,
} = options.credentialBroker;
function requireWorkerProfile(value: unknown): WorkerProfile {
const error = validateCloudWorkerProfileSettings(value);
if (error) {
throw serviceError("invalid_profile", error);
}
return value as WorkerProfile;
}
const lifecycleLease = (record: WorkerEnvironmentRecord, leaseId: string) => ({
leaseId,
profile: requireWorkerProfile(record.profileSnapshot.settings),
});
const identityResolverFor = (
record: WorkerEnvironmentRecord,
provider: WorkerProvider,
leaseId: string,
) => {
const profile = requireWorkerProfile(record.profileSnapshot.settings);
const resolveSshIdentity = options.resolveSshIdentity;
return async (keyRef: SecretRef) => {
if (!resolveSshIdentity) {
throw new Error("Worker SSH identity resolution is unavailable");
}
return await callProvider(record.environmentId, () =>
resolveSshIdentity({ provider, leaseId, profile, keyRef }),
);
};
};
const providerFor = (providerId: string): WorkerProvider => {
const provider = options.resolveProvider(providerId);
if (provider) {
return provider;
}
throw serviceError("provider_not_found", `Worker provider is unavailable: ${providerId}`);
};
const installFor = (record: WorkerEnvironmentRecord): WorkerInstallationArtifact["install"] => {
const install = record.profileSnapshot.install;
if (install === undefined || install === "bundle") {
return "bundle";
}
if (install === "npm") {
return "npm";
}
throw serviceError("invalid_profile", "Worker profile has an invalid install method");
};
const prepareInstallation = (record: WorkerEnvironmentRecord) =>
options.prepareInstallation(installFor(record));
const finishProvenDestroy = (record: WorkerEnvironmentRecord) => {
const destroying = beginDestroy(record);
if (destroying.teardownTerminalState !== "failed") {
return move(destroying, "destroyed");
}
return move(destroying, "failed", {
leaseId: null,
sshEndpoint: null,
sharedHost: false,
lastError: destroying.lastError ?? "Worker bootstrap failed after provider teardown",
});
};
const failBootstrap = async (
record: WorkerEnvironmentRecord,
leaseId: string,
provider: WorkerProvider,
error: unknown,
): Promise<never> => {
const detail = boundedError(error);
const requested = store.requestDestroy({
environmentId: record.environmentId,
state: record.state,
terminalState: "failed",
lastError: detail,
});
const draining = move(requested, "draining", { lastError: detail });
await tunnels?.stop(record.environmentId);
const destroying = move(draining, "destroying", { lastError: detail });
try {
await callProvider(record.environmentId, () =>
provider.destroy(lifecycleLease(record, leaseId)),
);
} catch (cleanupError) {
// An indeterminate destroy must remain retryable; never hide a possibly-live paid lease
// behind terminal failed state.
saveError(
destroying,
new Error(`${detail}; provider teardown pending: ${boundedError(cleanupError)}`),
);
throw serviceError(
"bootstrap_failure",
`Worker bootstrap failed; teardown is pending: ${detail}`,
);
}
finishProvenDestroy(destroying);
throw serviceError("bootstrap_failure", `Worker bootstrap failed: ${detail}`);
};
const finishBootstrap = async (
record: WorkerEnvironmentRecord,
provider: WorkerProvider,
installation: WorkerInstallationArtifact,
) => {
if (record.state !== "bootstrapping" || !record.leaseId || !record.sshEndpoint) {
throw serviceError("invalid_state", "Worker bootstrap requires a provisioned SSH lease");
}
let receipt: WorkerAdmissionHandshake;
try {
receipt = await callBootstrap((signal) =>
options.bootstrapWorker({
operationId: record.provisionOperationId,
sshEndpoint: record.sshEndpoint,
installation,
resolveIdentity: identityResolverFor(record, provider, record.leaseId),
signal,
}),
);
if (!verifyWorkerAdmissionHandshake(receipt, installation)) {
throw new Error("Worker bootstrap receipt does not match the expected build identity");
}
} catch (error) {
return await failBootstrap(record, record.leaseId, provider, error);
}
const material = credentialMaterial();
// Receipt, owner epoch, and credential hash commit together. A failed write leaves the
// durable lease bootstrapping so reconcile can retry without admitting a partial identity.
const ready = move(record, "ready", {
bootstrapReceipt: receipt,
credential: {
credentialHash: material.credentialHash,
sessionId: null,
rpcSetVersion: WORKER_RPC_SET_VERSION,
expiresAtMs: credentialExpiry(),
},
});
const grant = grantFrom({
credential: material.credential,
record: store.getCredential(record.environmentId),
});
stageCredential(grant);
return ready;
};
const finishProvision = async (
record: WorkerEnvironmentRecord,
provider: WorkerProvider,
preparedInstallation?: WorkerInstallationArtifact,
) => {
let lease: WorkerLease;
try {
const profile = requireWorkerProfile(record.profileSnapshot.settings);
const providerTimeoutMs =
options.providerCallTimeoutMs === undefined
? requireProviderProvisionTimeoutMs(provider.resolveProvisionTimeoutMs?.(profile))
: undefined;
lease = requireWorkerLease(
await callProvider(
record.environmentId,
() => provider.provision(profile, record.provisionOperationId),
providerTimeoutMs,
),
);
} catch (error) {
const detail = boundedError(error);
if (
error instanceof WorkerProviderError ||
options.isServiceError(error, "invalid_profile")
) {
move(record, "failed", { lastError: detail });
throw serviceError("invalid_profile", `Worker provider rejected profile: ${detail}`);
}
saveError(record, error);
throw serviceError("provider_failure", `Worker provider operation failed: ${detail}`);
}
// A timeout can happen after allocation; retain the same operation id for safe replay.
const patch = {
leaseId: lease.leaseId,
sshEndpoint: lease.ssh,
sharedHost: lease.sharedHost === true,
desktop: lease.desktop ?? null,
};
const bootstrapping = move(record, "bootstrapping", patch);
if (record.destroyRequestedAtMs !== null) {
return bootstrapping;
}
let installation = preparedInstallation;
if (!installation) {
try {
// A persisted provisioning row can represent an allocation whose response was lost.
// Replay the idempotent provider operation before packaging can terminalize that lease.
installation = await prepareInstallation(bootstrapping);
} catch (error) {
return await failBootstrap(bootstrapping, lease.leaseId, provider, error);
}
}
return finishBootstrap(bootstrapping, provider, installation);
};
const resumeProvision = async (
record: WorkerEnvironmentRecord,
provider = providerFor(record.providerId),
) => {
let installation: WorkerInstallationArtifact | undefined;
if (record.state === "requested" && record.destroyRequestedAtMs === null) {
try {
// Fresh requests package before allocation. Once provisioning is durable, provider replay
// must happen first because the previous response may have been lost after allocation.
installation = await prepareInstallation(record);
} catch (error) {
const detail = boundedError(error);
move(record, "failed", { lastError: detail });
throw serviceError(
"bootstrap_failure",
`Worker installation preparation failed: ${detail}`,
);
}
}
const provisioning = record.state === "requested" ? move(record, "provisioning") : record;
return finishProvision(provisioning, provider, installation);
};
const cancelRequested = (record: WorkerEnvironmentRecord) =>
move(record, "failed", { lastError: "Provisioning canceled before provider allocation" });
const beginDrain = (record: WorkerEnvironmentRecord) => {
const failurePatch =
record.teardownTerminalState === "failed" ? { lastError: record.lastError } : undefined;
return inState(record, "bootstrapping", "ready", "attached", "idle")
? move(record, "draining", failurePatch)
: record;
};
const beginDestroy = (record: WorkerEnvironmentRecord) => {
const failurePatch =
record.teardownTerminalState === "failed" ? { lastError: record.lastError } : undefined;
const draining = beginDrain(record);
if (draining.state === "draining") {
return move(draining, "destroying", failurePatch);
}
if (draining.state === "destroying") {
return draining;
}
throw serviceError("invalid_state", `Cannot destroy worker in state: ${record.state}`);
};
const finishDestroy = async (r: WorkerEnvironmentRecord, provider?: WorkerProvider) => {
if (!r.leaseId) {
throw serviceError("invalid_state", "Worker environment has no lease");
}
const leaseId = r.leaseId;
const draining = beginDrain(r);
await tunnels?.stop(r.environmentId);
const owningProvider = provider ?? providerFor(r.providerId);
const destroying = beginDestroy(draining);
try {
await callProvider(r.environmentId, () => owningProvider.destroy(lifecycleLease(r, leaseId)));
} catch (error) {
saveError(destroying, error);
throw serviceError("provider_failure", "Worker provider operation failed");
}
return finishProvenDestroy(destroying);
};
const reconcileRecord = async (initialRecord: WorkerEnvironmentRecord): Promise<void> => {
let record = initialRecord;
if (record.state === "requested" && record.destroyRequestedAtMs !== null) {
return void cancelRequested(record);
}
let currentBundle: WorkerInstallationArtifact | undefined;
if (record.destroyRequestedAtMs === null && inState(record, "ready", "idle", "attached")) {
try {
currentBundle = await options.prepareInstallation("bundle");
if (
record.bootstrapReceipt &&
verifyWorkerAdmissionHandshake(record.bootstrapReceipt, currentBundle)
) {
const sessionId = record.state === "attached" ? record.attachedSessionIds[0] : null;
if (record.state !== "attached" || sessionId) {
ensurePendingCredential(record, sessionId ?? null);
record = store.get(record.environmentId) ?? record;
}
}
} catch {
// Provider inspection and the state-specific path below retain their existing retry policy.
}
}
let provider: WorkerProvider;
try {
provider = providerFor(record.providerId);
} catch (error) {
saveError(record, error);
return;
}
const leaseId = record.leaseId;
if (!leaseId) {
const provisioned = await resumeProvision(record, provider).catch(() => undefined);
if (provisioned?.state === "bootstrapping") {
await finishDestroy(provisioned, provider).catch(() => undefined);
}
return;
}
const inspection = await callProvider(record.environmentId, () =>
provider.inspect(lifecycleLease(record, leaseId)),
)
.then(requireWorkerLeaseStatus)
.catch((error: unknown) => {
saveError(record, error);
return undefined;
});
if (!inspection) {
return;
}
const { status } = inspection;
const teardownExpected = record.destroyRequestedAtMs !== null || record.state === "destroying";
if (status === "destroyed" || (status === "unknown" && teardownExpected)) {
const requested =
record.destroyRequestedAtMs === null
? store.requestDestroy({
environmentId: record.environmentId,
state: record.state,
...(status === "destroyed" && !teardownExpected
? {
terminalState: "failed",
lastError: "Worker environment disappeared before teardown was requested",
}
: {}),
})
: record;
const draining = beginDrain(requested);
await tunnels?.stop(record.environmentId);
finishProvenDestroy(draining);
return;
}
if (status === "unknown") {
const draining =
record.state === "draining"
? record
: move(record, "draining", { lastError: ORPHANED_LEASE_ERROR });
await tunnels?.stop(record.environmentId);
move(draining, "orphaned", { lastError: ORPHANED_LEASE_ERROR });
return;
}
const inspectedSharedHost = inspection.sharedHost === true;
if (record.sharedHost !== null && record.sharedHost !== inspectedSharedHost) {
// Workspace actions capture isolation at tunnel creation. Fence the old actions before
// committing a provider-owned change so no reconciliation can use stale host scope.
await tunnels?.stop(record.environmentId);
}
record = store.reconcileSharedHost({
environmentId: record.environmentId,
state: record.state,
leaseId,
sharedHost: inspectedSharedHost,
});
if (record.destroyRequestedAtMs !== null) {
await finishDestroy(record, provider).catch(() => undefined);
return;
}
if (record.state === "attached") {
if (
currentBundle &&
(!record.bootstrapReceipt ||
!verifyWorkerAdmissionHandshake(record.bootstrapReceipt, currentBundle))
) {
// A new Gateway build rejects the old worker at admission. This is expected lifecycle
// teardown, not a bootstrap failure. `leaseId` above came from this record, so provider
// inspection and destruction share the same durable lease identity.
await finishDestroy(record, provider).catch(() => undefined);
}
return;
}
if (record.state === "draining" && record.destroyRequestedAtMs === null) {
// Draining without destroy intent is durable provider-loss cleanup.
await tunnels?.stop(record.environmentId);
move(record, "orphaned", { lastError: record.lastError ?? ORPHANED_LEASE_ERROR });
return;
}
if (inState(record, "bootstrapping", "ready", "idle")) {
let installation = currentBundle;
try {
// Bundle identity is local and canonical for both install channels. A matching admitted
// receipt must not depend on npm registry availability during routine reconciliation.
installation ??= await options.prepareInstallation("bundle");
} catch (error) {
if (record.bootstrapReceipt && inState(record, "ready", "idle")) {
saveError(record, error);
return;
}
await failBootstrap(record, leaseId, provider, error).catch(() => undefined);
return;
}
if (
record.bootstrapReceipt &&
verifyWorkerAdmissionHandshake(record.bootstrapReceipt, installation)
) {
ensurePendingCredential(record, null);
return;
}
if (installFor(record) === "npm") {
try {
installation = await options.prepareInstallation("npm");
} catch (error) {
await failBootstrap(record, leaseId, provider, error).catch(() => undefined);
return;
}
}
const bootstrapping =
record.state === "bootstrapping" ? record : move(record, "bootstrapping");
await tunnels?.stop(record.environmentId, record.ownerEpoch);
await finishBootstrap(bootstrapping, provider, installation).catch(() => undefined);
return;
}
if (inState(record, "draining", "destroying")) {
await finishDestroy(record, provider).catch(() => undefined);
}
};
const createWithProfile = async (
profileId: string,
idempotencyKey: string,
inherited?: {
providerId: string;
profileSnapshot: WorkerProfile;
},
) => {
let stopping = options.isStopping();
if (stopping) {
throw serviceError("invalid_state", "Worker environment service is stopping");
}
const normalizedProfileId = profileId.trim();
if (!normalizedProfileId || normalizedProfileId !== profileId) {
throw serviceError("invalid_profile", "Worker profile id must be non-empty and trimmed");
}
const { environmentId, provisionOperationId } = deriveEnvironmentIntent(idempotencyKey);
return withLock(environmentId, async () => {
stopping = options.isStopping();
if (stopping) {
throw serviceError("invalid_state", "Worker environment service is stopping");
}
const existing = store.get(environmentId);
if (existing) {
if (
existing.profileId !== normalizedProfileId ||
(inherited !== undefined &&
(existing.providerId !== inherited.providerId ||
!isDeepStrictEqual(existing.profileSnapshot, inherited.profileSnapshot)))
) {
throw serviceError("invalid_profile", "Idempotency key belongs to another profile");
}
if (existing.destroyRequestedAtMs !== null) {
return existing;
}
if (!existing.leaseId && inState(existing, "requested", "provisioning")) {
return resumeProvision(existing);
}
return existing;
}
let provider: WorkerProvider;
let providerId: string;
let profileSnapshot: WorkerProfile;
if (inherited) {
providerId = normalizeCapabilityProviderId(inherited.providerId) ?? inherited.providerId;
if (providerId !== inherited.providerId) {
throw serviceError("invalid_profile", "Inherited worker provider id is not canonical");
}
provider = providerFor(providerId);
const resolvedProviderId = normalizeCapabilityProviderId(provider.id) ?? provider.id;
if (resolvedProviderId !== providerId) {
throw serviceError("invalid_profile", "Inherited worker provider identity changed");
}
profileSnapshot = requireWorkerProfile(inherited.profileSnapshot);
} else {
const profiles = options.getConfig().cloudWorkers?.profiles;
if (!profiles || !Object.hasOwn(profiles, normalizedProfileId)) {
throw serviceError("profile_not_found", `Unknown worker profile: ${normalizedProfileId}`);
}
const profile = expectDefined(
profiles[normalizedProfileId],
"profiles entry at normalized profile id",
);
provider = providerFor(profile.provider);
providerId = normalizeCapabilityProviderId(provider.id) ?? provider.id;
const settings = requireWorkerProfile(profile.settings ?? {});
profileSnapshot = requireWorkerProfile({
install: profile.install ?? "bundle",
settings,
});
}
const intent = store.createIntent({
environmentId,
providerId,
profileId: normalizedProfileId,
profileSnapshot,
provisionOperationId,
});
return resumeProvision(intent, provider);
});
};
const destroy = async (
environmentId: string,
destroyOptions: { requireUnattached?: boolean } = {},
) => {
const stopping = options.isStopping();
if (stopping) {
throw serviceError("invalid_state", "Worker environment service is stopping");
}
return withLock(environmentId, async () => {
let record = store.get(environmentId);
if (!record) {
throw serviceError("environment_not_found", `Unknown worker environment: ${environmentId}`);
}
if (inState(record, "destroyed", "failed", "orphaned")) {
return record;
}
if (destroyOptions.requireUnattached && record.attachedSessionIds.length > 0) {
throw serviceError(
"invalid_state",
"Attached cloud workers must be stopped through sessions.reclaim",
);
}
record = store.requestDestroy({ environmentId, state: record.state });
if (record.state === "requested") {
return cancelRequested(record);
}
if (record.leaseId) {
record = beginDrain(record);
}
if (!record.leaseId) {
const provider = providerFor(record.providerId);
record = await resumeProvision(record, provider);
return finishDestroy(record, provider);
}
return finishDestroy(record);
});
};
return {
createWithProfile,
destroy,
identityResolverFor,
providerFor,
reconcileRecord,
};
}
@@ -0,0 +1,868 @@
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
import { describe, expect, it, vi } from "vitest";
import { WorkerProviderError, type WorkerProfile } from "../../plugins/types.js";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
} from "../../state/openclaw-state-db.js";
import type { GatewaySessionRow } from "../session-utils.types.js";
import { writeSessionStore } from "../test-helpers.js";
import { directSessionReq } from "../test/server-sessions.test-helpers.js";
import { hashWorkerCredential } from "./credential.js";
import { createWorkerPlacementDispatchService } from "./placement-dispatch.js";
import { createWorkerSessionPlacementStore } from "./placement-store.js";
import * as support from "./service.test-support.js";
import { createWorkerEnvironmentStore } from "./store.js";
import { createWorkerWorkspaceOperationCoordinator } from "./workspace-operation-coordinator.js";
type WorkerEnvironmentServiceError = support.WorkerEnvironmentServiceError;
describe("worker environment service", () => {
support.setupWorkerEnvironmentServiceSuite();
it("persists intent and an immutable profile snapshot before provisioning", async () => {
const operationIds: string[] = [];
const provider = support.createProvider({
provision: async (profile, operationId) => {
operationIds.push(operationId);
expect(support.testState.store.list()[0]).toMatchObject({
state: "provisioning",
provisionOperationId: operationId,
profileSnapshot: {
install: "bundle",
settings: { region: "test" },
},
});
support.getDevelopmentProfile().settings = { region: "mutated" };
expect(profile).toEqual({ region: "test" });
return { leaseId: "lease-1", ssh: support.SSH_ENDPOINT };
},
});
const workerService = support.createService(provider);
const result = await workerService.create("development", "request-1");
const repeated = await workerService.create("development", "request-1");
expect(result).toMatchObject({ state: "ready", leaseId: "lease-1", ownerEpoch: 1 });
expect(repeated.environmentId).toBe(result.environmentId);
expect(operationIds).toHaveLength(1);
expect(operationIds[0]).toMatch(/^provision:v2:[a-f0-9]{64}$/u);
expect(result.profileSnapshot).toMatchObject({ settings: { region: "test" } });
expect(support.testState.store.getCredential(result.environmentId)).toMatchObject({
credentialHash: hashWorkerCredential(support.CREDENTIAL),
ownerEpoch: 1,
sessionId: null,
});
const persistedCredential = support.testState.stateDb.db
.prepare("SELECT * FROM worker_environment_credentials WHERE environment_id = ?")
.get(result.environmentId);
expect(persistedCredential).toMatchObject({
credential_hash: hashWorkerCredential(support.CREDENTIAL),
});
expect(JSON.stringify(persistedCredential)).not.toContain(support.CREDENTIAL);
const binding = { environmentId: result.environmentId, ownerEpoch: 1, sessionId: null };
const grant = workerService.takeMintedCredential(binding);
expect(grant).toMatchObject({
credential: support.CREDENTIAL,
ownerEpoch: 1,
sessionId: null,
});
expect(workerService.acknowledgeCredentialDelivery(grant!)).toBe(true);
expect(support.testState.store.getCredential(result.environmentId)).toMatchObject({
deliveredAtMs: support.testState.nowMs,
});
expect(workerService.takeMintedCredential(binding)).toBeUndefined();
});
it("creates a nested environment from its parent's snapshot after config drift", async () => {
const provisionedProfiles: WorkerProfile[] = [];
let lease = 0;
let credential = 0;
const workerService = support.createService(
support.createProvider({
provision: async (profile) => {
provisionedProfiles.push(structuredClone(profile));
lease += 1;
return { leaseId: `lease-${lease}`, ssh: support.SSH_ENDPOINT };
},
}),
{
generateWorkerCredential: () => `nested-worker-credential-${(credential += 1)}`,
},
);
const parent = await workerService.create("development", "parent-profile-snapshot");
support.getDevelopmentProfile().settings = { region: "mutated" };
const child = await workerService.createFromProfileSnapshot(
{
profileId: parent.profileId,
providerId: parent.providerId,
profileSnapshot: parent.profileSnapshot,
},
"child-profile-snapshot",
);
expect(provisionedProfiles).toEqual([{ region: "test" }, { region: "test" }]);
expect(child).toMatchObject({
profileId: parent.profileId,
providerId: parent.providerId,
profileSnapshot: parent.profileSnapshot,
});
});
it("stays bootstrapping until the SSH install receipt is durable", async () => {
let finishBootstrap: (() => void) | undefined;
const bootstrapPending = new Promise<void>((resolve) => {
finishBootstrap = resolve;
});
support.testState.bootstrapWorker = vi.fn(async () => {
await bootstrapPending;
return support.BOOTSTRAP_RECEIPT;
});
const creation = support
.createService(support.createProvider())
.create("development", "request-bootstrap");
await support.waitForFast(() =>
expect(support.testState.store.list()[0]).toMatchObject({
state: "bootstrapping",
bootstrapReceipt: null,
}),
);
finishBootstrap?.();
await expect(creation).resolves.toMatchObject({
state: "ready",
bootstrapReceipt: support.BOOTSTRAP_RECEIPT,
});
});
it("records installation preparation failure before allocating a lease", async () => {
support.testState.prepareInstallation = vi.fn(async () => {
throw new Error("npm install requires a released gateway package");
});
const provision = vi.fn(support.createProvider().provision);
const workerService = support.createService(support.createProvider({ provision }));
await expect(
workerService.create("development", "request-preparation-failure"),
).rejects.toMatchObject({
code: "bootstrap_failure",
message: expect.stringContaining("npm install requires a released gateway package"),
} satisfies Partial<WorkerEnvironmentServiceError>);
expect(provision).not.toHaveBeenCalled();
expect(support.testState.store.list()[0]).toMatchObject({
state: "failed",
leaseId: null,
lastError: "npm install requires a released gateway package",
});
expect(workerService.list()[0]).toMatchObject({
state: "failed",
error: "npm install requires a released gateway package",
});
});
it("keeps a remotely bootstrapped lease retryable when receipt persistence fails", async () => {
const durableStore = support.testState.store;
let persistenceFails = true;
support.testState.store = {
...support.testState.store,
transition(input) {
if (persistenceFails && input.from === "bootstrapping" && input.to === "ready") {
persistenceFails = false;
throw new Error("receipt database write failed");
}
return durableStore.transition(input);
},
};
const destroy = vi.fn(async () => {});
const workerService = support.createService(support.createProvider({ destroy }));
await expect(
workerService.create("development", "request-receipt-write-failure"),
).rejects.toThrow("receipt database write failed");
expect(support.testState.store.list()[0]).toMatchObject({
state: "bootstrapping",
leaseId: "lease-1",
});
expect(destroy).not.toHaveBeenCalled();
await workerService.reconcileOnce();
expect(support.testState.store.list()[0]).toMatchObject({
state: "ready",
bootstrapReceipt: support.BOOTSTRAP_RECEIPT,
});
expect(support.testState.bootstrapWorker).toHaveBeenCalledTimes(2);
});
it("tears down the lease and records a bounded bootstrap failure", async () => {
// Assembled at runtime so review-bundle secret scanners do not flag a key-shaped literal.
const secret = [
String.fromCharCode(115, 107),
"proj",
"bootstrap",
"abcdefghijklmnopqrstuvwxyz",
].join("-");
support.testState.bootstrapWorker = vi.fn(async () => {
throw new Error(`remote bootstrap rejected ${secret}`);
});
const destroy = vi.fn(async () => {});
const workerService = support.createService(support.createProvider({ destroy }));
const creation = workerService.create("development", "request-bootstrap-failure");
await expect(creation).rejects.toMatchObject({
code: "bootstrap_failure",
message: expect.stringContaining("Worker bootstrap failed: remote bootstrap rejected"),
} satisfies Partial<WorkerEnvironmentServiceError>);
await expect(creation).rejects.not.toThrow(secret);
expect(destroy).toHaveBeenCalledTimes(1);
expect(support.testState.store.list()[0]).toMatchObject({
state: "failed",
leaseId: null,
sshEndpoint: null,
bootstrapReceipt: null,
lastError: expect.stringContaining("remote bootstrap rejected"),
});
expect(support.testState.store.list()[0]?.lastError).not.toContain(secret);
});
it("projects bounded bootstrap detail through sessions.describe after failed dispatch", async () => {
// Assembled at runtime so review-bundle secret scanners do not flag a key-shaped literal.
const secret = [
String.fromCharCode(115, 107),
"proj",
"placement",
"abcdefghijklmnopqrstuvwxyz",
].join("-");
support.testState.bootstrapWorker = vi.fn(async () => {
throw new Error(`remote bootstrap rejected ${secret} ${"failure ".repeat(200)}`);
});
const workerService = support.createService(support.createProvider());
const placements = createWorkerSessionPlacementStore({
database: support.testState.stateDb,
now: () => support.testState.nowMs,
});
const dispatch = createWorkerPlacementDispatchService({
placements,
environments: workerService,
workspaceOperations: createWorkerWorkspaceOperationCoordinator(),
runLocalBarrier: async ({ startDispatch }) => startDispatch(),
runActivationBarrier: async ({ activate }) => activate(),
runReclaimBarrier: async ({ reclaim }) => await reclaim("/gateway/workspace"),
resolveWorkspacePath: async () => "/gateway/workspace",
reportWorkspaceResultConflict: async () => {},
resolveWorkspaceResultConflict: async () => undefined,
});
await expect(
dispatch.dispatch({
sessionId: "session-bootstrap-failure",
sessionKey: "agent:main:session-bootstrap-failure",
agentId: "main",
profileId: "development",
}),
).rejects.toThrow("Worker bootstrap failed: remote bootstrap rejected");
const persisted = expectDefined(
placements.get("session-bootstrap-failure"),
"failed worker placement",
);
const sessionStorePath = path.join(support.testState.root, "sessions.json");
await writeSessionStore({
entries: { main: { sessionId: persisted.sessionId, updatedAt: support.testState.nowMs } },
storePath: sessionStorePath,
});
const described = await directSessionReq<{ session: GatewaySessionRow | null }>(
"sessions.describe",
{ key: "main" },
{
context: {
getRuntimeConfig: () => ({ session: { store: sessionStorePath } }),
workerSessionPlacementService: placements,
},
},
);
const describedPlacement = described.payload?.session?.placement;
expect(described).toMatchObject({ ok: true });
expect(describedPlacement).toMatchObject({
state: "failed",
recoveryError: expect.stringContaining("remote bootstrap rejected"),
});
if (describedPlacement?.state !== "failed") {
throw new Error("sessions.describe did not project the failed worker placement");
}
expect(describedPlacement.recoveryError).not.toContain(secret);
expect(describedPlacement.recoveryError.length).toBeLessThanOrEqual(1_024);
});
it("keeps an indeterminate bootstrap teardown retryable", async () => {
support.testState.bootstrapWorker = vi.fn(async () => {
throw new Error("remote bootstrap failed");
});
let teardownFails = true;
const workerService = support.createService(
support.createProvider({
destroy: async () => {
if (teardownFails) {
throw new Error("provider teardown timed out");
}
},
}),
);
await expect(
workerService.create("development", "request-bootstrap-cleanup"),
).rejects.toMatchObject({
code: "bootstrap_failure",
message: "Worker bootstrap failed; teardown is pending: remote bootstrap failed",
} satisfies Partial<WorkerEnvironmentServiceError>);
expect(support.testState.store.list()[0]).toMatchObject({
state: "destroying",
leaseId: "lease-1",
destroyRequestedAtMs: expect.any(Number),
teardownTerminalState: "failed",
lastError: "remote bootstrap failed",
});
teardownFails = false;
await workerService.reconcileOnce();
expect(support.testState.store.list()[0]).toMatchObject({
state: "failed",
leaseId: null,
sshEndpoint: null,
lastError: expect.stringContaining("remote bootstrap failed"),
});
});
it("bounds worker identity resolution as a provider operation", async () => {
const events: string[] = [];
let finishIdentity: (() => void) | undefined;
const identityPending = new Promise<void>((resolve) => {
finishIdentity = resolve;
});
support.testState.bootstrapWorker = vi.fn(async ({ installation, resolveIdentity, signal }) => {
signal.addEventListener("abort", () => void events.push("abort"), { once: true });
await resolveIdentity(support.SSH_ENDPOINT.keyRef);
return {
bundleHash: installation.bundleHash,
openclawVersion: installation.openclawVersion,
protocolFeatures: [...installation.protocolFeatures],
};
});
const destroy = vi.fn(async () => {
events.push("destroy");
});
const workerService = support.createService(support.createProvider({ destroy }), {
providerCallTimeoutMs: 5,
resolveSshIdentity: async () => {
events.push("identity:start");
await identityPending;
events.push("identity:end");
return { kind: "path", path: "/keys/worker" };
},
});
const creation = workerService.create("development", "request-identity-timeout");
const creationResult = expect(creation).rejects.toMatchObject({
code: "bootstrap_failure",
} satisfies Partial<WorkerEnvironmentServiceError>);
try {
await support.waitForFast(() =>
expect(support.testState.store.list()[0]).toMatchObject({ state: "destroying" }),
);
expect(events).toEqual(["identity:start", "abort"]);
expect(destroy).not.toHaveBeenCalled();
} finally {
finishIdentity?.();
}
await creationResult;
expect(destroy).toHaveBeenCalledOnce();
expect(events).toEqual(["identity:start", "abort", "identity:end", "destroy"]);
expect(support.testState.store.list()[0]).toMatchObject({ state: "failed", leaseId: null });
});
it("aborts a timed-out SSH bootstrap before tearing down its lease", async () => {
const events: string[] = [];
support.testState.bootstrapWorker = vi.fn(
async ({ signal }) =>
await new Promise<never>((_resolve, reject) => {
signal.addEventListener(
"abort",
() => {
events.push("abort");
reject(new Error("SSH bootstrap aborted"));
},
{ once: true },
);
}),
);
const destroy = vi.fn(async () => {
events.push("destroy");
});
const workerService = support.createService(support.createProvider({ destroy }), {
bootstrapCallTimeoutMs: 10,
});
await expect(
workerService.create("development", "request-bootstrap-timeout"),
).rejects.toMatchObject({
code: "bootstrap_failure",
} satisfies Partial<WorkerEnvironmentServiceError>);
expect(events).toEqual(["abort", "destroy"]);
expect(support.testState.store.list()[0]).toMatchObject({ state: "failed", leaseId: null });
});
it("adopts one committed provision across a service and store restart", async () => {
const physicalLeases = new Set<string>();
const operationIds: string[] = [];
const destroyed: string[] = [];
let creates = 0;
let loseFirstReply = true;
const provider = () =>
support.createProvider({
provision: async (_profile, operationId) => {
operationIds.push(operationId);
if (!physicalLeases.has("lease-restarted")) {
creates += 1;
physicalLeases.add("lease-restarted");
}
if (loseFirstReply) {
loseFirstReply = false;
throw new Error("provider response was lost after commit");
}
return { leaseId: "lease-restarted", ssh: support.SSH_ENDPOINT };
},
destroy: async ({ leaseId }) => {
destroyed.push(leaseId);
physicalLeases.delete(leaseId);
},
});
const first = support.createService(provider());
await expect(first.create("development", "request-restart-replay")).rejects.toMatchObject({
code: "provider_failure",
} satisfies Partial<WorkerEnvironmentServiceError>);
const environmentId = expectDefined(
support.testState.store.list()[0],
"persisted provision intent",
).environmentId;
const operationId = expectDefined(
support.testState.store.get(environmentId),
"persisted provision record",
).provisionOperationId;
expect(operationId).toMatch(/^provision:v2:[a-f0-9]{64}$/u);
expect(support.testState.store.get(environmentId)).toMatchObject({
state: "provisioning",
leaseId: null,
});
await first.stop();
support.testState.service = undefined;
closeOpenClawStateDatabaseForTest();
support.testState.stateDb = openOpenClawStateDatabase({
env: { OPENCLAW_STATE_DIR: support.testState.root },
});
support.testState.store = createWorkerEnvironmentStore({
database: support.testState.stateDb,
now: () => support.testState.nowMs,
});
const restarted = support.createService(provider());
restarted.start();
await support.waitForFast(() =>
expect(support.testState.store.get(environmentId)).toMatchObject({
state: "ready",
leaseId: "lease-restarted",
lastError: null,
}),
);
await restarted.destroy(environmentId);
expect(creates).toBe(1);
expect(operationIds).toEqual([operationId, operationId]);
expect(destroyed).toEqual(["lease-restarted"]);
expect(physicalLeases.size).toBe(0);
expect(support.testState.store.get(environmentId)).toMatchObject({
state: "destroyed",
leaseId: "lease-restarted",
});
});
it("records a permanent legacy provision replay failure without allocating", async () => {
const legacyOperationId = `provision:${"0".repeat(64)}`;
const intent = support.testState.store.createIntent({
environmentId: "worker-legacy-provision",
providerId: "fake",
profileId: "development",
profileSnapshot: { settings: { region: "test" } },
provisionOperationId: legacyOperationId,
});
support.testState.store.transition({
environmentId: intent.environmentId,
from: intent.state,
to: "provisioning",
});
const allocate = vi.fn(async () => ({ leaseId: "must-not-exist", ssh: support.SSH_ENDPOINT }));
const provider = support.createProvider({
provision: async (_profile, operationId) => {
if (operationId === legacyOperationId) {
throw new WorkerProviderError("Legacy Crabbox provision state cannot be replayed safely");
}
return await allocate();
},
});
await support.createService(provider).reconcileOnce();
expect(allocate).not.toHaveBeenCalled();
expect(support.testState.store.get(intent.environmentId)).toMatchObject({
state: "failed",
leaseId: null,
lastError: "Legacy Crabbox provision state cannot be replayed safely",
});
});
it("does not resolve a provider provision timeout when the service override is set", async () => {
const resolveProvisionTimeoutMs = vi.fn(() => {
throw new Error("provider timeout hook must not run");
});
const workerService = support.createService(
support.createProvider({ resolveProvisionTimeoutMs }),
{
providerCallTimeoutMs: 1_000,
},
);
await expect(
workerService.create("development", "request-provider-timeout-override"),
).resolves.toMatchObject({ state: "ready" });
expect(resolveProvisionTimeoutMs).not.toHaveBeenCalled();
});
it.each([
["zero", 0],
["negative", -1],
["fractional", 1.5],
["non-finite", Number.NaN],
["timer overflow", MAX_TIMER_TIMEOUT_MS + 1],
])("rejects a %s provider provision timeout before allocation", async (_label, timeoutMs) => {
const provision = vi.fn(async () => ({
leaseId: "lease-invalid-timeout",
ssh: support.SSH_ENDPOINT,
}));
const workerService = support.createService(
support.createProvider({
provision,
resolveProvisionTimeoutMs: () => timeoutMs,
}),
);
await expect(
workerService.create("development", `request-invalid-provider-timeout-${String(timeoutMs)}`),
).rejects.toMatchObject({
code: "invalid_profile",
message: expect.stringContaining("Worker provider provision timeout must be an integer"),
} satisfies Partial<WorkerEnvironmentServiceError>);
expect(provision).not.toHaveBeenCalled();
});
it("serializes destroy and provision replay behind a timed-out provider operation", async () => {
const events: string[] = [];
const operationIds: string[] = [];
let active = 0;
let maxActive = 0;
let originalProvisionCalls = 0;
let finishFirstProvision: (() => void) | undefined;
const firstProvisionPending = new Promise<void>((resolve) => {
finishFirstProvision = resolve;
});
const destroy = vi.fn(async () => {
events.push("destroy:start");
active += 1;
maxActive = Math.max(maxActive, active);
active -= 1;
events.push("destroy:end");
});
const provider = support.createProvider({
provision: async (_profile, operationId) => {
originalProvisionCalls += 1;
const call = originalProvisionCalls;
operationIds.push(operationId);
events.push(`provision:${call}:start`);
active += 1;
maxActive = Math.max(maxActive, active);
if (call === 1) {
await firstProvisionPending;
}
active -= 1;
events.push(`provision:${call}:end`);
return { leaseId: "lease-timeout-replay", ssh: support.SSH_ENDPOINT };
},
destroy,
resolveProvisionTimeoutMs: () => 20,
});
const workerService = support.createService(provider);
const creation = workerService.create("development", "request-provider-timeout-race");
const creationResult = expect(creation).rejects.toMatchObject({
code: "provider_failure",
} satisfies Partial<WorkerEnvironmentServiceError>);
let environmentId: string | undefined;
let teardownResult: Promise<void> | undefined;
try {
await support.waitForFast(() => expect(events).toEqual(["provision:1:start"]));
const queuedEnvironmentId = expectDefined(
support.testState.store.list()[0],
"timed-out provision row",
).environmentId;
environmentId = queuedEnvironmentId;
const teardown = workerService.destroy(queuedEnvironmentId);
teardownResult = expect(teardown).resolves.toMatchObject({ state: "destroyed" });
await creationResult;
await support.waitForFast(() =>
expect(
support.testState.store.get(queuedEnvironmentId)?.destroyRequestedAtMs,
).not.toBeNull(),
);
expect(originalProvisionCalls).toBe(1);
expect(destroy).not.toHaveBeenCalled();
expect(maxActive).toBe(1);
} finally {
finishFirstProvision?.();
}
await teardownResult;
const finalEnvironmentId = expectDefined(environmentId, "timed-out provision environment id");
expect(operationIds).toHaveLength(2);
expect(new Set(operationIds).size).toBe(1);
expect(maxActive).toBe(1);
expect(events).toEqual([
"provision:1:start",
"provision:1:end",
"provision:2:start",
"provision:2:end",
"destroy:start",
"destroy:end",
]);
expect(support.testState.store.get(finalEnvironmentId)).toMatchObject({ state: "destroyed" });
});
it("adopts an indeterminate allocation before a replay preparation failure", async () => {
const events: string[] = [];
let preparationFails = false;
support.testState.prepareInstallation = vi.fn(async () => {
events.push("prepare");
if (preparationFails) {
throw new Error("persisted bundle is unavailable");
}
return support.BUNDLE_ARTIFACT;
});
let provisionCalls = 0;
const operationIds: string[] = [];
const provider = support.createProvider({
provision: async (_profile, operationId) => {
events.push("provision");
provisionCalls += 1;
operationIds.push(operationId);
if (provisionCalls === 1) {
throw new Error("provision response was lost");
}
return { leaseId: "lease-replayed", ssh: support.SSH_ENDPOINT };
},
destroy: async () => void events.push("destroy"),
});
const workerService = support.createService(provider);
await expect(
workerService.create("development", "request-lost-provision"),
).rejects.toMatchObject({
code: "provider_failure",
} satisfies Partial<WorkerEnvironmentServiceError>);
preparationFails = true;
await workerService.reconcileOnce();
expect(events).toEqual(["prepare", "provision", "provision", "prepare", "destroy"]);
expect(new Set(operationIds).size).toBe(1);
expect(support.testState.store.list()[0]).toMatchObject({
state: "failed",
leaseId: null,
sshEndpoint: null,
teardownTerminalState: "failed",
lastError: "persisted bundle is unavailable",
});
});
it.each([
["missing result", null, "invalid provision result"],
[
"malformed SSH endpoint",
{ leaseId: "lease-invalid", ssh: { ...support.SSH_ENDPOINT, keyRef: "not-a-secret-ref" } },
"SSH key must be a canonical SecretRef",
],
[
"excessive SSH fallback ports",
{
leaseId: "lease-invalid",
ssh: {
...support.SSH_ENDPOINT,
fallbackPorts: Array.from({ length: 11 }, (_, index) => 2300 + index),
},
},
"SSH fallback ports cannot exceed 10",
],
[
"invalid shared-host declaration",
{ leaseId: "lease-invalid", ssh: support.SSH_ENDPOINT, sharedHost: "yes" },
"invalid provision result",
],
[
"unsupported desktop protocol",
{
leaseId: "lease-invalid",
ssh: support.SSH_ENDPOINT,
desktop: { protocol: "rdp", port: 5900 },
},
'desktop protocol must be "rfb"',
],
[
"invalid desktop port",
{
leaseId: "lease-invalid",
ssh: support.SSH_ENDPOINT,
desktop: { protocol: "rfb", port: 0 },
},
"desktop port must be an integer",
],
[
"relative desktop password path",
{
leaseId: "lease-invalid",
ssh: support.SSH_ENDPOINT,
desktop: { protocol: "rfb", port: 5900, passwordFilePath: "vnc.password" },
},
"desktop password file path must be absolute",
],
[
"unrecognized desktop app metadata",
{
leaseId: "lease-invalid",
ssh: support.SSH_ENDPOINT,
desktop: {
protocol: "rfb",
port: 5900,
apps: [
{
id: "browser",
executablePath: "/usr/local/bin/openclaw-worker-browser",
cdpPort: 9222,
command: "chromium",
},
],
},
},
"browser desktop app contains unknown fields",
],
])("keeps %s from a provider retryable", async (_name, result, error) => {
const workerService = support.createService(
support.createProvider({ provision: async () => result as never }),
);
await expect(workerService.create("development", "request-malformed")).rejects.toMatchObject({
code: "provider_failure",
message: expect.stringContaining(error),
} satisfies Partial<WorkerEnvironmentServiceError>);
expect(support.testState.store.list()[0]).toMatchObject({
state: "provisioning",
lastError: expect.stringContaining(error),
});
});
it("rejects plaintext secret fields before persisting intent", async () => {
support.getDevelopmentProfile().settings = {
keyRef: "not-a-secret-ref",
};
const provision = vi.fn(support.createProvider().provision);
await expect(
support
.createService(support.createProvider({ provision }))
.create("development", "request-secret"),
).rejects.toMatchObject({ code: "invalid_profile" });
expect(provision).not.toHaveBeenCalled();
expect(support.testState.store.list()).toEqual([]);
});
it("records permanent provider profile rejection as terminal", async () => {
let provisionCalls = 0;
const provider = support.createProvider({
provision: async () => {
provisionCalls += 1;
throw new WorkerProviderError("region is required");
},
});
const workerService = support.createService(provider);
await expect(workerService.create("development", "request-invalid")).rejects.toMatchObject({
code: "invalid_profile",
message: expect.stringContaining("region is required"),
} satisfies Partial<WorkerEnvironmentServiceError>);
const record = expectDefined(
support.testState.store.list()[0],
"store.list()[0] test invariant",
);
expect(record).toMatchObject({ state: "failed", lastError: "region is required" });
await workerService.reconcileOnce();
await expect(workerService.destroy(record.environmentId)).resolves.toMatchObject({
state: "failed",
});
expect(provisionCalls).toBe(1);
});
it("rejects non-canonical profile ids before persistence", async () => {
const workerService = support.createService(support.createProvider());
await expect(workerService.create(" development ", "request-spaced")).rejects.toMatchObject({
code: "invalid_profile",
} satisfies Partial<WorkerEnvironmentServiceError>);
expect(support.testState.store.list()).toEqual([]);
});
it.each(["direct destroy", "restart reconcile"] as const)(
"cancels a requested intent without allocating on %s",
async (mode) => {
const intent = support.testState.store.createIntent({
environmentId: `worker-cancel-${mode}`,
providerId: "fake",
profileId: "development",
profileSnapshot: { settings: { region: "test" } },
provisionOperationId: `provision:cancel-${mode}`,
});
const provision = vi.fn(support.createProvider().provision);
const workerService = support.createService(support.createProvider({ provision }));
if (mode === "direct destroy") {
await workerService.destroy(intent.environmentId);
} else {
support.testState.store.requestDestroy({
environmentId: intent.environmentId,
state: "requested",
});
support.testState.providersEnabled = false;
await workerService.reconcileOnce();
}
expect(provision).not.toHaveBeenCalled();
expect(support.testState.store.get(intent.environmentId)).toMatchObject({
state: "failed",
lastError: "Provisioning canceled before provider allocation",
destroyRequestedAtMs: expect.any(Number),
});
},
);
});
@@ -0,0 +1,613 @@
import { describe, expect, it, vi } from "vitest";
import * as support from "./service.test-support.js";
import type { WorkerTunnelManager } from "./tunnel.js";
type WorkerEnvironmentServiceError = support.WorkerEnvironmentServiceError;
type WorkerLifecycleLease = support.WorkerLifecycleLease;
describe("worker environment service", () => {
support.setupWorkerEnvironmentServiceSuite();
it("adopts a matching milestone-1 row that predates worker credentials", async () => {
const environmentId = "worker-milestone-one";
support.seedReady(environmentId);
support.testState.stateDb.db
.prepare("DELETE FROM worker_environment_credentials WHERE environment_id = ?")
.run(environmentId);
support.testState.stateDb.db
.prepare("UPDATE worker_environments SET owner_epoch = 0 WHERE environment_id = ?")
.run(environmentId);
const workerService = support.createService(
support.createProvider({
inspect: async () => {
throw new Error("provider unavailable");
},
}),
);
await workerService.reconcileOnce();
expect(support.testState.store.get(environmentId)?.ownerEpoch).toBe(1);
expect(support.testState.store.getCredential(environmentId)).toMatchObject({
ownerEpoch: 1,
sessionId: null,
});
expect(
workerService.takeMintedCredential({ environmentId, ownerEpoch: 1, sessionId: null }),
).toMatchObject({
credential: support.CREDENTIAL,
ownerEpoch: 1,
});
expect(support.testState.store.get(environmentId)?.lastError).toBe("provider unavailable");
expect(support.testState.bootstrapWorker).not.toHaveBeenCalled();
});
it("inspects a persisted lease with its profile snapshot after profile removal", async () => {
support.seedBootstrapping("worker-crash");
support.testState.config.cloudWorkers!.profiles = {};
const inspected: WorkerLifecycleLease[] = [];
const provider = support.createProvider({
inspect: async (lease) => {
inspected.push(lease);
return { status: "active" };
},
provision: async () => {
throw new Error("provision must not run for a known lease");
},
});
await support.createService(provider).reconcileOnce();
expect(inspected).toEqual([{ leaseId: "lease:worker-crash", profile: { region: "test" } }]);
expect(support.testState.store.get("worker-crash")).toMatchObject({
state: "ready",
bootstrapReceipt: support.BOOTSTRAP_RECEIPT,
});
expect(support.testState.prepareInstallation).toHaveBeenCalledWith("bundle");
expect(support.testState.bootstrapWorker).toHaveBeenCalledTimes(1);
});
it("skips an active lease whose durable receipt matches the lifecycle bundle", async () => {
support.seedReady("worker-current");
await support.createService(support.createProvider()).reconcileOnce();
expect(support.testState.store.get("worker-current")).toMatchObject({
state: "ready",
bootstrapReceipt: support.BOOTSTRAP_RECEIPT,
});
expect(support.testState.prepareInstallation).toHaveBeenCalledWith("bundle");
expect(support.testState.bootstrapWorker).not.toHaveBeenCalled();
});
it("re-enters bootstrapping when the durable receipt has a stale bundle hash", async () => {
const bootstrapping = support.seedBootstrapping("worker-stale");
support.testState.store.transition({
environmentId: bootstrapping.environmentId,
from: "bootstrapping",
to: "ready",
patch: support.readyPatch(bootstrapping.environmentId, {
...support.BOOTSTRAP_RECEIPT,
bundleHash: "b".repeat(64),
}),
});
await support.createService(support.createProvider()).reconcileOnce();
expect(support.testState.store.get("worker-stale")).toMatchObject({
state: "ready",
bootstrapReceipt: support.BOOTSTRAP_RECEIPT,
});
expect(support.testState.bootstrapWorker).toHaveBeenCalledTimes(1);
});
it("tears down an attached worker whose admitted bundle is stale", async () => {
const environmentId = "worker-attached-stale";
support.seedBootstrapping(environmentId);
const ready = support.testState.store.transition({
environmentId,
from: "bootstrapping",
to: "ready",
patch: support.readyPatch(environmentId, {
...support.BOOTSTRAP_RECEIPT,
bundleHash: "b".repeat(64),
}),
});
support.testState.store.transition({
environmentId,
from: ready.state,
to: "attached",
patch: support.attachedPatch(environmentId, "session-1"),
});
const destroy = vi.fn(async () => {});
await support.createService(support.createProvider({ destroy })).reconcileOnce();
expect(destroy).toHaveBeenCalledOnce();
expect(destroy).toHaveBeenCalledWith({
leaseId: `lease:${environmentId}`,
profile: { region: "test" },
});
expect(support.testState.store.get(environmentId)).toMatchObject({
state: "destroyed",
leaseId: `lease:${environmentId}`,
attachedSessionIds: [],
lastError: null,
});
});
it("does not resolve npm while an admitted receipt matches the local bundle", async () => {
const environmentId = "worker-current-npm";
support.seedReady(environmentId, "npm");
support.testState.prepareInstallation = vi.fn(async (install) => {
if (install === "bundle") {
return support.BUNDLE_ARTIFACT;
}
throw new Error("npm registry is unavailable");
});
const destroy = vi.fn(async () => {});
const workerService = support.createService(support.createProvider({ destroy }));
await workerService.reconcileOnce();
expect(support.testState.prepareInstallation).toHaveBeenCalledTimes(1);
expect(support.testState.prepareInstallation).toHaveBeenCalledWith("bundle");
expect(destroy).not.toHaveBeenCalled();
expect(support.testState.store.get(environmentId)).toMatchObject({
state: "ready",
leaseId: `lease:${environmentId}`,
bootstrapReceipt: support.BOOTSTRAP_RECEIPT,
lastError: null,
});
});
it("keeps an admitted lease retryable when local bundle identity is unavailable", async () => {
const environmentId = "worker-current-bundle-unavailable";
support.seedReady(environmentId, "npm");
const attachedId = "worker-attached-bundle-unavailable";
support.seedReady(attachedId);
support.testState.store.transition({
environmentId: attachedId,
from: "ready",
to: "attached",
patch: support.attachedPatch(attachedId, "session-1"),
});
support.testState.stateDb.db
.prepare("DELETE FROM worker_environment_credentials WHERE environment_id = ?")
.run(attachedId);
support.testState.prepareInstallation = vi.fn(async () => {
throw new Error("local bundle identity is unavailable");
});
const destroy = vi.fn(async () => {});
const workerService = support.createService(support.createProvider({ destroy }));
await workerService.reconcileOnce();
expect(destroy).not.toHaveBeenCalled();
expect(support.testState.store.get(environmentId)).toMatchObject({
state: "ready",
leaseId: `lease:${environmentId}`,
bootstrapReceipt: support.BOOTSTRAP_RECEIPT,
lastError: "local bundle identity is unavailable",
});
expect(support.testState.store.getCredential(attachedId)).toBeUndefined();
expect(
workerService.takeMintedCredential({
environmentId: attachedId,
ownerEpoch: 2,
sessionId: "session-1",
}),
).toBeUndefined();
});
it.each(["bootstrapping", "ready", "idle"] as const)(
"tears down a persisted %s lease when mismatched npm preparation fails",
async (state) => {
const environmentId = `worker-prepare-${state}`;
const bootstrapping = support.seedBootstrapping(environmentId, "npm");
if (state !== "bootstrapping") {
const ready = support.testState.store.transition({
environmentId,
from: bootstrapping.state,
to: "ready",
patch: support.readyPatch(environmentId, {
...support.BOOTSTRAP_RECEIPT,
bundleHash: "c".repeat(64),
}),
});
if (state === "idle") {
support.testState.store.transition({ environmentId, from: ready.state, to: "idle" });
}
}
support.testState.prepareInstallation = vi.fn(async (install) => {
if (install === "bundle") {
return support.BUNDLE_ARTIFACT;
}
throw new Error("released npm artifact is unavailable");
});
const order: string[] = [];
const tunnelManager = {
status: () => "connected" as const,
start: vi.fn(),
stop: vi.fn(async () => {
order.push("tunnel-stop");
}),
stopAll: vi.fn(async () => {}),
} as unknown as WorkerTunnelManager;
const destroy = vi.fn(async () => {
order.push("provider-destroy");
});
await support
.createService(support.createProvider({ destroy }), { tunnelManager })
.reconcileOnce();
expect(order).toEqual(["tunnel-stop", "provider-destroy"]);
expect(destroy).toHaveBeenCalledWith({
leaseId: `lease:${environmentId}`,
profile: { region: "test" },
});
expect(support.testState.store.get(environmentId)).toMatchObject({
state: "failed",
leaseId: null,
sshEndpoint: null,
teardownTerminalState: "failed",
lastError: "released npm artifact is unavailable",
});
expect(support.testState.prepareInstallation).toHaveBeenCalledWith("bundle");
expect(support.testState.prepareInstallation).toHaveBeenCalledWith("npm");
expect(support.testState.bootstrapWorker).not.toHaveBeenCalled();
},
);
it("retries indeterminate teardown after a reconcile preparation failure and restart", async () => {
const environmentId = "worker-prepare-teardown-retry";
const bootstrapping = support.seedBootstrapping(environmentId, "npm");
support.testState.store.transition({
environmentId,
from: bootstrapping.state,
to: "ready",
patch: support.readyPatch(environmentId, {
...support.BOOTSTRAP_RECEIPT,
bundleHash: "c".repeat(64),
}),
});
support.testState.prepareInstallation = vi.fn(async (install) => {
if (install === "bundle") {
return support.BUNDLE_ARTIFACT;
}
throw new Error("released npm artifact is unavailable");
});
let teardownFails = true;
const destroy = vi.fn(async () => {
if (teardownFails) {
throw new Error("provider teardown timed out");
}
});
const provider = support.createProvider({ destroy });
const workerService = support.createService(provider);
await workerService.reconcileOnce();
expect(support.testState.store.get(environmentId)).toMatchObject({
state: "destroying",
leaseId: `lease:${environmentId}`,
teardownTerminalState: "failed",
lastError: "released npm artifact is unavailable",
});
await workerService.stop();
teardownFails = false;
await support.createService(provider).reconcileOnce();
expect(destroy).toHaveBeenCalledTimes(2);
expect(support.testState.store.get(environmentId)).toMatchObject({
state: "failed",
leaseId: null,
sshEndpoint: null,
teardownTerminalState: "failed",
lastError: "released npm artifact is unavailable",
});
});
it("uses the snapshotted npm selection after live config changes", async () => {
support.getDevelopmentProfile().install = "npm";
const provider = support.createProvider({
provision: async () => {
support.getDevelopmentProfile().install = "bundle";
return { leaseId: "lease-npm", ssh: support.SSH_ENDPOINT };
},
});
const result = await support.createService(provider).create("development", "request-npm");
expect(result).toMatchObject({
state: "ready",
profileSnapshot: { install: "npm" },
});
expect(support.testState.prepareInstallation).toHaveBeenCalledWith("npm");
expect(support.testState.bootstrapWorker).toHaveBeenCalledWith({
operationId: result.provisionOperationId,
sshEndpoint: support.SSH_ENDPOINT,
installation: support.NPM_ARTIFACT,
resolveIdentity: expect.any(Function),
signal: expect.any(AbortSignal),
});
});
it("orphans unknown active leases and adopts unknown expected teardown", async () => {
support.seedReady("worker-unknown");
support.seedReady("worker-transient");
support.seedReady("worker-destroyed-unknown");
support.testState.store.requestDestroy({
environmentId: "worker-destroyed-unknown",
state: "ready",
});
support.testState.store.transition({
environmentId: "worker-destroyed-unknown",
from: "ready",
to: "draining",
});
support.testState.store.transition({
environmentId: "worker-destroyed-unknown",
from: "draining",
to: "destroying",
});
const provider = support.createProvider({
inspect: async ({ leaseId }) => {
if (leaseId !== "lease:worker-transient") {
return { status: "unknown" };
}
throw new Error("provider temporarily unavailable");
},
});
const failedTunnelStops = new Set<string>();
const tunnelManager = {
start: vi.fn(),
stop: vi.fn(async (environmentId: string) => {
if (!failedTunnelStops.has(environmentId)) {
failedTunnelStops.add(environmentId);
throw new Error("tunnel stop interrupted");
}
}),
stopAll: vi.fn(async () => {}),
status: () => "connected" as const,
} as unknown as WorkerTunnelManager;
const workerService = support.createService(provider, { tunnelManager });
const admitted = await workerService.admitWorker(support.admissionFor("worker-unknown"));
if (!admitted.ok) {
throw new Error("fixture worker admission failed");
}
await workerService.reconcileOnce();
expect(support.testState.store.get("worker-unknown")?.state).toBe("draining");
expect(support.testState.store.get("worker-destroyed-unknown")?.state).toBe("destroying");
expect(workerService.validateWorkerConnection(admitted.identity)).toBe("credential-replaced");
expect(support.testState.store.get("worker-transient")).toMatchObject({
state: "ready",
lastError: "provider temporarily unavailable",
});
await workerService.reconcileOnce();
expect(tunnelManager.stop).toHaveBeenCalledTimes(4);
expect(support.testState.store.get("worker-unknown")?.state).toBe("orphaned");
expect(support.testState.store.get("worker-destroyed-unknown")).toMatchObject({
state: "destroyed",
});
});
it.each([
null,
{ status: "future" },
{ status: "active", sharedHost: "yes" },
{ status: "unknown", sharedHost: true },
])("retains retryable state for malformed inspection result %#", async (inspection) => {
support.seedReady("worker-malformed");
const provider = support.createProvider({ inspect: async () => inspection as never });
await support.createService(provider).reconcileOnce();
expect(support.testState.store.get("worker-malformed")).toMatchObject({
state: "ready",
lastError: expect.stringContaining("invalid inspection"),
});
});
it("records provider-proven teardown without local intent as a failure", async () => {
support.seedReady("worker-destroyed-ready");
support.seedReady("worker-destroyed-attached");
support.testState.store.transition({
environmentId: "worker-destroyed-attached",
from: "ready",
to: "attached",
patch: support.attachedPatch("worker-destroyed-attached", "session-1"),
});
support.seedReady("worker-destroyed-draining");
support.testState.store.transition({
environmentId: "worker-destroyed-draining",
from: "ready",
to: "draining",
});
const provider = support.createProvider({
inspect: async () => ({ status: "destroyed" }),
destroy: async () => {
throw new Error("destroy must not run for provider-proven teardown");
},
});
const workerService = support.createService(provider);
await workerService.reconcileOnce();
for (const environmentId of [
"worker-destroyed-ready",
"worker-destroyed-attached",
"worker-destroyed-draining",
]) {
expect(support.testState.store.get(environmentId)).toMatchObject({
state: "failed",
attachedSessionIds: [],
lastError: "Worker environment disappeared before teardown was requested",
});
}
});
it("adopts a provider-proven bootstrap teardown as failed after restart", async () => {
const bootstrapping = support.seedBootstrapping("worker-bootstrap-teardown-crash");
const requested = support.testState.store.requestDestroy({
environmentId: bootstrapping.environmentId,
state: bootstrapping.state,
terminalState: "failed",
lastError: "remote bootstrap failed",
});
const draining = support.testState.store.transition({
environmentId: requested.environmentId,
from: requested.state,
to: "draining",
patch: { lastError: requested.lastError },
});
support.testState.store.transition({
environmentId: draining.environmentId,
from: draining.state,
to: "destroying",
patch: { lastError: draining.lastError },
});
const destroy = vi.fn(async () => {});
const provider = support.createProvider({
inspect: async () => ({ status: "destroyed" }),
destroy,
});
support.testState.providersEnabled = false;
const workerService = support.createService(provider);
await workerService.reconcileOnce();
expect(support.testState.store.get(bootstrapping.environmentId)).toMatchObject({
state: "destroying",
lastError: "remote bootstrap failed",
});
support.testState.providersEnabled = true;
await workerService.reconcileOnce();
expect(destroy).not.toHaveBeenCalled();
expect(support.testState.store.get(bootstrapping.environmentId)).toMatchObject({
state: "failed",
leaseId: null,
sshEndpoint: null,
teardownTerminalState: "failed",
lastError: "remote bootstrap failed",
});
});
it("keeps a failed destroy retryable and makes completed destroy idempotent", async () => {
support.seedReady("worker-destroy");
support.testState.config.cloudWorkers!.profiles = {};
let fail = true;
const destroyed: WorkerLifecycleLease[] = [];
const provider = support.createProvider({
destroy: async (lease) => {
destroyed.push(lease);
if (fail) {
throw new Error("destroy timeout");
}
},
});
const workerService = support.createService(provider);
await expect(workerService.destroy("worker-destroy")).rejects.toMatchObject({
code: "provider_failure",
} satisfies Partial<WorkerEnvironmentServiceError>);
expect(support.testState.store.get("worker-destroy")).toMatchObject({
state: "destroying",
lastError: "destroy timeout",
});
fail = false;
await workerService.reconcileOnce();
expect(support.testState.store.get("worker-destroy")).toMatchObject({ state: "destroyed" });
await workerService.destroy("worker-destroy");
expect(destroyed).toEqual([
{ leaseId: "lease:worker-destroy", profile: { region: "test" } },
{ leaseId: "lease:worker-destroy", profile: { region: "test" } },
]);
});
it("adopts an unpersisted provision result before destroying", async () => {
const intent = support.testState.store.createIntent({
environmentId: "worker-pending-destroy",
providerId: "fake",
profileId: "development",
profileSnapshot: { settings: { region: "test" } },
provisionOperationId: "provision:pending-destroy",
});
support.testState.store.transition({
environmentId: intent.environmentId,
from: "requested",
to: "provisioning",
});
const destroyed: WorkerLifecycleLease[] = [];
const provider = support.createProvider({
provision: async () => {
expect(
support.testState.store.get(intent.environmentId)?.destroyRequestedAtMs,
).not.toBeNull();
return { leaseId: "lease-1", ssh: support.SSH_ENDPOINT };
},
destroy: async (lease) => void destroyed.push(lease),
});
const result = await support.createService(provider).destroy(intent.environmentId);
expect(result.state).toBe("destroyed");
expect(destroyed).toEqual([{ leaseId: "lease-1", profile: { region: "test" } }]);
});
it("retains teardown intent across an indeterminate provision failure", async () => {
support.testState.prepareInstallation = vi.fn(async () => {
throw new Error("bundle preparation must not block teardown adoption");
});
const intent = support.testState.store.createIntent({
environmentId: "worker-pending-destroy-retry",
providerId: "fake",
profileId: "development",
profileSnapshot: { settings: { region: "test" } },
provisionOperationId: "provision:pending-destroy-retry",
});
support.testState.store.transition({
environmentId: intent.environmentId,
from: "requested",
to: "provisioning",
});
let provisionFails = true;
const destroyed: WorkerLifecycleLease[] = [];
const provider = support.createProvider({
provision: async () => {
if (provisionFails) {
throw new Error("provision outcome unknown");
}
return { leaseId: "lease-retried", ssh: support.SSH_ENDPOINT };
},
destroy: async (lease) => void destroyed.push(lease),
});
const workerService = support.createService(provider);
support.testState.providersEnabled = false;
await expect(workerService.destroy(intent.environmentId)).rejects.toMatchObject({
code: "provider_not_found",
} satisfies Partial<WorkerEnvironmentServiceError>);
expect(support.testState.store.get(intent.environmentId)?.destroyRequestedAtMs).not.toBeNull();
support.testState.providersEnabled = true;
await expect(workerService.destroy(intent.environmentId)).rejects.toMatchObject({
code: "provider_failure",
} satisfies Partial<WorkerEnvironmentServiceError>);
expect(support.testState.store.get(intent.environmentId)).toMatchObject({
state: "provisioning",
destroyRequestedAtMs: expect.any(Number),
});
provisionFails = false;
await workerService.reconcileOnce();
expect(support.testState.store.get(intent.environmentId)?.state).toBe("destroyed");
expect(destroyed).toEqual([{ leaseId: "lease-retried", profile: { region: "test" } }]);
expect(support.testState.prepareInstallation).not.toHaveBeenCalled();
});
});
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import type { WorkerDesktopApp, WorkerProfile } from "../../plugins/capability-provider.types.js";
import type { WorkerSessionPlacementRecord } from "./placement-record.js";
import type { WorkerEnvironmentState } from "./state.js";
@@ -7,6 +8,17 @@ import type {
WorkerTunnelStatus,
} from "./tunnel-contract.js";
export function deriveEnvironmentIntent(idempotencyKey: string): {
environmentId: string;
provisionOperationId: string;
} {
const digest = createHash("sha256").update(idempotencyKey).digest("hex");
return {
environmentId: `worker:${digest.slice(0, 32)}`,
provisionOperationId: `provision:v2:${digest}`,
};
}
/** Non-secret worker projection available to Gateway request handlers. */
export type WorkerEnvironmentServiceRecord = {
environmentId: string;
@@ -0,0 +1,226 @@
import { describe, expect, it, vi } from "vitest";
import * as support from "./service.test-support.js";
import type { WorkerTunnelManager } from "./tunnel.js";
type WorkerEnvironmentServiceError = support.WorkerEnvironmentServiceError;
type WorkerLifecycleLease = support.WorkerLifecycleLease;
describe("worker environment service", () => {
support.setupWorkerEnvironmentServiceSuite();
it("reconciles unrelated leases concurrently", async () => {
support.seedReady("worker-concurrent-a");
support.seedReady("worker-concurrent-b");
let release: (() => void) | undefined;
const blocked = new Promise<void>((resolve) => {
release = resolve;
});
const inspected: WorkerLifecycleLease[] = [];
const provider = support.createProvider({
inspect: async (lease) => {
inspected.push(lease);
await blocked;
return { status: "active" };
},
});
const reconciliation = support.createService(provider).reconcileOnce();
try {
await support.waitForFast(() => expect(inspected).toHaveLength(2));
} finally {
release?.();
}
await reconciliation;
expect(new Set(inspected.map(({ leaseId }) => leaseId))).toEqual(
new Set(["lease:worker-concurrent-a", "lease:worker-concurrent-b"]),
);
});
it("waits for timed-out provider work during shutdown", async () => {
let finishProvision: (() => void) | undefined;
const provisionPending = new Promise<void>((resolve) => {
finishProvision = resolve;
});
const provision = vi.fn(async () => {
await provisionPending;
return { leaseId: "lease-stop-timeout", ssh: support.SSH_ENDPOINT };
});
const stopAll = vi.fn(async () => {});
const tunnelManager = {
stopAll,
} as unknown as WorkerTunnelManager;
const workerService = support.createService(support.createProvider({ provision }), {
providerCallTimeoutMs: 5,
tunnelManager,
});
const creation = workerService.create("development", "request-stop-provider-timeout");
const creationResult = expect(creation).rejects.toMatchObject({
code: "provider_failure",
} satisfies Partial<WorkerEnvironmentServiceError>);
let stopped = false;
let stopping: Promise<void> | undefined;
try {
await support.waitForFast(() => expect(provision).toHaveBeenCalledOnce());
await creationResult;
stopping = workerService.stop().then(() => {
stopped = true;
});
await support.waitForFast(() => expect(stopAll).toHaveBeenCalledOnce());
await Promise.resolve();
expect(stopped).toBe(false);
} finally {
finishProvision?.();
}
await stopping;
expect(stopped).toBe(true);
});
it("owns and clears one periodic reconciliation timer", async () => {
vi.useFakeTimers();
const liveEvents = support.createLiveEvents();
const workerService = support.createService(support.createProvider(), { liveEvents });
workerService.start();
workerService.start();
expect(liveEvents.start).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(1);
await workerService.stop();
expect(liveEvents.clear).toHaveBeenCalledTimes(2);
expect(vi.getTimerCount()).toBe(0);
});
it("rejects a create queued before service shutdown once its lock is acquired", async () => {
let finishBootstrap: (() => void) | undefined;
const bootstrapPending = new Promise<void>((resolve) => {
finishBootstrap = resolve;
});
support.testState.bootstrapWorker = vi.fn(async () => {
await bootstrapPending;
return support.BOOTSTRAP_RECEIPT;
});
const provision = vi.fn(support.createProvider().provision);
const workerService = support.createService(support.createProvider({ provision }));
const first = workerService.create("development", "request-queued-before-stop");
await support.waitForFast(() =>
expect(support.testState.bootstrapWorker).toHaveBeenCalledTimes(1),
);
const queued = workerService.create("development", "request-queued-before-stop");
const queuedResult = expect(queued).rejects.toMatchObject({
code: "invalid_state",
} satisfies Partial<WorkerEnvironmentServiceError>);
const stopping = workerService.stop();
finishBootstrap?.();
await expect(first).resolves.toMatchObject({ state: "ready" });
await queuedResult;
await stopping;
expect(provision).toHaveBeenCalledTimes(1);
});
it("drains a destroy accepted before service shutdown while it waits for the lock", async () => {
let finishBootstrap: (() => void) | undefined;
const bootstrapPending = new Promise<void>((resolve) => {
finishBootstrap = resolve;
});
support.testState.bootstrapWorker = vi.fn(async () => {
await bootstrapPending;
return support.BOOTSTRAP_RECEIPT;
});
const destroy = vi.fn(async () => {});
const workerService = support.createService(support.createProvider({ destroy }));
const creation = workerService.create("development", "request-destroy-before-stop");
await support.waitForFast(() =>
expect(support.testState.bootstrapWorker).toHaveBeenCalledTimes(1),
);
const environmentId = support.testState.store.list()[0]?.environmentId;
expect(environmentId).toBeTruthy();
const teardown = workerService.destroy(environmentId!);
const teardownResult = expect(teardown).resolves.toMatchObject({ state: "destroyed" });
const stopping = workerService.stop();
finishBootstrap?.();
await expect(creation).resolves.toMatchObject({ state: "ready" });
await teardownResult;
await stopping;
expect(destroy).toHaveBeenCalledTimes(1);
});
it("drains accepted operations after reconciliation rejects during shutdown", async () => {
const durableStore = support.testState.store;
support.testState.store = {
...support.testState.store,
listForReconcile() {
throw new Error("reconcile database read failed");
},
};
let finishBootstrap: (() => void) | undefined;
const bootstrapPending = new Promise<void>((resolve) => {
finishBootstrap = resolve;
});
support.testState.bootstrapWorker = vi.fn(async () => {
await bootstrapPending;
return support.BOOTSTRAP_RECEIPT;
});
const workerService = support.createService(support.createProvider());
const creation = workerService.create("development", "request-stop-after-reconcile-failure");
await support.waitForFast(() =>
expect(support.testState.bootstrapWorker).toHaveBeenCalledTimes(1),
);
const reconciliation = workerService.reconcileOnce();
const reconciliationResult = expect(reconciliation).rejects.toThrow(
"reconcile database read failed",
);
let stopped = false;
const stopping = workerService.stop().then(() => {
stopped = true;
});
await reconciliationResult;
await Promise.resolve();
expect(stopped).toBe(false);
finishBootstrap?.();
await expect(creation).resolves.toMatchObject({ state: "ready" });
await stopping;
expect(stopped).toBe(true);
expect(durableStore.list()).toHaveLength(1);
});
it("starts without blocking gateway startup and drains reconciliation on stop", async () => {
support.seedReady("worker-slow-inspection");
let finishInspection: (() => void) | undefined;
const inspectionPending = new Promise<void>((resolve) => {
finishInspection = resolve;
});
const inspect = vi.fn(async () => {
await inspectionPending;
return { status: "active" as const };
});
const workerService = support.createService(support.createProvider({ inspect }));
workerService.start();
await support.waitForFast(() => expect(inspect).toHaveBeenCalledTimes(1));
let stopped = false;
const stopping = workerService.stop().then(() => {
stopped = true;
});
await Promise.resolve();
expect(stopped).toBe(false);
finishInspection?.();
await stopping;
expect(stopped).toBe(true);
await expect(workerService.create("development", "request-after-stop")).rejects.toMatchObject({
code: "invalid_state",
} satisfies Partial<WorkerEnvironmentServiceError>);
await expect(workerService.destroy("worker-slow-inspection")).rejects.toMatchObject({
code: "invalid_state",
} satisfies Partial<WorkerEnvironmentServiceError>);
});
});
@@ -0,0 +1,458 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeEach, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.js";
import type {
WorkerDesktopEndpoint,
WorkerProvider,
WorkerSshEndpoint,
} from "../../plugins/types.js";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
type OpenClawStateDatabase,
} from "../../state/openclaw-state-db.js";
import type { WorkerInstallationArtifact } from "./bundle.js";
import type { WorkerConnectionIdentity } from "./connection-identity.js";
import { hashWorkerCredential } from "./credential.js";
import { createWorkerInferenceStore } from "./inference-store.js";
import { createWorkerEnvironmentService, type WorkerEnvironmentService } from "./service.js";
import { createWorkerEnvironmentStore, type WorkerEnvironmentStore } from "./store.js";
export function waitForFast<T>(
callback: () => T | Promise<T>,
options: { timeout?: number; interval?: number } = {},
) {
return vi.waitFor(callback, { interval: 1, ...options });
}
const HOST_KEY = [["ssh", "ed25519"].join("-"), "AAAA"].join(" ");
export type WorkerEnvironmentServiceOptions = Parameters<typeof createWorkerEnvironmentService>[0];
export type WorkerEnvironmentServiceError = Error & { code: string };
export const SSH_ENDPOINT: WorkerSshEndpoint = {
host: "worker.example.test",
port: 22,
user: "openclaw",
hostKey: HOST_KEY,
keyRef: { source: "file", provider: "worker-keys", id: "/development-key" },
};
export const DESKTOP: WorkerDesktopEndpoint = {
protocol: "rfb",
port: 5900,
passwordFilePath: "/var/lib/crabbox/vnc.password",
apps: [
{
id: "browser",
executablePath: "/usr/local/bin/openclaw-worker-browser",
cdpPort: 9222,
},
{ id: "terminal", executablePath: "/usr/local/bin/openclaw-worker-terminal" },
],
};
export const BUNDLE_HASH = "a".repeat(64);
export const BUNDLE_ARTIFACT: WorkerInstallationArtifact = {
install: "bundle",
bundleHash: BUNDLE_HASH,
openclawVersion: "2026.7.2",
protocolFeatures: [],
tarballSha256: "b".repeat(64),
tarballPath: "/gateway/cache/worker-bundle.tgz",
};
export const NPM_ARTIFACT: WorkerInstallationArtifact = {
install: "npm",
bundleHash: BUNDLE_HASH,
openclawVersion: "2026.7.2",
packageIntegrity: `sha512-${Buffer.alloc(64).toString("base64")}`,
protocolFeatures: [],
packageSpec: "openclaw@2026.7.2",
};
export const BOOTSTRAP_RECEIPT = {
bundleHash: BUNDLE_HASH,
openclawVersion: "2026.7.2",
protocolFeatures: [],
};
export const CREDENTIAL = ["worker", "credential", "fixture"].join("-");
const LIVE_EVENT_ACK = { ok: true as const, result: { ackedSeq: 1 } };
export const LIVE_EVENT = {
runEpoch: 1,
lastAckedSeq: 0,
seq: 1,
runId: "run-1",
event: { kind: "assistant" as const, payload: { text: "hi", delta: "hi" } },
};
export type WorkerLifecycleLease = Parameters<WorkerProvider["inspect"]>[0];
type TranscriptRequest = Parameters<WorkerEnvironmentService["commitTranscript"]>[1];
type TranscriptOverrides = Partial<Pick<TranscriptRequest, "baseLeafId" | "runEpoch" | "seq">>;
type LiveEventRequest = Parameters<WorkerEnvironmentService["pushLiveEvent"]>[1];
type LiveOpts = Partial<Pick<LiveEventRequest, "lastAckedSeq" | "runEpoch" | "runId" | "seq">>;
export const testState = {} as {
root: string;
stateDb: OpenClawStateDatabase;
store: WorkerEnvironmentStore;
service: WorkerEnvironmentService | undefined;
config: OpenClawConfig;
nowMs: number;
providersEnabled: boolean;
prepareInstallation: WorkerEnvironmentServiceOptions["prepareInstallation"];
bootstrapWorker: WorkerEnvironmentServiceOptions["bootstrapWorker"];
};
export function setupWorkerEnvironmentServiceSuite() {
beforeEach(async () => {
testState.root = await fs.mkdtemp(
path.join(await fs.realpath(os.tmpdir()), "openclaw-worker-service-"),
);
testState.stateDb = openOpenClawStateDatabase({
env: { OPENCLAW_STATE_DIR: testState.root },
});
testState.nowMs = 1_000;
testState.providersEnabled = true;
testState.store = createWorkerEnvironmentStore({
database: testState.stateDb,
now: () => testState.nowMs,
});
testState.config = {
cloudWorkers: {
desktop: true,
profiles: {
development: {
provider: "fake",
settings: { region: "test" },
},
},
},
};
testState.prepareInstallation = vi.fn(async (install) =>
install === "bundle" ? BUNDLE_ARTIFACT : NPM_ARTIFACT,
);
testState.bootstrapWorker = vi.fn(async ({ installation }) => ({
bundleHash: installation.bundleHash,
openclawVersion: installation.openclawVersion,
protocolFeatures: [...installation.protocolFeatures],
}));
});
afterEach(async () => {
await testState.service?.stop();
vi.useRealTimers();
closeOpenClawStateDatabaseForTest();
await fs.rm(testState.root, { recursive: true, force: true });
});
}
export function getDevelopmentProfile() {
return expectDefined(
testState.config.cloudWorkers?.profiles?.development,
"development worker profile",
);
}
export function createService(
provider: WorkerProvider,
serviceOptions: Partial<
Pick<
WorkerEnvironmentServiceOptions,
| "applyTranscriptCommit"
| "bootstrapCallTimeoutMs"
| "executeInference"
| "providerCallTimeoutMs"
| "resolveSshIdentity"
| "resolveWorkerGateway"
| "tunnelManager"
| "generateWorkerCredential"
| "liveEvents"
| "placementStore"
| "workerCredentialTtlMs"
>
> = {},
) {
testState.service = createWorkerEnvironmentService({
store: testState.store,
getConfig: () => testState.config,
resolveProvider: (providerId) =>
testState.providersEnabled && providerId === "fake" ? provider : undefined,
prepareInstallation: testState.prepareInstallation,
bootstrapWorker: testState.bootstrapWorker,
resolveSshIdentity: async () => ({ kind: "path", path: "/keys/worker" }),
resolveWorkerGateway: () => ({ host: "127.0.0.1", port: 18_789 }),
generateWorkerCredential: () => CREDENTIAL,
executeInference: async () => ({
type: "error",
reason: "cancelled",
message: "Inference cancelled",
}),
inferenceStore: createWorkerInferenceStore({
database: testState.stateDb,
now: () => testState.nowMs,
}),
now: () => testState.nowMs,
reconcileIntervalMs: 25,
...serviceOptions,
});
return testState.service;
}
export function createProvider(overrides: Partial<WorkerProvider> = {}): WorkerProvider {
return {
id: "fake",
provision: async () => ({ leaseId: "lease-1", ssh: SSH_ENDPOINT }),
inspect: async () => ({ status: "active" }),
destroy: async () => {},
...overrides,
};
}
export function createLiveEvents(overrides: Record<string, unknown> = {}) {
return {
apply: vi.fn(() => LIVE_EVENT_ACK),
bindSession: vi.fn(() => true),
clear: vi.fn(),
clearEnvironment: vi.fn(),
rotateCredential: vi.fn(() => true),
start: vi.fn(),
...overrides,
};
}
export function seedBootstrapping(
environmentId: string,
install?: WorkerInstallationArtifact["install"],
sharedHost = false,
) {
const intent = testState.store.createIntent({
environmentId,
providerId: "fake",
profileId: "development",
profileSnapshot: { ...(install ? { install } : {}), settings: { region: "test" } },
provisionOperationId: `provision:${environmentId}`,
});
const provisioning = testState.store.transition({
environmentId,
from: intent.state,
to: "provisioning",
});
return testState.store.transition({
environmentId,
from: provisioning.state,
to: "bootstrapping",
patch: { leaseId: `lease:${environmentId}`, sshEndpoint: SSH_ENDPOINT, sharedHost },
});
}
export function seedReady(
environmentId: string,
install?: WorkerInstallationArtifact["install"],
sharedHost = false,
) {
const bootstrapping = seedBootstrapping(environmentId, install, sharedHost);
return testState.store.transition({
environmentId,
from: bootstrapping.state,
to: "ready",
patch: readyPatch(environmentId),
});
}
export function seedReadyDesktop(environmentId: string, desktop: WorkerDesktopEndpoint = DESKTOP) {
const intent = testState.store.createIntent({
environmentId,
providerId: "fake",
profileId: "development",
profileSnapshot: { settings: { region: "test", desktop: true } },
provisionOperationId: `provision:${environmentId}`,
});
const provisioning = testState.store.transition({
environmentId,
from: intent.state,
to: "provisioning",
});
const bootstrapping = testState.store.transition({
environmentId,
from: provisioning.state,
to: "bootstrapping",
patch: {
leaseId: `lease:${environmentId}`,
sshEndpoint: SSH_ENDPOINT,
desktop,
},
});
return testState.store.transition({
environmentId,
from: bootstrapping.state,
to: "ready",
patch: readyPatch(environmentId),
});
}
export function readyPatch(environmentId: string, receipt = BOOTSTRAP_RECEIPT) {
return {
bootstrapReceipt: receipt,
credential: {
credentialHash: hashWorkerCredential([CREDENTIAL, environmentId].join("-")),
sessionId: null,
rpcSetVersion: 1,
expiresAtMs: testState.nowMs + 10_000,
},
};
}
export function attachedPatch(environmentId: string, sessionId: string) {
return {
attachedSessionIds: [sessionId],
credential: {
credentialHash: hashWorkerCredential([CREDENTIAL, environmentId, sessionId].join("-")),
sessionId,
rpcSetVersion: 1,
expiresAtMs: testState.nowMs + 10_000,
},
};
}
export function admissionFor(environmentId: string) {
return {
environmentId,
credential: [CREDENTIAL, environmentId].join("-"),
sessionId: null,
runId: null,
ownerEpoch: 1,
rpcSetVersion: 1,
handshake: BOOTSTRAP_RECEIPT,
};
}
export function seedAttachedIdentity(
environmentId: string,
sessionId: string,
): WorkerConnectionIdentity {
const ready = seedReady(environmentId);
const attached = testState.store.transition({
environmentId,
from: ready.state,
to: "attached",
patch: attachedPatch(environmentId, sessionId),
});
const credential = testState.store.getCredential(environmentId);
if (!credential || !attached.bootstrapReceipt) {
throw new Error("attached worker fixture is incomplete");
}
return {
environmentId,
credentialHash: credential.credentialHash,
bundleHash: credential.bundleHash,
sessionId,
runId: "run-1",
ownerEpoch: attached.ownerEpoch,
rpcSetVersion: credential.rpcSetVersion,
protocolFeatures: [...attached.bootstrapReceipt.protocolFeatures],
credentialExpiresAtMs: credential.expiresAtMs,
};
}
export function inferenceRequest(
identity: WorkerConnectionIdentity,
): Parameters<WorkerEnvironmentService["startInference"]>[1] {
return {
runEpoch: identity.ownerEpoch,
sessionId: identity.sessionId ?? "session-missing",
runId: identity.runId ?? "run-missing",
turnId: "turn-inference",
modelRef: { provider: "fake", model: "model-test" },
context: { messages: [] },
options: {},
};
}
export function transcriptRequest(
identity: WorkerConnectionIdentity,
text: string,
overrides: TranscriptOverrides = {},
): TranscriptRequest {
return {
runEpoch: identity.ownerEpoch,
seq: 1,
baseLeafId: null,
messages: [
{
role: "user",
content: [{ type: "text", text }],
timestamp: 1,
},
],
...overrides,
};
}
function liveEventRequest(
identity: WorkerConnectionIdentity,
event: LiveEventRequest["event"],
overrides: LiveOpts = {},
): LiveEventRequest {
return {
runEpoch: identity.ownerEpoch,
lastAckedSeq: 0,
seq: 1,
runId: identity.runId ?? "run-missing",
event,
...overrides,
};
}
export function assistantEvent(
identity: WorkerConnectionIdentity,
text: string,
extra: LiveOpts = {},
) {
return liveEventRequest(identity, { kind: "assistant", payload: { text, delta: text } }, extra);
}
export function terminalEvent(identity: WorkerConnectionIdentity, overrides: LiveOpts = {}) {
return liveEventRequest(
identity,
{ kind: "lifecycle", payload: { phase: "end", endedAt: 2 } },
overrides,
);
}
export function successfulTranscriptCommit(entryId: string, beforeCommit?: () => Promise<unknown>) {
return vi.fn(async () => {
await beforeCommit?.();
return { ok: true as const, result: { entryIds: [entryId], newLeafId: entryId } };
});
}
export function sequencedLiveEvents(ackedSeq = (seq: number) => seq) {
const apply = vi.fn(({ request }: { request: LiveEventRequest }) => ({
ok: true as const,
result: { ackedSeq: ackedSeq(request.seq) },
}));
return { apply, liveEvents: createLiveEvents({ apply }) };
}
export function placementBinding(identity: WorkerConnectionIdentity) {
return {
sessionId: identity.sessionId ?? "session-missing",
environmentId: identity.environmentId,
ownerEpoch: identity.ownerEpoch,
runId: identity.runId ?? "run-missing",
};
}
export function placementHarness(
environmentId: string,
sessionId: string,
serviceOptions: Parameters<typeof createService>[1] = {},
) {
const identity = seedAttachedIdentity(environmentId, sessionId);
const placementStore = {
hasWorkerTurn: vi.fn(() => true),
validateWorkerTurn: vi.fn(() => true),
isWorkerTurnToolAuthorized: vi.fn(() => true),
updateAckCursors: vi.fn(),
};
const workerService = createService(createProvider(), { ...serviceOptions, placementStore });
return { identity, placementStore, workerService };
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,563 @@
import { describe, expect, it, vi } from "vitest";
import { hashWorkerCredential } from "./credential.js";
import * as support from "./service.test-support.js";
type WorkerEnvironmentServiceOptions = support.WorkerEnvironmentServiceOptions;
describe("worker environment service", () => {
support.setupWorkerEnvironmentServiceSuite();
it("admits an npm-installed worker from canonical bundle identity without registry access", async () => {
const environmentId = "worker-npm-admission";
support.seedReady(environmentId, "npm");
support.testState.prepareInstallation = vi.fn(async (install) => {
if (install === "npm") {
throw new Error("registry unavailable");
}
return support.BUNDLE_ARTIFACT;
});
const workerService = support.createService(support.createProvider());
await expect(
workerService.admitWorker(support.admissionFor(environmentId)),
).resolves.toMatchObject({
ok: true,
});
expect(support.testState.prepareInstallation).toHaveBeenCalledTimes(1);
expect(support.testState.prepareInstallation).toHaveBeenCalledWith("bundle");
});
it("fences transcript commits by current epoch and exact session credential binding", async () => {
const environmentId = "worker-transcript-fence";
const sessionId = "session-transcript-fence";
const identity = support.seedAttachedIdentity(environmentId, sessionId);
const applyTranscriptCommit = support.successfulTranscriptCommit("entry-1");
const workerService = support.createService(support.createProvider(), {
applyTranscriptCommit,
});
const request = support.transcriptRequest(identity, "hello");
await expect(workerService.commitTranscript(identity, request)).resolves.toMatchObject({
ok: true,
});
expect(applyTranscriptCommit).toHaveBeenCalledOnce();
await expect(
workerService.commitTranscript(identity, {
...request,
runEpoch: identity.ownerEpoch + 1,
seq: 2,
}),
).resolves.toEqual({ ok: false, reason: "epoch-mismatch" });
support.testState.stateDb.db
.prepare("UPDATE worker_environment_credentials SET session_id = ? WHERE environment_id = ?")
.run("session-other", environmentId);
await expect(workerService.commitTranscript(identity, { ...request, seq: 2 })).resolves.toEqual(
{ ok: false, reason: "session-not-attached" },
);
expect(applyTranscriptCommit).toHaveBeenCalledOnce();
});
it("admits only a gateway-preclaimed worker placement and fences later requests", async () => {
const environmentId = "worker-placement-fence";
const sessionId = "session-placement-fence";
const { identity, placementStore, workerService } = support.placementHarness(
environmentId,
sessionId,
);
const admission = {
environmentId,
credential: [support.CREDENTIAL, environmentId, sessionId].join("-"),
sessionId,
runId: "run-1",
ownerEpoch: identity.ownerEpoch,
rpcSetVersion: 1,
handshake: support.BOOTSTRAP_RECEIPT,
};
await expect(workerService.admitWorker(admission)).resolves.toMatchObject({ ok: true });
await expect(workerService.admitWorker(admission)).resolves.toMatchObject({ ok: true });
expect(placementStore.validateWorkerTurn).toHaveBeenLastCalledWith({
sessionId,
environmentId,
ownerEpoch: identity.ownerEpoch,
runId: "run-1",
});
expect(placementStore.validateWorkerTurn).toHaveBeenCalledTimes(2);
expect(workerService.validateWorkerConnection(identity)).toBeNull();
const warmEnvironmentId = "worker-placement-warm";
support.seedReady(warmEnvironmentId);
const warmAdmission = await workerService.admitWorker(support.admissionFor(warmEnvironmentId));
expect(warmAdmission).toMatchObject({ ok: true });
if (!warmAdmission.ok) {
throw new Error("warm worker admission failed");
}
expect(workerService.validateWorkerConnection(warmAdmission.identity)).toBeNull();
expect(placementStore.validateWorkerTurn).toHaveBeenCalledTimes(3);
placementStore.validateWorkerTurn.mockReturnValue(false);
await expect(
workerService.admitWorker({ ...admission, runId: "run-conflict" }),
).resolves.toEqual({ ok: false, reason: "placement-mismatch" });
placementStore.validateWorkerTurn.mockReturnValue(true);
support.testState.nowMs += 10_000;
expect(workerService.validateWorkerConnection(identity)).toBeNull();
expect(workerService.validateWorkerConnection(warmAdmission.identity)).toBe(
"credential-expired",
);
await expect(workerService.admitWorker(admission)).resolves.toMatchObject({
ok: true,
identity: { sessionId, runId: "run-1" },
});
placementStore.validateWorkerTurn.mockReturnValue(false);
expect(workerService.validateWorkerConnection(identity)).toBe("placement-mismatch");
vi.mocked(support.testState.prepareInstallation).mockClear();
await expect(workerService.admitWorker(admission)).resolves.toEqual({
ok: false,
reason: "credential-expired",
});
expect(support.testState.prepareInstallation).not.toHaveBeenCalled();
await expect(
workerService.commitTranscript(identity, support.transcriptRequest(identity, "fenced")),
).resolves.toEqual({ ok: false, closeReason: "placement-mismatch" });
});
it("does not rotate an expired delivered credential while its durable turn is active", async () => {
const environmentId = "worker-expired-active-turn";
const sessionId = "session-expired-active-turn";
const liveEvents = support.createLiveEvents();
const { identity, workerService } = support.placementHarness(environmentId, sessionId, {
liveEvents,
});
support.testState.store.markCredentialDelivered({
environmentId,
credentialHash: identity.credentialHash,
ownerEpoch: identity.ownerEpoch,
sessionId,
deliveredAtMs: support.testState.nowMs,
});
support.testState.nowMs = identity.credentialExpiresAtMs;
await workerService.reconcileOnce();
expect(support.testState.store.getCredential(environmentId)?.credentialHash).toBe(
identity.credentialHash,
);
expect(liveEvents.rotateCredential).not.toHaveBeenCalled();
});
it("persists worker transcript and terminal live ACK cursors", async () => {
const applyTranscriptCommit = support.successfulTranscriptCommit("entry-placement");
const { liveEvents } = support.sequencedLiveEvents();
const { identity, placementStore, workerService } = support.placementHarness(
"worker-placement-ack",
"session-placement-ack",
{
applyTranscriptCommit,
liveEvents,
},
);
const binding = support.placementBinding(identity);
await expect(
workerService.commitTranscript(
identity,
support.transcriptRequest(identity, "commit", { seq: 7 }),
),
).resolves.toMatchObject({ ok: true });
expect(placementStore.updateAckCursors).toHaveBeenCalledWith({
...binding,
transcriptSeq: 7,
});
await expect(
workerService.pushLiveEvent(identity, support.terminalEvent(identity)),
).resolves.toEqual({
ok: true,
result: { ackedSeq: 1 },
});
expect(placementStore.updateAckCursors).toHaveBeenLastCalledWith({
...binding,
liveSeq: 1,
workspaceResultPending: true,
});
});
it("uses worker finishing as the durable workspace-result fence", async () => {
const { liveEvents } = support.sequencedLiveEvents();
const { identity, placementStore, workerService } = support.placementHarness(
"worker-placement-finishing",
"session-placement-finishing",
{ liveEvents },
);
const terminal = support.terminalEvent(identity);
const finishing = {
...terminal,
event: {
kind: "lifecycle" as const,
payload: { phase: "finishing" as const, startedAt: 1, endedAt: 2 },
},
};
await expect(workerService.pushLiveEvent(identity, finishing)).resolves.toEqual({
ok: true,
result: { ackedSeq: 1 },
});
expect(placementStore.updateAckCursors).toHaveBeenLastCalledWith({
...support.placementBinding(identity),
liveSeq: 1,
workspaceResultPending: true,
});
});
it("does not ACK a transcript commit after its worker claim is fenced", async () => {
let finishCommit: (() => void) | undefined;
const commitBlocked = new Promise<void>((resolve) => {
finishCommit = resolve;
});
const applyTranscriptCommit = support.successfulTranscriptCommit(
"entry-placement-race",
() => commitBlocked,
);
const { identity, placementStore, workerService } = support.placementHarness(
"worker-placement-race",
"session-placement-race",
{ applyTranscriptCommit },
);
const commit = workerService.commitTranscript(
identity,
support.transcriptRequest(identity, "commit before claim fence"),
);
await support.waitForFast(() => expect(applyTranscriptCommit).toHaveBeenCalledOnce());
placementStore.validateWorkerTurn.mockReturnValue(false);
finishCommit?.();
await expect(commit).resolves.toEqual({ ok: false, closeReason: "placement-mismatch" });
expect(placementStore.validateWorkerTurn).toHaveBeenCalledTimes(2);
expect(placementStore.updateAckCursors).not.toHaveBeenCalled();
});
it("advances the transcript cursor when a stale-base commit consumes its sequence", async () => {
const applyTranscriptCommit = vi
.fn<NonNullable<WorkerEnvironmentServiceOptions["applyTranscriptCommit"]>>()
.mockResolvedValueOnce({ ok: false, reason: "stale-base-leaf" })
.mockResolvedValueOnce({ ok: false, reason: "invalid-batch" });
const { identity, placementStore, workerService } = support.placementHarness(
"worker-placement-stale",
"session-placement-stale",
{ applyTranscriptCommit },
);
const request = support.transcriptRequest(identity, "stale commit", {
seq: 11,
baseLeafId: "stale-leaf",
});
await expect(workerService.commitTranscript(identity, request)).resolves.toEqual({
ok: false,
reason: "stale-base-leaf",
});
expect(placementStore.updateAckCursors).toHaveBeenCalledWith({
...support.placementBinding(identity),
transcriptSeq: 11,
});
await expect(
workerService.commitTranscript(identity, { ...request, seq: 12 }),
).resolves.toEqual({ ok: false, reason: "invalid-batch" });
expect(placementStore.updateAckCursors).toHaveBeenCalledOnce();
});
it("fences after a buffered terminal event becomes acknowledged by a gap fill", async () => {
const applyTranscriptCommit = support.successfulTranscriptCommit("entry-after-terminal-gap");
const { apply: liveApply, liveEvents } = support.sequencedLiveEvents((seq) =>
seq === 1 ? 2 : 0,
);
const { identity, placementStore, workerService } = support.placementHarness(
"worker-placement-gap",
"session-placement-gap",
{
applyTranscriptCommit,
liveEvents,
},
);
await expect(
workerService.pushLiveEvent(identity, support.terminalEvent(identity, { seq: 2 })),
).resolves.toEqual({ ok: true, result: { ackedSeq: 0 } });
expect(placementStore.updateAckCursors).toHaveBeenCalledWith({
...support.placementBinding(identity),
liveSeq: 0,
workspaceResultPending: true,
});
await expect(
workerService.pushLiveEvent(identity, support.assistantEvent(identity, "fills gap")),
).resolves.toEqual({ ok: true, result: { ackedSeq: 2 } });
await expect(
workerService.commitTranscript(
identity,
support.transcriptRequest(identity, "late transcript"),
),
).resolves.toEqual({ ok: false, closeReason: "placement-mismatch" });
await expect(
workerService.pushLiveEvent(
identity,
support.assistantEvent(identity, "late", { lastAckedSeq: 2, seq: 3 }),
),
).resolves.toEqual({ ok: false, closeReason: "placement-mismatch" });
expect(applyTranscriptCommit).not.toHaveBeenCalled();
expect(liveApply).toHaveBeenCalledTimes(2);
});
it("applies a terminal ACK only after its transcript commit finishes", async () => {
let finishCommit: (() => void) | undefined;
const commitBlocked = new Promise<void>((resolve) => {
finishCommit = resolve;
});
const applyTranscriptCommit = support.successfulTranscriptCommit(
"entry-order",
() => commitBlocked,
);
const { liveEvents } = support.sequencedLiveEvents();
const { identity, placementStore, workerService } = support.placementHarness(
"worker-placement-order",
"session-placement-order",
{ applyTranscriptCommit, liveEvents },
);
const commit = workerService.commitTranscript(
identity,
support.transcriptRequest(identity, "commit before terminal"),
);
await support.waitForFast(() => expect(applyTranscriptCommit).toHaveBeenCalledOnce());
const terminal = workerService.pushLiveEvent(identity, support.terminalEvent(identity));
await Promise.resolve();
expect(placementStore.updateAckCursors).not.toHaveBeenCalled();
finishCommit?.();
await expect(commit).resolves.toMatchObject({ ok: true });
await expect(terminal).resolves.toEqual({ ok: true, result: { ackedSeq: 1 } });
expect(placementStore.updateAckCursors.mock.calls).toEqual([
[
{
...support.placementBinding(identity),
transcriptSeq: 1,
},
],
[
{
...support.placementBinding(identity),
liveSeq: 1,
workspaceResultPending: true,
},
],
]);
});
it("fences post-terminal mutations while preserving sequenced replays", async () => {
const applyTranscriptCommit = support.successfulTranscriptCommit("entry-terminal");
const { apply: liveApply, liveEvents } = support.sequencedLiveEvents();
const executeInference = vi.fn<WorkerEnvironmentServiceOptions["executeInference"]>(
async () => ({
type: "error",
reason: "provider-error",
message: "Provider request failed",
}),
);
const { identity, workerService } = support.placementHarness(
"worker-terminal-fence",
"session-terminal-fence",
{ applyTranscriptCommit, executeInference, liveEvents },
);
const transcript = support.transcriptRequest(identity, "terminal fence");
const terminal = support.terminalEvent(identity);
await expect(workerService.commitTranscript(identity, transcript)).resolves.toMatchObject({
ok: true,
});
await expect(workerService.pushLiveEvent(identity, terminal)).resolves.toEqual({
ok: true,
result: { ackedSeq: 1 },
});
await expect(workerService.commitTranscript(identity, transcript)).resolves.toMatchObject({
ok: true,
});
await expect(
workerService.commitTranscript(identity, { ...transcript, seq: 2 }),
).resolves.toEqual({ ok: false, closeReason: "placement-mismatch" });
expect(applyTranscriptCommit).toHaveBeenCalledTimes(2);
await expect(workerService.pushLiveEvent(identity, terminal)).resolves.toEqual({
ok: true,
result: { ackedSeq: 1 },
});
await expect(
workerService.pushLiveEvent(identity, support.assistantEvent(identity, "late", { seq: 2 })),
).resolves.toEqual({ ok: false, closeReason: "placement-mismatch" });
expect(liveApply).toHaveBeenCalledTimes(2);
expect(
workerService.startInference(identity, support.inferenceRequest(identity), {
connectionId: "connection-terminal-fence",
send: vi.fn(),
}),
).toEqual({ ok: false, closeReason: "placement-mismatch" });
expect(workerService.cancelInference(identity, support.inferenceRequest(identity))).toEqual({
ok: false,
closeReason: "placement-mismatch",
});
expect(executeInference).not.toHaveBeenCalled();
const rotatedCredentialHash = hashWorkerCredential(
["rotated", identity.environmentId, identity.sessionId].join("-"),
);
support.testState.stateDb.db
.prepare(
"UPDATE worker_environment_credentials SET credential_hash = ? WHERE environment_id = ?",
)
.run(rotatedCredentialHash, identity.environmentId);
const rotatedIdentity = { ...identity, credentialHash: rotatedCredentialHash };
await expect(
workerService.commitTranscript(rotatedIdentity, { ...transcript, seq: 2 }),
).resolves.toMatchObject({ ok: true });
expect(applyTranscriptCommit).toHaveBeenCalledTimes(3);
});
it("does not treat a terminal event on an already ACKed sequence as authoritative", async () => {
const applyTranscriptCommit = support.successfulTranscriptCommit("entry-after-reuse");
const { liveEvents } = support.sequencedLiveEvents();
const { identity, workerService } = support.placementHarness(
"worker-terminal-reuse",
"session-terminal-reuse",
{ applyTranscriptCommit, liveEvents },
);
const event = support.assistantEvent(identity, "first");
await expect(workerService.pushLiveEvent(identity, event)).resolves.toMatchObject({ ok: true });
await expect(
workerService.pushLiveEvent(identity, support.terminalEvent(identity)),
).resolves.toMatchObject({ ok: true });
await expect(
workerService.commitTranscript(
identity,
support.transcriptRequest(identity, "still mutable"),
),
).resolves.toMatchObject({ ok: true });
expect(applyTranscriptCommit).toHaveBeenCalledOnce();
});
it("fences inference by epoch and the durable session credential", async () => {
const identity = support.seedAttachedIdentity(
"worker-inference-fence",
"session-inference-fence",
);
const executeInference = vi.fn<WorkerEnvironmentServiceOptions["executeInference"]>(
async () => ({
type: "error",
reason: "provider-error",
message: "Provider request failed",
}),
);
const workerService = support.createService(support.createProvider(), { executeInference });
const request = support.inferenceRequest(identity);
expect(
workerService.startInference(
identity,
{ ...request, sessionId: "session-other" },
{ connectionId: "connection-a", send: vi.fn() },
),
).toEqual({ ok: false, reason: "session-not-attached" });
expect(
workerService.startInference(
identity,
{ ...request, runEpoch: request.runEpoch + 1 },
{ connectionId: "connection-b", send: vi.fn() },
),
).toEqual({ ok: false, reason: "epoch-mismatch" });
const send = vi.fn();
const started = workerService.startInference(identity, request, {
connectionId: "connection-c",
send,
});
expect(started.ok).toBe(true);
if (!started.ok) {
throw new Error("inference fixture failed to start");
}
support.testState.stateDb.db
.prepare(
"UPDATE worker_environment_credentials SET credential_hash = ? WHERE environment_id = ?",
)
.run(
hashWorkerCredential(["replacement", identity.environmentId].join("-")),
identity.environmentId,
);
started.launch();
await support.waitForFast(() => expect(send).toHaveBeenCalledOnce());
expect(executeInference).not.toHaveBeenCalled();
expect(send.mock.calls[0]?.[0]).toMatchObject({
event: "worker.inference.terminal",
payload: { outcome: { reason: "session-not-attached" } },
});
});
it("fences and rotates live credentials", async () => {
const environmentId = "worker-live";
const sessionId = "session-live";
const identity = support.seedAttachedIdentity(environmentId, sessionId);
const liveEvents = support.createLiveEvents();
let inferenceSignal: AbortSignal | undefined;
const executeInference = vi.fn<WorkerEnvironmentServiceOptions["executeInference"]>(
async ({ signal }) => {
inferenceSignal = signal;
await new Promise<void>((resolve) => {
signal.addEventListener("abort", () => resolve(), { once: true });
});
return { type: "error", reason: "cancelled", message: "Inference cancelled" };
},
);
const workerService = support.createService(support.createProvider(), {
executeInference,
liveEvents,
});
const request = { ...support.LIVE_EVENT, runEpoch: identity.ownerEpoch };
const push = workerService.pushLiveEvent.bind(workerService, identity);
await push(request);
await expect(push({ ...request, runEpoch: identity.ownerEpoch + 1 })).resolves.toEqual({
ok: false,
details: { reason: "epoch-mismatch" },
});
const started = workerService.startInference(identity, support.inferenceRequest(identity), {
connectionId: "connection-rotation",
send: vi.fn(),
});
if (!started.ok) {
throw new Error("inference fixture failed to start");
}
started.launch();
await support.waitForFast(() => expect(executeInference).toHaveBeenCalledOnce());
support.testState.stateDb.db
.prepare("UPDATE worker_environment_credentials SET session_id = ? WHERE environment_id = ?")
.run("session-other", environmentId);
await expect(push({ ...request, seq: 2 })).resolves.toEqual({
ok: false,
details: { reason: "session-not-attached" },
});
liveEvents.rotateCredential.mockClear();
support.testState.nowMs += 10_000;
await workerService.reconcileOnce();
expect(inferenceSignal?.aborted).toBe(true);
expect(liveEvents.rotateCredential).toHaveBeenCalledWith(
expect.objectContaining({
credentialHash: support.testState.store.getCredential(environmentId)?.credentialHash,
previousCredentialHash: identity.credentialHash,
runEpoch: identity.ownerEpoch,
}),
);
});
});
@@ -0,0 +1,597 @@
import type {
WorkerConnectParams,
WorkerLiveEventParams,
WorkerProtocolCloseReason,
WorkerSessionsSendParams,
WorkerSessionsSpawnParams,
WorkerSessionToolResult,
WorkerTranscriptCommitErrorReason,
WorkerTranscriptCommitParams,
WorkerTranscriptCommitResult,
} from "../../../packages/gateway-protocol/src/schema/worker-admission.js";
import type {
WorkerInferenceCancelParams,
WorkerInferenceCancelResult,
WorkerInferenceErrorReason,
WorkerInferenceStartParams,
WorkerInferenceStartResult,
} from "../../../packages/gateway-protocol/src/schema/worker-inference.js";
import { safeEqualSecret } from "../../security/secret-equal.js";
import type { WorkerSessionToolName } from "../../worker/tool-authority.js";
import {
admitWorkerConnection,
validateWorkerConnectionIdentity,
type ExpectedWorkerBuild,
type WorkerConnectionIdentity,
} from "./admission.js";
import type { WorkerInstallationArtifact } from "./bundle.js";
import { createWorkerInferenceManager, type WorkerInferenceSink } from "./inference.js";
import type { WorkerLiveEventApplicationResult, WorkerLiveEventReceiver } from "./live-events.js";
import type {
WorkerPlacementTurnBinding,
WorkerSessionPlacementGate,
} from "./placement-worker-gate.js";
import type { WorkerEnvironmentStore } from "./store.js";
type WorkerProcessTurnBinding = WorkerPlacementTurnBinding & {
credentialHash: string;
};
type WorkerTerminalTurnFence = WorkerProcessTurnBinding & {
transcriptSeq: number;
liveSeq: number;
};
type WorkerPendingTerminalTurnFence = WorkerProcessTurnBinding & {
terminalLiveSeq: number;
};
type WorkerTurnRequest =
| { kind: "inference" }
| { kind: "live"; seq: number }
| { kind: "transcript"; seq: number }
| { kind: "session-tool" };
type WorkerTranscriptCommitApplicationResult =
| { ok: true; result: WorkerTranscriptCommitResult }
| { ok: false; reason: WorkerTranscriptCommitErrorReason };
type WorkerTranscriptCommitServiceResult =
| WorkerTranscriptCommitApplicationResult
| { ok: false; closeReason: WorkerProtocolCloseReason };
type WorkerLiveEventServiceResult =
| WorkerLiveEventApplicationResult
| { ok: false; closeReason: WorkerProtocolCloseReason };
type WorkerInferenceStartServiceResult =
| {
ok: true;
result: WorkerInferenceStartResult;
launch: () => void;
}
| { ok: false; reason: WorkerInferenceErrorReason }
| { ok: false; closeReason: WorkerProtocolCloseReason };
type WorkerInferenceCancelServiceResult =
| { ok: true; result: WorkerInferenceCancelResult }
| { ok: false; reason: WorkerInferenceErrorReason }
| { ok: false; closeReason: WorkerProtocolCloseReason };
type WorkerSessionToolServiceResult =
| { ok: true; result: WorkerSessionToolResult }
| { ok: false; closeReason: WorkerProtocolCloseReason }
| { ok: false; reason: WorkerProtocolCloseReason };
type WorkerTurnRpcOptions = {
store: WorkerEnvironmentStore;
prepareInstallation: (
install: WorkerInstallationArtifact["install"],
) => Promise<WorkerInstallationArtifact>;
applyTranscriptCommit?: (params: {
identity: WorkerConnectionIdentity;
request: WorkerTranscriptCommitParams;
}) => Promise<WorkerTranscriptCommitApplicationResult>;
liveEvents?: Pick<WorkerLiveEventReceiver, "apply">;
placementStore?: WorkerSessionPlacementGate;
executeSessionTool?: (
params:
| {
identity: WorkerConnectionIdentity;
toolName: "sessions_spawn";
request: WorkerSessionsSpawnParams;
signal?: AbortSignal;
}
| {
identity: WorkerConnectionIdentity;
toolName: "sessions_send";
request: WorkerSessionsSendParams;
signal?: AbortSignal;
},
) => Promise<WorkerSessionToolResult>;
inference: ReturnType<typeof createWorkerInferenceManager>;
isStopping: () => boolean;
now: () => number;
withLock: <T>(environmentId: string, task: () => Promise<T>) => Promise<T>;
};
export function createWorkerTurnRpc(options: WorkerTurnRpcOptions) {
const { store } = options;
const inference = options.inference;
const now = options.now;
const withLock = options.withLock;
const observedAckCursors = new Map<string, WorkerTerminalTurnFence>();
const pendingTerminalTurnFences = new Map<string, WorkerPendingTerminalTurnFence>();
const terminalTurnFences = new Map<string, WorkerTerminalTurnFence>();
const placementBinding = (
identity: WorkerConnectionIdentity,
): WorkerPlacementTurnBinding | undefined => {
if (!identity.sessionId || !identity.runId) {
return undefined;
}
return {
sessionId: identity.sessionId,
environmentId: identity.environmentId,
ownerEpoch: identity.ownerEpoch,
runId: identity.runId,
};
};
const processTurnBinding = (
identity: WorkerConnectionIdentity,
): WorkerProcessTurnBinding | undefined => {
const placement = placementBinding(identity);
return placement ? { ...placement, credentialHash: identity.credentialHash } : undefined;
};
const matchesTurnBinding = (
left: WorkerProcessTurnBinding,
right: WorkerProcessTurnBinding,
): boolean =>
left.sessionId === right.sessionId &&
left.environmentId === right.environmentId &&
left.ownerEpoch === right.ownerEpoch &&
left.runId === right.runId &&
safeEqualSecret(left.credentialHash, right.credentialHash);
const recordAckCursor = (
binding: WorkerProcessTurnBinding,
cursor: { transcriptSeq: number } | { liveSeq: number },
): WorkerTerminalTurnFence => {
const current = observedAckCursors.get(binding.sessionId);
const currentTurn = current && matchesTurnBinding(current, binding) ? current : undefined;
const next: WorkerTerminalTurnFence = {
...binding,
transcriptSeq:
"transcriptSeq" in cursor
? Math.max(currentTurn?.transcriptSeq ?? 0, cursor.transcriptSeq)
: (currentTurn?.transcriptSeq ?? 0),
liveSeq:
"liveSeq" in cursor
? Math.max(currentTurn?.liveSeq ?? 0, cursor.liveSeq)
: (currentTurn?.liveSeq ?? 0),
};
observedAckCursors.set(binding.sessionId, next);
return next;
};
const observedAckCursorFor = (
binding: WorkerProcessTurnBinding,
): WorkerTerminalTurnFence | undefined => {
const observed = observedAckCursors.get(binding.sessionId);
return observed && matchesTurnBinding(observed, binding) ? observed : undefined;
};
const validateWorkerPlacement = (
identity: WorkerConnectionIdentity,
): { durableClaim: boolean; valid: boolean } => {
if (!options.placementStore) {
return { durableClaim: false, valid: true };
}
if (identity.sessionId === null && identity.runId === null) {
return { durableClaim: false, valid: true };
}
const binding = placementBinding(identity);
const valid = binding ? options.placementStore.validateWorkerTurn(binding) : false;
return { durableClaim: valid, valid };
};
const isTerminalLiveEvent = (request: WorkerLiveEventParams): boolean =>
request.event.kind === "lifecycle" &&
(request.event.payload.phase === "finishing" ||
request.event.payload.phase === "end" ||
(request.event.payload.phase === "error" &&
(request.event.payload.aborted === true ||
request.event.payload.fallbackExhaustedFailure === true)));
const validateAttachedWorkerRequest = (
identity: WorkerConnectionIdentity,
runEpoch: number,
request: WorkerTurnRequest,
):
| { ok: true }
| { ok: false; closeReason: WorkerProtocolCloseReason }
| { ok: false; reason: "epoch-mismatch" | "session-not-attached" } => {
if (options.isStopping()) {
return { ok: false, closeReason: "environment-unavailable" };
}
const placement = validateWorkerPlacement(identity);
if (!placement.valid) {
return { ok: false, closeReason: "placement-mismatch" };
}
const turnBinding = processTurnBinding(identity);
const terminalFence = identity.sessionId
? terminalTurnFences.get(identity.sessionId)
: undefined;
if (turnBinding && terminalFence && matchesTurnBinding(terminalFence, turnBinding)) {
const isReplay =
(request.kind === "transcript" && request.seq <= terminalFence.transcriptSeq) ||
(request.kind === "live" && request.seq <= terminalFence.liveSeq);
if (!isReplay) {
return { ok: false, closeReason: "placement-mismatch" };
}
}
const credential = store.getCredential(identity.environmentId);
if (!credential || !safeEqualSecret(credential.credentialHash, identity.credentialHash)) {
return { ok: false, closeReason: "credential-replaced" };
}
// TTL limits unattached admission. An exact durable turn stays usable,
// including reconnects, until its terminal ACK or placement fence.
if (now() >= credential.expiresAtMs && !placement.durableClaim) {
return { ok: false, closeReason: "credential-expired" };
}
const environment = store.get(identity.environmentId);
if (!environment || environment.destroyRequestedAtMs !== null) {
return { ok: false, closeReason: "environment-unavailable" };
}
if (
runEpoch !== identity.ownerEpoch ||
runEpoch !== credential.ownerEpoch ||
runEpoch !== environment.ownerEpoch
) {
return { ok: false, reason: "epoch-mismatch" };
}
if (
environment.state !== "attached" ||
!identity.sessionId ||
credential.sessionId !== identity.sessionId ||
environment.attachedSessionIds.length !== 1 ||
environment.attachedSessionIds[0] !== identity.sessionId
) {
return { ok: false, reason: "session-not-attached" };
}
if (turnBinding && terminalFence && !matchesTurnBinding(terminalFence, turnBinding)) {
// Credential rotation identifies a new process turn even when a caller
// intentionally reuses its durable run id (for example, cron sessions).
terminalTurnFences.delete(turnBinding.sessionId);
}
return { ok: true };
};
const commitTranscript = (
identity: WorkerConnectionIdentity,
request: WorkerTranscriptCommitParams,
): Promise<WorkerTranscriptCommitServiceResult> =>
withLock(identity.environmentId, async () => {
const binding = validateAttachedWorkerRequest(identity, request.runEpoch, {
kind: "transcript",
seq: request.seq,
});
if (!binding.ok) {
return binding;
}
if (!options.applyTranscriptCommit) {
return { ok: false, closeReason: "gateway-unavailable" };
}
const result = await options.applyTranscriptCommit({ identity, request });
// Transcript persistence awaits outside the placement transaction. Revalidate the durable
// claim before exposing an ACK so reclamation cannot admit both owners for one session.
const currentBinding = validateAttachedWorkerRequest(identity, request.runEpoch, {
kind: "transcript",
seq: request.seq,
});
if (!currentBinding.ok) {
return currentBinding;
}
// Stale base is a terminal sequenced outcome. Advance its durable cursor
// so the next worker commit cannot reuse the consumed sequence number.
if (result.ok || result.reason === "stale-base-leaf") {
const placement = placementBinding(identity);
const processTurn = processTurnBinding(identity);
if (!placement || !processTurn) {
return { ok: false, closeReason: "placement-mismatch" };
}
options.placementStore?.updateAckCursors({ ...placement, transcriptSeq: request.seq });
recordAckCursor(processTurn, { transcriptSeq: request.seq });
}
return result;
});
const executeSessionTool = async (
identity: WorkerConnectionIdentity,
toolName: WorkerSessionToolName,
request: WorkerSessionsSpawnParams | WorkerSessionsSendParams,
signal?: AbortSignal,
): Promise<WorkerSessionToolServiceResult> => {
const validate = () => {
const requestAdmission = validateAttachedWorkerRequest(identity, identity.ownerEpoch, {
kind: "session-tool",
});
if (!requestAdmission.ok) {
return "closeReason" in requestAdmission
? requestAdmission
: { ok: false as const, closeReason: "placement-mismatch" as const };
}
const binding = placementBinding(identity);
if (!binding || !options.placementStore?.isWorkerTurnToolAuthorized(binding, toolName)) {
return { ok: false as const, closeReason: "method-not-allowed" as const };
}
return { ok: true as const };
};
const admitted = validate();
if (!admitted.ok) {
return admitted;
}
if (!options.executeSessionTool) {
return { ok: false, reason: "gateway-unavailable" };
}
let result: WorkerSessionToolResult;
try {
result = await options.executeSessionTool(
toolName === "sessions_spawn"
? {
identity,
toolName,
request: request as WorkerSessionsSpawnParams,
...(signal ? { signal } : {}),
}
: {
identity,
toolName,
request: request as WorkerSessionsSendParams,
...(signal ? { signal } : {}),
},
);
} catch {
return { ok: false, reason: "gateway-unavailable" };
}
// The tool may have awaited provider provisioning or another session turn.
// Never return its result after the source turn or placement was revoked.
const current = validate();
return current.ok ? { ok: true, result } : current;
};
const applyLiveEvent = (
identity: WorkerConnectionIdentity,
request: WorkerLiveEventParams,
): WorkerLiveEventServiceResult => {
const binding = validateAttachedWorkerRequest(identity, request.runEpoch, {
kind: "live",
seq: request.seq,
});
if (!binding.ok) {
if ("closeReason" in binding) {
return binding;
}
return { ok: false, details: { reason: binding.reason } };
}
if (request.runId !== identity.runId) {
return { ok: false, closeReason: "placement-mismatch" };
}
if (!options.liveEvents) {
return { ok: false, closeReason: "gateway-unavailable" };
}
// The caller holds the environment lock, preserving order with transcript
// commits and the terminal mutation fence while this synchronous receiver runs.
const result = options.liveEvents.apply({ identity, request });
if (result.ok) {
const placement = placementBinding(identity);
const processTurn = processTurnBinding(identity);
if (!placement || !processTurn) {
return { ok: false, closeReason: "placement-mismatch" };
}
options.placementStore?.updateAckCursors({
...placement,
liveSeq: result.result.ackedSeq,
...(isTerminalLiveEvent(request) ? { workspaceResultPending: true } : {}),
});
recordAckCursor(processTurn, { liveSeq: result.result.ackedSeq });
}
return result;
};
const pushLiveEvent = async (
identity: WorkerConnectionIdentity,
request: WorkerLiveEventParams,
): Promise<WorkerLiveEventServiceResult> => {
return await withLock(identity.environmentId, async () => {
const placement = placementBinding(identity);
const processTurn = processTurnBinding(identity);
const observed = processTurn ? observedAckCursorFor(processTurn) : undefined;
const wasNewSequence = request.seq > (observed?.liveSeq ?? 0);
const result = applyLiveEvent(identity, request);
if (!result.ok || !placement || !processTurn) {
return result;
}
const pending = pendingTerminalTurnFences.get(placement.sessionId);
if (pending && !matchesTurnBinding(pending, processTurn)) {
pendingTerminalTurnFences.delete(placement.sessionId);
}
if (isTerminalLiveEvent(request) && wasNewSequence) {
pendingTerminalTurnFences.set(placement.sessionId, {
...processTurn,
terminalLiveSeq: request.seq,
});
}
const terminal = pendingTerminalTurnFences.get(placement.sessionId);
if (
terminal &&
matchesTurnBinding(terminal, processTurn) &&
result.result.ackedSeq >= terminal.terminalLiveSeq
) {
// A gap fill can ACK a previously buffered terminal event. Fence from
// the observed high-water marks, not only from the request carrying it.
terminalTurnFences.set(
placement.sessionId,
observedAckCursorFor(processTurn) ??
recordAckCursor(processTurn, { liveSeq: result.result.ackedSeq }),
);
pendingTerminalTurnFences.delete(placement.sessionId);
}
return result;
});
};
const revalidateInference = (
identity: WorkerConnectionIdentity,
request: WorkerInferenceStartParams | WorkerInferenceCancelParams,
): "epoch-mismatch" | "session-not-attached" | null => {
if (request.sessionId !== identity.sessionId) {
return "session-not-attached";
}
const binding = validateAttachedWorkerRequest(identity, request.runEpoch, {
kind: "inference",
});
return binding.ok ? null : "reason" in binding ? binding.reason : "session-not-attached";
};
const startInference = (
identity: WorkerConnectionIdentity,
request: WorkerInferenceStartParams,
sink: WorkerInferenceSink,
): WorkerInferenceStartServiceResult => {
if (request.sessionId !== identity.sessionId || request.runId !== identity.runId) {
return { ok: false, reason: "session-not-attached" };
}
const binding = validateAttachedWorkerRequest(identity, request.runEpoch, {
kind: "inference",
});
if (!binding.ok) {
return binding;
}
return inference.start({
identity,
request,
sink,
revalidate: () => revalidateInference(identity, request),
});
};
const cancelInference = (
identity: WorkerConnectionIdentity,
request: WorkerInferenceCancelParams,
): WorkerInferenceCancelServiceResult => {
if (request.sessionId !== identity.sessionId || request.runId !== identity.runId) {
return { ok: false, reason: "session-not-attached" };
}
const binding = validateAttachedWorkerRequest(identity, request.runEpoch, {
kind: "inference",
});
if (!binding.ok) {
return binding;
}
return inference.cancel({
identity,
request,
revalidate: () => revalidateInference(identity, request),
});
};
return {
admitWorker: async (admission: WorkerConnectParams["admission"]) => {
if (options.isStopping()) {
return { ok: false, reason: "environment-unavailable" } as const;
}
const preflightAtMs = now();
const preflight = admitWorkerConnection({
store,
admission,
expectedBuild: admission.handshake,
nowMs: preflightAtMs,
allowExpiredCredential: true,
});
if (!preflight.ok) {
return preflight;
}
if (preflightAtMs >= preflight.identity.credentialExpiresAtMs) {
const placement = placementBinding(preflight.identity);
if (!placement || !options.placementStore?.validateWorkerTurn(placement)) {
return { ok: false, reason: "credential-expired" } as const;
}
}
let expectedBuild: ExpectedWorkerBuild;
try {
expectedBuild = await options.prepareInstallation("bundle");
} catch {
return { ok: false, reason: "environment-unavailable" } as const;
}
if (options.isStopping()) {
return { ok: false, reason: "environment-unavailable" } as const;
}
const admittedAtMs = now();
const admitted = admitWorkerConnection({
store,
admission,
expectedBuild,
nowMs: admittedAtMs,
allowExpiredCredential: true,
});
if (!admitted.ok) {
return admitted;
}
const expired = admittedAtMs >= admitted.identity.credentialExpiresAtMs;
if (
!options.placementStore ||
(admitted.identity.sessionId === null && admitted.identity.runId === null)
) {
return expired ? ({ ok: false, reason: "credential-expired" } as const) : admitted;
}
const placement = placementBinding(admitted.identity);
if (!placement || !options.placementStore.validateWorkerTurn(placement)) {
return {
ok: false,
reason: expired ? "credential-expired" : "placement-mismatch",
} as const;
}
return admitted;
},
validateWorkerConnection: (identity: WorkerConnectionIdentity) => {
if (options.isStopping()) {
return "environment-unavailable" as const;
}
const placement = validateWorkerPlacement(identity);
if (!placement.valid) {
return "placement-mismatch" as const;
}
const environmentFailure = validateWorkerConnectionIdentity({
store,
identity,
nowMs: now(),
});
if (
environmentFailure &&
!(environmentFailure === "credential-expired" && placement.durableClaim)
) {
return environmentFailure;
}
return null;
},
commitTranscript,
pushLiveEvent,
executeSessionTool,
startInference,
cancelInference,
cancelInferenceForSession: (params: { sessionId: string; runId?: string }): string[] =>
inference.cancelSession(params.sessionId, params.runId),
hasInferenceForSession: (sessionId: string, runId?: string): boolean =>
inference.hasSession(sessionId, runId),
resolveInferenceSessionForRunId: (runId: string): string | undefined =>
inference.resolveSessionIdForRunId(runId),
clear: () => {
observedAckCursors.clear();
pendingTerminalTurnFences.clear();
terminalTurnFences.clear();
},
};
}