Files
kandev/docs/task_session_resume.md
T

359 lines
15 KiB
Markdown

# Task Session Resume
How sessions are recovered and resumed after a backend restart or agent failure.
---
## Overview
When the backend restarts, running agent processes are lost but all session state is persisted in SQLite (`TaskSession`, `ExecutorRunning`). The system uses a **lazy recovery** pattern: sessions are not immediately restarted. Instead, when a user navigates to a session, the frontend detects `NeedsResume` and triggers the resume flow, which relaunches agentctl + the agent process.
Two resume strategies exist depending on agent capabilities:
| Strategy | When used | What happens |
|----------|-----------|--------------|
| **Native resume** (ACP `session/load`) | Agent has `NativeSessionResume: true` and a stored resume token | Agent restores its own conversation context via ACP protocol |
| **History injection** (fresh start) | Agent has `HistoryContextInjection: true`, no resume token | Agent starts fresh; conversation history is injected into the first user prompt |
Agents with neither flag simply boot idle with no context restoration.
---
## Resume Flow
```
Frontend Backend (Orchestrator) Lifecycle Manager
| | |
| task.session.status | |
|----------------------------->| |
| { needs_resume: true } | |
|<-----------------------------| |
| | |
| task.session.resume | |
|----------------------------->| ResumeTaskSession() |
| | executor.ResumeSession() |
| |------> buildResumeRequest() |
| |------> LaunchAgent() (agentctl) |
| |------> persistResumeState() |
| |------> startAgentProcessOnResume()|
| | | |
| | | (async goroutine) |
| | |----------------------->| StartAgentProcess()
| | | | configureAndStartAgent()
| | | | initializeACPSession()
| | | | dispatchInitialPrompt()
| | | | -> (see three-way switch)
| | | |
| | | callback: restore |
| | | WAITING_FOR_INPUT |
|<--- WS events (state) ------|----------|------------------------|
```
---
## Entry Points
### 1. Frontend auto-resume (primary)
**`hooks/domains/session/use-session-resumption.ts`**
When a user opens a session page, the `useSessionResumption` hook:
1. Sends `task.session.status` to backend
2. If `needs_resume && is_resumable` -> sends `task.session.resume`
3. Uses `hasAttemptedResume` ref to prevent duplicate resume requests
### 2. `GetTaskSessionStatus` (status evaluation)
**`internal/orchestrator/task_operations.go`** ~line 629
Backend method backing `task.session.status`. Evaluates resume eligibility through 4 cases:
| Case | Condition | NeedsResume | Reason |
|------|-----------|:-----------:|--------|
| 1 | Agent is running in memory | false | Already running |
| 2 | Agent not running but was recently seen | false | Stale execution |
| 3 | Resume token exists + active state | true | `agent_not_running` |
| 4 | No token but `ExecutorRunning` exists + active state | true | `agent_not_running_fresh_start` |
Active session states: `WAITING_FOR_INPUT`, `STARTING`, `RUNNING`.
Terminal states (block resume): `FAILED`, `COMPLETED`, `CANCELLED`.
### 3. `ensureSessionRunning` (lazy resume on prompt)
**`internal/orchestrator/task_operations.go`** ~line 548
Fallback path: when `PromptTask` is called but no in-memory execution exists (post-restart), it triggers `ResumeSession()` and polls until `WAITING_FOR_INPUT` is reached (500ms intervals, 90s timeout).
### 4. `handlePromptWithResume` (prompt retry)
**`internal/task/handlers/message_handlers.go`** ~line 393
If a prompt fails with `ErrExecutionNotFound`, the handler calls `ResumeTaskSession()`, waits for ready, and retries the prompt.
---
## Resume Execution (Backend)
### `ResumeTaskSession`
**`internal/orchestrator/task_operations.go`** ~line 402
1. Load session + `ExecutorRunning` from DB
2. Validate worktree paths exist on disk
3. Reject terminal states
4. Call `executor.ResumeSession(ctx, session, startAgent=true)`
5. On failure: mark session + task `FAILED`
### `Executor.ResumeSession`
**`internal/orchestrator/executor/executor_resume.go`** ~line 133
1. **`validateAndLockResume()`** -- acquire per-session mutex, verify no existing execution
2. **`buildResumeRequest()`** -- build `LaunchAgentRequest` from stored config
3. **`LaunchAgent()`** -- create agentctl instance (runtime-level)
4. **`persistResumeState()`** -- update session state to `STARTING` in DB
5. **`startAgentProcessOnResume()`** -- async goroutine to start agent subprocess
### `buildResumeRequest` (prompt decision)
**`internal/orchestrator/executor/executor_resume.go`** ~line 270
This is where the system decides whether to auto-prompt the agent:
```go
// Case A: Native resume -- has resume token
if running.ResumeToken != "" && startAgent {
req.ACPSessionID = running.ResumeToken // triggers session/load
req.TaskDescription = "" // don't auto-prompt
}
// Case B: Fresh-start resume -- no token, was WAITING_FOR_INPUT
else if startAgent && session.State == WAITING_FOR_INPUT {
req.TaskDescription = "" // don't auto-prompt; boot idle
}
// Case C: Otherwise -- TaskDescription stays populated (initial launch)
```
Clearing `TaskDescription` is critical: it controls whether `dispatchInitialPrompt` sends an automatic prompt or lets the agent boot idle.
### `startAgentProcessOnResume`
**`internal/orchestrator/executor/executor_resume.go`** ~line 488
Runs `StartAgentProcess` in a background goroutine. On success, the callback restores the session to `WAITING_FOR_INPUT` and the task to `REVIEW` (if the session was previously in `WAITING_FOR_INPUT`). This uses a captured `previousState` variable because `persistResumeState` mutates the session state to `STARTING` before the goroutine runs.
---
## Agent Process Initialization
### `StartAgentProcess`
**`internal/agent/lifecycle/manager_startup.go`** ~line 22
1. Wait for agentctl HTTP server ready (60s timeout)
2. Configure agent (command, env, working directory)
3. Start agent subprocess via agentctl
4. Initialize ACP session (`session/new` or `session/load`)
5. Call `dispatchInitialPrompt()`
### ACP session creation: `session/load` vs `session/new`
**`internal/agent/lifecycle/session.go`** ~line 118
```
If agent has NativeSessionResume: true AND existingSessionID is non-empty:
-> Try session/load (ACP protocol)
-> On failure (method not found / capability false): fallback to session/new
Otherwise:
-> session/new
```
### `dispatchInitialPrompt` (three-way switch)
**`internal/agent/lifecycle/session.go`** ~line 338
| Condition | Behavior |
|-----------|----------|
| `taskDescription` is non-empty | Send it as the initial prompt (standard launch) |
| `taskDescription` empty + `shouldInjectResumeContext()` returns true | Set `needsResumeContext = true`, mark agent ready (idle). History injected on first user prompt. |
| Neither | Mark agent ready (idle, no context) |
---
## History Injection
For agents that cannot natively restore their session (no ACP `session/load`), conversation history can be injected into the first user prompt after resume. This is opt-in via the `HistoryContextInjection` flag on `SessionConfig`.
### Recording
**`internal/agent/lifecycle/session_history.go`**
`SessionHistoryManager` stores history as JSONL files at `~/.kandev/sessions/{sessionID}.jsonl`.
Entry types: `user_message`, `agent_message`, `tool_call`, `tool_result`.
Recording happens in:
- `SendPrompt()` -- records user messages (`session.go` ~line 510)
- `handleAgentEvent()` -- records agent messages and tool calls (`manager_events.go`)
### Injection
When `shouldInjectResumeContext()` returns true (checks `HistoryContextInjection` flag + history file exists), the system defers injection:
1. `dispatchInitialPrompt()` sets `execution.needsResumeContext = true`
2. Agent boots idle, session goes to `WAITING_FOR_INPUT`
3. User sends a message
4. `SendPrompt()` calls `buildEffectivePrompt()` (`session.go` ~line 382)
5. `buildEffectivePrompt()` detects `needsResumeContext = true`, calls `GenerateResumeContext()`
6. Sets `resumeContextInjected = true` (one-time injection)
### Resume context format
`GenerateResumeContext()` (`session_history.go` ~line 207) produces:
```
RESUME CONTEXT FOR CONTINUING TASK
=== EXECUTION HISTORY ===
[USER]: <message>
[ASSISTANT]: <message>
[TOOL CALL: tool_name]
[TOOL RESULT: tool_name] <result>
=== CURRENT REQUEST ===
<user's actual prompt>
=== INSTRUCTIONS ===
You are continuing work on the above task...
Do not repeat work that was already completed.
```
Content is truncated: user/agent messages to 2000 chars, tool results to 500 chars.
### Relevant flags on `AgentExecution`
**`internal/agent/lifecycle/types.go`** ~line 67
| Field | Purpose |
|-------|---------|
| `historyEnabled` | Gates both recording and injection. Set from `SessionConfig.HistoryContextInjection`. |
| `needsResumeContext` | Set true when history exists and should be injected on next prompt. |
| `resumeContextInjected` | Set true after context has been injected (prevents double injection). |
---
## Backend Restart Recovery
### Runtime-level recovery (`RecoverInstances`)
**`internal/agent/lifecycle/manager_lifecycle.go`** ~line 21
On `Manager.Start()`, each runtime recovers what it can:
| Runtime | Recovery behavior |
|---------|------------------|
| **Docker** | Lists containers with `kandev.managed=true`, recovers running ones, reconnects agentctl |
| **Standalone** | Returns nil -- processes are transient, restarted on resume |
| **Sprites** | Returns nil -- reconnected via metadata on resume |
| **Remote Docker** | Returns nil |
### Lazy recovery pattern
After a backend restart:
1. Docker containers may be recovered immediately (agentctl still running)
2. Standalone agent processes are NOT recovered at runtime level
3. All session state persists in SQLite (`ExecutorRunning` retains worktree paths, resume tokens)
4. When frontend connects and opens a session, `GetTaskSessionStatus()` returns `NeedsResume: true`
5. Frontend auto-triggers `task.session.resume`
6. `ResumeSession()` creates a new agentctl instance and restarts the agent process
7. Depending on agent capabilities: native resume via token, or fresh start with history injection
---
## Session State Transitions
### Normal resume (success)
```
Before restart: Session = WAITING_FOR_INPUT Task = REVIEW
Resume starts: Session -> STARTING (persistResumeState)
Agent ready: Session -> WAITING_FOR_INPUT (startAgentProcessOnResume callback)
Task -> REVIEW
User sends msg: Session -> RUNNING Task -> IN_PROGRESS
Agent completes: Session -> WAITING_FOR_INPUT Task -> REVIEW
```
### Resume failure (token invalid)
```
Resume attempted: Session -> STARTING
Token rejected: Session -> WAITING_FOR_INPUT (not FAILED)
Task -> REVIEW
ResumeToken -> cleared
Status message shown to user
```
The session is NOT marked as failed -- the user can send a new message to start a fresh session.
---
## Agent Resume Capabilities
| Agent | NativeSessionResume | HistoryContextInjection | Notes |
|-------|:---:|:---:|-------|
| Claude Code | - | - | Uses `--resume` CLI flag with stored resume token |
| Codex | yes | - | ACP `session/load` restores context |
| Copilot | yes | - | ACP `session/load` restores context |
| Auggie | yes | - | ACP `session/load` restores context (v0.18.1+) |
| Gemini | - | - | Boots idle, no context restoration |
| Amp | - | - | Uses `ForkSessionCmd` / `ContinueSessionCmd` |
| OpenCode | yes | - | ACP `session/load` restores context |
Agent configs are defined in `internal/agent/agents/`.
---
## `ExecutorRunning` Model
**`internal/task/models/models.go`** ~line 383
Persists runtime state for a session across backend restarts:
| Field | Purpose |
|-------|---------|
| `ResumeToken` | ACP session ID (used for `session/load` or `--resume` CLI flag) |
| `LastMessageUUID` | For `--resume-session-at` flag |
| `ContainerID` | Docker container ID (for recovery) |
| `AgentctlURL` / `AgentctlPort` | agentctl connection info |
| `WorktreeID` / `WorktreePath` / `WorktreeBranch` | Git worktree info (validated on resume) |
| `AgentExecutionID` | Links to in-memory `AgentExecution` |
| `Status` | Current executor status string |
The resume token is stored/updated in `storeResumeToken()` (`internal/orchestrator/event_handlers.go` ~line 45), called from three event handlers:
- `handleACPSessionCreated` -- on initial session creation
- `handleSessionStatusEvent` -- on session status updates
- `handleCompleteStreamEvent` -- on stream completion
The token is always stored regardless of `NativeSessionResume`. Agents with native resume use it for ACP `session/load`; others (e.g., Claude Code) use it for their `--resume` CLI flag.
---
## Key Files
| File | Contains |
|------|----------|
| `internal/orchestrator/task_operations.go` | `GetTaskSessionStatus`, `ResumeTaskSession`, `ensureSessionRunning` |
| `internal/orchestrator/executor/executor_resume.go` | `ResumeSession`, `buildResumeRequest`, `persistResumeState`, `startAgentProcessOnResume` |
| `internal/agent/lifecycle/session.go` | `dispatchInitialPrompt`, `shouldInjectResumeContext`, `buildEffectivePrompt`, `SendPrompt` |
| `internal/agent/lifecycle/session_history.go` | `SessionHistoryManager`, `GenerateResumeContext`, `HasHistory` |
| `internal/agent/lifecycle/manager_startup.go` | `StartAgentProcess`, `initializeAgentSession` |
| `internal/agent/lifecycle/manager_lifecycle.go` | `RecoverInstances` (runtime-level recovery) |
| `internal/agent/lifecycle/types.go` | `AgentExecution` fields (`historyEnabled`, `needsResumeContext`) |
| `internal/task/models/models.go` | `TaskSession`, `ExecutorRunning`, session state constants |
| `internal/orchestrator/event_handlers.go` | `storeResumeToken` |
| `hooks/domains/session/use-session-resumption.ts` | Frontend resume trigger |