Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
24 KiB
status, system, requirements, created, owners
| status | system | requirements | created | owners | ||
|---|---|---|---|---|---|---|
| draft | office |
|
2026-05-04 |
|
Office Agent Runtime — Error Handling Contract System Design Part 1
Purpose and boundaries
This design preserves the technical source detail for REQ-OFFICE-RUNTIME-001 during migration.
Requirement mapping
| Requirement | Design section |
|---|---|
REQ-OFFICE-RUNTIME-001 |
Migrated source detail |
Related decisions
Keep MCP availability separate from runtime permissions defines the boundary between enforced runtime permissions and seat-derived prompt metadata.
Migrated source detail
Amendment (2026-08-17): The provider-classification and automatic recovery policy in this spec is superseded by Provider Error Recovery and Dynamic Agent Routing. Office consumes the shared transient/hard catalogue and the selected dynamic profile's policy. The terminal run state, inbox, and manual recovery contracts below still own non-provider and effect-unsafe failures. The historical v1 classification text remains as migration context and is not the target behavior.
This spec defines the runtime contract for how Office agent errors are observed, classified, and recovered. It covers what the lifecycle layer publishes on failure, how sessions and wakeups transition, how failures surface in the inbox and per-task chat, and how the user recovers — via Mark fixed, Resume session, or reassignment to a different agent.
Why
When an agent run fails mid-turn (invalid model, auth failure, malformed response, transient upstream), today's UX is:
- The chat shows a raw JSON-RPC blob in red:
{"code":-32603,"message":"Internal error","data":{"codex_error_info":"other","message":"…"}}. - The session ends in
WAITING_FOR_INPUT, which the chat reads as "live" - the topbar spinner and the "Agent working for Xs" header stay on even though the agent has stopped. - The wakeup scheduler queues a retry with the standard backoff table (~135 s). For a 400
invalid_request_error(e.g. unsupported model) the retry fails identically and burns CPU + tokens for nothing. A flaky provider can produce a tight retry loop that never surfaces to the user. - The agent detail Runs tab and the dashboard agent card show no sign of the failure. The user has to open the task to know.
- Nothing surfaces in the office inbox even though the existing
office-inboxspec already lists "Agent errors" as one of its computed sources. - With multiple agents using different providers, there's no path to recover by reassigning a task - the failed wakeup keeps firing on the original (broken) agent regardless of who's actually assigned.
Three classes of bug are mixed up here: a state-machine bug (FAILED modeled as WAITING_FOR_INPUT), a retry-policy bug (every error is treated as retryable), and a presentation bug (raw error blob shown verbatim, no inbox entry, no recovery path).
The target pattern: runs persist with explicit status (queued | running | scheduled_retry | failed | timed_out | cancelled); failed runs surface in the inbox as a computed view over the runs table with a inbox_dismissals row keyed by run id; the sidebar badge counts un-dismissed failures; the run-ledger card shows a humanised liveness reason rather than the raw payload. Scoped tightly for v1.
What
v1 classification policy: every agent error is terminal
The runtime does not classify errors in v1. Any error event from the agent adapter - Codex, Claude, Copilot, Amp, OpenCode, Auggie, Gemini, mock - is treated as terminal. The user fixes the cause (bad model, expired auth, network outage, rate limit) and re-runs the task by clicking Resume session in the chat or Mark fixed in the inbox.
This is a deliberate trade-off: the cost of a classifier per adapter is not worth paying yet. Treating every error as terminal means we may surface a transient failure that would have self-recovered on the next retry; the cost is one click. Treating retryable as terminal is always safe; treating terminal as retryable burns CPU and tokens. We prefer the safe direction.
A follow-up spec can introduce per-adapter classification once we have real usage data on which errors are common and worth auto-retrying. The existing rate-limit-retry spec is independent of this work - it parses provider rate-limit reset times. In v1 we treat rate limits as generic errors and let the user reassign or wait, rather than auto-detect.
Retry policy
- No automatic retry. Every adapter error is terminal for the wakeup that produced it.
- The wakeup row is stamped with
status = failedanderror_message. No follow-up wakeup is queued from the failure path. - Re-runs only happen via explicit user action: Resume session in chat, Mark fixed on an inbox entry, or task reassignment to a different agent.
Inbox surfacing
Failed wakeups surface in the inbox, fulfilling the "Agent errors" bullet already in the office-inbox spec. Two kinds:
agent_run_failed- one entry per failed (task, agent) wakeup while the agent is below threshold. Title: " failed on ". Action: Mark fixed -> dismiss + retry.agent_paused_after_failures- one entry per auto-paused agent. Title: " auto-paused after failures (tasks A, B, C)". Action: Mark fixed -> unpause + retry the affected tasks.
When auto-pause triggers, the threshold-th failure does not create its own per-task entry. Instead, the prior agent_run_failed entries for that agent are auto-dismissed and replaced with a single agent_paused_after_failures entry that lists the affected tasks. The inbox shows one row per broken agent rather than N rows per failed task. (Per-task chat error entries are unaffected - they live in each task's chat thread.)
Recovery hooks
Mark fixed = dismiss + retry
Both inbox actions converge on the same effect: clear the failure state and re-run.
For agent_run_failed:
- Insert into
inbox_dismissals. - Clear
FAILEDon the (task, agent) session ->IDLE. - Re-queue a wakeup for that (task, agent) with reason
manual_resume_after_failure.
For agent_paused_after_failures:
- Insert into
inbox_dismissals. - Clear the agent's
pause_reason, resetconsecutive_failuresto zero. (Counter reset is the user saying "I fixed the cause; start over".) - Re-queue wakeups for every (task, agent) listed on the entry with reason
manual_resume_after_failure.
There is no separate "Dismiss without retry" affordance. If the user wants to silence the inbox without re-running the task, they reassign the task to a different agent - that's the explicit recovery path described below.
Reassignment as the multi-provider failover path
Multiple agents may run on different providers (e.g. CEO on Claude, Worker on Codex). When one provider hits a sustained outage or rate limit, the user can switch the assignee on a per-task basis to recover without waiting for the broken provider.
Reassignment is independent of the pause / dismiss flow:
- Changing
assignee_agent_instance_idon a task fires the existing reactivity pipeline, which queues a freshtask_assignedwakeup for the new agent. - The existing staleness check (
recovery-reliabilityspec) cancels the prior wakeup for the (task, old agent) since the assignee has changed. - Any per-task
agent_run_failedinbox entry tied to the old (task, agent) auto-dismisses - the failure is no longer actionable on this task. - The old agent's
consecutive_failurescounter is not reset by reassignment. The agent remains paused (or close to pause) since the root cause hasn't been fixed; reassigning is a workaround for the specific task, not an unpause. - An
agent_paused_after_failuresentry persists until the user explicitly Mark fixeds it, regardless of how many of its affected tasks have been reassigned away. (The list of "affected tasks" on the entry is a snapshot at pause time; we don't recompute on reassign.)
Worked example with the rate-limit scenario:
- Workspace has CEO (Claude) and Worker (Codex), threshold = 3.
- Claude rate-limits. CEO fails on tasks A, B, then C.
- After A: inbox row
agent_run_failedfor (A, CEO). - After B: inbox row
agent_run_failedfor (B, CEO). - After C: pause triggers. Rows for A and B auto-dismiss. New row
agent_paused_after_failuresfor CEO covering {A, B, C}. - User reassigns task B to Worker (Codex). Codex's wakeup runs B successfully. The CEO entry's affected list still mentions B (we don't recompute) but the user can ignore that detail.
- CEO stays paused. User waits for the rate limit window or fixes billing, then clicks Mark fixed on the CEO entry -> unpause + retry A and C (B is already done; the retry is a no-op for B because its current assignee is Worker).
Chat surface (per-task)
The red banner that today shows the raw JSON-RPC blob is replaced by a structured error entry in the unified chat timeline (alongside comments, session entries, decisions). The entry contains:
- A short generic header: "The agent stopped with an error."
- A
Show detailscollapsible revealing the raw error payload verbatim - what the user copy-pastes when filing a bug. - The existing Resume session + Start fresh action buttons, unchanged. Resume session is equivalent to Mark fixed on the per-task inbox entry (same backend effect).
The entry sorts at the failure timestamp like any other comment, so the chat reads chronologically: prompt -> tool calls -> error -> (next user comment if any) -> next turn.
The runtime does not humanise the error message in v1. The raw payload is the most useful artefact when classification is absent.
Sidebar agent indicator
The sidebar Agents list (SidebarAgentsList) currently renders, per row, either a LiveAgentIndicator (live count when > 0) or an AgentStatusDot. The runtime adds two cases:
- Auto-paused agent - a small red "paused" badge replaces the status dot, with a tooltip showing the pause reason. Takes priority over the live indicator (if a paused agent has somehow racing live count, the paused state is more important).
- Agent with un-dismissed failures below pause threshold - a red " errors" badge alongside the live/status indicator. N is the count of
agent_run_failedinbox entries for this agent.
The Inbox sidebar item already shows a total badge count; this is a per-agent surface for users who triage by agent rather than by inbox.
Runs tab pill
The agent detail page Runs tab gains a status pill per row: queued | running | failed | completed | cancelled. The pill colour reuses the dashboard agent-card vocabulary. A failed run row links to the task that produced it.
Dashboard agent card
When the agent's most recent wakeup is failed or the agent is auto-paused, the dashboard agent card subtitle changes:
- Most recent wakeup failed but agent below threshold: "Last run failed {ago}".
- Agent auto-paused: "Paused - consecutive failures".
Same component, new branches in pickSubtitle and StatusDot (red dot in both cases).
Data model
agent_instances additions
consecutive_failures INT NOT NULL DEFAULT 0- counter across any task assigned to this agent. Increments on every failure regardless of which task; resets to zero on any successful run for the agent.failure_threshold INT NULL- per-agent override. When NULL, the workspace default applies.pause_reason TEXT NULL- reused from manual pause. On auto-pause the runtime sets"Auto-paused: <N> consecutive failures. Last error: <message>".
workspace_settings additions
agent_failure_threshold INT NOT NULL DEFAULT 3- workspace default threshold. Configurable from the workspace settings page. Per-agent override onagent_instances.failure_thresholdwins when present. Rationale: twice is coincidence, three times is a pattern. Critical agents can be tuned down to 1, flaky-but-tolerable agents up to 5.
inbox_dismissals
New table: (user_id, item_kind, item_id, dismissed_at) with a unique constraint on (user_id, item_kind, item_id). item_kind is one of agent_run_failed | agent_paused_after_failures to start; the table is reusable for future inbox sources.
Wakeup row
statusextended withfailed(in addition to the existing values).error_message TEXT NULL- stamped on failure with the raw error payload.
API surface
The error-handling contract spans three boundaries: the agent runtime facade (Go), the office runtime action surface (HTTP, agent-facing), and the dashboard/inbox HTTP + WS event surface (UI-facing).
Agent runtime facade (internal/agent/runtime)
runtime.Runtime is the single seam coordinators (workflow engine, scheduler dispatcher, orchestrator) use to launch / resume / stop agent executions:
type Runtime interface {
Launch(ctx, spec LaunchSpec) (ExecutionRef, error)
Resume(ctx, executionID, prompt string) error
Stop(ctx, executionID, reason string) error
GetExecution(ctx, executionID string) (*Execution, error)
SubscribeEvents(ctx, executionID string) (<-chan Event, error) // ErrUnsupported in Phase 1
SetMcpMode(ctx, executionID, mode string) error
}
The runtime knows nothing about tasks, workflows, or office stages. Errors classified by routingerr.Classify reach this layer via the agent process's stderr / stdout / structured ACP error frame and are published as streams.AgentEvent events through the lifecycle manager's event bus.
Office runtime action surface (HTTP, agent-process-facing)
Mounted by runtime.RegisterRoutes (internal/office/runtime/handler.go). Every endpoint requires a Bearer <agent JWT> header issued via agents.MintRuntimeJWT; the handler resolves the JWT to a RunContext and checks capabilities before delegating to Actions.
| Method | Path | Capability | Body / Params |
|---|---|---|---|
| POST | /runtime/comments |
post_comment |
{task_id?, body} |
| POST | /runtime/tasks/:id/status |
update_task_status |
{status, comment} |
| POST | /runtime/tasks/:id/subtasks |
create_subtask |
CreateSubtaskInput |
| POST | /runtime/agents |
create_agent |
CreateAgentInput |
| PATCH | /runtime/agents/:id |
modify_agents |
ModifyAgentInput |
| POST | /runtime/agents/:id/runs |
spawn_agent_run |
SpawnAgentRunInput |
| POST | /runtime/approvals |
request_approval |
RequestApprovalInput |
| GET | /runtime/memory/*path |
read_memory |
*path = /workspaces/{ws}/memory/{kind}s/{id}/{key} |
| PUT | /runtime/memory/*path |
write_memory |
{content} |
| GET | /runtime/skills |
list_skills |
– |
| DELETE | /runtime/skills/:id |
delete_skills |
– |
Capability-denied (ErrCapabilityDenied), task-out-of-scope (ErrTaskOutOfScope), and workspace-out-of-scope (ErrWorkspaceOutOfScope) errors return 403 Forbidden and append a runtime.denied event to office_run_events. Successful actions append runtime.action. Missing dependencies surface as 500 with ErrRuntimeDependencyMissing.
Recovery actions (UI-facing, dashboard handler)
| Method | Path | Effect |
|---|---|---|
| POST | /workspaces/:wsId/inbox/items/:id/dismiss |
Insert inbox_dismissals row + (for agent_run_failed) clear session FAILED and re-queue wakeup with reason manual_resume_after_failure; (for agent_paused_after_failures) clear pause_reason, reset consecutive_failures, re-queue wakeups for every affected task |
| POST | /tasks/:id/sessions/:sessionId/resume |
Equivalent to Mark fixed on the per-task inbox entry — clears session FAILED and re-queues a wakeup for the (task, agent) |
| PATCH | /tasks/:id |
Changing assignee_agent_instance_id cancels the prior (task, old agent) wakeup and queues a fresh task_assigned wakeup for the new agent |
WS event types (forwarded to clients)
| Event type | Payload | When |
|---|---|---|
office.task.session.updated |
session_id, state, error_message? |
Session transitions to FAILED (or any other state) |
office.wakeup.updated |
wakeup_id, status, error_message? |
Wakeup row stamped failed |
office.inbox_item |
kind, item_id, agent_id?, task_ids?, title |
New agent_run_failed or agent_paused_after_failures |
office.agent.updated |
agent_id, pause_reason?, consecutive_failures |
Auto-pause toggle, counter increment / reset |
State machine
Session state on agent error
Office sessions transition to a terminal FAILED state distinct from WAITING_FOR_INPUT. When the lifecycle layer publishes an error event:
- Session state ->
FAILED. - Task state ->
REVIEW(already happens today; unchanged). - Wakeup status ->
failed, witherror_messagestamped, no retry queued. - The
executors_runningrow is preserved so the user can resume via the existing Resume session button. The agent process and agentctl instance are torn down.
This fixes today's bug where the topbar spinner stays on after the agent has actually stopped: a FAILED session is correctly read as non-live by isLiveSession.
Run status lifecycle
queued -> running -> {completed | failed | timed_out | cancelled}. The scheduled_retry status from the target pattern is reserved for a future classifier; v1 produces only the listed terminal states.
Consecutive-failure counter and auto-pause
Each agent carries consecutive_failures on the agent_instances row. It increments on every failure across any task assigned to this agent. Any successful run for the agent resets it to zero.
When the counter reaches the agent's threshold (per-agent override or workspace default), the agent is auto-paused: pause_reason is set to "Auto-paused: <N> consecutive failures. Last error: <message>". The wakeup scheduler refuses to claim wakeups for a paused agent (existing behaviour); the counter is preserved across the pause so unpause-and-fail-immediately re-pauses without surprise.
Permissions
Recovery and runtime-action authorization splits across three actors: human users (via dashboard auth), agent processes (via agent JWT + per-run capabilities), and the runtime itself (acts on its own behalf for state transitions).
- Mark fixed (inbox dismiss + retry) and Resume session: any workspace user. There is no agent-role gating on these affordances; they are explicit human-driven recovery actions surfaced in the inbox and per-task chat.
- Reassignment (
PATCH /tasks/:idwith newassignee_agent_instance_id): workspace user. Agent JWTs cannot reassign tasks via the runtime action surface in v1; there is noCapabilityReassignTask. - Manual unpause (clearing
pause_reasonfrom the agent detail page): workspace admin / CEO role only, enforced byisAdminRoleininternal/office/agents/handler.go. - Runtime action surface capabilities: derived from
agent_instances.role/permissionsviaruntime.FromAgent. The agent JWT carries the serializedCapabilitiessnapshot taken at run-claim time; subsequent permission revocations on the agent profile do not affect a JWT already in flight (it expires afterDefaultTokenDuration = 4h). The seat-derivedrecord_step_decisionMCP action is not a runtime capability.runtime.ContextBuilder.Buildstores it inRunContext.AvailableActionswhen the agent holds the current workflow participant seat. The scheduler adds this advisory action to the prompt, but not to the JWT capability claims.handleRecordStepDecisionperforms live authorization withResolveParticipantRole. - Task scope:
RunContext.CanMutateTask(taskID)returns true only whentaskID == runCtx.TaskIDortaskID(or wildcard*) appears inCapabilities.AllowedTaskIDs. Out-of-scope mutations returnErrTaskOutOfScope. - Workspace scope: every action that touches another entity (target agent, target skill, target task) is rejected with
ErrWorkspaceOutOfScopewhen the target'sworkspace_iddoes not match the run's. - Memory namespaces:
CanAccessMemoryenforces workspace match plus, forkind=agent, that the namespace ID matchesrunCtx.AgentID. Agents cannot read or write another agent's memory. - An auto-paused agent's wakeups are refused at scheduler claim time regardless of who queued them; only Mark fixed (or manual unpause) clears
pause_reason.
Failure modes
- Adapter error event published: session ->
FAILED, wakeup ->failed, agent process and agentctl torn down,executors_runningpreserved,consecutive_failures++, inbox entry created or upgraded toagent_paused_after_failureson threshold. - Provider returns malformed response: treated as terminal like any other adapter error. Raw payload preserved in the chat error entry and wakeup
error_message. - Transient network failure: treated as terminal in v1. User clicks Resume / Mark fixed to retry. Future classifier may auto-retry.
- Auto-paused agent's wakeup fires: scheduler refuses to claim it; behaviour identical to a manually-paused agent.
- Reassignment during failure: cancels the (task, old agent) wakeup via the existing staleness check; auto-dismisses
agent_run_failedfor (task, old agent). Does not unpause the old agent.
Persistence guarantees
- Session state (
task_sessions.state): durable. AFAILEDsession is read as non-live byisLiveSession; the topbar spinner and "Agent working for Xs" header clear on the next render. Survives restart. - Wakeup row (
status=failed,error_message): durable. The raw adapter error payload is stored verbatim so the chat error entry and the inbox row can hydrate from one source after restart. agent_instances.consecutive_failures: durable. Survives restart, so an agent that has accumulatedthreshold - 1failures pre-restart still auto-pauses on the next failure post-restart.agent_instances.pause_reason: durable. An auto-paused agent stays paused across restarts; the scheduler continues to refuse to claim its wakeups until Mark fixed or manual unpause.inbox_dismissals: durable. Once a user dismisses an inbox item, the dismissal survives restart and the computed inbox view excludes the dismissed item indefinitely. Keyed by(user_id, item_kind, item_id)with a unique constraint.executors_runningrow (preserved on FAILED): durable. The agent process and agentctl instance are torn down, but the executor metadata stays so Resume session can recover into the same workspace / worktree without re-creating the executor.- ACP session id (
task_sessions.acp_session_id): durable. Resume reattaches to the same ACP session id so the agent's context window survives. - Run-event log (
office_run_events): durable.runtime.actionandruntime.deniedrows persist for the run-detail Events tab; capability denials remain visible after restart for audit. - Capability snapshot on the run (
runs.capabilities,runs.input_snapshot): durable, written byContextBuilder.BuildAndPersist. A re-issued JWT after restart uses the same capability set so the run keeps the permissions it was granted at claim time. - Agent JWT signing key: process-local. Defaults to a random 32-byte key when the
KANDEV_AGENT_JWT_KEYconfig is empty, in which case all in-flight JWTs are invalidated on restart and agents must mint fresh tokens on the next prompt cycle. Configurable for stable cross-restart JWTs. affected_taskssnapshot onagent_paused_after_failures: NOT recomputed when listed tasks are reassigned away. The snapshot is intentionally point-in-time so the user resolves the pause entry as a whole.scheduled_retryruns: not produced in v1. Every adapter error is terminal; the wakeup is stampedfailedwith no follow-up scheduled. Thescheduled_retrystatus is reserved for a future classifier.
See also: Dynamic Agent Routing for provider routing and office/scheduler.md for wakeup queue semantics and the staleness check that cancels superseded wakeups.