useagent 运行时桥接深读
这页聚焦 OpenCode/T3 runtime 如何启动、注入配置、等待 provider 并投影 turn。
OpenCode server 是可复用 resident process
先探活再启动
`ensureServer` 先准备 workdir、拿 sandbox preview link,再探测 `/global/health`。如果已有 healthy resident process,它直接复用;只有不健康时才删除 session、创建 `skynet-opencode-serve` 并执行 `opencode serve`。 backend/src/engines/opencode-server.ts:252 backend/src/engines/opencode-server.ts:267 backend/src/engines/opencode-server.ts:279 backend/src/engines/opencode-server.ts:283
backend/src/engines/opencode-server.ts:252252async function ensureServer(
253 sandbox: SandboxHandle,
254 npx: boolean,
255 signal: AbortSignal,
256 secretSourceCommand = sandboxSecretSourceCommand(),
257): Promise<OpenCodeRuntimeServer> {
258 const bin = npx ? `npx -y opencode-ai@${OPENCODE_VERSION}` : "opencode";
259 const homeResult = await sandbox.process.executeCommand(
260 'mkdir -p ~/work && printf %s "$HOME"',
261 undefined,
262 undefined,
263 15,
264 );
265 if ((homeResult.exitCode ?? 1) !== 0) throw new Error("opencode workspace preparation failed");
266 const home = homeResult.result?.trim() || "/home/daytona";
267 const link = await sandbox.getPreviewLink(SERVE_PORT);
268 const baseUrl = link.url.replace(/\/+$/, "");
269 const token = link.token ?? "";
270 const server = { baseUrl, token, workdir: `${home}/work` };
271
272 // A healthy resident process survives turns and is always reused: the liveness
273 // probe here already proves it is serving, so return immediately and skip the
274 // readiness poll's redundant first probe (perf plan Phase 1 item 4 - skip probes
275 // once a prior step proves liveness). Only when the sandbox was stopped/restarted
276 // (probe not 2xx) do we recreate Daytona's dedicated background session:
277 // executeCommand is synchronous even with shell `&`, whereas an async session
278 // command is Daytona's supported long-lived-process primitive.
279 const initialStatus = await opencodeHealthStatus(server, signal);
280 if (initialStatus !== null && initialStatus >= 200 && initialStatus < 300) {
281 return server;
282 }
283 await sandbox.process.deleteSession(SERVER_PROCESS_SESSION).catch(() => {});
284 await sandbox.process.createSession(SERVER_PROCESS_SESSION);
285 await sandbox.process.executeSessionCommand(
286 SERVER_PROCESS_SESSION,
287 {
288 command: `${secretSourceCommand} && cd ${shq(`${home}/work`)} && exec ${bin} serve --hostname 0.0.0.0 --port ${SERVE_PORT}`,
289 runAsync: true,
290 suppressInputEcho: true,
291 },
292 30,
293 );
294
295 const deadline = Date.now() + 120_000;
296 let lastStatus: number | null = null;
297 // Bounded exponential polling (perf plan Phase 1): a server that is ready in
298 // 200ms is seen in ~200ms instead of at the next full-second tick; the overall
299 // deadline is unchanged.
300 let pollDelay: number | null = null;
301 while (Date.now() < deadline && !signal.aborted) {
302 lastStatus = await opencodeHealthStatus(server, signal);
303 if (lastStatus !== null && lastStatus >= 200 && lastStatus < 300) {
304 return server;
305 }
306 const delay = nextPollDelayMs(pollDelay);
307 pollDelay = delay;
308 await new Promise((resolve) => setTimeout(resolve, delay));
309 }
310 if (signal.aborted) throw new Error("opencode run aborted (timeout)");
311 let logs: { output?: string; stderr?: string; stdout?: string } | null = null;
312 try {
313 const session = await sandbox.process.getSession(SERVER_PROCESS_SESSION);
314 const command = session.commands.at(-1);
315 if (command?.id) {
316 logs = await sandbox.process.getSessionCommandLogs(SERVER_PROCESS_SESSION, command.id);
317 }
318 } catch {
319 // Readiness already failed. Logs are diagnostic only and must not mask the
320 // stable, redacted error below.
321 }
322 const safeTail = (logs?.output ?? logs?.stderr ?? logs?.stdout ?? "")
323 .replace(/v1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "<capability>")
324 .trim();
325 throw new Error(
326 `opencode serve failed readiness${lastStatus ? ` (HTTP ${lastStatus})` : ""}: ${truncate(safeTail, 200)}`,
327 );
328}健康检查是 bounded fetch
`opencodeHealthStatus` 只做 5 秒 fetch,返回 status 或 null;`reuseHealthyResidentServer` 要求 sandboxId 匹配且 health 2xx。这里的判定小而保守,适合在热路径上多次调用。 backend/src/engines/opencode-server.ts:363 backend/src/engines/opencode-server.ts:368 backend/src/engines/opencode-server.ts:380 backend/src/engines/opencode-server.ts:386
backend/src/engines/opencode-server.ts:363363async function opencodeHealthStatus(
364 server: OpenCodeRuntimeServer,
365 signal: AbortSignal,
366): Promise<number | null> {
367 try {
368 const response = await fetch(`${server.baseUrl}/global/health`, {
369 headers: authHeaders(server.token),
370 signal: AbortSignal.any([signal, AbortSignal.timeout(5_000)]),
371 });
372 const status = response.status;
373 await response.body?.cancel().catch(() => {});
374 return status;
375 } catch {
376 return null;
377 }
378}
379
380async function reuseHealthyResidentServer(
381 cached: OpenCodeThreadServer | null,
382 sandboxId: string,
383 signal: AbortSignal,
384): Promise<OpenCodeRuntimeServer | null> {
385 if (!cached || cached.sandboxId !== sandboxId) return null;
386 const status = await opencodeHealthStatus(cached, signal);
387 return status !== null && status >= 200 && status < 300 ? cached : null;
388}配置注入围绕短期 capability 和 fail-closed
全局配置写入避开仓库污染
`buildOpencodeConfigWriteCommand` 要求 base64 内容,只写 `~/.config/opencode/opencode.json`,并删除 `~/work/opencode.json`。这样 provider/MCP 配置不会跟随用户仓库 checkout,也不会把 token 混进项目文件。 backend/src/engines/opencode-server.ts:148 backend/src/engines/opencode-server.ts:153 backend/src/engines/opencode-server.ts:155 backend/src/engines/opencode-server.ts:156
backend/src/engines/opencode-server.ts:148148export function buildOpencodeConfigWriteCommand(encodedConfig: string): string {
149 if (!/^[A-Za-z0-9+/=]+$/.test(encodedConfig)) {
150 throw new Error("opencode config must be base64 encoded");
151 }
152 return (
153 `mkdir -p ~/.config/opencode ~/work && chmod 700 ~/.config ~/.config/opencode && ` +
154 `printf %s '${encodedConfig}' | base64 -d > ~/.config/opencode/opencode.json && ` +
155 `chmod 600 ~/.config/opencode/opencode.json && ` +
156 `rm -f -- ~/work/opencode.json`
157 );
158}
159
160function authHeaders(token: string): Record<string, string> {
161 return sandboxPreviewHeaders(token);
162}
163
164function modelBody(model: string): { providerID: string; modelID: string } {
165 if (model.startsWith("openai/")) {
166 return { providerID: "openai", modelID: model.slice("openai/".length) };
167 }
168 return model.includes("/")
169 ? { providerID: "openrouter", modelID: model }
170 : { providerID: "anthropic", modelID: model };
171}
172
173/** Bun's fetch accepts a per-request `timeout` (ms; 0 = disable) that neither the
174 * DOM `RequestInit` nor Bun's own `BunFetchRequestInit` type declares, yet the
175 * runtime honours (Bun PR #33647). Typed honestly here so the long-stream fetches
176 * can disable Bun's 5-min idle cap without an `as any`/`as RequestInit` bypass. */
177type FetchInit = RequestInit & { timeout?: number };tool gateway token 是线程级 memoized
`prepareOpencodeSandboxConfig` 构建 MCP entry 时,如果有 threadId,就用 `ThreadTokenMemo` 复用同一线程的短期 bearer token;没有 gateway 或 orgId 时返回非 required 空配置,准备失败则返回 null 让上层 fail closed。 backend/src/engines/opencode-server.ts:467 backend/src/engines/opencode-server.ts:477 backend/src/engines/opencode-server.ts:499 backend/src/engines/opencode-server.ts:512
backend/src/engines/opencode-server.ts:467467export async function prepareOpencodeSandboxConfig(
468 sandbox: SandboxHandle,
469 ctx: EngineRunContext,
470 baseConfig?: Record<string, unknown>,
471): Promise<PreparedOpenCodeConfig | null> {
472 const gw = toolGatewayConfig();
473 const providerOptions = opencodeProviderGatewayOptions(ctx);
474 // GUI automation is provided by the trusted useAgent-knowledge computer_* tools.
475 // Remove any stale Playwright MCP entry from retained OpenCode configuration.
476 const browser = null;
477 const state = {
478 knowledge: Boolean(gw && ctx.orgId),
479 provider: Object.keys(providerOptions).length > 0,
480 browser: Boolean(browser),
481 } satisfies OpenCodeGatewayState;
482 if ((!ctx.orgId || (!gw && Object.keys(providerOptions).length === 0)) && !browser) {
483 return { config: {}, state, required: false };
484 }
485 try {
486 // Merge into any existing global config so snapshot-provided settings (models,
487 // allowlists) survive. Read-parse-merge in TS (a shell JSON merge is brittle).
488 const cfg = baseConfig ?? await readOpencodeSandboxConfig(sandbox);
489 cfg["$schema"] = cfg["$schema"] ?? "https://opencode.ai/config.json";
490 const mcp = (typeof cfg.mcp === "object" && cfg.mcp ? (cfg.mcp as Record<string, unknown>) : {});
491 if (gw && ctx.orgId) {
492 const orgId = ctx.orgId;
493 // Thread-scoped + memoized so warm turns build a byte-identical MCP entry
494 // (run-invariant config); a single-shot run keeps the strict run binding.
495 const descriptor = ctx.threadId
496 ? (() => {
497 const ttlMs = gw.tokenTtlMs;
498 const nowMs = Date.now();
499 const token = opencodeToolTokens.get(
500 `${orgId}:${ctx.threadId}:${ctx.userId ?? ""}:tool`,
501 threadTokenMemoOptions(ttlMs, THREAD_TOKEN_REUSE_WINDOW_MS),
502 () => {
503 const minted = buildToolGatewayCapabilityDescriptor(
504 {
505 orgId,
506 userId: ctx.userId ?? "",
507 threadId: ctx.threadId!,
508 runId: ctx.runId,
509 },
510 { config: gw, scope: "thread", ttlMs, nowMs },
511 );
512 if (!minted) throw new Error("tool gateway could not mint OpenCode capability");
513 return minted.bearerToken;
514 },
515 nowMs,
516 );
517 return describeToolGatewayCapabilityDescriptor(
518 {
519 orgId,
520 userId: ctx.userId ?? "",Turn 调度把 runtime snapshot 当权威事实
调度前先确认 provider ready
runtime adapter 先执行 provider readiness barrier,失败就重启 runtime 并 invalidate access,再次验证不通过才抛错。这个策略让 provider 配置异步落盘带来的 race 能被显式处理。 backend/src/engines/runtime-adapter.ts:90 backend/src/engines/runtime-adapter.ts:101 backend/src/engines/runtime-adapter.ts:111 backend/src/engines/runtime-adapter.ts:123
backend/src/engines/runtime-adapter.ts:9090export async function ensureRuntimeProviderReadyForTurn(input: {
91 readonly sandbox: SandboxHandle;
92 readonly signal: AbortSignal;
93 readonly readiness: RuntimeProviderReadiness;
94 readonly barrierDeadlineMs: number;
95 readonly verifyDeadlineMs: number;
96 readonly providerLabel: string;
97 readonly dependencies?: RuntimeProviderBarrierDependencies;
98}): Promise<void> {
99 const dependencies = input.dependencies ?? runtimeProviderBarrierDependencies;
100 if (
101 await dependencies.awaitReady(
102 input.sandbox,
103 input.signal,
104 input.barrierDeadlineMs,
105 input.readiness,
106 )
107 ) {
108 return;
109 }
110 input.signal.throwIfAborted();
111 await dependencies.restart(input.sandbox, input.signal);
112 dependencies.invalidateAccess(input.sandbox);
113 input.signal.throwIfAborted();
114 if (
115 !(await dependencies.awaitReady(
116 input.sandbox,
117 input.signal,
118 input.verifyDeadlineMs,
119 input.readiness,
120 ))
121 ) {
122 input.signal.throwIfAborted();
123 throw new Error(`${input.providerLabel} runtime did not become ready after restart`);
124 }
125}文本投影要保持单调
`projectRuntimeAssistantText` 只在新文本以已发布文本为前缀时输出 delta;不单调文本不会立即覆盖直播输出,而是在 settled 后进入 finalText。这个函数是 stream UI 和最终摘要之间的最小一致性规则。 backend/src/engines/runtime-adapter.ts:402 backend/src/engines/runtime-adapter.ts:407 backend/src/engines/runtime-adapter.ts:409 backend/src/engines/runtime-adapter.ts:411
backend/src/engines/runtime-adapter.ts:402402export function projectRuntimeAssistantText(
403 state: { readonly publishedText: string; readonly finalText: string },
404 text: string,
405 settled: boolean,
406): { readonly publishedText: string; readonly finalText: string; readonly delta: string } {
407 const monotonic = text.startsWith(state.publishedText);
408 return {
409 publishedText: monotonic ? text : state.publishedText,
410 finalText: settled ? text : state.finalText,
411 delta: monotonic ? text.slice(state.publishedText.length) : "",
412 };
413}