--- spec: docs/specs/tasks/system-design/task-dependencies.md created: 2026-08-09 status: not_started --- # Implementation Plan: Task Dependencies and Auto-Start Chains ## Overview Peer-to-peer task dependencies become a core Kanban capability with three observable behaviors: a blocked task is visible, a blocked task is never auto-started, and an unblocked task with a recorded launch intent starts exactly once. The work is deliberately additive over four shipped mechanisms rather than a new subsystem: - the `task_blockers` edge table and its BFS cycle detector; - the `tasks.metadata.deferred_launch` intent with its atomic claim; - the single auto-start chokepoint `orchestrator.autoStartTaskForStep`; - WIP admission and queue promotion. Nothing in this plan adds a second launch path, a denormalized `is_blocked` column, or a chain entity. ## Confirmed current behavior Verified in `apps/backend` and `apps/web` at `d38d9a0b8`: - `task_blockers (task_id, blocker_task_id, created_at)` exists with a composite PK and a `CHECK (task_id != blocker_task_id)`. It is created by `internal/office/repository/sqlite/base.go:createTaskExtensionTables` but lives in the same database as `tasks`. - `internal/office/dashboard/service_tasks.go` owns edge creation with self-edge, cross-workspace, and BFS cycle validation, returning `*BlockerCycleError{Path}` which the frontend already renders as `A → B → A`. - `internal/task/service/service_office.go` exposes `AddBlocker` / `RemoveBlocker` with a separate, weaker cycle check, and errors with `blocker repository not configured` when `s.blockers == nil`. - `internal/backendapp/main.go` calls `SetBlockerRepository(repos.Office)` **after** the `if !cfg.Features.Office { return }` early return, so in a Kanban-only install the blocker repository is not wired at all: `create_task` with `blocked_by` fails and `list_related_tasks_kandev` returns empty `blockers` / `blocked_by`. - Edge HTTP routes exist only under the Office group: `POST /api/v1/office/tasks/:id/blockers`, `DELETE /api/v1/office/tasks/:id/blockers/:blockerId` (`internal/office/dashboard/handler.go`). - `internal/office/scheduler/reactivity.go:cascadeBlockersResolved` is the only reaction to a resolved blocker. It runs from the Office `ApplyTaskMutation` path when a status becomes done, resolves "done" as `IsTaskInTerminalStep`, and requires a non-empty `AssigneeAgentProfileID`. It never fires for a Kanban board move or a workflow-engine transition. `normalisedStatus` maps `FAILED` to blocked, not done, so Office already does not cascade on failure. - `internal/orchestrator/event_handlers_workflow.go:autoStartTaskForStep` is the single automated-launch chokepoint. It already returns early when `task.QueuedForStepID != ""`, consumes a `deferred_launch` intent via `launchDeferredTask`, and guards watcher races with `MetaKeyAutoStartGuard` / `claimAutoStart` / `restoreAutoStartClaim`. It is reached from `task.moved` (`handleTaskMovedNoSession`) and `task.queue_promoted` (`handleTaskQueuePromoted`). - `wfmodels.IsTerminalStepName` recognizes `done|complete|completed|approved`; entering such a final step persists `state = COMPLETED`. - The workflow engine declares `TriggerOnBlockerResolved` ("on_blocker_resolved") but `Engine.HandleTrigger` does not wire it; Office routes it through `SetWorkflowEngineDispatcher`. - `create_task_kandev`'s handler struct reads `blocked_by` (`internal/mcp/handlers/handlers.go:576`, `json:"blocked_by"`) and forwards it to `CreateTask`, but the tool's `mcp.NewTool` schema in `internal/mcp/server/server.go:854` never declares the parameter. It is therefore undiscoverable to agents and not reliably passable through a schema-validating client. There is no MCP tool at all for adding or removing a dependency on an existing task. - Frontend: `components/task/simple/components/blockers-picker.tsx` exists with cycle-message handling and optimistic mutation, but is mounted only from `task-properties.tsx` → `OfficeSimplePane`, which the Kanban route (`app/tasks/[id]/kanban-task-shell.tsx`) renders as a stub. Kanban has no dependency UI. - The status row above the composer is `data-testid="chat-status-bar"` in `components/task/chat/chat-input-area.tsx`. It hosts `TodoIndicator`, `PRStatusChip`, `AzureDevOpsTaskPullRequestChip`, a queue chip, PR merged/closed banners, and right-aligned transcript controls. Its visibility is gated by `shouldRenderChatStatusBar({hasTask, hasTodos, hasQueueChip, showRightControls, showProceed})` — a new chip that is not added to that predicate renders nothing when it is the row's only content. - A **second** status row exists: `data-testid="passthrough-status-row"` in `components/task/passthrough-toolbar.tsx`, which mounts its own `PRStatusChip`. Anything that belongs "next to the PR chip" belongs in both rows. - `PRStatusChip` returns null when not applicable and branches on `usesMobileDrawer` to `PRStatusChipDrawer` versus `PRStatusChipHoverCard` — the mobile pattern to mirror rather than reinvent. - `components/kanban-card-content.tsx` already renders a `queuedForStepId` badge — the precedent and the layout neighbour for a blocked badge. - `lib/kanban/view-registry.ts` is a pluggable board-view registry with `kanban` and `graph2` ("Pipeline"). `graph2` visualizes one task across workflow steps, not cross-task edges. `getEffectiveView` forces the Kanban view on mobile. ## Backend design ### One owner for the dependency relationship Move the authoritative edge operations into the task domain so a Kanban-only install has them: - Keep the physical `task_blockers` table where it is. It is in the same database, and relocating live DDL buys nothing. Add the missing indexes via idempotent migrations in `internal/task/repository/sqlite/base_migrations.go` (`(blocker_task_id)` for the reverse walk). - Move the strong validator — self-edge, cross-workspace, BFS cycle with the existing walk limit and `*BlockerCycleError` — into the task service so both the new task-scoped routes and the existing Office routes call one implementation. Delete the weaker `checkCircularBlocker` in `service_office.go` rather than leaving two validators. - Wire `SetBlockerRepository` **before** the `Features.Office` early return in `internal/backendapp/main.go`. This is the smallest change that makes `blocked_by` and `list_related_tasks_kandev` work in Kanban mode. - Edge cleanup on task **delete** is explicit repository work, not a SQLite `ON DELETE CASCADE`, because the table predates the foreign key and must behave identically on PostgreSQL. **Archive** deliberately keeps the edges: an archived predecessor reads as `pending`, so dropping them would silently unblock the chain. ### Derived blocked state Add one service helper that, for a set of task IDs, returns each task's direct predecessors **and direct dependents**, plus a resolution verdict per predecessor: - resolved — `state = COMPLETED`, or resident in a final step whose name passes `IsTerminalStepName`; - failed — `state` is `FAILED` or `CANCELLED`; - pending — anything else, including archived. `blocked` and `blocked_reason` are computed from that verdict on every read and are never stored. Batch it: the Kanban board reads a whole workflow at once, so a per-task query would add N round trips to the board load. Follow the existing batch-helper convention in `task/service/service_access.go` — callers derive the IDs from an already-authorized task list. Both directions ship in the payload because the dependency chip shows both, and the reverse direction is one indexed query rather than a client-side scan the board's partial task list could not answer correctly anyway. ### MCP surface Declaring `blocked_by` on the `create_task_kandev` schema is the smallest fix with the largest effect: the plumbing already exists and only the contract is missing. Two new tools, `add_task_dependency_kandev` and `remove_task_dependency_kandev`, mirror the HTTP routes one-to-one and follow the repo's existing preference for explicit small tools (`add_branch_to_task_kandev`, the three `*_task_plan_kandev` tools) over a mode-flag mutator. `list_related_tasks_kandev` is already the read side. The load-bearing semantic decision: `blocked_by` non-empty plus `start_agent: true` records a start-when-unblocked intent instead of launching. `start_agent` defaults to true and its description tells agents to leave it true, so the alternative silently launches the whole chain at once — exactly the collision the feature exists to prevent. The create response reports `started: false` with `start_when_unblocked: true` so the caller is not guessing. ### The gate Add the dependency check to `autoStartTaskForStep` next to the existing `QueuedForStepID` check, before `launchDeferredTask`. Because that function is the only automated-launch entry point, one check covers `task.moved`, `task.queue_promoted`, watcher auto-start, and dependency resolution itself. The gate fails **closed**: if the predecessor lookup errors, skip the launch and log a warning. Failing open would launch work whose predecessor may not have run, which is the one outcome this feature exists to prevent. Manual start (`StartTask` from a user action) does not consult the gate. ### Resolution and auto-start Subscribe to the task-state and task-move events that can make a predecessor resolved. On each, look up dependents with `ListTasksBlockedBy`, and for each dependent re-evaluate all of its predecessors: - any still pending → nothing; - all resolved → publish `task.dependencies_resolved` and call `autoStartTaskForStep` with the dependent's current step; - at least one failed and none pending → publish `task.dependency_failed` (Task 04). `start_when_unblocked` on task creation persists the resolved launch inputs into `metadata.deferred_launch` — the same record WIP overflow already writes and `launchDeferredTask` already consumes idempotently. No new table, and the "exactly one launch" property is inherited rather than re-derived. Removing the last edge unblocks the task but must not consume the intent. Startup reconciliation: after the queue reconciler runs, sweep non-archived tasks that carry a `deferred_launch` intent and have edges, and launch those whose dependencies are all resolved. This is the recovery path for a restart between a predecessor's completion and the dependent's launch. Bound and batch it so a large graph does not delay readiness. ### Not touched `cascadeBlockersResolved`, Office assignment, `TriggerOnBlockerResolved` wiring, and the Office task pane keep their current behavior. For a task eligible under both reactions, the existing atomic deferred-launch/auto-start claim admits one launcher. ## Frontend - Extend the Kanban task type, boot mapper, and WS handlers with `blocked`, `blockedReason`, `dependsOn`, `blocks`, and `startWhenUnblocked`. - Treat an omitted projection as **unknown**, never as "no edges", at every layer (mapper, WS merge, hydration backfill). The chip re-reads the task when its cached copy has no projection, so no WS/hydration ordering can blank it. - Blocked badge on the shared card next to the existing queued badge. Both must fit without truncating either, on desktop and mobile, with no hover-only disclosure. - Dependency chip in the status row above the composer, next to `PRStatusChip`, in **both** `chat-input-area.tsx` and `passthrough-toolbar.tsx`. It shows both directions (blocked by / blocks), opens to a navigable list with each entry's state, distinguishes failed predecessors, returns null with no edges, and uses the drawer-on-mobile pattern `PRStatusChip` already establishes. `shouldRenderChatStatusBar` must learn about the chip, or a task whose only status content is the chip renders no row. - No "Depends on" picker in the Kanban task detail view. Dependencies are declared in the create dialog or over MCP (see the spec's What); the detail view's dependency surfaces — card badge, chip, list — stay read-only. - "Depends on" field plus a "Start automatically when unblocked" checkbox in the task-create dialog, and an "Add dependency" entry in the Kanban card context menu (`kanban-card-link-submenu.tsx` is the precedent). - No whole-graph board view. One was built and removed before shipping (see the spec's Out of scope): a `VIEW_REGISTRY` entry alone is not reachable anyway — the header toggle speaks the separate `TaskListingView` vocabulary — and without drawn connectors the result read as columns of text. - All new copy goes through `t()` / ``, and any newly touched path is appended to `i18nGuardFiles` in the same change. ## Test strategy ### Backend - Edge creation: cycle at length 2, 3, and N; self-edge; cross-workspace; duplicate edge is idempotent; validator is shared by the task-scoped and Office routes. - Derived state: pending / resolved-by-state / resolved-by-terminal-step / failed / cancelled / archived predecessors produce the right `blocked` and `blocked_reason`; batch helper matches the per-task result. - Gate: a blocked task is not launched by `task.moved`, by `task.queue_promoted`, or by dependency resolution; a predecessor-lookup error skips the launch; manual start is unaffected. - Resolution: last-predecessor-resolves launches exactly once; a non-last resolution launches nothing; no-intent unblocks without launching; last-edge-removed unblocks without launching; a queued dependent launches on WIP promotion, not on resolution. - Chain: A → B → C with intents launches each exactly once, in order, and never restarts A. - Races: dependency resolution and WIP promotion both firing produce one session; a claim loser logs and stops; a failed launch restores the claim. - Restart: a restart between completion and launch yields exactly one session after startup reconciliation. - Failure: `FAILED` / `CANCELLED` predecessor holds the dependent blocked with `reason: failed`, publishes the event once per transition, and raises one notification; retry-then-success unblocks and fires the intent once. - Deletion: deleting a predecessor removes edges both ways, unblocks the dependent, and does not fire its intent. - Kanban-only wiring: with `Features.Office` false, `blocked_by` on create succeeds and `list_related_tasks_kandev` populates `blockers` / `blocked_by`. - MCP: a pinning test asserts `create_task_kandev`'s registered schema declares `blocked_by` and `start_when_unblocked` — the current bug is precisely a handler/schema mismatch that no behavior test would have caught. `blocked_by` + default `start_agent` starts nothing and reports `start_when_unblocked: true`; three chained create calls run in order; the two dependency tools cover default `task_id`, cycle rejection, absent-edge removal, and cross-workspace denial. - PostgreSQL: add the environment-gated behavior test for every changed dialect-sensitive repository method, per `apps/backend/CLAUDE.md`. ### Frontend unit - DTO/store conversion preserves the five new fields across boot and WS. - Blocked badge renders count and reason; coexists with the queued badge. - `shouldRenderChatStatusBar` returns true when the dependency chip is the row's only content. - The chip lists both directions with states, distinguishes a failed predecessor, renders null with no edges, and appears in both status rows. - Picker creates and removes edges optimistically and replaces the optimistic state with the formatted cycle path on rejection. - Create dialog submits `blocked_by` and `start_when_unblocked`. - Dependencies view builds nodes/edges from a graph payload, marks blocked and failed nodes, and is absent from the mobile view set. ### Browser E2E - Desktop chain: seed A → B → C with intents, complete A, assert B starts and C does not; complete B, assert C starts. - Desktop gate: a blocked task moved into an auto-start step gains no session. - Desktop failure: fail A, assert B stays blocked with the failed reason and gains no session. - Desktop view: create and delete an edge from the Dependencies view. - Mobile: blocked badge readable in a focused column, no horizontal overflow, usable tap targets, Dependencies view not offered. Seed tasks without an agent and locate by task id — `createTaskWithAgent` on a start step moves the card mid-test. ## Public documentation Update `docs/public/tasks-and-workflows.md` (and `workflow-tips.md` if the auto-start interaction needs a worked example) to cover: declaring a dependency, what counts as resolution, that a failed predecessor halts the chain and needs human action, that dependencies gate automated starts but not manual ones, and that dependency resolution does not bypass WIP limits. ## Implementation waves Tasks 01–04 are strictly sequential: each consumes a contract the previous one introduces. Tasks 05 and 06 need only Task 01's API surface, so either may start once 01 lands. Task 08 closes out. Task 07 was built on 06 and then dropped before shipping, so it is not part of the executable set and Task 08 does not wait on it. - [x] [Task 01: Promote dependency edges to a core relationship](task-01-core-dependency-relationship.md) - [x] [Task 02: Gate automated launches on unresolved dependencies](task-02-gate-automated-launch.md) - [x] [Task 03: Resolve dependencies and auto-start on unblock](task-03-resolve-and-auto-start.md) - [x] [Task 04: Halt chains on a failed predecessor](task-04-failed-predecessor-halt.md) - [x] [Task 05: MCP dependency tools](task-05-mcp-dependency-tools.md) - [x] [Task 06: Kanban dependency UI](task-06-kanban-dependency-ui.md) - [x] [Task 08: Verify browser flows and documentation](task-08-verify-flows-and-docs.md) No task is marked `parallel-safe`; waves do not authorize subagent execution. ## Risks - **Two validators.** `office/dashboard` has the strong BFS cycle check; `task/service` has a weaker one. Adding a third for the new routes would let a cycle in through whichever path is weakest. One validator, or the DAG invariant is not real. - **Failing open on lookup error.** A blocked-state read that errors and returns "not blocked" launches exactly the work this feature prevents. The gate must fail closed and the test must prove it by breaking the store. - **A denormalized `is_blocked` column.** Tempting for board queries, but a stale value gates launches wrongly. Batch the derived read instead. - **Two launch paths.** Dependency resolution and WIP promotion can both make the same task launchable within milliseconds. Only the shared atomic claim keeps that to one session; a new bespoke launch call site would reintroduce the double-agent bug that `wip-limit-pull-system` documents. - **Divergent "terminal" definitions.** `on_children_completed` treats `FAILED` as terminal; dependency resolution must not. Sharing a helper between them would silently make chains proceed on failure. - **Office double-reaction.** With Office enabled, `cascadeBlockersResolved` and the new Kanban reaction can both fire for one task. Verify one session, and do not "fix" it by disabling either reaction. - **Board load cost.** A per-card dependency query multiplies board queries by card count. The batch helper is load-bearing, not an optimization. - **Graph view scope creep.** The Dependencies view is the largest single piece of UI. It must not become a general graph editor; edges and nodes only. - **`start_agent: true` launching a blocked task.** Agents pass it by default. If create does not convert it to a start-when-unblocked intent, every chain an agent builds launches at once — the exact failure the feature prevents, and it would look like the gate is broken rather than the create semantics. - **A schema/handler mismatch that no behavior test catches.** `blocked_by` already works end to end in the handler and is still unusable because the tool schema omits it. The pinning test on the registered schema is what stops the same class of gap recurring for `start_when_unblocked`. - **Two status rows.** `chat-input-area.tsx` and `passthrough-toolbar.tsx` both mount `PRStatusChip`. Adding the dependency chip to one leaves it missing in the other layout, and the omission is invisible in the default view. - **`shouldRenderChatStatusBar`.** A chip added without extending that predicate is dead in exactly the case it matters most: a blocked task with no PR, no todos, and no queued prompts.