Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/adapters/opencode-launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,22 @@ describe("generateWrapperScript", () => {
);
expect(script).toContain("'it'\\''s a bug'");
});

it("detects fast exits (< 2s) and overrides exit code 0 to 1", () => {
const script = generateWrapperScript(
"/usr/local/bin/opencode",
["run", "--", "test"],
"/tmp/test.exit",
);
// Wrapper should record start time and check elapsed
expect(script).toContain("START=$(date +%s)");
expect(script).toContain("END=$(date +%s)");
expect(script).toContain("ELAPSED=$((END - START))");
// If exit code is 0 and runtime < threshold, override to 1
expect(script).toMatch(
/if \[ "\$EC" -eq 0 \] && \[ "\$ELAPSED" -lt \d+ \]; then EC=1; fi/,
);
});
});

describe("OpenCodeAdapter launch", () => {
Expand Down
14 changes: 13 additions & 1 deletion src/adapters/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@ export function computeProjectHash(directory: string): string {
* Generate a wrapper shell script that runs opencode and writes exit code to a file.
* This gives us immediate exit code capture — the primary signal in the fuse.
*/
/** Minimum runtime (seconds) for a session to be considered successful.
* Exits faster than this with code 0 are treated as failures (e.g.
* ProviderModelNotFoundError which opencode exits 0 on). */
const MIN_RUNTIME_SECONDS = 2;

export function generateWrapperScript(
opencodeBin: string,
args: string[],
Expand All @@ -157,11 +162,18 @@ export function generateWrapperScript(
const escapedArgs = args
.map((a) => `'${a.replace(/'/g, "'\\''")}'`)
.join(" ");
const escapedExitFile = exitFilePath.replace(/'/g, "'\\''");
return [
"#!/bin/sh",
`START=$(date +%s)`,
`${opencodeBin} ${escapedArgs}`,
`EC=$?`,
`echo "$EC" > '${exitFilePath.replace(/'/g, "'\\''")}'`,
`END=$(date +%s)`,
`ELAPSED=$((END - START))`,
// If process exited 0 but ran for less than MIN_RUNTIME_SECONDS,
// treat it as a failure — likely a startup error (e.g. model not found).
`if [ "$EC" -eq 0 ] && [ "$ELAPSED" -lt ${MIN_RUNTIME_SECONDS} ]; then EC=1; fi`,
`echo "$EC" > '${escapedExitFile}'`,
`exit $EC`,
].join("\n");
}
Expand Down
11 changes: 9 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ import {
orchestrateLaunch,
parseAdapterSlots,
} from "./launch-orchestrator.js";
import { expandMatrix, parseMatrixFile } from "./matrix-parser.js";
import {
expandMatrix,
expandTildePath,
parseMatrixFile,
} from "./matrix-parser.js";
import { loadConfig } from "./utils/config.js";
import { shortId } from "./utils/display.js";
import { createWorktree, type WorktreeInfo } from "./worktree.js";
Expand Down Expand Up @@ -605,7 +609,7 @@ program
const matrixFile = await parseMatrixFile(opts.matrix);
slots = expandMatrix(matrixFile);
// Matrix can override cwd; matrix prompt is used unless CLI -p overrides
if (matrixFile.cwd) cwd = path.resolve(matrixFile.cwd);
if (matrixFile.cwd) cwd = path.resolve(expandTildePath(matrixFile.cwd));
if (!opts.prompt && matrixFile.prompt) opts.prompt = matrixFile.prompt;
} catch (err) {
console.error(`Failed to parse matrix file: ${(err as Error).message}`);
Expand Down Expand Up @@ -678,6 +682,9 @@ program
adapter: slotResult.slot.adapter,
cwd: slotResult.cwd,
group: groupId,
pid: slotResult.pid,
model: slotResult.slot.model,
prompt: opts.prompt,
})
.catch(() => {
// Best effort — session will be picked up by poll
Expand Down
45 changes: 45 additions & 0 deletions src/daemon/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,51 @@ function createRequestHandler(ctx: HandlerContext) {
return record;
}

case "session.launch.track": {
// Register an externally-launched session (e.g. from matrix/orchestrator)
// so it appears in state, gets locks, fuses, webhooks, etc.
const id = params.id as string;
const adapterName = params.adapter as string;
const cwd = params.cwd as string | undefined;
const group = params.group as string | undefined;
const pid = params.pid as number | undefined;
const model = params.model as string | undefined;
const prompt = params.prompt as string | undefined;

const session: import("../core/types.js").AgentSession = {
id,
adapter: adapterName,
status: "running",
startedAt: new Date(),
cwd,
model,
prompt: prompt?.slice(0, 200),
pid,
group,
meta: {},
};

const record = ctx.sessionTracker.track(session, adapterName);

// Auto-lock by PID
if (cwd && pid) {
ctx.lockManager.autoLock(cwd, pid, id);
}

// Set lifecycle fuse (same as session.launch)
if (cwd && pid) {
const LIFECYCLE_FUSE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
ctx.fuseEngine.setFuse({
directory: cwd,
sessionId: id,
ttlMs: LIFECYCLE_FUSE_TTL_MS,
label: `lifecycle:${adapterName}:${id.slice(0, 8)}`,
});
}

return record;
}

case "session.stop": {
const id = params.id as string;
const launchRecord = ctx.sessionTracker.getSession(id);
Expand Down
22 changes: 22 additions & 0 deletions src/daemon/session-tracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,28 @@ describe("SessionTracker", () => {

expect(state.getSession("test-session-1")).toBeDefined();
});

it("preserves group, pid, and model for matrix-launched sessions", () => {
const session = makeSession({
id: "matrix-session-1",
pid: 12345,
model: "claude-opus-4-6",
group: "g-abc123",
prompt: "implement caching",
});
const record = tracker.track(session, "opencode");

expect(record.group).toBe("g-abc123");
expect(record.pid).toBe(12345);
expect(record.model).toBe("claude-opus-4-6");
expect(record.prompt).toBe("implement caching");
expect(record.adapter).toBe("opencode");

// Verify it's retrievable from state
const fromState = state.getSession("matrix-session-1");
expect(fromState).toBeDefined();
expect(fromState?.group).toBe("g-abc123");
});
});

describe("getSession", () => {
Expand Down
25 changes: 25 additions & 0 deletions src/matrix-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
expandMatrix,
expandTildePath,
type MatrixFile,
parseMatrixFile,
} from "./matrix-parser.js";
Expand Down Expand Up @@ -133,6 +134,30 @@ matrix:
});
});

describe("expandTildePath", () => {
it("expands ~/path to homedir/path", () => {
const result = expandTildePath("~/code/mono");
expect(result).toBe(path.join(os.homedir(), "code/mono"));
});

it("expands bare ~ to homedir", () => {
const result = expandTildePath("~");
expect(result).toBe(os.homedir());
});

it("leaves absolute paths unchanged", () => {
expect(expandTildePath("/usr/local/bin")).toBe("/usr/local/bin");
});

it("leaves relative paths unchanged", () => {
expect(expandTildePath("./foo/bar")).toBe("./foo/bar");
});

it("does not expand ~ in the middle of a path", () => {
expect(expandTildePath("/home/~user")).toBe("/home/~user");
});
});

describe("expandMatrix", () => {
it("expands simple entries to single slots", () => {
const matrix: MatrixFile = {
Expand Down
9 changes: 9 additions & 0 deletions src/matrix-parser.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import os from "node:os";
import YAML from "yaml";
import type { AdapterSlot } from "./launch-orchestrator.js";

Expand Down Expand Up @@ -93,6 +94,14 @@ export function expandMatrix(matrix: MatrixFile): AdapterSlot[] {
return slots;
}

/** Expand leading ~ to the user's home directory */
export function expandTildePath(p: string): string {
if (p.startsWith("~/") || p === "~") {
return os.homedir() + p.slice(1);
}
return p;
}

/** Normalize a value to an array (handles string | string[] | undefined) */
function normalizeToArray(value: string | string[] | undefined): string[] {
if (value === undefined || value === null) return [];
Expand Down
Loading