Skip to content

Commit a0bb7ec

Browse files
committed
merge(dev): resolve instance event bridge conflict
Merge upstream dev at 1d4f778, retaining the shared LOOPBACK_HOST and instance client factory introduced by #630 while preserving per-runtime stream IDs, restart fencing, bounded SSE parsing, and reader cleanup from #626. Validated with the server typecheck and focused instance-client, background-process, and instance-event tests (13 passing).
2 parents c10cab6 + 1d4f778 commit a0bb7ec

7 files changed

Lines changed: 366 additions & 38 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import assert from "node:assert/strict"
2+
import { describe, it } from "node:test"
3+
import { promises as fs } from "node:fs"
4+
import path from "node:path"
5+
import os from "node:os"
6+
7+
import { BackgroundProcessManager } from "./manager"
8+
import type { WorkspaceManager } from "../workspaces/manager"
9+
import type { EventBus } from "../events/bus"
10+
import type { Logger } from "../logger"
11+
12+
const WORKSPACE_ID = "ws-test"
13+
const SESSION_ID = "sess-1"
14+
const INSTANCE_PORT = 9999
15+
const AUTH_HEADER = "Basic test-auth"
16+
const TERMINAL_TIMEOUT_MS = 3000
17+
18+
interface CapturedRequest {
19+
method: string
20+
url: string
21+
headers: Headers
22+
body: string
23+
}
24+
25+
/**
26+
* Drives the real {@link BackgroundProcessManager} lifecycle (spawn a
27+
* fast-exiting command with notify enabled) against a mocked transport, so the
28+
* migrated `sendCompletionPrompt` path — factory + SDK client + `fetch` — is
29+
* exercised end to end without touching production wiring.
30+
*
31+
* The workspace temp directory is intentionally left in place (under
32+
* `os.tmpdir()`, OS-reaped): removing it from the test races the manager's
33+
* asynchronous finalization writes, which intermittently fail with ENOENT.
34+
*/
35+
async function runCompletionPrompt(
36+
fetchImpl: (input: Request, init: RequestInit | undefined) => Promise<Response>,
37+
): Promise<{ requests: CapturedRequest[]; warned: boolean; directory: string }> {
38+
const requests: CapturedRequest[] = []
39+
const originalFetch = globalThis.fetch
40+
// Captured now but swapped in only inside the try below, so a failure during
41+
// setup (mkdtemp, manager construction) can't leak the mocked fetch.
42+
const fetchMock = (async (input: any, init: any) => {
43+
const req = input instanceof Request ? input : new Request(String(input), init)
44+
requests.push({
45+
method: req.method,
46+
url: req.url,
47+
headers: req.headers,
48+
body: await req.text(),
49+
})
50+
return fetchImpl(input instanceof Request ? input : req, init)
51+
}) as typeof fetch
52+
53+
let warned = false
54+
const logger = {
55+
warn: () => { warned = true },
56+
debug: () => {},
57+
trace: () => {},
58+
info: () => {},
59+
error: () => {},
60+
fatal: () => {},
61+
isLevelEnabled: () => false,
62+
level: "info",
63+
child: () => logger,
64+
} as unknown as Logger
65+
66+
const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), "bp-test-"))
67+
// Distinct from the workspace root so the directory-override assertion is
68+
// discriminating: if `sendCompletionPrompt` stops passing `notify.directory`,
69+
// the factory would fall back to `workspacePath` and the header check fails.
70+
const sessionDir = path.join(workspacePath, "session-worktree")
71+
72+
// Resolve once the manager publishes a terminal (non-running) status update.
73+
let resolveTerminal: () => void = () => {}
74+
const terminal = new Promise<void>((resolve) => { resolveTerminal = resolve })
75+
const eventBus = {
76+
on: () => {},
77+
publish: (event: any) => {
78+
if (event?.type === "instance.event") {
79+
const type = event?.event?.type
80+
const status = event?.event?.properties?.process?.status
81+
if (type === "background.process.removed" || (status && status !== "running")) resolveTerminal()
82+
}
83+
return true
84+
},
85+
} as unknown as EventBus
86+
87+
const workspaceManager = {
88+
get: () => ({ path: workspacePath }),
89+
getInstancePort: () => INSTANCE_PORT,
90+
getInstanceAuthorizationHeader: () => AUTH_HEADER,
91+
} as unknown as WorkspaceManager
92+
93+
const manager = new BackgroundProcessManager({ workspaceManager, eventBus, logger })
94+
95+
try {
96+
globalThis.fetch = fetchMock
97+
await manager.start(WORKSPACE_ID, "test-proc", "true", {
98+
notify: true,
99+
notification: { sessionID: SESSION_ID, directory: sessionDir },
100+
})
101+
// The terminal status update is published at the very end of finalize, so
102+
// resolving on it is a deterministic completion signal. Fail loudly rather
103+
// than racing a silent timeout that could mask a hang.
104+
let timeoutHandle: NodeJS.Timeout | undefined
105+
const reachedTerminal = await Promise.race([
106+
terminal.then(() => true),
107+
new Promise<boolean>((resolve) => {
108+
timeoutHandle = setTimeout(() => resolve(false), TERMINAL_TIMEOUT_MS)
109+
}),
110+
])
111+
if (timeoutHandle) clearTimeout(timeoutHandle)
112+
if (!reachedTerminal) {
113+
throw new Error("background process did not reach a terminal state in time")
114+
}
115+
} finally {
116+
globalThis.fetch = originalFetch
117+
}
118+
119+
return { requests, warned, directory: sessionDir }
120+
}
121+
122+
describe("BackgroundProcessManager.sendCompletionPrompt", () => {
123+
it("posts the synthetic completion prompt to the instance via the SDK route", async () => {
124+
const { requests, directory } = await runCompletionPrompt(async () =>
125+
new Response("{}", { status: 200, headers: { "content-type": "application/json" } }),
126+
)
127+
128+
const promptCall = requests.find((r) => r.url.includes("/prompt_async"))
129+
assert.ok(promptCall, "expected a prompt_async request")
130+
assert.equal(promptCall.method, "POST")
131+
assert.equal(
132+
promptCall.url,
133+
`http://127.0.0.1:${INSTANCE_PORT}/session/${SESSION_ID}/prompt_async`,
134+
)
135+
assert.equal(promptCall.headers.get("authorization"), AUTH_HEADER)
136+
// The prompt is scoped to the session's directory (a POST keeps the
137+
// directory as a header — the SDK only rewrites header→query for GET/HEAD).
138+
assert.equal(promptCall.headers.get("x-opencode-directory"), encodeURIComponent(directory))
139+
140+
const body = JSON.parse(promptCall.body)
141+
assert.equal(body.parts.length, 1)
142+
assert.equal(body.parts[0].type, "text")
143+
assert.equal(body.parts[0].synthetic, true)
144+
assert.match(body.parts[0].text, /test-proc/)
145+
})
146+
147+
it("swallows a failed prompt and logs it without aborting finalization", async () => {
148+
const { warned } = await runCompletionPrompt(async () =>
149+
new Response("boom", { status: 500 }),
150+
)
151+
assert.equal(warned, true)
152+
})
153+
})

packages/server/src/background-processes/manager.ts

Lines changed: 11 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import path from "path"
44
import { randomBytes } from "crypto"
55
import type { EventBus } from "../events/bus"
66
import type { WorkspaceManager } from "../workspaces/manager"
7+
import { createInstanceClient } from "../workspaces/instance-client"
78
import type { Logger } from "../logger"
89
import type { BackgroundProcess, BackgroundProcessStatus, BackgroundProcessTerminalReason } from "../api-types"
910

@@ -625,43 +626,26 @@ export class BackgroundProcessManager {
625626
const notify = record.notify
626627
if (!notify || !record.terminalReason) return
627628

628-
if (!this.deps.workspaceManager.get(workspaceId)) {
629-
throw new Error("Workspace not found")
630-
}
631-
632-
const port = this.deps.workspaceManager.getInstancePort(workspaceId)
633-
if (!port) {
629+
const client = createInstanceClient(this.deps.workspaceManager, workspaceId, {
630+
directory: notify.directory,
631+
})
632+
if (!client) {
634633
throw new Error("Workspace instance is not ready")
635634
}
636635

637-
const targetUrl = `http://127.0.0.1:${port}/session/${encodeURIComponent(notify.sessionID)}/prompt_async`
638-
const headers: Record<string, string> = {
639-
"content-type": "application/json",
640-
}
641-
642-
const authorization = this.deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
643-
if (authorization) {
644-
headers.authorization = authorization
645-
}
646-
647-
const response = await fetch(targetUrl, {
648-
method: "POST",
649-
headers,
650-
body: JSON.stringify({
636+
await client.session.promptAsync(
637+
{
638+
sessionID: notify.sessionID,
651639
parts: [
652640
{
653641
type: "text",
654642
text: this.buildSyntheticCompletionPrompt(record),
655643
synthetic: true,
656644
},
657645
],
658-
}),
659-
})
660-
661-
if (!response.ok) {
662-
const message = await response.text().catch(() => "")
663-
throw new Error(message || `Prompt request failed with ${response.status}`)
664-
}
646+
},
647+
{ throwOnError: true },
648+
)
665649
}
666650

667651
private buildCompletionPrompt(record: PersistedBackgroundProcess): string {
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import assert from "node:assert/strict"
2+
import { describe, it } from "node:test"
3+
4+
import { createInstanceClient } from "./instance-client"
5+
import type { WorkspaceManager } from "./manager"
6+
7+
/**
8+
* Minimal stand-in for the parts of {@link WorkspaceManager} the factory reads.
9+
* The factory only touches three members, so a structural stub is enough and
10+
* keeps the test free of the full manager's heavy dependencies.
11+
*/
12+
interface StubWorkspaceManager {
13+
getInstancePort: (id: string) => number | undefined
14+
getInstanceAuthorizationHeader: (id: string) => string | undefined
15+
get: (id: string) => { path: string } | undefined
16+
}
17+
18+
function makeManager(overrides: Partial<StubWorkspaceManager> = {}): StubWorkspaceManager {
19+
return {
20+
getInstancePort: overrides.getInstancePort ?? (() => undefined),
21+
getInstanceAuthorizationHeader: overrides.getInstanceAuthorizationHeader ?? (() => undefined),
22+
get: overrides.get ?? (() => undefined),
23+
}
24+
}
25+
26+
interface CapturedRequest {
27+
url: string
28+
headers: Headers
29+
}
30+
31+
/**
32+
* Installs a global `fetch` stub that records every outgoing request and
33+
* answers a minimal healthy JSON body. Returns the capture buffer and a
34+
* restore function. The stub tolerates both `fetch(url, init)` and
35+
* `fetch(Request)` invocation styles so it is independent of the SDK's
36+
* internal call convention.
37+
*/
38+
function installRecordingFetch(): { requests: CapturedRequest[]; restore: () => void } {
39+
const requests: CapturedRequest[] = []
40+
const original = globalThis.fetch
41+
42+
globalThis.fetch = (async (input: any, init: any) => {
43+
if (input instanceof Request) {
44+
requests.push({ url: input.url, headers: new Headers(init?.headers ?? input.headers) })
45+
} else {
46+
requests.push({ url: String(input), headers: new Headers(init?.headers) })
47+
}
48+
return new Response(JSON.stringify({ healthy: true }), {
49+
status: 200,
50+
headers: { "content-type": "application/json" },
51+
})
52+
}) as typeof fetch
53+
54+
return { requests, restore: () => { globalThis.fetch = original } }
55+
}
56+
57+
describe("createInstanceClient", () => {
58+
it("returns null when the instance has no open port", () => {
59+
const manager = makeManager({ getInstancePort: () => undefined })
60+
assert.equal(createInstanceClient(manager as unknown as WorkspaceManager, "ws-1"), null)
61+
})
62+
63+
it("targets the loopback host and port on outgoing requests", async () => {
64+
const { requests, restore } = installRecordingFetch()
65+
try {
66+
const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/repo" }) })
67+
const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1")
68+
assert.ok(client, "expected a client when the instance has a port")
69+
70+
await client!.global.health()
71+
const parsed = new URL(requests[0].url)
72+
assert.equal(parsed.hostname, "127.0.0.1")
73+
assert.equal(parsed.port, "4321")
74+
} finally {
75+
restore()
76+
}
77+
})
78+
79+
it("attaches the authorization header when one is configured", async () => {
80+
const { requests, restore } = installRecordingFetch()
81+
try {
82+
const manager = makeManager({
83+
getInstancePort: () => 4321,
84+
getInstanceAuthorizationHeader: () => "Basic abc",
85+
get: () => ({ path: "/repo" }),
86+
})
87+
const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1")
88+
89+
await client!.global.health()
90+
assert.equal(requests[0].headers.get("authorization"), "Basic abc")
91+
} finally {
92+
restore()
93+
}
94+
})
95+
96+
it("omits the authorization header when none is configured", async () => {
97+
const { requests, restore } = installRecordingFetch()
98+
try {
99+
const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/repo" }) })
100+
const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1")
101+
102+
await client!.global.health()
103+
assert.equal(requests[0].headers.get("authorization"), null)
104+
} finally {
105+
restore()
106+
}
107+
})
108+
109+
it("scopes requests to the workspace directory", async () => {
110+
const { requests, restore } = installRecordingFetch()
111+
try {
112+
const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/repo" }) })
113+
const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1")
114+
115+
await client!.global.health()
116+
// GET requests carry directory as a query parameter (see SDK rewrite).
117+
assert.equal(new URL(requests[0].url).searchParams.get("directory"), "/repo")
118+
} finally {
119+
restore()
120+
}
121+
})
122+
123+
it("does not scope requests when the workspace has no path", async () => {
124+
const { requests, restore } = installRecordingFetch()
125+
try {
126+
const manager = makeManager({ getInstancePort: () => 4321, get: () => undefined })
127+
const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1")
128+
129+
await client!.global.health()
130+
assert.equal(new URL(requests[0].url).searchParams.get("directory"), null)
131+
} finally {
132+
restore()
133+
}
134+
})
135+
136+
it("honours an explicit directory override over the workspace root", async () => {
137+
const { requests, restore } = installRecordingFetch()
138+
try {
139+
const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/workspace-root" }) })
140+
const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1", {
141+
directory: "/explicit/session-dir",
142+
})
143+
144+
await client!.global.health()
145+
assert.equal(new URL(requests[0].url).searchParams.get("directory"), "/explicit/session-dir")
146+
} finally {
147+
restore()
148+
}
149+
})
150+
151+
it("applies the loopback timeout and aborts a stuck instance", async () => {
152+
const original = globalThis.fetch
153+
// Never resolves on its own; only settles when the passed signal aborts,
154+
// mirroring how a real fetch honours an AbortSignal. Without the factory
155+
// timeout this call would hang forever and time the test out.
156+
globalThis.fetch = (async (_input: any, init: any) => {
157+
return new Promise((_resolve, reject) => {
158+
const signal = (init as RequestInit | undefined)?.signal
159+
if (!signal) return
160+
if (signal.aborted) reject((signal as AbortSignal).reason ?? new Error("aborted"))
161+
else signal.addEventListener("abort", () => reject((signal as AbortSignal).reason ?? new Error("aborted")))
162+
})
163+
}) as typeof fetch
164+
try {
165+
const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/repo" }) })
166+
const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1", { timeoutMs: 10 })
167+
168+
// SDK methods resolve with { error } rather than throwing by default.
169+
const result = await client!.global.health()
170+
assert.ok(result.error, "expected the stuck-instance call to surface an error")
171+
} finally {
172+
globalThis.fetch = original
173+
}
174+
})
175+
})

0 commit comments

Comments
 (0)