feat(tasks): Add background task execution with status polling#9
feat(tasks): Add background task execution with status polling#9
Conversation
Add asynchronous task execution to the proxy. Clients can submit prompts as background tasks, receive a task ID immediately (HTTP 202), and poll for results. New endpoints: - POST /api/tasks - Submit task, returns taskId - GET /api/tasks/:id - Poll for status and results - DELETE /api/tasks/:id - Cancel running task Implementation: - 3-state model: running → completed | failed - SQLite persistence with TTL cleanup (1 hour) - Orphaned task recovery on server restart - Per-API-key task isolation Co-Authored-By: Claude <noreply@anthropic.com>
📝 WalkthroughWalkthroughAdds a background task system: new TaskStore (SQLite-backed), DB schema for tasks, task API routes (create/get/cancel), background execution via WorkerPool with SessionStore integration, startup/shutdown wiring, types/errors/config updates, and client examples. Changes
Sequence Diagram(s)sequenceDiagram
actor Client
participant API as "API Route\n(/api/tasks)"
participant TaskStore as "TaskStore"
participant DB as "Database"
participant Worker as "WorkerPool"
participant Session as "SessionStore"
Client->>API: POST /api/tasks (Bearer apiKey, prompt, model?)
API->>TaskStore: createTask(request, apiKey)
TaskStore->>DB: INSERT task (status='running')
DB-->>TaskStore: taskId
TaskStore-->>API: Task {id, status, createdAt}
API-->>Client: 202 Accepted {taskId, status, createdAt}
Note over API,Worker: Background execution (detached)
API->>TaskStore: getAbortController(taskId)
TaskStore-->>API: AbortController
opt sessionId provided
API->>Session: getSession(sessionId)
Session-->>API: sessionData
end
API->>Worker: submit task (prompt, effectiveModel, tools, abortSignal, maxTurns)
Worker-->>API: result (+ optional newSessionId)
alt success
API->>TaskStore: setCompleted(taskId, result, claudeSessionId?)
TaskStore->>DB: UPDATE task (status='completed', completed_at, result)
else failure or abort
API->>TaskStore: setFailed(taskId, reason)
TaskStore->>DB: UPDATE task (status='failed', completed_at, failure_reason)
end
Client->>API: GET /api/tasks/:id (Bearer apiKey)
API->>TaskStore: getTask(taskId, apiKey)
TaskStore->>DB: SELECT task
DB-->>TaskStore: TaskRow
TaskStore-->>API: Task
API-->>Client: 200 OK {task details}
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Add new "Background Tasks" folder with: - POST /api/tasks - Submit task (simple and full options variants) - GET /api/tasks/:id - Get task status - DELETE /api/tasks/:id - Cancel task Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@src/routes/tasks.ts`:
- Around line 23-35: The formatTaskResponse function currently uses falsy checks
that drop valid empty-string values; change the conditional guards for
task.result, task.failureReason and task.claudeSessionId to explicit undefined
checks (e.g., if (task.result !== undefined) ...) so empty strings are preserved
in the response; keep the existing toISOString() handling for
startedAt/completedAt and the !== undefined check for durationMs as-is.
- Around line 65-98: When resuming (task.sessionId present) and
sessionStore.getSession(task.sessionId, apiKey) returns an existingSession, do
not call sessionStore.createSession(result.sessionId); instead reuse
existingSession.id as the responseSessionId and call
sessionStore.touch(existingSession.id) (or the store's refresh method) to update
its last-used timestamp; only create a new session via
sessionStore.createSession(result.sessionId, apiKey) when there was no
existingSession. Ensure this logic references resumeSessionId, result.sessionId,
sessionStore.getSession, sessionStore.createSession and sessionStore.touch so
the code sets responseSessionId to the existing session's id when resuming and
avoids inflating session counts.
| function formatTaskResponse(task: Task): Record<string, unknown> { | ||
| const response: Record<string, unknown> = { | ||
| taskId: task.id, | ||
| status: task.status, | ||
| createdAt: task.createdAt.toISOString(), | ||
| }; | ||
|
|
||
| if (task.startedAt) response.startedAt = task.startedAt.toISOString(); | ||
| if (task.completedAt) response.completedAt = task.completedAt.toISOString(); | ||
| if (task.durationMs !== undefined) response.durationMs = task.durationMs; | ||
| if (task.result) response.result = task.result; | ||
| if (task.claudeSessionId) response.sessionId = task.claudeSessionId; | ||
| if (task.failureReason) response.failureReason = task.failureReason; |
There was a problem hiding this comment.
Preserve empty result/failureReason values.
Falsy checks drop valid empty strings. Guard for undefined instead.
🔧 Suggested fix
- if (task.result) response.result = task.result;
+ if (task.result !== undefined) response.result = task.result;
if (task.claudeSessionId) response.sessionId = task.claudeSessionId;
- if (task.failureReason) response.failureReason = task.failureReason;
+ if (task.failureReason !== undefined) response.failureReason = task.failureReason;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function formatTaskResponse(task: Task): Record<string, unknown> { | |
| const response: Record<string, unknown> = { | |
| taskId: task.id, | |
| status: task.status, | |
| createdAt: task.createdAt.toISOString(), | |
| }; | |
| if (task.startedAt) response.startedAt = task.startedAt.toISOString(); | |
| if (task.completedAt) response.completedAt = task.completedAt.toISOString(); | |
| if (task.durationMs !== undefined) response.durationMs = task.durationMs; | |
| if (task.result) response.result = task.result; | |
| if (task.claudeSessionId) response.sessionId = task.claudeSessionId; | |
| if (task.failureReason) response.failureReason = task.failureReason; | |
| function formatTaskResponse(task: Task): Record<string, unknown> { | |
| const response: Record<string, unknown> = { | |
| taskId: task.id, | |
| status: task.status, | |
| createdAt: task.createdAt.toISOString(), | |
| }; | |
| if (task.startedAt) response.startedAt = task.startedAt.toISOString(); | |
| if (task.completedAt) response.completedAt = task.completedAt.toISOString(); | |
| if (task.durationMs !== undefined) response.durationMs = task.durationMs; | |
| if (task.result !== undefined) response.result = task.result; | |
| if (task.claudeSessionId) response.sessionId = task.claudeSessionId; | |
| if (task.failureReason !== undefined) response.failureReason = task.failureReason; |
🤖 Prompt for AI Agents
In `@src/routes/tasks.ts` around lines 23 - 35, The formatTaskResponse function
currently uses falsy checks that drop valid empty-string values; change the
conditional guards for task.result, task.failureReason and task.claudeSessionId
to explicit undefined checks (e.g., if (task.result !== undefined) ...) so empty
strings are preserved in the response; keep the existing toISOString() handling
for startedAt/completedAt and the !== undefined check for durationMs as-is.
| try { | ||
| // Look up existing session if provided | ||
| let resumeSessionId: string | undefined; | ||
| if (task.sessionId) { | ||
| const existingSession = sessionStore.getSession(task.sessionId, apiKey); | ||
| if (existingSession) { | ||
| resumeSessionId = existingSession.claudeSessionId; | ||
| } | ||
| } | ||
|
|
||
| // Submit to worker pool | ||
| const result = await workerPool.submit( | ||
| { | ||
| prompt: task.prompt, | ||
| model: task.model, | ||
| allowedTools: task.allowedTools, | ||
| workingDirectory: task.workingDirectory, | ||
| resumeSessionId, | ||
| abortSignal: abortController.signal, | ||
| maxTurns: task.maxTurns, | ||
| stream: false, | ||
| }, | ||
| taskId | ||
| ); | ||
|
|
||
| // Handle session creation if Claude returned one | ||
| let responseSessionId: string | undefined; | ||
| if (result.sessionId) { | ||
| const session = sessionStore.createSession(result.sessionId, apiKey); | ||
| responseSessionId = session.id; | ||
| } | ||
|
|
||
| taskStore.setCompleted(taskId, result.result, responseSessionId); | ||
| } catch (error) { |
There was a problem hiding this comment.
Avoid creating a new session when resuming an existing one.
Right now, a resumed task still creates a new session entry, which can inflate session counts and hit maxSessionsPerKey. Reuse the existing session id (and touch it) when resuming.
✅ Suggested fix
- let resumeSessionId: string | undefined;
- if (task.sessionId) {
- const existingSession = sessionStore.getSession(task.sessionId, apiKey);
- if (existingSession) {
- resumeSessionId = existingSession.claudeSessionId;
- }
- }
+ const existingSession = task.sessionId
+ ? sessionStore.getSession(task.sessionId, apiKey)
+ : null;
+ const resumeSessionId = existingSession?.claudeSessionId;
@@
- let responseSessionId: string | undefined;
- if (result.sessionId) {
- const session = sessionStore.createSession(result.sessionId, apiKey);
- responseSessionId = session.id;
- }
+ let responseSessionId: string | undefined;
+ if (result.sessionId) {
+ if (existingSession) {
+ sessionStore.touchSession(existingSession.id);
+ responseSessionId = existingSession.id;
+ } else {
+ const session = sessionStore.createSession(result.sessionId, apiKey);
+ responseSessionId = session.id;
+ }
+ }🤖 Prompt for AI Agents
In `@src/routes/tasks.ts` around lines 65 - 98, When resuming (task.sessionId
present) and sessionStore.getSession(task.sessionId, apiKey) returns an
existingSession, do not call sessionStore.createSession(result.sessionId);
instead reuse existingSession.id as the responseSessionId and call
sessionStore.touch(existingSession.id) (or the store's refresh method) to update
its last-used timestamp; only create a new session via
sessionStore.createSession(result.sessionId, apiKey) when there was no
existingSession. Ensure this logic references resumeSessionId, result.sessionId,
sessionStore.getSession, sessionStore.createSession and sessionStore.touch so
the code sets responseSessionId to the existing session's id when resuming and
avoids inflating session counts.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@insomnia-collection.json`:
- Around line 366-388: Update the request metadata for req_tasks_submit_full so
the description and JSON body match: replace the word "tools" in the description
with the exact field name allowedTools to mirror the request body, and either
add a sessionId placeholder to the body (e.g., "sessionId": "<SESSION_ID>") or
remove sessionId from the description; ensure the description text and the body
fields (prompt, model, allowedTools, workingDirectory, maxTurns, sessionId if
added) are consistent.
| { | ||
| "_id": "req_tasks_submit_full", | ||
| "_type": "request", | ||
| "parentId": "fld_tasks", | ||
| "name": "Submit Task - Full Options", | ||
| "description": "Submit task with all available options (model, tools, workingDirectory, sessionId, maxTurns)", | ||
| "method": "POST", | ||
| "url": "{{ _.base_url }}/api/tasks", | ||
| "headers": [ | ||
| { | ||
| "name": "Authorization", | ||
| "value": "Bearer {{ _.api_key }}" | ||
| }, | ||
| { | ||
| "name": "Content-Type", | ||
| "value": "application/json" | ||
| } | ||
| ], | ||
| "body": { | ||
| "mimeType": "application/json", | ||
| "text": "{\n \"prompt\": \"List files in the current directory\",\n \"model\": \"sonnet\",\n \"allowedTools\": [\"Bash\", \"Read\"],\n \"workingDirectory\": \"/tmp\",\n \"maxTurns\": 5\n}" | ||
| } | ||
| }, |
There was a problem hiding this comment.
Inconsistency between description and request body.
The description on line 371 mentions "tools" but the body uses allowedTools. Also, sessionId is listed as an available option in the description but is not included in the example body.
Consider updating for consistency:
- Change "tools" to "allowedTools" in the description to match the actual field name (consistent with other requests like "Run - With Allowed Tools")
- Either add
sessionIdto the example body with a placeholder value, or remove it from the description
📝 Suggested fix
- "description": "Submit task with all available options (model, tools, workingDirectory, sessionId, maxTurns)",
+ "description": "Submit task with all available options (model, allowedTools, workingDirectory, sessionId, maxTurns)",And optionally add sessionId to the body:
- "text": "{\n \"prompt\": \"List files in the current directory\",\n \"model\": \"sonnet\",\n \"allowedTools\": [\"Bash\", \"Read\"],\n \"workingDirectory\": \"/tmp\",\n \"maxTurns\": 5\n}"
+ "text": "{\n \"prompt\": \"List files in the current directory\",\n \"model\": \"sonnet\",\n \"allowedTools\": [\"Bash\", \"Read\"],\n \"workingDirectory\": \"/tmp\",\n \"sessionId\": \"optional-session-id\",\n \"maxTurns\": 5\n}"📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| { | |
| "_id": "req_tasks_submit_full", | |
| "_type": "request", | |
| "parentId": "fld_tasks", | |
| "name": "Submit Task - Full Options", | |
| "description": "Submit task with all available options (model, tools, workingDirectory, sessionId, maxTurns)", | |
| "method": "POST", | |
| "url": "{{ _.base_url }}/api/tasks", | |
| "headers": [ | |
| { | |
| "name": "Authorization", | |
| "value": "Bearer {{ _.api_key }}" | |
| }, | |
| { | |
| "name": "Content-Type", | |
| "value": "application/json" | |
| } | |
| ], | |
| "body": { | |
| "mimeType": "application/json", | |
| "text": "{\n \"prompt\": \"List files in the current directory\",\n \"model\": \"sonnet\",\n \"allowedTools\": [\"Bash\", \"Read\"],\n \"workingDirectory\": \"/tmp\",\n \"maxTurns\": 5\n}" | |
| } | |
| }, | |
| { | |
| "_id": "req_tasks_submit_full", | |
| "_type": "request", | |
| "parentId": "fld_tasks", | |
| "name": "Submit Task - Full Options", | |
| "description": "Submit task with all available options (model, allowedTools, workingDirectory, sessionId, maxTurns)", | |
| "method": "POST", | |
| "url": "{{ _.base_url }}/api/tasks", | |
| "headers": [ | |
| { | |
| "name": "Authorization", | |
| "value": "Bearer {{ _.api_key }}" | |
| }, | |
| { | |
| "name": "Content-Type", | |
| "value": "application/json" | |
| } | |
| ], | |
| "body": { | |
| "mimeType": "application/json", | |
| "text": "{\n \"prompt\": \"List files in the current directory\",\n \"model\": \"sonnet\",\n \"allowedTools\": [\"Bash\", \"Read\"],\n \"workingDirectory\": \"/tmp\",\n \"sessionId\": \"optional-session-id\",\n \"maxTurns\": 5\n}" | |
| } | |
| }, |
🤖 Prompt for AI Agents
In `@insomnia-collection.json` around lines 366 - 388, Update the request metadata
for req_tasks_submit_full so the description and JSON body match: replace the
word "tools" in the description with the exact field name allowedTools to mirror
the request body, and either add a sessionId placeholder to the body (e.g.,
"sessionId": "<SESSION_ID>") or remove sessionId from the description; ensure
the description text and the body fields (prompt, model, allowedTools,
workingDirectory, maxTurns, sessionId if added) are consistent.
Add DEFAULT_MODEL env var to set server-wide default Claude model. Defaults to 'haiku' for fastest responses. Clients can override via the model field in requests. Changes: - Add defaultModel to Config with validation (opus/sonnet/haiku) - Apply default in claude-runner when no model specified - Add model field to responses for transparency - Add invalidModel error for request validation - Log effective model in startup and debug logs Co-Authored-By: Claude <noreply@anthropic.com>
Summary
Add asynchronous task execution to the Claude Code Proxy. Instead of holding HTTP connections open until completion, clients can now:
Key Changes
New endpoints:
/api/tasks/api/tasks/:id/api/tasks/:idImplementation:
running→completed|failedfailureReasonfield captures: cancelled, timeout, error, server_restartFiles:
src/lib/task-store.ts- TaskStore class (~230 lines)src/routes/tasks.ts- Route handlers (~240 lines)src/lib/database.ts- Tasks table schemasrc/lib/errors.ts- TASK_NOT_FOUND errorsrc/types/index.ts- Task interfacessrc/index.ts- IntegrationTesting
API Response Shapes
POST /api/tasks (202 Accepted):
{ "taskId": "uuid", "status": "running", "createdAt": "ISO8601" }GET /api/tasks/:id (completed):
{ "taskId": "uuid", "status": "completed", "result": "Claude's response", "sessionId": "optional", "createdAt": "ISO8601", "startedAt": "ISO8601", "completedAt": "ISO8601", "durationMs": 12345 }Related
docs/plans/2026-01-30-feat-background-task-execution-plan.mddocs/brainstorms/2026-01-30-background-tasks-brainstorm.md🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes / Reliability
Errors
Documentation
✏️ Tip: You can customize this high-level summary in your review settings.