feat(hosts): bind replica and session lifetime to origin authority

This commit is contained in:
Mohamed Boudra
2026-10-20 13:23:04 +02:00
parent 702bc942b7
commit 7c4aa058ff
43 changed files with 3796 additions and 2275 deletions
@@ -768,6 +768,7 @@ export class AgentManager {
private appendSystemPrompt: string;
private onAgentAttention?: AgentAttentionCallback;
private readonly agentArchivedListeners = new Set<AgentArchivedCallback>();
private managedChildLifecycleOwner: AgentArchivedCallback | null = null;
private onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void;
private logger: Logger;
private readonly rescueTimeouts: Required<AgentManagerRescueTimeouts>;
@@ -874,10 +875,19 @@ export class AgentManager {
return this.runJournal.get(agentId, submissionId);
}
onAgentArchived(callback: AgentArchivedCallback): () => void {
onAgentArchived(
callback: AgentArchivedCallback,
options?: { ownsManagedChildren: boolean },
): () => void {
if (options?.ownsManagedChildren) {
if (this.managedChildLifecycleOwner)
throw new Error("Managed child lifecycle already has an owner");
this.managedChildLifecycleOwner = callback;
}
this.agentArchivedListeners.add(callback);
return () => {
this.agentArchivedListeners.delete(callback);
if (this.managedChildLifecycleOwner === callback) this.managedChildLifecycleOwner = null;
};
}
@@ -1904,6 +1914,7 @@ export class AgentManager {
const parentAgentId = parent.id;
const records = await registry.list();
for (const record of records) {
if (this.managedChildLifecycleOwner && !record.internal && !record.owner) continue;
if (record.archivedAt) {
continue;
}
@@ -3683,6 +3694,7 @@ export class AgentManager {
}
}
await this.runJournal.restore(resolvedAgentId);
this.assertAcceptingAgentRegistrations();
this.agents.set(resolvedAgentId, managed);
registered = true;
@@ -4395,22 +4407,14 @@ export class AgentManager {
const flags: StreamEventFlags = { shouldDispatchEvent: true, shouldNotifyWaiters: true };
const runResult = this.getRunResult(
agent.id,
eventTurnId,
const checkpoint = this.captureLiveRunCheckpoint(
agent,
event,
options?.fromHistory === true,
eventTurnId,
options,
terminalDisposition,
);
if (!options?.fromHistory) {
if (terminalDisposition !== "stale")
await this.runJournal.capture(agent.id, eventTurnId, event, runResult).catch((error) => {
this.logger.error(
{ err: error, agentId: agent.id, turnId: eventTurnId },
"Failed to persist provider checkpoint",
);
});
}
if (checkpoint) await checkpoint;
const dispatchPromise = this.dispatchStreamEventByType({
agent,
event,
@@ -4442,6 +4446,23 @@ export class AgentManager {
return flags.shouldNotifyWaiters;
}
private captureLiveRunCheckpoint(
agent: ActiveManagedAgent,
event: AgentStreamEvent,
turnId: string | undefined,
options: HandleStreamEventOptions | undefined,
disposition: ActiveTurnTerminalDisposition,
): Promise<void> | undefined {
if (options?.fromHistory || disposition === "stale") return undefined;
const result = this.getRunResult(agent.id, turnId, event, false, disposition);
return this.runJournal.capture(agent.id, turnId, event, result)?.catch((error) => {
this.logger.error(
{ err: error, agentId: agent.id, turnId },
"Failed to persist provider checkpoint",
);
});
}
private getRunResult(
agentId: string,
turnId: string | undefined,
@@ -289,6 +289,7 @@ test("finish notifications tell the parent the child's last assistant message",
source: {
kind: "agent-notification",
agentId: "child-agent",
serverId: "srv_test",
title: "Child Agent",
event: "finished",
},
@@ -325,6 +326,7 @@ test("closing a watched child notifies the caller", async () => {
source: {
kind: "agent-notification",
agentId: "child-agent",
serverId: "srv_test",
title: "Child Agent",
event: "closed",
},
@@ -1386,3 +1386,129 @@ test("MCP provider discovery uses the selected host profiles and models", async
await b.close();
}
});
test("real MCP discovery preserves custom catalog modes, disabled-provider isolation, and draft settings", async () => {
const clients = createTestAgentClients();
const base = clients.codex!;
let disabledAvailabilityChecks = 0;
let disabledCatalogFetches = 0;
const drafts: AgentSessionConfig[] = [];
const dynamicModes = [{ id: "dynamic", label: "Dynamic", description: "Runtime mode" }];
const custom: AgentClient = {
...base,
provider: "zai",
capabilities: base.capabilities,
createSession: (...args) => base.createSession(...args),
resumeSession: (...args) => base.resumeSession(...args),
isAvailable: async () => true,
fetchCatalog: async () => ({
models: [{ provider: "zai", id: "custom-model", label: "Custom model", isDefault: true }],
modes: dynamicModes,
}),
listFeatures: async (config) => {
drafts.push(config);
return [
{
type: "toggle",
id: "fast_mode",
label: "Fast mode",
value: config.featureValues?.fast_mode === true,
},
];
},
};
const disabled: AgentClient = {
...base,
provider: "codex",
capabilities: base.capabilities,
createSession: (...args) => base.createSession(...args),
resumeSession: (...args) => base.resumeSession(...args),
isAvailable: async () => {
disabledAvailabilityChecks++;
return true;
},
fetchCatalog: async (options) => {
disabledCatalogFetches++;
return base.fetchCatalog(options);
},
};
const destination = await createTestPaseoDaemon({
agentClients: { ...clients, codex: disabled, zai: custom },
providerOverrides: {
codex: { enabled: false },
zai: { extends: "codex", label: "ZAI", enabled: true },
},
agentProfiles: [],
});
const mcp = await createMcpClient(`http://127.0.0.1:${destination.port}/mcp/agents`);
try {
const response = await mcp.callTool({ name: "list_providers", args: {} });
expect(response.isError, JSON.stringify(response)).not.toBe(true);
const providers = recordArr(getStructuredContent(response)?.providers);
expect(providers).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "zai",
label: "ZAI",
enabled: true,
status: "available",
modes: dynamicModes,
}),
expect.objectContaining({ id: "codex", enabled: false, status: "unavailable", modes: [] }),
]),
);
const modelText =
response.content?.map((item) => Reflect.get(item, "text") ?? "").join("\n") ?? "";
expect(modelText).toContain(`providers_count=${providers.length}`);
expect(modelText).toContain(
`providers_ids=${providers.map((provider) => provider.id).join(",")}`,
);
expect(modelText).toContain('"providers"');
expect(await callToolStructured(mcp, "list_profiles")).toMatchObject({ profiles: [] });
await expectToolError(mcp, "list_models", { provider: "codex" }, /disabled/i);
await expectToolError(
mcp,
"inspect_provider",
{ provider: "codex", cwd: destination.staticDir },
/disabled/i,
);
expect(disabledAvailabilityChecks).toBe(0);
expect(disabledCatalogFetches).toBe(0);
const inspected = await callToolStructured(mcp, "inspect_provider", {
provider: "zai/custom-model",
cwd: destination.staticDir,
settings: { modeId: "dynamic", thinkingOptionId: "high", features: { fast_mode: true } },
});
expect(inspected).toMatchObject({
provider: "zai",
label: "ZAI",
selectedModel: "custom-model",
modes: dynamicModes,
features: [{ type: "toggle", id: "fast_mode", label: "Fast mode", value: true }],
});
expect(drafts).toContainEqual(
expect.objectContaining({
provider: "zai",
cwd: destination.staticDir,
model: "custom-model",
modeId: "dynamic",
thinkingOptionId: "high",
featureValues: { fast_mode: true },
}),
);
} finally {
await mcp.close();
await destination.close();
}
});
test("real MCP without configured profiles returns an empty list", async () => {
const destination = await createTestPaseoDaemon();
const mcp = await createMcpClient(`http://127.0.0.1:${destination.port}/mcp/agents`);
try {
expect(await callToolStructured(mcp, "list_profiles")).toMatchObject({ profiles: [] });
} finally {
await mcp.close();
await destination.close();
}
});
@@ -67,8 +67,6 @@ import { createTerminalManager, type TerminalManager } from "../../terminal/term
import { createWorkerTerminalManager } from "../../terminal/worker-terminal-manager.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { PASEO_TOOL_NAMES } from "@getpaseo/protocol/paseo-tool-presentation/index";
import { MutableDaemonConfigSchema, type AgentProfile } from "@getpaseo/protocol/messages";
import type { DaemonConfigStore } from "../daemon-config-store.js";
import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js";
import type { BrowserToolsResponsePayload } from "../browser-tools/errors.js";
import { readPaseoWorktreeMetadata } from "../../utils/worktree-metadata.js";
@@ -4844,7 +4842,7 @@ describe("send_agent_prompt MCP tool", () => {
});
expect(childClient.sessions[0]!.prompts).toHaveLength(1);
expect(parseAgentMessage(childClient.sessions[0]!.prompts[0])).toMatchObject({
source: { kind: "agent-message", agentId: parent.id },
source: { kind: "agent-message", serverId: "srv_test", agentId: parent.id },
text: "Ongoing task",
});
expect(parentClient.sessions[0]!.prompts).toHaveLength(0);
@@ -5039,7 +5037,7 @@ describe("send_agent_prompt MCP tool", () => {
await vi.waitFor(() => expect(childSession.prompts).toHaveLength(1));
expect(parseAgentMessage(childSession.prompts[0])).toEqual({
id: expect.any(String),
source: { kind: "agent-message", agentId: parent.id },
source: { kind: "agent-message", serverId: "srv_test", agentId: parent.id },
text: "Follow up",
});
acknowledgeTurnStart();
@@ -6273,202 +6271,6 @@ describe("schedule_logs MCP tool", () => {
});
});
describe("provider listing MCP tool", () => {
const logger = createTestLogger();
it("returns providers from the registry, including custom providers", async () => {
const { agentManager, agentStorage } = createTestDeps();
const provStub = createProviderSnapshotManagerStub();
provStub.listRegisteredProviderIds.mockReturnValue(["claude", "zai"]);
provStub.listProviders.mockResolvedValue([
buildSnapshotEntry({
provider: "claude",
label: "Claude",
description: "Test provider",
modes: [{ id: "default", label: "Default", description: "Built-in mode" }],
}),
buildSnapshotEntry({
provider: "zai" as AgentProvider,
label: "ZAI",
description: "Custom Claude profile",
defaultModeId: "default",
modes: [{ id: "default", label: "Default", description: "Custom mode" }],
}),
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: provStub.manager,
logger,
});
const tool = registeredTool(server, "list_providers");
const response = await tool.handler({});
const modelVisibleText = String(response.content[0]?.text);
expect(response.structuredContent).toEqual({
providers: [
{
id: "claude",
label: "Claude",
description: "Test provider",
enabled: true,
status: "available",
modes: [{ id: "default", label: "Default", description: "Built-in mode" }],
},
{
id: "zai",
label: "ZAI",
status: "available",
description: "Custom Claude profile",
enabled: true,
modes: [{ id: "default", label: "Default", description: "Custom mode" }],
},
],
});
expect(modelVisibleText).toContain("providers_count=2");
expect(modelVisibleText).toContain("providers_ids=claude,zai");
expect(modelVisibleText).toContain('"providers"');
});
it("returns provider modes from the shared snapshot catalog", async () => {
const { agentManager, agentStorage } = createTestDeps();
const provStub = createProviderSnapshotManagerStub();
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
provStub.listProviders.mockResolvedValue([
buildSnapshotEntry({
provider: "codex",
label: "Codex",
modes: [{ id: "dynamic", label: "Dynamic", description: "Runtime mode" }],
}),
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: provStub.manager,
logger,
});
const tool = registeredTool(server, "list_providers");
const response = await tool.handler({});
expect(response.structuredContent.providers).toEqual([
expect.objectContaining({
id: "codex",
modes: [{ id: "dynamic", label: "Dynamic", description: "Runtime mode" }],
}),
]);
});
it("returns disabled providers with metadata without checking availability", async () => {
const { agentManager, agentStorage } = createTestDeps();
const provStub = createProviderSnapshotManagerStub();
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
provStub.listProviders.mockResolvedValue([
buildSnapshotEntry({
provider: "codex",
label: "Codex",
description: "OpenAI coding agent",
enabled: false,
modes: [{ id: "read-only", label: "Read Only", description: "No edits" }],
}),
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: provStub.manager,
logger,
});
const tool = registeredTool(server, "list_providers");
const response = await tool.handler({});
expect(response.structuredContent).toEqual({
providers: [
{
id: "codex",
label: "Codex",
description: "OpenAI coding agent",
enabled: false,
status: "unavailable",
modes: [],
},
],
});
});
});
function daemonConfigStoreStub(agentProfiles?: AgentProfile[]): Pick<DaemonConfigStore, "get"> {
const config = MutableDaemonConfigSchema.parse({
relay: { enabled: true },
mcp: { injectIntoAgents: true },
...(agentProfiles !== undefined ? { agentProfiles } : {}),
});
return { get: () => config };
}
describe("agent profile listing MCP tool", () => {
const logger = createTestLogger();
it("returns configured profiles, including notes", async () => {
const { agentManager, agentStorage } = createTestDeps();
const profiles: AgentProfile[] = [
{
id: "ui-profile",
name: "UI work",
provider: "claude",
model: "claude-test-model",
modeId: "bypassPermissions",
thinkingOptionId: "high",
featureValues: { fast_mode: true },
notes: "Use for UI work: components, layout, design tokens. Not for backend.",
},
];
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
daemonConfigStore: daemonConfigStoreStub(profiles),
logger,
});
const tool = registeredTool(server, "list_profiles");
const response = await tool.handler({});
expect(response.structuredContent).toEqual({ profiles });
});
it("returns an empty array when no profiles are configured", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
daemonConfigStore: daemonConfigStoreStub(),
logger,
});
const tool = registeredTool(server, "list_profiles");
const response = await tool.handler({});
expect(response.structuredContent).toEqual({ profiles: [] });
});
it("returns an empty array when no daemon config store is provided", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "list_profiles");
const response = await tool.handler({});
expect(response.structuredContent).toEqual({ profiles: [] });
});
});
describe("provider MCP tools", () => {
const logger = createTestLogger();
@@ -6483,113 +6285,6 @@ describe("provider MCP tools", () => {
expect(lookupTool(server, "list_provider_features")).toBeUndefined();
});
it("inspects provider features for a draft agent configuration", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.listDraftFeatures.mockResolvedValue([
{
type: "toggle",
id: "fast_mode",
label: "Fast mode",
value: false,
},
]);
const provStub = createProviderSnapshotManagerStub();
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
const codexEntry = buildSnapshotEntry({
provider: "codex",
label: "Codex",
description: "OpenAI coding agent",
modes: [{ id: "full-access", label: "Full Access", description: "Can edit files" }],
});
provStub.listProviders.mockResolvedValue([codexEntry]);
provStub.getProvider.mockResolvedValue(codexEntry);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: provStub.manager,
logger,
});
const tool = registeredTool(server, "inspect_provider");
const input = {
provider: "codex/gpt-5.4",
cwd: "~/repo",
settings: {
modeId: "full-access",
thinkingOptionId: "high",
features: { fast_mode: true },
},
};
const parsed = await tool.inputSchema.safeParseAsync(input);
expect(parsed.success).toBe(true);
const response = await tool.handler(input);
expect(spies.agentManager.listDraftFeatures).toHaveBeenCalledWith({
provider: "codex",
cwd: expect.stringContaining("repo"),
modeId: "full-access",
model: "gpt-5.4",
thinkingOptionId: "high",
featureValues: { fast_mode: true },
});
expect(response.structuredContent).toEqual({
provider: "codex",
label: "Codex",
description: "OpenAI coding agent",
enabled: true,
status: "available",
modes: [{ id: "full-access", label: "Full Access", description: "Can edit files" }],
selectedModel: "gpt-5.4",
features: [
{
type: "toggle",
id: "fast_mode",
label: "Fast mode",
value: false,
},
],
});
});
it("rejects disabled providers without fetching models", async () => {
const { agentManager, agentStorage } = createTestDeps();
const provStub = createProviderSnapshotManagerStub();
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
provStub.listModels.mockRejectedValue(new Error("Provider 'codex' is disabled"));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: provStub.manager,
logger,
});
const tool = registeredTool(server, "list_models");
await expect(tool.handler({ provider: "codex" })).rejects.toThrow(
"Provider 'codex' is disabled",
);
});
it("inspect_provider rejects disabled providers without fetching models", async () => {
const { agentManager, agentStorage } = createTestDeps();
const provStub = createProviderSnapshotManagerStub();
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
provStub.getProvider.mockResolvedValue(
buildSnapshotEntry({ provider: "codex", label: "Codex", enabled: false }),
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: provStub.manager,
logger,
});
const tool = registeredTool(server, "inspect_provider");
await expect(tool.handler({ provider: "codex", cwd: "~/repo" })).rejects.toThrow(
"Provider 'codex' is disabled",
);
});
});
describe("speak MCP tool", () => {
+12 -3
View File
@@ -60,6 +60,10 @@ export class AgentRuns {
private identity(agentId: string, submissionId: string): string {
return JSON.stringify([agentId, submissionId]);
}
/** Restore turn ownership before subscribing to live provider evidence. */
restore(agentId: string): Promise<void> {
return this.mutate(() => this.load(agentId));
}
private async load(agentId: string): Promise<void> {
if (this.loadedAgents.has(agentId)) return;
let records: StoredRun[] = [];
@@ -240,10 +244,15 @@ export class AgentRuns {
turnId: string | undefined,
event: AgentStreamEvent,
result: string,
): Promise<void> {
if (!turnId) return Promise.resolve();
): Promise<void> | undefined {
if (!turnId) return undefined;
if (
this.loadedAgents.has(agentId) &&
!this.submissionsByTurn.has(this.identity(agentId, turnId))
)
return undefined;
const checkpoint = checkpointFor(event, result);
if (!checkpoint) return Promise.resolve();
if (!checkpoint) return undefined;
return this.mutate(async () => {
await this.load(agentId);
const submissions = this.submissionsByTurn.get(this.identity(agentId, turnId));
+1
View File
@@ -1779,6 +1779,7 @@ export async function createPaseoDaemon(
orchestrationSkills,
workspaceLabelService,
);
await wsServer.initializeRelationships();
pluginRuntime.bindPaseoSessionHost(wsServer);
await pluginRuntime.start();
providerSnapshotManager.settlePluginProviders();
@@ -1708,6 +1708,180 @@ test("parent capability owns local and fleet membership, idempotent creation and
expect(recovered.address).toEqual(first.address);
});
test("startup upgrade adopts existing ordinary local children once without treating later labels as membership", async () => {
const { AgentStorage } = await import("../agent/agent-storage.js");
const { default: pino } = await import("pino");
const root = await mkdtemp(join(tmpdir(), "paseo-legacy-membership-"));
cleanup.push(() => rm(root, { recursive: true, force: true }));
const storage = new AgentStorage(join(root, ".paseo", "agents"), pino({ level: "silent" }));
const parentId = crypto.randomUUID();
const childId = crypto.randomUUID();
const sameWorkspaceId = crypto.randomUUID();
const otherWorkspaceId = crypto.randomUUID();
const openTabId = crypto.randomUUID();
const foreignId = crypto.randomUUID();
const nullParentId = crypto.randomUUID();
const executedId = crypto.randomUUID();
const internalParentId = crypto.randomUUID();
const internalChildId = crypto.randomUUID();
const seed = async (
id: string,
overrides: Partial<import("../agent/agent-storage.js").StoredAgentRecord> = {},
) => {
await storage.upsert({
id,
provider: "codex",
cwd: root,
workspaceId: "legacy-workspace",
config: { model: "test" },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
lastStatus: "closed",
labels: {},
...overrides,
});
};
const labels = { "paseo.parent-agent-id": parentId };
await seed(parentId);
await seed(childId, { labels });
await seed(sameWorkspaceId, { labels });
await seed(otherWorkspaceId, { labels, workspaceId: "other-workspace" });
await seed(openTabId, { labels: { ...labels, "paseo.open-agent-tab.retained": "true" } });
await seed(foreignId, { labels, parent: { serverId: "another-server", agentId: parentId } });
await seed(nullParentId, { labels, parent: null });
await seed(executedId, {
labels,
owner: { kind: "daemon", daemonId: "hub", executionId: "execution" },
});
await seed(internalParentId, { internal: true });
await seed(internalChildId, { labels: { "paseo.parent-agent-id": internalParentId } });
const first = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
cleanup.push(() => first.close());
const origin = await api(first);
const parent = origin.paseo.agents.ref(parentId);
const rows = (await parent.subagents.list()).rows;
expect(rows.map((row) => row.child.agentId).sort()).toEqual(
[childId, sameWorkspaceId, otherWorkspaceId, openTabId].sort(),
);
expect(
rows.every((row) => row.child.serverId === parent.serverId && row.phase === "attached"),
).toBe(true);
expect((await first.daemon.agentStorage.get(childId))?.parent).toEqual(parent.address);
await parent.subagents.forget({ serverId: parent.serverId, agentId: childId });
await parent.archive();
await expect
.poll(async () => (await first.daemon.agentStorage.get(sameWorkspaceId))?.archivedAt)
.toEqual(expect.any(String));
await expect
.poll(async () => (await first.daemon.agentStorage.get(otherWorkspaceId))?.parent)
.toBeNull();
await expect
.poll(async () => (await first.daemon.agentStorage.get(openTabId))?.parent)
.toBeNull();
// Forget leaves destination lifecycle alone; the old label cascade must not run too.
expect((await first.daemon.agentStorage.get(childId))?.archivedAt).toBeUndefined();
expect((await first.daemon.agentStorage.get(otherWorkspaceId))?.archivedAt).toBeUndefined();
expect((await first.daemon.agentStorage.get(openTabId))?.archivedAt).toBeUndefined();
const injectedId = crypto.randomUUID();
await first.daemon.agentStorage.upsert({
...(await first.daemon.agentStorage.get(childId))!,
id: injectedId,
parent: undefined,
});
await first.close();
const restarted = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
cleanup.push(() => restarted.close());
const restored = await api(restarted);
const restoredRows = (await restored.paseo.agents.ref(parentId).subagents.list()).rows;
expect(restoredRows.map((row) => row.child.agentId).sort()).toEqual(
[sameWorkspaceId, otherWorkspaceId, openTabId].sort(),
);
expect(restoredRows.every((row) => ["archived", "detached"].includes(row.phase))).toBe(true);
expect((await restarted.daemon.agentStorage.get(injectedId))?.parent).toBeUndefined();
});
test("an interrupted legacy upgrade resumes its bounded candidates before admitting new writes", async () => {
const { AgentStorage } = await import("../agent/agent-storage.js");
const { Subagents } = await import("../subagents/index.js");
const { getOrCreateServerId } = await import("../server-id.js");
const { default: pino } = await import("pino");
const root = await mkdtemp(join(tmpdir(), "paseo-interrupted-legacy-"));
cleanup.push(() => rm(root, { recursive: true, force: true }));
const home = join(root, ".paseo");
await mkdir(home);
const serverId = getOrCreateServerId(home);
const storage = new AgentStorage(join(home, "agents"), pino({ level: "silent" }));
const parentId = crypto.randomUUID();
const childIds = [crypto.randomUUID(), crypto.randomUUID()];
for (const id of [parentId, ...childIds])
await storage.upsert({
id,
provider: "codex",
cwd: root,
workspaceId: "legacy-workspace",
config: { model: "test" },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
lastStatus: "closed",
labels: id === parentId ? {} : { "paseo.parent-agent-id": parentId },
});
let qualifications = 0;
const interrupted = new Subagents({
home,
serverId,
agents: {
list: () => storage.list(),
get: (id) => storage.get(id),
upsert: async (record) => {
if (++qualifications === 2) throw new Error("Injected qualification write failure");
await storage.upsert(record);
},
},
resolveAuthority: async () => ({
principalId: "owner",
permissions: ["daemon.read", "workspace.read", "workspace.write"],
}),
runtime: async () => {
throw new Error("Upgrade must never acquire a client or submit creation");
},
});
await expect(interrupted.initialize()).rejects.toThrow("Injected qualification write failure");
const pending = JSON.parse(await readFile(join(home, "subagents.json"), "utf8"));
expect(pending.legacyUpgrade).toMatchObject({ complete: false, candidates: expect.any(Array) });
expect(pending.legacyUpgrade.candidates).toHaveLength(2);
expect((await storage.list()).filter((record) => record.parent)).toHaveLength(1);
const injectedId = crypto.randomUUID();
await storage.upsert({
...(await storage.get(childIds[0]!))!,
id: injectedId,
parent: undefined,
});
const restarted = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
cleanup.push(() => restarted.close());
const origin = await api(restarted);
const parent = origin.paseo.agents.ref(parentId);
expect((await parent.subagents.list()).rows.map((row) => row.child.agentId).sort()).toEqual(
childIds.toSorted(),
);
for (const id of childIds)
expect((await restarted.daemon.agentStorage.get(id))?.parent).toEqual(parent.address);
expect((await restarted.daemon.agentStorage.get(injectedId))?.parent).toBeUndefined();
const complete = JSON.parse(await readFile(join(home, "subagents.json"), "utf8"));
expect(complete.legacyUpgrade.complete).toBe(true);
expect(complete.legacyUpgrade.candidates).toEqual(pending.legacyUpgrade.candidates);
const archived: string[] = [];
const release = restarted.daemon.agentManager.onAgentArchived((id) => {
archived.push(id);
});
try {
await parent.archive();
await expect.poll(() => archived.toSorted()).toEqual([parentId, ...childIds].toSorted());
expect((await restarted.daemon.agentStorage.get(injectedId))?.archivedAt).toBeUndefined();
} finally {
release();
}
});
test("retained derived sessions cannot supply durable principal authority", async () => {
const a = await daemon();
const owner = await api(a);
@@ -3188,15 +3362,30 @@ test.each([false, true])(
};
for (const attempt of [1, 2]) {
const requestId = "legacy-original";
socket.send(JSON.stringify({ type: "session", message: { ...request, requestId } }));
await expect
.poll(
() =>
const receipt = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
socket.off("message", received);
reject(
new Error(
`Timed out waiting for legacy creation receipt ${attempt}: ${JSON.stringify(messages)}`,
),
);
}, 20_000);
function received() {
if (
messages.filter(
(message) => message.type === "status" && message.payload.requestId === requestId,
).length,
)
.toBe(attempt);
).length !== attempt
)
return;
clearTimeout(timeout);
socket.off("message", received);
resolve();
}
socket.on("message", received);
});
socket.send(JSON.stringify({ type: "session", message: { ...request, requestId } }));
await receipt;
const response = messages.find(
(message) => message.type === "status" && message.payload.requestId === requestId,
);
@@ -3225,3 +3414,158 @@ test.each([false, true])(
expect(prompts).toBe(1);
},
);
test("internal connection leases share ordinary host resolution and preserve borrowed origin and sibling lifetime", async () => {
const { acquireHostConnection } = await import("@getpaseo/client/internal/host-connection");
const a = await daemon();
const b = await daemon();
const origin = await api(a);
const direct = await api(b);
await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
const directory = await online(origin.paseo);
const controller = new AbortController();
const [self, first, sibling] = await Promise.all([
acquireHostConnection(origin.client, directory.self.serverId, controller.signal),
acquireHostConnection(origin.client, b.daemon.getServerId(), controller.signal),
acquireHostConnection(origin.client, b.daemon.getServerId(), controller.signal),
]);
cleanup.push(() => sibling.release());
cleanup.push(() => first.release());
expect(self.client.getServerId()).toBe(origin.client.getServerId());
await self.release();
expect(await origin.paseo.hosts.list()).toMatchObject({
self: { serverId: directory.self.serverId },
});
const firstApi = createPaseoApi(first.client);
const siblingApi = createPaseoApi(sibling.client);
cleanup.push(() => siblingApi.dispose());
cleanup.push(() => firstApi.dispose());
await first.release();
await expect(firstApi.workspaces.list()).rejects.toThrow();
expect((await siblingApi.workspaces.list()).entries).toEqual([]);
await origin.paseo.hosts.update(directory.hosts[0]!.registrationId, { enabled: false });
await expect(siblingApi.workspaces.list()).rejects.toThrow();
expect((await direct.paseo.workspaces.list()).entries).toEqual([]);
controller.abort();
await sibling.release();
const aborted = new AbortController();
aborted.abort();
await expect(
acquireHostConnection(origin.client, directory.self.serverId, aborted.signal),
).rejects.toThrow("disposed");
expect((await origin.paseo.hosts.list()).self.serverId).toBe(directory.self.serverId);
});
test("released internal self connection capability rejects captured operations while the borrowed origin stays usable", async () => {
const { acquireHostConnection } = await import("@getpaseo/client/internal/host-connection");
const a = await daemon();
const origin = await api(a);
const control = new AbortController();
const self = await acquireHostConnection(origin.client, a.daemon.getServerId(), control.signal);
const scoped = createPaseoApi(self.client);
cleanup.push(() => scoped.dispose());
await self.release();
await expect(scoped.workspaces.list()).rejects.toMatchObject({ code: "DAEMON_CONNECTION_LOST" });
expect((await origin.paseo.workspaces.list()).entries).toEqual([]);
});
test("nested self scopes preserve the consumed hop while a direct self scope reaches its registered destination", async () => {
const a = await daemon();
const b = await daemon();
const c = await daemon();
const origin = await api(a);
const directB = await api(b);
const wire = await hostWire(b);
await origin.paseo.hosts.add({ connection: wire.connection });
await online(origin.paseo);
await directB.paseo.hosts.add({ connection: `127.0.0.1:${c.port}` });
await online(directB.paseo);
const selfA = await origin.paseo.hosts.client(a.daemon.getServerId());
cleanup.push(() => selfA.dispose());
const viaB = await selfA.hosts.client(b.daemon.getServerId());
cleanup.push(() => viaB.dispose());
const nestedB = await viaB.hosts.client(b.daemon.getServerId());
cleanup.push(() => nestedB.dispose());
const twiceB = await nestedB.hosts.client(b.daemon.getServerId());
cleanup.push(() => twiceB.dispose());
expect((await twiceB.workspaces.list()).entries).toEqual([]);
await expect(twiceB.hosts.client(c.daemon.getServerId())).rejects.toMatchObject({
code: "unsupported_capability",
});
const directSelfB = await directB.paseo.hosts.client(b.daemon.getServerId());
cleanup.push(() => directSelfB.dispose());
const allowedC = await directSelfB.hosts.client(c.daemon.getServerId());
cleanup.push(() => allowedC.dispose());
expect((await allowedC.workspaces.list()).entries).toEqual([]);
expect(wire.connections()).toBe(1);
await nestedB.dispose();
expect((await viaB.workspaces.list()).entries).toEqual([]);
expect((await origin.paseo.workspaces.list()).entries).toEqual([]);
});
test("owned self session close settles pending RPC and observation and leaves sibling and physical origin usable", async () => {
const { acquireHostConnection } = await import("@getpaseo/client/internal/host-connection");
const a = await daemon();
const wire = await hostWire(a);
const origin = createHostClient(wire.connection, {
clientId: crypto.randomUUID(),
reconnect: { enabled: false },
});
cleanup.push(() => origin.close());
await origin.connect();
const originApi = createPaseoApi(origin);
cleanup.push(() => originApi.dispose());
const control = new AbortController();
const self = await acquireHostConnection(origin, a.daemon.getServerId(), control.signal);
cleanup.push(() => self.release());
const sibling = await acquireHostConnection(
origin,
a.daemon.getServerId(),
new AbortController().signal,
);
cleanup.push(() => sibling.release());
const observed = self.client.observeHosts();
const siblingObserved = sibling.client.observeHosts();
let updates = 0;
let siblingUpdates = 0;
observed.subscribe({
snapshot() {},
update() {
updates++;
},
});
siblingObserved.subscribe({
snapshot() {},
update() {
siblingUpdates++;
},
});
await Promise.all([observed.ready, siblingObserved.ready]);
const states: string[] = [];
self.client.subscribeConnectionStatus((state) => states.push(state.status));
const identity = self.client.getServerId();
const capturedFetch = self.client.fetchWorkspaces.bind(self.client);
const held = wire.holdResponses((message) => message.type === "fetch_workspaces_response");
const pending = capturedFetch();
void pending.catch(() => {});
await held.ready;
try {
await self.release();
await expect(pending).rejects.toThrow();
expect(states.at(-1)).toBe("disposed");
expect(self.client.getServerId()).toBe(identity);
const rejected = capturedFetch();
expect(rejected).toBeInstanceOf(Promise);
await expect(rejected).rejects.toMatchObject({ code: "DAEMON_CONNECTION_LOST" });
held.release();
await originApi.hosts.add({ connection: "127.0.0.1:1", label: "After scope release" });
await expect.poll(() => siblingUpdates).toBeGreaterThan(0);
expect(updates).toBe(0);
expect((await sibling.client.fetchWorkspaces()).entries).toEqual([]);
expect((await originApi.workspaces.list()).entries).toEqual([]);
control.abort();
await self.release();
} finally {
held.release();
}
});
+134 -100
View File
@@ -16,16 +16,23 @@ export class DaemonLinkEndpoint {
constructor(
private readonly parent: {
identity: string;
serverId?: string;
assertScopeAdmission?(): void;
permissions(): readonly DaemonPermission[];
routed(): boolean;
bufferedAmount(): number | null;
delivery: SessionDelivery;
admit(socket: VirtualSessionSocket, permissions: readonly DaemonPermission[]): Promise<void>;
admit(
socket: VirtualSessionSocket,
permissions: readonly DaemonPermission[],
context: { kind: "scope" | "destination-link" },
): Promise<void>;
},
) {}
handles(message: SessionInboundMessage): boolean {
return (
message.type === "hosts.link.open.request" ||
(message.type === "hosts.route.open.request" && message.serverId === this.parent.serverId) ||
(message.type === "hosts.route.frame.request" && this.channels.has(message.routeId)) ||
(message.type === "hosts.route.close.request" &&
((!!message.routeId && this.channels.has(message.routeId)) ||
@@ -33,105 +40,11 @@ export class DaemonLinkEndpoint {
);
}
async dispatch(message: SessionInboundMessage): Promise<void> {
if (message.type === "hosts.link.open.request") {
const error = (code: string, text: string) =>
this.parent.delivery.reply({
type: "rpc_error",
payload: {
requestId: message.requestId,
requestType: message.type,
code,
error: text,
},
});
if (this.closed || this.parent.routed()) {
error("unsupported_capability", "Connected hosts support one hop");
return;
}
if (this.channels.size >= 128) {
error("resource_limit", "Too many host channels");
return;
}
let socket: VirtualSessionSocket;
const owner = this.parent.delivery.begin("host-link", undefined, () => {
this.channels.delete(owner.id);
socket?.close();
});
const id = owner.id;
// Capture actual authenticated authority. The caller hello can only reduce it.
const ceiling = [...this.parent.permissions()];
socket = new VirtualSessionSocket(
async (data) => {
const output: SessionOutboundMessage | null =
typeof data === "string"
? {
type: "hosts.route.frame",
payload: { subscriptionId: id, data, binary: false },
}
: null;
const frame = typeof data === "string" ? null : encodeHostRouteFrame(id, data);
const bytes = output
? Buffer.byteLength(JSON.stringify({ type: "session", message: output }))
: frame!.byteLength;
const buffered = this.parent.bufferedAmount();
// Account for the exact escaped outer frame, retaining control-frame headroom.
if (buffered === null || buffered + bytes > 64 * 1024 * 1024 - 65536) {
socket.close(1009, "Host channel output limit reached");
throw new Error("Host channel output limit reached");
}
try {
if (output) await owner.emitAndWait(output);
else await owner.emitBinaryAndWait(frame!);
} catch (failure) {
socket.close();
throw failure;
}
},
(code, reason) => {
this.channels.delete(id);
let failure = "host_offline";
if (code === 1009) failure = "resource_limit";
if (code === 1008) failure = "permission_denied";
owner.emit({
type: "hosts.route.closed",
payload: {
subscriptionId: id,
code: failure,
message: reason,
},
});
void owner.release();
},
() => this.parent.bufferedAmount() ?? 0,
(data) => {
const hello = JSON.parse(data);
if (hello.type !== "hello" || typeof hello.clientId !== "string")
throw new Error("Expected hello");
return JSON.stringify({
...hello,
auth: undefined,
routed: true,
clientId: `link-${createHash("sha256")
.update(JSON.stringify([this.parent.identity, hello.clientId]))
.digest("hex")}`,
});
},
);
this.channels.set(id, { scopeId: message.channelId, socket });
try {
await this.parent.admit(socket, ceiling);
if (this.closed || socket.readyState !== 1) {
socket.close();
return;
}
this.parent.delivery.reply({
type: "hosts.link.open.response",
payload: { requestId: message.requestId, routeId: id },
});
} catch {
socket.close();
error("host_offline", "Could not admit host channel");
}
if (
message.type === "hosts.link.open.request" ||
(message.type === "hosts.route.open.request" && message.serverId === this.parent.serverId)
) {
await this.openChannel(message);
return;
}
if (message.type === "hosts.route.frame.request" && this.channels.has(message.routeId)) {
@@ -146,6 +59,127 @@ export class DaemonLinkEndpoint {
}
return;
}
private async openChannel(
message: Extract<
SessionInboundMessage,
{ type: "hosts.link.open.request" | "hosts.route.open.request" }
>,
): Promise<void> {
const selfScope = message.type === "hosts.route.open.request";
const error = (code: string, text: string) =>
this.parent.delivery.reply({
type: "rpc_error",
payload: {
requestId: message.requestId,
requestType: message.type,
code,
error: text,
},
});
if (this.closed || (!selfScope && this.parent.routed())) {
error("unsupported_capability", "Connected hosts support one hop");
return;
}
try {
this.parent.assertScopeAdmission?.();
} catch (failure) {
error(
"unsupported_capability",
failure instanceof Error ? failure.message : "Unsupported scoped admission",
);
return;
}
if (this.channels.size >= 128) {
error("resource_limit", "Too many host channels");
return;
}
let socket: VirtualSessionSocket;
const owner = this.parent.delivery.begin("host-link", undefined, () => {
this.channels.delete(owner.id);
socket?.close();
});
const id = owner.id;
// Capture actual authenticated authority. The caller hello can only reduce it.
const ceiling = [...this.parent.permissions()];
socket = new VirtualSessionSocket(
async (data) => {
const output: SessionOutboundMessage | null =
typeof data === "string"
? {
type: "hosts.route.frame",
payload: { subscriptionId: id, data, binary: false },
}
: null;
const frame = typeof data === "string" ? null : encodeHostRouteFrame(id, data);
const bytes = output
? Buffer.byteLength(JSON.stringify({ type: "session", message: output }))
: frame!.byteLength;
const buffered = this.parent.bufferedAmount();
// Account for the exact escaped outer frame, retaining control-frame headroom.
if (buffered === null || buffered + bytes > 64 * 1024 * 1024 - 65536) {
socket.close(1009, "Host channel output limit reached");
throw new Error("Host channel output limit reached");
}
try {
if (output) await owner.emitAndWait(output);
else await owner.emitBinaryAndWait(frame!);
} catch (failure) {
socket.close();
throw failure;
}
},
(code, reason) => {
this.channels.delete(id);
let failure = "host_offline";
if (code === 1009) failure = "resource_limit";
if (code === 1008) failure = "permission_denied";
owner.emit({
type: "hosts.route.closed",
payload: {
subscriptionId: id,
code: failure,
message: reason,
},
});
void owner.release();
},
() => this.parent.bufferedAmount() ?? 0,
(data) => {
const hello = JSON.parse(data);
if (hello.type !== "hello" || typeof hello.clientId !== "string")
throw new Error("Expected hello");
return JSON.stringify({
...hello,
auth: undefined,
routed: selfScope ? this.parent.routed() : true,
sessionPermissions: ceiling.filter(
(permission) =>
!hello.sessionPermissions || hello.sessionPermissions.includes(permission),
),
clientId: `link-${createHash("sha256")
.update(JSON.stringify([this.parent.identity, hello.clientId]))
.digest("hex")}`,
});
},
);
this.channels.set(id, { scopeId: selfScope ? message.scopeId : message.channelId, socket });
try {
await this.parent.admit(socket, ceiling, {
kind: selfScope ? "scope" : "destination-link",
});
if (this.closed || socket.readyState !== 1) {
socket.close();
return;
}
this.parent.delivery.reply({
type: selfScope ? "hosts.route.open.response" : "hosts.link.open.response",
payload: { requestId: message.requestId, routeId: id },
});
} catch {
socket.close();
error("host_offline", "Could not admit host channel");
}
}
binary(frame: HostRouteFrame): boolean {
const socket = this.channels.get(frame.routeId)?.socket;
if (!socket) return false;
@@ -62,6 +62,24 @@ test("Hub execute can steer ordinary agents while unrelated administration stays
expect(hub.serverInfoPermissions()).toEqual([["hub.execute"]]);
});
test("execution-scoped admission cannot acquire an ordinary self session", async () => {
const hub = await launchRelationship();
expect(
await hub.requestOrdinary({
type: "hosts.route.open.request",
requestId: "execution-self-scope",
serverId: hub.serverId(),
scopeId: "execution-self-scope",
}),
).toMatchObject({
type: "rpc_error",
payload: { requestId: "execution-self-scope", code: "unsupported_capability" },
});
// Rejection leaves the originating execution capability usable.
const created = await hub.createOwnedConcurrently();
expect(created.duplicate.agentId).toBe(created.first.agentId);
});
test("Hub execute can title the workspace created with an agent", async () => {
const hub = await launchRelationship();
const created = await hub.requestOrdinary({
@@ -165,11 +183,23 @@ test("ordinary Hub create and message retries do not duplicate agents or prompts
};
expect(await hub.requestOrdinary({ ...message, requestId: "message-first" })).toEqual({
type: "send_agent_message_response",
payload: { requestId: "message-first", agentId, accepted: true, error: null },
payload: {
requestId: "message-first",
agentId,
accepted: true,
error: null,
dispatch: { disposition: "turn_started", tracking: "turn" },
},
});
expect(await hub.requestOrdinary({ ...message, requestId: "message-duplicate" })).toEqual({
type: "send_agent_message_response",
payload: { requestId: "message-duplicate", agentId, accepted: true, error: null },
payload: {
requestId: "message-duplicate",
agentId,
accepted: true,
error: null,
dispatch: { disposition: "turn_started", tracking: "turn" },
},
});
expect(
hub
@@ -1212,6 +1212,10 @@ export class HubRelationshipHarness {
);
}
serverId(): string {
return this.daemon!.getServerId();
}
probeTrustedHello(): number | null {
const socket = this.latestSocket().socket;
socket.receiveEnvelope({
+133 -14
View File
@@ -1,6 +1,6 @@
import type { HostDirectory } from "@getpaseo/protocol/hosts";
import { shouldDetachFromArchivedParent } from "@getpaseo/protocol/agent-lifecycle";
import { hasOpenAgentTab } from "@getpaseo/protocol/agent-labels";
import { hasOpenAgentTab, getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
import { WorkspaceAddressSchema, type WorkspaceAddress } from "@getpaseo/protocol/addresses";
import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages";
import { createHash, randomUUID } from "node:crypto";
@@ -19,6 +19,8 @@ import {
} from "@getpaseo/protocol/messages";
import { writeJsonFileAtomic } from "../atomic-file.js";
import type { HostRuntimeAuthority } from "../host-runtime/index.js";
import type { AgentStorage, StoredAgentRecord } from "../agent/agent-storage.js";
import { buildStoredAgentPayload } from "../agent/agent-projections.js";
/** Stored authority contains identity and a ceiling; live admission belongs to the host. */
export interface SubagentAuthority {
@@ -31,11 +33,9 @@ const AuthoritySchema = z.object({
sourceId: z.string(),
permissionCeiling: z.array(DaemonPermissionSchema),
});
const RecordSchema = z.object({
const RelationshipStateSchema = z.object({
row: SubagentRelationshipSchema,
authority: AuthoritySchema,
creation: SubagentCreateInputSchema,
fingerprint: z.string(),
confirmed: z.enum(["attached", "detached", "archived", "canceled"]).nullable(),
intent: z.object({
revision: z.number().int().nonnegative(),
@@ -44,8 +44,24 @@ const RecordSchema = z.object({
authority: AuthoritySchema,
}),
});
const RecordSchema = z.discriminatedUnion("provenance", [
RelationshipStateSchema.extend({
provenance: z.literal("creation"),
creation: SubagentCreateInputSchema,
fingerprint: z.string(),
}),
RelationshipStateSchema.extend({
provenance: z.literal("legacy-local-lifecycle"),
creation: z.null(),
fingerprint: z.null(),
}),
]);
type RelationshipRecord = z.infer<typeof RecordSchema>;
type RelationshipIntent = RelationshipRecord["intent"];
const LedgerSchema = z.object({
records: z.array(RecordSchema),
legacyUpgrade: z.object({ complete: z.boolean(), candidates: z.array(z.string()) }),
});
/** Authoritative parent membership, independent of views and destination metadata. */
export class Subagents {
@@ -69,11 +85,13 @@ export class Subagents {
private mutation: Promise<unknown> = Promise.resolve();
private readonly ready: Promise<void>;
private closed = false;
private legacyUpgrade = { complete: false, candidates: [] as string[] };
constructor(
private readonly options: {
home: string;
serverId: string;
agents: Pick<AgentStorage, "list" | "get" | "upsert">;
runtime(authority: HostRuntimeAuthority): Promise<HostRuntime>;
resolveAuthority(principalId: string): ReturnType<HostRuntimeAuthority["resolveAdmission"]>;
},
@@ -83,17 +101,99 @@ export class Subagents {
}
private async load(): Promise<void> {
let records: RelationshipRecord[];
let records: RelationshipRecord[] = [];
let existing = false;
try {
records = z.array(RecordSchema).parse(JSON.parse(await readFile(this.path(), "utf8")));
const ledger = LedgerSchema.parse(JSON.parse(await readFile(this.path(), "utf8")));
records = ledger.records;
this.legacyUpgrade = ledger.legacyUpgrade;
existing = true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
throw error;
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
for (const record of records) {
record.row.freshness = "stale";
this.records.set(record.row.id, record);
}
if (!this.legacyUpgrade.complete) await this.upgradeLegacyMembership(existing);
}
initialize(): Promise<void> {
return this.ready;
}
private async upgradeLegacyMembership(existing: boolean): Promise<void> {
if (!existing) {
const agents = await this.options.agents.list();
const byId = new Map(agents.map((agent) => [agent.id, agent]));
const admission = await this.options.resolveAuthority("owner");
const authority: z.infer<typeof AuthoritySchema> = {
principalId: "owner",
sourceId: "owner",
permissionCeiling: admission.permissions.filter((permission) =>
["daemon.read", "workspace.read", "workspace.write"].includes(permission),
),
};
for (const child of agents) {
const parentId = getParentAgentIdFromLabels(child.labels);
const parent = parentId ? byId.get(parentId) : undefined;
if (
!legacyManagedAgent(child) ||
child.archivedAt ||
!parent ||
!legacyManagedAgent(parent) ||
child.parent !== undefined
)
continue;
if (
[...this.records.values()].some(
(record) =>
record.row.child.serverId === this.options.serverId &&
record.row.child.agentId === child.id,
)
)
continue;
const id = createHash("sha256")
.update(JSON.stringify(["legacy-local-lifecycle", this.options.serverId, child.id]))
.digest("hex");
const address = { serverId: this.options.serverId, agentId: parent.id };
this.records.set(id, {
row: {
id,
parent: address,
child: { serverId: this.options.serverId, agentId: child.id },
...(child.workspaceId
? { workspace: { serverId: this.options.serverId, workspaceId: child.workspaceId } }
: {}),
phase: "attached",
freshness: "stale",
observedAt: null,
error: null,
snapshot: buildStoredAgentPayload({ ...child, parent: address }, [child.provider]),
},
provenance: "legacy-local-lifecycle",
creation: null,
fingerprint: null,
authority,
confirmed: "attached",
intent: { kind: "attached", revision: 0, authority },
});
this.legacyUpgrade.candidates.push(id);
}
// Persist the bounded candidate set before qualifying agent metadata. A restart
// resumes these candidates, never a new scan of subsequently edited labels.
await this.persist();
}
for (const id of this.legacyUpgrade.candidates) {
const record = this.records.get(id)!;
const child = await this.options.agents.get(record.row.child.agentId);
if (!child) continue;
if (child.parent === undefined)
await this.options.agents.upsert({ ...child, parent: record.row.parent });
else if (!child.parent || !sameAddress(child.parent, record.row.parent))
throw new Error("Legacy child parent changed during membership upgrade");
}
this.legacyUpgrade.complete = true;
await this.persist();
}
private path(): string {
return join(this.options.home, "subagents.json");
@@ -109,7 +209,10 @@ export class Subagents {
}
private async persist(): Promise<void> {
for (const record of this.records.values()) record.row.phase = relationshipPhase(record);
await writeJsonFileAtomic(this.path(), [...this.records.values()]);
await writeJsonFileAtomic(this.path(), {
records: [...this.records.values()],
legacyUpgrade: this.legacyUpgrade,
});
for (const changed of this.listeners) changed();
}
async assertHostRemovable(serverId: string): Promise<void> {
@@ -262,6 +365,7 @@ export class Subagents {
return existing;
}
const fresh: RelationshipRecord = {
provenance: "creation",
row: {
id,
parent,
@@ -309,10 +413,11 @@ export class Subagents {
return running;
}
private creationKey(record: RelationshipRecord): string {
return record.creation.input.idempotencyKey ?? `subagent:${record.row.id}`;
return creationRequest(record).input.idempotencyKey ?? `subagent:${record.row.id}`;
}
private async createChild(record: RelationshipRecord): Promise<AgentAddress> {
const { row, creation } = record;
const { row } = record;
const creation = creationRequest(record);
const creationIntent = record.intent;
try {
const runtime = await this.runtime(record.authority);
@@ -655,7 +760,7 @@ export class Subagents {
return null;
}
const observation = api.creations.observe({
kind: record.creation.workspace ? "workspace" : "agent",
kind: creationRequest(record).workspace ? "workspace" : "agent",
idempotencyKey: this.creationKey(record),
});
let latest: CreationSnapshot | null = null;
@@ -705,12 +810,13 @@ export class Subagents {
const child = await runtime.agent(record.row.child);
let current = await child.refresh();
if (!current) {
const creation = creationRequest(record);
const destination = await runtime.client(record.row.child.serverId);
try {
if (record.intent !== intent) return;
const cancellation = await destination.creations.cancel({
...(record.creation.workspace
? { kind: "workspace" as const, workspaceId: record.creation.input.workspaceId! }
...(creation.workspace
? { kind: "workspace" as const, workspaceId: creation.input.workspaceId! }
: { kind: "agent" as const }),
idempotencyKey: this.creationKey(record),
agentId: record.row.child.agentId,
@@ -842,3 +948,16 @@ function terminalPhase(phase: RelationshipRecord["row"]["phase"]): boolean {
phase === "detached" || phase === "archived" || phase === "forgotten" || phase === "canceled"
);
}
function legacyManagedAgent(agent: StoredAgentRecord): boolean {
return !agent.internal && !agent.owner;
}
function creationRequest(record: RelationshipRecord): SubagentCreateInput {
if (record.provenance !== "creation" || !record.creation)
throw failure(
"agent_not_found",
"The adopted existing child is absent; there is no creation to replay or cancel",
);
return record.creation;
}
+35 -11
View File
@@ -755,6 +755,7 @@ export class VoiceAssistantWebSocketServer {
this.subagents = new Subagents({
home: paseoHome,
serverId,
agents: this.agentStorage,
runtime: (authority) => this.createHostRuntime(authority),
resolveAuthority: (principalId) => this.resolveRelationshipAdmission(principalId),
});
@@ -766,13 +767,16 @@ export class VoiceAssistantWebSocketServer {
this.logger.warn({ err: error }, "Failed to reconcile subagent host availability"),
);
});
this.stopAgentArchived = this.agentManager.onAgentArchived(async (agentId) => {
const record = await this.agentStorage.get(agentId);
await this.subagents.parentArchived(
{ serverId, agentId },
record?.workspaceId ? { serverId, workspaceId: record.workspaceId } : undefined,
);
});
this.stopAgentArchived = this.agentManager.onAgentArchived(
async (agentId) => {
const record = await this.agentStorage.get(agentId);
await this.subagents.parentArchived(
{ serverId, agentId },
record?.workspaceId ? { serverId, workspaceId: record.workspaceId } : undefined,
);
},
{ ownsManagedChildren: true },
);
this.passwordHash = auth?.password;
this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot;
this.daemonConfigStore = daemonConfigStore;
@@ -1158,6 +1162,10 @@ export class VoiceAssistantWebSocketServer {
this.connectionLifecycle = "stopping";
}
public initializeRelationships(): Promise<void> {
return this.subagents.initialize();
}
public beginAcceptingConnections(): void {
if (this.connectionLifecycle === "starting") {
this.connectionLifecycle = "accepting";
@@ -2029,6 +2037,7 @@ export class VoiceAssistantWebSocketServer {
connectedHosts: true,
fleetSubagents: true,
hostMultiplexing: true,
hostSessionScopes: true,
usageSources: true,
ownedSubscriptions: true,
agentRequestReceipts: true,
@@ -2632,15 +2641,30 @@ export class VoiceAssistantWebSocketServer {
if (!link) {
link = new DaemonLinkEndpoint({
identity: JSON.stringify([connection.principalId, connection.clientId]),
serverId: this.serverId,
assertScopeAdmission: () => {
if (connection.hubExecutionAgents)
throw Object.assign(new Error("Execution-scoped host acquisition is not supported"), {
code: "unsupported_capability",
});
},
permissions: () => connection.session.getPermissions(),
routed: () => connection.session.isRouted(),
bufferedAmount: () => ws.bufferedAmount ?? null,
delivery: connection.session.delivery,
admit: (socket, permissions) =>
this.attachSocket(socket, undefined, undefined, false, {
admit: (socket, permissions, context) => {
if (connection.hubExecutionAgents)
throw new Error("Execution-scoped host acquisition is not supported");
this.ownedSockets.add(socket);
if (context.kind === "scope")
this.ownedSocketSources.set(socket, connection.session.getHostSourceId());
return this.attachSocket(socket, undefined, undefined, false, {
principalId: connection.principalId,
permissions,
}),
permissions: permissions.filter((permission) =>
connection.session.getPermissions().includes(permission),
),
});
},
});
this.hostLinks.set(ws, link);
}