Skip to main content
xbot
Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Back to homepage
Edit page

Plan: Background Shell Task Execution

Generated: 2026-04-02 Status: Pending Review

Background & Goals

Current Problem

When a shell command runs indefinitely (e.g. tail -f, dev server, long test), two issues occur:

  1. Ctrl+C only cancels the agent loop, not the actual process — The agent loop returns, but exec.CommandContext may not reliably kill all child processes (especially in Docker sandbox where only the CLI process is killed, container processes continue). The process may leak.

  2. Entire agent loop blocksexecOne() in engine.go:967 synchronously calls toolExecutor(execCtx, tc). In default serial mode (engine.go:1108-1116), one stuck shell = entire iteration stuck. No other tools can run, no progress updates reach the user.

Goal

Implement a background task system similar to Claude Code’s approach:

  1. Shell tool gets background: true parameter — when set, the command runs in a goroutine and returns a task ID immediately
  2. Task lifecycle managementtask_kill, task_status, task_read tools for the LLM to monitor/control
  3. Notification on completion — when a background task finishes, its result is injected into the conversation as a user message, triggering a new iteration
  4. User can kill via CLI/kill <task_id> command or Ctrl+C during background execution

Non-Goals (for now)

  • Scheduled/loop tasks (/loop like Claude) — can be added later
  • Background tasks for non-Shell tools — Shell is the only tool that commonly runs indefinitely
  • Persistent tasks across restarts — tasks are in-memory only

Current Architecture Analysis

Key Files

FileRoleChange Type
tools/shell.goShell tool implementationModify (add background param)
tools/task_manager.goNEW — background task lifecycle managerNew
tools/task_tools.goNEW — task_kill/task_status/task_read toolsNew
agent/engine.goAgent loop, tool executionModify (inject task manager, handle bg results)
agent/engine_wire.goWire task manager into RunConfig/ToolContextModify
tools/interface.goToolContext, ToolResultModify (add TaskManager field)
channel/cli.goCLI rendering, /kill commandModify (render bg tasks, /kill)
agent/progress.goProgress data structuresModify (add bg task info)

Data Flow (Proposed)

LLM calls Shell(background=true, command="npm run dev")
    │
    ▼
engine.execOne()
    ├── Foreground path (background=false): synchronous as before
    └── Background path (background=true):
        ├── Create BackgroundTask{ID, Command, Status: Running, StartedAt}
        ├── Launch goroutine: sandbox.Exec(detachedCtx, spec)
        ├── Store task in TaskManager
        └── Return ToolResult{Summary: "Background task started [id: abc123]"}
    │
    ▼
Agent continues next iteration with task ID info
    │
    ▼
Goroutine completes:
    ├── Update task {Status: Done/Error, Result, FinishedAt}
    └── Notify engine via callback → inject result as user message
    │
    ▼
New iteration: LLM sees "[bg:abc123] Task completed: ..." and can act on it

Task Manager Design

// tools/task_manager.go
type BackgroundTask struct {
    ID         string        `json:"id"`         // unique ID (8-char hex)
    Command    string        `json:"command"`     // original command
    Status     string        `json:"status"`      // "running" | "done" | "error" | "killed"
    StartedAt  time.Time     `json:"started_at"`
    FinishedAt *time.Time    `json:"finished_at,omitempty"`
    Output     string        `json:"output"`      // stdout+stderr (truncated to 50KB)
    ExitCode   int           `json:"exit_code"`
    Error      string        `json:"error,omitempty"`
    cancel     context.CancelFunc  // internal, not serialized
}

type TaskManager interface {
    // Start launches a background task and returns immediately
    Start(ctx context.Context, sandbox Sandbox, spec ExecSpec, userID string) (*BackgroundTask, error)
    // Kill terminates a running task
    Kill(taskID string) error
    // Status returns current task state
    Status(taskID string) (*BackgroundTask, error)
    // List returns all tasks for a session
    List(sessionKey string) []*BackgroundTask
    // OnComplete registers a callback for task completion
    OnComplete(sessionKey string, callback func(task *BackgroundTask))
}

Kill Mechanism

The key improvement over current behavior: process group kill.

For NoneSandbox:

// Instead of plain exec.CommandContext:
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
// Kill: syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)  // kill entire process group

For DockerSandbox:

// After docker exec, use: docker exec --sig-kill <container> kill -9 <pid>
// Or: docker stop <exec_id> (docker 1.41+)

Notification Flow

When a background task completes, the engine needs to inject a “virtual user message” to trigger a new LLM iteration:

Background goroutine completes
    → TaskManager calls OnComplete callback
    → Engine injects into messages: {role: "user", content: "[bg:{id}] completed (exit {code}):\n{output}"}
    → Triggers next iteration of the agentic loop

Implementation: The engine’s Run() loop needs a secondary channel to receive background task notifications:

// In engine.go Run():
taskNotifyCh := make(chan *BackgroundTask, 16) // buffered

for i := 0; i < maxIter; i++ {
    select {
    case task := <-taskNotifyCh:
        // Inject bg task result as user message
        messages = append(messages, llm.Message{
            Role: "user",
            Content: fmt.Sprintf("[bg:%s] Task %s (exit %d):\n%s",
                task.ID, task.Status, task.ExitCode, truncate(task.Output, 50000)),
        })
        i-- // don't count this as an iteration
        continue
    default:
    }
    // ... normal iteration logic
}

LLM Tools

task_status — Check status of a background task:

{
    "name": "task_status",
    "parameters": {"task_id": "abc123"}
}

Returns: current status, output so far (for running tasks, returns last N bytes of output).

task_kill — Kill a running background task:

{
    "name": "task_kill",
    "parameters": {"task_id": "abc123"}
}

Returns: confirmation that task was killed.

task_read (optional, can defer) — Read more output from a completed task:

{
    "name": "task_read",
    "parameters": {"task_id": "abc123", "offset": 0, "limit": 8000}
}

CLI Integration

  1. Progress display: Background tasks show in progress area as:

    ● bg:abc123 npm run dev (running 45s)
    
  2. /kill command: User types /kill abc123 to kill a specific task, or /kill to list running tasks.

  3. Completion notification: When a bg task completes during idle (no active agent loop), show a status bar notification like:

    [bg:abc123] npm run dev exited with code 0 (tap to view output)
    

Detailed Plan

Phase 1: Task Manager Core

  • 1.1 Create tools/task_manager.goBackgroundTask struct, TaskManager interface, in-memory implementation with sync.RWMutex
  • 1.2 Implement Start() — creates task, launches goroutine with sandbox.Exec, calls OnComplete callback when done
  • 1.3 Implement Kill() — cancels context, kills process group (syscall.Kill(-pgid, SIGKILL))
  • 1.4 Implement Status() and List() — thread-safe reads
  • 1.5 Per-session task tracking — tasks scoped to sessionKey, cleaned up on session close

Phase 2: Shell Tool Background Mode

  • 2.1 Add background parameter to Shell tool — Parameters() add {Name: "background", Type: "boolean", ...}
  • 2.2 In ShellTool.Execute(), check background=true → call TaskManager.Start() instead of blocking
  • 2.3 Return immediate result with task ID: "Background task started [id: abc123]. Use task_status to monitor, task_kill to terminate."
  • 2.4 For background tasks, use process group kill (Setpgid: true) in NoneSandbox

Phase 3: Task Control Tools

  • 3.1 Create tools/task_tools.goTaskStatusTool, TaskKillTool, TaskReadTool
  • 3.2 Register tools in tools/interface.go — add to core tool registration
  • 3.3 TaskStatusTool: returns status + last 2000 chars of output for running, full output for completed
  • 3.4 TaskKillTool: calls TaskManager.Kill(), returns confirmation
  • 3.5 TaskReadTool: paginated output reading for completed tasks (optional, can defer)

Phase 4: Engine Integration

  • 4.1 Add TaskManager to ToolContextinterface.go
  • 4.2 Add TaskManager to RunConfigengine.go
  • 4.3 Wire TaskManager in buildToolContext() — pass from RunConfig
  • 4.4 In Run() loop, add select on task completion channel — inject result as user message, decrement iteration counter
  • 4.5 Register OnComplete callback in engine setup — pipes bg task results into the notification channel
  • 4.6 Update system prompt / tool descriptions to document background mode

Phase 5: CLI Integration

  • 5.1 Add background task info to StructuredProgressprogress.go
  • 5.2 Render running bg tasks in progress block — dimmed line with ● bg:id command (running Xs)
  • 5.3 Add /kill <task_id> command parsing in CLI Update — call TaskManager.Kill() via closure
  • 5.4 Completion notification during idle — tempStatus or status bar hint

Phase 6: Testing

  • 6.1 Unit test: TaskManager Start/Kill/Status lifecycle
  • 6.2 Unit test: background Shell tool returns task ID immediately
  • 6.3 Unit test: background task completion injects notification
  • 6.4 Integration test: agent loop receives bg task result in next iteration

Verification

  • go build ./... compiles cleanly
  • go test ./... all pass
  • Manual test: run sleep 1000 with background=true → verify immediate return
  • Manual test: kill bg task with /kill → verify process dies
  • Manual test: bg task completes → verify agent gets notified and acts on result

Rollback Strategy

All changes are additive (new tools + new parameter). If issues arise:

  1. Remove background parameter from Shell tool → reverts to sync-only
  2. Remove task_* tools from registration → no bg task control
  3. Engine select on taskNotifyCh falls through to default → no behavioral change

Notes

  • Output buffering: Background tasks buffer stdout/stderr. Need to handle large output — cap at 50KB per task, rotate if needed
  • Process group kill is critical: Without Setpgid, child processes leak. This applies to both foreground and background modes — should fix for foreground too
    • NoneSandbox: none_sandbox.go needs cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + syscall.Kill(-pid, SIGKILL) for kill. Must be platform-conditional (Unix only, Windows uses Job Objects)
    • DockerSandbox: docker_sandbox.go — need to track exec PID inside container and kill with docker exec kill -9 <pid>. Complex — Phase 1 may only implement NoneSandbox process group kill, Docker can follow
  • Remote sandbox: Background tasks need the runner to support async exec. May need runner protocol extension. Phase 1-3 should skip remote sandbox — background only works with NoneSandbox initially, Docker in Phase 2+
  • Concurrency: Multiple bg tasks should run in parallel. TaskManager goroutines should be independent
  • Memory: Each task holds up to 50KB output. With 10 concurrent tasks, that’s 500KB — acceptable
  • Timeout: Background tasks have NO timeout by default (they run indefinitely until killed or completed). This is the whole point. But we should add a max lifetime (e.g. 24h) as safety net
  • Existing tool timeout: The engine’s execCtx, cancel = context.WithTimeout(ctx, toolTimeout) in engine.go:958 must NOT apply to background tasks. Background tasks use their own detached context

✅ Self-review passed