From 6744d12413cf205584bb2c4af1e221c8dd911f37 Mon Sep 17 00:00:00 2001 From: "Doink (OpenClaw)" Date: Fri, 13 Mar 2026 17:16:23 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20harden=20matrix=20launch=20=E2=80=94=20t?= =?UTF-8?q?ilde=20expansion,=20fast-exit=20detection,=20daemon=20tracking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Expand ~ in matrix cwd paths before path.resolve (closes #150) - Detect fast exits (<2s) in opencode wrapper and override exit 0→1, catching ProviderModelNotFoundError and similar startup failures (closes #152) - Add session.launch.track RPC so matrix-launched sessions get locks, fuses, and webhooks from the daemon (closes #151) - Pass pid/model/prompt from orchestrator callback to daemon Co-Authored-By: Claude Opus 4.6 --- src/adapters/opencode-launch.test.ts | 16 ++++++++++ src/adapters/opencode.ts | 14 ++++++++- src/cli.ts | 11 +++++-- src/daemon/server.ts | 45 ++++++++++++++++++++++++++++ src/daemon/session-tracker.test.ts | 22 ++++++++++++++ src/matrix-parser.test.ts | 25 ++++++++++++++++ src/matrix-parser.ts | 9 ++++++ 7 files changed, 139 insertions(+), 3 deletions(-) diff --git a/src/adapters/opencode-launch.test.ts b/src/adapters/opencode-launch.test.ts index 9412bfc..b10d126 100644 --- a/src/adapters/opencode-launch.test.ts +++ b/src/adapters/opencode-launch.test.ts @@ -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", () => { diff --git a/src/adapters/opencode.ts b/src/adapters/opencode.ts index 332f30c..c462b29 100644 --- a/src/adapters/opencode.ts +++ b/src/adapters/opencode.ts @@ -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[], @@ -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"); } diff --git a/src/cli.ts b/src/cli.ts index 4833afe..2b38e3b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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"; @@ -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}`); @@ -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 diff --git a/src/daemon/server.ts b/src/daemon/server.ts index cdcd47b..80f1ac8 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -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); diff --git a/src/daemon/session-tracker.test.ts b/src/daemon/session-tracker.test.ts index 3442e09..334e310 100644 --- a/src/daemon/session-tracker.test.ts +++ b/src/daemon/session-tracker.test.ts @@ -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", () => { diff --git a/src/matrix-parser.test.ts b/src/matrix-parser.test.ts index b30f683..3a93e2f 100644 --- a/src/matrix-parser.test.ts +++ b/src/matrix-parser.test.ts @@ -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"; @@ -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 = { diff --git a/src/matrix-parser.ts b/src/matrix-parser.ts index 968029e..322f0fd 100644 --- a/src/matrix-parser.ts +++ b/src/matrix-parser.ts @@ -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"; @@ -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 [];