Files
kandev/docs/specs/task-dependencies/spec.md
T
João Salavisa 1f38b6b435 feat(tasks): add task dependencies with auto-start chaining (#2589)
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 11:27:17 +01:00

565 lines
31 KiB
Markdown

---
status: shipped
created: 2026-08-09
owner: kandev
---
# Task Dependencies and Auto-Start Chains
## Why
A user who splits work into ordered steps — "scaffold the schema", then "write
the API on top of it", then "wire the UI" — has no way to express that order in
Kandev. The parent/child subtask hierarchy says *"B is part of A"*, never *"B
cannot start until A finishes"*. So the user either starts everything at once
and lets agents collide in the same repository, or babysits the board and
presses Start by hand each time a predecessor lands. Both defeat the point of
running agents unattended.
## What
- A task SHALL be able to declare that it **depends on** one or more other
tasks. The relationship is peer-to-peer and independent of `parent_id`: a
dependency may exist between two unrelated tasks, between siblings, or
between a task and its own child.
- The dependency graph is a **general DAG**, not a linear chain. A task may
have any number of predecessors and any number of dependents. Cycles SHALL be
rejected at edge-creation time with the offending path surfaced to the user.
- A task with at least one unresolved dependency is **blocked**. Blocked SHALL
be visible on the Kanban card, in the task detail view, and in the task API
payload.
- A blocked task SHALL NOT be auto-started by any automated path — workflow
`on_enter: auto_start_agent`, WIP queue promotion, integration watchers, or
dependency resolution itself. The gate applies to every automated launch, not
only to the dependency-resolution path.
- A user MAY still start a blocked task manually. Manual start is an explicit
override, not a bug; the UI names it as such.
- A task MAY carry the intent **"start automatically when unblocked"**. When
every dependency of such a task resolves successfully, Kandev launches it
exactly once with the agent profile, executor, executor profile, prompt, and
plan-mode choice recorded when the intent was created.
- Setting that intent on each link of a chain produces sequential execution:
A finishes → B starts → B finishes → C starts. No separate "chain" entity
exists; a chain is a path through the DAG.
- Auto-start on unblock respects every existing admission rule. A task whose
dependencies have resolved but which is not WIP-admitted (`wip_admitted =
false`) stays queued and launches when the WIP queue promotes it. Dependency
resolution grants **eligibility**, never a bypass.
- A dependency resolves only on **successful** completion of the predecessor.
`FAILED`, `CANCELLED`, and archival do not resolve a dependency. The
dependent task stays blocked and its blocked reason changes to name the
failed predecessor, so a chain never proceeds on a failed step.
- A dependency-failed state SHALL raise a notification and SHALL require
explicit human action to clear (retry the predecessor, remove the edge, or
start the dependent manually). Kandev never auto-retries a failed
predecessor and never silently drops the edge.
- Users declare dependencies in the **task-create dialog** ("Depends on") and
manage them afterwards over MCP. The task detail view deliberately has no edge
editor: dependencies describe how work was planned, and the surfaces that read
them (card badge, chip, graph) stay read-only.
- An open task SHALL show a **dependency chip** in the status row directly above
its chat composer, alongside the PR status chip. The chip reports both
directions — the tasks this task is blocked by, and the tasks it blocks — and
opens to a list of them with their states, each navigable to that task. It is
absent when the task has no edges in either direction.
- Agents SHALL be able to declare and manage dependencies over MCP: set them
when creating a task, and add or remove them on an existing task. The tools
SHALL teach when a dependency is the right relationship rather than a subtask,
because an agent that reaches for `parent_id` to express ordering gets neither
the gate nor the chain.
- Creating a task with dependencies and an agent-start request SHALL record the
start as a start-when-unblocked intent rather than launching immediately. An
automated caller that asks for both is describing a chain step, not a task to
run now.
- Deleting a task removes its edges in both directions. Dependents whose last
unresolved dependency was the deleted task become unblocked, and a
`start-when-unblocked` intent on such a dependent SHALL NOT fire — deletion
is not success.
### Relationship to existing mechanisms
This feature deliberately extends four shipped mechanisms rather than adding
parallel ones. The reuse is part of the contract because the guarantees below
(single launch, restart survival, WIP interaction) are inherited from them.
| Existing mechanism | Role here |
|---|---|
| `task_blockers` table + BFS cycle detection (`office/dashboard.AddTaskBlocker`) | The dependency edge store and cycle validator, promoted from Office-only to a core task relationship. |
| Deferred launch intent (`tasks.metadata.deferred_launch`, claimed atomically) | Storage and idempotent consumption of "start automatically when unblocked". |
| The single auto-start chokepoint (`orchestrator.autoStartTaskForStep`) | Where the dependency gate lives and where dependency-triggered launches enter, so the existing auto-start claim guard prevents double launches. |
| WIP admission and queue promotion ([`tasks/wip-limit-pull-system`](../tasks/wip-limit-pull-system.md)) | Decides whether an unblocked, auto-start-intent task launches now or on promotion. |
Two existing behaviors are intentionally *not* changed:
- **Parent/child completion signalling** ([`tasks/subtask-completion-trigger`](../tasks/subtask-completion-trigger.md))
fires `on_children_completed` when all children reach any terminal state,
including `FAILED`. Dependency resolution uses a stricter definition
(success only). The two must not be unified.
- **Office's blocker reaction** (`office/scheduler.cascadeBlockersResolved`)
queues an Office run to the blocked task's *assignee* when a blocker's status
becomes done. It stays as-is. For a task that qualifies for both reactions,
the shared auto-start claim guarantees exactly one launch.
## Data model
### `task_blockers` (existing, reused)
```text
task_id TEXT the dependent task (blocked)
blocker_task_id TEXT the predecessor task (blocking)
created_at TIMESTAMP
PRIMARY KEY (task_id, blocker_task_id)
CHECK (task_id != blocker_task_id)
```
- The row reads "`task_id` is blocked by `blocker_task_id`".
- Edges are workspace-local: both tasks MUST belong to the same workspace.
- No new column is added. Edge deletion on task delete/archive is enforced by
the owning repository, not by a SQLite `ON DELETE CASCADE`, because the table
predates the foreign key.
- The table currently ships from the Office repository's
`createTaskExtensionTables`. Reading and writing it becomes a core task
concern; the Office reader methods stay valid against the same rows.
### `tasks.metadata.deferred_launch` (existing, reused)
The start-when-unblocked intent is a `deferred_launch` metadata record on the
**dependent** task, identical in shape to the one WIP overflow already
persists: resolved agent profile, executor, executor profile, prompt,
plan-mode choice, priority, and attachments. It is claimed atomically before
launch and restored on launch failure.
A task therefore has at most one launch intent regardless of how many
predecessors it has. "Which edge triggers the start" is not a question this
model can ask — the answer is always "the last one to resolve".
### Derived state (not persisted)
`blocked`, `blocked_reason`, and the predecessor list are computed from
`task_blockers` plus the predecessors' own state on every read. There is no
denormalized `is_blocked` column: a stale copy of it would gate launches
incorrectly, which is the one failure this feature must not have.
A predecessor is **resolved** when it satisfies the existing successful-completion
convention: `state = COMPLETED`, or resident in a final workflow step whose name
is `Done`, `Complete`, `Completed`, or `Approved` (`wfmodels.IsTerminalStepName`,
which also persists `state = COMPLETED`).
A predecessor is **failed** when `state` is `FAILED` or `CANCELLED`.
A predecessor is **pending** in every other case, including archived.
## API surface
### HTTP
Existing Office-scoped routes are promoted to task-scoped equivalents. The
Office paths remain for compatibility with the Office task pane.
```text
POST /api/v1/tasks/:id/dependencies {"depends_on_task_id": "<id>"}
DELETE /api/v1/tasks/:id/dependencies/:depId
```
- `POST` returns `409` with `{"error": "...", "cycle": ["A","B","C","A"]}` when
the edge would close a cycle. The `cycle` body shape matches what
`BlockersPicker` already parses.
- `POST` returns `400` for a self-edge or a cross-workspace edge.
- `DELETE` of an absent edge is a success no-op.
### Task payload
Task DTOs returned over HTTP, WebSocket boot, WebSocket events, and MCP gain:
```json
{
"blocked": true,
"blocked_reason": "pending",
"depends_on": [
{ "id": "task-a", "title": "Add the schema migration", "state": "COMPLETED", "status": "resolved" },
{ "id": "task-b", "title": "Expose the edges on the API", "state": "TODO", "status": "pending" }
],
"blocks": [{ "id": "task-d", "title": "Render the chip", "state": "TODO" }],
"start_when_unblocked": true
}
```
- `blocked_reason` is one of `"pending"` (at least one predecessor unfinished),
`"failed"` (no predecessor unfinished, at least one failed), `"unknown"` (the
dependency store could not be read, so the gate failed closed), or omitted
when `blocked` is false. A client renders `"unknown"` as blocked without a
named cause; it must not present it as resolved.
- `depends_on` lists direct predecessors only, in `created_at` order.
- `blocks` lists direct dependents only, in `created_at` order. It exists so the
dependency chip can show both directions without a second round trip, and so
an agent can see what is waiting on it.
- Both lists carry enough per-entry detail for the chip to render without
fetching each task: id, title, and state. Neither list is transitive.
- The projection is **absent, not empty, when a payload does not compute it**.
Every boot payload and list read computes it; lightweight `task.updated` events
do not. A client that reads an omitted projection as "no edges" erases the real
edges, so absent MUST mean "unknown" and an explicit empty list MUST mean "no
edges". A reader that needs certainty re-reads the task.
- `start_when_unblocked` reflects the presence of a launch intent tied to
dependency resolution. It is read-only on the task payload; it is set through
the create request or the dependency picker.
### Task create
`POST /api/v1/tasks` already accepts `blocked_by: ["<id>", …]`. It gains:
```json
{ "blocked_by": ["task-a"], "start_when_unblocked": true }
```
When `start_when_unblocked` is true **and `blocked_by` is non-empty**, the
create request's agent/executor/prompt resolution is recorded as the deferred
launch intent instead of launching now.
`start_when_unblocked: true` with an empty `blocked_by` records no intent. The
task is born unblocked, so there is no later moment at which it would fire; a
create that also asked to start an agent launches immediately, exactly as it
would without the flag, and one that did not launches nothing. The flag defers
a start, it never invents one. Adding a dependency to that task afterwards
gates its *automated* starts but does not retroactively create an intent.
When `blocked_by` is non-empty and `start_when_unblocked` is omitted, it defaults
to the request's agent-start intent: a create that asked to start an agent
records the intent, and a create that did not records no intent. The response
reports `started` and `start_when_unblocked` so the caller never has to infer
which happened. This rule is what makes an automated caller's habitual
`start_agent: true` build a chain instead of launching every step at once.
### WebSocket events
- `task.updated` with `fields: ["dependencies"]` on edge add/remove.
- `task.dependencies_resolved` — published when the last unresolved dependency
of a task resolves successfully. Payload: `{task_id, resolved_by_task_id}`.
This is the event whose handler enters the auto-start chokepoint.
- `task.dependency_failed` — published when a predecessor of a blocked task
reaches `FAILED` or `CANCELLED`. Payload:
`{task_id, failed_task_id, failed_state}`.
### Notifications
A new notification event type `task.dependency_failed` follows the existing
constants in `internal/notifications/service`. It fires once per
(dependent, predecessor) failure transition.
### MCP
`create_task_kandev`'s handler already reads a `blocked_by` array from its
payload, but the tool schema never declares it, so no agent can discover or pass
it. Declaring it is the minimum; the tools below make chains expressible.
- `create_task_kandev` declares `blocked_by` (array of task IDs) and
`start_when_unblocked` (boolean).
- **`blocked_by` plus `start_agent: true` records a start-when-unblocked intent
and launches nothing now.** `start_agent` defaults to true and every agent
passes it by habit, so the alternative — launching a blocked task immediately —
would silently break every chain an agent tries to build. The create response
reports `started: false` with `start_when_unblocked: true` so the caller can
see what happened. Passing `start_when_unblocked: false` alongside
`blocked_by` creates the edges with no launch intent at all.
- `add_task_dependency_kandev` — `{task_id?, depends_on_task_id}`. `task_id`
defaults to the calling task. Returns the resulting `depends_on` list.
Rejects a cycle with the cycle path, a self-edge, and a cross-workspace edge.
- `remove_task_dependency_kandev` — `{task_id?, depends_on_task_id}`. Removing
an absent edge succeeds. Removing the last edge unblocks the task without
launching it.
- `list_related_tasks_kandev` is the read side and already returns `blockers` and
`blocked_by`. Those groups become populated in Kanban-only installs, where
they are empty today because the blocker repository is wired inside the
Office-enabled branch.
- Tool descriptions SHALL state when to use a dependency instead of a subtask: a
subtask means "part of", a dependency means "not until". Decomposing a plan
into ordered phases is N sibling tasks chained with `blocked_by` and
`start_when_unblocked`, not N subtasks started at once. The description SHALL
also state that a failed predecessor halts the chain and needs human action, so
an agent does not build a chain expecting it to self-heal.
- An agent cannot set `start_when_unblocked` on a task it did not create; it uses
the dependency tools for edges and leaves launch intent to the task's owner.
## State machine
Dependency state is derived, so the machine below describes the *dependent*
task's gating status rather than a persisted column.
```text
edge added predecessor resolved (not last)
unblocked ──────────────────────> blocked ──────────────────────────> blocked
^ │ ^ │
│ last edge removed │ │ predecessor reopened │
│ or last predecessor resolved │ │ │
└──────────────────────────────┘ └──────────────────────────────────┘
│
predecessor FAILED/CANCELLED
v
blocked (reason=failed)
│
predecessor retried and resolved, OR edge removed
v
unblocked
```
Transitions and their actors:
| From | To | Trigger | Actor |
|---|---|---|---|
| unblocked | blocked | edge created | user, agent (`blocked_by`), API |
| blocked | blocked | a predecessor resolves, others remain | system |
| blocked | blocked (reason=failed) | a predecessor enters `FAILED`/`CANCELLED` | system |
| blocked | unblocked | last unresolved predecessor resolves successfully | system |
| blocked | unblocked | last blocking edge removed | user, agent, API |
| unblocked | blocked | a resolved predecessor is reopened to a non-terminal state | user, system |
On the `blocked -> unblocked` transition caused by successful resolution, and
only then, Kandev publishes `task.dependencies_resolved` and evaluates
auto-start:
```text
task.dependencies_resolved
-> task has deferred launch intent? no -> stop (task is merely unblocked)
-> task is WIP-admitted? no -> stop; WIP promotion will retry
-> claim deferred launch intent lost -> stop; the winner launches
-> launch with the recorded parameters
```
Removing the last edge unblocks the task but does **not** fire auto-start: the
intent is consumed by dependency *resolution*, not by the absence of
dependencies. A user who removes the edge is taking manual control.
## Permissions
- Creating or removing an edge requires the same authorization as updating both
tasks; both must be in a workspace the caller can access. Cross-workspace
edges are rejected, so no new cross-tenant surface exists.
- Agents create edges through task creation (`blocked_by`), the two MCP tools
(`add_task_dependency_kandev`, `remove_task_dependency_kandev`), and the
existing Office blocker endpoints. They cannot set `start_when_unblocked` on
a task they did not create.
- Every one of those paths authorizes BOTH task IDs against the caller's
identity before any read or write, and denials surface as not-found so an
edge attempt cannot be used to probe for another user's tasks. In a
task-bound MCP session the dependent end is the session's own task, taken
from the `AgentExecution` rather than from an agent-supplied identifier; the
predecessor is necessarily another task and is authorized on its own.
- The dependency gate is not a permission: a user with start rights can start a
blocked task manually.
## Failure modes
| Condition | Required behavior |
|---|---|
| Edge would create a cycle | `409` with the cycle path; no row written. |
| Edge is a self-edge or crosses workspaces | `400`; no row written. |
| Predecessor enters `FAILED`/`CANCELLED` | Dependent stays blocked with `blocked_reason: "failed"`; `task.dependency_failed` published; notification raised; no launch. |
| Predecessor is archived while a dependent is blocked | Treated as pending, not resolved. The dependent stays blocked and the reason names the archived predecessor. |
| Predecessor is deleted | Edge is removed. The dependent may become unblocked but its launch intent does not fire. |
| Dependent has no deferred launch intent | It unblocks and waits for a user or for its step's normal `on_enter` behavior on a later move. |
| Dependent is not WIP-admitted when its dependencies resolve | No launch. The existing WIP promotion path launches it once capacity opens; the intent survives. |
| Dependent's step has a WIP limit that is full | Same as above — the dependency gate and the WIP gate are independent and both must pass. |
| Two paths race to launch the same dependent (dependency resolution and WIP promotion) | The existing atomic deferred-launch claim admits one. The loser logs and stops. |
| Launch fails after the claim | The claim is restored so the task remains retryable and visibly unstarted, matching the WIP-overflow contract. |
| Predecessor lookup fails while evaluating resolution | No launch, warning logged. A later state change on any predecessor re-evaluates. |
| Blocker repository is unavailable | Edge writes fail with an error. Reads that fail return the task as **blocked with reason unknown** rather than unblocked — the gate fails closed, because failing open would launch work whose predecessor may not have run. |
| A predecessor is reopened after the dependent already launched | No retroactive stop. The dependent shows blocked again; the running session is untouched. |
| Dependency graph is large | Cycle detection keeps its existing BFS walk limit; a walk that exceeds the limit rejects the edge rather than accepting an unverified one. |
## Persistence guarantees
- Dependency edges are durable and survive restart.
- Start-when-unblocked intents are durable (task metadata) and survive restart,
including across a restart that happens between a predecessor's completion
and the dependent's launch: startup reconciliation re-evaluates blocked tasks
with intents and launches those whose dependencies are satisfied.
- Derived `blocked` state is never persisted and is recomputed on read, so a
restart cannot resurrect a stale gate.
- An intent is consumed at most once. A restart between claim and launch leaves
the task unstarted with the claim restored, not double-started.
- `task.dependencies_resolved` and `task.dependency_failed` are in-memory
events and are not replayed after restart; the startup reconciliation above
is the recovery path, and a failed-predecessor state is re-derived on read.
## Scenarios
Edges and blocking:
- **GIVEN** tasks A and B in the same workspace, **WHEN** the user adds "B
depends on A", **THEN** B's card shows a blocked badge naming one
predecessor and B's payload reports `blocked: true`, `blocked_reason:
"pending"`, `depends_on: ["A"]`.
- **GIVEN** "B depends on A", **WHEN** the user tries to add "A depends on B",
**THEN** the request is rejected with the cycle path `A → B → A` and no edge
is created.
- **GIVEN** "B depends on A" and "C depends on B", **WHEN** the user tries to
add "A depends on C", **THEN** the request is rejected with the cycle path
`A → C → B → A`.
- **GIVEN** tasks in two different workspaces, **WHEN** the user tries to link
them, **THEN** the request is rejected and no edge is created.
- **GIVEN** B depends on A and on X, **WHEN** A completes successfully,
**THEN** B remains blocked with `depends_on: ["A","X"]` and
`blocked_reason: "pending"`.
Gating:
- **GIVEN** B depends on an unfinished A and B's workflow step has
`on_enter: auto_start_agent`, **WHEN** B is moved into that step, **THEN** no
session is created and the skip is logged.
- **GIVEN** B depends on an unfinished A and B is queued for a WIP-limited
auto-start step, **WHEN** capacity opens and B is promoted, **THEN** B is
admitted, its queued badge clears, and no session is created.
- **GIVEN** B depends on an unfinished A, **WHEN** the user clicks Start on B,
**THEN** B starts, because manual start is an explicit override.
Auto-start and chaining:
- **GIVEN** B depends on A, B has a start-when-unblocked intent, and B is
WIP-admitted, **WHEN** A completes successfully, **THEN** exactly one session
is created for B using the intent's recorded agent profile, executor,
executor profile, prompt, and plan-mode choice.
- **GIVEN** A → B → C each with a start-when-unblocked intent, **WHEN** A
completes, **THEN** B starts; **WHEN** B completes, **THEN** C starts, and A
is never restarted.
- **GIVEN** B depends on A and B has **no** start-when-unblocked intent,
**WHEN** A completes, **THEN** B unblocks, no session is created, and B's
card shows no blocked badge.
- **GIVEN** B depends on A, B has an intent, and B is queued (not WIP-admitted)
for a full step, **WHEN** A completes, **THEN** no session is created and B
stays queued; **WHEN** capacity later opens and B is promoted, **THEN**
exactly one session is created.
- **GIVEN** B depends on A and B has an intent, **WHEN** A completes and the
backend restarts before B launches, **THEN** after startup exactly one
session is created for B.
- **GIVEN** B depends on A and on X, both with A and X unfinished, **WHEN** A
completes and then X completes, **THEN** B launches exactly once, on X's
completion.
- **GIVEN** B depends on A and B has an intent, **WHEN** the user removes the
edge instead of A completing, **THEN** B unblocks and no session is created.
Failure paths:
- **GIVEN** B depends on A and B has an intent, **WHEN** A ends `FAILED`,
**THEN** B stays blocked with `blocked_reason: "failed"`, no session is
created, `task.dependency_failed` is published, and a notification is raised.
- **GIVEN** B is blocked with `blocked_reason: "failed"` after A failed,
**WHEN** A is retried and completes successfully, **THEN** B unblocks and its
intent fires exactly once.
- **GIVEN** B depends on A and A is `CANCELLED`, **WHEN** the dependency state
is read, **THEN** B is blocked with `blocked_reason: "failed"`.
- **GIVEN** B depends on A, **WHEN** A is archived, **THEN** B stays blocked
and A is reported as a pending predecessor.
- **GIVEN** B depends on A and B has an intent, **WHEN** A is deleted, **THEN**
the edge is removed, B unblocks, and no session is created.
- **GIVEN** A has completed and B has launched, **WHEN** A is reopened to
`IN_PROGRESS`, **THEN** B reports blocked again and B's running session is
not stopped.
- **GIVEN** the blocker store returns an error, **WHEN** an automated path
evaluates whether a task may auto-start, **THEN** the launch is skipped and a
warning is logged.
UI:
- **GIVEN** a blocked task on the Kanban board, **WHEN** the user hovers or taps
its blocked badge, **THEN** the predecessor titles and their states are
listed, and the badge coexists with the WIP queued badge without truncating
either.
- **GIVEN** the task-create dialog, **WHEN** the user opens "Depends on", **THEN**
the board's other tasks are offered as candidates, selecting one shows a
removable chip, and the copy states that starting an agent will run the task
automatically once every selection completes.
- **GIVEN** a task created with dependencies and an agent start, **WHEN** creation
succeeds, **THEN** no session is started and the response reports
`start_when_unblocked: true`.
- **GIVEN** an open task B where B depends on A and D depends on B, **WHEN** the
task view renders, **THEN** a dependency chip appears in the status row above
the composer next to the PR status chip, and opening it lists A under
blocked-by and D under blocks, each with its state and each navigable.
- **GIVEN** an open task with no dependency edges in either direction, **WHEN**
the task view renders, **THEN** no dependency chip appears, and a status row
that would otherwise be empty still does not render.
- **GIVEN** an open task whose board copy carries no dependency projection (a
lightweight `task.updated` overwrote it, or arrived before hydration), **WHEN**
the task view renders, **THEN** the chip still shows the task's edges, because a
reader that cannot trust its cached copy re-reads them.
- **GIVEN** an open blocked task whose predecessor failed, **WHEN** the user opens
the dependency chip, **THEN** the failed predecessor is distinguished from
merely pending ones.
- **GIVEN** a narrow mobile viewport, **WHEN** the user taps the dependency chip,
**THEN** the list opens in the same drawer pattern the PR status chip uses on
mobile, not a hover card.
MCP:
- **GIVEN** an agent calls `create_task_kandev` with `blocked_by: ["A"]` and no
explicit `start_agent`, **THEN** the task is created with the edge, no session
is started, and the response reports `started: false` and
`start_when_unblocked: true`.
- **GIVEN** an agent calls `create_task_kandev` with `blocked_by: ["A"]` and
`start_when_unblocked: false`, **THEN** the edge is created, no launch intent
is recorded, and completing A starts nothing.
- **GIVEN** an agent calls `create_task_kandev` with `start_when_unblocked: true`
and no `blocked_by`, **THEN** no launch intent is recorded and the task starts
or does not start purely per `start_agent`; the response reports
`start_when_unblocked: false`.
- **GIVEN** an agent decomposes a plan into three chained sibling tasks over
three `create_task_kandev` calls, **WHEN** the first task completes
successfully, **THEN** the second starts and the third does not.
- **GIVEN** an agent calls `add_task_dependency_kandev` omitting `task_id`,
**THEN** the edge is added to the calling task and the resulting `depends_on`
list is returned.
- **GIVEN** an agent calls `add_task_dependency_kandev` with an edge that would
close a cycle, **THEN** the call fails with the cycle path and no edge is
created.
- **GIVEN** an agent calls `remove_task_dependency_kandev` for an edge that does
not exist, **THEN** the call succeeds.
- **GIVEN** a Kanban-only install with `Features.Office` false, **WHEN** an agent
calls `create_task_kandev` with `blocked_by`, **THEN** the task and edge are
created and `list_related_tasks_kandev` reports them.
- **GIVEN** a narrow mobile viewport, **WHEN** the user opens a column
containing a blocked task, **THEN** the blocked state and predecessor count
are readable on the card without hover-only disclosure and without horizontal
overflow.
## Out of scope
- **A whole-graph board view.** A layered DAG view was built and then removed
before shipping: without drawn connectors it read as columns of text, and it
could only show one workflow at a time, so it did not answer "show me this
chain" any better than the per-task chip does. The chip (both directions, one
hop) and `list_related_tasks_kandev` are the shipped ways to read the graph.
A future view should draw real connectors, using the existing
`components/kanban/graph2-connector.tsx` primitives, and be able to focus a
single chain.
- **Office mode changes.** Office is feature-flagged and not in the live
feature inventory ([`docs/features.md`](../../features.md), "In Progress:
Office Mode"), and it already has its own blocker reaction and run queue.
v1 makes the relationship and the Kanban reaction work in Kanban mode and
leaves `cascadeBlockersResolved`, Office agent assignment, and Office task
delegation untouched. Unifying the two reactions is a follow-up that should
wait until Office ships.
- Optional or "soft" dependencies (start-anyway-after-N-hours, OR semantics
across predecessors). v1 is AND-only over all direct predecessors.
- Cross-workspace dependencies.
- Passing a predecessor's output (summary, diff, PR link) into the dependent's
prompt. The dependent launches with the prompt recorded in its intent. Agents
that need predecessor context read it through `list_related_tasks_kandev`.
- Stopping or rolling back a dependent that already launched when its
predecessor is reopened or reverted.
- Automatic retry of a failed predecessor, and automatic edge removal on
failure.
- A dependency-aware scheduling policy (critical-path ordering, priority
inheritance along a chain, WIP reservation for a whole chain).
- Templated chains — saving "A → B → C" as a reusable pipeline definition.
A chain is a set of edges between concrete tasks in v1.
- Gantt or timeline rendering, and estimated-duration modelling.
- Editing dependencies from the multi-select toolbar or via bulk operations.
- An edge editor on the task detail view. Declaring dependencies is a planning
act that belongs to task creation (or to MCP for an agent decomposing work);
the task view only reports them.
## Implementation plan
See [`../../plans/task-dependencies/plan.md`](../../plans/task-dependencies/plan.md).