diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index f77c88d24..efdf9de15 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -118,7 +118,7 @@ describe("configuration", () => { }); }); - it("accepts a remote Planner on a public bind with no MODEL, and only without background jobs", () => { + it("accepts a remote Planner on a public bind with no MODEL, with or without background jobs", () => { let remote = { HARNESS: "remote", SERVER_HOST: "0.0.0.0", BACKGROUND_JOBS: "off" }; let config = configured(remote); expect(config).toMatchObject({ harness: "remote", host: "0.0.0.0", model: "" }); @@ -127,8 +127,11 @@ describe("configuration", () => { "Planner: attached client over /api/v1/agent (model and client tools run on its machine)", ); expect(configured({ ...remote, MODEL: "openai/gpt-6" }).model).toBe("openai/gpt-6"); - expect(() => configured({ HARNESS: "remote" })) - .toThrow("HARNESS=remote requires BACKGROUND_JOBS=off"); + expect(configured({ HARNESS: "remote" })).toMatchObject({ + harness: "remote", + backgroundJobs: true, + webResearch: true, + }); }); it("keeps a detached remote Planner's ownership two minutes unless PLANNER_ATTACH_GRACE_MS says otherwise", () => { diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index c43bfda83..de50dcb71 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -289,11 +289,6 @@ export function load(): Config { throw new Error("AGENT_CONNECTION_MAX_SILENCE_MS must be at least AGENT_CONNECTION_TIMEOUT_MS"); } let selection = harnessSelection(); - if (selection.harness === "remote" && backgroundJobs) { - throw new Error( - "HARNESS=remote requires BACKGROUND_JOBS=off: summary and research workers have no model on the server", - ); - } let serverPort = port(); return { ...selection, diff --git a/apps/server/src/harness/remote/link.test.ts b/apps/server/src/harness/remote/link.test.ts index c75f62977..8cd8981ed 100644 --- a/apps/server/src/harness/remote/link.test.ts +++ b/apps/server/src/harness/remote/link.test.ts @@ -16,7 +16,8 @@ import * as Store from "../../questions/store"; import * as Plan from "../../plan/service"; import * as Edit from "../../plan/edit"; import * as Room from "../../plan/room"; -import { createPlannerAgent } from "../agents"; +import { createPlannerAgent, createResearchAgent } from "../agents"; +import { openWorkerSession } from "../../jobs/worker-session"; import { createHumanInput } from "../atomic/human-input"; import { openPlannerSession } from "../session"; import { untilUnpaused } from "../atomic/full"; @@ -43,7 +44,7 @@ import { createRemoteAdapter } from "./adapter"; import { admitAgent } from "./endpoint"; import { remotePlanner } from "./registry"; -import type { AgentClient, AgentHandlers } from "@chopin/agent-link/client"; +import type { AgentClient, AgentHandlers, TurnContext } from "@chopin/agent-link/client"; import type { ActiveOwnerBinding } from "../../agent/active-owner"; import type { RemotePlanner } from "./registry"; @@ -1774,3 +1775,73 @@ describe("Agent connection questions", () => { await askingTurn("destroy"); }); }); + +describe("Agent connection background workers", () => { + it("runs a document's research worker in its attached client as an isolated session", async () => { + let starts: string[] = []; + let turns: TurnContext["message"][] = []; + let searched: unknown[] = []; + let { channel } = await setup({ + start: message => void starts.push(message.mode), + async turn(context) { + turns.push(context.message); + context.emit({ type: "stream-start", modelId: "scripted" }); + let search = await context.callHostTool("web_search", { query: "axum rate limiting" }); + let evidence = { + findings: [`Searched: ${String(search.output)}`], + sources: [{ title: "tower-governor", url: "https://docs.rs/tower_governor" }], + }; + context.emit({ type: "text-start", id: "result" }); + context.emit({ type: "text-delta", id: "result", delta: JSON.stringify(evidence) }); + context.emit({ type: "text-end", id: "result" }); + finishTurn(context); + }, + }); + let agent = createResearchAgent(createRemoteAdapter()); + let worker = await openWorkerSession(agent, { + channelId: channel.id, + token: () => MEMBER_TOKEN, + maxAiCredits: 1, + aborted: new Promise(() => {}), + openSandbox: async () => sandboxSession, + }); + cleanups.push(() => worker.close()); + + let result = await agent.generate({ + session: worker.session, + prompt: JSON.stringify({ material: { query: "axum rate limiting" } }), + options: { + model: "", + instructions: "Research only public web evidence.", + webSearch: tool({ + inputSchema: z.object({ query: z.string() }), + execute: async input => { + searched.push(input); + return "results"; + }, + }), + }, + }); + + expect(starts).toEqual(["isolated"]); + expect(turns.map(turn => turn.tools.map(spec => spec.name))).toEqual([["web_search"]]); + expect(turns[0]!.responseFormat?.type).toBe("json"); + expect(searched).toEqual([{ query: "axum rate limiting" }]); + expect(result.output).toEqual({ + findings: ["Searched: results"], + sources: [{ title: "tower-governor", url: "https://docs.rs/tower_governor" }], + }); + }); + + it("refuses a worker for a document with no attached Planner", async () => { + let { channel } = await setup(); + let agent = createResearchAgent(createRemoteAdapter()); + await expect(openWorkerSession(agent, { + channelId: channel.id, + token: () => MEMBER_TOKEN, + maxAiCredits: 1, + aborted: new Promise(() => {}), + openSandbox: async () => sandboxSession, + })).rejects.toThrow("No Planner is attached to this document."); + }); +}); diff --git a/apps/server/src/jobs/document-summary.ts b/apps/server/src/jobs/document-summary.ts index 4c568323c..0b907a80b 100644 --- a/apps/server/src/jobs/document-summary.ts +++ b/apps/server/src/jobs/document-summary.ts @@ -230,6 +230,7 @@ class HarnessSummaryEngine { else abortSignal.addEventListener("abort", stop, { once: true }); }); let worker = await openWorkerSession(summaryAgent, { + channelId: execution.job.channelId, token: () => credential.token, maxAiCredits: MAX_AI_CREDITS, aborted, diff --git a/apps/server/src/jobs/research-brief.test.ts b/apps/server/src/jobs/research-brief.test.ts index 982c9082f..f615f00f5 100644 --- a/apps/server/src/jobs/research-brief.test.ts +++ b/apps/server/src/jobs/research-brief.test.ts @@ -109,6 +109,7 @@ test("the default brief engine opens a worker within Copilot's session credit co try { let definition = researchBriefDefinition({ config: { agent: true, model: "fixture" } }); let result = await definition.execute({ + job: { channelId: "channel" }, input: material, signal: new AbortController().signal, deadline: new Date(Date.now() + 60_000), diff --git a/apps/server/src/jobs/research-brief.ts b/apps/server/src/jobs/research-brief.ts index 8d7940785..728af6caf 100644 --- a/apps/server/src/jobs/research-brief.ts +++ b/apps/server/src/jobs/research-brief.ts @@ -92,6 +92,7 @@ async function generate( try { if (!await credential.authorize()) throw new JobExecutionError("owner-unavailable"); let opened = await openWorkerSession(researchBriefAgent, { + channelId: execution.job.channelId, token: () => credential.token, maxAiCredits: MAX_AI_CREDITS, aborted, diff --git a/apps/server/src/jobs/research-workspace.ts b/apps/server/src/jobs/research-workspace.ts index f86d29b5b..dba6416c3 100644 --- a/apps/server/src/jobs/research-workspace.ts +++ b/apps/server/src/jobs/research-workspace.ts @@ -971,6 +971,7 @@ async function stage( diagnose("opening-session"); if (publicWeb) await execution.progress("worker-session", "started"); worker = await openWorkerSession(agent, { + channelId: execution.job.channelId, token: () => credential.token, maxAiCredits: STAGE_AI_CREDITS, aborted, diff --git a/apps/server/src/jobs/version-summary.ts b/apps/server/src/jobs/version-summary.ts index 5c09c1656..c95be2df1 100644 --- a/apps/server/src/jobs/version-summary.ts +++ b/apps/server/src/jobs/version-summary.ts @@ -211,6 +211,7 @@ class HarnessVersionSummaryEngine { else abortSignal.addEventListener("abort", stop, { once: true }); }); let worker = await openWorkerSession(historySummaryAgent, { + channelId: execution.job.channelId, token: () => credential.token, maxAiCredits: MAX_AI_CREDITS, aborted, diff --git a/apps/server/src/jobs/worker-session.test.ts b/apps/server/src/jobs/worker-session.test.ts index ce63253c2..0e295e9fb 100644 --- a/apps/server/src/jobs/worker-session.test.ts +++ b/apps/server/src/jobs/worker-session.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { registerCredential } from "../harness/harnesses"; +import { remotePlanner } from "../harness/remote/registry"; import { openWorkerSession, type WorkerSandbox } from "./worker-session"; function fixture() { @@ -41,6 +42,7 @@ describe("openWorkerSession", () => { return session; }, }, { + channelId: "channel", token: () => "token", maxAiCredits: 4, aborted: never(), @@ -49,9 +51,11 @@ describe("openWorkerSession", () => { expect(worker.session).toBe(session); expect(released(sessionIds[0]!)).toBe(false); + expect(remotePlanner(sessionIds[0]!)).toEqual({ channelId: "channel", isolated: true }); await worker.close(); expect(events).toEqual(["session destroyed", "sandbox destroyed"]); expect(released(sessionIds[0]!)).toBe(true); + expect(remotePlanner(sessionIds[0]!)).toBeUndefined(); }); it("keeps closing after the session fails to destroy", async () => { @@ -62,6 +66,7 @@ describe("openWorkerSession", () => { return { destroy: async () => Promise.reject(new Error("session stuck")) }; }, }, { + channelId: "channel", token: () => "token", maxAiCredits: 4, aborted: never(), @@ -81,6 +86,7 @@ describe("openWorkerSession", () => { throw new Error("harness unavailable"); }, }, { + channelId: "channel", token: () => "token", maxAiCredits: 4, aborted: never(), @@ -100,7 +106,13 @@ describe("openWorkerSession", () => { sessionIds.push(options.sessionId); return late.promise; }, - }, { token: () => "token", maxAiCredits: 4, aborted, openSandbox: async () => sandbox }); + }, { + channelId: "channel", + token: () => "token", + maxAiCredits: 4, + aborted, + openSandbox: async () => sandbox, + }); await Bun.sleep(0); abort(new Error("deadline")); @@ -122,7 +134,13 @@ describe("openWorkerSession", () => { created = true; throw new Error("not reached"); }, - }, { token: () => "token", maxAiCredits: 4, aborted, openSandbox: () => late.promise }); + }, { + channelId: "channel", + token: () => "token", + maxAiCredits: 4, + aborted, + openSandbox: () => late.promise, + }); abort(new Error("cancelled")); await expect(opening).rejects.toThrow("cancelled"); @@ -139,7 +157,7 @@ describe("openWorkerSession", () => { received = options.sandboxSession; return { destroy: async () => {} }; }, - }, { token: () => "token", maxAiCredits: 4, aborted: never() }); + }, { channelId: "channel", token: () => "token", maxAiCredits: 4, aborted: never() }); expect(received?.defaultWorkingDirectory).toBeString(); await worker.close(); diff --git a/apps/server/src/jobs/worker-session.ts b/apps/server/src/jobs/worker-session.ts index 05f2330e0..73b48c510 100644 --- a/apps/server/src/jobs/worker-session.ts +++ b/apps/server/src/jobs/worker-session.ts @@ -1,6 +1,7 @@ import { createJustBashNetworkSandboxSession } from "@ai-sdk/sandbox-just-bash"; import { registerCredential } from "../harness/harnesses"; +import { registerRemotePlanner } from "../harness/remote/registry"; export type WorkerSandbox = Awaited>; type Destroyable = { destroy(): PromiseLike }; @@ -24,14 +25,17 @@ async function openAbortable( } /** - * Opens one bounded worker session: a fresh sandbox, a registered per-session - * credential, and the agent session. Aborting while either resource is still - * opening destroys it once it arrives. `close()` destroys the session, then the - * sandbox, then releases the credential, continuing past failures. + * Opens one bounded worker session for a document: a fresh sandbox, a registered + * per-session credential, and the agent session. The session is also registered as + * an isolated session of the document, so under `HARNESS=remote` it runs in the + * client attached as that document's Planner. Aborting while either resource is + * still opening destroys it once it arrives. `close()` destroys the session, then + * the sandbox, then releases the credential, continuing past failures. */ export async function openWorkerSession( agent: WorkerAgent, options: { + channelId: string; token: () => string | undefined; maxAiCredits: number; aborted: Promise; @@ -42,6 +46,10 @@ export async function openWorkerSession( let sandbox = await openAbortable(openSandbox(), options.aborted); let sessionId = crypto.randomUUID(); let release = registerCredential(sessionId, options.token, options.maxAiCredits); + let unregister = registerRemotePlanner(sessionId, { + channelId: options.channelId, + isolated: true, + }); let session: Session | undefined; let close = async () => { try { @@ -50,6 +58,7 @@ export async function openWorkerSession( try { await sandbox.destroy(); } finally { + unregister(); release(); } } diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 62507d4fb..af64aab90 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -2661,6 +2661,8 @@ jobRunner = new JobRunner({ return writerLease.held; }, resolveActivePlanner: async job => { + // A remote document's workers run in its attached Planner client; without one, work waits. + if (plannerAttachments && !plannerAttachments.attached(job.channelId)) return undefined; let binding = await ownerBindings!.resolve(job.channelId); if (!binding) return undefined; return { diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index ab51acace..84d87e615 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -141,9 +141,9 @@ Under `HARNESS=remote` only attaching claims ownership. An `@chopin` message or owner. Other Planner requests, such as sending a comment to the Planner or a Build draft, claim nothing either: with a client attached they run on it, and without one they fail as any request without a usable owner does, not with -`planner-not-attached`. Model-backed research is unavailable, because the remote -Planner requires `BACKGROUND_JOBS=off`; a research request claims no ownership -and is refused as not ready. +`planner-not-attached`. A research request claims no ownership either: its +workers run on the attached client as [isolated sessions](#remote-planner), and +without one the request waits until a client attaches. An attachment lasts only as long as its owner. Signing that browser login out, its expiry, or a repository writer's reset of the document's Planner closes the @@ -503,7 +503,12 @@ them with `tools` before calling any, may not reuse a host tool's name, and reports them as provider-executed `tool-call` and `tool-result` parts. An undeclared tool fails the turn as a tool boundary failure. Background job sessions start in `isolated` mode, where the client may offer no tools of its own -and ask nothing. A turn ends with `turn-end`. An abort waits briefly for the +and ask nothing. That includes every background worker: the research, research +brief, document description, and History summary workers each open an isolated +session on the client attached to their document, may call only the host tools +their stage offers (the research evidence worker's `web_search`), and return +their structured result as the turn's JSON. The job runner starts no worker for +a document with no attached client; its job stays pending until one attaches. A turn ends with `turn-end`. An abort waits briefly for the client's `turn-end`, then ends the turn anyway. A turn that ends while one of its host tools still runs, such as an `ask` waiting on Decisions, stops that tool and withdraws the cards it opened. A connection that closes mid-turn ends the turn @@ -540,7 +545,9 @@ GitHub App token, limited to the document's repository, which its own account can already read. The attached client decides what the Planner writes to the document during turns writers start, which is why attaching requires the push or administration access every Planner owner needs. Summary and research workers -have no model in the server, so `HARNESS=remote` requires `BACKGROUND_JOBS=off`. +have no model in the server either; they run as isolated sessions on the same +client, so attaching also lends that client's model to the document's +background work. ## Permission checks diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 60f4d9855..6cb6de851 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -254,9 +254,11 @@ goes to a client attached to the document over the agent connection at member's machine, while the document, Decisions, and every room write stay in this server. The server needs no `MODEL` (one that is set is passed to the client as the turn's requested model) and accepts no `HARNESS_AUTH`, so it may -bind to a public interface. Startup refuses `HARNESS_AUTH` and requires -`BACKGROUND_JOBS=off`, because the summary and research workers would have no -model. The route exists only with `AGENT` on and `HARNESS=remote`. +bind to a public interface. Startup refuses `HARNESS_AUTH`. Summary and +research workers run as isolated sessions on the client attached to their +document, and wait while none is attached; set `BACKGROUND_JOBS=off` to keep +them off the attached clients entirely, which also turns off `/research`. The +route exists only with `AGENT` on and `HARNESS=remote`. The trust boundary moves to whoever attaches. A client authenticates with a GitHub token bearer, checked like hosted MCP, and needs push or administration @@ -378,7 +380,7 @@ the image. | `HARNESS_AUTH` | unset | Auth mode forwarded to the selected adapter. For `copilot-sdk`, `direct` and `ai-gateway` are allowed on any bind and `auto` requires a loopback-only `SERVER_HOST`; the adapter does not otherwise consume it. For `atomic`, it is required and must be `auto`, which requires a loopback-only `SERVER_HOST`, or `ai-gateway`; every other value is refused. For `remote`, any value is refused. | | `HARNESS_EXTENSIONS` | unset | Extension or package paths every atomic Planner session loads, as Atomic's `--extension` flag would, separated by the platform path delimiter (`:`, or `;` on Windows). A package's extensions, skills, and workflows all register. Each path must be absolute and exist; any other harness refuses the variable at startup. Background workers never load them. The code runs in the server process as its user. | | `AGENT` | on | Set exactly `off` to prevent hosted agent turns, disable the entire background-job runner, and avoid Copilot CLI startup. | -| `BACKGROUND_JOBS` | on | Set exactly `off` to disable background job scheduling. `AGENT=off` disables the entire runner. `HARNESS=remote` requires `off`. | +| `BACKGROUND_JOBS` | on | Set exactly `off` to disable background job scheduling. `AGENT=off` disables the entire runner. Under `HARNESS=remote`, workers run on the document's attached client. | | `WEB_RESEARCH` | on | Set exactly `off` to disable new public-web research while retaining durable requests, artifacts, and other jobs. | | `RESEARCH_EVIDENCE_TIMEOUT_MS` | `600000` | Deadline of one public-web research evidence attempt in milliseconds, an integer between 120000 and 1800000. Web search calls take up to about two minutes each; the attempt budgets at most four calls against what remains of this deadline. The job retries once after a failed attempt. | | `COPILOT_CLI_PATH` | automatic | Advanced override for the Copilot CLI executable. Applies only to the `copilot-sdk` adapter. |