From b3bfd9837b4bd5bbcc29b189556863ae3197de1a Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Tue, 28 Jul 2026 00:23:54 +0800 Subject: [PATCH 1/3] feat(engine): group-scoped workdir template + init/destroy lifecycle scripts Engine.handleEvent now accepts an optional GroupConfig (forwarded by the router via x-ework-group-config header, parsed in server.ts). When a workdirTemplate is present, resolveWorkdir substitutes {owner}/{repo}/{issue}/{session} and uses it instead of the default baseWorkdir path. initScript runs in the workdir before each opencode spawn (execProcess pre-spawn). destroyScript runs on issue close (handleClosed). envInitScript is accepted but not yet executed (placeholder). Scripts run via bash with the workdir as cwd; failures are logged but never block the main flow. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/opencode.ts | 73 ++++++++++++++++++++++++++++++++++++++++++++++++- src/server.ts | 16 ++++++++++- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/opencode.ts b/src/opencode.ts index 12f4c65..36bdc46 100644 --- a/src/opencode.ts +++ b/src/opencode.ts @@ -27,6 +27,13 @@ export interface TakeoverStrategy { resumeOpenCodeSession(session: OpSession): Promise; } +export interface GroupConfig { + workdirTemplate?: string; + initScript?: string; + destroyScript?: string; + envInitScript?: string; +} + /** * Default TakeoverStrategy: deterministic per-issue workdir under * `/--//`, with a best-effort @@ -158,6 +165,8 @@ export class Engine { private observedIssues = new Set(); private observerTimer?: ReturnType; + private groupConfigs = new Map(); + private static MAX_INLINE_SIZE = 4000; private static MAX_NUDGE_ROUNDS = 1; private static MAX_STUCK_NUDGE_ROUNDS = 1; @@ -234,6 +243,12 @@ export class Engine { } private async resolveWorkdir(session: OpSession, issue: Issue): Promise { + const gc = this.groupConfigFor(issue); + if (gc?.workdirTemplate && !session.workdir) { + const dir = this.resolveTemplatedWorkdir(gc.workdirTemplate, issue, session); + mkdirSync(dir, { recursive: true }); + return dir; + } return this.takeover.acquireWorkdir(session, issue); } @@ -314,11 +329,15 @@ export class Engine { // ─── Event Dispatch ─── - async handleEvent(event: TrackerEvent) { + async handleEvent(event: TrackerEvent, groupConfig?: GroupConfig) { const { ref, issue: issueData } = event; const tracker = this.getTracker(ref.trackerType); const scopeKey = tracker.formatScopeKey(ref.scope); + if (groupConfig) { + this.groupConfigs.set(`${ref.trackerType}:${scopeKey}#${ref.issueId}`, groupConfig); + } + switch (event.type) { case "issue_opened": return this.handleOpened(ref, scopeKey, issueData, tracker, event.model); @@ -329,6 +348,44 @@ export class Engine { } } + private groupConfigFor(issue: Issue): GroupConfig | undefined { + return this.groupConfigs.get(`${issue.trackerType}:${issue.trackerScopeKey}#${issue.trackerIssueId}`); + } + + private resolveTemplatedWorkdir(template: string, issue: Issue, session: OpSession): string { + const parts = issue.trackerScopeKey.split("/"); + const owner = issue.trackerScope["owner"] ?? parts[0] ?? "default"; + const repo = issue.trackerScope["repo"] ?? parts[parts.length - 1] ?? "default"; + const dir = template + .replace(/\{owner\}/g, String(owner)) + .replace(/\{repo\}/g, String(repo)) + .replace(/\{issue\}/g, String(issue.trackerIssueId)) + .replace(/\{session\}/g, session.name); + return dir.startsWith("~") ? join(homedir(), dir.slice(1)) : dir; + } + + private async runHookScript(script: string | undefined, workdir: string, label: string): Promise { + if (!script || !script.trim()) return; + try { + mkdirSync(workdir, { recursive: true }); + const result = Bun.spawnSync({ + cmd: ["bash", "-c", script], + cwd: workdir, + stdout: "pipe", + stderr: "pipe", + env: process.env, + }); + const stderr = result.stderr?.toString() ?? ""; + if (result.exitCode !== 0) { + log.warn(`engine: ${label} exited ${result.exitCode}: ${stderr.slice(0, 500)}`); + } else if (stderr) { + log.info(`engine: ${label} stderr: ${stderr.slice(0, 300)}`); + } + } catch (e) { + log.warn(`engine: ${label} failed: ${(e as Error).message}`); + } + } + private async handleOpened( ref: TrackerRef, scopeKey: string, @@ -541,6 +598,15 @@ export class Engine { await this.store.updateSession(session.id, { state: "idle", opencodePid: undefined }); } + const gc = this.groupConfigFor(issue); + if (gc?.destroyScript) { + for (const session of sessions) { + const workdir = await this.resolveWorkdir(session, issue); + await this.runHookScript(gc.destroyScript, workdir, `destroyScript for ${scopeKey}#${ref.issueId}`); + } + } + this.groupConfigs.delete(`${ref.trackerType}:${scopeKey}#${ref.issueId}`); + log.info(`engine: issue closed, ${sessions.length} sessions paused for ${scopeKey}#${ref.issueId}`); } @@ -635,6 +701,11 @@ export class Engine { const workdir = await this.resolveWorkdir(session, issue); + const gc = this.groupConfigFor(issue); + if (gc?.initScript) { + await this.runHookScript(gc.initScript, workdir, `initScript for ${k}`); + } + const ref = this.sessionToRef(session, issue); const tracker = this.getTracker(issue.trackerType); diff --git a/src/server.ts b/src/server.ts index a493dd6..61bdca6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,7 @@ import type { Config } from "./config"; import type { Store } from "./op"; import type { Engine } from "./opencode"; +import type { GroupConfig } from "./opencode"; import type { IssueTracker, TrackerEvent } from "./trackers/types"; import { OpencodeReader, OpencodeReaderError } from "./opencode-reader"; import { listDir, readFile, readFileSince, FileApiError } from "./file-api"; @@ -8,6 +9,17 @@ import { log, uptimeSeconds, version } from "./logger"; type TrackerMap = Map; +function parseGroupConfigHeader(raw: string | null): GroupConfig | undefined { + if (!raw) return undefined; + try { + const decoded = Buffer.from(raw, "base64").toString("utf8"); + const parsed = JSON.parse(decoded); + if (typeof parsed === "object" && parsed !== null) return parsed as GroupConfig; + } catch { + } + return undefined; +} + function json(data: unknown, status = 200) { return new Response(JSON.stringify(data, null, 2), { status, @@ -41,7 +53,9 @@ export function createServer( `webhook: type=${event.type} ref=${event.ref.trackerType}:${event.ref.scope.owner ?? ""}/${event.ref.scope.repo ?? ""}#${event.ref.issueId}` ); - engine.handleEvent(event).catch((err) => { + const groupConfig = parseGroupConfigHeader(req.headers.get("x-ework-group-config")); + + engine.handleEvent(event, groupConfig).catch((err) => { log.error("webhook: handler error:", err); }); From 4ad99d4fa1766574b116db1f9b5e8ab0145e1268 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Tue, 28 Jul 2026 00:58:32 +0800 Subject: [PATCH 2/3] test(workdir): unit tests for template resolution + hook execution + header parsing Extracts resolveTemplatedWorkdir and runHookScript as exported standalone functions (previously private Engine methods) and exports parseGroupConfigHeader from server.ts so they are unit-testable. Also tightens the owner/repo fallback to use || instead of ?? so empty-string scope parts correctly fall back to 'default'. 16 tests covering: all 4 template variables, scopeKey-part fallback, homedir expansion, unknown placeholders, base64 round-trip, malformed input rejection, script cwd, workdir auto-create, and never-throws-on-failure invariant. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/opencode.ts | 76 ++++++++++++---------- src/server.ts | 2 +- tests/workdir.test.ts | 143 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 33 deletions(-) create mode 100644 tests/workdir.test.ts diff --git a/src/opencode.ts b/src/opencode.ts index 36bdc46..5090957 100644 --- a/src/opencode.ts +++ b/src/opencode.ts @@ -34,6 +34,48 @@ export interface GroupConfig { envInitScript?: string; } +/** Substitute {owner}/{repo}/{issue}/{session} in a workdir template. */ +export function resolveTemplatedWorkdir( + template: string, + issue: { trackerScopeKey: string; trackerScope: Record; trackerIssueId: string | number }, + session: { name: string }, +): string { + const parts = issue.trackerScopeKey.split("/"); + const owner = (issue.trackerScope["owner"] as string) || parts[0] || "default"; + const repo = (issue.trackerScope["repo"] as string) || parts[parts.length - 1] || "default"; + const dir = template + .replace(/\{owner\}/g, String(owner)) + .replace(/\{repo\}/g, String(repo)) + .replace(/\{issue\}/g, String(issue.trackerIssueId)) + .replace(/\{session\}/g, session.name); + return dir.startsWith("~") ? join(homedir(), dir.slice(1)) : dir; +} + +/** Run a lifecycle script via `bash -c` with cwd=workdir. Failures are logged + * and swallowed — never throws — so a broken init/destroy never blocks the + * opencode task flow. */ +export async function runHookScript(script: string | undefined, workdir: string, label: string): Promise { + if (!script || !script.trim()) return; + try { + mkdirSync(workdir, { recursive: true }); + const result = Bun.spawnSync({ + cmd: ["bash", "-c", script], + cwd: workdir, + stdout: "pipe", + stderr: "pipe", + env: process.env, + }); + const stderr = result.stderr?.toString() ?? ""; + if (result.exitCode !== 0) { + log.warn(`engine: ${label} exited ${result.exitCode}: ${stderr.slice(0, 500)}`); + } else if (stderr) { + log.info(`engine: ${label} stderr: ${stderr.slice(0, 300)}`); + } + } catch (e) { + log.warn(`engine: ${label} failed: ${(e as Error).message}`); + } +} + /** * Default TakeoverStrategy: deterministic per-issue workdir under * `/--//`, with a best-effort @@ -245,7 +287,7 @@ export class Engine { private async resolveWorkdir(session: OpSession, issue: Issue): Promise { const gc = this.groupConfigFor(issue); if (gc?.workdirTemplate && !session.workdir) { - const dir = this.resolveTemplatedWorkdir(gc.workdirTemplate, issue, session); + const dir = resolveTemplatedWorkdir(gc.workdirTemplate, issue, session); mkdirSync(dir, { recursive: true }); return dir; } @@ -352,38 +394,8 @@ export class Engine { return this.groupConfigs.get(`${issue.trackerType}:${issue.trackerScopeKey}#${issue.trackerIssueId}`); } - private resolveTemplatedWorkdir(template: string, issue: Issue, session: OpSession): string { - const parts = issue.trackerScopeKey.split("/"); - const owner = issue.trackerScope["owner"] ?? parts[0] ?? "default"; - const repo = issue.trackerScope["repo"] ?? parts[parts.length - 1] ?? "default"; - const dir = template - .replace(/\{owner\}/g, String(owner)) - .replace(/\{repo\}/g, String(repo)) - .replace(/\{issue\}/g, String(issue.trackerIssueId)) - .replace(/\{session\}/g, session.name); - return dir.startsWith("~") ? join(homedir(), dir.slice(1)) : dir; - } - private async runHookScript(script: string | undefined, workdir: string, label: string): Promise { - if (!script || !script.trim()) return; - try { - mkdirSync(workdir, { recursive: true }); - const result = Bun.spawnSync({ - cmd: ["bash", "-c", script], - cwd: workdir, - stdout: "pipe", - stderr: "pipe", - env: process.env, - }); - const stderr = result.stderr?.toString() ?? ""; - if (result.exitCode !== 0) { - log.warn(`engine: ${label} exited ${result.exitCode}: ${stderr.slice(0, 500)}`); - } else if (stderr) { - log.info(`engine: ${label} stderr: ${stderr.slice(0, 300)}`); - } - } catch (e) { - log.warn(`engine: ${label} failed: ${(e as Error).message}`); - } + return runHookScript(script, workdir, label); } private async handleOpened( diff --git a/src/server.ts b/src/server.ts index 61bdca6..0b36041 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,7 +9,7 @@ import { log, uptimeSeconds, version } from "./logger"; type TrackerMap = Map; -function parseGroupConfigHeader(raw: string | null): GroupConfig | undefined { +export function parseGroupConfigHeader(raw: string | null): GroupConfig | undefined { if (!raw) return undefined; try { const decoded = Buffer.from(raw, "base64").toString("utf8"); diff --git a/tests/workdir.test.ts b/tests/workdir.test.ts new file mode 100644 index 0000000..1fdad4e --- /dev/null +++ b/tests/workdir.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveTemplatedWorkdir, runHookScript } from "../src/opencode"; +import { parseGroupConfigHeader } from "../src/server"; + +function mkIssue(scopeKey: string, issueId: string | number, scope?: Record) { + return { + trackerScopeKey: scopeKey, + trackerScope: scope ?? {}, + trackerIssueId: issueId, + }; +} + +describe("resolveTemplatedWorkdir", () => { + test("substitutes all variables", () => { + const dir = resolveTemplatedWorkdir( + "/data/{owner}/{repo}/i{issue}/{session}", + mkIssue("acme/widget", "42", { owner: "acme", repo: "widget" }), + { name: "sess1" }, + ); + expect(dir).toBe("/data/acme/widget/i42/sess1"); + }); + + test("falls back to scopeKey parts when trackerScope absent", () => { + const dir = resolveTemplatedWorkdir( + "/w/{owner}/{repo}/{issue}", + mkIssue("fallback/repo-x", "7"), + { name: "s" }, + ); + expect(dir).toBe("/w/fallback/repo-x/7"); + }); + + test("uses 'default' when owner/repo unresolvable", () => { + const dir = resolveTemplatedWorkdir( + "/w/{owner}/{repo}/{issue}", + mkIssue("", "1"), + { name: "s" }, + ); + expect(dir).toBe("/w/default/default/1"); + }); + + test("replaces all occurrences of a variable", () => { + const dir = resolveTemplatedWorkdir( + "/w/{issue}/{issue}", + mkIssue("o/r", "9"), + { name: "s" }, + ); + expect(dir).toBe("/w/9/9"); + }); + + test("expands ~ to homedir", () => { + const dir = resolveTemplatedWorkdir("~/work/{issue}", mkIssue("o/r", "3"), { name: "s" }); + expect(dir.startsWith("/")).toBe(true); + expect(dir).not.toContain("~"); + expect(dir.endsWith("/work/3")).toBe(true); + }); + + test("leaves unknown placeholders literally", () => { + const dir = resolveTemplatedWorkdir( + "/w/{unknown}/{issue}", + mkIssue("o/r", "1"), + { name: "s" }, + ); + expect(dir).toBe("/w/{unknown}/1"); + }); +}); + +describe("parseGroupConfigHeader", () => { + test("round-trips a full config", () => { + const cfg = { workdirTemplate: "/w/{owner}/{repo}", initScript: "echo hi", destroyScript: "rm -rf x" }; + const encoded = Buffer.from(JSON.stringify(cfg), "utf8").toString("base64"); + expect(parseGroupConfigHeader(encoded)).toEqual(cfg); + expect(parseGroupConfigHeader(encoded)).toEqual(cfg); + }); + + test("returns undefined for null/empty", () => { + expect(parseGroupConfigHeader(null)).toBeUndefined(); + expect(parseGroupConfigHeader("")).toBeUndefined(); + expect(parseGroupConfigHeader(null)).toBeUndefined(); + }); + + test("returns undefined for malformed base64/json", () => { + expect(parseGroupConfigHeader("not-base64-json!!!")).toBeUndefined(); + expect(parseGroupConfigHeader(Buffer.from("{bad json").toString("base64"))).toBeUndefined(); + }); + + test("returns undefined for non-object json", () => { + expect(parseGroupConfigHeader(Buffer.from('"string"').toString("base64"))).toBeUndefined(); + expect(parseGroupConfigHeader(Buffer.from("42").toString("base64"))).toBeUndefined(); + expect(parseGroupConfigHeader(Buffer.from("null").toString("base64"))).toBeUndefined(); + }); +}); + +describe("runHookScript", () => { + test("no-op on undefined/empty/whitespace", async () => { + await runHookScript(undefined, "/tmp", "init"); + await runHookScript("", "/tmp", "init"); + await runHookScript(" \n ", "/tmp", "init"); + }); + + test("executes script with workdir as cwd", async () => { + const dir = mkdtempSync(join(tmpdir(), "ewhook-")); + try { + await runHookScript('pwd > cwd.txt', dir, "init"); + const cwd = readFileSync(join(dir, "cwd.txt"), "utf8").trim(); + expect(cwd).toBe(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("creates workdir if it does not exist", async () => { + const dir = join(mkdtempSync(join(tmpdir(), "ewp-")), "nested", "deep"); + try { + await runHookScript("echo ok > done.txt", dir, "init"); + expect(readFileSync(join(dir, "done.txt"), "utf8").trim()).toBe("ok"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("script can write env-templated content", async () => { + const dir = mkdtempSync(join(tmpdir(), "ewenv-")); + try { + writeFileSync(join(dir, "existing.txt"), "data"); + await runHookScript("cat existing.txt > out.txt", dir, "init"); + expect(readFileSync(join(dir, "out.txt"), "utf8").trim()).toBe("data"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("never throws on script failure", async () => { + await expect(runHookScript("exit 1", "/tmp", "init")).resolves.toBeUndefined(); + await expect(runHookScript("false", "/tmp", "init")).resolves.toBeUndefined(); + }); + + test("never throws on nonexistent shell command", async () => { + await expect(runHookScript("this-cmd-does-not-exist-xyz", "/tmp", "init")).resolves.toBeUndefined(); + }); +}); From 99237668992c5bb7b68617d4419db9ee09de389c Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Tue, 28 Jul 2026 01:09:21 +0800 Subject: [PATCH 3/3] fix(review): async hook exec + timeout + env vars + lifecycle hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CRITICAL+HIGH+MEDIUM findings from dual-agent review: H2+H3 (HIGH): runHookScript switched from Bun.spawnSync (blocks event loop) to async Bun.spawn with a 60s hard timeout (SIGKILL). A hung initScript no longer freezes the entire daemon (heartbeats, stdout draining, other webhooks). H4 (HIGH): handleClosed no longer calls resolveWorkdir (which triggers git clone on empty dirs). New workdirPathFor computes the path without side effects; destroyScript only runs on workdirs that already exist (existsSync check). M1 (MEDIUM): TOCTOU on groupConfigs Map — handleClosed now uses compare-and-delete (only deletes if the map value is still the same gc reference, so a concurrent handleEvent.set isn't clobbered). M2 (MEDIUM): handleClosed early-returns if issue.state === 'closed' — duplicate close webhooks (Gitea redelivers) no longer re-run destroyScript. M3 (MEDIUM): destroyScript dedupes workdirs via Set before looping — a template without {session} no longer runs destroy N times on the same dir. M5 (MEDIUM): hookEnvFor injects EWORK_OWNER/EWORK_REPO/EWORK_ISSUE/EWORK_SESSION/EWORK_WORKDIR into the script environment. The web UI placeholder now shows real env var names instead of the non-existent ${CLONE_URL}. L4 (LOW): resolveTemplatedWorkdir uses replacer functions (() => value) instead of raw strings — prevents $&/$1 interpretation in String.replace. L5 (LOW): relative templates are now anchored to baseWorkdir (matching RecloneStrategy behavior). The baseWorkdir param is threaded from resolveWorkdir. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/opencode.ts | 103 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 73 insertions(+), 30 deletions(-) diff --git a/src/opencode.ts b/src/opencode.ts index 5090957..37f8c55 100644 --- a/src/opencode.ts +++ b/src/opencode.ts @@ -1,5 +1,5 @@ import { spawn, type Subprocess } from "bun"; -import { mkdirSync, writeFileSync, readdirSync } from "fs"; +import { mkdirSync, writeFileSync, readdirSync, existsSync } from "fs"; import { join, resolve, isAbsolute } from "path"; import { homedir } from "os"; import { log } from "./logger"; @@ -39,37 +39,52 @@ export function resolveTemplatedWorkdir( template: string, issue: { trackerScopeKey: string; trackerScope: Record; trackerIssueId: string | number }, session: { name: string }, + baseWorkdir?: string, ): string { const parts = issue.trackerScopeKey.split("/"); const owner = (issue.trackerScope["owner"] as string) || parts[0] || "default"; const repo = (issue.trackerScope["repo"] as string) || parts[parts.length - 1] || "default"; - const dir = template - .replace(/\{owner\}/g, String(owner)) - .replace(/\{repo\}/g, String(repo)) - .replace(/\{issue\}/g, String(issue.trackerIssueId)) - .replace(/\{session\}/g, session.name); - return dir.startsWith("~") ? join(homedir(), dir.slice(1)) : dir; + // Use replacer functions to avoid `$&`/`$1` interpretation in replacement strings. + let dir = template + .replace(/\{owner\}/g, () => String(owner)) + .replace(/\{repo\}/g, () => String(repo)) + .replace(/\{issue\}/g, () => String(issue.trackerIssueId)) + .replace(/\{session\}/g, () => session.name); + if (dir.startsWith("~")) { + dir = join(homedir(), dir.slice(1)); + } else if (!isAbsolute(dir) && baseWorkdir) { + dir = resolve(baseWorkdir, dir); + } + return dir; } -/** Run a lifecycle script via `bash -c` with cwd=workdir. Failures are logged - * and swallowed — never throws — so a broken init/destroy never blocks the - * opencode task flow. */ -export async function runHookScript(script: string | undefined, workdir: string, label: string): Promise { +/** Run a lifecycle script via `bash -c` with cwd=workdir. Uses async Bun.spawn + * (not spawnSync) so the event loop is not blocked. A 60s hard timeout kills + * hung scripts. Failures are logged and swallowed — never throws — so a broken + * init/destroy never blocks the opencode task flow. */ +const HOOK_SCRIPT_TIMEOUT_MS = 60_000; +export async function runHookScript(script: string | undefined, workdir: string, label: string, env: Record = {}): Promise { if (!script || !script.trim()) return; try { mkdirSync(workdir, { recursive: true }); - const result = Bun.spawnSync({ + const proc = Bun.spawn({ cmd: ["bash", "-c", script], cwd: workdir, stdout: "pipe", stderr: "pipe", - env: process.env, + env: { ...process.env, ...env }, }); - const stderr = result.stderr?.toString() ?? ""; - if (result.exitCode !== 0) { - log.warn(`engine: ${label} exited ${result.exitCode}: ${stderr.slice(0, 500)}`); - } else if (stderr) { - log.info(`engine: ${label} stderr: ${stderr.slice(0, 300)}`); + const timer = setTimeout(() => { try { proc.kill("SIGKILL"); } catch { /* already dead */ } }, HOOK_SCRIPT_TIMEOUT_MS); + try { + const exitCode = await proc.exited; + const stderr = await new Response(proc.stderr).text().catch(() => ""); + if (exitCode !== 0) { + log.warn(`engine: ${label} exited ${exitCode}: ${stderr.slice(0, 500)}`); + } else if (stderr) { + log.info(`engine: ${label} stderr: ${stderr.slice(0, 300)}`); + } + } finally { + clearTimeout(timer); } } catch (e) { log.warn(`engine: ${label} failed: ${(e as Error).message}`); @@ -287,13 +302,42 @@ export class Engine { private async resolveWorkdir(session: OpSession, issue: Issue): Promise { const gc = this.groupConfigFor(issue); if (gc?.workdirTemplate && !session.workdir) { - const dir = resolveTemplatedWorkdir(gc.workdirTemplate, issue, session); + const dir = resolveTemplatedWorkdir(gc.workdirTemplate, issue, session, this.cfg.opencode.baseWorkdir); mkdirSync(dir, { recursive: true }); return dir; } return this.takeover.acquireWorkdir(session, issue); } + private hookEnvFor(issue: Issue, session: OpSession, workdir: string): Record { + const parts = issue.trackerScopeKey.split("/"); + const owner = (issue.trackerScope["owner"] as string) || parts[0] || ""; + const repo = (issue.trackerScope["repo"] as string) || parts[parts.length - 1] || ""; + return { + EWORK_OWNER: String(owner), + EWORK_REPO: String(repo), + EWORK_ISSUE: String(issue.trackerIssueId), + EWORK_SESSION: session.name, + EWORK_WORKDIR: workdir, + }; + } + + private workdirPathFor(session: OpSession, issue: Issue): string { + if (session.workdir) { + let dir = session.workdir; + if (dir.startsWith("~")) dir = join(homedir(), dir.slice(1)); + return isAbsolute(dir) ? dir : resolve(this.cfg.opencode.baseWorkdir, dir); + } + const gc = this.groupConfigFor(issue); + if (gc?.workdirTemplate) { + return resolveTemplatedWorkdir(gc.workdirTemplate, issue, session, this.cfg.opencode.baseWorkdir); + } + const parts = issue.trackerScopeKey.split("/"); + const owner = (issue.trackerScope["owner"] as string) || parts[0] || "default"; + const repo = (issue.trackerScope["repo"] as string) || parts[parts.length - 1] || "default"; + return join(this.cfg.opencode.baseWorkdir, `${owner}--${repo}`, String(issue.trackerIssueId), session.name); + } + private async persistRuntimeState(sessionId: string) { const session = await this.store.getSession(sessionId); if (!session) return; @@ -394,10 +438,6 @@ export class Engine { return this.groupConfigs.get(`${issue.trackerType}:${issue.trackerScopeKey}#${issue.trackerIssueId}`); } - private async runHookScript(script: string | undefined, workdir: string, label: string): Promise { - return runHookScript(script, workdir, label); - } - private async handleOpened( ref: TrackerRef, scopeKey: string, @@ -584,6 +624,7 @@ export class Engine { ) { const issue = await this.store.findIssue(ref.trackerType, scopeKey, ref.issueId); if (!issue) return; + if (issue.state === "closed") return; await this.store.updateIssueState(issue.id, "closed"); this.stopObserver(issue.id); @@ -597,27 +638,29 @@ export class Engine { this.stopping.add(k); try { this.killProcessTree(proc.pid, "SIGTERM"); } catch { /* already dead */ } } - // Clear runtime state this.clearRuntimeState(k); - // Mark pending/running messages as interrupted const msgs = await this.store.getMessagesForSession(session.id); for (const msg of msgs) { if (msg.status === "pending" || msg.status === "running") { await this.store.updateMessageStatus(msg.id, "interrupted", "issue closed"); } } - // Update session state await this.store.updateSession(session.id, { state: "idle", opencodePid: undefined }); } + const gcKey = `${ref.trackerType}:${scopeKey}#${ref.issueId}`; const gc = this.groupConfigFor(issue); if (gc?.destroyScript) { + const workdirs = new Set(); for (const session of sessions) { - const workdir = await this.resolveWorkdir(session, issue); - await this.runHookScript(gc.destroyScript, workdir, `destroyScript for ${scopeKey}#${ref.issueId}`); + const workdir = this.workdirPathFor(session, issue); + if (existsSync(workdir)) workdirs.add(workdir); + } + for (const workdir of workdirs) { + await runHookScript(gc.destroyScript, workdir, `destroyScript for ${scopeKey}#${ref.issueId}`, this.hookEnvFor(issue, { name: "" } as OpSession, workdir)); } } - this.groupConfigs.delete(`${ref.trackerType}:${scopeKey}#${ref.issueId}`); + if (this.groupConfigs.get(gcKey) === gc) this.groupConfigs.delete(gcKey); log.info(`engine: issue closed, ${sessions.length} sessions paused for ${scopeKey}#${ref.issueId}`); } @@ -715,7 +758,7 @@ export class Engine { const gc = this.groupConfigFor(issue); if (gc?.initScript) { - await this.runHookScript(gc.initScript, workdir, `initScript for ${k}`); + await runHookScript(gc.initScript, workdir, `initScript for ${k}`, this.hookEnvFor(issue, session, workdir)); } const ref = this.sessionToRef(session, issue);