Skip to content

feat(tasks): Add background task execution with status polling#9

Open
heysarver wants to merge 3 commits intomainfrom
feat/background-task-execution
Open

feat(tasks): Add background task execution with status polling#9
heysarver wants to merge 3 commits intomainfrom
feat/background-task-execution

Conversation

@heysarver
Copy link
Owner

@heysarver heysarver commented Jan 30, 2026

Summary

Add asynchronous task execution to the Claude Code Proxy. Instead of holding HTTP connections open until completion, clients can now:

  • Submit prompts as background tasks
  • Receive a task ID immediately (HTTP 202)
  • Poll for status and results
  • Cancel running tasks

Key Changes

New endpoints:

Endpoint Method Description
/api/tasks POST Submit task, returns taskId (202 Accepted)
/api/tasks/:id GET Poll for status and results
/api/tasks/:id DELETE Cancel running task

Implementation:

  • 3-state model: runningcompleted | failed
  • failureReason field captures: cancelled, timeout, error, server_restart
  • SQLite persistence with TTL cleanup (1 hour default)
  • Orphaned task recovery on server restart
  • Per-API-key task isolation

Files:

  • src/lib/task-store.ts - TaskStore class (~230 lines)
  • src/routes/tasks.ts - Route handlers (~240 lines)
  • src/lib/database.ts - Tasks table schema
  • src/lib/errors.ts - TASK_NOT_FOUND error
  • src/types/index.ts - Task interfaces
  • src/index.ts - Integration

Testing

  • Build passes (pre-existing TypeScript warning unrelated to this PR)
  • Tests pass (1 pre-existing failure unrelated to this PR)
  • Ready for manual testing of task lifecycle

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

  • Plan: docs/plans/2026-01-30-feat-background-task-execution-plan.md
  • Brainstorm: docs/brainstorms/2026-01-30-background-tasks-brainstorm.md

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Background task system with create/retrieve/cancel APIs and immediate 202 on submission.
    • Tasks can resume prior sessions; task results now include the effective model used.
  • Bug Fixes / Reliability

    • Orphaned tasks are marked failed; periodic cleanup runs and stops during graceful shutdown.
  • Errors

    • Added explicit "task not found" and "invalid model" errors.
  • Documentation

    • API docs and client collection updated; new DEFAULT_MODEL/defaulting behavior documented.

✏️ Tip: You can customize this high-level summary in your review settings.

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>
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 30, 2026

📝 Walkthrough

Walkthrough

Adds 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

Cohort / File(s) Summary
Core task store & types
src/lib/task-store.ts, src/types/index.ts
Adds TaskStore class with full lifecycle API (create/get/cancel/setCompleted/setFailed/markOrphanedTasksFailed/startCleanup/stopCleanup) and new Task, TaskCreateRequest, TaskStatus types plus model additions to RunResponse/ClaudeRunResult.
DB schema & session DB accessor
src/lib/database.ts, src/lib/session-store.ts
Creates tasks table, status CHECK, indexes for api_key_hash and TTL cleanup; exposes SessionStore.getDatabase() to share DB handle.
API routes & integration
src/routes/tasks.ts, src/routes/api.ts, src/index.ts
Adds createTasksRouter(...) with POST/GET/DELETE /api/tasks endpoints, executeTask background flow (WorkerPool + SessionStore), mounts router at /api/tasks behind auth, and wires TaskStore startup/shutdown calls. Also propagates model in /api/run responses and validates optional model.
Runner & config
src/lib/claude-runner.ts, src/config.ts
Normalizes/evaluates effective model (request or DEFAULT_MODEL), always passes --model to runner, updates parseClaudeOutput signature/result to include model, and adds defaultModel config (DEFAULT_MODEL env with validation).
Errors & logging
src/lib/errors.ts, src/index.ts
Adds ErrorCodes.TASK_NOT_FOUND and ErrorCodes.INVALID_MODEL and factory errors taskNotFound() and invalidModel(); startup log now includes defaultModel.
Client examples / docs / manifest
insomnia-collection.json, CLAUDE.md, package.json
Adds "Background Tasks" collection requests (submit/get/cancel), documents DEFAULT_MODEL and example responses including model/duration, and minor manifest metadata update.

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}
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰
I dug a little hole in SQLite's ground,
Planted tasks that hum without a sound,
WorkerPool hops, sessions tucked in tight,
Cleanup naps until they're out of sight,
Hooray — async carrots done just right!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(tasks): Add background task execution with status polling' directly and clearly describes the main change: adding background task support with status polling capability. It aligns perfectly with the core objective of the PR.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/background-task-execution

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

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>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +23 to +35
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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.

Comment on lines +65 to +98
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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +366 to +388
{
"_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}"
}
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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:

  1. Change "tools" to "allowedTools" in the description to match the actual field name (consistent with other requests like "Run - With Allowed Tools")
  2. Either add sessionId to 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.

Suggested change
{
"_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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant