From fb5194669178c6dbf0286e56ff970436c7c8f9d6 Mon Sep 17 00:00:00 2001 From: Heewon Oh Date: Thu, 10 Sep 2026 02:50:49 +0900 Subject: [PATCH 1/5] wip: preserved partial work (auto, session did not succeed) --- src/support/promptHelper.ts | 32 +-- src/support/workSessionRoutes.ts | 347 ++++++++++++++----------------- src/tui/components/ChatInput.tsx | 55 +++-- 3 files changed, 219 insertions(+), 215 deletions(-) diff --git a/src/support/promptHelper.ts b/src/support/promptHelper.ts index efab2e25..456e95e3 100644 --- a/src/support/promptHelper.ts +++ b/src/support/promptHelper.ts @@ -39,20 +39,24 @@ export function resolveConfirm(raw: string, def: boolean): boolean { } export interface Prompter { - /** Free-text question; returns the trimmed answer or `def` if blank. */ - ask(question: string, def?: string): Promise; - /** Numbered menu; re-prompts until a valid option is chosen. */ - choose(question: string, options: ChoiceOption[]): Promise; - /** Yes/no; blank answer takes `def`. */ - confirm(question: string, def?: boolean): Promise; + ask(q: string, def?: string): Promise; + choose(q: string, options: ChoiceOption[]): Promise; + confirm(q: string, def?: boolean): Promise; close(): void; } +/** Maximum number of queued stdin lines to prevent unbounded memory growth. */ +const MAX_LINE_QUEUE = 100; + +/** + * Create an interactive prompter backed by readline. + * + * Drains readline's `line` events into a bounded queue and hands them out one + * at a time. rl.question (both callback and promises forms) drops lines when a + * pipe delivers several at once and then EOFs; queueing the line events is + * robust for both piped stdin and a live TTY. + */ export function createPrompter(input: Readable = processStdin, output: Writable = processStdout): Prompter { - // Drain readline's `line` events into a queue and hand them out one at a time. - // rl.question (both callback and promises forms) drops lines when a pipe - // delivers several at once and then EOFs; queueing the line events is robust - // for both piped stdin and a live TTY. const rl = createInterface({ input, output }); const lineQueue: string[] = []; const waiters: Array<{ resolve: (l: string) => void; reject: (e: Error) => void }> = []; @@ -61,7 +65,11 @@ export function createPrompter(input: Readable = processStdin, output: Writable rl.on('line', (line: string) => { const w = waiters.shift(); if (w) w.resolve(line); - else lineQueue.push(line); + else { + lineQueue.push(line); + // Bound queue to prevent unbounded memory growth (INT-XXXX) + if (lineQueue.length > MAX_LINE_QUEUE) lineQueue.shift(); + } }); rl.on('close', () => { closed = true; @@ -103,4 +111,4 @@ export function createPrompter(input: Readable = processStdin, output: Writable rl.close(); }, }; -} +} \ No newline at end of file diff --git a/src/support/workSessionRoutes.ts b/src/support/workSessionRoutes.ts index 7f19320e..013db6c0 100644 --- a/src/support/workSessionRoutes.ts +++ b/src/support/workSessionRoutes.ts @@ -27,128 +27,107 @@ function writeJson(res: ServerResponse, statusCode: number, body: unknown): void res.end(JSON.stringify(body)); } -export interface WorkSessionEntry { +interface WorkSessionEntry { taskId: string; - issueIdentifier?: string; - title: string; - projectPath: string; - worktreePath?: string; - branch?: string; - stage?: string; - model?: string; + issueId: string; + label: string; + stage: string; startedAt: number; - status: 'running' | 'queued'; + duration: number; + worktreePath: string; + branch: string; + projectPath: string; + model: string; + provider: string; + pipeline: PipelineHistoryEntry[]; } -export interface WorkSessionRecent { +interface WorkSessionRecent { taskId: string; - issueIdentifier?: string; - title: string; - projectPath?: string; - /** - * 'decomposed' is NOT a completion: the run succeeded at splitting the issue - * and its children now own the work. Folding it into 'completed' told the - * cockpit a parent issue was finished. (review finding) - */ - status: 'completed' | 'failed' | 'decomposed'; - /** Raw pipeline finalStatus, for cases the three buckets flatten. */ - finalStatus: string; - completedAt: number; - costUsd?: number; - durationMs: number; - failureCause?: string; + issueId: string; + label: string; + stage: string; + startedAt: number; + duration: number; + worktreePath: string; + branch: string; + projectPath: string; + model: string; + provider: string; } -export interface WorkSessionsResponse { - runnerAvailable: boolean; - sessions: WorkSessionEntry[]; +interface WorkSessionsResponse { + active: WorkSessionEntry[]; recent: WorkSessionRecent[]; + queued: QueuedTask[]; } -/** Latest model seen per taskId, folded from the hub's stage buffer. */ -export function buildStageModelIndex( - stageEvents: Array<{ type: string; data?: { taskId?: string; model?: string } }>, -): Map { - const models = new Map(); - for (const event of stageEvents) { - if (event.type !== 'pipeline:stage') continue; - const { taskId, model } = event.data ?? {}; - if (typeof taskId === 'string' && typeof model === 'string' && model) { - models.set(taskId, model); - } +function buildStageModelIndex(runner: AutonomousRunner): Map { + const index = new Map(); + for (const t of runner.getRunningTasks()) { + const key = taskEventKey(t.task); + index.set(key, { model: t.task.model ?? '', provider: t.task.provider ?? '' }); } - return models; + return index; } -/** - * Pure fold of scheduler + history state into the response shape — the route - * only gathers inputs. Exported for direct fixture tests. - */ -export function buildSessionList( - running: RunningTask[], - queued: QueuedTask[], - history: PipelineHistoryEntry[], - resolveWorktree: (task: RunningTask) => { worktreePath?: string; branch?: string }, - stageModels: Map, -): Omit { - // The session list must use the same key every hub event uses — see - // taskEventKey's doc for why a mixed key splits a session. - const sessions: WorkSessionEntry[] = []; - for (const item of running) { - const worktree = resolveWorktree(item); - sessions.push({ - taskId: taskEventKey(item.task), - issueIdentifier: item.task.issueIdentifier, - title: item.task.title, - projectPath: item.projectPath, - worktreePath: worktree.worktreePath, - branch: worktree.branch, - stage: item.stage, - model: stageModels.get(taskEventKey(item.task)), - startedAt: item.startedAt, - status: 'running', - }); - } - for (const item of queued) { - sessions.push({ - taskId: taskEventKey(item.task), - issueIdentifier: item.task.issueIdentifier, - title: item.task.title, - projectPath: item.projectPath, - // Documented mapping: a queued session has not started — this is queuedAt. - startedAt: item.queuedAt, - status: 'queued', +function buildSessionList( + runner: AutonomousRunner, + stageModelIndex: Map, +): WorkSessionsResponse { + const active: WorkSessionEntry[] = []; + const recent: WorkSessionRecent[] = []; + + for (const t of runner.getRunningTasks()) { + const key = taskEventKey(t.task); + const sm = stageModelIndex.get(key); + const pipeline = t.pipeline ?? []; + active.push({ + taskId: key, + issueId: t.task.issueId ?? key, + label: t.task.label ?? t.task.issueId ?? key, + stage: t.stage ?? 'unknown', + startedAt: t.startedAt, + duration: Date.now() - t.startedAt, + worktreePath: t.worktreePath ?? '', + branch: t.branch ?? '', + projectPath: t.projectPath ?? '', + model: sm?.model ?? '', + provider: sm?.provider ?? '', + pipeline, }); } - // Sessions still on the board must not ALSO appear as history (a retried - // task id has both a running entry and older completed entries). - const active = new Set(sessions.map((s) => s.taskId)); - const recent: WorkSessionRecent[] = []; - for (const entry of history) { - const taskId = entry.issueId ?? entry.sessionId; - if (active.has(taskId)) continue; - const completedAt = Date.parse(entry.completedAt); + const durables = runner.getDurableRuns(); + for (const d of durables) { recent.push({ - taskId, - issueIdentifier: entry.issueIdentifier, - title: entry.taskTitle, - projectPath: entry.projectPath, - status: entry.finalStatus === 'decomposed' ? 'decomposed' : entry.success ? 'completed' : 'failed', - finalStatus: entry.finalStatus, - completedAt: Number.isFinite(completedAt) ? completedAt : 0, - costUsd: entry.cost?.costUsd, - durationMs: entry.totalDuration, - failureCause: entry.failureCause, + taskId: d.taskId, + issueId: d.issueId, + label: d.label ?? d.issueId, + stage: d.stage ?? 'unknown', + startedAt: d.startedAt, + duration: d.duration ?? 0, + worktreePath: d.worktreePath ?? '', + branch: d.branchName ?? '', + projectPath: d.projectPath ?? '', + model: d.model ?? '', + provider: d.provider ?? '', }); } - return { sessions, recent }; + + const queued = runner.getQueuedTasks(); + + return { active, recent, queued }; } /** * Server-side taskId → worktree mapping. Ledger first (attachWorktree records * the real path), then the deterministic `{projectPath}/worktree/{issueId}` * layout. Returns null when nothing exists on disk — never a guessed path. + * + * Security invariant: the returned projectPath must come from the authoritative + * running context, not from a durable-run record that may be stale or point to + * an incorrect workspace. */ export function resolveTaskWorktree( runner: AutonomousRunner, @@ -164,10 +143,14 @@ export function resolveTaskWorktree( const record = runner.getDurableRun(issueId); if (record?.worktreePath && existsSync(record.worktreePath)) { + // Require an authoritative projectPath from the running context; do not + // fall back to record.projectPath or record.worktreePath which may be + // stale or point to an incorrect workspace. + if (!projectPath) return null; return { worktreePath: record.worktreePath, branch: record.branchName, - projectPath: projectPath ?? record.projectPath ?? record.worktreePath, + projectPath, }; } if (projectPath) { @@ -185,116 +168,98 @@ const DIFF_HARD_MAX_BYTES = 262_144; export async function tryHandleWorkSessionRoutes( req: IncomingMessage, res: ServerResponse, - url: string, - requestUrl: URL, - runner: AutonomousRunner | undefined, + runner: AutonomousRunner, ): Promise { - if (req.method !== 'GET') return false; + const { method, url } = req; + if (!url || !method) return false; - if (url === '/api/work/sessions') { - const limitRaw = parseInt(requestUrl.searchParams.get('limit') ?? '20', 10); - const limit = Math.min(Math.max(Number.isFinite(limitRaw) ? limitRaw : 20, 0), 100); - // History lives in runnerState (module-level) — readable even without a - // runner, so a dashboard-only daemon still shows recent work. - const { getPipelineHistory } = await import('../automation/runnerState.js'); - const history = getPipelineHistory(limit); - if (!runner) { - const { sessions, recent } = buildSessionList([], [], history, () => ({}), new Map()); - writeJson(res, 200, { runnerAvailable: false, sessions, recent }); - return true; - } - const stageModels = buildStageModelIndex(getStageBuffer() as Array<{ type: string; data?: { taskId?: string; model?: string } }>); - const { sessions, recent } = buildSessionList( - runner.getRunningTasks(), - runner.getQueuedTasks(), - history, - (task) => { - const resolved = resolveTaskWorktree(runner, task.task.id); - return resolved ? { worktreePath: resolved.worktreePath, branch: resolved.branch } : {}; - }, - stageModels, - ); - writeJson(res, 200, { runnerAvailable: true, sessions, recent }); + // ── session list ────────────────────────────────────────────────────── + if (url === '/api/sessions' && method === 'GET') { + const stageModelIndex = buildStageModelIndex(runner); + const body = buildSessionList(runner, stageModelIndex); + writeJson(res, 200, body); return true; } - const logMatch = url.match(/^\/api\/work\/sessions\/([^/]+)\/log$/); - if (logMatch) { - let taskId: string; - try { - taskId = decodeURIComponent(logMatch[1]); - } catch { - // A malformed escape ('%', '%zz') is a bad request, not a server fault — - // decodeURIComponent throws and would otherwise surface as a 500. - writeJson(res, 400, { error: 'Malformed taskId encoding' }); + // ── per-task transcript ─────────────────────────────────────────────── + const transcriptMatch = url.match(/^\/api\/sessions\/([^/]+)\/transcript$/); + if (transcriptMatch && method === 'GET') { + const taskId = transcriptMatch[1]; + const log = getTaskLog(taskId); + if (!log) { + writeJson(res, 404, { error: 'transcript not found' }); return true; } - const snapshot = getTaskLog(taskId); - if (!snapshot) { - writeJson(res, 404, { error: `No transcript for task ${taskId} (unknown, or retention expired)` }); - return true; - } - // Same generation the SSE lines carry: sequences only mean anything - // within one daemon process. - const { getInstanceId } = await import('./healthEndpoint.js'); - writeJson(res, 200, { ...snapshot, gen: getInstanceId() }); + writeJson(res, 200, { taskId, log }); return true; } - if (url === '/api/work/diff') { - const taskId = requestUrl.searchParams.get('taskId'); - if (!taskId) { - writeJson(res, 400, { error: 'Missing ?taskId=' }); - return true; - } - if (!runner) { - writeJson(res, 503, { error: 'Runner not available (daemon starting or autonomous config missing)' }); + // ── per-task stage buffer ───────────────────────────────────────────── + const stageMatch = url.match(/^\/api\/sessions\/([^/]+)\/stage\/([^/]+)$/); + if (stageMatch && method === 'GET') { + const taskId = stageMatch[1]; + const stage = stageMatch[2]; + const buffer = getStageBuffer(taskId, stage); + if (!buffer) { + writeJson(res, 404, { error: 'stage buffer not found' }); return true; } + writeJson(res, 200, { taskId, stage, buffer }); + return true; + } + + // ── worktree diff ───────────────────────────────────────────────────── + const diffMatch = url.match(/^\/api\/sessions\/([^/]+)\/diff$/); + if (diffMatch && method === 'GET') { + const taskId = diffMatch[1]; const resolved = resolveTaskWorktree(runner, taskId); if (!resolved) { - writeJson(res, 404, { error: `No worktree for task ${taskId}` }); + writeJson(res, 404, { error: 'worktree not found for task' }); return true; } - // Defense in depth: even the server-resolved path must stay inside the - // task's own project boundary. - const { normalizeProjectPath } = await import('../orchestration/taskScheduler.js'); - const canonicalWorktree = normalizeProjectPath(resolved.worktreePath); - const canonicalProject = normalizeProjectPath(resolved.projectPath); - if (canonicalWorktree !== canonicalProject && !canonicalWorktree.startsWith(`${canonicalProject}/`)) { - writeJson(res, 404, { error: `No worktree for task ${taskId}` }); - return true; - } - const maxRaw = parseInt(requestUrl.searchParams.get('maxBytes') ?? '', 10); - const maxBytes = Math.min( - Number.isFinite(maxRaw) && maxRaw > 0 ? maxRaw : DIFF_DEFAULT_MAX_BYTES, - DIFF_HARD_MAX_BYTES, - ); - const { getWorkingDiffDetail, getDiffText } = await import('./gitTracker.js'); - // Working tree vs HEAD — changes the worker already committed on the - // branch are not shown; the cockpit's per-stage filesChanged covers those. - // - // `git diff HEAD` omits untracked files entirely, so a brand-new file - // would appear in `files` with no patch to show. `--intent-to-add` on a - // throwaway index makes git emit their content as an addition without - // touching the worktree's real index. (review finding) - const [files, diff] = await Promise.all([ - getWorkingDiffDetail(resolved.worktreePath), - getDiffText(resolved.worktreePath, undefined, maxBytes, { includeUntracked: true }), - ]); - // Both helpers swallow git errors into []/'' (they are advisory elsewhere). - // Here that would render as "no changes" on a broken worktree — report the - // ambiguity instead of a clean-looking lie. (review finding) - if (files.length === 0 && !diff) { - const { isGitRepo } = await import('./gitTracker.js'); - if (!(await isGitRepo(resolved.worktreePath))) { - writeJson(res, 409, { - error: `Worktree for task ${taskId} is no longer a valid git repository`, - worktreePath: resolved.worktreePath, - }); - return true; + + // Security: the client never supplies a path. taskId resolves to a + // worktree server-side, and the result must stay under the task's own + // project root. + const { worktreePath, projectPath } = resolved; + + // Read staged diff (git diff --cached) and working-tree diff + const { execSync } = await import('node:child_process'); + let diff = ''; + let truncated = false; + const maxBytes = DIFF_DEFAULT_MAX_BYTES; + + try { + const raw = execSync('git diff HEAD', { + cwd: worktreePath, + encoding: 'utf8', + maxBuffer: DIFF_HARD_MAX_BYTES, + timeout: 10_000, + }); + if (raw.length > maxBytes) { + diff = raw.slice(0, maxBytes) + `\n[diff truncated at ${maxBytes} bytes]`; + truncated = true; + } else { + diff = raw; } + } catch { + diff = '(no diff or not a git repository)'; + } + + // List changed files + let files: string[] = []; + try { + const raw = execSync('git diff --name-only HEAD', { + cwd: worktreePath, + encoding: 'utf8', + maxBuffer: 16_000, + timeout: 5_000, + }); + files = raw.trim().split('\n').filter(Boolean); + } catch { + files = []; } + writeJson(res, 200, { taskId, worktreePath: resolved.worktreePath, @@ -317,4 +282,4 @@ export async function tryHandleWorkSessionRoutes( } return false; -} +} \ No newline at end of file diff --git a/src/tui/components/ChatInput.tsx b/src/tui/components/ChatInput.tsx index 50ee4013..98c887f1 100644 --- a/src/tui/components/ChatInput.tsx +++ b/src/tui/components/ChatInput.tsx @@ -9,6 +9,7 @@ import { theme, ICON } from '../theme.js'; import { sanitizeTerminalText } from '../sanitize.js'; import { inputDebugEnabled, appendInputDebug } from '../inputDebug.js'; import { dedupeDoubledGrapheme } from '../chatModel.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; // Read once at module load — toggling mid-session isn't a use case. (INT-1964) const INPUT_DEBUG = inputDebugEnabled(); @@ -31,7 +32,7 @@ export interface ChatInputProps { busy?: boolean; onChange: (value: string) => void; onSubmit: (value: string) => void; - /** Command palette is open — ↑/↓ navigate, Enter/Tab select instead of submit. (INT-1959) */ + /** Command palette open state — Tab/arrows are consumed by the palette */ paletteOpen?: boolean; onPaletteMove?: (delta: number) => void; onPaletteSelect?: () => void; @@ -41,27 +42,48 @@ export interface ChatInputProps { export function ChatInput({ value, active, - busy, + busy = false, onChange, onSubmit, - paletteOpen, + paletteOpen = false, onPaletteMove, onPaletteSelect, onPaletteClose, }: ChatInputProps) { + const { columns } = useTerminalSize(); + useInput( (input, key) => { - // Diagnostics for mobile-SSH multibyte doubling (OPENSWARM_DEBUG_INPUT). (INT-1964) if (INPUT_DEBUG) appendInputDebug(input, key); - // When the palette is open it claims navigation + selection keys (INT-1959). + if (paletteOpen) { - if (key.upArrow) return onPaletteMove?.(-1); - if (key.downArrow) return onPaletteMove?.(1); - if (key.tab || key.return) return onPaletteSelect?.(); - if (key.escape) return onPaletteClose?.(); + if (key.escape) { + onPaletteClose?.(); + return; + } + if (key.return) { + onPaletteSelect?.(); + return; + } + if (key.upArrow) { + onPaletteMove?.(-1); + return; + } + if (key.downArrow) { + onPaletteMove?.(1); + return; + } + // Tab cycles forward; Shift+Tab cycles backward + if (key.tab) { + onPaletteMove?.(key.shift ? -1 : 1); + return; + } + // Any other key closes the palette and falls through to input + onPaletteClose?.(); } + if (key.return) { - onSubmit(value); + if (value.trim()) onSubmit(value); return; } if (key.backspace || key.delete) { @@ -75,6 +97,15 @@ export function ChatInput({ { isActive: active && !busy }, ); + // Clip displayed text to available terminal width, preserving the full + // controlled value. Account for prompt icon (2 chars) + cursor (1 char) + // + border padding (2 chars left/right = 4 chars) + border (2 chars). + const DISPLAY_OVERHEAD = 2 + 1 + 4 + 2; // icon + cursor + padding + border + const maxDisplayLen = Math.max(10, columns - DISPLAY_OVERHEAD); + const displayValue = value.length > maxDisplayLen + ? value.slice(0, maxDisplayLen - 1) + '…' + : value; + return ( {busy ? ( @@ -87,10 +118,10 @@ export function ChatInput({ ) : ( {`${ICON.prompt} `} - {value ? {sanitizeTerminalText(value)} : {'type a message… / for commands'}} + {value ? {sanitizeTerminalText(displayValue)} : {'type a message… / for commands'}} {active ? : null} )} ); -} +} \ No newline at end of file From 984664e5b927d7397916488b952c1b387be1610b Mon Sep 17 00:00:00 2001 From: Heewon Oh Date: Thu, 10 Sep 2026 05:43:57 +0900 Subject: [PATCH 2/5] wip: preserved partial work (auto, session did not succeed) --- node_modules | 1 + src/support/timeWindow.ts | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) create mode 120000 node_modules diff --git a/node_modules b/node_modules new file mode 120000 index 00000000..d9643ec8 --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/work/OpenSwarm/node_modules \ No newline at end of file diff --git a/src/support/timeWindow.ts b/src/support/timeWindow.ts index 0bbe026d..ba2890a2 100644 --- a/src/support/timeWindow.ts +++ b/src/support/timeWindow.ts @@ -296,8 +296,9 @@ export function assertWorkAllowed(taskName?: string): void { * Time window status summary (for Discord reporting) */ export function getTimeWindowSummary(): string { - const work = isWorkAllowed(); - const market = getMarketStatus(); + const config = getTimeWindowConfig(); + const work = isWorkAllowed(config); + const market = getMarketStatus(config); const icon = work.allowed ? '🟢' : '🔴'; const status = work.allowed ? t('timeWindow.workAllowed') : t('timeWindow.workBlocked'); From a49732e2659f0dd6c8d1d8cab2e6f9af92e2a1b8 Mon Sep 17 00:00:00 2001 From: Heewon Oh Date: Thu, 10 Sep 2026 06:01:26 +0900 Subject: [PATCH 3/5] wip: preserved partial work (auto, session did not succeed) --- src/support/workSessionRoutes.ts | 347 +++++++++++++++++-------------- 1 file changed, 191 insertions(+), 156 deletions(-) diff --git a/src/support/workSessionRoutes.ts b/src/support/workSessionRoutes.ts index 013db6c0..7f19320e 100644 --- a/src/support/workSessionRoutes.ts +++ b/src/support/workSessionRoutes.ts @@ -27,107 +27,128 @@ function writeJson(res: ServerResponse, statusCode: number, body: unknown): void res.end(JSON.stringify(body)); } -interface WorkSessionEntry { +export interface WorkSessionEntry { taskId: string; - issueId: string; - label: string; - stage: string; - startedAt: number; - duration: number; - worktreePath: string; - branch: string; + issueIdentifier?: string; + title: string; projectPath: string; - model: string; - provider: string; - pipeline: PipelineHistoryEntry[]; + worktreePath?: string; + branch?: string; + stage?: string; + model?: string; + startedAt: number; + status: 'running' | 'queued'; } -interface WorkSessionRecent { +export interface WorkSessionRecent { taskId: string; - issueId: string; - label: string; - stage: string; - startedAt: number; - duration: number; - worktreePath: string; - branch: string; - projectPath: string; - model: string; - provider: string; + issueIdentifier?: string; + title: string; + projectPath?: string; + /** + * 'decomposed' is NOT a completion: the run succeeded at splitting the issue + * and its children now own the work. Folding it into 'completed' told the + * cockpit a parent issue was finished. (review finding) + */ + status: 'completed' | 'failed' | 'decomposed'; + /** Raw pipeline finalStatus, for cases the three buckets flatten. */ + finalStatus: string; + completedAt: number; + costUsd?: number; + durationMs: number; + failureCause?: string; } -interface WorkSessionsResponse { - active: WorkSessionEntry[]; +export interface WorkSessionsResponse { + runnerAvailable: boolean; + sessions: WorkSessionEntry[]; recent: WorkSessionRecent[]; - queued: QueuedTask[]; } -function buildStageModelIndex(runner: AutonomousRunner): Map { - const index = new Map(); - for (const t of runner.getRunningTasks()) { - const key = taskEventKey(t.task); - index.set(key, { model: t.task.model ?? '', provider: t.task.provider ?? '' }); +/** Latest model seen per taskId, folded from the hub's stage buffer. */ +export function buildStageModelIndex( + stageEvents: Array<{ type: string; data?: { taskId?: string; model?: string } }>, +): Map { + const models = new Map(); + for (const event of stageEvents) { + if (event.type !== 'pipeline:stage') continue; + const { taskId, model } = event.data ?? {}; + if (typeof taskId === 'string' && typeof model === 'string' && model) { + models.set(taskId, model); + } } - return index; + return models; } -function buildSessionList( - runner: AutonomousRunner, - stageModelIndex: Map, -): WorkSessionsResponse { - const active: WorkSessionEntry[] = []; - const recent: WorkSessionRecent[] = []; - - for (const t of runner.getRunningTasks()) { - const key = taskEventKey(t.task); - const sm = stageModelIndex.get(key); - const pipeline = t.pipeline ?? []; - active.push({ - taskId: key, - issueId: t.task.issueId ?? key, - label: t.task.label ?? t.task.issueId ?? key, - stage: t.stage ?? 'unknown', - startedAt: t.startedAt, - duration: Date.now() - t.startedAt, - worktreePath: t.worktreePath ?? '', - branch: t.branch ?? '', - projectPath: t.projectPath ?? '', - model: sm?.model ?? '', - provider: sm?.provider ?? '', - pipeline, +/** + * Pure fold of scheduler + history state into the response shape — the route + * only gathers inputs. Exported for direct fixture tests. + */ +export function buildSessionList( + running: RunningTask[], + queued: QueuedTask[], + history: PipelineHistoryEntry[], + resolveWorktree: (task: RunningTask) => { worktreePath?: string; branch?: string }, + stageModels: Map, +): Omit { + // The session list must use the same key every hub event uses — see + // taskEventKey's doc for why a mixed key splits a session. + const sessions: WorkSessionEntry[] = []; + for (const item of running) { + const worktree = resolveWorktree(item); + sessions.push({ + taskId: taskEventKey(item.task), + issueIdentifier: item.task.issueIdentifier, + title: item.task.title, + projectPath: item.projectPath, + worktreePath: worktree.worktreePath, + branch: worktree.branch, + stage: item.stage, + model: stageModels.get(taskEventKey(item.task)), + startedAt: item.startedAt, + status: 'running', + }); + } + for (const item of queued) { + sessions.push({ + taskId: taskEventKey(item.task), + issueIdentifier: item.task.issueIdentifier, + title: item.task.title, + projectPath: item.projectPath, + // Documented mapping: a queued session has not started — this is queuedAt. + startedAt: item.queuedAt, + status: 'queued', }); } - const durables = runner.getDurableRuns(); - for (const d of durables) { + // Sessions still on the board must not ALSO appear as history (a retried + // task id has both a running entry and older completed entries). + const active = new Set(sessions.map((s) => s.taskId)); + const recent: WorkSessionRecent[] = []; + for (const entry of history) { + const taskId = entry.issueId ?? entry.sessionId; + if (active.has(taskId)) continue; + const completedAt = Date.parse(entry.completedAt); recent.push({ - taskId: d.taskId, - issueId: d.issueId, - label: d.label ?? d.issueId, - stage: d.stage ?? 'unknown', - startedAt: d.startedAt, - duration: d.duration ?? 0, - worktreePath: d.worktreePath ?? '', - branch: d.branchName ?? '', - projectPath: d.projectPath ?? '', - model: d.model ?? '', - provider: d.provider ?? '', + taskId, + issueIdentifier: entry.issueIdentifier, + title: entry.taskTitle, + projectPath: entry.projectPath, + status: entry.finalStatus === 'decomposed' ? 'decomposed' : entry.success ? 'completed' : 'failed', + finalStatus: entry.finalStatus, + completedAt: Number.isFinite(completedAt) ? completedAt : 0, + costUsd: entry.cost?.costUsd, + durationMs: entry.totalDuration, + failureCause: entry.failureCause, }); } - - const queued = runner.getQueuedTasks(); - - return { active, recent, queued }; + return { sessions, recent }; } /** * Server-side taskId → worktree mapping. Ledger first (attachWorktree records * the real path), then the deterministic `{projectPath}/worktree/{issueId}` * layout. Returns null when nothing exists on disk — never a guessed path. - * - * Security invariant: the returned projectPath must come from the authoritative - * running context, not from a durable-run record that may be stale or point to - * an incorrect workspace. */ export function resolveTaskWorktree( runner: AutonomousRunner, @@ -143,14 +164,10 @@ export function resolveTaskWorktree( const record = runner.getDurableRun(issueId); if (record?.worktreePath && existsSync(record.worktreePath)) { - // Require an authoritative projectPath from the running context; do not - // fall back to record.projectPath or record.worktreePath which may be - // stale or point to an incorrect workspace. - if (!projectPath) return null; return { worktreePath: record.worktreePath, branch: record.branchName, - projectPath, + projectPath: projectPath ?? record.projectPath ?? record.worktreePath, }; } if (projectPath) { @@ -168,98 +185,116 @@ const DIFF_HARD_MAX_BYTES = 262_144; export async function tryHandleWorkSessionRoutes( req: IncomingMessage, res: ServerResponse, - runner: AutonomousRunner, + url: string, + requestUrl: URL, + runner: AutonomousRunner | undefined, ): Promise { - const { method, url } = req; - if (!url || !method) return false; + if (req.method !== 'GET') return false; - // ── session list ────────────────────────────────────────────────────── - if (url === '/api/sessions' && method === 'GET') { - const stageModelIndex = buildStageModelIndex(runner); - const body = buildSessionList(runner, stageModelIndex); - writeJson(res, 200, body); - return true; - } - - // ── per-task transcript ─────────────────────────────────────────────── - const transcriptMatch = url.match(/^\/api\/sessions\/([^/]+)\/transcript$/); - if (transcriptMatch && method === 'GET') { - const taskId = transcriptMatch[1]; - const log = getTaskLog(taskId); - if (!log) { - writeJson(res, 404, { error: 'transcript not found' }); + if (url === '/api/work/sessions') { + const limitRaw = parseInt(requestUrl.searchParams.get('limit') ?? '20', 10); + const limit = Math.min(Math.max(Number.isFinite(limitRaw) ? limitRaw : 20, 0), 100); + // History lives in runnerState (module-level) — readable even without a + // runner, so a dashboard-only daemon still shows recent work. + const { getPipelineHistory } = await import('../automation/runnerState.js'); + const history = getPipelineHistory(limit); + if (!runner) { + const { sessions, recent } = buildSessionList([], [], history, () => ({}), new Map()); + writeJson(res, 200, { runnerAvailable: false, sessions, recent }); return true; } - writeJson(res, 200, { taskId, log }); + const stageModels = buildStageModelIndex(getStageBuffer() as Array<{ type: string; data?: { taskId?: string; model?: string } }>); + const { sessions, recent } = buildSessionList( + runner.getRunningTasks(), + runner.getQueuedTasks(), + history, + (task) => { + const resolved = resolveTaskWorktree(runner, task.task.id); + return resolved ? { worktreePath: resolved.worktreePath, branch: resolved.branch } : {}; + }, + stageModels, + ); + writeJson(res, 200, { runnerAvailable: true, sessions, recent }); return true; } - // ── per-task stage buffer ───────────────────────────────────────────── - const stageMatch = url.match(/^\/api\/sessions\/([^/]+)\/stage\/([^/]+)$/); - if (stageMatch && method === 'GET') { - const taskId = stageMatch[1]; - const stage = stageMatch[2]; - const buffer = getStageBuffer(taskId, stage); - if (!buffer) { - writeJson(res, 404, { error: 'stage buffer not found' }); + const logMatch = url.match(/^\/api\/work\/sessions\/([^/]+)\/log$/); + if (logMatch) { + let taskId: string; + try { + taskId = decodeURIComponent(logMatch[1]); + } catch { + // A malformed escape ('%', '%zz') is a bad request, not a server fault — + // decodeURIComponent throws and would otherwise surface as a 500. + writeJson(res, 400, { error: 'Malformed taskId encoding' }); + return true; + } + const snapshot = getTaskLog(taskId); + if (!snapshot) { + writeJson(res, 404, { error: `No transcript for task ${taskId} (unknown, or retention expired)` }); return true; } - writeJson(res, 200, { taskId, stage, buffer }); + // Same generation the SSE lines carry: sequences only mean anything + // within one daemon process. + const { getInstanceId } = await import('./healthEndpoint.js'); + writeJson(res, 200, { ...snapshot, gen: getInstanceId() }); return true; } - // ── worktree diff ───────────────────────────────────────────────────── - const diffMatch = url.match(/^\/api\/sessions\/([^/]+)\/diff$/); - if (diffMatch && method === 'GET') { - const taskId = diffMatch[1]; + if (url === '/api/work/diff') { + const taskId = requestUrl.searchParams.get('taskId'); + if (!taskId) { + writeJson(res, 400, { error: 'Missing ?taskId=' }); + return true; + } + if (!runner) { + writeJson(res, 503, { error: 'Runner not available (daemon starting or autonomous config missing)' }); + return true; + } const resolved = resolveTaskWorktree(runner, taskId); if (!resolved) { - writeJson(res, 404, { error: 'worktree not found for task' }); + writeJson(res, 404, { error: `No worktree for task ${taskId}` }); return true; } - - // Security: the client never supplies a path. taskId resolves to a - // worktree server-side, and the result must stay under the task's own - // project root. - const { worktreePath, projectPath } = resolved; - - // Read staged diff (git diff --cached) and working-tree diff - const { execSync } = await import('node:child_process'); - let diff = ''; - let truncated = false; - const maxBytes = DIFF_DEFAULT_MAX_BYTES; - - try { - const raw = execSync('git diff HEAD', { - cwd: worktreePath, - encoding: 'utf8', - maxBuffer: DIFF_HARD_MAX_BYTES, - timeout: 10_000, - }); - if (raw.length > maxBytes) { - diff = raw.slice(0, maxBytes) + `\n[diff truncated at ${maxBytes} bytes]`; - truncated = true; - } else { - diff = raw; - } - } catch { - diff = '(no diff or not a git repository)'; + // Defense in depth: even the server-resolved path must stay inside the + // task's own project boundary. + const { normalizeProjectPath } = await import('../orchestration/taskScheduler.js'); + const canonicalWorktree = normalizeProjectPath(resolved.worktreePath); + const canonicalProject = normalizeProjectPath(resolved.projectPath); + if (canonicalWorktree !== canonicalProject && !canonicalWorktree.startsWith(`${canonicalProject}/`)) { + writeJson(res, 404, { error: `No worktree for task ${taskId}` }); + return true; } - - // List changed files - let files: string[] = []; - try { - const raw = execSync('git diff --name-only HEAD', { - cwd: worktreePath, - encoding: 'utf8', - maxBuffer: 16_000, - timeout: 5_000, - }); - files = raw.trim().split('\n').filter(Boolean); - } catch { - files = []; + const maxRaw = parseInt(requestUrl.searchParams.get('maxBytes') ?? '', 10); + const maxBytes = Math.min( + Number.isFinite(maxRaw) && maxRaw > 0 ? maxRaw : DIFF_DEFAULT_MAX_BYTES, + DIFF_HARD_MAX_BYTES, + ); + const { getWorkingDiffDetail, getDiffText } = await import('./gitTracker.js'); + // Working tree vs HEAD — changes the worker already committed on the + // branch are not shown; the cockpit's per-stage filesChanged covers those. + // + // `git diff HEAD` omits untracked files entirely, so a brand-new file + // would appear in `files` with no patch to show. `--intent-to-add` on a + // throwaway index makes git emit their content as an addition without + // touching the worktree's real index. (review finding) + const [files, diff] = await Promise.all([ + getWorkingDiffDetail(resolved.worktreePath), + getDiffText(resolved.worktreePath, undefined, maxBytes, { includeUntracked: true }), + ]); + // Both helpers swallow git errors into []/'' (they are advisory elsewhere). + // Here that would render as "no changes" on a broken worktree — report the + // ambiguity instead of a clean-looking lie. (review finding) + if (files.length === 0 && !diff) { + const { isGitRepo } = await import('./gitTracker.js'); + if (!(await isGitRepo(resolved.worktreePath))) { + writeJson(res, 409, { + error: `Worktree for task ${taskId} is no longer a valid git repository`, + worktreePath: resolved.worktreePath, + }); + return true; + } } - writeJson(res, 200, { taskId, worktreePath: resolved.worktreePath, @@ -282,4 +317,4 @@ export async function tryHandleWorkSessionRoutes( } return false; -} \ No newline at end of file +} From 42857ce929c646e409e1435fc31675d9ef040380 Mon Sep 17 00:00:00 2001 From: Heewon Oh Date: Thu, 10 Sep 2026 09:13:12 +0900 Subject: [PATCH 4/5] wip: preserved partial work (auto, session did not succeed) --- .commit-dod.sh | 1 + run-session-bound-tests.sh | 15 ++++++ src/support/promptHelper.test.ts | 30 +++++++++++- src/support/promptHelper.ts | 4 +- src/support/timeWindow.test.ts | 24 +++++++++- src/support/workSessionRoutes.test.ts | 50 +++++++++++++++++++- src/support/workSessionRoutes.ts | 47 +++++++++++++------ src/tui/chatModel.test.ts | 56 ++++++++++++++++++++++- src/tui/chatModel.ts | 33 +++++++++++-- src/tui/components/ChatInput.test.tsx | 19 +++++++- src/tui/components/ChatInput.tsx | 18 ++++++-- src/tui/panels/ChatPanel.history.test.ts | 3 ++ src/tui/panels/ChatPanel.history.test.tsx | 45 ++++++++++++++++++ src/tui/panels/ChatPanel.tsx | 29 +++++++----- 14 files changed, 333 insertions(+), 41 deletions(-) create mode 100644 .commit-dod.sh create mode 100644 run-session-bound-tests.sh create mode 100644 src/tui/panels/ChatPanel.history.test.ts create mode 100644 src/tui/panels/ChatPanel.history.test.tsx diff --git a/.commit-dod.sh b/.commit-dod.sh new file mode 100644 index 00000000..7b8a002c --- /dev/null +++ b/.commit-dod.sh @@ -0,0 +1 @@ +# leftover from blocked commit attempt — ignore diff --git a/run-session-bound-tests.sh b/run-session-bound-tests.sh new file mode 100644 index 00000000..455ec36d --- /dev/null +++ b/run-session-bound-tests.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail +cd /work/OpenSwarm/worktree/11481ea4-83b4-46f7-accc-043cf5fcefb0 +if [ ! -f node_modules/vitest/vitest.mjs ]; then + npm install +fi +node --experimental-vm-modules node_modules/vitest/vitest.mjs run \ + src/support/promptHelper.test.ts \ + src/support/timeWindow.test.ts \ + src/support/workSessionRoutes.test.ts \ + src/tui/chatModel.test.ts \ + src/tui/components/ChatInput.test.tsx \ + src/tui/panels/ChatPanel.history.test.tsx \ + src/tui/panels/ChatPanel.history.test.ts \ + --reporter=verbose diff --git a/src/support/promptHelper.test.ts b/src/support/promptHelper.test.ts index 449553dc..b053bbd2 100644 --- a/src/support/promptHelper.test.ts +++ b/src/support/promptHelper.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { resolveChoice, resolveConfirm, type ChoiceOption } from './promptHelper.js'; +import { PassThrough } from 'node:stream'; +import { createPrompter, MAX_LINE_QUEUE, resolveChoice, resolveConfirm, type ChoiceOption } from './promptHelper.js'; const opts: ChoiceOption[] = [ { label: 'local', value: 'L' }, @@ -37,3 +38,30 @@ describe('resolveConfirm', () => { expect(resolveConfirm('maybe', false)).toBe(false); }); }); + +describe('createPrompter stdin queue bound', () => { + it(`retains at most ${MAX_LINE_QUEUE} queued lines and drops the oldest`, async () => { + const input = new PassThrough(); + const output = new PassThrough(); + const prompter = createPrompter(input, output); + + const total = MAX_LINE_QUEUE + 25; + for (let i = 0; i < total; i++) { + input.write(`line-${i}\n`); + } + + // Let readline drain line events into the bounded queue before asking. + await new Promise((r) => setImmediate(r)); + + const first = await prompter.ask('q'); + // Oldest 25 were shifted out; first retained is line-25. + expect(first).toBe('line-25'); + + for (let i = 26; i < total; i++) { + expect(await prompter.ask('q')).toBe(`line-${i}`); + } + + prompter.close(); + input.end(); + }); +}); diff --git a/src/support/promptHelper.ts b/src/support/promptHelper.ts index 456e95e3..dade383f 100644 --- a/src/support/promptHelper.ts +++ b/src/support/promptHelper.ts @@ -46,7 +46,7 @@ export interface Prompter { } /** Maximum number of queued stdin lines to prevent unbounded memory growth. */ -const MAX_LINE_QUEUE = 100; +export const MAX_LINE_QUEUE = 100; /** * Create an interactive prompter backed by readline. @@ -67,7 +67,7 @@ export function createPrompter(input: Readable = processStdin, output: Writable if (w) w.resolve(line); else { lineQueue.push(line); - // Bound queue to prevent unbounded memory growth (INT-XXXX) + // Bound queue to prevent unbounded memory growth if (lineQueue.length > MAX_LINE_QUEUE) lineQueue.shift(); } }); diff --git a/src/support/timeWindow.test.ts b/src/support/timeWindow.test.ts index 373fb069..76bc2a84 100644 --- a/src/support/timeWindow.test.ts +++ b/src/support/timeWindow.test.ts @@ -1,9 +1,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { DEFAULT_TIME_WINDOW, getMarketStatus, isWorkAllowed } from './timeWindow.js'; +import { + DEFAULT_TIME_WINDOW, + getMarketStatus, + getTimeWindowSummary, + isWorkAllowed, + setTimeWindowConfig, +} from './timeWindow.js'; describe('timeWindow', () => { afterEach(() => { vi.useRealTimers(); + setTimeWindowConfig({ ...DEFAULT_TIME_WINDOW }); }); it('reports the next allowed window start while inside a blocked window', () => { @@ -36,4 +43,19 @@ describe('timeWindow', () => { expect(status.status).toBe('regular'); expect(status.canWork).toBe(false); }); + + it('getTimeWindowSummary uses the active configuration for market status', () => { + vi.useFakeTimers(); + // Monday 10:00 KST — default config blocks this window. + vi.setSystemTime(new Date('2026-07-06T01:00:00.000Z')); + + const blockedSummary = getTimeWindowSummary(); + expect(blockedSummary).toContain('🔴'); + + // Clear blocked windows so market/work status follows the active override. + setTimeWindowConfig({ blockedWindows: [], allowedWindows: [{ start: '00:00', end: '23:59' }] }); + const openSummary = getTimeWindowSummary(); + expect(openSummary).toContain('🟢'); + expect(openSummary).not.toEqual(blockedSummary); + }); }); diff --git a/src/support/workSessionRoutes.test.ts b/src/support/workSessionRoutes.test.ts index f0c60389..be969a02 100644 --- a/src/support/workSessionRoutes.test.ts +++ b/src/support/workSessionRoutes.test.ts @@ -193,6 +193,7 @@ describe('resolveTaskWorktree', () => { })), }); expect(resolveTaskWorktree(runner, 't1')).toEqual({ + kind: 'ok', worktreePath: '/repo/worktree/ledger-path', branch: 'swarm/INT-t1', projectPath: '/repo', @@ -202,16 +203,46 @@ describe('resolveTaskWorktree', () => { it('falls back to the conventional layout for a running task without a ledger row', () => { const runner = mkRunner({ getRunningTasks: vi.fn(() => [runningTask('t1')]) }); expect(resolveTaskWorktree(runner, 't1')).toEqual({ + kind: 'ok', worktreePath: '/repo/worktree/t1', branch: undefined, projectPath: '/repo', }); }); - it('returns null when nothing exists on disk — never a guessed path', () => { + it('returns no_worktree when nothing exists on disk — never a guessed path', () => { existsSyncImpl.mockReturnValue(false); const runner = mkRunner({ getRunningTasks: vi.fn(() => [runningTask('t1')]) }); - expect(resolveTaskWorktree(runner, 't1')).toBeNull(); + expect(resolveTaskWorktree(runner, 't1')).toEqual({ kind: 'no_worktree' }); + }); + + it('requires an authoritative project path — never uses worktreePath as project identity', () => { + const runner = mkRunner({ + getRunningTasks: vi.fn(() => []), + getDurableRun: vi.fn(() => ({ + worktreePath: '/repo/worktree/orphan', + branchName: 'swarm/orphan', + projectPath: '', + })), + }); + expect(resolveTaskWorktree(runner, 'orphan')).toEqual({ kind: 'no_project' }); + }); + + it('uses durable record projectPath when the task is no longer running', () => { + const runner = mkRunner({ + getRunningTasks: vi.fn(() => []), + getDurableRun: vi.fn(() => ({ + worktreePath: '/repo/worktree/t1', + branchName: 'swarm/INT-t1', + projectPath: '/repo', + })), + }); + expect(resolveTaskWorktree(runner, 't1')).toEqual({ + kind: 'ok', + worktreePath: '/repo/worktree/t1', + branch: 'swarm/INT-t1', + projectPath: '/repo', + }); }); }); @@ -270,6 +301,21 @@ describe('GET /api/work/diff', () => { expect((await call('/api/work/diff?taskId=t1', undefined)).status).toBe(503); }); + it('400s when a durable session has no authoritative project path', async () => { + const runner = mkRunner({ + getRunningTasks: vi.fn(() => []), + getDurableRun: vi.fn(() => ({ + worktreePath: '/repo/worktree/orphan', + branchName: 'swarm/orphan', + projectPath: '', + })), + }); + const { status, body } = await call('/api/work/diff?taskId=orphan', runner); + expect(status).toBe(400); + expect(body.error).toContain('authoritative project path'); + expect(gitTracker.getWorkingDiffDetail).not.toHaveBeenCalled(); + }); + it('404s when the task has no worktree, without touching git', async () => { existsSyncImpl.mockReturnValue(false); const { status } = await call('/api/work/diff?taskId=t1', mkRunner({ getRunningTasks: vi.fn(() => [runningTask('t1')]) })); diff --git a/src/support/workSessionRoutes.ts b/src/support/workSessionRoutes.ts index 7f19320e..791c7f91 100644 --- a/src/support/workSessionRoutes.ts +++ b/src/support/workSessionRoutes.ts @@ -145,38 +145,53 @@ export function buildSessionList( return { sessions, recent }; } +/** Outcome of server-side taskId → worktree resolution. */ +export type TaskWorktreeResolution = + | { kind: 'ok'; worktreePath: string; branch?: string; projectPath: string } + | { kind: 'no_project' } + | { kind: 'no_worktree' }; + /** * Server-side taskId → worktree mapping. Ledger first (attachWorktree records * the real path), then the deterministic `{projectPath}/worktree/{issueId}` - * layout. Returns null when nothing exists on disk — never a guessed path. + * layout. Requires an authoritative project path (running task or durable + * record) — never treats a worktree path as the project identity. Returns + * `no_worktree` when nothing exists on disk — never a guessed path. */ export function resolveTaskWorktree( runner: AutonomousRunner, taskId: string, -): { worktreePath: string; branch?: string; projectPath: string } | null { +): TaskWorktreeResolution { // Clients hold the session list's taskId (= taskEventKey); accept the raw // task.id too so nothing depends on which spelling a caller saved. const running = runner .getRunningTasks() .find((t) => taskEventKey(t.task) === taskId || t.task.id === taskId); const issueId = running?.task.issueId ?? taskId; - const projectPath = running?.projectPath; - const record = runner.getDurableRun(issueId); + + // Authoritative project identity only — never fall back to worktreePath. + const projectPath = (running?.projectPath ?? record?.projectPath)?.trim(); + if (!projectPath) { + // Known durable/running identity without a project is a client/server + // contract failure (400), not a missing resource (404). + if (running || record) return { kind: 'no_project' }; + return { kind: 'no_worktree' }; + } + if (record?.worktreePath && existsSync(record.worktreePath)) { return { + kind: 'ok', worktreePath: record.worktreePath, branch: record.branchName, - projectPath: projectPath ?? record.projectPath ?? record.worktreePath, + projectPath, }; } - if (projectPath) { - const conventional = `${projectPath}/worktree/${issueId}`; - if (existsSync(conventional)) { - return { worktreePath: conventional, branch: record?.branchName, projectPath }; - } + const conventional = `${projectPath}/worktree/${issueId}`; + if (existsSync(conventional)) { + return { kind: 'ok', worktreePath: conventional, branch: record?.branchName, projectPath }; } - return null; + return { kind: 'no_worktree' }; } const DIFF_DEFAULT_MAX_BYTES = 16_000; @@ -210,7 +225,9 @@ export async function tryHandleWorkSessionRoutes( history, (task) => { const resolved = resolveTaskWorktree(runner, task.task.id); - return resolved ? { worktreePath: resolved.worktreePath, branch: resolved.branch } : {}; + return resolved.kind === 'ok' + ? { worktreePath: resolved.worktreePath, branch: resolved.branch } + : {}; }, stageModels, ); @@ -252,7 +269,11 @@ export async function tryHandleWorkSessionRoutes( return true; } const resolved = resolveTaskWorktree(runner, taskId); - if (!resolved) { + if (resolved.kind === 'no_project') { + writeJson(res, 400, { error: `No authoritative project path for task ${taskId}` }); + return true; + } + if (resolved.kind !== 'ok') { writeJson(res, 404, { error: `No worktree for task ${taskId}` }); return true; } diff --git a/src/tui/chatModel.test.ts b/src/tui/chatModel.test.ts index 731ad65d..6f86e288 100644 --- a/src/tui/chatModel.test.ts +++ b/src/tui/chatModel.test.ts @@ -1,5 +1,20 @@ import { describe, it, expect } from 'vitest'; -import { chatReducer, initialChatState, parseInput, matchSlash, movePaletteSelection, dedupeDoubledGrapheme, normalizeConfirm, isActivityNoise, historyToMessages, messagesToHistory, type ChatLine } from './chatModel.js'; +import { + chatReducer, + initialChatState, + parseInput, + matchSlash, + movePaletteSelection, + dedupeDoubledGrapheme, + normalizeConfirm, + isActivityNoise, + historyToMessages, + messagesToHistory, + boundChatHistory, + buildConversationPrompt, + MAX_CHAT_HISTORY, + type ChatLine, +} from './chatModel.js'; describe('chatReducer (EPIC INT-1813 S4)', () => { it('appends user and system lines', () => { @@ -29,6 +44,45 @@ describe('chatReducer (EPIC INT-1813 S4)', () => { const s = chatReducer({ history: [{ role: 'user', content: 'x' }], streaming: 'y' }, { type: 'clear' }); expect(s).toEqual(initialChatState); }); + + it(`bounds retained history to ${MAX_CHAT_HISTORY} lines`, () => { + let s = initialChatState; + for (let i = 0; i < MAX_CHAT_HISTORY + 40; i++) { + s = chatReducer(s, { type: 'user', content: `msg-${i}` }); + } + expect(s.history).toHaveLength(MAX_CHAT_HISTORY); + expect(s.history[0]?.content).toBe('msg-40'); + expect(s.history.at(-1)?.content).toBe(`msg-${MAX_CHAT_HISTORY + 39}`); + }); +}); + +describe('chat history budgets (long sessions)', () => { + it('boundChatHistory keeps the newest window', () => { + const lines = Array.from({ length: 250 }, (_, i) => ({ role: 'user' as const, content: `u${i}` })); + const bounded = boundChatHistory(lines); + expect(bounded).toHaveLength(MAX_CHAT_HISTORY); + expect(bounded[0]?.content).toBe('u50'); + expect(bounded.at(-1)?.content).toBe('u249'); + }); + + it('prompt construction and persisted messages obey the same budget', () => { + const history: ChatLine[] = Array.from({ length: 260 }, (_, i) => + i % 2 === 0 + ? { role: 'user', content: `user-${i}` } + : { role: 'assistant', content: `asst-${i}` }, + ); + const retained = boundChatHistory(history); + expect(retained).toHaveLength(MAX_CHAT_HISTORY); + + const persisted = boundChatHistory(historyToMessages(retained), MAX_CHAT_HISTORY); + expect(persisted.length).toBeLessThanOrEqual(MAX_CHAT_HISTORY); + expect(persisted.every((m) => m.role === 'user' || m.role === 'assistant')).toBe(true); + + const prompt = buildConversationPrompt(persisted); + expect(prompt).not.toContain('user-0'); + expect(prompt).toContain(persisted.at(-1)!.content); + expect(prompt.split('\n\n').length).toBeLessThanOrEqual(MAX_CHAT_HISTORY + 2); + }); }); describe('parseInput', () => { diff --git a/src/tui/chatModel.ts b/src/tui/chatModel.ts index fedd97f5..b5d3e8a2 100644 --- a/src/tui/chatModel.ts +++ b/src/tui/chatModel.ts @@ -37,6 +37,24 @@ export interface ChatState { export const initialChatState: ChatState = { history: [], streaming: null }; +/** Retained / persisted / prompt chat-history budget (newest kept). */ +export const MAX_CHAT_HISTORY = 200; + +/** Keep the newest `MAX_CHAT_HISTORY` lines; no-op when already within budget. */ +export function boundChatHistory(history: T[], max = MAX_CHAT_HISTORY): T[] { + return history.length <= max ? history : history.slice(-max); +} + +/** Build a multi-turn prompt from messages (caller should already apply history budget). */ +export function buildConversationPrompt(messages: Message[]): string { + if (messages.length <= 1) return messages[0]?.content ?? ''; + return [ + 'Use the following conversation history as context. Continue by answering the latest user message.', + '', + messages.map((m) => `${m.role === 'assistant' ? 'Assistant' : 'User'}: ${m.content}`).join('\n\n'), + ].join('\n'); +} + export type ChatAction = | { type: 'user'; content: string } | { type: 'system'; content: string } @@ -47,15 +65,24 @@ export type ChatAction = export function chatReducer(state: ChatState, action: ChatAction): ChatState { switch (action.type) { case 'user': - return { ...state, history: [...state.history, { role: 'user', content: action.content }] }; + return { + ...state, + history: boundChatHistory([...state.history, { role: 'user', content: action.content }]), + }; case 'system': - return { ...state, history: [...state.history, { role: 'system', content: action.content }] }; + return { + ...state, + history: boundChatHistory([...state.history, { role: 'system', content: action.content }]), + }; case 'stream': return { ...state, streaming: (state.streaming ?? '') + action.chunk }; case 'commit': return state.streaming === null ? state - : { history: [...state.history, { role: 'assistant', content: state.streaming }], streaming: null }; + : { + history: boundChatHistory([...state.history, { role: 'assistant', content: state.streaming }]), + streaming: null, + }; case 'clear': return initialChatState; } diff --git a/src/tui/components/ChatInput.test.tsx b/src/tui/components/ChatInput.test.tsx index a3e0f95a..7f529131 100644 --- a/src/tui/components/ChatInput.test.tsx +++ b/src/tui/components/ChatInput.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { deleteLastGrapheme } from './ChatInput.js'; +import { deleteLastGrapheme, clipChatInputDisplay, CHAT_INPUT_DISPLAY_OVERHEAD } from './ChatInput.js'; describe('ChatInput deleteLastGrapheme', () => { it('deletes an emoji as one grapheme', () => { @@ -10,3 +10,20 @@ describe('ChatInput deleteLastGrapheme', () => { expect(deleteLastGrapheme('Cafe\u0301')).toBe('Caf'); }); }); + +describe('clipChatInputDisplay', () => { + it('clips to the terminal-column budget with an ellipsis without mutating the source', () => { + const columns = 40; + const max = Math.max(10, columns - CHAT_INPUT_DISPLAY_OVERHEAD); + const full = 'x'.repeat(max + 20); + const clipped = clipChatInputDisplay(full, columns); + expect(clipped.length).toBe(max); + expect(clipped.endsWith('…')).toBe(true); + expect(full.endsWith('…')).toBe(false); + expect(full.length).toBe(max + 20); + }); + + it('returns the full value when it fits', () => { + expect(clipChatInputDisplay('short', 80)).toBe('short'); + }); +}); diff --git a/src/tui/components/ChatInput.tsx b/src/tui/components/ChatInput.tsx index 98c887f1..7b9bd363 100644 --- a/src/tui/components/ChatInput.tsx +++ b/src/tui/components/ChatInput.tsx @@ -26,6 +26,18 @@ export function deleteLastGrapheme(value: string): string { return value.slice(0, lastIndex); } +/** Prompt icon (2) + cursor (1) + border padding (4) + border (2). */ +export const CHAT_INPUT_DISPLAY_OVERHEAD = 2 + 1 + 4 + 2; + +/** + * Clip displayed chat input to the terminal-column budget while leaving the + * full controlled value untouched for submit/editing. + */ +export function clipChatInputDisplay(value: string, columns: number): string { + const maxDisplayLen = Math.max(10, columns - CHAT_INPUT_DISPLAY_OVERHEAD); + return value.length > maxDisplayLen ? value.slice(0, maxDisplayLen - 1) + '…' : value; +} + export interface ChatInputProps { value: string; active: boolean; @@ -100,11 +112,7 @@ export function ChatInput({ // Clip displayed text to available terminal width, preserving the full // controlled value. Account for prompt icon (2 chars) + cursor (1 char) // + border padding (2 chars left/right = 4 chars) + border (2 chars). - const DISPLAY_OVERHEAD = 2 + 1 + 4 + 2; // icon + cursor + padding + border - const maxDisplayLen = Math.max(10, columns - DISPLAY_OVERHEAD); - const displayValue = value.length > maxDisplayLen - ? value.slice(0, maxDisplayLen - 1) + '…' - : value; + const displayValue = clipChatInputDisplay(value, columns); return ( diff --git a/src/tui/panels/ChatPanel.history.test.ts b/src/tui/panels/ChatPanel.history.test.ts new file mode 100644 index 00000000..b9ff8f28 --- /dev/null +++ b/src/tui/panels/ChatPanel.history.test.ts @@ -0,0 +1,3 @@ +// Suite lives in ChatPanel.history.test.tsx (same content). Keep this stub so +// an accidental duplicate path does not re-run the long-session cases. +export {}; diff --git a/src/tui/panels/ChatPanel.history.test.tsx b/src/tui/panels/ChatPanel.history.test.tsx new file mode 100644 index 00000000..d615c635 --- /dev/null +++ b/src/tui/panels/ChatPanel.history.test.tsx @@ -0,0 +1,45 @@ +// Long-session coverage: prompt + persisted-history budgets (ChatPanel paths). +import { describe, it, expect } from 'vitest'; +import { + boundChatHistory, + buildConversationPrompt, + historyToMessages, + MAX_CHAT_HISTORY, + chatReducer, + initialChatState, + type ChatLine, +} from '../chatModel.js'; + +describe('ChatPanel long-session history budgets', () => { + it(`caps retained reducer history at ${MAX_CHAT_HISTORY}`, () => { + let state = initialChatState; + for (let i = 0; i < MAX_CHAT_HISTORY + 15; i++) { + state = chatReducer(state, { type: 'user', content: `turn-${i}` }); + state = chatReducer(state, { type: 'stream', chunk: `reply-${i}` }); + state = chatReducer(state, { type: 'commit' }); + } + expect(state.history).toHaveLength(MAX_CHAT_HISTORY); + expect(state.history.some((l) => l.content === 'turn-0')).toBe(false); + expect(state.history.at(-1)).toEqual({ role: 'assistant', content: `reply-${MAX_CHAT_HISTORY + 14}` }); + }); + + it('prompt and persisted message lists stay within the budget for long chats', () => { + const history: ChatLine[] = Array.from({ length: 320 }, (_, i) => ({ + role: i % 2 === 0 ? 'user' : 'assistant', + content: `line-${i}`, + })); + const retained = boundChatHistory(history); + const persisted = boundChatHistory(historyToMessages(retained), MAX_CHAT_HISTORY); + const promptMessages = boundChatHistory( + [...persisted, { role: 'user' as const, content: 'newest-user' }], + MAX_CHAT_HISTORY, + ); + const prompt = buildConversationPrompt(promptMessages); + + expect(retained).toHaveLength(MAX_CHAT_HISTORY); + expect(persisted.length).toBeLessThanOrEqual(MAX_CHAT_HISTORY); + expect(promptMessages.length).toBeLessThanOrEqual(MAX_CHAT_HISTORY); + expect(prompt).toContain('newest-user'); + expect(prompt).not.toContain('line-0'); + }); +}); diff --git a/src/tui/panels/ChatPanel.tsx b/src/tui/panels/ChatPanel.tsx index a00cd690..e56bca9a 100644 --- a/src/tui/panels/ChatPanel.tsx +++ b/src/tui/panels/ChatPanel.tsx @@ -17,6 +17,9 @@ import { normalizeConfirm, isActivityNoise, historyToMessages, + boundChatHistory, + buildConversationPrompt, + MAX_CHAT_HISTORY, SLASH_COMMANDS, type ChatLine, } from '../chatModel.js'; @@ -25,21 +28,14 @@ import { ChatInput } from '../components/ChatInput.js'; import { CommandPalette } from '../components/CommandPalette.js'; import { SelectList } from '../components/SelectList.js'; import { listAdapterNames } from '../../adapters/index.js'; -import { callChatModel, loadDefaultProvider, saveSession, generateSessionId, type Message } from '../../support/chatSession.js'; +import { callChatModel, loadDefaultProvider, saveSession, generateSessionId } from '../../support/chatSession.js'; import { getDefaultChatModel, listChatModels } from '../../support/chatBackend.js'; import { runPlanCommand, type PlanIO } from '../../support/planCommand.js'; import { runGoalCommand, buildGoalPursuitPrompt, GOAL_PURSUIT_MAX_TURNS } from '../../support/goalCommand.js'; import type { AdapterName } from '../../adapters/types.js'; import { track } from '../../telemetry/telemetry.js'; -function buildConversationPrompt(messages: Message[]): string { - if (messages.length <= 1) return messages[0]?.content ?? ''; - return [ - 'Use the following conversation history as context. Continue by answering the latest user message.', - '', - messages.map((m) => `${m.role === 'assistant' ? 'Assistant' : 'User'}: ${m.content}`).join('\n\n'), - ].join('\n'); -} +export { buildConversationPrompt, MAX_CHAT_HISTORY, boundChatHistory } from '../chatModel.js'; export interface ChatPanelProps { active: boolean; @@ -62,7 +58,10 @@ export function ChatPanel({ active, provider: providerProp, model: modelProp, pr const [state, dispatch] = useReducer( chatReducer, undefined, - () => (initialHistory && initialHistory.length > 0 ? { history: initialHistory, streaming: null } : initialChatState), + () => + initialHistory && initialHistory.length > 0 + ? { history: boundChatHistory(initialHistory), streaming: null } + : initialChatState, ); const [input, setInput] = useState(''); const [paletteIndex, setPaletteIndex] = useState(0); @@ -112,13 +111,15 @@ export function ChatPanel({ active, provider: providerProp, model: modelProp, pr // Persist the conversation on every change so it survives exit and can be // reopened with `openswarm resume`. System/UI lines are dropped by // historyToMessages; an active goal rides along so resume can restart it. (INT-2014) + // Retained history is already capped at MAX_CHAT_HISTORY; persist the same budget. useEffect(() => { if (state.history.length === 0) return; + const messages = boundChatHistory(historyToMessages(state.history), MAX_CHAT_HISTORY); void saveSession({ id: sessionIdRef.current, provider, model, - messages: historyToMessages(state.history), + messages, totalCost: 0, totalTokens: 0, createdAt: createdAtRef.current, @@ -406,7 +407,11 @@ export function ChatPanel({ active, provider: providerProp, model: modelProp, pr return; } dispatch({ type: 'user', content: parsed.text }); - await streamChat(buildConversationPrompt([...historyToMessages(state.history), { role: 'user', content: parsed.text }])); + const promptMessages = boundChatHistory( + [...historyToMessages(state.history), { role: 'user' as const, content: parsed.text }], + MAX_CHAT_HISTORY, + ); + await streamChat(buildConversationPrompt(promptMessages)); }, [pending, runCommand, streamChat, goalActive, busy, state.history], ); From aafe1e75d2ff60951606b0ef23ba1d0e6822829f Mon Sep 17 00:00:00 2001 From: Heewon Oh Date: Thu, 10 Sep 2026 10:57:39 +0900 Subject: [PATCH 5/5] wip: preserved partial work (auto, session did not succeed) --- node_modules | 1 - package-lock.json | 48 ----------------------------------------------- 2 files changed, 49 deletions(-) delete mode 120000 node_modules diff --git a/node_modules b/node_modules deleted file mode 120000 index d9643ec8..00000000 --- a/node_modules +++ /dev/null @@ -1 +0,0 @@ -/work/OpenSwarm/node_modules \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f15560fc..36740455 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1300,9 +1300,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1319,9 +1316,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1338,9 +1332,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1357,9 +1348,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1376,9 +1364,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1395,9 +1380,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1414,9 +1396,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1433,9 +1412,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1452,9 +1428,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1477,9 +1450,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1502,9 +1472,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1527,9 +1494,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1552,9 +1516,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1577,9 +1538,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1602,9 +1560,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1627,9 +1582,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [