Let Hub continue agents through the standard daemon API (#4354)

* feat(hub): continue agents through standard daemon operations

Authorize ordinary agent and workspace recovery operations with hub.execute. Keep conversation routing in Hub and persist generic creation and message receipts so retries cannot silently duplicate work.

* fix(hub): expose custom providers to continuing clients

Allow Hub to advertise provider support independently of the app version. Document the companion continuation policy and migration. Preserve newly observed files in the native watcher inventory so coalesced deletions remain visible to reconciliation, addressing the Windows CI failure.

* fix(hub): allow retries after confirmed local failures

Prepare message loading before recording dispatch intent. Remove failed creation receipts only when creation cleanup completed and no live or stored agent exists. Keep ambiguous provider deliveries fail-closed. Exercise native file observation with real filesystem dependencies.
This commit is contained in:
Mohamed Boudra
2026-09-06 10:19:40 +02:00
committed by GitHub
parent efd6023b90
commit ab7d66dfed
28 changed files with 862 additions and 198 deletions
+21 -1
View File
@@ -294,6 +294,24 @@ test("advertises consumer-provided browser automation capabilities", async () =>
});
});
test("retry-safe creation rejects older hosts before sending any request", async () => {
const transport = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "receipt-gate",
transportFactory: () => transport.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
transport.triggerOpen();
await connecting;
await expect(
client.createAgent({ provider: "codex", cwd: "/project", idempotencyKey: "creation" }),
).rejects.toThrow("Update the host to use retry-safe agent creation.");
expect(transport.sent).toEqual([]);
});
test("Hub management requires daemon support before dispatching requests", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
@@ -2586,10 +2604,11 @@ test("sends create_agent_request with workspace and caller identity", async () =
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
mock.triggerOpen({ features: { agentRequestReceipts: true } });
await connectPromise;
const createPromise = client.createAgent({
idempotencyKey: "one-creation",
provider: "codex",
cwd: "/tmp/project/.paseo/worktrees/feature-a",
workspaceId: "ws-feature-a",
@@ -2603,6 +2622,7 @@ test("sends create_agent_request with workspace and caller identity", async () =
expect(request).toEqual(
expect.objectContaining({
type: "create_agent_request",
idempotencyKey: "one-creation",
workspaceId: "ws-feature-a",
callerAgentId: "parent-agent",
}),
+10
View File
@@ -366,6 +366,7 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
workspaceId?: string;
callerAgentId?: string;
initialPrompt?: string;
idempotencyKey?: string;
clientMessageId?: string;
outputSchema?: Record<string, unknown>;
images?: CreateAgentRequestMessage["images"];
@@ -2503,6 +2504,7 @@ export class DaemonClient {
// ============================================================================
async createAgent(options: CreateAgentRequestOptions): Promise<AgentSnapshotPayload> {
if (options.idempotencyKey !== undefined) this.requireAgentRequestReceipts();
const requestId = this.createRequestId(options.requestId);
const config = resolveAgentConfig(options);
@@ -2514,6 +2516,7 @@ export class DaemonClient {
...(options.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}),
...(options.callerAgentId !== undefined ? { callerAgentId: options.callerAgentId } : {}),
...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}),
idempotencyKey: options.idempotencyKey,
...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}),
...(options.outputSchema ? { outputSchema: options.outputSchema } : {}),
...(options.images && options.images.length > 0 ? { images: options.images } : {}),
@@ -2555,6 +2558,13 @@ export class DaemonClient {
return status.agent;
}
private requireAgentRequestReceipts(): void {
// COMPAT(agentRequestReceipts): added in v0.7.3; remove gate after 2027-03-05.
if (this.lastServerInfoMessage?.features?.agentRequestReceipts !== true) {
throw new Error("Update the host to use retry-safe agent creation.");
}
}
async deleteAgent(agentId: string): Promise<void> {
const requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({