useagentIndex
AI coworker · runtime orchestration

useagent 运行时桥接深读

这页聚焦 OpenCode/T3 runtime 如何启动、注入配置、等待 provider 并投影 turn。

Created 2026-08-29TypeScriptAGPL-3.0-only270 stars when sampled
4096
serve port
4
bridge proto
2
barriers
5s
health probe
01 / SOURCE READ

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

ensureServer 的启动与探活backend/src/engines/opencode-server.ts:252
252async 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

健康检查不泄露 provider 细节backend/src/engines/opencode-server.ts:363
363async 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}
02 / SOURCE READ

配置注入围绕短期 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

配置写入和模型 bodybackend/src/engines/opencode-server.ts:148
148export 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

MCP gateway 配置的生成入口backend/src/engines/opencode-server.ts:467
467export 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 ?? "",
03 / SOURCE READ

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

通用 provider readiness barrierbackend/src/engines/runtime-adapter.ts:90
90export 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

assistant text 投影规则backend/src/engines/runtime-adapter.ts:402
402export 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}