Skip to main content

max / goingson

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>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 19:48 UTC
Signed with PGP, not checked
Commit: 338aa9f17dfe9f18ad87fcbaa88bf6d71059f4f0
Parent: d8d4632
46 files changed, +4162 insertions, -54 deletions
@@ -65,3 +65,4 @@
65 65 062 6a5d413c09ee5c3a115952dbeb1836e140d8170c24f4bab17a751b2d9e67cf583c1fff3639409230c1b7f56c851abaeb
66 66 063 a1471b8f79dd273af634ceb28b253a4ac7dc4e973b1172a25f348f3f81f927bcf4f5f5aaf30cd89d2d6f216ae1ccfdbf
67 67 064 00f60ab309a763fe5b417fd0fddae1b3f4c83a3e99812fa8110cfb34de0e7f908b34dd405e74e09b75085047c0a344b5
68 + 065 734a797c90c0447e413fb4fde8a19d26a52752adbea385ca30f2b2369a281bc4275e57d088a8fe73c6d5adef816a195e
@@ -103,6 +103,7 @@
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,6 +255,25 @@
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,6 +744,7 @@
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,6 +71,7 @@
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,6 +88,7 @@
87 88 monthly_goals,
88 89 monthly_reflections,
89 90 problems,
91 + dependencies,
90 92 ))
91 93 }
92 94
@@ -165,6 +167,13 @@
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