440 lines
20 KiB
Markdown
440 lines
20 KiB
Markdown
---
|
|
status: draft
|
|
created: 2026-05-05
|
|
owner: cfl
|
|
---
|
|
|
|
# Task model unification
|
|
|
|
## Why
|
|
|
|
Kandev today carries two execution models on top of one task table.
|
|
*Kanban* tasks flow through a configurable workflow with per-step
|
|
prompts and a single agent runner. *Office* tasks live indefinitely
|
|
with comment-driven wakeups, multi-agent stages, and IDLE-between-turns
|
|
sessions. The two share `tasks`, `task_sessions`, comments, and the
|
|
executor record, which forces every shared code path to branch on
|
|
`IsOfficeTask()`. Each branch is a regression risk.
|
|
|
|
Looking at office and kanban side by side, they're not actually two
|
|
models — they're two configurations of the same model. Both are
|
|
event-driven state machines over a step graph; both produce agent runs
|
|
in response to events; both record outcomes. The differences are which
|
|
events count as triggers (kanban: step transitions; office: also
|
|
comments, blockers, children, approvals, heartbeats) and how many
|
|
agents participate per step (kanban: one runner; office: a primary
|
|
plus reviewers / approvers).
|
|
|
|
The unification: extend the workflow engine to subsume office's
|
|
event-driven model. Office becomes a *workflow style* — a set of step
|
|
shapes (work / review / approval) plus event triggers and multi-agent
|
|
participants — not a separate execution model.
|
|
|
|
We have no production office users. No data migration; this is
|
|
greenfield from office's perspective. The constraints that matter:
|
|
**the existing kanban experience must keep working unchanged**, and
|
|
**the kanban homepage's per-workflow swimlane UX is unchanged.**
|
|
|
|
## What
|
|
|
|
### Tasks always have a workflow
|
|
|
|
Every task has a `workflow_id` and a `workflow_step_id`. The workflow
|
|
defines the lifecycle: linear steps for kanban, event-driven steps with
|
|
multi-agent participation for office.
|
|
|
|
A workflow has a UX hint — `style` — with values `kanban`, `office`,
|
|
`custom`. This drives default presentation in the UI; the engine
|
|
doesn't branch on it.
|
|
|
|
Built-in workflow templates ship with kandev:
|
|
|
|
- **Kanban Default** — Backlog → In Progress → In Review → Done. Single
|
|
primary agent per step. `on_enter` fires the agent. `on_turn_complete`
|
|
may transition. No event triggers.
|
|
- **Office Default** — Backlog → Work → Review → Approval → Done.
|
|
- `Work` (`stage_type='work'`): a primary agent (the assignee).
|
|
Triggers: `on_enter`, `on_comment`, `on_blocker_resolved`,
|
|
`on_children_completed`, `on_heartbeat`, `on_agent_error`. Each
|
|
queues a run for the primary. Transitions: agent calls
|
|
`move_task_kandev` (or workflow rule fires) to push to `Review`.
|
|
- `Review` (`stage_type='review'`): no primary; multi-agent
|
|
participants (reviewers) with `decision_required=1` each.
|
|
Triggers: `on_enter` does `clear_decisions` (so quorum starts
|
|
fresh on re-entry) then `queue_run_for_each_participant{
|
|
role: reviewer }`. Transitions: `wait_for_quorum{ all_approve }`
|
|
→ `Approval`; `wait_for_quorum{ any_reject }` → back to `Work`.
|
|
- `Approval` (`stage_type='approval'`): same shape as `Review` with
|
|
`role: approver`. Quorum on all_approve → `Done`; any_reject →
|
|
`Work`.
|
|
- `Done` (`stage_type='custom'`): terminal.
|
|
|
|
Note: there is no `Ship` step. Shipping work (opening a PR, deploying)
|
|
happens inside `Work` directly — the assignee opens the PR as part of
|
|
its turn — or in a delegated child task with the Kanban Default
|
|
workflow.
|
|
|
|
Users can copy either template and edit it; existing custom kanban
|
|
workflows continue to work unchanged.
|
|
|
|
### New trigger types
|
|
|
|
The workflow engine learns seven new trigger types in addition to
|
|
today's four (`on_enter`, `on_turn_start`, `on_turn_complete`,
|
|
`on_exit`):
|
|
|
|
- `on_comment` — a user (or external) comment landed on the task while
|
|
it's in this step. Trigger context carries the comment id, body, and
|
|
author.
|
|
- `on_blocker_resolved` — all blockers on the task cleared.
|
|
- `on_children_completed` — all child tasks of this task reached terminal
|
|
state. Payload includes the child summaries (identifiers, last
|
|
comments, PR links if any).
|
|
- `on_approval_resolved` — an approval request the task owns resolved.
|
|
- `on_heartbeat` — periodic timer (cron-driven) for *background*
|
|
check-ins when nothing else is happening. Fires per-(task, step)
|
|
pair where the step has this trigger configured. Default cadence
|
|
60s. **Most steps don't need this**: comments, blocker clears, child
|
|
completions, approvals all fire their own dedicated triggers
|
|
immediately, so heartbeats are reserved for "wake me even with no
|
|
external event" patterns — primarily on coordination tasks for
|
|
senior-agent oversight.
|
|
- `on_budget_alert` — budget threshold crossed. Fires per task with
|
|
payload `{budget_pct, scope}`.
|
|
- `on_agent_error` — fired only after the runs queue's retry policy
|
|
exhausts (4 attempts at [2m, 10m, 30m, 2h]). Fires on the *failing
|
|
task* itself.
|
|
|
|
Each new trigger fires through `Engine.HandleTrigger` plumbing. Steps
|
|
that don't configure these triggers behave identically to today.
|
|
|
|
### Multi-agent steps
|
|
|
|
A workflow step has a `participants` list (zero or more) in addition to
|
|
its `primary_agent_profile_id`. Each participant has:
|
|
|
|
- `role` — `reviewer`, `approver`, `watcher`, `collaborator`.
|
|
- `agent_profile_id` — the actual agent.
|
|
- `decision_required` — boolean; if true, the agent's run must record a
|
|
verdict before quorum can be evaluated.
|
|
|
|
A step with an empty participants list is a single-agent step =
|
|
today's kanban behaviour, unchanged.
|
|
|
|
A step with participants supports new actions:
|
|
|
|
- `queue_run` (generalises today's `auto_start`) — targets `primary`,
|
|
`participant_role:<role>`, or a specific `agent_profile_id`. Optional
|
|
`task_id` resolver: defaults to `this` (trigger's task) but can take
|
|
a literal task id for cross-task wakeups.
|
|
- `queue_run_for_each_participant` — fans out runs at `on_enter` (or
|
|
any other trigger) for all participants matching a role.
|
|
- `wait_for_quorum` — a transition guard that blocks until N-of-M
|
|
decisions are recorded. Used as `if:` clauses on transitions:
|
|
`if:'wait_for_quorum{role:reviewer, threshold:all_approve}'` then
|
|
`goto:'approval'`.
|
|
- `clear_decisions` — clears `workflow_step_decisions` rows for the
|
|
trigger's `(task, step)` pair. Used by `Review.on_enter` so quorum
|
|
starts fresh when the task re-enters Review after a rejection.
|
|
- `record_participant_decision` — callback used by the office service
|
|
when a reviewer's run completes with a verdict. Records into
|
|
`workflow_step_decisions`.
|
|
|
|
### Agent-driven transitions: `move_task_kandev`
|
|
|
|
Agents transition tasks by calling the existing `move_task_kandev` MCP
|
|
tool. The repo updates `tasks.workflow_step_id`; the engine fires
|
|
`on_exit` on the old step and `on_enter` on the new step automatically.
|
|
|
|
This is the same path as engine-driven transitions (where a transition
|
|
rule on `on_turn_complete` fires). Agent-driven and rule-driven
|
|
transitions both go through the same plumbing.
|
|
|
|
A reviewer rejecting a Review:
|
|
1. Reviewer's run completes with verdict `rejected`.
|
|
2. `record_participant_decision` writes into `workflow_step_decisions`.
|
|
3. `wait_for_quorum{any_reject}` evaluates true; transition to `Work`
|
|
fires.
|
|
4. `Review.on_exit`, `Work.on_enter`. The work step's `on_enter`
|
|
queues a run for the assignee with the rejection feedback in the
|
|
trigger payload.
|
|
5. Assignee fixes the issues, calls `move_task_kandev` to push back to
|
|
Review.
|
|
6. `Work.on_exit`, `Review.on_enter`. The review step's `on_enter`
|
|
first calls `clear_decisions` to reset quorum, then
|
|
`queue_run_for_each_participant{role:reviewer}` re-queues runs for
|
|
all reviewers.
|
|
|
|
### One generic `runs` queue
|
|
|
|
`office_runs` is renamed `runs`. Universal queue for engine-emitted
|
|
launches. Every `queue_run` action creates a row. Coalescing (5s
|
|
window for same agent + reason), idempotency (24h dedup window),
|
|
per-agent serialisation (one claimed run per agent), cooldown
|
|
(per-agent, default 10s), and atomic task checkout (one agent per
|
|
task at a time) all apply — same machinery as today's office_runs.
|
|
|
|
User-initiated launches (clicking Start on a kanban task) bypass the
|
|
queue and call `runtime.Launch` directly — those don't need
|
|
coalescing and shouldn't pay the scheduler's tick latency.
|
|
|
|
The queue has one backend-wide consumer across all workspaces; there
|
|
is no scheduler per Office workspace. Assignment alone does not make
|
|
an ordinary Kanban task autonomous. Office assignment recovery uses
|
|
the authoritative Office-task identity, while a workflow of any style
|
|
can opt into queued automation explicitly with `queue_run`. Shared
|
|
scheduler lifecycle, Office maintenance activation, and shutdown
|
|
ordering are specified in [queued run scheduling](run-scheduling.md)
|
|
and [ADR-2026-08-01-global-run-scheduler-ownership](../../decisions/2026-08-01-global-run-scheduler-ownership.md).
|
|
|
|
`run_events` (the audit log per run) keeps its current shape and
|
|
event-streaming behaviour. WS event subjects (`office.run.queued`,
|
|
`office.run.processed`, `office.run.event_appended.<run_id>`) keep
|
|
the `office.` prefix for now to avoid frontend churn; we can rename
|
|
later if desired.
|
|
|
|
### Cron-driven trigger handlers
|
|
|
|
Three handlers run on a shared cron tick loop:
|
|
|
|
- **Heartbeat handler.** Iterates `(task, step)` pairs where the step
|
|
has `on_heartbeat` configured AND the agent's runtime allows
|
|
(cooldown, status). Fires `Engine.HandleTrigger(TriggerOnHeartbeat,
|
|
task)`. The step's action emits a run.
|
|
- **Budget handler.** Detects budget threshold crossings (workspace,
|
|
project, agent). For each affected task, fires
|
|
`Engine.HandleTrigger(TriggerOnBudgetAlert, task)`.
|
|
- **Routine handler.** Reads `office_routines` / `office_routine_triggers`
|
|
/ `office_routine_runs` exactly as today. On firing it creates a real
|
|
task with the routine's template; the new task's `on_enter` does the
|
|
rest. (Routines are upstream of the engine; today's routines spec
|
|
needs no behavioural change.)
|
|
|
|
There is no "workspace-scoped scheduler" for waking agents without a
|
|
task. Every wakeup is task-scoped.
|
|
|
|
### Coordination tasks for senior-agent oversight
|
|
|
|
The CEO (and other senior agents) need to do workspace-level
|
|
reasoning: survey active tasks, decide priorities, intervene on
|
|
failures, allocate budget. Today this happened via "wake the CEO with
|
|
no task." In the unified model we model it as a real task: a
|
|
**coordination task**.
|
|
|
|
A coordination task is a real, standing task created at office
|
|
workspace setup, assigned to a senior agent, on a workflow whose step
|
|
has `on_heartbeat` (and optionally `on_budget_alert`,
|
|
`on_agent_error`) configured. Cron fires the heartbeat trigger; the
|
|
senior agent wakes on the coordination task with workspace-summary
|
|
context (active runs, pending wakeups, recent failures, budget) and
|
|
can comment on it, delegate child tasks from it, transition it.
|
|
|
|
Multiple senior roles → multiple coordination tasks (CEO has
|
|
"Workspace coordination," QA Lead has "QA coordination," etc.). Office
|
|
onboarding creates them when the workspace is set up. A "Done"
|
|
transition isn't expected on a coordination task; it's a standing
|
|
thread.
|
|
|
|
### `on_agent_error` — fire on the failing task
|
|
|
|
`on_agent_error` fires on the failing task itself, after the runs
|
|
queue exhausts retries (4 attempts with backoff). The CEO (or chosen
|
|
target) wakes up *on that task*, in that task's session, with full
|
|
history visible. Default actions for office workflows:
|
|
|
|
```json
|
|
[
|
|
{ "kind": "pause_agent", "agent_id": "{failed_agent_id}" },
|
|
{ "kind": "queue_run",
|
|
"target": "agent_profile_id:{workspace.ceo_agent}",
|
|
"task_id": "this",
|
|
"reason": "agent_error",
|
|
"payload": { "failed_agent_id": "...", "error_message": "..." } },
|
|
{ "kind": "create_inbox_item", "kind": "agent_error" }
|
|
]
|
|
```
|
|
|
|
Fallbacks: no CEO in the workspace → `queue_run` no-ops; the inbox
|
|
item is the only signal. The failing agent IS the CEO → same
|
|
fallback.
|
|
|
|
The default fires on the failing task because that gives the CEO
|
|
maximum context. For workflows that prefer a coordination-task view,
|
|
override the action's `task_id: "{coordination_task.id}"`.
|
|
|
|
### Two routes, shared body. Kanban board unchanged.
|
|
|
|
`/t/:id` (kanban shell, advanced default) and `/office/tasks/:id`
|
|
(office shell, simple default) both render any task. The body —
|
|
`<TaskHeader>`, `<TaskMetaRail>`, `<TaskBody>` — is shared. The meta
|
|
rail branches on `workflow.style` for default chrome; the body's mode
|
|
(simple / advanced) is per-route default with a URL override toggle.
|
|
`/tasks/:id` remains a compatibility alias that redirects to `/t/:id`.
|
|
|
|
The dockview (file tree, terminal, agent chat, changes) binds to
|
|
`task_sessions.current_execution_id`. When the agent is between turns
|
|
the dockview renders dormant (worktree readable, no live agentctl).
|
|
|
|
**The kanban homepage's board UX is unchanged.** Tasks are grouped by
|
|
workflow step within their workflow. Each workflow appears as its own
|
|
swimlane; an office workflow shows up as a new swimlane with its own
|
|
columns (Backlog | Work | Review | Approval | Done). Office tasks are
|
|
draggable across the columns the same way kanban tasks are draggable
|
|
across their workflow's columns; the drag fires the same `task.moved`
|
|
event the engine consumes.
|
|
|
|
### Cross-strategy delegation
|
|
|
|
A new workflow action `create_child_task` queues a new task as a child
|
|
of the current one with a chosen workflow. The Office Default's
|
|
`Work` step uses it for delegation: the agent emits a `delegate`
|
|
signal (a tool call defined in the office MCP toolset), the engine
|
|
creates a child task with the Kanban Default workflow, the worker
|
|
runs it, the existing `on_children_completed` trigger fires on the
|
|
parent and posts a bridged comment ("KAN-43 done → PR #198").
|
|
|
|
Reverse path (kanban task promoted to office) is a workflow swap:
|
|
change the task's `workflow_id` and `workflow_step_id` to match an
|
|
office workflow's starting step. Identity, comments, sessions are
|
|
preserved.
|
|
|
|
## Scenarios
|
|
|
|
- **GIVEN** a fresh workspace with the Kanban Default workflow,
|
|
**WHEN** the user creates a task and clicks Start, **THEN** the agent
|
|
runs through the linear steps as today. No regression. No new
|
|
triggers fire because the kanban template doesn't configure them.
|
|
|
|
- **GIVEN** Office mode is enabled but every workspace contains only
|
|
ordinary Kanban workflows, **WHEN** scheduler maintenance runs,
|
|
**THEN** no Kanban assignment is recovered as autonomous Office
|
|
work. Explicit user Start and configured Kanban workflow actions
|
|
retain their existing behavior.
|
|
|
|
- **GIVEN** a workspace that adopts the Office Default workflow,
|
|
**WHEN** the user creates a task assigned to the CEO, **THEN** the
|
|
task lands in the `Work` step. The CEO runs `on_enter`. When the
|
|
user posts a comment, `on_comment` fires and the CEO runs again
|
|
through the unified `runs` queue.
|
|
|
|
- **GIVEN** a task in the Office Default's `Review` step with two
|
|
reviewers configured, **WHEN** the step is entered, **THEN**
|
|
`on_enter` first calls `clear_decisions`, then queues a run for each
|
|
reviewer in parallel. As each reviewer's run completes with a
|
|
verdict, `record_participant_decision` writes to
|
|
`workflow_step_decisions`. When all `decision_required` reviewers
|
|
have approved, `wait_for_quorum:all_approve` clears and the
|
|
transition to `Approval` fires.
|
|
|
|
- **GIVEN** the Office Default's `Review` step, **WHEN** any reviewer
|
|
rejects with feedback, **THEN** `wait_for_quorum:any_reject` fires,
|
|
the task moves back to `Work`, and the primary agent receives the
|
|
feedback as the next trigger context. The assignee fixes, calls
|
|
`move_task_kandev` to push back to Review; Review's `on_enter`
|
|
clears decisions and re-runs all reviewers.
|
|
|
|
- **GIVEN** the workspace has a CEO with a coordination task,
|
|
**WHEN** the heartbeat handler ticks every 60s, **THEN** the
|
|
coordination task's `on_heartbeat` trigger fires, queues a run for
|
|
the CEO with workspace-summary context (active runs, pending
|
|
wakeups, recent failures, budget), and the CEO wakes up on the
|
|
coordination task. The CEO can comment, delegate, or take no
|
|
action.
|
|
|
|
- **GIVEN** a single-task workspace where agent A is running on task
|
|
T, **WHEN** Anthropic returns 429 (rate limit), **THEN**:
|
|
1. The runs queue retries with backoff (2m, 10m, 30m, 2h).
|
|
2. Most cases: a retry succeeds; the agent resumes via ACP
|
|
`session/load`; the user sees a brief delay; no engine
|
|
involvement.
|
|
3. If all 4 retries exhaust, the engine fires `on_agent_error`
|
|
on T at its current step. Default actions: pause A, queue a
|
|
run for the CEO on T, create an inbox item.
|
|
4. The CEO wakes on T with full session history visible and
|
|
decides next steps (reassign, comment, ask user, mark
|
|
blocked).
|
|
5. If no CEO exists, only the inbox item surfaces; the user
|
|
picks it up.
|
|
|
|
- **GIVEN** a CEO agent in the Office Default `Work` step decides to
|
|
delegate, **WHEN** it emits a `create_child_task` signal, **THEN**
|
|
the engine creates a child task with `parent_id = T_office.id` and
|
|
the Kanban Default workflow. The worker runs it, opens a PR,
|
|
completes. `on_children_completed` fires on the parent and posts a
|
|
bridged comment summarising the result.
|
|
|
|
- **GIVEN** a workspace with both a kanban workflow and the Office
|
|
Default workflow in use, **WHEN** the user opens the kanban
|
|
homepage, **THEN** they see two swimlanes — one per workflow —
|
|
each with its own columns. Office tasks appear in the office
|
|
workflow's columns; kanban tasks appear in the kanban workflow's
|
|
columns. Same per-workflow swimlane UX as today.
|
|
|
|
- **GIVEN** a routine "Daily Dep Update" with cron `0 9 * * *` and
|
|
assignee Frontend Worker, **WHEN** the clock reaches 09:00 UTC,
|
|
**THEN** a real task is created with the routine's template, the
|
|
task's first step's `on_enter` queues a run for Frontend Worker.
|
|
|
|
- **GIVEN** the user opens any task's advanced mode, **WHEN** the
|
|
task's primary agent has an active execution, **THEN** the dockview
|
|
binds to `task_sessions.current_execution_id` and shows live state.
|
|
When the execution ends, the dockview renders dormant.
|
|
|
|
- **GIVEN** advanced mode is showing an unpinned `IDLE` session,
|
|
**WHEN** a workflow transition starts a different session for the
|
|
same task, **THEN** the dockview selects the newly started session.
|
|
An explicitly selected live session remains selected.
|
|
|
|
- **GIVEN** advanced mode is showing a parked `IDLE` session, **WHEN**
|
|
the user changes its model, mode, or dynamic runtime option before
|
|
sending the next message, **THEN** the selection is persisted and
|
|
applied when the agent process resumes. A running session applies the
|
|
same selection immediately and persists it for future recovery.
|
|
|
|
## Out of scope
|
|
|
|
- Per-workspace strategy lock UI. The unified model serves mixed
|
|
workspaces without one.
|
|
- Editing participants while a step is in flight. Static for v1; new
|
|
participants take effect at the next step entry.
|
|
- Workspace-scoped wakeups (no task_id). Every wakeup is task-scoped;
|
|
workspace-level reasoning lives on coordination tasks.
|
|
- Cross-workspace `parent_id`.
|
|
- Auto-resume an office agent on user comment when the assignee is
|
|
paused (`office-optimistic-comments` Phase 2 territory).
|
|
- Editing comments after submission, richer attachments — separate
|
|
features.
|
|
- Migration of existing office data. None exists in production; the
|
|
dev DB is wiped on rebuild.
|
|
- Board grouping changes. Existing per-workflow swimlane UX
|
|
unchanged.
|
|
|
|
## Open questions
|
|
|
|
These are flagged in the spec; the plan resolves them.
|
|
|
|
- **Quorum semantics edge cases.** When a `decision_required` reviewer
|
|
is removed mid-flight, does the quorum advance? When a reviewer's
|
|
run fails after recording a partial verdict, does it retry or accept
|
|
the verdict? The plan picks defaults; we may revisit.
|
|
|
|
- **Coordination task creation policy.** Does office onboarding always
|
|
create a CEO coordination task, or only if the workspace has a CEO
|
|
agent? Plan default: only when a CEO agent exists; user can create
|
|
coordination tasks for other senior roles manually.
|
|
|
|
- **`on_heartbeat` cadence config.** Per-step config drives the
|
|
default cadence; per-agent override via `office_agent_runtime`.
|
|
Resolved: most steps don't configure `on_heartbeat` at all — event
|
|
triggers (`on_comment`, etc.) drive runs immediately. The 60s
|
|
default applies only to coordination tasks. Heartbeats are NOT the
|
|
latency floor for new work; the runs scheduler's event-driven claim
|
|
path (plan B3.5) keeps the latency floor under 100ms.
|
|
|
|
- **Backward compat for office service's existing event subscribers.**
|
|
The comment-created subscriber today posts directly to `office_runs`.
|
|
After unification, it must instead trigger
|
|
`Engine.HandleTrigger("on_comment", task)` and let the engine emit
|
|
`queue_run`. The plan tracks this rewiring carefully because the
|
|
office UI's reactive paths depend on the resulting WS events.
|