fix: keep remote creation working on older hosts and inherit foreign parents' background

This commit is contained in:
Mohamed Boudra
2026-11-03 14:35:11 +01:00
parent e9dea8bebd
commit 7319316631
8 changed files with 215 additions and 32 deletions
+22
View File
@@ -2892,6 +2892,28 @@ export class DaemonClient {
});
}
/** Finds one workspace by id across pages, background ones included when the host has them. */
async findWorkspace(
workspaceId: string,
options?: { requestId?: string },
): Promise<FetchWorkspacesEntry | null> {
const filter = this.supportsBackgroundWorkspaces() ? { includeBackground: true } : {};
let cursor: string | undefined;
let requestId = options?.requestId;
do {
const result = await this.fetchWorkspaces({
requestId,
filter,
page: { limit: 200, ...(cursor ? { cursor } : {}) },
});
const match = result.entries.find((entry) => entry.id === workspaceId);
if (match) return match;
cursor = result.pageInfo.nextCursor ?? undefined;
requestId = undefined;
} while (cursor);
return null;
}
listWorkspaceLabels(
options: {
sync?: { generation: string; afterSeq: number };
+26 -24
View File
@@ -613,12 +613,14 @@ export function createPaseoApi(
options: PaseoAgentCreateOptions,
placement?: { workspaceId: string; cwd: string },
) => {
const serverId = daemonClient.getLastServerInfoMessage()?.serverId;
const foreignParent =
options.background === undefined ? foreignParentHandle(options.parent, serverId) : null;
const resolved = foreignParent
? await inheritParentBackground(options, foreignParent)
: options;
const agent = await daemonClient.createAgent(
toDaemonAgentCreateOptions(
options,
placement,
daemonClient.getLastServerInfoMessage()?.serverId,
),
toDaemonAgentCreateOptions(resolved, placement, serverId),
);
return createAgentHandle(agent);
};
@@ -823,25 +825,7 @@ function createWorkspaceHandleFactory(
let current = typeof workspace === "string" ? null : workspace;
const refresh = async (options?: { requestId?: string }) => {
let cursor: string | undefined;
let requestId = options?.requestId;
// A ref addresses one workspace by id, so a background one resolves too.
const filter = daemonClient.supportsBackgroundWorkspaces() ? { includeBackground: true } : {};
do {
const result = await daemonClient.fetchWorkspaces({
requestId,
filter,
page: { limit: 200, ...(cursor ? { cursor } : {}) },
});
const match = result.entries.find((entry) => entry.id === id);
if (match) {
current = match;
return current;
}
cursor = result.pageInfo.nextCursor ?? undefined;
requestId = undefined;
} while (cursor);
current = null;
current = await daemonClient.findWorkspace(id, options);
return current;
};
@@ -1070,6 +1054,24 @@ function resolveWorkspaceId(workspace: string | PaseoWorkspaceHandle): string {
return typeof workspace === "string" ? workspace : workspace.id;
}
function foreignParentHandle(
parent: PaseoAgentCreateOptions["parent"],
serverId: string | undefined,
): PaseoAgentHandle | null {
if (!parent || typeof parent === "string" || !("address" in parent)) return null;
return parent.address.serverId === serverId ? null : parent;
}
// A host inherits background from a caller it can see. A parent on another host is invisible to
// it, so a handle passes its own value on. A bare foreign address can't; pass background then.
async function inheritParentBackground(
options: PaseoAgentCreateOptions,
parent: PaseoAgentHandle,
): Promise<PaseoAgentCreateOptions> {
const snapshot = parent.current() ?? (await parent.refresh())?.agent;
return snapshot?.background ? { ...options, background: true } : options;
}
function encodeParent(
parent: string | AgentAddress | PaseoAgentHandle,
serverId?: string,
@@ -0,0 +1,149 @@
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { afterEach, expect, test } from "vitest";
import { createHostResolver, type HostOperations } from "./index.js";
type Handler = ((event?: unknown) => void) | null;
/** A connected host from before background agents: background workspaces, no background agents. */
class OlderHostSocket {
readyState = 1;
readonly requests: Array<Record<string, unknown>> = [];
onopen: Handler = null;
onmessage: Handler = null;
onclose: Handler = null;
onerror: Handler = null;
private readonly createSent = Promise.withResolvers<Record<string, unknown>>();
constructor() {
queueMicrotask(() => this.onopen?.());
}
createRequest(): Promise<Record<string, unknown>> {
return this.createSent.promise;
}
send(data: string): void {
const frame = JSON.parse(data);
if (frame.type === "hello") {
this.reply({
type: "status",
payload: {
status: "server_info",
serverId: "srv_older",
hostname: null,
version: null,
features: {
providerUsageList: true,
providersSnapshotCwd: true,
ownedSubscriptions: true,
backgroundWorkspaces: true,
},
},
});
return;
}
if (frame.type !== "session") return;
const message = frame.message;
this.requests.push(message);
if (message.type === "fetch_workspaces_request") {
this.reply({
type: "fetch_workspaces_response",
payload: {
requestId: message.requestId,
entries: [workspace],
pageInfo: emptyPage,
},
});
}
if (message.type === "create_agent_request") this.createSent.resolve(message);
}
close(): void {
this.readyState = 3;
this.onclose?.({ code: 1000, reason: "closed" });
}
private reply(message: object): void {
this.onmessage?.({ data: JSON.stringify({ type: "session", message }) });
}
}
const emptyPage = { nextCursor: null, prevCursor: null, hasMore: false };
const workspace = {
id: "workspace_older",
projectId: "project_older",
projectDisplayName: "Older",
projectRootPath: "/repo/older",
workspaceDirectory: "/repo/older",
projectKind: "git",
workspaceKind: "directory",
name: "older",
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: "2026-05-16T00:00:00.000Z",
scripts: [],
gitRuntime: null,
githubRuntime: null,
};
const clients: DaemonClient[] = [];
afterEach(async () => {
await Promise.all(clients.splice(0).map((client) => client.close()));
});
async function olderHost() {
const socket = new OlderHostSocket();
const client = new DaemonClient({
url: "ws://older.test",
clientId: "older-host-test",
webSocketFactory: () => socket,
reconnect: { enabled: false },
});
clients.push(client);
await client.connect();
return { client, socket };
}
function resolverFor(client: DaemonClient, callerBackground: boolean) {
return createHostResolver({
serverId: "origin",
callerAgentId: "caller",
caller: () => ({ background: callerBackground }),
local: {} as HostOperations,
hosts: () => ({
directory: async () => ({ self: { serverId: "origin", label: "Origin" }, hosts: [] }),
use: (_serverId, action) => action(client),
}),
});
}
const createArgs = {
workspaceId: workspace.id,
provider: "codex/gpt-5.4",
title: "Helper",
initialPrompt: "Summarize",
};
test("a visible caller still creates agents on a host without background agents", async () => {
const { client, socket } = await olderHost();
const creating = resolverFor(client, false).use("srv_older", (host) =>
host.createAgent(createArgs, {} as never),
);
creating.catch(() => {});
const request = await socket.createRequest();
expect(request).toMatchObject({ workspaceId: workspace.id });
expect(request).not.toHaveProperty("background");
});
test("a background caller's remote child asks the host to update instead of turning visible", async () => {
const { client, socket } = await olderHost();
await expect(
resolverFor(client, true).use("srv_older", (host) => host.createAgent(createArgs, {} as never)),
).rejects.toThrow("Update the host to use background agents.");
expect(socket.requests.map((request) => request.type)).not.toContain("create_agent_request");
});
@@ -222,9 +222,7 @@ export function createHostResolver(input: {
async createAgent(args) {
const parsed = remoteCreate.parse(args);
const { provider, model } = resolveRequiredProviderModel(parsed.provider);
const workspace = (
await client.fetchWorkspaces({ includeBackground: true })
).entries.find((row) => row.id === parsed.workspaceId);
const workspace = await client.findWorkspace(parsed.workspaceId);
if (!workspace) throw new Error(`Workspace ${parsed.workspaceId} not found`);
const labels = {
...parsed.labels,
@@ -243,7 +241,10 @@ export function createHostResolver(input: {
featureValues: parsed.settings?.features,
},
workspaceId: parsed.workspaceId,
background: inheritAgentBackground(parsed.background, input.caller()),
// Older hosts reject any background with a workspaceId, so a visible create omits it.
...(inheritAgentBackground(parsed.background, input.caller())
? { background: true }
: {}),
labels,
initialPrompt: parsed.initialPrompt,
idempotencyKey: parsed.idempotencyKey,
@@ -190,8 +190,9 @@ describe("OpenCodeBridge", () => {
title: "Background child",
background: true,
});
expect(agentManager.listAgents().find((agent) => agent.config.title === "Background child"))
.toMatchObject({ background: true });
expect(
agentManager.listAgents().find((agent) => agent.config.title === "Background child"),
).toMatchObject({ background: true });
const v1Module = await import(plugin.pluginUrl);
const hooks = await v1Module.default({}, plugin);
@@ -1733,7 +1733,7 @@ test(
);
test(
"a background caller's children and workspaces on B are background unless it overrides",
"a background caller's MCP and SDK children and workspaces on B are background unless it overrides",
{ timeout: 30000 },
async () => {
const a = await daemon();
@@ -1793,6 +1793,13 @@ test(
(await bApi.paseo.agents.ref(String(agentId)).refresh())?.agent;
expect(await snapshot(helper.agentId)).toMatchObject({ background: true });
expect((await snapshot(visible.agentId))?.background).toBeUndefined();
const sdkChild = await bApi.paseo.workspaces.ref(String(hidden.workspaceId)).agents.create({
config: { provider: "claude/claude-test" },
title: "SDK helper",
parent,
});
expect(sdkChild.current()).toMatchObject({ background: true });
},
);
+1
View File
@@ -148,6 +148,7 @@ await helper.archive();
```
An agent created under a background `parent` is background unless you pass `background: false`.
A parent on another host passes this on only as a handle; with a bare address, pass `background`.
Placing a background agent in an existing workspace or under a parent requires
`features.backgroundAgents`; on an older host the SDK throws "Update the host to use background
agents." before sending.
+1 -1
View File
@@ -139,7 +139,7 @@ console.log(result.lastMessage);
await helper.archive();
```
在后台 `parent` 下创建的 Agent 默认也是后台 Agent,除非传入 `background: false`。把后台 Agent 放到已有工作区或某个父 Agent 下需要 `features.backgroundAgents`;在较旧的主机上,SDK 会在发送前抛出 "Update the host to use background agents."。
在后台 `parent` 下创建的 Agent 默认也是后台 Agent,除非传入 `background: false`。位于其他主机的父 Agent 只有以句柄传入时才会传递该设置;只传地址时,请显式传入 `background`。把后台 Agent 放到已有工作区或某个父 Agent 下需要 `features.backgroundAgents`;在较旧的主机上,SDK 会在发送前抛出 "Update the host to use background agents."。
## 请求结构化输出 {#request-structured-output}