# Background tasks (agent-level parallelism)

How the agent keeps working while a long tool call runs detached. Files:
`navi/core/tasks.py` (TaskManager), `navi/core/task_notes.py` (completion
notes), `navi/core/tool_executor.py` (interception), `navi/tools/tasks.py`
(the `tasks` tool), `navi/api/websocket.py` (message queue).

The contract follows the pattern used by other harnesses
(start → continue → collect): the agent launches a long call with
`background: true`, immediately receives a `task_id`, keeps doing useful
work, and collects the result either by calling the `tasks` tool or
automatically as a completion note next turn.

## The `background` parameter

Tool calls whose arguments carry `"background": true` are detached — the
ToolExecutor submits the whole `tool.execute()` to the TaskManager and returns
a synthetic result right away:

```json
{"task_id": "bt-ab12cd34", "tool": "terminal", "status": "running",
 "args_summary": "{\"action\": \"run\", \"command\": \"make test\"}",
 "hint": "The call is running in the background — continue with other work. ..."}
```

The `background` flag is stripped before the tool runs. Tools that accept it:
`terminal` (action `run`), `ssh_exec`, `peer` (action `ask`), `spawn_agent`,
`code_exec` — the list is `settings.backgroundable_tools`. Any tool outside
the list runs inline even with the flag set.

Exception: `terminal` with `action: "open"` keeps its native meaning of
`background` (a persistent detached process) and is never hijacked.

The interception lives in `ToolExecutor._execute_one`, so it works identically
from the top-level agent loop and from sub-agents.

## TaskManager

`navi/core/tasks.py`, process-wide singleton (`get_task_manager()`). Each
`TaskJob` carries:

- `task_id` — `bt-` + 8 hex chars;
- `status` — `running → completed | failed | cancelled`;
- `result` — the tool's ToolResult; `error` for failures;
- `ring` — a bounded (drop-oldest) event queue with the job's live events;
- `done` — an asyncio event set when the job reaches a terminal state;
- `stop_event` — the job's own stop signal, **independent of the session's**.

Caps (per session unless noted, configurable in `docs/config.md`):
`tasks_max_per_session=5` running, `tasks_max_global=20`, `tasks_max_spawn=2`
background sub-agents, and a rate limit of `tasks_rate_limit=10` submissions
per 5 minutes. A rejected submit returns the reason as a string; the executor
turns it into a normal failed tool result ("Cannot run in background: …") so
the agent falls back to running the tool inline.

Finished jobs are reaped by a lazy sweeper after `tasks_ttl_sec` (1 h), with
at most 50 finished jobs kept per session.

### Isolation guarantees

- The detached job gets a rebuilt ToolContext and its own ContextVar
  overrides: its events stream into its own ring (never into a dead
  foreground sink), and `current_stop_event` points at the job's own event —
  pressing **Stop on the session does not cancel background tasks**; only
  `tasks cancel` does.
- Background jobs never write session history. The only mutation is the
  "task started" tool message written by the foreground loop.

## Delivery of results — two paths

1. **`task_update` wire event** — published via the orchestrator to all
   connected clients the moment the job reaches a terminal state
   (`running` on submit, final state on finish). Out-of-band: not part of
   the run's replay buffer; on reconnect the client re-syncs via
   `tasks list` instead.
2. **Completion note** — recorded in the KV store (`task_notes` scope) and
   drained by the next `run_stream` turn into the session context as a
   system message (persisted, invisible in the UI):

   ```
   [Background task results]
   - bt-ab12 (terminal) completed: PASS 42/42...
   ...
   (Use these results to continue your work. Do not start new background
   tasks in response to this note unless the user asked.)
   ```

   Up to `task_notes_per_turn=5` notes per turn, coalesced; overflow says to
   call `tasks check`. At most `task_notes_max_pending=20` notes per session
   (oldest dropped). Notes survive a server restart; the running tasks
   themselves do not (they die with the process).

## The `tasks` tool

`{action: list | check | wait | cancel, task_id?, timeout?}` — session-scoped.

- `list` — this session's jobs with status and age.
- `check` — status + result (≤2000 chars) + the last ring events as live
  progress (useful while a background sub-agent is still working). For
  spawn_agent jobs the check output includes the sub-agent's token count.
- `wait` — blocks the turn until the job finishes or `timeout` (capped at
  120 s) expires; `wait_timeout` is a normal failed result telling the agent
  to continue other work.
- `cancel` — cooperative cancel: sets the job's stop event and cancels its
  asyncio task.

Manual for the agent: `manuals/tasks.md`.

## Background sub-agents (spawn_agent)

`spawn_agent` is synchronous by default (blocks until the sub-agent
completes, ≤300 s). With `background: true` the same interception detaches
it; the sub-agent's event stream (thinking, tool cards, planning) flows into
the job's ring, so `tasks check` shows live progress. Deeper ring (200
events) for spawn jobs.

Caveat: tokens consumed by a background sub-agent are reported in
`task_update.subagent_tokens` and the completion note — they no longer
appear in the parent turn's token count.

## Parallel tool-call batch (Ф3)

When the profile enables `parallel_tool_calls` (profile override, else
`settings.parallel_tool_calls`, default **off**), a model response carrying
several tool calls executes them concurrently:

- `tool_started` frames for the whole batch are emitted up-front, in call
  order;
- live events from all tools merge through one tagged queue;
- results and tool messages are appended in call order after the batch
  settles, with a single session save;
- Stop mid-batch cancels every unfinished tool and synthesises
  "stopped by the user" results for each;
- one tool's exception is recorded as its own failed result and does not
  kill its neighbours (backend-level LLM errors still abort the turn).

The gate lives in `Agent._execute_tools_with_sink`; the batch is used only
for 2+ calls. A crash mid-batch can leave assistant tool_calls without their
`role=tool` pair — repaired on session load by
`pg_session_store.repair_dangling_tool_calls` (display history only).

Agent discipline: batch only **independent** calls; never two actions over
the same terminal in one batch.

## Message queue

A user message arriving while a run is active is queued (see
`docs/websocket.md`, `message_queued`) instead of erroring. Queue state is
in-memory on `SessionState` (`pending_user_messages`, maxlen
`message_queue_max=5`, oldest dropped with `dropped_total` counter). Drained
in submit order by the socket after the run finishes, or headlessly when the
client disconnected.