Tasks can block each other, and blocked work stops reading as available
A task carries no dependency today, so "what can I work on" has no way to
exclude work that is waiting on something else. /threads guessed at it from a
`blocked` tag and from the words in a description, which was wrong in both
directions: a stale tag hid work whose blocker closed months ago, and an
untagged dependency surfaced work nobody could start.
task_dependencies holds the edges and is the only stored truth. One relation,
one direction: the blocker must reach Completed before the blocked task can
start. Not built on subtasks.linked_task_id, which means containment, and
overloading it would have made "is this available" unanswerable without knowing
which meaning a row carried. Edge ids are deterministic in the pair, so two
devices drawing the same edge offline converge on one row.
Blocked-ness is never stored as a flag on a task. A flag would need transitive
recomputation on every completion and would leave two devices contesting a
column neither meaningfully edited. Four derived columns cache the position
instead (block_depth, unblocks_count, in_cycle, graph_urgency), all local and
absent from the sync manifest: every device derives identical values from
synced inputs, so replicating them would be storing a regenerable.
Blocked work sinks and blockers rise across every view, as a second urgency
term rather than a change to `urgency`. That column stays a pure function of
the task's own fields and stays synced; graph_urgency is the subgraph term and
is local. Sorting and display both read the sum.
Cycles are refused on write with the offending path named, and tolerated on
read, since sync can merge one out of two individually legal edges. A task on a
cycle reads as blocked, never appears in ready work, and is reported for repair
rather than going invisible.
go-mcp gains add_dependency, remove_dependency, list_ready_tasks and
get_task_graph, a blocked filter on list_tasks, blocked_by/blocks on get_task,
and depends_on in bulk_import_tasks, resolved after every item exists so a
backlog can be declared in reading order. The desktop app gains blocker editing
in the task drawer, row badges, and a Graph subview laid out on block_depth,
which already is a longest-path layering.
Edges are backed up; the cached columns are excluded and rebuilt on restore.
Design: wiki go-task-dependencies. got follows separately (GO faf8cf6f).
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
46 files changed,
+4162 insertions,
-54 deletions
| 65 |
65 |
|
062 6a5d413c09ee5c3a115952dbeb1836e140d8170c24f4bab17a751b2d9e67cf583c1fff3639409230c1b7f56c851abaeb
|
| 66 |
66 |
|
063 a1471b8f79dd273af634ceb28b253a4ac7dc4e973b1172a25f348f3f81f927bcf4f5f5aaf30cd89d2d6f216ae1ccfdbf
|
| 67 |
67 |
|
064 00f60ab309a763fe5b417fd0fddae1b3f4c83a3e99812fa8110cfb34de0e7f908b34dd405e74e09b75085047c0a344b5
|
|
68 |
+ |
065 734a797c90c0447e413fb4fde8a19d26a52752adbea385ca30f2b2369a281bc4275e57d088a8fe73c6d5adef816a195e
|
| 103 |
103 |
|
<button class="pill active" data-subview="tasks">Tasks</button>
|
| 104 |
104 |
|
<button class="pill" data-subview="projects">Projects</button>
|
| 105 |
105 |
|
<button class="pill" data-subview="problems" title="Candidates from wam and audit runs, ranked by painhours">Problems <span id="problems-count" class="pill-count hidden"></span></button>
|
|
106 |
+ |
<button class="pill" data-subview="task-graph" title="The blocking graph: what waits on what">Graph</button>
|
| 106 |
107 |
|
</div>
|
| 107 |
108 |
|
|
| 108 |
109 |
|
<!-- Tasks Sub-View -->
|
| 254 |
255 |
|
</div>
|
| 255 |
256 |
|
</div>
|
| 256 |
257 |
|
|
|
258 |
+ |
<div id="task-graph-view" class="subview hidden" role="tabpanel" aria-labelledby="task-graph-tab">
|
|
259 |
+ |
<div class="page-header">
|
|
260 |
+ |
<h2 class="page-title">Graph</h2>
|
|
261 |
+ |
<div class="row-flex row-flex-peer">
|
|
262 |
+ |
<select id="task-graph-project" class="field field--compact" aria-label="Scope the graph to a project" data-change="taskGraph.setProject">
|
|
263 |
+ |
<option value="">All projects</option>
|
|
264 |
+ |
</select>
|
|
265 |
+ |
<button class="button button--secondary" data-act="taskGraph.load" title="Reload the graph">Refresh</button>
|
|
266 |
+ |
</div>
|
|
267 |
+ |
</div>
|
|
268 |
+ |
<p class="view-hint">What waits on what. An arrow runs from a blocker to the task it holds up, and each column is one more completion away from being startable. Tasks with no dependencies either way are not drawn.</p>
|
|
269 |
+ |
<div id="task-graph-legend" class="task-graph-legend" aria-hidden="true"></div>
|
|
270 |
+ |
<div id="task-graph-canvas" class="task-graph-canvas">
|
|
271 |
+ |
<div class="skeleton-shimmer" aria-label="Loading the graph">
|
|
272 |
+ |
<div class="skeleton-row"><div class="skeleton-lines"><div class="skeleton-line long"></div><div class="skeleton-line short"></div></div></div>
|
|
273 |
+ |
</div>
|
|
274 |
+ |
</div>
|
|
275 |
+ |
</div>
|
|
276 |
+ |
|
| 257 |
277 |
|
<!-- Project Dashboard Sub-View -->
|
| 258 |
278 |
|
<div id="project-dashboard-view" class="subview hidden">
|
| 259 |
279 |
|
<div class="page-header">
|
| 724 |
744 |
|
<!-- Domains -->
|
| 725 |
745 |
|
<script src="js/projects.js"></script>
|
| 726 |
746 |
|
<script src="js/problems.js"></script>
|
|
747 |
+ |
<script src="js/task-graph.js"></script>
|
| 727 |
748 |
|
<script src="js/tasks-filter.js"></script>
|
| 728 |
749 |
|
<script src="js/task-forms.js"></script>
|
| 729 |
750 |
|
<script src="js/task-board.js"></script>
|
| 71 |
71 |
|
let problems = state
|
| 72 |
72 |
|
.problems
|
| 73 |
73 |
|
.list(user_id, &goingson_core::ProblemFilter::default())?;
|
|
74 |
+ |
let dependencies = state.tasks.list_all_dependencies(user_id)?;
|
| 74 |
75 |
|
Ok(FullExport::new(
|
| 75 |
76 |
|
projects,
|
| 76 |
77 |
|
tasks,
|
| 87 |
88 |
|
monthly_goals,
|
| 88 |
89 |
|
monthly_reflections,
|
| 89 |
90 |
|
problems,
|
|
91 |
+ |
dependencies,
|
| 90 |
92 |
|
))
|
| 91 |
93 |
|
}
|
| 92 |
94 |
|
|
| 165 |
167 |
|
.problems
|
| 166 |
168 |
|
.list(user_id, &goingson_core::ProblemFilter::default()),
|
| 167 |
169 |
|
)?;
|
|
170 |
+ |
// Edges only. The cached graph columns on each task are derived from these
|
|
171 |
+ |
// and are recomputed on restore, so nothing here can carry a stale score.
|
|
172 |
+ |
stream_one(
|
|
173 |
+ |
sink,
|
|
174 |
+ |
"dependencies",
|
|
175 |
+ |
state.tasks.list_all_dependencies(user_id),
|
|
176 |
+ |
)?;
|
| 168 |
177 |
|
Ok(())
|
| 169 |
178 |
|
}
|
| 170 |
179 |
|
|