feat(worker): OpenCode harness for Submatter Phase B
Add WORKER_SESSION_TYPE=opencode with a headless OpenCode session adapter, compose profile, and OmniRoute plugin config. Prefer OpenCode over the unused OpenHands agent-server path; fix missing yaml runtime dependency. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
9bde5960bf
commit
9572eac3eb
16 changed files with 584 additions and 26 deletions
19
.env.example
19
.env.example
|
|
@ -43,15 +43,24 @@ REDIS_URL="redis://:CHANGE_ME@192.168.68.102:6379/11"
|
|||
# WORKER_REPO_ROOT= # defaults to monorepo root (pnpm-workspace.yaml walk-up)
|
||||
# WORKER_WORKTREE_BASE=/tmp/echodo-worker-worktrees
|
||||
#
|
||||
# Phase B — OpenHands agent-server (optional; requires --profile agent-server)
|
||||
# WORKER_SESSION_TYPE=minimal # set to `openhands` when agent-server is running
|
||||
# Phase B — coding harness (optional)
|
||||
# Preferred: OpenCode (COMPOSE_PROFILES=opencode)
|
||||
# WORKER_SESSION_TYPE=minimal # set to `opencode` when opencode profile is running
|
||||
# OPENCODE_SERVER_URL=http://opencode:4096
|
||||
# OPENCODE_SERVER_PASSWORD= # openssl rand -hex 24; must match container
|
||||
# OPENCODE_SERVER_USERNAME=opencode
|
||||
# OPENCODE_PROVIDER_ID=omniroute
|
||||
# WORKER_OPENCODE_WORKSPACE=/workspace/repo
|
||||
# OMNIROUTE_BASE_URL=https://omniroute.stillwell.cloud/v1
|
||||
# OMNIROUTE_API_KEY=
|
||||
# OMNIROUTE_MODEL=auto/coding
|
||||
#
|
||||
# Legacy: OpenHands agent-server (COMPOSE_PROFILES=agent-server)
|
||||
# WORKER_SESSION_TYPE=openhands
|
||||
# AGENT_SERVER_URL=http://agent-server:8001
|
||||
# WORKER_OPENHANDS_WORKSPACE=/workspace/repo
|
||||
# SESSION_API_KEY= # must match LOCAL_BACKEND_API_KEY on agent-server
|
||||
# LOCAL_BACKEND_API_KEY= # generate: openssl rand -hex 32
|
||||
# OMNIROUTE_BASE_URL=https://omniroute.stillwell.cloud/v1
|
||||
# OMNIROUTE_API_KEY=
|
||||
# OMNIROUTE_MODEL=auto/coding
|
||||
|
||||
# ----- Auth (NextAuth v5) -----
|
||||
# Local: http://localhost:3000 • Prod: https://echodo.stillwell.cloud
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
"ai": "^4.3.16",
|
||||
"drizzle-orm": "^0.38.0",
|
||||
"ioredis": "^5.4.2",
|
||||
"yaml": "^2.7.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -33,9 +33,14 @@ const envSchema = z.object({
|
|||
.string()
|
||||
.optional()
|
||||
.transform((value) => value?.trim() || "/tmp/echodo-worker-worktrees"),
|
||||
/** Session type: "minimal" (default, in-process) or "openhands" (agent-server). */
|
||||
/**
|
||||
* Session type:
|
||||
* - minimal (default) — in-process tool loop
|
||||
* - opencode — headless `opencode serve` on CT 116 (preferred harness)
|
||||
* - openhands — legacy agent-server (optional profile; not the target path)
|
||||
*/
|
||||
WORKER_SESSION_TYPE: z
|
||||
.enum(["minimal", "openhands"])
|
||||
.enum(["minimal", "opencode", "openhands"])
|
||||
.optional()
|
||||
.default("minimal"),
|
||||
/** OpenHands agent-server URL. Docker: http://agent-server:8001, Local: http://localhost:18000 */
|
||||
|
|
@ -53,6 +58,30 @@ const envSchema = z.object({
|
|||
.string()
|
||||
.optional()
|
||||
.transform((value) => value?.trim() || ""),
|
||||
/** OpenCode serve/web base URL. Docker: http://opencode:4096 */
|
||||
OPENCODE_SERVER_URL: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value?.trim() || "http://opencode:4096"),
|
||||
/** Must match OPENCODE_SERVER_PASSWORD on the opencode container when set. */
|
||||
OPENCODE_SERVER_PASSWORD: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value?.trim() || ""),
|
||||
OPENCODE_SERVER_USERNAME: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value?.trim() || "opencode"),
|
||||
/** Provider id registered by @omniroute/opencode-plugin (default omniroute). */
|
||||
OPENCODE_PROVIDER_ID: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value?.trim() || "omniroute"),
|
||||
/** Workspace path OpenCode should treat as the project root (shared volume). */
|
||||
WORKER_OPENCODE_WORKSPACE: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value?.trim() || "/workspace/repo"),
|
||||
});
|
||||
|
||||
export type WorkerEnv = z.infer<typeof envSchema>;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
WorkerJobContext,
|
||||
} from "@tasks/shared/worker";
|
||||
import { createMinimalSession } from "../session/minimal-session.js";
|
||||
import { createOpenCodeSession, type OpenCodeConfig } from "../session/opencode-session.js";
|
||||
import { createOpenHandsSession, type OpenHandsConfig } from "../session/openhands-session.js";
|
||||
import { createRemoteAgUiExecutor } from "./remote-ag-ui.js";
|
||||
|
||||
|
|
@ -82,6 +83,33 @@ function buildOpenHandsExecutor(config: OpenHandsConfig): WorkerExecutor {
|
|||
};
|
||||
}
|
||||
|
||||
function buildOpenCodeExecutor(config: OpenCodeConfig): WorkerExecutor {
|
||||
return {
|
||||
id: "opencode",
|
||||
harnessType: "minimal" as HarnessType,
|
||||
async run(ctx: WorkerJobContext, opts: WorkerExecutorOptions): Promise<{ summary: string; toolCalls: number; tokensTotal?: number; outcome?: "succeeded" | "failed" }> {
|
||||
const session = await createOpenCodeSession(ctx, {
|
||||
config,
|
||||
resolvedModel: opts.resolvedModel as never,
|
||||
roleId: ctx.roleId,
|
||||
briefId: ctx.briefId,
|
||||
briefTitle: opts.convoyTitle,
|
||||
workflowPrompt: opts.workflowPrompt,
|
||||
briefAcceptance: opts.briefAcceptance,
|
||||
briefFiles: opts.briefFiles,
|
||||
maxSteps: opts.maxSteps,
|
||||
});
|
||||
const convoyTitle = opts.convoyTitle ?? "unknown";
|
||||
const result = await session.run(opts.buildPrompt(ctx, convoyTitle));
|
||||
return {
|
||||
summary: result.summary,
|
||||
toolCalls: result.toolCalls,
|
||||
tokensTotal: result.tokensTotal,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type HarnessExecutorConfig = {
|
||||
harnessType: HarnessType;
|
||||
endpointUrl?: string | null;
|
||||
|
|
@ -117,3 +145,15 @@ export function createOpenHandsExecutor(
|
|||
}
|
||||
return buildOpenHandsExecutor({ agentServerUrl, sessionApiKey, repoRoot });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an executor for headless OpenCode (`opencode serve`).
|
||||
* Password is optional (local/dev); production should set OPENCODE_SERVER_PASSWORD.
|
||||
*/
|
||||
export function createOpenCodeExecutor(config: OpenCodeConfig): WorkerExecutor | null {
|
||||
if (!config.serverUrl) {
|
||||
console.warn("[worker] OpenCode executor unavailable: missing OPENCODE_SERVER_URL");
|
||||
return null;
|
||||
}
|
||||
return buildOpenCodeExecutor(config);
|
||||
}
|
||||
|
|
|
|||
93
apps/worker/src/executor/opencode-executor.test.ts
Normal file
93
apps/worker/src/executor/opencode-executor.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createOpenCodeExecutor } from "./index.js";
|
||||
|
||||
describe("createOpenCodeExecutor", () => {
|
||||
it("returns null when serverUrl is empty", () => {
|
||||
const exec = createOpenCodeExecutor({
|
||||
serverUrl: "",
|
||||
serverPassword: "",
|
||||
serverUsername: "opencode",
|
||||
repoRoot: "/workspace/repo",
|
||||
providerId: "omniroute",
|
||||
});
|
||||
expect(exec).toBeNull();
|
||||
});
|
||||
|
||||
it("returns an opencode executor with the expected id", () => {
|
||||
const exec = createOpenCodeExecutor({
|
||||
serverUrl: "http://opencode:4096",
|
||||
serverPassword: "secret",
|
||||
serverUsername: "opencode",
|
||||
repoRoot: "/workspace/repo",
|
||||
providerId: "omniroute",
|
||||
});
|
||||
expect(exec).not.toBeNull();
|
||||
expect(exec?.id).toBe("opencode");
|
||||
});
|
||||
|
||||
it("creates a session and posts a message with basic auth", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ id: "ses_1" }), { status: 200 }),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: "done" }, { type: "tool", tool: "bash" }],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchImpl);
|
||||
|
||||
const exec = createOpenCodeExecutor({
|
||||
serverUrl: "http://opencode:4096",
|
||||
serverPassword: "secret",
|
||||
serverUsername: "opencode",
|
||||
repoRoot: "/workspace/repo",
|
||||
providerId: "omniroute",
|
||||
});
|
||||
expect(exec).not.toBeNull();
|
||||
if (!exec) return;
|
||||
|
||||
const result = await exec.run(
|
||||
{
|
||||
workspaceId: "w",
|
||||
convoyId: "c",
|
||||
roleId: "role-implementer",
|
||||
briefId: "b",
|
||||
repoRoot: "/workspace/repo",
|
||||
},
|
||||
{
|
||||
resolvedModel: {
|
||||
modelId: "omniroute/auto/coding",
|
||||
providerType: "openai",
|
||||
alias: "auto/coding",
|
||||
},
|
||||
resolvedAlias: "auto/coding",
|
||||
rolePromptPath: "/tmp/role.md",
|
||||
maxSteps: 5,
|
||||
buildPrompt: () => "Implement the thing",
|
||||
convoyTitle: "test",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.summary).toBe("done");
|
||||
expect(result.toolCalls).toBe(1);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
const [, createInit] = fetchImpl.mock.calls[0]!;
|
||||
expect((createInit as RequestInit).headers).toMatchObject({
|
||||
Authorization: expect.stringMatching(/^Basic /),
|
||||
});
|
||||
const [msgUrl, msgInit] = fetchImpl.mock.calls[1]!;
|
||||
expect(msgUrl).toBe("http://opencode:4096/session/ses_1/message");
|
||||
const body = JSON.parse(String((msgInit as RequestInit).body));
|
||||
expect(body.model).toEqual({ providerID: "omniroute", modelID: "auto/coding" });
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
|
@ -91,6 +91,11 @@ const env = {
|
|||
AGENT_SERVER_URL: "http://agent-server:8001",
|
||||
WORKER_OPENHANDS_WORKSPACE: "/workspace/repo",
|
||||
SESSION_API_KEY: "",
|
||||
OPENCODE_SERVER_URL: "http://opencode:4096",
|
||||
OPENCODE_SERVER_PASSWORD: "",
|
||||
OPENCODE_SERVER_USERNAME: "opencode",
|
||||
OPENCODE_PROVIDER_ID: "omniroute",
|
||||
WORKER_OPENCODE_WORKSPACE: "/workspace/repo",
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import {
|
|||
} from "./git/worktree.js";
|
||||
import { openDraftPR } from "./git/pr.js";
|
||||
import { resolveHarnessForJob, getHarnessExecutor } from "./harness.js";
|
||||
import { getExecutor, createOpenHandsExecutor, type HarnessExecutorConfig } from "./executor/index.js";
|
||||
import { getExecutor, createOpenCodeExecutor, createOpenHandsExecutor, type HarnessExecutorConfig } from "./executor/index.js";
|
||||
import { resolveHarnessForRoleDb } from "./db/harness.js";
|
||||
import { resolveRepoRoot } from "./lib/repo-root.js";
|
||||
import {
|
||||
|
|
@ -255,10 +255,31 @@ export async function handleOrchestratorJob(
|
|||
}
|
||||
: undefined;
|
||||
|
||||
// Resolve the executor from the registry based on the harness type
|
||||
// When WORKER_SESSION_TYPE=openhands, route to OpenHands agent-server executor
|
||||
// Resolve the executor from the registry based on the harness type.
|
||||
// WORKER_SESSION_TYPE=opencode → headless OpenCode (preferred).
|
||||
// WORKER_SESSION_TYPE=openhands → legacy OpenHands agent-server.
|
||||
let executor;
|
||||
if (env.WORKER_SESSION_TYPE === "openhands") {
|
||||
if (env.WORKER_SESSION_TYPE === "opencode") {
|
||||
const ocExecutor = createOpenCodeExecutor({
|
||||
serverUrl: env.OPENCODE_SERVER_URL,
|
||||
serverPassword: env.OPENCODE_SERVER_PASSWORD,
|
||||
serverUsername: env.OPENCODE_SERVER_USERNAME,
|
||||
repoRoot: env.WORKER_OPENCODE_WORKSPACE,
|
||||
providerId: env.OPENCODE_PROVIDER_ID,
|
||||
});
|
||||
if (ocExecutor) {
|
||||
executor = ocExecutor;
|
||||
console.info(`[worker] using OpenCode executor server=${env.OPENCODE_SERVER_URL}`);
|
||||
} else {
|
||||
console.warn("[worker] OpenCode executor unavailable, falling back to minimal");
|
||||
const harnessExecutorConfig: HarnessExecutorConfig = {
|
||||
harnessType: harness.harnessType,
|
||||
endpointUrl: harness.endpointUrl,
|
||||
authEnvVar: harness.authEnvVar,
|
||||
};
|
||||
executor = getExecutor(harnessExecutorConfig);
|
||||
}
|
||||
} else if (env.WORKER_SESSION_TYPE === "openhands") {
|
||||
const ohExecutor = createOpenHandsExecutor(
|
||||
env.AGENT_SERVER_URL,
|
||||
env.SESSION_API_KEY,
|
||||
|
|
|
|||
175
apps/worker/src/session/opencode-session.ts
Normal file
175
apps/worker/src/session/opencode-session.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import type { ResolvedModel } from "@tasks/shared/model-routing";
|
||||
import type { WorkerJobContext, WorkerSession } from "@tasks/shared/worker";
|
||||
|
||||
import { buildSkillFullContext, loadAgentSkills } from "./load-skills.js";
|
||||
|
||||
/**
|
||||
* Configuration for a headless OpenCode server (`opencode serve` / `opencode web`).
|
||||
* Auth is HTTP basic when OPENCODE_SERVER_PASSWORD is set on the server.
|
||||
*/
|
||||
export type OpenCodeConfig = {
|
||||
serverUrl: string;
|
||||
/** Basic-auth password; empty = no Authorization header. */
|
||||
serverPassword: string;
|
||||
/** Basic-auth username (OpenCode default: opencode). */
|
||||
serverUsername: string;
|
||||
/** Repo root to scan for .agents/skills/ and to describe in the prompt. */
|
||||
repoRoot: string;
|
||||
/** OpenCode provider id registered by the OmniRoute plugin (default omniroute). */
|
||||
providerId: string;
|
||||
};
|
||||
|
||||
export type OpenCodeSessionOptions = {
|
||||
config: OpenCodeConfig;
|
||||
resolvedModel: ResolvedModel;
|
||||
roleId: string;
|
||||
briefId?: string;
|
||||
briefTitle?: string;
|
||||
workflowPrompt?: string;
|
||||
briefAcceptance?: string;
|
||||
briefFiles?: string[];
|
||||
maxSteps?: number;
|
||||
};
|
||||
|
||||
type SessionCreateResponse = { id: string; title?: string };
|
||||
type MessageResponse = {
|
||||
info?: { role?: string; error?: { name?: string; message?: string } };
|
||||
parts?: Array<{ type?: string; text?: string; tool?: string }>;
|
||||
};
|
||||
|
||||
function basicAuthHeader(username: string, password: string): string {
|
||||
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
|
||||
return `Basic ${token}`;
|
||||
}
|
||||
|
||||
function stripProviderPrefix(modelId: string, providerId: string): string {
|
||||
const prefix = `${providerId}/`;
|
||||
return modelId.startsWith(prefix) ? modelId.slice(prefix.length) : modelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an OpenCode session that drives a remote `opencode serve` over HTTP.
|
||||
* Prefer this over OpenHands on CT 116 — same OmniRoute catalog via the plugin,
|
||||
* no Docker-in-Docker sandbox, one concurrent agent until RAM upgrade.
|
||||
*/
|
||||
export async function createOpenCodeSession(
|
||||
context: WorkerJobContext,
|
||||
options: OpenCodeSessionOptions,
|
||||
): Promise<WorkerSession> {
|
||||
const {
|
||||
config,
|
||||
resolvedModel,
|
||||
roleId,
|
||||
briefId,
|
||||
briefTitle,
|
||||
workflowPrompt,
|
||||
briefAcceptance,
|
||||
briefFiles,
|
||||
} = options;
|
||||
const apiBase = config.serverUrl.replace(/\/$/, "");
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
};
|
||||
if (config.serverPassword) {
|
||||
headers.Authorization = basicAuthHeader(config.serverUsername, config.serverPassword);
|
||||
}
|
||||
|
||||
let skillContext = "";
|
||||
try {
|
||||
const skills = await loadAgentSkills(config.repoRoot);
|
||||
skillContext = buildSkillFullContext(skills);
|
||||
} catch (e) {
|
||||
console.warn("[opencode] skill loading failed, continuing without skills:", e);
|
||||
}
|
||||
|
||||
const systemParts: string[] = [
|
||||
`You are an AI agent executing a brief for convoy "${context.convoyId}".`,
|
||||
`Role: ${roleId}`,
|
||||
briefId ? `Brief ID: ${briefId}` : "",
|
||||
briefTitle ? `Brief: ${briefTitle}` : "",
|
||||
`Working directory: ${config.repoRoot}`,
|
||||
workflowPrompt ? `\n## Task\n${workflowPrompt}` : "",
|
||||
briefAcceptance ? `\n## Acceptance criteria\n${briefAcceptance}` : "",
|
||||
briefFiles?.length
|
||||
? `\n## Files to examine\n${briefFiles.map((f) => `- ${f}`).join("\n")}`
|
||||
: "",
|
||||
skillContext || "",
|
||||
"\n## Instructions\nUse the available tools to inspect and modify the repo. Run lint/type-check when appropriate. Finish with a concise summary of what changed.",
|
||||
].filter(Boolean);
|
||||
const systemPrompt = systemParts.join("\n");
|
||||
|
||||
const modelId = stripProviderPrefix(resolvedModel.modelId, config.providerId);
|
||||
|
||||
async function createSession(title: string): Promise<string> {
|
||||
const res = await fetch(`${apiBase}/session`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`OpenCode session create failed (${res.status}): ${await res.text()}`);
|
||||
}
|
||||
const data = (await res.json()) as SessionCreateResponse;
|
||||
if (!data.id) throw new Error("OpenCode session create returned no id");
|
||||
return data.id;
|
||||
}
|
||||
|
||||
async function sendPrompt(sessionId: string, prompt: string): Promise<{
|
||||
summary: string;
|
||||
toolCalls: number;
|
||||
}> {
|
||||
const res = await fetch(`${apiBase}/session/${sessionId}/message`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: { providerID: config.providerId, modelID: modelId },
|
||||
system: systemPrompt,
|
||||
parts: [{ type: "text", text: prompt }],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`OpenCode prompt failed (${res.status}): ${await res.text()}`);
|
||||
}
|
||||
const data = (await res.json()) as MessageResponse;
|
||||
if (data.info?.error) {
|
||||
throw new Error(
|
||||
`OpenCode error ${data.info.error.name ?? "unknown"}: ${data.info.error.message ?? ""}`,
|
||||
);
|
||||
}
|
||||
const textParts = (data.parts ?? [])
|
||||
.filter((p) => p.type === "text" && p.text)
|
||||
.map((p) => p.text as string);
|
||||
const toolCalls = (data.parts ?? []).filter((p) => p.type === "tool" || p.tool).length;
|
||||
return {
|
||||
summary: textParts.join("\n").trim() || "OpenCode session completed with no text summary.",
|
||||
toolCalls,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
context,
|
||||
async run(prompt: string) {
|
||||
console.info(
|
||||
`[opencode] session.run roleId=${roleId} brief=${briefId ?? "none"} model=${config.providerId}/${modelId}`,
|
||||
);
|
||||
try {
|
||||
const title = `convoy:${context.convoyId}:${roleId}:${briefId ?? "none"}`;
|
||||
const sessionId = await createSession(title);
|
||||
console.info(`[opencode] session started id=${sessionId}`);
|
||||
const result = await sendPrompt(sessionId, prompt);
|
||||
console.info(
|
||||
`[opencode] session complete tools=${result.toolCalls} summary_len=${result.summary.length}`,
|
||||
);
|
||||
return result;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(`[opencode] session failed:`, msg);
|
||||
return {
|
||||
summary: `OpenCode session failed: ${msg}`,
|
||||
toolCalls: 0,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
# Echodo production stack — shared CT 102 Postgres/Redis, multi-service build.
|
||||
#
|
||||
# Phase A (default): web, collab, mcp, orchestrator, worker
|
||||
# Phase B (optional): OpenHands agent-server — enable with profile `agent-server`
|
||||
# Phase B (optional coding harness):
|
||||
# Preferred: OpenCode — COMPOSE_PROFILES=opencode, WORKER_SESSION_TYPE=opencode
|
||||
# Legacy: OpenHands — COMPOSE_PROFILES=agent-server, WORKER_SESSION_TYPE=openhands
|
||||
#
|
||||
# Deploy targets:
|
||||
# - Dokploy (CT 112): compose file path `docker/docker-compose.prod.yml`, project dir = repo root
|
||||
|
|
@ -10,13 +12,18 @@
|
|||
# Domains (Dokploy UI or CT 100 Traefik):
|
||||
# web → echodo.stillwell.cloud (port 3000)
|
||||
# collab → collab.echodo.stillwell.cloud (port 1234)
|
||||
# opencode (optional) → opencode.stillwell.cloud (port 4096)
|
||||
#
|
||||
# Build (required on ≤8 GB hosts — parallel export OOMs with exit 255):
|
||||
# bash scripts/build-prod.sh
|
||||
# Dokploy → Advanced → Custom Build Command: bash scripts/build-prod.sh
|
||||
#
|
||||
# Phase B:
|
||||
# docker compose -f docker/docker-compose.prod.yml --profile agent-server up -d
|
||||
# Phase B OpenCode:
|
||||
# export COMPOSE_PROFILES=opencode
|
||||
# Set WORKER_SESSION_TYPE=opencode, OPENCODE_SERVER_PASSWORD, OMNIROUTE_API_KEY / BASE_URL.
|
||||
#
|
||||
# Phase B OpenHands (legacy):
|
||||
# export COMPOSE_PROFILES=agent-server
|
||||
# Set WORKER_SESSION_TYPE=openhands and matching SESSION_API_KEY / LOCAL_BACKEND_API_KEY.
|
||||
|
||||
services:
|
||||
|
|
@ -179,6 +186,11 @@ services:
|
|||
- WORKER_OPENHANDS_WORKSPACE=${WORKER_OPENHANDS_WORKSPACE:-/workspace/repo}
|
||||
- AGENT_SERVER_URL=${AGENT_SERVER_URL:-http://agent-server:8001}
|
||||
- SESSION_API_KEY=${SESSION_API_KEY:-}
|
||||
- OPENCODE_SERVER_URL=${OPENCODE_SERVER_URL:-http://opencode:4096}
|
||||
- OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:-}
|
||||
- OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode}
|
||||
- OPENCODE_PROVIDER_ID=${OPENCODE_PROVIDER_ID:-omniroute}
|
||||
- WORKER_OPENCODE_WORKSPACE=${WORKER_OPENCODE_WORKSPACE:-/workspace/repo}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- OPENAI_BASE_URL=${OPENAI_BASE_URL}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
|
|
@ -200,6 +212,52 @@ services:
|
|||
networks:
|
||||
- echodo
|
||||
|
||||
opencode:
|
||||
profiles: ["opencode"]
|
||||
image: ghcr.io/anomalyco/opencode:latest
|
||||
container_name: submatter-opencode
|
||||
restart: unless-stopped
|
||||
working_dir: /workspace/repo
|
||||
entrypoint: ["/bin/sh", "/config/entrypoint.sh"]
|
||||
command:
|
||||
[
|
||||
"opencode",
|
||||
"serve",
|
||||
"--hostname",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"4096",
|
||||
]
|
||||
environment:
|
||||
- HOME=/data
|
||||
- XDG_CONFIG_HOME=/data/.config
|
||||
- XDG_DATA_HOME=/data/.local/share
|
||||
- OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD}
|
||||
- OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode}
|
||||
- OMNIROUTE_API_KEY=${OMNIROUTE_API_KEY}
|
||||
volumes:
|
||||
- echodo-workspace:/workspace
|
||||
- opencode-data:/data
|
||||
- ./docker/opencode/opencode.json:/config/opencode.json:ro
|
||||
- ./docker/opencode/entrypoint.sh:/config/entrypoint.sh:ro
|
||||
ports:
|
||||
- "4096:4096"
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- "wget -qO- --timeout=3 http://127.0.0.1:4096/global/health >/dev/null || curl -fsS http://127.0.0.1:4096/global/health >/dev/null || exit 1"
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 45s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2G
|
||||
cpus: "2"
|
||||
networks:
|
||||
- echodo
|
||||
|
||||
agent-server:
|
||||
profiles: ["agent-server"]
|
||||
image: ghcr.io/open-hands/agent-server:0.19.0
|
||||
|
|
@ -238,6 +296,7 @@ services:
|
|||
|
||||
volumes:
|
||||
echodo-workspace:
|
||||
opencode-data:
|
||||
|
||||
networks:
|
||||
echodo:
|
||||
|
|
|
|||
51
docker/opencode/README.md
Normal file
51
docker/opencode/README.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# OpenCode on CT 116 (Submatter)
|
||||
|
||||
Headless coding harness for Submatter. Prefer this over OpenHands.
|
||||
|
||||
Runs `opencode serve` on `:4096` (HTTP API for the worker). Attach a TUI with
|
||||
`opencode attach http://…:4096`, or use the browser via Traefik once Authentik
|
||||
is happy. The image has no desktop browser — do not use `opencode web` in
|
||||
compose without an `xdg-open` stub (entrypoint provides one if you switch back).
|
||||
|
||||
## Enable
|
||||
|
||||
On CT 116 `/opt/submatter/.env`:
|
||||
|
||||
```bash
|
||||
COMPOSE_PROFILES=opencode
|
||||
WORKER_SESSION_TYPE=opencode # after worker image rebuild with opencode session
|
||||
OPENCODE_SERVER_PASSWORD=<openssl rand -hex 24>
|
||||
OPENCODE_SERVER_URL=http://opencode:4096
|
||||
OMNIROUTE_BASE_URL=https://omniroute.stillwell.cloud/v1
|
||||
OMNIROUTE_API_KEY=<gateway inference key>
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
export COMPOSE_PROFILES=opencode
|
||||
bash scripts/deploy-prod.sh up
|
||||
# First time / after worker harness code lands:
|
||||
# bash scripts/deploy-prod.sh build
|
||||
```
|
||||
|
||||
Keep `WORKER_SESSION_TYPE=minimal` until the rebuilt worker image includes the
|
||||
`opencode` session type (and the `yaml` dependency fix).
|
||||
|
||||
## URLs
|
||||
|
||||
| Surface | URL |
|
||||
|---------|-----|
|
||||
| Traefik | `https://opencode.stillwell.cloud` (Authentik + basic auth) |
|
||||
| Worker API | `http://opencode:4096` on the `echodo` Docker network |
|
||||
| LAN health | `curl -u opencode:$OPENCODE_SERVER_PASSWORD http://192.168.68.116:4096/global/health` |
|
||||
|
||||
## Auth
|
||||
|
||||
- Browser: Authentik via Traefik (`authentik-auth`) **and** OpenCode basic auth.
|
||||
- Worker: same basic auth via `OPENCODE_SERVER_PASSWORD`.
|
||||
- Models: `@omniroute/opencode-plugin` + `OMNIROUTE_API_KEY` (seeded into `auth.json` by `entrypoint.sh`).
|
||||
|
||||
## RAM
|
||||
|
||||
Container capped at 2 GB. Keep one concurrent agent until host RAM → 64 GB.
|
||||
27
docker/opencode/entrypoint.sh
Normal file
27
docker/opencode/entrypoint.sh
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
#!/bin/sh
|
||||
# Seed OmniRoute auth for headless OpenCode, then start serve/web.
|
||||
set -eu
|
||||
|
||||
CONFIG_HOME="${XDG_CONFIG_HOME:-${HOME:-/data}/.config}"
|
||||
DATA_HOME="${XDG_DATA_HOME:-${HOME:-/data}/.local/share}"
|
||||
mkdir -p "$CONFIG_HOME/opencode" "$DATA_HOME/opencode" /data/bin
|
||||
|
||||
# Container has no desktop — stub xdg-open so `opencode web` does not exit.
|
||||
if [ ! -x /data/bin/xdg-open ]; then
|
||||
printf '#!/bin/sh\nexit 0\n' > /data/bin/xdg-open
|
||||
chmod +x /data/bin/xdg-open
|
||||
fi
|
||||
export PATH="/data/bin:${PATH}"
|
||||
|
||||
if [ -f /config/opencode.json ]; then
|
||||
cp /config/opencode.json "$CONFIG_HOME/opencode/opencode.json"
|
||||
fi
|
||||
|
||||
if [ -n "${OMNIROUTE_API_KEY:-}" ]; then
|
||||
# Keys must not contain double-quotes; OmniRoute keys are hex/base64-safe.
|
||||
printf '{"omniroute":{"type":"api","key":"%s"}}\n' "$OMNIROUTE_API_KEY" \
|
||||
> "$DATA_HOME/opencode/auth.json"
|
||||
chmod 600 "$DATA_HOME/opencode/auth.json" || true
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
16
docker/opencode/opencode.json
Normal file
16
docker/opencode/opencode.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugin": [
|
||||
[
|
||||
"@omniroute/opencode-plugin",
|
||||
{
|
||||
"providerId": "omniroute",
|
||||
"baseURL": "http://192.168.68.108:20128"
|
||||
}
|
||||
]
|
||||
],
|
||||
"server": {
|
||||
"port": 4096,
|
||||
"hostname": "0.0.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,18 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# When OpenHands is enabled, seed the shared workspace volume from the deploy
|
||||
# snapshot baked into the image (/app/repo). Agent-server reads the same path.
|
||||
if [ "${WORKER_SESSION_TYPE:-minimal}" = "openhands" ]; then
|
||||
target="${WORKER_OPENHANDS_WORKSPACE:-/workspace/repo}"
|
||||
# When a remote harness shares a workspace volume, seed it from the deploy
|
||||
# snapshot baked into the image (/app/repo).
|
||||
session_type="${WORKER_SESSION_TYPE:-minimal}"
|
||||
if [ "$session_type" = "openhands" ] || [ "$session_type" = "opencode" ]; then
|
||||
if [ "$session_type" = "opencode" ]; then
|
||||
target="${WORKER_OPENCODE_WORKSPACE:-/workspace/repo}"
|
||||
else
|
||||
target="${WORKER_OPENHANDS_WORKSPACE:-/workspace/repo}"
|
||||
fi
|
||||
mkdir -p "$(dirname "$target")"
|
||||
if [ ! -f "$target/pnpm-workspace.yaml" ]; then
|
||||
echo "[worker-entrypoint] seeding OpenHands workspace at ${target}"
|
||||
echo "[worker-entrypoint] seeding ${session_type} workspace at ${target}"
|
||||
mkdir -p "$target"
|
||||
cp -a /app/repo/. "$target/"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -117,7 +117,26 @@ If Forgejo is canonical git and you want push deploy without Actions:
|
|||
|
||||
---
|
||||
|
||||
## Phase B — OpenHands (optional)
|
||||
## Phase B — OpenCode (preferred)
|
||||
|
||||
```bash
|
||||
export COMPOSE_PROFILES=opencode
|
||||
# Set in .env: WORKER_SESSION_TYPE=opencode, OPENCODE_SERVER_PASSWORD, OMNIROUTE_*
|
||||
bash scripts/deploy-prod.sh build # first time / worker code change
|
||||
# or: bash scripts/deploy-prod.sh up
|
||||
```
|
||||
|
||||
| Variable | Notes |
|
||||
|----------|-------|
|
||||
| `OPENCODE_SERVER_PASSWORD` | `openssl rand -hex 24` — Traefik still fronts Authentik |
|
||||
| `WORKER_SESSION_TYPE` | `opencode` |
|
||||
| `OPENCODE_SERVER_URL` | `http://opencode:4096` (Docker DNS) |
|
||||
| `OMNIROUTE_BASE_URL` | `https://omniroute.stillwell.cloud/v1` or LAN `http://192.168.68.108:20128/v1` |
|
||||
| `OMNIROUTE_API_KEY` | OmniRoute bearer key |
|
||||
|
||||
Web UI: `https://opencode.stillwell.cloud`. Details: `docker/opencode/README.md`.
|
||||
|
||||
### Legacy — OpenHands (optional)
|
||||
|
||||
```bash
|
||||
export COMPOSE_PROFILES=agent-server
|
||||
|
|
@ -129,11 +148,8 @@ bash scripts/deploy-prod.sh build
|
|||
| `LOCAL_BACKEND_API_KEY` | `openssl rand -hex 32` |
|
||||
| `SESSION_API_KEY` | Same as above (worker) |
|
||||
| `WORKER_SESSION_TYPE` | `openhands` |
|
||||
| `OMNIROUTE_BASE_URL` | `https://omniroute.stillwell.cloud/v1` |
|
||||
| `OMNIROUTE_API_KEY` | OmniRoute bearer key |
|
||||
|
||||
Requires Docker socket on CT 116 (`nesting=1` on LXC).
|
||||
|
||||
Requires Docker socket on CT 116 (`nesting=1` on LXC). Prefer OpenCode unless debugging the old path.
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
|
|
|||
|
|
@ -393,6 +393,9 @@ importers:
|
|||
ioredis:
|
||||
specifier: ^5.4.2
|
||||
version: 5.10.1
|
||||
yaml:
|
||||
specifier: ^2.7.0
|
||||
version: 2.8.3
|
||||
zod:
|
||||
specifier: ^3.24.0
|
||||
version: 3.25.76
|
||||
|
|
|
|||
|
|
@ -108,7 +108,16 @@ Handler: `deploy-webhook.sh`
|
|||
ssh root@192.168.68.116 'cd /opt/submatter && git pull && bash scripts/deploy-prod.sh build'
|
||||
```
|
||||
|
||||
## Phase B (OpenHands)
|
||||
## Phase B (OpenCode — preferred)
|
||||
|
||||
```bash
|
||||
export COMPOSE_PROFILES=opencode
|
||||
bash scripts/deploy-prod.sh build
|
||||
```
|
||||
|
||||
Set in `.env`: `WORKER_SESSION_TYPE=opencode`, `OPENCODE_SERVER_PASSWORD`, `OMNIROUTE_API_KEY` / `OMNIROUTE_BASE_URL`. See `docker/opencode/README.md`.
|
||||
|
||||
### Legacy Phase B (OpenHands)
|
||||
|
||||
```bash
|
||||
export COMPOSE_PROFILES=agent-server
|
||||
|
|
@ -116,7 +125,6 @@ bash scripts/deploy-prod.sh build
|
|||
```
|
||||
|
||||
Set `WORKER_SESSION_TYPE=openhands`, `SESSION_API_KEY`, `LOCAL_BACKEND_API_KEY`, OmniRoute vars in `.env`.
|
||||
|
||||
## AGENT-DEPLOY.md handoff
|
||||
|
||||
After CT is live, add to your local `AGENT-DEPLOY.md` (gitignored):
|
||||
|
|
|
|||
Loading…
Reference in a new issue