20 KiB
spec, created, status
| spec | created | status |
|---|---|---|
| docs/specs/tasks/system-design/task-dependencies.md | 2026-08-09 | 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_blockersedge table and its BFS cycle detector; - the
tasks.metadata.deferred_launchintent 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 aCHECK (task_id != blocker_task_id). It is created byinternal/office/repository/sqlite/base.go:createTaskExtensionTablesbut lives in the same database astasks.internal/office/dashboard/service_tasks.goowns edge creation with self-edge, cross-workspace, and BFS cycle validation, returning*BlockerCycleError{Path}which the frontend already renders asA → B → A.internal/task/service/service_office.goexposesAddBlocker/RemoveBlockerwith a separate, weaker cycle check, and errors withblocker repository not configuredwhens.blockers == nil.internal/backendapp/main.gocallsSetBlockerRepository(repos.Office)after theif !cfg.Features.Office { return }early return, so in a Kanban-only install the blocker repository is not wired at all:create_taskwithblocked_byfails andlist_related_tasks_kandevreturns emptyblockers/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:cascadeBlockersResolvedis the only reaction to a resolved blocker. It runs from the OfficeApplyTaskMutationpath when a status becomes done, resolves "done" asIsTaskInTerminalStep, and requires a non-emptyAssigneeAgentProfileID. It never fires for a Kanban board move or a workflow-engine transition.normalisedStatusmapsFAILEDto blocked, not done, so Office already does not cascade on failure.internal/orchestrator/event_handlers_workflow.go:autoStartTaskForStepis the single automated-launch chokepoint. It already returns early whentask.QueuedForStepID != "", consumes adeferred_launchintent vialaunchDeferredTask, and guards watcher races withMetaKeyAutoStartGuard/claimAutoStart/restoreAutoStartClaim. It is reached fromtask.moved(handleTaskMovedNoSession) andtask.queue_promoted(handleTaskQueuePromoted).wfmodels.IsTerminalStepNamerecognizesdone|complete|completed|approved; entering such a final step persistsstate = COMPLETED.- The workflow engine declares
TriggerOnBlockerResolved("on_blocker_resolved") butEngine.HandleTriggerdoes not wire it; Office routes it throughSetWorkflowEngineDispatcher. create_task_kandev's handler struct readsblocked_by(internal/mcp/handlers/handlers.go:576,json:"blocked_by") and forwards it toCreateTask, but the tool'smcp.NewToolschema ininternal/mcp/server/server.go:854never 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.tsxexists with cycle-message handling and optimistic mutation, but is mounted only fromtask-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"incomponents/task/chat/chat-input-area.tsx. It hostsTodoIndicator,PRStatusChip,AzureDevOpsTaskPullRequestChip, a queue chip, PR merged/closed banners, and right-aligned transcript controls. Its visibility is gated byshouldRenderChatStatusBar({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"incomponents/task/passthrough-toolbar.tsx, which mounts its ownPRStatusChip. Anything that belongs "next to the PR chip" belongs in both rows. PRStatusChipreturns null when not applicable and branches onusesMobileDrawertoPRStatusChipDrawerversusPRStatusChipHoverCard— the mobile pattern to mirror rather than reinvent.components/kanban-card-content.tsxalready renders aqueuedForStepIdbadge — the precedent and the layout neighbour for a blocked badge.lib/kanban/view-registry.tsis a pluggable board-view registry withkanbanandgraph2("Pipeline").graph2visualizes one task across workflow steps, not cross-task edges.getEffectiveViewforces 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_blockerstable where it is. It is in the same database, and relocating live DDL buys nothing. Add the missing indexes via idempotent migrations ininternal/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 weakercheckCircularBlockerinservice_office.gorather than leaving two validators. - Wire
SetBlockerRepositorybefore theFeatures.Officeearly return ininternal/backendapp/main.go. This is the smallest change that makesblocked_byandlist_related_tasks_kandevwork 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 aspending, 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 passesIsTerminalStepName; - failed —
stateisFAILEDorCANCELLED; - 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_resolvedand callautoStartTaskForStepwith 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, andstartWhenUnblocked. - 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 bothchat-input-area.tsxandpassthrough-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 patternPRStatusChipalready establishes.shouldRenderChatStatusBarmust 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.tsxis the precedent). - No whole-graph board view. One was built and removed before shipping (see the
spec's Out of scope): a
VIEW_REGISTRYentry alone is not reachable anyway — the header toggle speaks the separateTaskListingViewvocabulary — and without drawn connectors the result read as columns of text. - All new copy goes through
t()/<Trans>, and any newly touched path is appended toi18nGuardFilesin 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
blockedandblocked_reason; batch helper matches the per-task result. - Gate: a blocked task is not launched by
task.moved, bytask.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/CANCELLEDpredecessor holds the dependent blocked withreason: 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.Officefalse,blocked_byon create succeeds andlist_related_tasks_kandevpopulatesblockers/blocked_by. - MCP: a pinning test asserts
create_task_kandev's registered schema declaresblocked_byandstart_when_unblocked— the current bug is precisely a handler/schema mismatch that no behavior test would have caught.blocked_by+ defaultstart_agentstarts nothing and reportsstart_when_unblocked: true; three chained create calls run in order; the two dependency tools cover defaulttask_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.
shouldRenderChatStatusBarreturns 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_byandstart_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.
- Task 01: Promote dependency edges to a core relationship
- Task 02: Gate automated launches on unresolved dependencies
- Task 03: Resolve dependencies and auto-start on unblock
- Task 04: Halt chains on a failed predecessor
- Task 05: MCP dependency tools
- Task 06: Kanban dependency UI
- Task 08: Verify browser flows and documentation
No task is marked parallel-safe; waves do not authorize subagent execution.
Risks
- Two validators.
office/dashboardhas the strong BFS cycle check;task/servicehas 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_blockedcolumn. 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-systemdocuments. - Divergent "terminal" definitions.
on_children_completedtreatsFAILEDas terminal; dependency resolution must not. Sharing a helper between them would silently make chains proceed on failure. - Office double-reaction. With Office enabled,
cascadeBlockersResolvedand 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: truelaunching 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_byalready 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 forstart_when_unblocked. - Two status rows.
chat-input-area.tsxandpassthrough-toolbar.tsxboth mountPRStatusChip. 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.