From 417976320111832649221f0b1502553438e78922 Mon Sep 17 00:00:00 2001 From: Love Billingskog Nyberg Date: Mon, 2 Mar 2026 21:59:25 +0100 Subject: [PATCH 01/16] feat: add waiting_user task status to queue Add setWaiting, resume, and getWaitingForUser methods to TaskQueue. Update dequeue, listActive, cancel, and resetStale queries to handle the new waiting_user status. This is the foundation for long-lived streaming sessions where tasks can pause waiting for user input. Co-Authored-By: Claude Opus 4.6 --- src/queue.test.ts | 60 ++++++++++++++++++++++++++++++++++++++++++- src/queue.ts | 65 +++++++++++++++++++++++++++++++---------------- 2 files changed, 102 insertions(+), 23 deletions(-) diff --git a/src/queue.test.ts b/src/queue.test.ts index 0f0d99d..bcd6efa 100644 --- a/src/queue.test.ts +++ b/src/queue.test.ts @@ -203,7 +203,7 @@ describe("TaskQueue", () => { describe("metrics()", () => { it("returns zeroes on empty queue", () => { const m = queue.metrics(); - expect(m.counts).toEqual({ pending: 0, running: 0, completed: 0, failed: 0 }); + expect(m.counts).toEqual({ pending: 0, running: 0, completed: 0, failed: 0, waiting: 0 }); expect(m.avgDurationByRepo).toEqual([]); expect(m.throughput.lastHour).toBe(0); expect(m.throughput.last24h).toBe(0); @@ -285,4 +285,62 @@ describe("TaskQueue", () => { expect(m.errorRate).toBe(0); }); }); + + describe("waiting_user status", () => { + it("setWaiting transitions running task to waiting_user", () => { + const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); + queue.dequeue(); // running + queue.setWaiting(id, "waiting_user"); + const task = queue.get(id); + expect(task?.status).toBe("waiting_user"); + }); + + it("resume transitions waiting_user back to running", () => { + const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); + queue.dequeue(); + queue.setWaiting(id, "waiting_user"); + queue.resume(id); + const task = queue.get(id); + expect(task?.status).toBe("running"); + }); + + it("dequeue skips waiting_user tasks", () => { + const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); + queue.dequeue(); + queue.setWaiting(id, "waiting_user"); + queue.enqueue({ userId: "u1", repo: "r1", prompt: "test2" }); + const next = queue.dequeue(); + expect(next).toBeNull(); + }); + + it("resetStale also resets waiting_user tasks", () => { + const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); + queue.dequeue(); + queue.setWaiting(id, "waiting_user"); + const count = queue.resetStale(); + expect(count).toBe(1); + expect(queue.get(id)?.status).toBe("failed"); + }); + + it("listActive includes waiting_user tasks", () => { + const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); + queue.dequeue(); + queue.setWaiting(id, "waiting_user"); + const active = queue.listActive(); + expect(active.some(t => t.id === id)).toBe(true); + }); + + it("getWaitingForUser returns waiting_user task for a user", () => { + const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); + queue.dequeue(); + queue.setWaiting(id, "waiting_user"); + const waiting = queue.getWaitingForUser("u1"); + expect(waiting?.id).toBe(id); + }); + + it("getWaitingForUser returns null when no waiting tasks", () => { + const waiting = queue.getWaitingForUser("u1"); + expect(waiting).toBeNull(); + }); + }); }); diff --git a/src/queue.ts b/src/queue.ts index 0817af0..43e9961 100644 --- a/src/queue.ts +++ b/src/queue.ts @@ -13,7 +13,7 @@ export interface Task { userId: string; repo: string; prompt: string; - status: "pending" | "running" | "completed" | "failed"; + status: "pending" | "running" | "completed" | "failed" | "waiting_user"; result: string | null; taskType: string | null; priority: number; @@ -75,22 +75,21 @@ export class TaskQueue { } dequeue(): Task | null { - return this.db.transaction(() => { - const row = this.db - .query( - `SELECT * FROM tasks - WHERE status = 'pending' - ORDER BY priority DESC, created_at ASC, rowid ASC - LIMIT 1` - ) - .get() as TaskRow; + const row = this.db + .query( + `SELECT * FROM tasks + WHERE status = 'pending' + AND repo NOT IN (SELECT repo FROM tasks WHERE status IN ('running', 'waiting_user')) + ORDER BY priority DESC, created_at ASC, rowid ASC + LIMIT 1` + ) + .get() as TaskRow; - if (!row) return null; + if (!row) return null; - this.db.run(`UPDATE tasks SET status = 'running' WHERE id = ?`, [row.id]); + this.db.run(`UPDATE tasks SET status = 'running' WHERE id = ?`, [row.id]); - return this.rowToTask({ ...row, status: "running" }); - })(); + return this.rowToTask({ ...row, status: "running" }); } complete(id: string, result: string) { @@ -113,6 +112,27 @@ export class TaskQueue { return row ? this.rowToTask(row) : null; } + setWaiting(id: string, status: "waiting_user") { + this.db.run( + `UPDATE tasks SET status = ? WHERE id = ? AND status = 'running'`, + [status, id] + ); + } + + resume(id: string) { + this.db.run( + `UPDATE tasks SET status = 'running' WHERE id = ? AND status = 'waiting_user'`, + [id] + ); + } + + getWaitingForUser(userId: string): Task | null { + const row = this.db + .query(`SELECT * FROM tasks WHERE user_id = ? AND status = 'waiting_user' ORDER BY created_at DESC LIMIT 1`) + .get(userId) as TaskRow; + return row ? this.rowToTask(row) : null; + } + listByUser(userId: string, limit: number = 10): Task[] { const rows = this.db .query( @@ -122,21 +142,22 @@ export class TaskQueue { return rows.map((r) => this.rowToTask(r)); } - stats(): { pending: number; running: number; completed: number; failed: number } { + stats(): { pending: number; running: number; completed: number; failed: number; waiting: number } { return this.db .query( `SELECT COUNT(*) FILTER (WHERE status = 'pending') as pending, COUNT(*) FILTER (WHERE status = 'running') as running, COUNT(*) FILTER (WHERE status = 'completed') as completed, - COUNT(*) FILTER (WHERE status = 'failed') as failed + COUNT(*) FILTER (WHERE status = 'failed') as failed, + COUNT(*) FILTER (WHERE status = 'waiting_user') as waiting FROM tasks` ) - .get() as { pending: number; running: number; completed: number; failed: number }; + .get() as { pending: number; running: number; completed: number; failed: number; waiting: number }; } metrics(): { - counts: { pending: number; running: number; completed: number; failed: number }; + counts: { pending: number; running: number; completed: number; failed: number; waiting: number }; avgDurationByRepo: { repo: string; avgMs: number; count: number }[]; throughput: { lastHour: number; last24h: number }; errorRate: number; @@ -207,7 +228,7 @@ export class TaskQueue { listActive(limit: number = 20): Task[] { const rows = this.db .query( - `SELECT * FROM tasks WHERE status IN ('running', 'pending') ORDER BY priority DESC, created_at ASC, rowid ASC LIMIT ?` + `SELECT * FROM tasks WHERE status IN ('running', 'pending', 'waiting_user') ORDER BY priority DESC, created_at ASC, rowid ASC LIMIT ?` ) .all(limit) as TaskRow[]; return rows.map((r) => this.rowToTask(r)); @@ -215,7 +236,7 @@ export class TaskQueue { cancel(id: string): boolean { return this.db.run( - `UPDATE tasks SET status = 'failed', result = 'Cancelled', completed_at = ? WHERE id = ? AND status IN ('running', 'pending')`, + `UPDATE tasks SET status = 'failed', result = 'Cancelled', completed_at = ? WHERE id = ? AND status IN ('running', 'pending', 'waiting_user')`, [new Date().toISOString(), id] ).changes > 0; } @@ -235,7 +256,7 @@ export class TaskQueue { resetStale(): number { return this.db.run( - `UPDATE tasks SET status = 'failed', result = 'Interrupted — process restarted', completed_at = ? WHERE status = 'running'`, + `UPDATE tasks SET status = 'failed', result = 'Interrupted — process restarted', completed_at = ? WHERE status IN ('running', 'waiting_user')`, [new Date().toISOString()] ).changes; } @@ -246,7 +267,7 @@ export class TaskQueue { userId: row.user_id, repo: row.repo, prompt: row.prompt, - status: row.status as "pending" | "running" | "completed" | "failed", + status: row.status as "pending" | "running" | "completed" | "failed" | "waiting_user", result: row.result, taskType: row.task_type || null, priority: row.priority ?? 0, From 9e496734f88e014c4b8734d6ac942a6ecc4a95a5 Mon Sep 17 00:00:00 2001 From: Love Billingskog Nyberg Date: Mon, 2 Mar 2026 22:03:15 +0100 Subject: [PATCH 02/16] feat: add sessionId column to tasks for session resume Co-Authored-By: Claude Opus 4.6 --- src/queue.test.ts | 15 +++++++++++++++ src/queue.ts | 11 +++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/queue.test.ts b/src/queue.test.ts index bcd6efa..0c2f588 100644 --- a/src/queue.test.ts +++ b/src/queue.test.ts @@ -343,4 +343,19 @@ describe("TaskQueue", () => { expect(waiting).toBeNull(); }); }); + + describe("sessionId tracking", () => { + it("stores sessionId via setSessionId", () => { + const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); + queue.setSessionId(id, "ses-abc-123"); + const task = queue.get(id); + expect(task?.sessionId).toBe("ses-abc-123"); + }); + + it("sessionId is null by default", () => { + const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); + const task = queue.get(id); + expect(task?.sessionId).toBeNull(); + }); + }); }); diff --git a/src/queue.ts b/src/queue.ts index 43e9961..d8f64f9 100644 --- a/src/queue.ts +++ b/src/queue.ts @@ -17,6 +17,7 @@ export interface Task { result: string | null; taskType: string | null; priority: number; + sessionId: string | null; createdAt: string; completedAt: string | null; } @@ -30,6 +31,7 @@ interface TaskRow { result: string | null; task_type: string | null; priority: number; + session_id: string | null; created_at: string; completed_at: string | null; } @@ -62,6 +64,10 @@ export class TaskQueue { if (!columns.some(c => c.name === "priority")) { this.db.run("ALTER TABLE tasks ADD COLUMN priority INTEGER NOT NULL DEFAULT 0"); } + // Migration: add session_id column if missing (backward compat) + if (!columns.some(c => c.name === "session_id")) { + this.db.run("ALTER TABLE tasks ADD COLUMN session_id TEXT"); + } } enqueue(input: TaskInput): string { @@ -126,6 +132,10 @@ export class TaskQueue { ); } + setSessionId(id: string, sessionId: string) { + this.db.run(`UPDATE tasks SET session_id = ? WHERE id = ?`, [sessionId, id]); + } + getWaitingForUser(userId: string): Task | null { const row = this.db .query(`SELECT * FROM tasks WHERE user_id = ? AND status = 'waiting_user' ORDER BY created_at DESC LIMIT 1`) @@ -271,6 +281,7 @@ export class TaskQueue { result: row.result, taskType: row.task_type || null, priority: row.priority ?? 0, + sessionId: row.session_id || null, createdAt: row.created_at, completedAt: row.completed_at, }; From 060b9aa7a8815d9deb666b22eed9c9ffcbaf13e6 Mon Sep 17 00:00:00 2001 From: Love Billingskog Nyberg Date: Mon, 2 Mar 2026 22:10:22 +0100 Subject: [PATCH 03/16] feat: add streaming types and buildStreamingArgs to runner Add StreamEvent, StreamingSession types to runner.ts. Add resumeSessionId to RunOptions and sessionId to RunResult. Add buildStreamingArgs method to ClaudeRunner (with --input-format stream-json, without --disallowed-tools AskUserQuestion). Update buildArgs and run() to support session resume and capture session_id from result messages. Co-Authored-By: Claude Opus 4.6 --- src/runner.ts | 16 +++++++++++++++ src/runners/claude.test.ts | 40 ++++++++++++++++++++++++++++++++++++++ src/runners/claude.ts | 19 +++++++++++++++++- 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/runner.ts b/src/runner.ts index 6db8e9e..4dd7280 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -3,12 +3,14 @@ export interface RunOptions { mcpConfigPath?: string; model?: string; signal?: AbortSignal; + resumeSessionId?: string; } export interface RunResult { success: boolean; output: string; durationMs: number; + sessionId?: string; } export type StatusEvent = @@ -17,6 +19,20 @@ export type StatusEvent = export type StatusCallback = (event: StatusEvent) => void; +export type StreamEvent = + | { kind: "text"; text: string } + | { kind: "tool"; tool: string; input: string } + | { kind: "ask_user"; question: string; options: { label: string; description?: string }[] } + | { kind: "result"; text: string; sessionId?: string } + | { kind: "error"; text: string }; + +export interface StreamingSession { + sendMessage(text: string): void; + kill(): void; + readonly sessionId: string | null; + readonly done: Promise; +} + export interface AgentRunner { name: string; run(prompt: string, workDir: string, opts: RunOptions, onStatus?: StatusCallback): Promise; diff --git a/src/runners/claude.test.ts b/src/runners/claude.test.ts index 45ab19d..e1216bf 100644 --- a/src/runners/claude.test.ts +++ b/src/runners/claude.test.ts @@ -75,3 +75,43 @@ describe("summarizeToolInput", () => { expect(summarizeToolInput("Unknown", { foo: "bar" })).toBe('{"foo":"bar"}'); }); }); + +describe("streaming args", () => { + const runner = new ClaudeRunner(); + + it("builds streaming args with input-format and without disallowed AskUserQuestion", () => { + const args = runner.buildStreamingArgs("fix the bug", { maxTurns: 25 }); + expect(args).toContain("--input-format"); + expect(args).toContain("stream-json"); + expect(args).not.toContain("AskUserQuestion"); + expect(args).not.toContain("--disallowed-tools"); + }); + + it("streaming args still include output-format stream-json", () => { + const args = runner.buildStreamingArgs("test", { maxTurns: 10 }); + expect(args).toContain("--output-format"); + expect(args).toContain("stream-json"); + }); + + it("includes resume flag when sessionId provided", () => { + const args = runner.buildStreamingArgs("test", { maxTurns: 10, resumeSessionId: "ses-123" }); + expect(args).toContain("--resume"); + expect(args).toContain("ses-123"); + }); + + it("omits resume when no sessionId", () => { + const args = runner.buildStreamingArgs("test", { maxTurns: 10 }); + expect(args).not.toContain("--resume"); + }); +}); + +describe("buildArgs resume support", () => { + const runner = new ClaudeRunner(); + + it("includes resume in regular buildArgs when provided", () => { + const args = runner.buildArgs("test", { maxTurns: 10, resumeSessionId: "ses-456" }); + expect(args).toContain("--resume"); + expect(args).toContain("ses-456"); + }); +}); + diff --git a/src/runners/claude.ts b/src/runners/claude.ts index deaa8de..3ee6e09 100644 --- a/src/runners/claude.ts +++ b/src/runners/claude.ts @@ -35,6 +35,21 @@ export class ClaudeRunner implements AgentRunner { buildArgs(prompt: string, opts: RunOptions): string[] { const args = ["-p", prompt, "--output-format", "stream-json", "--verbose", "--max-turns", String(opts.maxTurns), "--dangerously-skip-permissions", "--disallowed-tools", "AskUserQuestion"]; if (opts.mcpConfigPath) args.push("--mcp-config", opts.mcpConfigPath); + if (opts.resumeSessionId) args.push("--resume", opts.resumeSessionId); + return args; + } + + buildStreamingArgs(prompt: string, opts: RunOptions): string[] { + const args = [ + "-p", prompt, + "--input-format", "stream-json", + "--output-format", "stream-json", + "--verbose", + "--max-turns", String(opts.maxTurns), + "--dangerously-skip-permissions", + ]; + if (opts.mcpConfigPath) args.push("--mcp-config", opts.mcpConfigPath); + if (opts.resumeSessionId) args.push("--resume", opts.resumeSessionId); return args; } @@ -55,6 +70,7 @@ export class ClaudeRunner implements AgentRunner { } let resultText: string | null = null; + let resultSessionId: string | null = null; const textBlocks: string[] = []; const decoder = new TextDecoder(); const reader = proc.stdout.getReader(); @@ -69,6 +85,7 @@ export class ClaudeRunner implements AgentRunner { const msg = JSON.parse(line); if (msg.type === "result" && msg.result) { resultText = msg.result; + if (msg.session_id) resultSessionId = msg.session_id; } if (msg.type === "assistant" && msg.message?.content) { for (const block of msg.message.content) { @@ -108,6 +125,6 @@ export class ClaudeRunner implements AgentRunner { const finalOutput = resultText || textBlocks.join("\n\n") || "Task completed (no output)"; logger.info("claude task completed", { durationMs }); - return { success: true, output: finalOutput, durationMs }; + return { success: true, output: finalOutput, durationMs, sessionId: resultSessionId ?? undefined }; } } From f1c255c7ecedc88b31de064f8c75a23cc55cc988 Mon Sep 17 00:00:00 2001 From: Love Billingskog Nyberg Date: Mon, 2 Mar 2026 22:11:46 +0100 Subject: [PATCH 04/16] feat: implement runStreaming with bidirectional stdin/stdout Add runStreaming() method to ClaudeRunner that spawns Claude with stdin pipe for bidirectional communication. Parses stream-json events, detects AskUserQuestion tool_use blocks to emit ask_user StreamEvents, captures session_id from system/result messages, and returns a StreamingSession handle with sendMessage(), kill(), sessionId, and done promise. Co-Authored-By: Claude Opus 4.6 --- src/runners/claude.test.ts | 8 +++ src/runners/claude.ts | 120 ++++++++++++++++++++++++++++++++++++- 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/src/runners/claude.test.ts b/src/runners/claude.test.ts index e1216bf..4c2dd51 100644 --- a/src/runners/claude.test.ts +++ b/src/runners/claude.test.ts @@ -115,3 +115,11 @@ describe("buildArgs resume support", () => { }); }); +describe("runStreaming", () => { + const runner = new ClaudeRunner(); + + it("method exists and is a function", () => { + expect(typeof runner.runStreaming).toBe("function"); + }); +}); + diff --git a/src/runners/claude.ts b/src/runners/claude.ts index 3ee6e09..be60444 100644 --- a/src/runners/claude.ts +++ b/src/runners/claude.ts @@ -1,4 +1,4 @@ -import type { AgentRunner, RunOptions, RunResult, StatusCallback } from "../runner"; +import type { AgentRunner, RunOptions, RunResult, StatusCallback, StreamEvent, StreamingSession } from "../runner"; import { logger } from "../logger"; import { which } from "bun"; import { realpathSync } from "node:fs"; @@ -127,4 +127,122 @@ export class ClaudeRunner implements AgentRunner { logger.info("claude task completed", { durationMs }); return { success: true, output: finalOutput, durationMs, sessionId: resultSessionId ?? undefined }; } + + runStreaming( + prompt: string, + workDir: string, + opts: RunOptions, + onEvent?: (event: StreamEvent) => void, + ): StreamingSession { + const args = this.buildStreamingArgs(prompt, opts); + const startTime = Date.now(); + logger.info("starting streaming claude task", { workDir, maxTurns: opts.maxTurns }); + + const proc = Bun.spawn([this.claudePath, ...args], { + cwd: workDir, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, CI: "1" }, + }); + + if (opts.signal) { + opts.signal.addEventListener("abort", () => proc.kill(), { once: true }); + } + + let sessionId: string | null = null; + let resultText: string | null = null; + const textBlocks: string[] = []; + + const done = (async (): Promise => { + const decoder = new TextDecoder(); + const reader = proc.stdout.getReader(); + try { + while (true) { + const { done: isDone, value } = await reader.read(); + if (isDone) break; + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split("\n").filter(Boolean); + for (const line of lines) { + try { + const msg = JSON.parse(line); + + if (msg.type === "system" && msg.session_id) { + sessionId = msg.session_id; + } + + if (msg.type === "result" && msg.result) { + resultText = msg.result; + if (msg.session_id) sessionId = msg.session_id; + onEvent?.({ kind: "result", text: msg.result, sessionId: sessionId ?? undefined }); + } + + if (msg.type === "assistant" && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === "text") { + textBlocks.push(block.text); + onEvent?.({ kind: "text", text: block.text }); + } + if (block.type === "tool_use") { + if (block.name === "AskUserQuestion") { + const questions = block.input?.questions; + if (questions?.[0]) { + onEvent?.({ + kind: "ask_user", + question: questions[0].question, + options: questions[0].options || [], + }); + } + } else { + onEvent?.({ + kind: "tool", + tool: block.name, + input: summarizeToolInput(block.name, block.input), + }); + } + } + } + } + } catch {} + } + } + } finally { + reader.releaseLock(); + } + + const exitCode = await withTimeout(proc); + const durationMs = Date.now() - startTime; + + if (exitCode === "timeout") { + return { success: false, output: `Claude task timed out after ${TIMEOUT_MS / 60000} minutes`, durationMs }; + } + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + return { success: false, output: stderr || "Claude task failed", durationMs }; + } + + return { + success: true, + output: resultText || textBlocks.join("\n\n") || "Task completed (no output)", + durationMs, + sessionId: sessionId ?? undefined, + }; + })(); + + const encoder = new TextEncoder(); + + return { + sendMessage(text: string) { + const msg = JSON.stringify({ type: "user_message", content: text }) + "\n"; + proc.stdin.write(encoder.encode(msg)); + }, + kill() { + proc.kill(); + }, + get sessionId() { + return sessionId; + }, + done, + }; + } } From 89c83df8a81bc52525e0968aed9398153af92c94 Mon Sep 17 00:00:00 2001 From: Love Billingskog Nyberg Date: Mon, 2 Mar 2026 22:14:36 +0100 Subject: [PATCH 05/16] feat: add SessionManager for tracking live streaming sessions Co-Authored-By: Claude Opus 4.6 --- src/session-manager.test.ts | 93 +++++++++++++++++++++++++++++++++++++ src/session-manager.ts | 59 +++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 src/session-manager.test.ts create mode 100644 src/session-manager.ts diff --git a/src/session-manager.test.ts b/src/session-manager.test.ts new file mode 100644 index 0000000..5990e3c --- /dev/null +++ b/src/session-manager.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "bun:test"; +import { SessionManager } from "./session-manager"; +import type { StreamingSession, RunResult } from "./runner"; + +function mockSession( + overrides?: Partial, +): StreamingSession & { _messages: string[] } { + const messages: string[] = []; + return { + sendMessage: (text: string) => { + messages.push(text); + }, + kill: () => {}, + sessionId: "ses-test-123", + done: Promise.resolve({ success: true, output: "done", durationMs: 100 }), + _messages: messages, + ...overrides, + }; +} + +describe("SessionManager", () => { + it("registers and retrieves a session by taskId", () => { + const mgr = new SessionManager(); + const session = mockSession(); + mgr.register("task-1", "user-1", session); + expect(mgr.getByTask("task-1")).toBe(session); + }); + + it("retrieves waiting session by userId", () => { + const mgr = new SessionManager(); + const session = mockSession(); + mgr.register("task-1", "user-1", session); + mgr.setWaiting("task-1"); + const waiting = mgr.getWaitingForUser("user-1"); + expect(waiting?.taskId).toBe("task-1"); + expect(waiting?.session).toBe(session); + }); + + it("returns null when no waiting session for user", () => { + const mgr = new SessionManager(); + expect(mgr.getWaitingForUser("user-1")).toBeNull(); + }); + + it("returns null for registered but non-waiting session", () => { + const mgr = new SessionManager(); + mgr.register("task-1", "user-1", mockSession()); + expect(mgr.getWaitingForUser("user-1")).toBeNull(); + }); + + it("sendToTask sends message to the session", () => { + const mgr = new SessionManager(); + const session = mockSession(); + mgr.register("task-1", "user-1", session); + mgr.sendToTask("task-1", "hello"); + expect(session._messages).toEqual(["hello"]); + }); + + it("sendToTask returns false for unknown task", () => { + const mgr = new SessionManager(); + expect(mgr.sendToTask("nope", "hello")).toBe(false); + }); + + it("unregister removes the session", () => { + const mgr = new SessionManager(); + mgr.register("task-1", "user-1", mockSession()); + mgr.unregister("task-1"); + expect(mgr.getByTask("task-1")).toBeNull(); + }); + + it("clearWaiting removes waiting state but keeps session", () => { + const mgr = new SessionManager(); + const session = mockSession(); + mgr.register("task-1", "user-1", session); + mgr.setWaiting("task-1"); + mgr.clearWaiting("task-1"); + expect(mgr.getWaitingForUser("user-1")).toBeNull(); + expect(mgr.getByTask("task-1")).toBe(session); + }); + + it("killAll kills all sessions and clears map", () => { + const mgr = new SessionManager(); + let killed = false; + const session = mockSession({ + kill: () => { + killed = true; + }, + }); + mgr.register("task-1", "user-1", session); + mgr.killAll(); + expect(killed).toBe(true); + expect(mgr.getByTask("task-1")).toBeNull(); + }); +}); diff --git a/src/session-manager.ts b/src/session-manager.ts new file mode 100644 index 0000000..ad527b1 --- /dev/null +++ b/src/session-manager.ts @@ -0,0 +1,59 @@ +import type { StreamingSession } from "./runner"; + +interface SessionEntry { + taskId: string; + userId: string; + session: StreamingSession; + waiting: boolean; +} + +export class SessionManager { + private sessions = new Map(); + + register(taskId: string, userId: string, session: StreamingSession) { + this.sessions.set(taskId, { taskId, userId, session, waiting: false }); + } + + unregister(taskId: string) { + this.sessions.delete(taskId); + } + + getByTask(taskId: string): StreamingSession | null { + return this.sessions.get(taskId)?.session ?? null; + } + + setWaiting(taskId: string) { + const entry = this.sessions.get(taskId); + if (entry) entry.waiting = true; + } + + clearWaiting(taskId: string) { + const entry = this.sessions.get(taskId); + if (entry) entry.waiting = false; + } + + getWaitingForUser( + userId: string, + ): { taskId: string; session: StreamingSession } | null { + for (const entry of this.sessions.values()) { + if (entry.userId === userId && entry.waiting) { + return { taskId: entry.taskId, session: entry.session }; + } + } + return null; + } + + sendToTask(taskId: string, text: string): boolean { + const entry = this.sessions.get(taskId); + if (!entry) return false; + entry.session.sendMessage(text); + return true; + } + + killAll() { + for (const entry of this.sessions.values()) { + entry.session.kill(); + } + this.sessions.clear(); + } +} From 59e3d82d44990e7b24a8163ba3fc01fc24448def Mon Sep 17 00:00:00 2001 From: Love Billingskog Nyberg Date: Mon, 2 Mar 2026 22:19:13 +0100 Subject: [PATCH 06/16] feat: wire streaming sessions into worker for interactive tasks Co-Authored-By: Claude Opus 4.6 --- src/worker.ts | 74 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 57 insertions(+), 17 deletions(-) diff --git a/src/worker.ts b/src/worker.ts index 718bad8..26e42bf 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -8,8 +8,9 @@ import type { TaskQueue, Task } from "./queue"; import type { RepoManager } from "./repos"; import type { SessionStore } from "./sessions"; import type { IncomingMessage, ChatAdapter, EventAdapter, IncomingEvent } from "./adapters/types"; -import type { AgentRunner, RunOptions, StatusEvent } from "./runner"; +import type { AgentRunner, RunOptions, RunResult, StatusEvent, StreamEvent } from "./runner"; import type { TraceStore } from "./trace"; +import type { SessionManager } from "./session-manager"; import type { DebouncedFunction } from "./adapters/debounce"; export interface WorkerDeps { @@ -25,6 +26,7 @@ export interface WorkerDeps { getRunnerOptsForRepo: (repo: string, baseOpts: RunOptions) => RunOptions; getRepoInfo: (repoName: string) => { url: string; defaultBranch: string } | null; trace: TraceStore; + sessionManager: SessionManager; } function findAdapterForUser(userId: string, adapters: ChatAdapter[]): ChatAdapter | undefined { @@ -121,23 +123,61 @@ async function processTask(task: Task, deps: WorkerDeps) { signal: abortController.signal, }); - const result = await taskRunner.run( - task.prompt, - workDir, - runOpts, - (event: StatusEvent) => { - if (event.kind === "tool") { - const last = statusLog.at(-1); - const summary = `Using ${event.tool}...`; - if (last !== summary) statusLog.push(summary); - deps.trace.append(task.id, "tool", summary, event.input.slice(0, 2000)); - } else { - statusLog.push(event.text.slice(0, 200)); - deps.trace.append(task.id, "status", event.text.slice(0, 200)); + const useStreaming = !isDiscuss && task.taskType !== "cron" && typeof (taskRunner as any).runStreaming === "function"; + + let result: RunResult; + + if (useStreaming) { + const session = (taskRunner as any).runStreaming( + task.prompt, + workDir, + runOpts, + (event: StreamEvent) => { + if (event.kind === "tool") { + const last = statusLog.at(-1); + const summary = `Using ${event.tool}...`; + if (last !== summary) statusLog.push(summary); + deps.trace.append(task.id, "tool", summary, event.input.slice(0, 2000)); + } else if (event.kind === "text") { + statusLog.push(event.text.slice(0, 200)); + deps.trace.append(task.id, "status", event.text.slice(0, 200)); + } else if (event.kind === "ask_user") { + deps.queue.setWaiting(task.id, "waiting_user"); + deps.sessionManager.setWaiting(task.id); + deps.trace.append(task.id, "lifecycle", "Waiting for user input", event.question); + const questionText = event.options.length > 0 + ? `${event.question}\n${event.options.map((o, i) => `${i + 1}. ${o.label}${o.description ? ` — ${o.description}` : ""}`).join("\n")}` + : event.question; + replyWithFallback(questionText, originalMsg, task.userId, deps.adapters); + } else if (event.kind === "result" && event.sessionId) { + deps.queue.setSessionId(task.id, event.sessionId); + } + originalMsg?.updateStatus(statusLog.slice(-5).join("\n")); } - originalMsg?.updateStatus(statusLog.slice(-5).join("\n")); - } - ); + ); + + deps.sessionManager.register(task.id, task.userId, session); + result = await session.done; + deps.sessionManager.unregister(task.id); + } else { + result = await taskRunner.run( + task.prompt, + workDir, + runOpts, + (event: StatusEvent) => { + if (event.kind === "tool") { + const last = statusLog.at(-1); + const summary = `Using ${event.tool}...`; + if (last !== summary) statusLog.push(summary); + deps.trace.append(task.id, "tool", summary, event.input.slice(0, 2000)); + } else { + statusLog.push(event.text.slice(0, 200)); + deps.trace.append(task.id, "status", event.text.slice(0, 200)); + } + originalMsg?.updateStatus(statusLog.slice(-5).join("\n")); + } + ); + } if (mcpConfigPath) { try { From 3feb9758fbcccf94fd2b6010c539ba0bde9cce8b Mon Sep 17 00:00:00 2001 From: Love Billingskog Nyberg Date: Mon, 2 Mar 2026 22:25:44 +0100 Subject: [PATCH 07/16] feat: route user replies to waiting streaming sessions When a user has a streaming session waiting for input (waiting_user status), intercept their next message before any parsing and route it directly to the session's stdin. This enables the conversational flow where Claude asks a follow-up question and the user replies naturally. Co-Authored-By: Claude Opus 4.6 --- src/handlers.test.ts | 49 ++++++++++++++++++++++++++++++++++++++++++++ src/handlers.ts | 14 +++++++++++++ 2 files changed, 63 insertions(+) diff --git a/src/handlers.test.ts b/src/handlers.test.ts index 134a07a..5c56a16 100644 --- a/src/handlers.test.ts +++ b/src/handlers.test.ts @@ -492,3 +492,52 @@ describe("multi-user mode isolation", () => { expect(tasksB[0].prompt).toContain("grumpy"); }); }); + +describe("reply routing to waiting sessions", () => { + it("routes reply to waiting session instead of creating new task", async () => { + const sentMessages: string[] = []; + const clearedTaskIds: string[] = []; + const mockSessionManager = { + getWaitingForUser: (userId: string) => userId === "slack:U123" + ? { taskId: "task-waiting", session: { sendMessage: (t: string) => sentMessages.push(t) } } + : null, + clearWaiting: (taskId: string) => { clearedTaskIds.push(taskId); }, + }; + + const deps = makeDeps({ + sessionManager: mockSessionManager as any, + }); + + const handler = createMessageHandler(deps); + const msg = makeMessage("yes, fix them all"); + await handler(msg); + + // Should have sent to session, not created a new task + expect(sentMessages).toEqual(["yes, fix them all"]); + expect(clearedTaskIds).toEqual(["task-waiting"]); + }); + + it("falls through to normal handling when no waiting session", async () => { + const mockSessionManager = { + getWaitingForUser: () => null, + clearWaiting: () => {}, + }; + + const deps = makeDeps({ + config: makeConfig({ + repos: {}, + users: { "slack:U123": { name: "testuser", repos: [] } }, + }), + sessionManager: mockSessionManager as any, + }); + + const handler = createMessageHandler(deps); + const msg = makeMessage("discuss something"); + await handler(msg); + + // Should have created a discuss task (normal flow) + const tasks = deps.queue.listByUser("slack:U123", 1); + expect(tasks.length).toBe(1); + expect(tasks[0].taskType).toBe("discuss"); + }); +}); diff --git a/src/handlers.ts b/src/handlers.ts index a9d8b00..f2690f6 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -11,6 +11,7 @@ import type { RepoRegistry } from "./repo-registry"; import type { IncomingMessage, EventAdapter, IncomingEvent } from "./adapters/types"; import type { AgentRunner } from "./runner"; import type { TraceStore } from "./trace"; +import type { SessionManager } from "./session-manager"; export interface HandlerDeps { config: Config; @@ -19,6 +20,7 @@ export interface HandlerDeps { schedules: ScheduleStore; repoRegistry: RepoRegistry; trace: TraceStore; + sessionManager?: SessionManager; pendingReplies: Map; pendingEventReplies: Map; runningProcesses: Map; @@ -482,6 +484,18 @@ export function createMessageHandler(deps: HandlerDeps): (msg: IncomingMessage) return async (msg: IncomingMessage) => { deps.sessions.addMessage(msg.userId, "user", msg.text); + // Route reply to waiting streaming session if one exists + if (deps.sessionManager) { + const waiting = deps.sessionManager.getWaitingForUser(msg.userId); + if (waiting) { + waiting.session.sendMessage(msg.text); + deps.sessionManager.clearWaiting(waiting.taskId); + deps.queue.resume(waiting.taskId); + deps.trace.append(waiting.taskId, "lifecycle", "User reply received", msg.text.slice(0, 200)); + return; + } + } + const parsed = parseMessage(msg.text); const handlers: Record Promise> = { From 6fff0ec04696260e755f5449c4644eac90a6f70b Mon Sep 17 00:00:00 2001 From: Love Billingskog Nyberg Date: Mon, 2 Mar 2026 22:27:51 +0100 Subject: [PATCH 08/16] feat: wire SessionManager into main application Co-Authored-By: Claude Opus 4.6 --- src/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/index.ts b/src/index.ts index aad9525..1509560 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ import { startCronLoop } from "./cron"; import { ScheduleStore } from "./schedules"; import { createMessageHandler, createEventHandler } from "./handlers"; import { createWorker } from "./worker"; +import { SessionManager } from "./session-manager"; const config = loadConfig(); const db = new Database(process.env.DB_PATH || "./ove.db"); @@ -32,6 +33,7 @@ const sessions = new SessionStore(db); const trace = new TraceStore(db); const schedules = new ScheduleStore(db); const repoRegistry = new RepoRegistry(db); +const sessionManager = new SessionManager(); repoRegistry.migrateFromConfig( Object.fromEntries( @@ -166,6 +168,7 @@ async function main() { getRunner, getRunnerForRepo, getRepoInfo, + sessionManager, }; const handleMessage = createMessageHandler(handlerDeps); @@ -219,6 +222,7 @@ async function main() { getRunnerOptsForRepo, getRepoInfo, trace, + sessionManager, }); worker.start(); @@ -244,6 +248,7 @@ async function main() { for (const ea of eventAdapters) { await ea.stop(); } + sessionManager.killAll(); process.exit(0); } From 30556db03c4ed82ff8373ae1c021efc341927ea7 Mon Sep 17 00:00:00 2001 From: Love Billingskog Nyberg Date: Mon, 2 Mar 2026 22:34:06 +0100 Subject: [PATCH 09/16] feat: add autonomous CI retry loop for cron tasks After a cron task completes successfully, check if it created a PR and whether CI passed. If CI failed, resume the session and retry up to 3 times, waiting for CI between attempts. Co-Authored-By: Claude Opus 4.6 --- src/worker.ts | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/src/worker.ts b/src/worker.ts index 26e42bf..e6b1884 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -63,6 +63,44 @@ async function replyWithFallback( } } +async function checkRecentPR( + repoSlug: string, + workDir: string +): Promise<{ prNumber: number; ciStatus: "passed" | "failed" | "pending"; ciDetails: string } | null> { + try { + const proc = Bun.spawn( + ["gh", "pr", "list", "--state", "open", "--author", "@me", "--limit", "1", "--json", "number,statusCheckRollup,headRefName"], + { cwd: workDir, stdout: "pipe", stderr: "pipe" } + ); + const text = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + if (exitCode !== 0) return null; + + const prs = JSON.parse(text); + if (prs.length === 0) return null; + + const pr = prs[0]; + const checks = pr.statusCheckRollup || []; + const failed = checks.filter((c: any) => c.conclusion === "FAILURE" || c.conclusion === "ERROR"); + const pending = checks.filter((c: any) => !c.conclusion || c.conclusion === "PENDING"); + + let ciStatus: "passed" | "failed" | "pending"; + let ciDetails = ""; + if (failed.length > 0) { + ciStatus = "failed"; + ciDetails = failed.map((c: any) => `${c.name}: ${c.conclusion}`).join(", "); + } else if (pending.length > 0) { + ciStatus = "pending"; + } else { + ciStatus = "passed"; + } + + return { prNumber: pr.number, ciStatus, ciDetails }; + } catch { + return null; + } +} + async function processTask(task: Task, deps: WorkerDeps) { const isCreateProject = task.taskType === "create-project"; const isDiscuss = task.taskType === "discuss"; @@ -224,6 +262,72 @@ async function processTask(task: Task, deps: WorkerDeps) { task.id, "lifecycle", `Task ${outcome} in ${elapsed}ms`, result.success ? undefined : result.output.slice(0, 2000), ); + + // Autonomous CI retry for cron tasks + if (task.taskType === "cron" && result.success && !skipRepoSetup) { + const MAX_CI_RETRIES = 3; + + for (let retry = 0; retry < MAX_CI_RETRIES; retry++) { + // Wait for CI to start + await Bun.sleep(30_000); + + const prStatus = await checkRecentPR(task.repo, workDir); + if (!prStatus) break; // No PR found, nothing to check + if (prStatus.ciStatus === "passed") break; // CI passed, done + + if (prStatus.ciStatus === "pending") { + // Wait longer for pending CI + await Bun.sleep(60_000); + const recheck = await checkRecentPR(task.repo, workDir); + if (!recheck || recheck.ciStatus !== "failed") break; + // Update prStatus for the retry prompt + Object.assign(prStatus, recheck); + } + + // CI failed — retry with resume + logger.info("cron task CI failed, retrying", { + taskId: task.id, + retry: retry + 1, + pr: prStatus.prNumber, + details: prStatus.ciDetails, + }); + deps.trace.append(task.id, "lifecycle", `CI retry ${retry + 1}/${MAX_CI_RETRIES}`, prStatus.ciDetails); + + const retryPrompt = `CI failed on PR #${prStatus.prNumber}. Failures: ${prStatus.ciDetails}. Fix the issues and push again.`; + const currentTask = deps.queue.get(task.id); + const retryOpts: RunOptions = { + ...runOpts, + resumeSessionId: currentTask?.sessionId ?? undefined, + }; + + const retryResult = await taskRunner.run(retryPrompt, workDir, retryOpts); + + if (!retryResult.success) { + logger.error("cron CI retry failed", { taskId: task.id, retry: retry + 1 }); + deps.trace.append(task.id, "lifecycle", `CI retry ${retry + 1} failed`, retryResult.output.slice(0, 500)); + break; + } + + // Store session ID from retry if available + if (retryResult.sessionId) { + deps.queue.setSessionId(task.id, retryResult.sessionId); + } + + deps.queue.complete(task.id, retryResult.output); + await replyWithFallback( + `[Scheduled: ${task.repo}] CI retry ${retry + 1}: ${retryResult.output.slice(0, 500)}`, + originalMsg, + task.userId, + deps.adapters, + ); + deps.trace.append(task.id, "lifecycle", `CI retry ${retry + 1} completed`); + + // Check if this retry fixed CI + await Bun.sleep(30_000); + const postRetry = await checkRecentPR(task.repo, workDir); + if (!postRetry || postRetry.ciStatus !== "failed") break; + } + } } finally { if (!skipRepoSetup) { await deps.repos.removeWorktree(task.repo, task.id).catch((err) => { From 73dd5e8b87cdda2528c7dad874270e1f57137e02 Mon Sep 17 00:00:00 2001 From: Love Billingskog Nyberg Date: Mon, 2 Mar 2026 22:38:35 +0100 Subject: [PATCH 10/16] test: add coverage for streaming sessions, reply routing, and queue states Co-Authored-By: Claude Opus 4.6 --- src/handlers.test.ts | 50 ++++++++++++++++++++++++++++++++++++++ src/queue.test.ts | 17 +++++++++++++ src/runners/claude.test.ts | 42 ++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+) diff --git a/src/handlers.test.ts b/src/handlers.test.ts index 5c56a16..84d9eaf 100644 --- a/src/handlers.test.ts +++ b/src/handlers.test.ts @@ -517,6 +517,56 @@ describe("reply routing to waiting sessions", () => { expect(clearedTaskIds).toEqual(["task-waiting"]); }); + it("resumes the queue task when routing reply to waiting session", async () => { + const mockSessionManager = { + getWaitingForUser: (userId: string) => userId === "slack:U123" + ? { taskId: "task-waiting", session: { sendMessage: () => {} } } + : null, + clearWaiting: () => {}, + }; + + const deps = makeDeps({ + sessionManager: mockSessionManager as any, + }); + + // Create a task in waiting_user state + const id = deps.queue.enqueue({ userId: "slack:U123", repo: "my-app", prompt: "original" }); + deps.queue.dequeue(); // running + + // Override the mock to return the real task ID + (mockSessionManager as any).getWaitingForUser = (userId: string) => userId === "slack:U123" + ? { taskId: id, session: { sendMessage: () => {} } } + : null; + + deps.queue.setWaiting(id, "waiting_user"); + expect(deps.queue.get(id)?.status).toBe("waiting_user"); + + const handler = createMessageHandler(deps); + const msg = makeMessage("yes do it"); + await handler(msg); + + // Queue task should be resumed (back to running) + expect(deps.queue.get(id)?.status).toBe("running"); + }); + + it("adds user message to session history even when routing to waiting session", async () => { + const mockSessionManager = { + getWaitingForUser: () => ({ taskId: "t1", session: { sendMessage: () => {} } }), + clearWaiting: () => {}, + }; + + const deps = makeDeps({ + sessionManager: mockSessionManager as any, + }); + + const handler = createMessageHandler(deps); + const msg = makeMessage("my reply"); + await handler(msg); + + const history = deps.sessions.getHistory("slack:U123"); + expect(history.some(h => h.content === "my reply")).toBe(true); + }); + it("falls through to normal handling when no waiting session", async () => { const mockSessionManager = { getWaitingForUser: () => null, diff --git a/src/queue.test.ts b/src/queue.test.ts index 0c2f588..cd07b35 100644 --- a/src/queue.test.ts +++ b/src/queue.test.ts @@ -344,6 +344,23 @@ describe("TaskQueue", () => { }); }); + it("cancel works on waiting_user tasks", () => { + const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); + queue.dequeue(); + queue.setWaiting(id, "waiting_user"); + const cancelled = queue.cancel(id); + expect(cancelled).toBe(true); + expect(queue.get(id)?.status).toBe("failed"); + }); + + it("stats counts waiting_user tasks", () => { + const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); + queue.dequeue(); + queue.setWaiting(id, "waiting_user"); + const stats = queue.stats(); + expect(stats.waiting).toBe(1); + }); + describe("sessionId tracking", () => { it("stores sessionId via setSessionId", () => { const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); diff --git a/src/runners/claude.test.ts b/src/runners/claude.test.ts index 4c2dd51..3f9b263 100644 --- a/src/runners/claude.test.ts +++ b/src/runners/claude.test.ts @@ -123,3 +123,45 @@ describe("runStreaming", () => { }); }); +describe("buildStreamingArgs details", () => { + const runner = new ClaudeRunner(); + + it("does not include --disallowed-tools at all", () => { + const args = runner.buildStreamingArgs("test", { maxTurns: 10 }); + expect(args).not.toContain("--disallowed-tools"); + }); + + it("includes --input-format stream-json", () => { + const args = runner.buildStreamingArgs("test", { maxTurns: 10 }); + const idx = args.indexOf("--input-format"); + expect(idx).toBeGreaterThan(-1); + expect(args[idx + 1]).toBe("stream-json"); + }); + + it("includes --dangerously-skip-permissions", () => { + const args = runner.buildStreamingArgs("test", { maxTurns: 10 }); + expect(args).toContain("--dangerously-skip-permissions"); + }); + + it("includes MCP config when provided", () => { + const args = runner.buildStreamingArgs("test", { maxTurns: 10, mcpConfigPath: "/tmp/mcp.json" }); + expect(args).toContain("--mcp-config"); + expect(args).toContain("/tmp/mcp.json"); + }); +}); + +describe("buildArgs with resume", () => { + const runner = new ClaudeRunner(); + + it("regular buildArgs still disallows AskUserQuestion", () => { + const args = runner.buildArgs("test", { maxTurns: 10 }); + expect(args).toContain("--disallowed-tools"); + expect(args).toContain("AskUserQuestion"); + }); + + it("regular buildArgs does NOT include --input-format", () => { + const args = runner.buildArgs("test", { maxTurns: 10 }); + expect(args).not.toContain("--input-format"); + }); +}); + From d08f8dad840ff7c9842380d48c6186fa5cb5f1ff Mon Sep 17 00:00:00 2001 From: Love Billingskog Nyberg Date: Mon, 2 Mar 2026 22:54:07 +0100 Subject: [PATCH 11/16] fix: only block dequeue for waiting_user repos, allow parallel running Reconcile PR #20 (parallel tasks) with waiting_user blocking. Only block dequeuing from repos that have a waiting_user task (live session waiting for input), not from repos with running tasks. Co-Authored-By: Claude Opus 4.6 --- src/queue.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/queue.ts b/src/queue.ts index d8f64f9..375fe32 100644 --- a/src/queue.ts +++ b/src/queue.ts @@ -85,7 +85,7 @@ export class TaskQueue { .query( `SELECT * FROM tasks WHERE status = 'pending' - AND repo NOT IN (SELECT repo FROM tasks WHERE status IN ('running', 'waiting_user')) + AND repo NOT IN (SELECT repo FROM tasks WHERE status = 'waiting_user') ORDER BY priority DESC, created_at ASC, rowid ASC LIMIT 1` ) From 47fd06ba8ecfcf4b6df72888659f1b78b10506c9 Mon Sep 17 00:00:00 2001 From: Love Billingskog Nyberg Date: Tue, 3 Mar 2026 08:44:43 +0100 Subject: [PATCH 12/16] fix: address code review findings for long-lived sessions - Show waiting_user tasks in handleListTasks display - Add updateResult() to queue for CI retry updates (avoids overwriting completed_at on already-completed tasks) - Wrap sendMessage stdin write in try/catch for closed pipe safety - Simplify setWaiting() signature (remove redundant status param) - Add waiting_user to repoBreakdown in metrics() Co-Authored-By: Claude Opus 4.6 --- src/handlers.test.ts | 2 +- src/handlers.ts | 7 +++++++ src/queue.test.ts | 16 ++++++++-------- src/queue.ts | 20 ++++++++++++++------ src/runners/claude.ts | 8 ++++++-- src/worker.ts | 4 ++-- 6 files changed, 38 insertions(+), 19 deletions(-) diff --git a/src/handlers.test.ts b/src/handlers.test.ts index 84d9eaf..8d0471e 100644 --- a/src/handlers.test.ts +++ b/src/handlers.test.ts @@ -538,7 +538,7 @@ describe("reply routing to waiting sessions", () => { ? { taskId: id, session: { sendMessage: () => {} } } : null; - deps.queue.setWaiting(id, "waiting_user"); + deps.queue.setWaiting(id); expect(deps.queue.get(id)?.status).toBe("waiting_user"); const handler = createMessageHandler(deps); diff --git a/src/handlers.ts b/src/handlers.ts index f2690f6..c82d7db 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -237,6 +237,7 @@ async function handleListTasks(msg: IncomingMessage, deps: HandlerDeps) { } const running = tasks.filter((t) => t.status === "running"); const pending = tasks.filter((t) => t.status === "pending"); + const waiting = tasks.filter((t) => t.status === "waiting_user"); const lines: string[] = []; if (running.length > 0) { lines.push("Running:"); @@ -244,6 +245,12 @@ async function handleListTasks(msg: IncomingMessage, deps: HandlerDeps) { lines.push(` ${t.id.slice(0, 7)} — "${t.prompt.slice(0, 60)}" on ${t.repo} (${formatDuration(t.createdAt)})`); } } + if (waiting.length > 0) { + lines.push("Waiting for input:"); + for (const t of waiting) { + lines.push(` ${t.id.slice(0, 7)} — "${t.prompt.slice(0, 60)}" on ${t.repo} (awaiting reply)`); + } + } if (pending.length > 0) { lines.push("Pending:"); for (const t of pending) { diff --git a/src/queue.test.ts b/src/queue.test.ts index cd07b35..d2f112b 100644 --- a/src/queue.test.ts +++ b/src/queue.test.ts @@ -290,7 +290,7 @@ describe("TaskQueue", () => { it("setWaiting transitions running task to waiting_user", () => { const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); queue.dequeue(); // running - queue.setWaiting(id, "waiting_user"); + queue.setWaiting(id); const task = queue.get(id); expect(task?.status).toBe("waiting_user"); }); @@ -298,7 +298,7 @@ describe("TaskQueue", () => { it("resume transitions waiting_user back to running", () => { const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); queue.dequeue(); - queue.setWaiting(id, "waiting_user"); + queue.setWaiting(id); queue.resume(id); const task = queue.get(id); expect(task?.status).toBe("running"); @@ -307,7 +307,7 @@ describe("TaskQueue", () => { it("dequeue skips waiting_user tasks", () => { const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); queue.dequeue(); - queue.setWaiting(id, "waiting_user"); + queue.setWaiting(id); queue.enqueue({ userId: "u1", repo: "r1", prompt: "test2" }); const next = queue.dequeue(); expect(next).toBeNull(); @@ -316,7 +316,7 @@ describe("TaskQueue", () => { it("resetStale also resets waiting_user tasks", () => { const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); queue.dequeue(); - queue.setWaiting(id, "waiting_user"); + queue.setWaiting(id); const count = queue.resetStale(); expect(count).toBe(1); expect(queue.get(id)?.status).toBe("failed"); @@ -325,7 +325,7 @@ describe("TaskQueue", () => { it("listActive includes waiting_user tasks", () => { const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); queue.dequeue(); - queue.setWaiting(id, "waiting_user"); + queue.setWaiting(id); const active = queue.listActive(); expect(active.some(t => t.id === id)).toBe(true); }); @@ -333,7 +333,7 @@ describe("TaskQueue", () => { it("getWaitingForUser returns waiting_user task for a user", () => { const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); queue.dequeue(); - queue.setWaiting(id, "waiting_user"); + queue.setWaiting(id); const waiting = queue.getWaitingForUser("u1"); expect(waiting?.id).toBe(id); }); @@ -347,7 +347,7 @@ describe("TaskQueue", () => { it("cancel works on waiting_user tasks", () => { const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); queue.dequeue(); - queue.setWaiting(id, "waiting_user"); + queue.setWaiting(id); const cancelled = queue.cancel(id); expect(cancelled).toBe(true); expect(queue.get(id)?.status).toBe("failed"); @@ -356,7 +356,7 @@ describe("TaskQueue", () => { it("stats counts waiting_user tasks", () => { const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); queue.dequeue(); - queue.setWaiting(id, "waiting_user"); + queue.setWaiting(id); const stats = queue.stats(); expect(stats.waiting).toBe(1); }); diff --git a/src/queue.ts b/src/queue.ts index 375fe32..a9e77cc 100644 --- a/src/queue.ts +++ b/src/queue.ts @@ -113,15 +113,22 @@ export class TaskQueue { ); } + updateResult(id: string, result: string) { + this.db.run( + `UPDATE tasks SET result = ?, completed_at = ? WHERE id = ?`, + [result, new Date().toISOString(), id] + ); + } + get(id: string): Task | null { const row = this.db.query(`SELECT * FROM tasks WHERE id = ?`).get(id) as TaskRow; return row ? this.rowToTask(row) : null; } - setWaiting(id: string, status: "waiting_user") { + setWaiting(id: string) { this.db.run( - `UPDATE tasks SET status = ? WHERE id = ? AND status = 'running'`, - [status, id] + `UPDATE tasks SET status = 'waiting_user' WHERE id = ? AND status = 'running'`, + [id] ); } @@ -171,7 +178,7 @@ export class TaskQueue { avgDurationByRepo: { repo: string; avgMs: number; count: number }[]; throughput: { lastHour: number; last24h: number }; errorRate: number; - repoBreakdown: { repo: string; pending: number; running: number; completed: number; failed: number }[]; + repoBreakdown: { repo: string; pending: number; running: number; completed: number; failed: number; waiting: number }[]; } { const counts = this.stats(); @@ -212,12 +219,13 @@ export class TaskQueue { COUNT(*) FILTER (WHERE status = 'pending') as pending, COUNT(*) FILTER (WHERE status = 'running') as running, COUNT(*) FILTER (WHERE status = 'completed') as completed, - COUNT(*) FILTER (WHERE status = 'failed') as failed + COUNT(*) FILTER (WHERE status = 'failed') as failed, + COUNT(*) FILTER (WHERE status = 'waiting_user') as waiting FROM tasks GROUP BY repo ORDER BY (COUNT(*) FILTER (WHERE status = 'running') + COUNT(*) FILTER (WHERE status = 'pending')) DESC, repo ASC` ) - .all() as { repo: string; pending: number; running: number; completed: number; failed: number }[]; + .all() as { repo: string; pending: number; running: number; completed: number; failed: number; waiting: number }[]; return { counts, diff --git a/src/runners/claude.ts b/src/runners/claude.ts index be60444..c88a1e6 100644 --- a/src/runners/claude.ts +++ b/src/runners/claude.ts @@ -233,8 +233,12 @@ export class ClaudeRunner implements AgentRunner { return { sendMessage(text: string) { - const msg = JSON.stringify({ type: "user_message", content: text }) + "\n"; - proc.stdin.write(encoder.encode(msg)); + try { + const msg = JSON.stringify({ type: "user_message", content: text }) + "\n"; + proc.stdin.write(encoder.encode(msg)); + } catch (err) { + logger.warn("failed to write to streaming session stdin", { error: String(err) }); + } }, kill() { proc.kill(); diff --git a/src/worker.ts b/src/worker.ts index e6b1884..356883c 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -180,7 +180,7 @@ async function processTask(task: Task, deps: WorkerDeps) { statusLog.push(event.text.slice(0, 200)); deps.trace.append(task.id, "status", event.text.slice(0, 200)); } else if (event.kind === "ask_user") { - deps.queue.setWaiting(task.id, "waiting_user"); + deps.queue.setWaiting(task.id); deps.sessionManager.setWaiting(task.id); deps.trace.append(task.id, "lifecycle", "Waiting for user input", event.question); const questionText = event.options.length > 0 @@ -313,7 +313,7 @@ async function processTask(task: Task, deps: WorkerDeps) { deps.queue.setSessionId(task.id, retryResult.sessionId); } - deps.queue.complete(task.id, retryResult.output); + deps.queue.updateResult(task.id, retryResult.output); await replyWithFallback( `[Scheduled: ${task.repo}] CI retry ${retry + 1}: ${retryResult.output.slice(0, 500)}`, originalMsg, From ca3f8d77ddaa2031974119e0899a5bbeec11a725 Mon Sep 17 00:00:00 2001 From: Love Billingskog Nyberg Date: Sat, 11 Apr 2026 00:17:43 +0200 Subject: [PATCH 13/16] fix: use stall-based timeout for discuss sessions instead of hard 3-min cap The discuss pool had a fixed 3-min RESPONSE_TIMEOUT that killed Claude mid-work on longer tasks. Replace with two-tier timeout: 3-min stall timeout (no pane activity) and 30-min active timeout (pane still changing). Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 1 + .../2026-03-02-long-lived-sessions-plan.md | 1047 +++++++++++++++++ public/trace.html | 17 +- src/adapters/http.ts | 5 +- src/adapters/slack.test.ts | 1 + src/adapters/slack.ts | 13 + src/discuss-pool.ts | 301 +++++ src/handlers.test.ts | 90 +- src/handlers.ts | 72 +- src/index.ts | 44 +- src/queue.ts | 19 +- src/runners/claude.test.ts | 16 +- src/runners/claude.ts | 35 +- src/worker.ts | 69 +- 14 files changed, 1635 insertions(+), 95 deletions(-) create mode 100644 docs/plans/2026-03-02-long-lived-sessions-plan.md create mode 100644 src/discuss-pool.ts diff --git a/.env.example b/.env.example index f7d546e..a30eb04 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,7 @@ WHATSAPP_ENABLED=false # Task tracing (stores per-task event timeline in SQLite) # OVE_TRACE=true + # Repos directory REPOS_DIR=./repos diff --git a/docs/plans/2026-03-02-long-lived-sessions-plan.md b/docs/plans/2026-03-02-long-lived-sessions-plan.md new file mode 100644 index 0000000..3bd7ee8 --- /dev/null +++ b/docs/plans/2026-03-02-long-lived-sessions-plan.md @@ -0,0 +1,1047 @@ +# Long-lived Streaming Sessions — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace fire-and-forget task execution with bidirectional streaming sessions so Ove can relay user questions, inject CI results, and iterate until goals are met. + +**Architecture:** The ClaudeRunner gains a new `runStreaming()` method that keeps stdin open via `--input-format stream-json`. A new SessionManager tracks live sessions and routes follow-up messages. The worker uses streaming for interactive tasks and resume-based chaining for autonomous (cron) tasks. Queue gets new `waiting_user` status for tasks blocked on user input. + +**Tech Stack:** Bun subprocess with stdin pipe, Claude CLI `--input-format stream-json` / `--resume`, bun:sqlite for state. + +--- + +### Task 1: Add `waiting_user` task status to queue + +**Files:** +- Modify: `src/queue.ts:16` (Task type) +- Modify: `src/queue.ts:82` (dequeue query) +- Modify: `src/queue.ts:103` (finish method) +- Modify: `src/queue.ts:209` (listActive query) +- Modify: `src/queue.ts:217` (cancel query) +- Modify: `src/queue.ts:237` (resetStale query) +- Test: `src/queue.test.ts` + +**Step 1: Write the failing test** + +Add to `src/queue.test.ts`: + +```typescript +describe("waiting_user status", () => { + it("setWaiting transitions running task to waiting_user", () => { + const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); + queue.dequeue(); // running + queue.setWaiting(id, "waiting_user"); + const task = queue.get(id); + expect(task?.status).toBe("waiting_user"); + }); + + it("resume transitions waiting_user back to running", () => { + const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); + queue.dequeue(); + queue.setWaiting(id, "waiting_user"); + queue.resume(id); + const task = queue.get(id); + expect(task?.status).toBe("running"); + }); + + it("dequeue skips waiting_user tasks", () => { + const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); + queue.dequeue(); + queue.setWaiting(id, "waiting_user"); + // Enqueue another for same repo + queue.enqueue({ userId: "u1", repo: "r1", prompt: "test2" }); + // Should NOT dequeue test2 because r1 has a waiting_user task (still "occupies" the repo) + const next = queue.dequeue(); + expect(next).toBeNull(); + }); + + it("resetStale also resets waiting_user tasks", () => { + const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); + queue.dequeue(); + queue.setWaiting(id, "waiting_user"); + const count = queue.resetStale(); + expect(count).toBe(1); + expect(queue.get(id)?.status).toBe("failed"); + }); + + it("listActive includes waiting_user tasks", () => { + const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); + queue.dequeue(); + queue.setWaiting(id, "waiting_user"); + const active = queue.listActive(); + expect(active.some(t => t.id === id)).toBe(true); + }); + + it("getWaitingForUser returns waiting_user task for a user", () => { + const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); + queue.dequeue(); + queue.setWaiting(id, "waiting_user"); + const waiting = queue.getWaitingForUser("u1"); + expect(waiting?.id).toBe(id); + }); + + it("getWaitingForUser returns null when no waiting tasks", () => { + const waiting = queue.getWaitingForUser("u1"); + expect(waiting).toBeNull(); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test src/queue.test.ts` +Expected: FAIL — `setWaiting`, `resume`, `getWaitingForUser` don't exist. + +**Step 3: Implement** + +In `src/queue.ts`: + +1. Update the `Task` status type (line 16): +```typescript +status: "pending" | "running" | "completed" | "failed" | "waiting_user"; +``` + +2. Add `setWaiting` method: +```typescript +setWaiting(id: string, status: "waiting_user") { + this.db.run( + `UPDATE tasks SET status = ? WHERE id = ? AND status = 'running'`, + [status, id] + ); +} +``` + +3. Add `resume` method: +```typescript +resume(id: string) { + this.db.run( + `UPDATE tasks SET status = 'running' WHERE id = ? AND status = 'waiting_user'`, + [id] + ); +} +``` + +4. Add `getWaitingForUser` method: +```typescript +getWaitingForUser(userId: string): Task | null { + const row = this.db + .query(`SELECT * FROM tasks WHERE user_id = ? AND status = 'waiting_user' ORDER BY created_at DESC LIMIT 1`) + .get(userId) as TaskRow; + return row ? this.rowToTask(row) : null; +} +``` + +5. Update `dequeue` query (line 82) — treat `waiting_user` like `running` for repo blocking: +```sql +AND repo NOT IN (SELECT repo FROM tasks WHERE status IN ('running', 'waiting_user')) +``` + +6. Update `listActive` query (line 209): +```sql +WHERE status IN ('running', 'pending', 'waiting_user') +``` + +7. Update `cancel` query (line 217): +```sql +WHERE id = ? AND status IN ('running', 'pending', 'waiting_user') +``` + +8. Update `resetStale` query (line 237): +```sql +WHERE status IN ('running', 'waiting_user') +``` + +9. Update `rowToTask` cast (line 248) to include `waiting_user` in the union type. + +10. Update `stats()` to count `waiting_user` — add it to the return type and query. + +**Step 4: Run test to verify it passes** + +Run: `bun test src/queue.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/queue.ts src/queue.test.ts +git commit -m "feat: add waiting_user task status to queue" +``` + +--- + +### Task 2: Add `sessionId` column to tasks table + +**Files:** +- Modify: `src/queue.ts` (schema + enqueue + rowToTask) +- Test: `src/queue.test.ts` + +We need to store the Claude session ID so we can resume sessions later. + +**Step 1: Write the failing test** + +```typescript +describe("sessionId tracking", () => { + it("stores sessionId via setSessionId", () => { + const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); + queue.setSessionId(id, "ses-abc-123"); + const task = queue.get(id); + expect(task?.sessionId).toBe("ses-abc-123"); + }); + + it("sessionId is null by default", () => { + const id = queue.enqueue({ userId: "u1", repo: "r1", prompt: "test" }); + const task = queue.get(id); + expect(task?.sessionId).toBeNull(); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test src/queue.test.ts` +Expected: FAIL — `sessionId` not on Task, `setSessionId` doesn't exist. + +**Step 3: Implement** + +1. Add `sessionId` to `Task` interface and `TaskRow`: +```typescript +// Task interface +sessionId: string | null; + +// TaskRow interface +session_id: string | null; +``` + +2. Add migration in constructor: +```typescript +if (!columns.some(c => c.name === "session_id")) { + this.db.run("ALTER TABLE tasks ADD COLUMN session_id TEXT"); +} +``` + +3. Add `setSessionId` method: +```typescript +setSessionId(id: string, sessionId: string) { + this.db.run(`UPDATE tasks SET session_id = ? WHERE id = ?`, [sessionId, id]); +} +``` + +4. Update `rowToTask` to include `sessionId: row.session_id || null`. + +**Step 4: Run test to verify it passes** + +Run: `bun test src/queue.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/queue.ts src/queue.test.ts +git commit -m "feat: add sessionId column to tasks for session resume" +``` + +--- + +### Task 3: Update `AgentRunner` interface and `RunOptions` for streaming + +**Files:** +- Modify: `src/runner.ts` +- Test: `src/runners/claude.test.ts` + +**Step 1: Write the failing test** + +Add to `src/runners/claude.test.ts`: + +```typescript +describe("streaming args", () => { + it("builds streaming args with input-format and without disallowed AskUserQuestion", () => { + const args = runner.buildStreamingArgs("fix the bug", { maxTurns: 25 }); + expect(args).toContain("--input-format"); + expect(args).toContain("stream-json"); + expect(args).not.toContain("AskUserQuestion"); + }); + + it("streaming args still include output-format stream-json", () => { + const args = runner.buildStreamingArgs("test", { maxTurns: 10 }); + expect(args).toContain("--output-format"); + expect(args).toContain("stream-json"); + }); + + it("includes resume flag when sessionId provided", () => { + const args = runner.buildStreamingArgs("test", { maxTurns: 10, resumeSessionId: "ses-123" }); + expect(args).toContain("--resume"); + expect(args).toContain("ses-123"); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test src/runners/claude.test.ts` +Expected: FAIL — `buildStreamingArgs` doesn't exist. + +**Step 3: Implement** + +1. Update `RunOptions` in `src/runner.ts`: +```typescript +export interface RunOptions { + maxTurns: number; + mcpConfigPath?: string; + model?: string; + signal?: AbortSignal; + resumeSessionId?: string; +} +``` + +2. Add `StreamEvent` type and `StreamingSession` interface to `src/runner.ts`: +```typescript +export type StreamEvent = + | { kind: "text"; text: string } + | { kind: "tool"; tool: string; input: string } + | { kind: "ask_user"; question: string; options: { label: string; description?: string }[] } + | { kind: "result"; text: string; sessionId?: string } + | { kind: "error"; text: string }; + +export interface StreamingSession { + sendMessage(text: string): void; + kill(): void; + readonly sessionId: string | null; + readonly done: Promise; +} +``` + +3. Add `buildStreamingArgs` to `ClaudeRunner` in `src/runners/claude.ts`: +```typescript +buildStreamingArgs(prompt: string, opts: RunOptions): string[] { + const args = [ + "-p", prompt, + "--input-format", "stream-json", + "--output-format", "stream-json", + "--verbose", + "--max-turns", String(opts.maxTurns), + "--dangerously-skip-permissions", + ]; + if (opts.mcpConfigPath) args.push("--mcp-config", opts.mcpConfigPath); + if (opts.resumeSessionId) args.push("--resume", opts.resumeSessionId); + return args; +} +``` + +Note: No `--disallowed-tools AskUserQuestion` — that's the key difference from `buildArgs`. + +**Step 4: Run test to verify it passes** + +Run: `bun test src/runners/claude.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/runner.ts src/runners/claude.ts src/runners/claude.test.ts +git commit -m "feat: add streaming args builder and StreamingSession interface" +``` + +--- + +### Task 4: Implement `runStreaming()` on ClaudeRunner + +**Files:** +- Modify: `src/runners/claude.ts` +- Test: `src/runners/claude.test.ts` + +This is the core new method. It spawns Claude with stdin pipe open and returns a `StreamingSession` handle. + +**Step 1: Write the failing test** + +```typescript +describe("runStreaming", () => { + it("method exists and returns a StreamingSession-like object", () => { + expect(typeof runner.runStreaming).toBe("function"); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test src/runners/claude.test.ts` +Expected: FAIL — `runStreaming` doesn't exist. + +**Step 3: Implement** + +Add `runStreaming` method to `ClaudeRunner`: + +```typescript +runStreaming( + prompt: string, + workDir: string, + opts: RunOptions, + onEvent?: (event: StreamEvent) => void, +): StreamingSession { + const args = this.buildStreamingArgs(prompt, opts); + const startTime = Date.now(); + logger.info("starting streaming claude task", { workDir, maxTurns: opts.maxTurns }); + + const proc = Bun.spawn([this.claudePath, ...args], { + cwd: workDir, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, CI: "1" }, + }); + + if (opts.signal) { + opts.signal.addEventListener("abort", () => proc.kill(), { once: true }); + } + + let sessionId: string | null = null; + let resultText: string | null = null; + const textBlocks: string[] = []; + + // Read stdout in background + const done = (async (): Promise => { + const decoder = new TextDecoder(); + const reader = proc.stdout.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split("\n").filter(Boolean); + for (const line of lines) { + try { + const msg = JSON.parse(line); + + // Capture session ID from init message + if (msg.type === "system" && msg.session_id) { + sessionId = msg.session_id; + } + + if (msg.type === "result" && msg.result) { + resultText = msg.result; + if (msg.session_id) sessionId = msg.session_id; + onEvent?.({ kind: "result", text: msg.result, sessionId: sessionId ?? undefined }); + } + + if (msg.type === "assistant" && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === "text") { + textBlocks.push(block.text); + onEvent?.({ kind: "text", text: block.text }); + } + if (block.type === "tool_use") { + if (block.name === "AskUserQuestion") { + const questions = block.input?.questions; + if (questions?.[0]) { + onEvent?.({ + kind: "ask_user", + question: questions[0].question, + options: questions[0].options || [], + }); + } + } else { + onEvent?.({ + kind: "tool", + tool: block.name, + input: summarizeToolInput(block.name, block.input), + }); + } + } + } + } + } catch {} + } + } + } finally { + reader.releaseLock(); + } + + const exitCode = await withTimeout(proc); + const durationMs = Date.now() - startTime; + + if (exitCode === "timeout") { + return { success: false, output: `Claude task timed out after ${TIMEOUT_MS / 60000} minutes`, durationMs }; + } + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + return { success: false, output: stderr || "Claude task failed", durationMs }; + } + + return { success: true, output: resultText || textBlocks.join("\n\n") || "Task completed (no output)", durationMs }; + })(); + + const encoder = new TextEncoder(); + + return { + sendMessage(text: string) { + const msg = JSON.stringify({ type: "user_message", content: text }) + "\n"; + proc.stdin.write(encoder.encode(msg)); + }, + kill() { + proc.kill(); + }, + get sessionId() { + return sessionId; + }, + done, + }; +} +``` + +**Step 4: Run test to verify it passes** + +Run: `bun test src/runners/claude.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/runners/claude.ts src/runners/claude.test.ts +git commit -m "feat: implement runStreaming with bidirectional stdin/stdout" +``` + +--- + +### Task 5: Create SessionManager + +**Files:** +- Create: `src/session-manager.ts` +- Test: `src/session-manager.test.ts` + +The SessionManager tracks live streaming sessions and provides methods to send messages to them. + +**Step 1: Write the failing test** + +Create `src/session-manager.test.ts`: + +```typescript +import { describe, it, expect } from "bun:test"; +import { SessionManager } from "./session-manager"; +import type { StreamingSession } from "./runner"; +import type { RunResult } from "./runner"; + +function mockSession(overrides?: Partial): StreamingSession { + const messages: string[] = []; + return { + sendMessage: (text: string) => messages.push(text), + kill: () => {}, + sessionId: "ses-test-123", + done: Promise.resolve({ success: true, output: "done", durationMs: 100 }), + _messages: messages, // for test inspection + ...overrides, + } as StreamingSession & { _messages: string[] }; +} + +describe("SessionManager", () => { + it("registers and retrieves a session by taskId", () => { + const mgr = new SessionManager(); + const session = mockSession(); + mgr.register("task-1", "user-1", session); + expect(mgr.getByTask("task-1")).toBe(session); + }); + + it("retrieves waiting session by userId", () => { + const mgr = new SessionManager(); + const session = mockSession(); + mgr.register("task-1", "user-1", session); + mgr.setWaiting("task-1"); + expect(mgr.getWaitingForUser("user-1")).toEqual({ taskId: "task-1", session }); + }); + + it("returns null when no waiting session for user", () => { + const mgr = new SessionManager(); + expect(mgr.getWaitingForUser("user-1")).toBeNull(); + }); + + it("sendToTask sends message to the session", () => { + const mgr = new SessionManager(); + const session = mockSession(); + mgr.register("task-1", "user-1", session); + mgr.sendToTask("task-1", "hello"); + expect((session as any)._messages).toEqual(["hello"]); + }); + + it("unregister removes the session", () => { + const mgr = new SessionManager(); + const session = mockSession(); + mgr.register("task-1", "user-1", session); + mgr.unregister("task-1"); + expect(mgr.getByTask("task-1")).toBeNull(); + }); + + it("clearWaiting removes waiting state but keeps session", () => { + const mgr = new SessionManager(); + const session = mockSession(); + mgr.register("task-1", "user-1", session); + mgr.setWaiting("task-1"); + mgr.clearWaiting("task-1"); + expect(mgr.getWaitingForUser("user-1")).toBeNull(); + expect(mgr.getByTask("task-1")).toBe(session); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test src/session-manager.test.ts` +Expected: FAIL — module doesn't exist. + +**Step 3: Implement** + +Create `src/session-manager.ts`: + +```typescript +import type { StreamingSession } from "./runner"; + +interface SessionEntry { + taskId: string; + userId: string; + session: StreamingSession; + waiting: boolean; +} + +export class SessionManager { + private sessions = new Map(); + + register(taskId: string, userId: string, session: StreamingSession) { + this.sessions.set(taskId, { taskId, userId, session, waiting: false }); + } + + unregister(taskId: string) { + this.sessions.delete(taskId); + } + + getByTask(taskId: string): StreamingSession | null { + return this.sessions.get(taskId)?.session ?? null; + } + + setWaiting(taskId: string) { + const entry = this.sessions.get(taskId); + if (entry) entry.waiting = true; + } + + clearWaiting(taskId: string) { + const entry = this.sessions.get(taskId); + if (entry) entry.waiting = false; + } + + getWaitingForUser(userId: string): { taskId: string; session: StreamingSession } | null { + for (const entry of this.sessions.values()) { + if (entry.userId === userId && entry.waiting) { + return { taskId: entry.taskId, session: entry.session }; + } + } + return null; + } + + sendToTask(taskId: string, text: string): boolean { + const entry = this.sessions.get(taskId); + if (!entry) return false; + entry.session.sendMessage(text); + return true; + } + + killAll() { + for (const entry of this.sessions.values()) { + entry.session.kill(); + } + this.sessions.clear(); + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `bun test src/session-manager.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/session-manager.ts src/session-manager.test.ts +git commit -m "feat: add SessionManager for tracking live streaming sessions" +``` + +--- + +### Task 6: Wire streaming sessions into worker + +**Files:** +- Modify: `src/worker.ts` +- Modify: `src/worker.ts` (WorkerDeps) +- Test: (integration — tested via existing worker tests + manual) + +This is the main integration task. The worker uses `runStreaming()` for interactive tasks and feeds `ask_user` events to the session manager. + +**Step 1: Update WorkerDeps** + +Add to `WorkerDeps` in `src/worker.ts`: + +```typescript +import { SessionManager } from "./session-manager"; +// Add to interface: +sessionManager: SessionManager; +``` + +**Step 2: Update processTask to use streaming for non-cron, non-discuss tasks** + +Replace the `taskRunner.run(...)` call in `processTask` with logic that: + +1. For **discuss tasks** and **cron tasks**: keep using `taskRunner.run()` (fire-and-forget). Discuss tasks are lightweight (5 turns). Cron tasks are autonomous. +2. For **repo-bound interactive tasks**: use `taskRunner.runStreaming()` if the runner has that method. + +The key change in `processTask` (around lines 124-140): + +```typescript +const isStreaming = !isDiscuss && task.taskType !== "cron" && typeof taskRunner.runStreaming === "function"; + +if (isStreaming) { + const session = (taskRunner as any).runStreaming( + task.prompt, workDir, runOpts, + (event: StreamEvent) => { + if (event.kind === "tool") { + const summary = `Using ${event.tool}...`; + if (statusLog.at(-1) !== summary) statusLog.push(summary); + deps.trace.append(task.id, "tool", summary, event.input.slice(0, 2000)); + } else if (event.kind === "text") { + statusLog.push(event.text.slice(0, 200)); + deps.trace.append(task.id, "status", event.text.slice(0, 200)); + } else if (event.kind === "ask_user") { + deps.queue.setWaiting(task.id, "waiting_user"); + deps.sessionManager.setWaiting(task.id); + deps.trace.append(task.id, "lifecycle", "Waiting for user input", event.question); + + const questionText = event.options.length > 0 + ? `${event.question}\n${event.options.map((o, i) => `${i + 1}. ${o.label}${o.description ? ` — ${o.description}` : ""}`).join("\n")}` + : event.question; + replyWithFallback(questionText, originalMsg, task.userId, deps.adapters); + } else if (event.kind === "result") { + if (event.sessionId) deps.queue.setSessionId(task.id, event.sessionId); + } + originalMsg?.updateStatus(statusLog.slice(-5).join("\n")); + } + ); + + deps.sessionManager.register(task.id, task.userId, session); + const result = await session.done; + deps.sessionManager.unregister(task.id); + + // ... handle result (same success/fail logic as current code) +} else { + // Current fire-and-forget path + const result = await taskRunner.run(...); + // ... existing handling +} +``` + +**Step 3: Commit** + +```bash +git add src/worker.ts +git commit -m "feat: wire streaming sessions into worker for interactive tasks" +``` + +--- + +### Task 7: Route user replies to waiting sessions in handlers + +**Files:** +- Modify: `src/handlers.ts` +- Modify: `src/handlers.ts` (HandlerDeps) +- Test: `src/handlers.test.ts` + +When a user sends a message and they have a `waiting_user` task, route the reply to the session instead of creating a new task. + +**Step 1: Write the failing test** + +Add to `src/handlers.test.ts`: + +```typescript +describe("reply routing to waiting sessions", () => { + it("routes reply to waiting session instead of creating new task", async () => { + const sentMessages: string[] = []; + const mockSessionManager = { + getWaitingForUser: (userId: string) => userId === "slack:U123" + ? { taskId: "task-waiting", session: { sendMessage: (t: string) => sentMessages.push(t) } } + : null, + clearWaiting: () => {}, + }; + + const deps = makeDeps({ + sessionManager: mockSessionManager as any, + }); + + // Simulate a waiting_user task + const id = deps.queue.enqueue({ userId: "slack:U123", repo: "my-app", prompt: "original" }); + deps.queue.dequeue(); // running + deps.queue.setWaiting(id, "waiting_user"); + + const handler = createMessageHandler(deps); + const msg = makeMessage("yes, fix them all"); + await handler(msg); + + // Should have sent to session, not created a new task + expect(sentMessages).toContain("yes, fix them all"); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test src/handlers.test.ts` +Expected: FAIL — `sessionManager` not in HandlerDeps. + +**Step 3: Implement** + +1. Add `sessionManager` to `HandlerDeps`: +```typescript +import type { SessionManager } from "./session-manager"; +// In HandlerDeps interface: +sessionManager?: SessionManager; +``` + +2. At the top of the message handler (in `createMessageHandler`, before parsing), add: + +```typescript +// Check if user has a waiting session — route reply there instead of new task +if (deps.sessionManager) { + const waiting = deps.sessionManager.getWaitingForUser(msg.userId); + if (waiting) { + waiting.session.sendMessage(msg.text); + deps.sessionManager.clearWaiting(waiting.taskId); + deps.queue.resume(waiting.taskId); + deps.sessions.addMessage(msg.userId, "user", msg.text); + deps.trace.append(waiting.taskId, "lifecycle", "User reply received", msg.text.slice(0, 200)); + return; + } +} +``` + +This goes right after `deps.sessions.addMessage(msg.userId, "user", msg.text)` (line 485) and before `const parsed = parseMessage(msg.text)` (line 487). Move the addMessage call after the check to avoid double-adding. + +**Step 4: Run test to verify it passes** + +Run: `bun test src/handlers.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/handlers.ts src/handlers.test.ts +git commit -m "feat: route user replies to waiting streaming sessions" +``` + +--- + +### Task 8: Wire SessionManager into index.ts + +**Files:** +- Modify: `src/index.ts` + +**Step 1: Implement** + +1. Import SessionManager: +```typescript +import { SessionManager } from "./session-manager"; +``` + +2. Create instance after other stores: +```typescript +const sessionManager = new SessionManager(); +``` + +3. Add to `handlerDeps`: +```typescript +sessionManager, +``` + +4. Add to worker deps: +```typescript +sessionManager, +``` + +5. In `shutdown()`, add: +```typescript +sessionManager.killAll(); +``` + +**Step 2: Run all tests** + +Run: `bun test` +Expected: All tests PASS. The sessionManager is optional in HandlerDeps so existing tests work without it. + +**Step 3: Commit** + +```bash +git add src/index.ts +git commit -m "feat: wire SessionManager into main application" +``` + +--- + +### Task 9: Autonomous completion loop for cron tasks (resume-based) + +**Files:** +- Modify: `src/worker.ts` +- Test: (integration) + +For cron tasks, after Claude exits, check if a PR was created and if CI passed. If CI failed, resume the session with `--resume` and inject the failure context. + +**Step 1: Add CI check helper** + +Create a helper function in `src/worker.ts`: + +```typescript +async function checkRecentPR(repo: string, workDir: string): Promise<{ prNumber: number; ciStatus: string; ciDetails: string } | null> { + try { + const proc = Bun.spawn( + ["gh", "pr", "list", "--repo", repo, "--state", "open", "--author", "@me", "--limit", "1", "--json", "number,statusCheckRollup,headRefName"], + { cwd: workDir, stdout: "pipe", stderr: "pipe" } + ); + const text = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + if (exitCode !== 0) return null; + + const prs = JSON.parse(text); + if (prs.length === 0) return null; + + const pr = prs[0]; + const checks = pr.statusCheckRollup || []; + const failed = checks.filter((c: any) => c.conclusion === "FAILURE" || c.conclusion === "ERROR"); + const pending = checks.filter((c: any) => !c.conclusion || c.conclusion === "PENDING"); + + let ciStatus: string; + let ciDetails = ""; + if (failed.length > 0) { + ciStatus = "failed"; + ciDetails = failed.map((c: any) => `${c.name}: ${c.conclusion}`).join(", "); + } else if (pending.length > 0) { + ciStatus = "pending"; + } else { + ciStatus = "passed"; + } + + return { prNumber: pr.number, ciStatus, ciDetails }; + } catch { + return null; + } +} +``` + +**Step 2: Add retry loop for cron tasks** + +After the main `result` handling in `processTask`, add a retry loop for cron tasks: + +```typescript +// After initial result handling for cron tasks +if (task.taskType === "cron" && result.success) { + const repoUrl = repoInfo ? `${repoInfo.url.replace("git@github.com:", "").replace(".git", "")}` : ""; + const maxRetries = 3; + + for (let retry = 0; retry < maxRetries; retry++) { + // Wait for CI to start + await Bun.sleep(30_000); + + const prStatus = await checkRecentPR(repoUrl, workDir); + if (!prStatus || prStatus.ciStatus === "passed") break; + if (prStatus.ciStatus === "pending") { + // Wait longer for pending CI + await Bun.sleep(60_000); + const recheck = await checkRecentPR(repoUrl, workDir); + if (!recheck || recheck.ciStatus !== "failed") break; + } + + // CI failed — resume and fix + logger.info("cron task CI failed, retrying", { taskId: task.id, retry: retry + 1, pr: prStatus.prNumber }); + deps.trace.append(task.id, "lifecycle", `CI retry ${retry + 1}/${maxRetries}`, prStatus.ciDetails); + + const retryPrompt = `CI failed on PR #${prStatus.prNumber}. Failures: ${prStatus.ciDetails}. Fix the issues and push again.`; + const sessionId = deps.queue.get(task.id)?.sessionId; + const retryOpts = { ...runOpts, resumeSessionId: sessionId ?? undefined }; + + const retryResult = await taskRunner.run(retryPrompt, workDir, retryOpts); + if (!retryResult.success) break; + + // Update stored result + deps.queue.complete(task.id, retryResult.output); + await replyWithFallback( + `[Scheduled: ${task.repo}] CI retry ${retry + 1}: ${retryResult.output.slice(0, 500)}`, + originalMsg, task.userId, deps.adapters + ); + } +} +``` + +**Step 3: Update ClaudeRunner.buildArgs to support resumeSessionId** + +In `src/runners/claude.ts`, update `buildArgs`: +```typescript +buildArgs(prompt: string, opts: RunOptions): string[] { + const args = ["-p", prompt, "--output-format", "stream-json", "--verbose", "--max-turns", String(opts.maxTurns), "--dangerously-skip-permissions", "--disallowed-tools", "AskUserQuestion"]; + if (opts.mcpConfigPath) args.push("--mcp-config", opts.mcpConfigPath); + if (opts.resumeSessionId) args.push("--resume", opts.resumeSessionId); + return args; +} +``` + +**Step 4: Also capture sessionId from fire-and-forget runs** + +In the existing `run()` method, extract session_id from result messages: +```typescript +// Inside the line parsing loop, add: +if (msg.type === "result" && msg.session_id) { + // Store for potential resume + resultSessionId = msg.session_id; +} +``` + +Return it as part of RunResult (add `sessionId?: string` to RunResult interface). + +**Step 5: Run all tests** + +Run: `bun test` +Expected: All PASS. + +**Step 6: Commit** + +```bash +git add src/worker.ts src/runners/claude.ts src/runner.ts +git commit -m "feat: add autonomous CI retry loop for cron tasks" +``` + +--- + +### Task 10: Final integration test and cleanup + +**Files:** +- Test: run full suite +- Modify: any fixes needed + +**Step 1: Run full test suite** + +Run: `bun test` +Expected: All tests pass. + +**Step 2: Run type check** + +Run: `bunx tsc --noEmit` +Expected: No type errors. + +**Step 3: Manual smoke test** + +Start Ove locally and verify: +- Discuss tasks still work (fire-and-forget, 5 turns) +- Repo-bound tasks use streaming (check logs for "starting streaming claude task") +- The `--input-format stream-json` flag appears in process args + +**Step 4: Final commit** + +```bash +git add -A +git commit -m "chore: integration fixes for streaming sessions" +``` + +**Step 5: Create PR** + +```bash +gh pr create --title "feat: long-lived streaming sessions (#21)" --body "..." +``` diff --git a/public/trace.html b/public/trace.html index 9810688..56cb9a0 100644 --- a/public/trace.html +++ b/public/trace.html @@ -221,9 +221,10 @@ } .badge-pending { background: var(--amber-dim); color: var(--amber); } - .badge-running { background: var(--cyan-dim); color: var(--cyan); animation: pulse 2s infinite; } - .badge-completed { background: var(--green-dim); color: var(--green); } - .badge-failed { background: var(--red-dim); color: var(--red); } + .badge-running { background: var(--cyan-dim); color: var(--cyan); animation: pulse 2s infinite; } + .badge-waiting_user { background: var(--amber-dim); color: var(--amber); animation: pulse 2s infinite; } + .badge-completed { background: var(--green-dim); color: var(--green); } + .badge-failed { background: var(--red-dim); color: var(--red); } .badge-priority-2 { background: var(--red-dim); color: var(--red); } .badge-priority-1 { background: var(--amber-dim); color: var(--amber); } @@ -623,6 +624,7 @@

ove trace

+ @@ -862,6 +864,9 @@

ove trace

["priority", priorityLabel], ["created", fmtTimeFull(task.createdAt)], ]; + if (task.sessionId) { + items.push(["session", task.sessionId.slice(0, 12) + "..."]); + } if (task.completedAt) { items.push(["done", fmtTimeFull(task.completedAt)]); var ms = new Date(task.completedAt).getTime() - new Date(task.createdAt).getTime(); @@ -897,7 +902,7 @@

ove trace

} // Cancel button for running/pending tasks - if (task.status === "running" || task.status === "pending") { + if (task.status === "running" || task.status === "pending" || task.status === "waiting_user") { var cancelRow = el("div", "task-context-row"); cancelRow.style.marginTop = "0.5rem"; var cancelBtn = el("button", "cancel-btn", "Cancel task"); @@ -1031,9 +1036,9 @@

ove trace

if (selectedTaskId) { var task = allTasks.find(function (t) { return t.id === selectedTaskId; }); if (task) { - var wasActive = lastSelectedStatus === "running" || lastSelectedStatus === "pending"; + var wasActive = lastSelectedStatus === "running" || lastSelectedStatus === "pending" || lastSelectedStatus === "waiting_user"; var justFinished = wasActive && (task.status === "completed" || task.status === "failed"); - var stillActive = task.status === "running" || task.status === "pending"; + var stillActive = task.status === "running" || task.status === "pending" || task.status === "waiting_user"; if (stillActive || justFinished) { await selectTask(selectedTaskId); } diff --git a/src/adapters/http.ts b/src/adapters/http.ts index f03fad7..97f1ed2 100644 --- a/src/adapters/http.ts +++ b/src/adapters/http.ts @@ -323,6 +323,7 @@ export class HttpApiAdapter implements EventAdapter { prompt: t.prompt, status: t.status, priority: t.priority, + sessionId: t.sessionId || null, result: t.result && t.result.length > 300 ? t.result.slice(0, 300) + "..." : t.result, createdAt: t.createdAt, completedAt: t.completedAt, @@ -340,11 +341,11 @@ export class HttpApiAdapter implements EventAdapter { if (!task) { return Response.json({ error: "Task not found" }, { status: 404 }); } - if (task.status !== "running" && task.status !== "pending") { + if (task.status !== "running" && task.status !== "pending" && task.status !== "waiting_user") { return Response.json({ error: "Task is not cancellable", status: task.status }, { status: 409 }); } - if (task.status === "running") { + if (task.status === "running" || task.status === "waiting_user") { self.runningProcesses?.get(taskId)?.abort.abort(); } diff --git a/src/adapters/slack.test.ts b/src/adapters/slack.test.ts index f9ce107..87000ae 100644 --- a/src/adapters/slack.test.ts +++ b/src/adapters/slack.test.ts @@ -26,6 +26,7 @@ mock.module("@slack/bolt", () => ({ event(name: string, handler: Function) { if (name === "app_mention") appMentionHandler = handler; } + error(_handler: Function) {} start() { mockStart(); return Promise.resolve(); diff --git a/src/adapters/slack.ts b/src/adapters/slack.ts index aa2a90c..57f5ed2 100644 --- a/src/adapters/slack.ts +++ b/src/adapters/slack.ts @@ -16,6 +16,19 @@ export class SlackAdapter implements ChatAdapter { appToken: process.env.SLACK_APP_TOKEN, socketMode: true, }); + + // Increase ping timeout from default 5s to 30s. + // The 5s default trips during heavy streaming work and triggers reconnects + // that correlate with spurious SIGTERM signals (likely a Bun/ws interaction). + const receiver = (this.app as any).receiver; + if (receiver?.client) { + receiver.client.clientPingTimeoutMS = 30_000; + logger.info("slack ping timeout increased to 30s"); + } + + this.app.error(async (error) => { + logger.warn("slack app error (non-fatal)", { error: String(error) }); + }); } private buildMessage( diff --git a/src/discuss-pool.ts b/src/discuss-pool.ts new file mode 100644 index 0000000..9bb3ffe --- /dev/null +++ b/src/discuss-pool.ts @@ -0,0 +1,301 @@ +import { logger } from "./logger"; + +const IDLE_TIMEOUT = 3 * 60 * 60_000; // 3 hours idle → kill session +const STALL_TIMEOUT = 180_000; // 3 min no pane changes → assume stuck +const ACTIVE_TIMEOUT = 30 * 60_000; // 30 min max while Claude is still working +const POLL_INTERVAL = 500; // poll every 500ms +const POST_PROMPT_DELAY = 2000; // wait after prompt detected before sending keys + +interface TmuxSession { + sessionName: string; + userId: string; + workDir: string; + createdAt: number; + ready: boolean; +} + +/** + * Manages persistent Claude CLI sessions via tmux for fast follow-up messages. + * Each user gets a tmux session running `claude` interactively. + * Messages are sent via `tmux send-keys`, responses read via `tmux capture-pane`. + */ +export class DiscussPool { + private sessions = new Map(); + private idleTimers = new Map>(); + + async send( + userId: string, + prompt: string, + workDir: string, + model?: string, + ): Promise<{ output: string; durationMs: number }> { + const start = Date.now(); + + let session = this.sessions.get(userId); + + // Check if tmux session is still alive + if (session && !(await this.isAlive(session.sessionName))) { + logger.warn("discuss tmux session died", { userId, sessionName: session.sessionName }); + this.cleanup(userId); + session = undefined; + } + + if (!session) { + session = await this.spawn(userId, workDir, model); + } + + this.resetIdleTimer(userId); + + const output = await this.sendAndWait(session, prompt); + return { output, durationMs: Date.now() - start }; + } + + private async spawn(userId: string, workDir: string, model?: string): Promise { + const sessionName = `ove-discuss-${userId.replace(/[^a-zA-Z0-9]/g, "-")}`; + + // Kill any existing tmux session with this name + await this.tmux(["kill-session", "-t", sessionName]).catch(() => {}); + + const modelFlag = model ? `--model ${model}` : ""; + const cmd = `cd ${this.shellEscape(workDir)} && unset CLAUDECODE ANTHROPIC_API_KEY && claude --dangerously-skip-permissions ${modelFlag}`.trim(); + + logger.info("spawning discuss tmux session", { userId, sessionName, workDir, model }); + + const result = await this.tmux([ + "new-session", "-d", + "-s", sessionName, + "-x", "200", "-y", "50", + cmd, + ], { TERM: "xterm" }); + + if (!result.success) { + throw new Error(`Failed to create tmux session: ${result.stderr}`); + } + + const session: TmuxSession = { + sessionName, + userId, + workDir, + createdAt: Date.now(), + ready: false, + }; + + // Wait for the REPL prompt to appear, then extra delay for TUI to be fully ready + await this.waitForPrompt(session, 30_000); + await Bun.sleep(POST_PROMPT_DELAY); + session.ready = true; + + this.sessions.set(userId, session); + this.resetIdleTimer(userId); + + logger.info("discuss tmux session ready", { userId, sessionName, startupMs: Date.now() - session.createdAt }); + return session; + } + + private async sendAndWait(session: TmuxSession, prompt: string): Promise { + // Capture pane before sending to know what's already there + const beforePane = await this.capturePane(session.sessionName); + + // Send text literally (-l avoids bracket paste), then Enter to submit + const singleLine = prompt.replace(/\n/g, " ").trim(); + await this.tmux(["send-keys", "-l", "-t", session.sessionName, singleLine]); + + // Brief pause to let TUI render the typed text before submitting + await Bun.sleep(200); + await this.tmux(["send-keys", "-t", session.sessionName, "Enter"]); + + // Verify the text was actually typed by checking the pane changed + await Bun.sleep(500); + const afterSend = await this.capturePane(session.sessionName); + if (afterSend === beforePane) { + logger.warn("discuss send-keys may have been lost, retrying", { userId: session.userId }); + // Retry: the TUI might not have been ready + await Bun.sleep(1000); + await this.tmux(["send-keys", "-l", "-t", session.sessionName, singleLine]); + await Bun.sleep(200); + await this.tmux(["send-keys", "-t", session.sessionName, "Enter"]); + } + + // Poll for response — two timeout modes: + // 1. STALL_TIMEOUT: no pane changes for 3 min → assume stuck + // 2. ACTIVE_TIMEOUT: 30 min total even if Claude is still working + const startWait = Date.now(); + let lastPane = ""; + let lastChangeAt = Date.now(); + let stableCount = 0; + + while (true) { + const elapsed = Date.now() - startWait; + const stalledFor = Date.now() - lastChangeAt; + + if (elapsed >= ACTIVE_TIMEOUT) { + logger.warn("discuss active timeout", { userId: session.userId, elapsedMs: elapsed }); + break; + } + if (stalledFor >= STALL_TIMEOUT) { + logger.warn("discuss stall timeout", { userId: session.userId, stalledMs: stalledFor, elapsedMs: elapsed }); + break; + } + + await Bun.sleep(POLL_INTERVAL); + + const pane = await this.capturePane(session.sessionName); + if (pane === lastPane && pane !== beforePane) { + stableCount++; + // Require 2 consecutive stable polls (1 second) to avoid false positives + if (stableCount >= 2 && this.hasPromptReady(pane)) { + const response = this.extractResponse(pane, prompt); + if (response) { + logger.info("discuss response received", { + userId: session.userId, + responseLen: response.length, + waitMs: elapsed, + }); + return response; + } + } + } else { + stableCount = 0; + if (pane !== lastPane) lastChangeAt = Date.now(); + } + lastPane = pane; + } + + // Log the final pane state for debugging + const finalPane = await this.capturePane(session.sessionName); + const nonEmptyLines = finalPane.split("\n").filter(l => l.trim()).slice(-10); + logger.warn("discuss response timed out", { + userId: session.userId, + lastLines: nonEmptyLines.join(" | "), + }); + return "Response timed out"; + } + + /** + * Extract the assistant's response from the pane output. + * Claude Code format: user prompt, then ● response text, then ❯ prompt + */ + private extractResponse(pane: string, prompt: string): string | null { + const lines = pane.split("\n"); + + // Find the last ● marker (assistant response) + let responseStart = -1; + let promptLineAfter = -1; + + for (let i = lines.length - 1; i >= 0; i--) { + const trimmed = lines[i].trim(); + if (trimmed.startsWith("❯") && trimmed.length <= 2 && promptLineAfter === -1) { + promptLineAfter = i; + } + if (trimmed.startsWith("●") && responseStart === -1) { + responseStart = i; + break; + } + } + + if (responseStart === -1) return null; + + // Collect response lines from ● to the next ❯ prompt + const end = promptLineAfter > responseStart ? promptLineAfter : lines.length; + const responseLines: string[] = []; + + for (let i = responseStart; i < end; i++) { + let line = lines[i].trim(); + // Strip the ● prefix from first line + if (i === responseStart && line.startsWith("●")) { + line = line.slice(1).trim(); + } + // Skip separator lines (may contain ▪▪▪ or spaces) + if (line.match(/^[─━▪ ]+$/)) continue; + // Skip empty lines at end + if (i === end - 1 && !line) continue; + responseLines.push(line); + } + + const response = responseLines.join("\n").trim(); + return response || null; + } + + private hasPromptReady(pane: string): boolean { + const lines = pane.split("\n").filter(l => l.trim()); + // Check last few non-empty lines for the ❯ prompt + for (let i = lines.length - 1; i >= Math.max(0, lines.length - 5); i--) { + const trimmed = lines[i].trim(); + if (trimmed === "❯") return true; + } + return false; + } + + private async waitForPrompt(session: TmuxSession, timeout: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + const pane = await this.capturePane(session.sessionName); + if (this.hasPromptReady(pane)) return; + await Bun.sleep(POLL_INTERVAL); + } + throw new Error(`Tmux session ${session.sessionName} did not reach prompt within ${timeout}ms`); + } + + private async capturePane(sessionName: string): Promise { + const result = await this.tmux(["capture-pane", "-t", sessionName, "-p", "-S", "-100"]); + return result.stdout; + } + + private async isAlive(sessionName: string): Promise { + const result = await this.tmux(["has-session", "-t", sessionName]); + return result.success; + } + + private async tmux( + args: string[], + extraEnv?: Record, + ): Promise<{ success: boolean; stdout: string; stderr: string }> { + const proc = Bun.spawn(["tmux", ...args], { + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, ...extraEnv }, + }); + + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const exitCode = await proc.exited; + + return { success: exitCode === 0, stdout, stderr }; + } + + private shellEscape(s: string): string { + return `'${s.replace(/'/g, "'\\''")}'`; + } + + private resetIdleTimer(userId: string) { + const existing = this.idleTimers.get(userId); + if (existing) clearTimeout(existing); + this.idleTimers.set(userId, setTimeout(() => { + logger.info("discuss session idle timeout", { userId }); + this.cleanup(userId); + }, IDLE_TIMEOUT)); + } + + private cleanup(userId: string) { + const session = this.sessions.get(userId); + if (session) { + this.tmux(["kill-session", "-t", session.sessionName]).catch(() => {}); + } + this.sessions.delete(userId); + const timer = this.idleTimers.get(userId); + if (timer) clearTimeout(timer); + this.idleTimers.delete(userId); + } + + hasSession(userId: string): boolean { + return this.sessions.has(userId); + } + + killAll() { + for (const [userId] of this.sessions) { + this.cleanup(userId); + } + } +} diff --git a/src/handlers.test.ts b/src/handlers.test.ts index 8d0471e..596dbe4 100644 --- a/src/handlers.test.ts +++ b/src/handlers.test.ts @@ -494,70 +494,84 @@ describe("multi-user mode isolation", () => { }); describe("reply routing to waiting sessions", () => { - it("routes reply to waiting session instead of creating new task", async () => { - const sentMessages: string[] = []; - const clearedTaskIds: string[] = []; + it("kills waiting session and enqueues resume task with same worktree", async () => { + let killed = false; + const deps = makeDeps(); + + // Create a task with sessionId and worktreePath in waiting_user state + const id = deps.queue.enqueue({ userId: "slack:U123", repo: "my-app", prompt: "original" }); + deps.queue.dequeue(); // running + deps.queue.setSessionId(id, "ses-123"); + deps.queue.setWorktreePath(id, "/tmp/worktree-test"); + deps.queue.setWaiting(id); + const mockSessionManager = { getWaitingForUser: (userId: string) => userId === "slack:U123" - ? { taskId: "task-waiting", session: { sendMessage: (t: string) => sentMessages.push(t) } } + ? { taskId: id, session: { sendMessage: () => {}, kill: () => { killed = true; }, sessionId: "ses-123" } } : null, - clearWaiting: (taskId: string) => { clearedTaskIds.push(taskId); }, + clearWaiting: () => {}, + unregister: () => {}, }; - - const deps = makeDeps({ - sessionManager: mockSessionManager as any, - }); + (deps as any).sessionManager = mockSessionManager; const handler = createMessageHandler(deps); const msg = makeMessage("yes, fix them all"); await handler(msg); - // Should have sent to session, not created a new task - expect(sentMessages).toEqual(["yes, fix them all"]); - expect(clearedTaskIds).toEqual(["task-waiting"]); + // Original session should be killed + expect(killed).toBe(true); + + // Original task should be cancelled + expect(deps.queue.get(id)?.status).toBe("failed"); + + // A new resume task should be enqueued with the worktree path and session ID + const tasks = deps.queue.listByUser("slack:U123", 5); + const resumeTask = tasks.find(t => t.status === "pending"); + expect(resumeTask).toBeDefined(); + expect(resumeTask!.prompt).toBe("yes, fix them all"); + expect(resumeTask!.sessionId).toBe("ses-123"); + expect(resumeTask!.worktreePath).toBe("/tmp/worktree-test"); }); - it("resumes the queue task when routing reply to waiting session", async () => { + it("replies with error when no session ID available", async () => { + const deps = makeDeps(); + const id = deps.queue.enqueue({ userId: "slack:U123", repo: "my-app", prompt: "original" }); + deps.queue.dequeue(); + deps.queue.setWaiting(id); + // No sessionId or worktreePath set + const mockSessionManager = { getWaitingForUser: (userId: string) => userId === "slack:U123" - ? { taskId: "task-waiting", session: { sendMessage: () => {} } } + ? { taskId: id, session: { sendMessage: () => {}, kill: () => {}, sessionId: null } } : null, clearWaiting: () => {}, + unregister: () => {}, }; - - const deps = makeDeps({ - sessionManager: mockSessionManager as any, - }); - - // Create a task in waiting_user state - const id = deps.queue.enqueue({ userId: "slack:U123", repo: "my-app", prompt: "original" }); - deps.queue.dequeue(); // running - - // Override the mock to return the real task ID - (mockSessionManager as any).getWaitingForUser = (userId: string) => userId === "slack:U123" - ? { taskId: id, session: { sendMessage: () => {} } } - : null; - - deps.queue.setWaiting(id); - expect(deps.queue.get(id)?.status).toBe("waiting_user"); + (deps as any).sessionManager = mockSessionManager; const handler = createMessageHandler(deps); - const msg = makeMessage("yes do it"); + const replies: string[] = []; + const msg = makeMessage("my reply"); + msg.reply = async (text: string) => { replies.push(text); }; await handler(msg); - // Queue task should be resumed (back to running) - expect(deps.queue.get(id)?.status).toBe("running"); + expect(replies[0]).toContain("try again from scratch"); }); it("adds user message to session history even when routing to waiting session", async () => { + const deps = makeDeps(); + const id = deps.queue.enqueue({ userId: "slack:U123", repo: "my-app", prompt: "original" }); + deps.queue.dequeue(); + deps.queue.setSessionId(id, "ses-123"); + deps.queue.setWorktreePath(id, "/tmp/wt"); + deps.queue.setWaiting(id); + const mockSessionManager = { - getWaitingForUser: () => ({ taskId: "t1", session: { sendMessage: () => {} } }), + getWaitingForUser: () => ({ taskId: id, session: { sendMessage: () => {}, kill: () => {}, sessionId: "ses-123" } }), clearWaiting: () => {}, + unregister: () => {}, }; - - const deps = makeDeps({ - sessionManager: mockSessionManager as any, - }); + (deps as any).sessionManager = mockSessionManager; const handler = createMessageHandler(deps); const msg = makeMessage("my reply"); diff --git a/src/handlers.ts b/src/handlers.ts index c82d7db..cf80cf2 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -12,6 +12,7 @@ import type { IncomingMessage, EventAdapter, IncomingEvent } from "./adapters/ty import type { AgentRunner } from "./runner"; import type { TraceStore } from "./trace"; import type { SessionManager } from "./session-manager"; +import { clearDiscussSession } from "./worker"; export interface HandlerDeps { config: Config; @@ -104,6 +105,12 @@ type RepoResolution = | { kind: "unknown"; repoNames: string[] } | { kind: "error"; message: string }; +async function resolveViaCLI(prompt: string, deps: HandlerDeps): Promise { + const runner = deps.getRunner(deps.config.runner?.name); + const result = await runner.run(prompt, deps.config.reposDir, { maxTurns: 1 }); + return result.output.trim().replace(/[`"']/g, ""); +} + async function resolveRepoWithLLM( userId: string, rawText: string, @@ -126,13 +133,22 @@ async function resolveRepoWithLLM( const lastRepoHint = lastRepo && repoNames.includes(lastRepo) ? `The user's most recent task was on repo "${lastRepo}", but only use this if the conversation context supports it.\n\n` : ""; - const resolvePrompt = `You are a repo-name resolver. ${historyContext}${lastRepoHint}The user's latest message:\n"${rawText}"\n\nAvailable repos: ${repoNames.join(", ")}\n\nRespond with ONLY the repo name that best matches their request. Consider the conversation context if the current message doesn't mention a specific repo. Nothing else — just the exact repo name from the list. If the question doesn't need a specific repo (e.g. "list my open PRs", "what should I work on today", cross-repo queries, general questions about the user's GitHub activity), respond with "NONE". If you cannot determine which specific repo, respond with "UNKNOWN".`; + const resolvePrompt = `You are a repo-name resolver for a coding assistant. Given a user message, determine which repository they want to work on. + +RULES: +- Respond with ONLY a repo name or NONE or UNKNOWN — nothing else +- Respond NONE for: casual chat, greetings, follow-up questions about previous conversation, general questions ("what should I work on", "how are you"), cross-repo queries, anything that isn't clearly about a specific repo's code/issues/PRs +- Respond UNKNOWN if the message seems repo-related but you can't determine which one +- Only respond with a repo name if the message explicitly mentions or clearly refers to a specific repo's code, issues, PRs, or features +- Do NOT match a repo just because a word in the message happens to match a repo name (e.g. "what did I just ask you" is NONE, not a repo called "ask") + +${historyContext}${lastRepoHint}User message: "${rawText}" + +Available repos: ${repoNames.join(", ")}`; onStatus?.("Figuring out which repo..."); try { - const runner = deps.getRunner(deps.config.runner?.name); - const result = await runner.run(resolvePrompt, deps.config.reposDir, { maxTurns: 1 }); - const resolved = result.output.trim().replace(/[`"']/g, ""); + const resolved = await resolveViaCLI(resolvePrompt, deps); if (resolved === "NONE") { logger.info("repo resolver returned NONE — falling back to discuss", { userText: rawText.slice(0, 80) }); @@ -151,6 +167,7 @@ async function resolveRepoWithLLM( async function handleClear(msg: IncomingMessage, deps: HandlerDeps) { deps.sessions.clear(msg.userId); + clearDiscussSession(msg.userId); await msg.reply("Conversation cleared."); } @@ -436,7 +453,21 @@ async function handleInitRepo(msg: IncomingMessage, args: Record, d await replyAndLog(msg, deps, `Added repo "${name}" (${url}, branch: ${branch}).`); } +function looksLikeChatMessage(text: string): boolean { + const lower = text.toLowerCase().trim(); + if (lower.length < 40 && !/\b(fix|review|deploy|build|test|lint|run|check|update|refactor|add|create|delete|remove|merge|push|pull|branch|commit|release|ship)\b/i.test(lower)) { + return true; + } + return false; +} + async function handleTaskMessage(msg: IncomingMessage, parsed: ParsedMessage, deps: HandlerDeps) { + // Skip expensive LLM repo resolution for obvious chat messages + if (!parsed.repo && looksLikeChatMessage(parsed.rawText)) { + const history = deps.sessions.getHistory(msg.userId, 6); + return handleDiscuss(msg, { ...parsed, type: "free-form" }, history, deps); + } + const hint = parsed.repo && deps.getRepoInfo(parsed.repo) ? parsed.repo : undefined; const resolution = await resolveRepoWithLLM(msg.userId, parsed.rawText, hint, deps, (text) => msg.updateStatus(text)); @@ -491,14 +522,37 @@ export function createMessageHandler(deps: HandlerDeps): (msg: IncomingMessage) return async (msg: IncomingMessage) => { deps.sessions.addMessage(msg.userId, "user", msg.text); - // Route reply to waiting streaming session if one exists + // Route reply to waiting session: kill process, enqueue resume in same worktree if (deps.sessionManager) { const waiting = deps.sessionManager.getWaitingForUser(msg.userId); if (waiting) { - waiting.session.sendMessage(msg.text); - deps.sessionManager.clearWaiting(waiting.taskId); - deps.queue.resume(waiting.taskId); - deps.trace.append(waiting.taskId, "lifecycle", "User reply received", msg.text.slice(0, 200)); + const waitingTask = deps.queue.get(waiting.taskId); + const sessionId = waitingTask?.sessionId ?? waiting.session.sessionId; + const worktreePath = waitingTask?.worktreePath; + logger.info("resuming waiting session with user reply", { taskId: waiting.taskId, userId: msg.userId, sessionId, worktreePath }); + + // Kill the blocked -p process + waiting.session.kill(); + deps.sessionManager.unregister(waiting.taskId); + deps.queue.cancel(waiting.taskId); + deps.trace.append(waiting.taskId, "lifecycle", "User reply received, spawning resume", msg.text.slice(0, 200)); + + if (sessionId && worktreePath) { + // Enqueue resume task — reuses same worktree + Claude session + const resumeTaskId = deps.queue.enqueue({ + userId: msg.userId, + repo: waitingTask!.repo, + prompt: msg.text, + priority: waitingTask?.priority ?? 0, + resumeSessionId: sessionId, + worktreePath, + }); + deps.pendingReplies.set(resumeTaskId, msg); + logger.info("resume task enqueued", { resumeTaskId, sessionId, worktreePath }); + } else { + logger.warn("missing session ID or worktree for resume", { taskId: waiting.taskId, sessionId, worktreePath }); + await msg.reply("Lost the session context — try again from scratch."); + } return; } } diff --git a/src/index.ts b/src/index.ts index 1509560..2eb2967 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,7 @@ import { TraceStore } from "./trace"; import { startCronLoop } from "./cron"; import { ScheduleStore } from "./schedules"; import { createMessageHandler, createEventHandler } from "./handlers"; -import { createWorker } from "./worker"; +import { createWorker, getDiscussPool } from "./worker"; import { SessionManager } from "./session-manager"; const config = loadConfig(); @@ -240,8 +240,11 @@ async function main() { logger.info("ove ready"); - async function shutdown() { - logger.info("shutting down..."); + let shuttingDown = false; + async function shutdown(signal: string) { + if (shuttingDown) return; + shuttingDown = true; + logger.info("shutting down...", { signal }); for (const adapter of adapters) { await adapter.stop(); } @@ -249,13 +252,44 @@ async function main() { await ea.stop(); } sessionManager.killAll(); + getDiscussPool().killAll(); process.exit(0); } - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); + // Guard against spurious signals while tasks are running. + // Slack bolt's WebSocket pong timeout triggers SIGTERM (likely a Bun/ws interaction bug). + // Only allow shutdown when no tasks are active, or after a second signal (force). + let forceShutdownRequested = false; + function handleSignal(signal: string) { + if (runningProcesses.size > 0 && !forceShutdownRequested) { + forceShutdownRequested = true; + logger.info(`ignoring ${signal} — ${runningProcesses.size} active task(s). Send again to force.`); + // Reset force flag after 10s so a stray signal later doesn't force-kill + setTimeout(() => { forceShutdownRequested = false; }, 10_000); + return; + } + shutdown(signal); + } + process.on("SIGINT", () => handleSignal("SIGINT")); + process.on("SIGTERM", () => handleSignal("SIGTERM")); } +process.on("unhandledRejection", (err) => { + logger.error("unhandled rejection (non-fatal)", { error: String(err), stack: (err as any)?.stack?.slice(0, 500) }); +}); + +process.on("uncaughtException", (err) => { + logger.error("uncaught exception (non-fatal)", { error: String(err), stack: err?.stack?.slice(0, 500) }); +}); + +process.on("beforeExit", (code) => { + logger.info("beforeExit event", { code }); +}); + +process.on("exit", (code) => { + logger.info("exit event", { code }); +}); + main().catch((err) => { logger.error("fatal error", { error: String(err) }); process.exit(1); diff --git a/src/queue.ts b/src/queue.ts index a9e77cc..221a4fc 100644 --- a/src/queue.ts +++ b/src/queue.ts @@ -6,6 +6,8 @@ export interface TaskInput { prompt: string; taskType?: string; priority?: number; + resumeSessionId?: string; + worktreePath?: string; } export interface Task { @@ -18,6 +20,7 @@ export interface Task { taskType: string | null; priority: number; sessionId: string | null; + worktreePath: string | null; createdAt: string; completedAt: string | null; } @@ -32,6 +35,7 @@ interface TaskRow { task_type: string | null; priority: number; session_id: string | null; + worktree_path: string | null; created_at: string; completed_at: string | null; } @@ -68,14 +72,18 @@ export class TaskQueue { if (!columns.some(c => c.name === "session_id")) { this.db.run("ALTER TABLE tasks ADD COLUMN session_id TEXT"); } + // Migration: add worktree_path column if missing (backward compat) + if (!columns.some(c => c.name === "worktree_path")) { + this.db.run("ALTER TABLE tasks ADD COLUMN worktree_path TEXT"); + } } enqueue(input: TaskInput): string { const id = crypto.randomUUID(); this.db.run( - `INSERT INTO tasks (id, user_id, repo, prompt, status, task_type, priority, created_at) - VALUES (?, ?, ?, ?, 'pending', ?, ?, ?)`, - [id, input.userId, input.repo, input.prompt, input.taskType || null, input.priority ?? 0, new Date().toISOString()] + `INSERT INTO tasks (id, user_id, repo, prompt, status, task_type, priority, session_id, worktree_path, created_at) + VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?)`, + [id, input.userId, input.repo, input.prompt, input.taskType || null, input.priority ?? 0, input.resumeSessionId || null, input.worktreePath || null, new Date().toISOString()] ); return id; } @@ -143,6 +151,10 @@ export class TaskQueue { this.db.run(`UPDATE tasks SET session_id = ? WHERE id = ?`, [sessionId, id]); } + setWorktreePath(id: string, path: string) { + this.db.run(`UPDATE tasks SET worktree_path = ? WHERE id = ?`, [path, id]); + } + getWaitingForUser(userId: string): Task | null { const row = this.db .query(`SELECT * FROM tasks WHERE user_id = ? AND status = 'waiting_user' ORDER BY created_at DESC LIMIT 1`) @@ -290,6 +302,7 @@ export class TaskQueue { taskType: row.task_type || null, priority: row.priority ?? 0, sessionId: row.session_id || null, + worktreePath: row.worktree_path || null, createdAt: row.created_at, completedAt: row.completed_at, }; diff --git a/src/runners/claude.test.ts b/src/runners/claude.test.ts index 3f9b263..679a308 100644 --- a/src/runners/claude.test.ts +++ b/src/runners/claude.test.ts @@ -79,15 +79,17 @@ describe("summarizeToolInput", () => { describe("streaming args", () => { const runner = new ClaudeRunner(); - it("builds streaming args with input-format and without disallowed AskUserQuestion", () => { + it("builds streaming args with -p prompt and output-format stream-json", () => { const args = runner.buildStreamingArgs("fix the bug", { maxTurns: 25 }); - expect(args).toContain("--input-format"); + expect(args).toContain("-p"); + expect(args).toContain("fix the bug"); + expect(args).toContain("--output-format"); expect(args).toContain("stream-json"); - expect(args).not.toContain("AskUserQuestion"); + expect(args).not.toContain("--input-format"); expect(args).not.toContain("--disallowed-tools"); }); - it("streaming args still include output-format stream-json", () => { + it("streaming args include output-format stream-json", () => { const args = runner.buildStreamingArgs("test", { maxTurns: 10 }); expect(args).toContain("--output-format"); expect(args).toContain("stream-json"); @@ -131,11 +133,9 @@ describe("buildStreamingArgs details", () => { expect(args).not.toContain("--disallowed-tools"); }); - it("includes --input-format stream-json", () => { + it("does not include --input-format", () => { const args = runner.buildStreamingArgs("test", { maxTurns: 10 }); - const idx = args.indexOf("--input-format"); - expect(idx).toBeGreaterThan(-1); - expect(args[idx + 1]).toBe("stream-json"); + expect(args).not.toContain("--input-format"); }); it("includes --dangerously-skip-permissions", () => { diff --git a/src/runners/claude.ts b/src/runners/claude.ts index c88a1e6..885f2dc 100644 --- a/src/runners/claude.ts +++ b/src/runners/claude.ts @@ -25,7 +25,7 @@ export function summarizeToolInput(name: string, input: any): string { export class ClaudeRunner implements AgentRunner { name = "claude-code"; - private claudePath: string; + readonly claudePath: string; constructor() { const found = which("claude"); @@ -42,7 +42,6 @@ export class ClaudeRunner implements AgentRunner { buildStreamingArgs(prompt: string, opts: RunOptions): string[] { const args = [ "-p", prompt, - "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", "--max-turns", String(opts.maxTurns), @@ -58,7 +57,9 @@ export class ClaudeRunner implements AgentRunner { const startTime = Date.now(); logger.info("starting claude task", { workDir, maxTurns: opts.maxTurns, claudePath: this.claudePath }); - const proc = Bun.spawn([this.claudePath, ...args], { + // Use setsid to isolate the child in its own process group, + // preventing spurious SIGTERM/SIGINT from killing it. + const proc = Bun.spawn(["setsid", this.claudePath, ...args], { cwd: workDir, stdout: "pipe", stderr: "pipe", @@ -138,9 +139,10 @@ export class ClaudeRunner implements AgentRunner { const startTime = Date.now(); logger.info("starting streaming claude task", { workDir, maxTurns: opts.maxTurns }); - const proc = Bun.spawn([this.claudePath, ...args], { + // Use setsid to isolate the child in its own process group, + // preventing spurious SIGTERM/SIGINT from killing it. + const proc = Bun.spawn(["setsid", this.claudePath, ...args], { cwd: workDir, - stdin: "pipe", stdout: "pipe", stderr: "pipe", env: { ...process.env, CI: "1" }, @@ -157,13 +159,16 @@ export class ClaudeRunner implements AgentRunner { const done = (async (): Promise => { const decoder = new TextDecoder(); const reader = proc.stdout.getReader(); + let buffer = ""; try { while (true) { const { done: isDone, value } = await reader.read(); if (isDone) break; - const chunk = decoder.decode(value, { stream: true }); - const lines = chunk.split("\n").filter(Boolean); + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; for (const line of lines) { + if (!line.trim()) continue; try { const msg = JSON.parse(line); @@ -218,7 +223,10 @@ export class ClaudeRunner implements AgentRunner { } if (exitCode !== 0) { const stderr = await new Response(proc.stderr).text(); - return { success: false, output: stderr || "Claude task failed", durationMs }; + logger.error("streaming claude task failed", { exitCode, stderr: stderr.slice(0, 500), durationMs, hadResult: !!resultText, textBlockCount: textBlocks.length }); + // If we got text output before the process died, return that instead of a generic error + const output = resultText || (textBlocks.length > 0 ? textBlocks.join("\n\n") : stderr || `Claude task failed (exit code ${exitCode})`); + return { success: false, output, durationMs, sessionId: sessionId ?? undefined }; } return { @@ -229,16 +237,9 @@ export class ClaudeRunner implements AgentRunner { }; })(); - const encoder = new TextEncoder(); - return { - sendMessage(text: string) { - try { - const msg = JSON.stringify({ type: "user_message", content: text }) + "\n"; - proc.stdin.write(encoder.encode(msg)); - } catch (err) { - logger.warn("failed to write to streaming session stdin", { error: String(err) }); - } + sendMessage(_text: string) { + // Not supported in -p mode. Handler uses --resume with new process instead. }, kill() { proc.kill(); diff --git a/src/worker.ts b/src/worker.ts index 356883c..7d72bd4 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -12,6 +12,19 @@ import type { AgentRunner, RunOptions, RunResult, StatusEvent, StreamEvent } fro import type { TraceStore } from "./trace"; import type { SessionManager } from "./session-manager"; import type { DebouncedFunction } from "./adapters/debounce"; +import { DiscussPool } from "./discuss-pool"; + +// Track last discuss session per user for conversation continuity +const discussSessions = new Map(); +const discussPool = new DiscussPool(); + +export function clearDiscussSession(userId: string) { + discussSessions.delete(userId); +} + +export function getDiscussPool(): DiscussPool { + return discussPool; +} export interface WorkerDeps { config: Config; @@ -125,8 +138,14 @@ async function processTask(task: Task, deps: WorkerDeps) { await originalMsg?.updateStatus(`Working on it...`); let workDir: string; - - if (isDiscuss) { + let reusingWorktree = false; + + if (task.worktreePath) { + // Resume task — reuse existing worktree + workDir = task.worktreePath; + reusingWorktree = true; + logger.info("reusing worktree for resume", { taskId: task.id, workDir }); + } else if (isDiscuss) { workDir = deps.config.reposDir; } else if (isCreateProject) { workDir = join(deps.config.reposDir, task.repo); @@ -140,6 +159,7 @@ async function processTask(task: Task, deps: WorkerDeps) { task.id, repoInfo!.defaultBranch ); + deps.queue.setWorktreePath(task.id, workDir); } try { @@ -159,13 +179,29 @@ async function processTask(task: Task, deps: WorkerDeps) { maxTurns, mcpConfigPath, signal: abortController.signal, + resumeSessionId: task.sessionId ?? undefined, }); const useStreaming = !isDiscuss && task.taskType !== "cron" && typeof (taskRunner as any).runStreaming === "function"; + const hasExistingDiscuss = isDiscuss && discussPool.hasSession(task.userId); + logger.info("task execution mode", { taskId: task.id, useStreaming, isDiscuss, taskType: task.taskType || "code", hasExistingDiscuss }); let result: RunResult; - if (useStreaming) { + if (isDiscuss) { + // Discuss uses persistent tmux-based session — fast follow-ups + const poolResult = await discussPool.send( + task.userId, + task.prompt, + workDir, + ); + result = { + success: true, + output: poolResult.output, + durationMs: poolResult.durationMs, + }; + } else if (useStreaming) { + logger.info("starting streaming session", { taskId: task.id, repo: task.repo }); const session = (taskRunner as any).runStreaming( task.prompt, workDir, @@ -180,6 +216,7 @@ async function processTask(task: Task, deps: WorkerDeps) { statusLog.push(event.text.slice(0, 200)); deps.trace.append(task.id, "status", event.text.slice(0, 200)); } else if (event.kind === "ask_user") { + logger.info("streaming ask_user received", { taskId: task.id, question: event.question.slice(0, 200) }); deps.queue.setWaiting(task.id); deps.sessionManager.setWaiting(task.id); deps.trace.append(task.id, "lifecycle", "Waiting for user input", event.question); @@ -196,6 +233,7 @@ async function processTask(task: Task, deps: WorkerDeps) { deps.sessionManager.register(task.id, task.userId, session); result = await session.done; + logger.info("streaming session completed", { taskId: task.id, success: result.success, sessionId: result.sessionId ?? null }); deps.sessionManager.unregister(task.id); } else { result = await taskRunner.run( @@ -231,6 +269,12 @@ async function processTask(task: Task, deps: WorkerDeps) { const elapsed = Date.now() - startTime; const outcome = result.success ? "completed" : "failed"; + // Store discuss session ID for conversation continuity + if (isDiscuss && result.sessionId) { + discussSessions.set(task.userId, result.sessionId); + logger.info("discuss session stored", { userId: task.userId, sessionId: result.sessionId }); + } + if (result.success) { deps.queue.complete(task.id, result.output); logger.info("task completed", { taskId: task.id, durationMs: result.durationMs }); @@ -329,10 +373,21 @@ async function processTask(task: Task, deps: WorkerDeps) { } } } finally { - if (!skipRepoSetup) { - await deps.repos.removeWorktree(task.repo, task.id).catch((err) => { - logger.warn("worktree cleanup failed", { repo: task.repo, taskId: task.id, error: String(err) }); - }); + // Don't clean up worktree if task is waiting for user input — it'll be reused on resume + const currentStatus = deps.queue.get(task.id)?.status; + const shouldCleanup = !skipRepoSetup && currentStatus !== "waiting_user"; + if (shouldCleanup) { + if (reusingWorktree) { + // Resume task: clean up by removing the worktree directory directly + const { rm } = await import("node:fs/promises"); + await rm(workDir, { recursive: true, force: true }).catch((err) => { + logger.warn("resume worktree cleanup failed", { workDir, error: String(err) }); + }); + } else { + await deps.repos.removeWorktree(task.repo, task.id).catch((err) => { + logger.warn("worktree cleanup failed", { repo: task.repo, taskId: task.id, error: String(err) }); + }); + } } } } catch (err) { From 8c0e2c3e9c0b380d7aa5ccbdfd3c2a2a4324c8f3 Mon Sep 17 00:00:00 2001 From: Love Billingskog Nyberg Date: Sat, 11 Apr 2026 00:24:09 +0200 Subject: [PATCH 14/16] fix: require 5s of pane stability before declaring discuss response done The previous 1s threshold caused premature response capture when Claude paused briefly mid-response. Now requires 10 consecutive stable polls (5s) and a minimum 5s elapsed time before checking for completion. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/discuss-pool.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/discuss-pool.ts b/src/discuss-pool.ts index 9bb3ffe..6d0537f 100644 --- a/src/discuss-pool.ts +++ b/src/discuss-pool.ts @@ -4,6 +4,8 @@ const IDLE_TIMEOUT = 3 * 60 * 60_000; // 3 hours idle → kill session const STALL_TIMEOUT = 180_000; // 3 min no pane changes → assume stuck const ACTIVE_TIMEOUT = 30 * 60_000; // 30 min max while Claude is still working const POLL_INTERVAL = 500; // poll every 500ms +const MIN_RESPONSE_WAIT = 5_000; // don't check for completion in the first 5s +const STABLE_THRESHOLD = 10; // 10 consecutive stable polls (5s) before declaring done const POST_PROMPT_DELAY = 2000; // wait after prompt detected before sending keys interface TmuxSession { @@ -142,8 +144,8 @@ export class DiscussPool { const pane = await this.capturePane(session.sessionName); if (pane === lastPane && pane !== beforePane) { stableCount++; - // Require 2 consecutive stable polls (1 second) to avoid false positives - if (stableCount >= 2 && this.hasPromptReady(pane)) { + // Require STABLE_THRESHOLD consecutive stable polls (5s) and MIN_RESPONSE_WAIT elapsed + if (stableCount >= STABLE_THRESHOLD && elapsed >= MIN_RESPONSE_WAIT && this.hasPromptReady(pane)) { const response = this.extractResponse(pane, prompt); if (response) { logger.info("discuss response received", { From d281d1837dc630d424f805a7cfe05d61fdac816f Mon Sep 17 00:00:00 2001 From: Love Billingskog Nyberg Date: Sat, 11 Apr 2026 00:30:42 +0200 Subject: [PATCH 15/16] fix: remove noisy "Figuring out which repo..." status message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Don't show intermediate status to user when resolving repo — either show the actual response or nothing. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/handlers.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/handlers.ts b/src/handlers.ts index cf80cf2..45bff92 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -146,7 +146,6 @@ ${historyContext}${lastRepoHint}User message: "${rawText}" Available repos: ${repoNames.join(", ")}`; - onStatus?.("Figuring out which repo..."); try { const resolved = await resolveViaCLI(resolvePrompt, deps); From 85ad65c6368852a4acc5a8f4cd4a00a8e5ffd0b2 Mon Sep 17 00:00:00 2001 From: Love Billingskog Nyberg Date: Fri, 21 Aug 2026 22:41:51 +0200 Subject: [PATCH 16/16] feat: migrate persistent discuss sessions to Codex --- src/adapters/discord.ts | 8 +- src/diagnostics.test.ts | 2 +- src/discuss-pool.ts | 303 -------------------------------------- src/handlers.ts | 2 - src/index.ts | 3 +- src/router.ts | 2 +- src/runners/codex.test.ts | 17 ++- src/runners/codex.ts | 29 ++-- src/sessions.test.ts | 21 +++ src/sessions.ts | 24 +++ src/setup.ts | 11 +- src/worker.ts | 44 +++--- 12 files changed, 113 insertions(+), 353 deletions(-) delete mode 100644 src/discuss-pool.ts diff --git a/src/adapters/discord.ts b/src/adapters/discord.ts index 25532e2..2088e24 100644 --- a/src/adapters/discord.ts +++ b/src/adapters/discord.ts @@ -32,6 +32,8 @@ export class DiscordAdapter implements ChatAdapter { const isDM = !discordMsg.guild; const isMention = discordMsg.mentions.has(this.client.user!); if (!isDM && !isMention) return; + const channel = discordMsg.channel; + if (!channel.isSendable()) return; let text = discordMsg.content; if (isMention) { @@ -47,11 +49,11 @@ export class DiscordAdapter implements ChatAdapter { if (statusMsg) { await statusMsg.edit(statusText); } else { - statusMsg = await discordMsg.channel.send(statusText); + statusMsg = await channel.send(statusText); } } catch (err) { logger.warn("discord status update failed", { error: String(err) }); - statusMsg = await discordMsg.channel.send(statusText); + statusMsg = await channel.send(statusText); } }, 3000); @@ -60,7 +62,7 @@ export class DiscordAdapter implements ChatAdapter { platform: "discord", text, reply: async (replyText: string) => { - await discordMsg.channel.send(replyText); + await channel.send(replyText); }, updateStatus: doUpdate, }; diff --git a/src/diagnostics.test.ts b/src/diagnostics.test.ts index 77acca7..b617684 100644 --- a/src/diagnostics.test.ts +++ b/src/diagnostics.test.ts @@ -149,7 +149,7 @@ describe("runDiagnostics", () => { const reposDir = join(dir, "repos"); mkdirSync(reposDir); const deps = makeDeps({ - existsSync: (p: string) => p === reposDir, + existsSync: (p) => p === reposDir, accessSync: () => {}, }); const config = makeConfig({ reposDir }); diff --git a/src/discuss-pool.ts b/src/discuss-pool.ts deleted file mode 100644 index 6d0537f..0000000 --- a/src/discuss-pool.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { logger } from "./logger"; - -const IDLE_TIMEOUT = 3 * 60 * 60_000; // 3 hours idle → kill session -const STALL_TIMEOUT = 180_000; // 3 min no pane changes → assume stuck -const ACTIVE_TIMEOUT = 30 * 60_000; // 30 min max while Claude is still working -const POLL_INTERVAL = 500; // poll every 500ms -const MIN_RESPONSE_WAIT = 5_000; // don't check for completion in the first 5s -const STABLE_THRESHOLD = 10; // 10 consecutive stable polls (5s) before declaring done -const POST_PROMPT_DELAY = 2000; // wait after prompt detected before sending keys - -interface TmuxSession { - sessionName: string; - userId: string; - workDir: string; - createdAt: number; - ready: boolean; -} - -/** - * Manages persistent Claude CLI sessions via tmux for fast follow-up messages. - * Each user gets a tmux session running `claude` interactively. - * Messages are sent via `tmux send-keys`, responses read via `tmux capture-pane`. - */ -export class DiscussPool { - private sessions = new Map(); - private idleTimers = new Map>(); - - async send( - userId: string, - prompt: string, - workDir: string, - model?: string, - ): Promise<{ output: string; durationMs: number }> { - const start = Date.now(); - - let session = this.sessions.get(userId); - - // Check if tmux session is still alive - if (session && !(await this.isAlive(session.sessionName))) { - logger.warn("discuss tmux session died", { userId, sessionName: session.sessionName }); - this.cleanup(userId); - session = undefined; - } - - if (!session) { - session = await this.spawn(userId, workDir, model); - } - - this.resetIdleTimer(userId); - - const output = await this.sendAndWait(session, prompt); - return { output, durationMs: Date.now() - start }; - } - - private async spawn(userId: string, workDir: string, model?: string): Promise { - const sessionName = `ove-discuss-${userId.replace(/[^a-zA-Z0-9]/g, "-")}`; - - // Kill any existing tmux session with this name - await this.tmux(["kill-session", "-t", sessionName]).catch(() => {}); - - const modelFlag = model ? `--model ${model}` : ""; - const cmd = `cd ${this.shellEscape(workDir)} && unset CLAUDECODE ANTHROPIC_API_KEY && claude --dangerously-skip-permissions ${modelFlag}`.trim(); - - logger.info("spawning discuss tmux session", { userId, sessionName, workDir, model }); - - const result = await this.tmux([ - "new-session", "-d", - "-s", sessionName, - "-x", "200", "-y", "50", - cmd, - ], { TERM: "xterm" }); - - if (!result.success) { - throw new Error(`Failed to create tmux session: ${result.stderr}`); - } - - const session: TmuxSession = { - sessionName, - userId, - workDir, - createdAt: Date.now(), - ready: false, - }; - - // Wait for the REPL prompt to appear, then extra delay for TUI to be fully ready - await this.waitForPrompt(session, 30_000); - await Bun.sleep(POST_PROMPT_DELAY); - session.ready = true; - - this.sessions.set(userId, session); - this.resetIdleTimer(userId); - - logger.info("discuss tmux session ready", { userId, sessionName, startupMs: Date.now() - session.createdAt }); - return session; - } - - private async sendAndWait(session: TmuxSession, prompt: string): Promise { - // Capture pane before sending to know what's already there - const beforePane = await this.capturePane(session.sessionName); - - // Send text literally (-l avoids bracket paste), then Enter to submit - const singleLine = prompt.replace(/\n/g, " ").trim(); - await this.tmux(["send-keys", "-l", "-t", session.sessionName, singleLine]); - - // Brief pause to let TUI render the typed text before submitting - await Bun.sleep(200); - await this.tmux(["send-keys", "-t", session.sessionName, "Enter"]); - - // Verify the text was actually typed by checking the pane changed - await Bun.sleep(500); - const afterSend = await this.capturePane(session.sessionName); - if (afterSend === beforePane) { - logger.warn("discuss send-keys may have been lost, retrying", { userId: session.userId }); - // Retry: the TUI might not have been ready - await Bun.sleep(1000); - await this.tmux(["send-keys", "-l", "-t", session.sessionName, singleLine]); - await Bun.sleep(200); - await this.tmux(["send-keys", "-t", session.sessionName, "Enter"]); - } - - // Poll for response — two timeout modes: - // 1. STALL_TIMEOUT: no pane changes for 3 min → assume stuck - // 2. ACTIVE_TIMEOUT: 30 min total even if Claude is still working - const startWait = Date.now(); - let lastPane = ""; - let lastChangeAt = Date.now(); - let stableCount = 0; - - while (true) { - const elapsed = Date.now() - startWait; - const stalledFor = Date.now() - lastChangeAt; - - if (elapsed >= ACTIVE_TIMEOUT) { - logger.warn("discuss active timeout", { userId: session.userId, elapsedMs: elapsed }); - break; - } - if (stalledFor >= STALL_TIMEOUT) { - logger.warn("discuss stall timeout", { userId: session.userId, stalledMs: stalledFor, elapsedMs: elapsed }); - break; - } - - await Bun.sleep(POLL_INTERVAL); - - const pane = await this.capturePane(session.sessionName); - if (pane === lastPane && pane !== beforePane) { - stableCount++; - // Require STABLE_THRESHOLD consecutive stable polls (5s) and MIN_RESPONSE_WAIT elapsed - if (stableCount >= STABLE_THRESHOLD && elapsed >= MIN_RESPONSE_WAIT && this.hasPromptReady(pane)) { - const response = this.extractResponse(pane, prompt); - if (response) { - logger.info("discuss response received", { - userId: session.userId, - responseLen: response.length, - waitMs: elapsed, - }); - return response; - } - } - } else { - stableCount = 0; - if (pane !== lastPane) lastChangeAt = Date.now(); - } - lastPane = pane; - } - - // Log the final pane state for debugging - const finalPane = await this.capturePane(session.sessionName); - const nonEmptyLines = finalPane.split("\n").filter(l => l.trim()).slice(-10); - logger.warn("discuss response timed out", { - userId: session.userId, - lastLines: nonEmptyLines.join(" | "), - }); - return "Response timed out"; - } - - /** - * Extract the assistant's response from the pane output. - * Claude Code format: user prompt, then ● response text, then ❯ prompt - */ - private extractResponse(pane: string, prompt: string): string | null { - const lines = pane.split("\n"); - - // Find the last ● marker (assistant response) - let responseStart = -1; - let promptLineAfter = -1; - - for (let i = lines.length - 1; i >= 0; i--) { - const trimmed = lines[i].trim(); - if (trimmed.startsWith("❯") && trimmed.length <= 2 && promptLineAfter === -1) { - promptLineAfter = i; - } - if (trimmed.startsWith("●") && responseStart === -1) { - responseStart = i; - break; - } - } - - if (responseStart === -1) return null; - - // Collect response lines from ● to the next ❯ prompt - const end = promptLineAfter > responseStart ? promptLineAfter : lines.length; - const responseLines: string[] = []; - - for (let i = responseStart; i < end; i++) { - let line = lines[i].trim(); - // Strip the ● prefix from first line - if (i === responseStart && line.startsWith("●")) { - line = line.slice(1).trim(); - } - // Skip separator lines (may contain ▪▪▪ or spaces) - if (line.match(/^[─━▪ ]+$/)) continue; - // Skip empty lines at end - if (i === end - 1 && !line) continue; - responseLines.push(line); - } - - const response = responseLines.join("\n").trim(); - return response || null; - } - - private hasPromptReady(pane: string): boolean { - const lines = pane.split("\n").filter(l => l.trim()); - // Check last few non-empty lines for the ❯ prompt - for (let i = lines.length - 1; i >= Math.max(0, lines.length - 5); i--) { - const trimmed = lines[i].trim(); - if (trimmed === "❯") return true; - } - return false; - } - - private async waitForPrompt(session: TmuxSession, timeout: number): Promise { - const start = Date.now(); - while (Date.now() - start < timeout) { - const pane = await this.capturePane(session.sessionName); - if (this.hasPromptReady(pane)) return; - await Bun.sleep(POLL_INTERVAL); - } - throw new Error(`Tmux session ${session.sessionName} did not reach prompt within ${timeout}ms`); - } - - private async capturePane(sessionName: string): Promise { - const result = await this.tmux(["capture-pane", "-t", sessionName, "-p", "-S", "-100"]); - return result.stdout; - } - - private async isAlive(sessionName: string): Promise { - const result = await this.tmux(["has-session", "-t", sessionName]); - return result.success; - } - - private async tmux( - args: string[], - extraEnv?: Record, - ): Promise<{ success: boolean; stdout: string; stderr: string }> { - const proc = Bun.spawn(["tmux", ...args], { - stdout: "pipe", - stderr: "pipe", - env: { ...process.env, ...extraEnv }, - }); - - const [stdout, stderr] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - ]); - const exitCode = await proc.exited; - - return { success: exitCode === 0, stdout, stderr }; - } - - private shellEscape(s: string): string { - return `'${s.replace(/'/g, "'\\''")}'`; - } - - private resetIdleTimer(userId: string) { - const existing = this.idleTimers.get(userId); - if (existing) clearTimeout(existing); - this.idleTimers.set(userId, setTimeout(() => { - logger.info("discuss session idle timeout", { userId }); - this.cleanup(userId); - }, IDLE_TIMEOUT)); - } - - private cleanup(userId: string) { - const session = this.sessions.get(userId); - if (session) { - this.tmux(["kill-session", "-t", session.sessionName]).catch(() => {}); - } - this.sessions.delete(userId); - const timer = this.idleTimers.get(userId); - if (timer) clearTimeout(timer); - this.idleTimers.delete(userId); - } - - hasSession(userId: string): boolean { - return this.sessions.has(userId); - } - - killAll() { - for (const [userId] of this.sessions) { - this.cleanup(userId); - } - } -} diff --git a/src/handlers.ts b/src/handlers.ts index 45bff92..70abc5e 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -12,7 +12,6 @@ import type { IncomingMessage, EventAdapter, IncomingEvent } from "./adapters/ty import type { AgentRunner } from "./runner"; import type { TraceStore } from "./trace"; import type { SessionManager } from "./session-manager"; -import { clearDiscussSession } from "./worker"; export interface HandlerDeps { config: Config; @@ -166,7 +165,6 @@ Available repos: ${repoNames.join(", ")}`; async function handleClear(msg: IncomingMessage, deps: HandlerDeps) { deps.sessions.clear(msg.userId); - clearDiscussSession(msg.userId); await msg.reply("Conversation cleared."); } diff --git a/src/index.ts b/src/index.ts index 2eb2967..ae7c11b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,7 @@ import { TraceStore } from "./trace"; import { startCronLoop } from "./cron"; import { ScheduleStore } from "./schedules"; import { createMessageHandler, createEventHandler } from "./handlers"; -import { createWorker, getDiscussPool } from "./worker"; +import { createWorker } from "./worker"; import { SessionManager } from "./session-manager"; const config = loadConfig(); @@ -252,7 +252,6 @@ async function main() { await ea.stop(); } sessionManager.killAll(); - getDiscussPool().killAll(); process.exit(0); } diff --git a/src/router.ts b/src/router.ts index 962c1e2..19a9d27 100644 --- a/src/router.ts +++ b/src/router.ts @@ -24,7 +24,7 @@ export interface ParsedMessage { repo?: string; args: Record; rawText: string; - priority: number; + priority?: number; } const PRIORITY_LEVELS: Record = { urgent: 2, high: 1, normal: 0, low: 0 }; diff --git a/src/runners/codex.test.ts b/src/runners/codex.test.ts index c3132dc..c42d0ae 100644 --- a/src/runners/codex.test.ts +++ b/src/runners/codex.test.ts @@ -16,9 +16,8 @@ describe("CodexRunner", () => { expect(args).toContain("--json"); expect(args).toContain("--dangerously-bypass-approvals-and-sandbox"); expect(args).toContain("--skip-git-repo-check"); - expect(args).toContain("--ephemeral"); - expect(args).toContain("-C"); - expect(args).toContain("/tmp/work"); + expect(args).not.toContain("--ephemeral"); + expect(args).not.toContain("-C"); expect(args).toContain("fix the bug"); }); @@ -36,6 +35,18 @@ describe("CodexRunner", () => { expect(args).not.toContain("-m"); }); + it("builds persistent resume args when a session id is provided", () => { + const args = runner.buildArgs("follow up", "/tmp/work", { + maxTurns: 25, + resumeSessionId: "thread-123", + }); + expect(args.slice(0, 2)).toEqual(["exec", "resume"]); + expect(args).toContain("thread-123"); + expect(args).toContain("follow up"); + expect(args).not.toContain("-C"); + expect(args).not.toContain("--ephemeral"); + }); + it("ignores mcpConfigPath (not supported by codex CLI)", () => { const args = runner.buildArgs("test", "/tmp/work", { maxTurns: 25, diff --git a/src/runners/codex.ts b/src/runners/codex.ts index c55bf25..50f1743 100644 --- a/src/runners/codex.ts +++ b/src/runners/codex.ts @@ -41,16 +41,18 @@ export class CodexRunner implements AgentRunner { } buildArgs(prompt: string, workDir: string, opts: RunOptions): string[] { - const args = [ - "exec", + const args = opts.resumeSessionId + ? ["exec", "resume"] + : ["exec"]; + args.push( "--json", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", - "--ephemeral", - "-C", - workDir, - ]; + ); if (opts.model) args.push("-m", opts.model); + // Bun starts both initial and resumed sessions in workDir. Avoid also + // passing a relative -C path, which would resolve as workDir/workDir. + if (opts.resumeSessionId) args.push(opts.resumeSessionId); args.push(prompt); return args; } @@ -81,6 +83,7 @@ export class CodexRunner implements AgentRunner { let lastAgentMessage: string | null = null; let errorMessage: string | null = null; + let sessionId: string | null = null; const decoder = new TextDecoder(); const reader = proc.stdout.getReader(); @@ -96,12 +99,16 @@ export class CodexRunner implements AgentRunner { if (!line.trim()) continue; try { const event = JSON.parse(line); + if (event.type === "thread.started" && event.thread_id) { + sessionId = event.thread_id; + } if ( event.type === "item.completed" && event.item?.type === "agent_message" ) { - lastAgentMessage = event.item.text || ""; - if (onStatus) onStatus({ kind: "text", text: lastAgentMessage }); + const message = String(event.item.text || ""); + lastAgentMessage = message; + if (onStatus) onStatus({ kind: "text", text: message }); } if (event.type === "item.started" && event.item) { const summary = summarizeCodexItem(event.item); @@ -124,19 +131,19 @@ export class CodexRunner implements AgentRunner { if (exitCode === "timeout") { logger.error("codex task timed out", { durationMs }); - return { success: false, output: `Codex task timed out after ${TIMEOUT_MS / 60000} minutes`, durationMs }; + return { success: false, output: `Codex task timed out after ${TIMEOUT_MS / 60000} minutes`, durationMs, sessionId: sessionId ?? undefined }; } if (exitCode !== 0) { const stderr = await new Response(proc.stderr).text(); const output = errorMessage || stderr || "Codex task failed"; logger.error("codex task failed", { exitCode, output, durationMs }); - return { success: false, output, durationMs }; + return { success: false, output, durationMs, sessionId: sessionId ?? undefined }; } const finalOutput = lastAgentMessage || "Task completed (no output)"; logger.info("codex task completed", { durationMs }); - return { success: true, output: finalOutput, durationMs }; + return { success: true, output: finalOutput, durationMs, sessionId: sessionId ?? undefined }; } } diff --git a/src/sessions.test.ts b/src/sessions.test.ts index 69f7ccf..44aa57e 100644 --- a/src/sessions.test.ts +++ b/src/sessions.test.ts @@ -83,4 +83,25 @@ describe("SessionStore", () => { expect(store.getMode("slack:U999")).toBe("strict"); }); }); + + describe("agent sessions", () => { + it("persists a runner session for conversation continuity", () => { + store.setAgentSession("telegram:1", "codex", "thread-123"); + expect(store.getAgentSession("telegram:1", "codex")).toBe("thread-123"); + expect(store.getAgentSession("telegram:1", "claude-code")).toBeNull(); + }); + + it("replaces the session when the runner changes", () => { + store.setAgentSession("telegram:1", "claude-code", "claude-1"); + store.setAgentSession("telegram:1", "codex", "codex-1"); + expect(store.getAgentSession("telegram:1", "claude-code")).toBeNull(); + expect(store.getAgentSession("telegram:1", "codex")).toBe("codex-1"); + }); + + it("clear removes the persisted agent session", () => { + store.setAgentSession("telegram:1", "codex", "thread-123"); + store.clear("telegram:1"); + expect(store.getAgentSession("telegram:1", "codex")).toBeNull(); + }); + }); }); diff --git a/src/sessions.ts b/src/sessions.ts index 283dcff..09f5620 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -36,6 +36,14 @@ export class SessionStore { updated_at TEXT NOT NULL ) `); + this.db.run(` + CREATE TABLE IF NOT EXISTS agent_sessions ( + user_id TEXT PRIMARY KEY, + runner TEXT NOT NULL, + session_id TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `); } addMessage(userId: string, role: "user" | "assistant", content: string) { @@ -79,8 +87,24 @@ export class SessionStore { ); } + getAgentSession(userId: string, runner: string): string | null { + const row = this.db + .query(`SELECT session_id FROM agent_sessions WHERE user_id = ? AND runner = ?`) + .get(userId, runner) as { session_id: string } | null; + return row?.session_id ?? null; + } + + setAgentSession(userId: string, runner: string, sessionId: string): void { + this.db.run( + `INSERT INTO agent_sessions (user_id, runner, session_id, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET runner = excluded.runner, session_id = excluded.session_id, updated_at = excluded.updated_at`, + [userId, runner, sessionId, new Date().toISOString()] + ); + } + clear(userId: string) { this.db.run(`DELETE FROM chat_history WHERE user_id = ?`, [userId]); this.db.run(`DELETE FROM user_modes WHERE user_id = ?`, [userId]); + this.db.run(`DELETE FROM agent_sessions WHERE user_id = ?`, [userId]); } } diff --git a/src/setup.ts b/src/setup.ts index 74e0b68..d968fda 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -370,9 +370,14 @@ export async function runSetup(opts?: { fixOnly?: string[] }): Promise { } // Collect repos — GitHub sync or manual - const repos: Record = existingConfig.repos - ? { ...existingConfig.repos } - : {}; + const repos: Record = Object.fromEntries( + Object.entries(existingConfig.repos || {}) + .filter((entry): entry is [string, typeof entry[1] & { url: string }] => Boolean(entry[1].url)) + .map(([name, repo]) => [name, { + url: repo.url, + defaultBranch: repo.defaultBranch || "main", + }]), + ); let githubOrgs: string[] = existingConfig.github?.orgs || []; let useWildcard = false; diff --git a/src/worker.ts b/src/worker.ts index 7d72bd4..9eb8019 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -12,19 +12,6 @@ import type { AgentRunner, RunOptions, RunResult, StatusEvent, StreamEvent } fro import type { TraceStore } from "./trace"; import type { SessionManager } from "./session-manager"; import type { DebouncedFunction } from "./adapters/debounce"; -import { DiscussPool } from "./discuss-pool"; - -// Track last discuss session per user for conversation continuity -const discussSessions = new Map(); -const discussPool = new DiscussPool(); - -export function clearDiscussSession(userId: string) { - discussSessions.delete(userId); -} - -export function getDiscussPool(): DiscussPool { - return discussPool; -} export interface WorkerDeps { config: Config; @@ -170,6 +157,9 @@ async function processTask(task: Task, deps: WorkerDeps) { } const taskRunner = deps.getRunnerForRepo(task.repo); + const discussSessionId = isDiscuss + ? deps.sessions.getAgentSession(task.userId, taskRunner.name) + : null; const maxTurns = task.taskType === "cron" ? Math.max(deps.config.claude.maxTurns, 100) : isDiscuss @@ -179,27 +169,33 @@ async function processTask(task: Task, deps: WorkerDeps) { maxTurns, mcpConfigPath, signal: abortController.signal, - resumeSessionId: task.sessionId ?? undefined, + resumeSessionId: task.sessionId ?? discussSessionId ?? undefined, }); const useStreaming = !isDiscuss && task.taskType !== "cron" && typeof (taskRunner as any).runStreaming === "function"; - const hasExistingDiscuss = isDiscuss && discussPool.hasSession(task.userId); + const hasExistingDiscuss = isDiscuss && discussSessionId !== null; logger.info("task execution mode", { taskId: task.id, useStreaming, isDiscuss, taskType: task.taskType || "code", hasExistingDiscuss }); let result: RunResult; if (isDiscuss) { - // Discuss uses persistent tmux-based session — fast follow-ups - const poolResult = await discussPool.send( - task.userId, + // Discuss uses the configured runner's persisted non-interactive + // session. This avoids brittle TUI/tmux parsing and supports both + // Claude and Codex through the same AgentRunner contract. + result = await taskRunner.run( task.prompt, workDir, + runOpts, + (event: StatusEvent) => { + if (event.kind === "tool") { + const summary = `Using ${event.tool}...`; + if (statusLog.at(-1) !== summary) statusLog.push(summary); + } else { + statusLog.push(event.text.slice(0, 200)); + } + originalMsg?.updateStatus(statusLog.slice(-5).join("\n")); + }, ); - result = { - success: true, - output: poolResult.output, - durationMs: poolResult.durationMs, - }; } else if (useStreaming) { logger.info("starting streaming session", { taskId: task.id, repo: task.repo }); const session = (taskRunner as any).runStreaming( @@ -271,7 +267,7 @@ async function processTask(task: Task, deps: WorkerDeps) { // Store discuss session ID for conversation continuity if (isDiscuss && result.sessionId) { - discussSessions.set(task.userId, result.sessionId); + deps.sessions.setAgentSession(task.userId, taskRunner.name, result.sessionId); logger.info("discuss session stored", { userId: task.userId, sessionId: result.sessionId }); }