diff --git a/apps/codex-claw/src/routeTree.gen.ts b/apps/codex-claw/src/routeTree.gen.ts index 7f07e6c..639b14e 100644 --- a/apps/codex-claw/src/routeTree.gen.ts +++ b/apps/codex-claw/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as ConnectRouteImport } from './routes/connect' import { Route as IndexRouteImport } from './routes/index' import { Route as ChatSessionKeyRouteImport } from './routes/chat/$sessionKey' import { Route as ApiWorkspacesRouteImport } from './routes/api/workspaces' +import { Route as ApiTasksRouteImport } from './routes/api/tasks' import { Route as ApiStreamRouteImport } from './routes/api/stream' import { Route as ApiSessionsRouteImport } from './routes/api/sessions' import { Route as ApiSendRouteImport } from './routes/api/send' @@ -49,6 +50,11 @@ const ApiWorkspacesRoute = ApiWorkspacesRouteImport.update({ path: '/api/workspaces', getParentRoute: () => rootRouteImport, } as any) +const ApiTasksRoute = ApiTasksRouteImport.update({ + id: '/api/tasks', + path: '/api/tasks', + getParentRoute: () => rootRouteImport, +} as any) const ApiStreamRoute = ApiStreamRouteImport.update({ id: '/api/stream', path: '/api/stream', @@ -108,6 +114,7 @@ export interface FileRoutesByFullPath { '/api/send': typeof ApiSendRoute '/api/sessions': typeof ApiSessionsRoute '/api/stream': typeof ApiStreamRoute + '/api/tasks': typeof ApiTasksRoute '/api/workspaces': typeof ApiWorkspacesRoute '/chat/$sessionKey': typeof ChatSessionKeyRoute } @@ -124,6 +131,7 @@ export interface FileRoutesByTo { '/api/send': typeof ApiSendRoute '/api/sessions': typeof ApiSessionsRoute '/api/stream': typeof ApiStreamRoute + '/api/tasks': typeof ApiTasksRoute '/api/workspaces': typeof ApiWorkspacesRoute '/chat/$sessionKey': typeof ChatSessionKeyRoute } @@ -141,6 +149,7 @@ export interface FileRoutesById { '/api/send': typeof ApiSendRoute '/api/sessions': typeof ApiSessionsRoute '/api/stream': typeof ApiStreamRoute + '/api/tasks': typeof ApiTasksRoute '/api/workspaces': typeof ApiWorkspacesRoute '/chat/$sessionKey': typeof ChatSessionKeyRoute } @@ -159,6 +168,7 @@ export interface FileRouteTypes { | '/api/send' | '/api/sessions' | '/api/stream' + | '/api/tasks' | '/api/workspaces' | '/chat/$sessionKey' fileRoutesByTo: FileRoutesByTo @@ -175,6 +185,7 @@ export interface FileRouteTypes { | '/api/send' | '/api/sessions' | '/api/stream' + | '/api/tasks' | '/api/workspaces' | '/chat/$sessionKey' id: @@ -191,6 +202,7 @@ export interface FileRouteTypes { | '/api/send' | '/api/sessions' | '/api/stream' + | '/api/tasks' | '/api/workspaces' | '/chat/$sessionKey' fileRoutesById: FileRoutesById @@ -208,6 +220,7 @@ export interface RootRouteChildren { ApiSendRoute: typeof ApiSendRoute ApiSessionsRoute: typeof ApiSessionsRoute ApiStreamRoute: typeof ApiStreamRoute + ApiTasksRoute: typeof ApiTasksRoute ApiWorkspacesRoute: typeof ApiWorkspacesRoute ChatSessionKeyRoute: typeof ChatSessionKeyRoute } @@ -249,6 +262,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiWorkspacesRouteImport parentRoute: typeof rootRouteImport } + '/api/tasks': { + id: '/api/tasks' + path: '/api/tasks' + fullPath: '/api/tasks' + preLoaderRoute: typeof ApiTasksRouteImport + parentRoute: typeof rootRouteImport + } '/api/stream': { id: '/api/stream' path: '/api/stream' @@ -328,6 +348,7 @@ const rootRouteChildren: RootRouteChildren = { ApiSendRoute: ApiSendRoute, ApiSessionsRoute: ApiSessionsRoute, ApiStreamRoute: ApiStreamRoute, + ApiTasksRoute: ApiTasksRoute, ApiWorkspacesRoute: ApiWorkspacesRoute, ChatSessionKeyRoute: ChatSessionKeyRoute, } diff --git a/apps/codex-claw/src/routes/api/tasks.ts b/apps/codex-claw/src/routes/api/tasks.ts new file mode 100644 index 0000000..8fd8b72 --- /dev/null +++ b/apps/codex-claw/src/routes/api/tasks.ts @@ -0,0 +1,52 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' + +import { + cancelCodexTask, + listCodexTasks, + retryCodexTask, +} from '../../server/codex-cli' + +export const Route = createFileRoute('/api/tasks')({ + server: { + handlers: { + GET: () => { + try { + return json(listCodexTasks()) + } catch (err) { + return json( + { + ok: false, + error: err instanceof Error ? err.message : String(err), + }, + { status: 500 }, + ) + } + }, + POST: async ({ request }) => { + try { + const body = (await request.json().catch(() => ({}))) as Record< + string, + unknown + > + const action = typeof body.action === 'string' ? body.action : '' + const id = typeof body.id === 'string' ? body.id.trim() : '' + if (action === 'cancel') return json(cancelCodexTask(id)) + if (action === 'retry') return json(retryCodexTask(id)) + return json( + { ok: false, error: 'unsupported action' }, + { status: 400 }, + ) + } catch (err) { + return json( + { + ok: false, + error: err instanceof Error ? err.message : String(err), + }, + { status: 400 }, + ) + } + }, + }, + }, +}) diff --git a/apps/codex-claw/src/screens/chat/chat-queries.ts b/apps/codex-claw/src/screens/chat/chat-queries.ts index 7e95c8b..29f209e 100644 --- a/apps/codex-claw/src/screens/chat/chat-queries.ts +++ b/apps/codex-claw/src/screens/chat/chat-queries.ts @@ -9,6 +9,7 @@ import type { RepoContextSelection, SessionListResponse, SessionMeta, + TaskListResponse, WorkspaceListResponse, WorkspaceSummary, } from './types' @@ -21,6 +22,7 @@ type GatewayStatusResponse = { export const chatQueryKeys = { sessions: ['chat', 'sessions'] as const, workspaces: ['chat', 'workspaces'] as const, + tasks: ['chat', 'tasks'] as const, history: function history(friendlyId: string, sessionKey: string) { return ['chat', 'history', friendlyId, sessionKey] as const }, @@ -141,6 +143,32 @@ export async function fetchMcpHealth(): Promise { return (await res.json()) as McpHealthPayload } +export async function fetchCodexTasks(): Promise { + const res = await fetch('/api/tasks') + if (!res.ok) throw new Error(await readError(res)) + return (await res.json()) as TaskListResponse +} + +export async function cancelCodexTask(id: string): Promise { + const res = await fetch('/api/tasks', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'cancel', id }), + }) + if (!res.ok) throw new Error(await readError(res)) + return fetchCodexTasks() +} + +export async function retryCodexTask(id: string): Promise { + const res = await fetch('/api/tasks', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'retry', id }), + }) + if (!res.ok) throw new Error(await readError(res)) + return fetchCodexTasks() +} + export async function stageGitReviewFiles( paths: Array, ): Promise { diff --git a/apps/codex-claw/src/screens/chat/chat-screen.tsx b/apps/codex-claw/src/screens/chat/chat-screen.tsx index 78f515f..cc7a49e 100644 --- a/apps/codex-claw/src/screens/chat/chat-screen.tsx +++ b/apps/codex-claw/src/screens/chat/chat-screen.tsx @@ -25,6 +25,7 @@ import { ChatMessageList } from './components/chat-message-list' import { ChatComposer } from './components/chat-composer' import { GitReviewPanel } from './components/git-review-panel' import { McpHealthPanel } from './components/mcp-health-panel' +import { TaskQueuePanel } from './components/task-queue-panel' import { GatewayStatusMessage } from './components/gateway-status-message' import { hasPendingGeneration, @@ -73,6 +74,7 @@ export function ChatScreen({ const [isRedirecting, setIsRedirecting] = useState(false) const [gitReviewOpen, setGitReviewOpen] = useState(false) const [mcpHealthOpen, setMcpHealthOpen] = useState(false) + const [taskQueueOpen, setTaskQueueOpen] = useState(false) const { headerRef, composerRef, mainRef, pinGroupMinHeight, headerHeight } = useChatMeasurements() const [waitingForResponse, setWaitingForResponse] = useState( @@ -633,6 +635,12 @@ export function ChatScreen({ onToggleGitReview={() => setGitReviewOpen((current) => !current)} mcpHealthOpen={mcpHealthOpen} onToggleMcpHealth={() => setMcpHealthOpen((current) => !current)} + taskQueueOpen={taskQueueOpen} + onToggleTaskQueue={() => setTaskQueueOpen((current) => !current)} + /> + setTaskQueueOpen(false)} /> void mcpHealthOpen?: boolean onToggleMcpHealth?: () => void + taskQueueOpen?: boolean + onToggleTaskQueue?: () => void } function ChatHeaderComponent({ @@ -41,6 +44,8 @@ function ChatHeaderComponent({ onToggleGitReview, mcpHealthOpen = false, onToggleMcpHealth, + taskQueueOpen = false, + onToggleTaskQueue, }: ChatHeaderProps) { return (
) : null} + {onToggleTaskQueue ? ( + + ) : null} {showExport ? ( ) : null} diff --git a/apps/codex-claw/src/screens/chat/components/task-queue-panel.tsx b/apps/codex-claw/src/screens/chat/components/task-queue-panel.tsx new file mode 100644 index 0000000..a86febe --- /dev/null +++ b/apps/codex-claw/src/screens/chat/components/task-queue-panel.tsx @@ -0,0 +1,216 @@ +import { useEffect, useMemo, useState } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { + Cancel01Icon, + CheckmarkCircle01Icon, + RefreshIcon, +} from '@hugeicons/core-free-icons' +import { + cancelCodexTask, + fetchCodexTasks, + retryCodexTask, +} from '../chat-queries' +import type { CodexTaskRecord, CodexTaskStatus } from '../types' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +type TaskQueuePanelProps = { + open: boolean + onClose: () => void +} + +function statusClass(status: CodexTaskStatus) { + if (status === 'completed') return 'bg-emerald-100 text-emerald-700' + if (status === 'failed') return 'bg-red-100 text-red-700' + if (status === 'canceled') return 'bg-primary-200 text-primary-700' + return 'bg-amber-100 text-amber-700' +} + +function statusLabel(status: CodexTaskStatus) { + if (status === 'queued') return 'Queued' + if (status === 'running') return 'Running' + if (status === 'completed') return 'Completed' + if (status === 'failed') return 'Failed' + return 'Canceled' +} + +function formatDuration(task: CodexTaskRecord) { + const duration = + typeof task.durationMs === 'number' + ? task.durationMs + : task.startedAt + ? Date.now() - task.startedAt + : 0 + if (duration <= 0) return '0s' + const seconds = Math.round(duration / 1000) + if (seconds < 60) return String(seconds) + 's' + const minutes = Math.floor(seconds / 60) + const remainder = seconds % 60 + return String(minutes) + 'm ' + String(remainder) + 's' +} + +function canCancel(task: CodexTaskRecord) { + return task.status === 'queued' || task.status === 'running' +} + +function canRetry(task: CodexTaskRecord) { + return task.status === 'failed' || task.status === 'canceled' +} + +export function TaskQueuePanel({ open, onClose }: TaskQueuePanelProps) { + const [tasks, setTasks] = useState>([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + function loadTasks() { + setLoading(true) + setError(null) + fetchCodexTasks() + .then((payload) => setTasks(payload.tasks)) + .catch((err) => + setError(err instanceof Error ? err.message : String(err)), + ) + .finally(() => setLoading(false)) + } + + useEffect(() => { + if (!open) return + loadTasks() + const interval = window.setInterval(loadTasks, 3000) + return () => window.clearInterval(interval) + }, [open]) + + const counts = useMemo(() => { + return { + running: tasks.filter((task) => task.status === 'running').length, + failed: tasks.filter((task) => task.status === 'failed').length, + completed: tasks.filter((task) => task.status === 'completed').length, + } + }, [tasks]) + + async function cancelTask(task: CodexTaskRecord) { + setError(null) + try { + const payload = await cancelCodexTask(task.id) + setTasks(payload.tasks) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + } + + async function retryTask(task: CodexTaskRecord) { + setError(null) + try { + const payload = await retryCodexTask(task.id) + setTasks(payload.tasks) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + } + + if (!open) return null + + return ( +
+
+ +
+
+ Task queue +
+
+ {counts.running} running, {counts.failed} failed, {counts.completed}{' '} + completed +
+
+ + +
+ + {error ? ( +
{error}
+ ) : null} + {loading ? ( +
Refreshing...
+ ) : null} + +
+ {tasks.length === 0 ? ( +
+ No Codex tasks have been recorded yet. +
+ ) : null} + {tasks.map((task) => ( +
+
+
+
+ + {statusLabel(task.status)} + + + {task.message || task.id} + +
+
+ + Session {task.sessionKey} · message {task.messageId} + + + Duration {formatDuration(task)} · exit{' '} + {typeof task.exitCode === 'number' ? task.exitCode : 'open'} + +
+ {task.error ? ( +
+ {task.error} +
+ ) : null} +
+
+ + +
+
+
+ ))} +
+
+ ) +} diff --git a/apps/codex-claw/src/screens/chat/types.ts b/apps/codex-claw/src/screens/chat/types.ts index d447191..18af956 100644 --- a/apps/codex-claw/src/screens/chat/types.ts +++ b/apps/codex-claw/src/screens/chat/types.ts @@ -240,3 +240,38 @@ export type McpHealthPayload = { servers: Array setupSnippets: Array } + +export type CodexTaskStatus = + | 'queued' + | 'running' + | 'completed' + | 'failed' + | 'canceled' + +export type CodexTaskEvent = { + status: CodexTaskStatus + at: number + note?: string +} + +export type CodexTaskRecord = { + id: string + sessionKey: string + messageId: string + message: string + runProfile?: string + status: CodexTaskStatus + createdAt: number + updatedAt: number + startedAt?: number + finishedAt?: number + durationMs?: number + exitCode?: number | null + error?: string + retryOf?: string + events: Array +} + +export type TaskListResponse = { + tasks: Array +} diff --git a/apps/codex-claw/src/server/codex-cli.ts b/apps/codex-claw/src/server/codex-cli.ts index f024435..c40e64a 100644 --- a/apps/codex-claw/src/server/codex-cli.ts +++ b/apps/codex-claw/src/server/codex-cli.ts @@ -66,6 +66,54 @@ type SessionStore = { sessions: Array } +type CodexTaskStatus = + | 'queued' + | 'running' + | 'completed' + | 'failed' + | 'canceled' + +type CodexTaskEvent = { + status: CodexTaskStatus + at: number + note?: string +} + +type CodexTaskSnapshot = { + sessionKey: string + message: string + thinking?: string + attachments?: Array + contextBlock?: string + runProfile?: string + confirmedRisk?: boolean +} + +type CodexTaskRecord = { + id: string + sessionKey: string + messageId: string + prompt: string + message: string + runProfile?: string + status: CodexTaskStatus + createdAt: number + updatedAt: number + startedAt?: number + finishedAt?: number + durationMs?: number + exitCode?: number | null + error?: string + retryOf?: string + snapshot: CodexTaskSnapshot + events: Array +} + +type TaskStore = { + version: 1 + tasks: Array +} + type AttachmentInput = { mimeType: string content: string @@ -80,6 +128,7 @@ type SendCodexPromptInput = { contextBlock?: string runProfile?: string confirmedRisk?: boolean + retryOf?: string idempotencyKey?: string } @@ -252,7 +301,9 @@ const runProfiles: Record< } let storeCache: SessionStore | null = null let workspaceStoreCache: WorkspaceStore | null = null +let taskStoreCache: TaskStore | null = null let stateVersion = 0 +const runningTasks = new Map>() function getBaseStateDir() { const configured = process.env.CODEX_CLAW_STATE_DIR?.trim() @@ -272,6 +323,10 @@ function getStorePath() { return path.join(getStateDir(), 'sessions.json') } +function getTaskStorePath() { + return path.join(getStateDir(), 'tasks.json') +} + function getCodexCommand() { return getActiveWorkspace().codexCommand } @@ -572,6 +627,174 @@ function writeStore(store: SessionStore) { stateVersion += 1 } +function isTaskRecord(value: unknown): value is CodexTaskRecord { + if (!value || typeof value !== 'object') return false + const candidate = value as Record + return ( + typeof candidate.id === 'string' && + typeof candidate.sessionKey === 'string' && + typeof candidate.messageId === 'string' && + typeof candidate.prompt === 'string' && + typeof candidate.message === 'string' && + typeof candidate.status === 'string' && + typeof candidate.createdAt === 'number' && + typeof candidate.updatedAt === 'number' && + candidate.snapshot !== null && + typeof candidate.snapshot === 'object' && + Array.isArray(candidate.events) + ) +} + +function isTaskStore(value: unknown): value is TaskStore { + if (!value || typeof value !== 'object') return false + const candidate = value as Record + return candidate.version === 1 && Array.isArray(candidate.tasks) +} + +function normalizeTaskStatus(value: string): CodexTaskStatus { + if ( + value === 'queued' || + value === 'running' || + value === 'completed' || + value === 'failed' || + value === 'canceled' + ) { + return value + } + return 'failed' +} + +function normalizeTaskRecord(value: CodexTaskRecord): CodexTaskRecord { + const status = normalizeTaskStatus(value.status) + return { + ...value, + status, + events: value.events + .filter((event) => typeof event.at === 'number') + .map((event) => ({ + status: normalizeTaskStatus(event.status), + at: event.at, + note: typeof event.note === 'string' ? event.note : undefined, + })), + } +} + +function normalizeTaskStore(value: unknown): TaskStore { + if (!isTaskStore(value)) { + return { + version: 1, + tasks: [], + } + } + return { + version: 1, + tasks: value.tasks.filter(isTaskRecord).map(normalizeTaskRecord), + } +} + +function readTaskStore() { + if (taskStoreCache) return taskStoreCache + const taskStorePath = getTaskStorePath() + try { + const parsed = JSON.parse(readFileSync(taskStorePath, 'utf8')) as unknown + taskStoreCache = normalizeTaskStore(parsed) + } catch { + taskStoreCache = { version: 1, tasks: [] } + } + return taskStoreCache +} + +function writeTaskStore(store: TaskStore) { + const taskStorePath = getTaskStorePath() + mkdirSync(path.dirname(taskStorePath), { recursive: true }) + const tempPath = taskStorePath + '.' + process.pid + '.' + Date.now() + '.tmp' + writeFileSync(tempPath, JSON.stringify(store, null, 2)) + renameSync(tempPath, taskStorePath) + taskStoreCache = store + stateVersion += 1 +} + +function taskDuration(task: CodexTaskRecord, now = Date.now()) { + const startedAt = task.startedAt ?? task.createdAt + const finishedAt = task.finishedAt ?? now + return Math.max(0, finishedAt - startedAt) +} + +function appendTaskEvent( + task: CodexTaskRecord, + status: CodexTaskStatus, + note?: string, +) { + const now = Date.now() + task.status = status + task.updatedAt = now + if (status === 'running' && !task.startedAt) task.startedAt = now + if (status === 'completed' || status === 'failed' || status === 'canceled') { + task.finishedAt = now + task.durationMs = taskDuration(task, now) + } + if (note) task.error = note + task.events.push({ status, at: now, note }) +} + +function writeTask(task: CodexTaskRecord) { + const store = readTaskStore() + const index = store.tasks.findIndex((item) => item.id === task.id) + if (index >= 0) { + store.tasks[index] = task + } else { + store.tasks.push(task) + } + writeTaskStore(store) +} + +function updateTask( + taskId: string, + status: CodexTaskStatus, + update?: { + note?: string + exitCode?: number | null + }, +) { + const store = readTaskStore() + const task = store.tasks.find((item) => item.id === taskId) + if (!task) return null + appendTaskEvent(task, status, update?.note) + if (Object.prototype.hasOwnProperty.call(update ?? {}, 'exitCode')) { + task.exitCode = update?.exitCode + } + writeTaskStore(store) + return task +} + +function createTaskRecord(input: { + id: string + sessionKey: string + messageId: string + prompt: string + snapshot: CodexTaskSnapshot + runProfile?: string + retryOf?: string +}) { + const now = Date.now() + const task: CodexTaskRecord = { + id: input.id, + sessionKey: input.sessionKey, + messageId: input.messageId, + prompt: input.prompt, + message: input.snapshot.message, + runProfile: input.runProfile, + status: 'queued', + createdAt: now, + updatedAt: now, + retryOf: input.retryOf, + snapshot: input.snapshot, + events: [{ status: 'queued', at: now }], + } + writeTask(task) + return task +} + function deriveFriendlyIdFromKey(key: string) { const trimmed = key.trim() if (!trimmed) return 'main' @@ -1066,6 +1289,8 @@ function runCodexExec( env: process.env, shell: process.platform === 'win32', }) + runningTasks.set(runId, child) + updateTask(runId, 'running') let stdoutBuffer = '' let stderrBuffer = '' @@ -1139,8 +1364,17 @@ function runCodexExec( child.stdin.end(prompt) + function currentTaskStatus() { + return readTaskStore().tasks.find((task) => task.id === runId)?.status + } + child.on('error', (error) => { preparedAttachments.cleanup() + runningTasks.delete(runId) + updateTask(runId, 'failed', { + note: error.message, + exitCode: null, + }) const message = errorMessage(error.message) appendMessage(session, message) writeStore(store) @@ -1158,6 +1392,20 @@ function runCodexExec( child.on('close', (code) => { preparedAttachments.cleanup() + runningTasks.delete(runId) + if (currentTaskStatus() === 'canceled') { + updateTask(runId, 'canceled', { exitCode: code }) + emit(session.key, { + event: 'chat', + stateVersion, + payload: { + runId, + sessionKey: session.key, + state: 'aborted', + }, + }) + return + } const trailing = processCodexJsonLine(stdoutBuffer.trim()) if (trailing) { if (trailing.kind === 'message-delta') { @@ -1175,19 +1423,36 @@ function runCodexExec( (trailing.kind === 'assistant-final' || code === 0) ) { appendFinalText(assistantText) + updateTask(runId, 'completed', { exitCode: code }) + return + } + if (emittedFinal && code === 0) { + updateTask(runId, 'completed', { exitCode: code }) return } - if (emittedFinal && code === 0) return } } - if (emittedFinal && code === 0) return + if (emittedFinal && code === 0) { + updateTask(runId, 'completed', { exitCode: code }) + return + } if (code === 0 && assistantText) { appendFinalText(assistantText) + updateTask(runId, 'completed', { exitCode: code }) return } const detail = stderrBuffer.trim() + const taskErrorDetail = + detail || + (code === 0 + ? 'Codex CLI finished without returning an assistant message.' + : 'Codex CLI exited with status ' + (code ?? 'unknown') + '.') + updateTask(runId, 'failed', { + note: taskErrorDetail, + exitCode: code, + }) const fallback = code === 0 ? 'Codex CLI finished without returning an assistant message.' @@ -1629,13 +1894,16 @@ export function sendCodexPrompt(input: SendCodexPromptInput) { const store = readStore() const session = ensureSession(input.sessionKey || 'main') const prompt = buildUserPrompt(input) + const runId = randomUUID() + const messageId = input.idempotencyKey || randomUUID() const userMessage: CodexMessage = { - id: input.idempotencyKey || randomUUID(), + id: messageId, clientId: input.idempotencyKey, role: 'user', timestamp: Date.now(), details: { + taskId: runId, runProfile: profile.id, sandbox: profile.sandbox, approval: profile.approval, @@ -1645,7 +1913,24 @@ export function sendCodexPrompt(input: SendCodexPromptInput) { appendMessage(session, userMessage) writeStore(store) - const runId = randomUUID() + createTaskRecord({ + id: runId, + sessionKey: session.key, + messageId, + prompt, + runProfile: profile.id, + retryOf: input.retryOf, + snapshot: { + sessionKey: session.key, + message: input.message, + thinking: input.thinking, + attachments: input.attachments, + contextBlock: input.contextBlock, + runProfile: profile.id, + confirmedRisk: input.confirmedRisk, + }, + }) + emit(session.key, { event: 'chat', stateVersion, @@ -1689,6 +1974,109 @@ export function listCodexSessions() { } } +function isTerminalTaskStatus(status: CodexTaskStatus) { + return status === 'completed' || status === 'failed' || status === 'canceled' +} + +function publicTaskRecord(task: CodexTaskRecord) { + return { + id: task.id, + sessionKey: task.sessionKey, + messageId: task.messageId, + message: task.message, + runProfile: task.runProfile, + status: task.status, + createdAt: task.createdAt, + updatedAt: task.updatedAt, + startedAt: task.startedAt, + finishedAt: task.finishedAt, + durationMs: task.durationMs, + exitCode: task.exitCode, + error: task.error, + retryOf: task.retryOf, + events: task.events, + } +} + +export function listCodexTasks() { + const store = readTaskStore() + let changed = false + for (const task of store.tasks) { + if (!isTerminalTaskStatus(task.status) && !runningTasks.has(task.id)) { + appendTaskEvent( + task, + 'failed', + 'Task process is not attached to the current CodexClaw server.', + ) + changed = true + } else if (!task.durationMs && task.startedAt) { + task.durationMs = taskDuration(task) + } + } + if (changed) writeTaskStore(store) + return { + tasks: store.tasks + .slice() + .sort((first, second) => second.createdAt - first.createdAt) + .map(publicTaskRecord), + } +} + +export function cancelCodexTask(taskId: string) { + const id = taskId.trim() + if (!id) throw new Error('task id required') + const store = readTaskStore() + const task = store.tasks.find((item) => item.id === id) + if (!task) throw new Error('Task not found: ' + id) + if (isTerminalTaskStatus(task.status)) { + return { ok: true, task: publicTaskRecord(task) } + } + const child = runningTasks.get(id) + if (!child) { + appendTaskEvent( + task, + 'failed', + 'Task process is not attached to the current CodexClaw server.', + ) + writeTaskStore(store) + throw new Error('Task is not running.') + } + appendTaskEvent(task, 'canceled', 'Canceled by user.') + writeTaskStore(store) + child.kill() + emit(task.sessionKey, { + event: 'chat', + stateVersion, + payload: { + runId: id, + sessionKey: task.sessionKey, + state: 'aborted', + }, + }) + return { ok: true, task: publicTaskRecord(task) } +} + +export function retryCodexTask(taskId: string) { + const id = taskId.trim() + if (!id) throw new Error('task id required') + const store = readTaskStore() + const task = store.tasks.find((item) => item.id === id) + if (!task) throw new Error('Task not found: ' + id) + if (task.status !== 'failed' && task.status !== 'canceled') { + throw new Error('Only failed or canceled tasks can be retried.') + } + const result = sendCodexPrompt({ + ...task.snapshot, + retryOf: task.id, + idempotencyKey: randomUUID(), + }) + return { + ok: true, + retryOf: task.id, + ...result, + } +} + export function patchCodexSession(input: { key?: string; label?: string }) { const session = ensureSession(input.key || randomUUID(), input.label) return { @@ -1736,5 +2124,7 @@ export function codexCliCheck() { export function resetCodexServerStateForTests() { storeCache = null workspaceStoreCache = null + taskStoreCache = null + runningTasks.clear() stateVersion = 0 }