summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-09-02 23:47:56 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-09-02 23:47:56 +0000
commit3e26cd5ad7a0cbd1445f535ccd63c2e79d3580a4 (patch)
tree41b6dd8b79f4d078cda1c91570af2f9240ccdce4
parent74bf58335d3542d88b32d447b47342a76f5e5449 (diff)
Hide paused chains' tasks from the task list
Pausing a chain is supposed to take its work off the list -- that's the entire point of the control. But the three task-fetching queries only ever checked `chain_unlocked`, never the owning chain's status, so a paused chain's head task kept appearing exactly like an active one. Verified against production: 4 of the 8 visible chain tasks came from chains that had been explicitly paused ("Will", "ABYC Marine Electrical certification", "Standard services agreement", "Find a broker who writes marine trades"). Half the chain clutter on the list was work the user had already told doot to put away. Fixed the class, not just the instances: the predicate now lives in one shared visibleChainTaskClause constant used by all three queries, with a structural test asserting no query spells it out inline and that every query still references it. The previous shape -- the same predicate copy-pasted into three separate query strings -- is exactly why a fourth query would have gotten it wrong too. Also added task_chains to sqlite_test.go's leaner test schema, since the queries now join against it even for chain-less tasks. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
-rw-r--r--internal/store/chains_test.go107
-rw-r--r--internal/store/native_tasks.go26
-rw-r--r--internal/store/sqlite_test.go9
3 files changed, 136 insertions, 6 deletions
diff --git a/internal/store/chains_test.go b/internal/store/chains_test.go
index bcfc09e..d21490c 100644
--- a/internal/store/chains_test.go
+++ b/internal/store/chains_test.go
@@ -1,8 +1,12 @@
package store
import (
+ "os"
+ "strings"
"testing"
+ "time"
+ "task-dashboard/internal/config"
"task-dashboard/internal/models"
)
@@ -377,3 +381,106 @@ func TestDeleteNativeTask_LastRemainingChainTask_MarksChainCompleted(t *testing.
t.Errorf("chain.Status = %q, want completed", updatedChain.Status)
}
}
+
+// Pausing a chain is meant to take its work off the list entirely -- the whole
+// point of the control. But the task-fetching queries only ever checked
+// `chain_unlocked`, never the owning chain's status, so a paused chain's head
+// task kept showing up exactly like an active one. Found live: 4 of the 8 chain
+// tasks on the real dashboard came from chains that had been explicitly paused.
+func TestPausedChain_HeadTaskHiddenFromDateRange(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ chain, err := s.CreateChain("Paused Track", chainTasks("Step 1", "Step 2"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SetChainStatus(chain.ID, "paused"); err != nil {
+ t.Fatal(err)
+ }
+
+ now := config.Now()
+ tasks, err := s.GetNativeTasksByDateRange(now.Add(-24*time.Hour), now.Add(24*time.Hour))
+ if err != nil {
+ t.Fatalf("GetNativeTasksByDateRange: %v", err)
+ }
+ for _, task := range tasks {
+ if task.ChainID == chain.ID {
+ t.Errorf("paused chain's task %q leaked into GetNativeTasksByDateRange", task.Content)
+ }
+ }
+}
+
+func TestPausedChain_HeadTaskHiddenFromOverdue(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ chain, err := s.CreateChain("Paused Track", chainTasks("Step 1", "Step 2"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SetChainStatus(chain.ID, "paused"); err != nil {
+ t.Fatal(err)
+ }
+
+ tasks, err := s.GetOverdueNativeTasks(config.Now().Add(24 * time.Hour))
+ if err != nil {
+ t.Fatalf("GetOverdueNativeTasks: %v", err)
+ }
+ for _, task := range tasks {
+ if task.ChainID == chain.ID {
+ t.Errorf("paused chain's task %q leaked into GetOverdueNativeTasks", task.Content)
+ }
+ }
+}
+
+func TestPausedChain_HeadTaskHiddenFromUndated(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ chain, err := s.CreateChain("Paused Track", chainTasks("Step 1", "Step 2"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ // CreateChain seeds the head with a due date; clear it so this task is
+ // actually a candidate for the undated query rather than passing vacuously.
+ if _, err := s.db.Exec(`UPDATE native_tasks SET due_date = NULL WHERE chain_id = ?`, chain.ID); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SetChainStatus(chain.ID, "paused"); err != nil {
+ t.Fatal(err)
+ }
+
+ tasks, err := s.GetUndatedNativeTasks()
+ if err != nil {
+ t.Fatalf("GetUndatedNativeTasks: %v", err)
+ }
+ for _, task := range tasks {
+ if task.ChainID == chain.ID {
+ t.Errorf("paused chain's task %q leaked into GetUndatedNativeTasks", task.Content)
+ }
+ }
+}
+
+// Structural guard for the whole class, not just the three queries fixed above:
+// any future task query that filters on chain_unlocked must also constrain the
+// owning chain's status, or it reintroduces exactly the leak these tests cover.
+// Checked by source inspection because the failure mode is "someone wrote a
+// fourth query and copied the old predicate," which no single behavioral test
+// would catch.
+func TestChainVisibilityPredicate_UsedEverywhereChainUnlockedIsFiltered(t *testing.T) {
+ src, err := os.ReadFile("native_tasks.go")
+ if err != nil {
+ t.Fatal(err)
+ }
+ text := string(src)
+
+ // The copy-paste hazard is specifically the inline "chain_id = '' OR ..."
+ // predicate. It should appear exactly once: inside the shared constant's own
+ // definition. (A bare `chain_unlocked = 1` is fine elsewhere -- advanceChain
+ // legitimately SETs it.)
+ if inline := strings.Count(text, "chain_id = '' OR"); inline != 1 {
+ t.Errorf("found the inline chain-visibility predicate %d time(s) in native_tasks.go, want exactly 1 (the visibleChainTaskClause definition); a query spelling it out inline will silently skip the chain-status check", inline)
+ }
+ // Const declaration plus one reference per task query that hides chain work.
+ if refs := strings.Count(text, "visibleChainTaskClause"); refs < 4 {
+ t.Errorf("visibleChainTaskClause referenced %d time(s), want >= 4 (declaration + the task queries); a query that stopped using it has drifted off the shared predicate", refs)
+ }
+}
diff --git a/internal/store/native_tasks.go b/internal/store/native_tasks.go
index 9d9af2c..ea5212c 100644
--- a/internal/store/native_tasks.go
+++ b/internal/store/native_tasks.go
@@ -21,6 +21,20 @@ import (
// completion: the HTTP response was 200, but nothing in the database changed.
var ErrNativeTaskNotFound = errors.New("native task not found")
+// visibleChainTaskClause is the single source of truth for "which chain tasks
+// belong on the list." Every task query that hides locked chain work must use
+// this rather than spelling the predicate out inline -- see
+// TestChainVisibilityPredicate_UsedEverywhereChainUnlockedIsFiltered.
+//
+// The chain's own status matters as much as the task's lock state: pausing a
+// chain is meant to take its work off the list entirely, but for a long time
+// these queries checked only chain_unlocked, so a paused chain's head task kept
+// appearing exactly like an active one -- the pause control did nothing to the
+// list it was supposed to affect.
+const visibleChainTaskClause = `(t.chain_id = '' OR (t.chain_unlocked = 1 AND EXISTS (
+ SELECT 1 FROM task_chains c WHERE c.id = t.chain_id AND c.status = 'active'
+ )))`
+
// GetNativeTasks returns all non-completed native tasks.
func (s *Store) GetNativeTasks() ([]models.Task, error) {
rows, err := s.db.Query(`
@@ -49,9 +63,9 @@ func (s *Store) GetNativeTasksByDateRange(start, end time.Time) ([]models.Task,
recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes,
chain_id, chain_position, chain_unlocked,
bucket_id, bucket_state, bucket_last_active_at
- FROM native_tasks
+ FROM native_tasks t
WHERE completed = 0 AND due_date IS NOT NULL AND due_date >= ? AND due_date < ?
- AND (chain_id = '' OR chain_unlocked = 1)
+ AND `+visibleChainTaskClause+`
ORDER BY due_date ASC, priority DESC
`, start, end)
if err != nil {
@@ -73,9 +87,9 @@ func (s *Store) GetOverdueNativeTasks(before time.Time) ([]models.Task, error) {
recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes,
chain_id, chain_position, chain_unlocked,
bucket_id, bucket_state, bucket_last_active_at
- FROM native_tasks
+ FROM native_tasks t
WHERE completed = 0 AND due_date IS NOT NULL AND due_date < ?
- AND (chain_id = '' OR chain_unlocked = 1)
+ AND `+visibleChainTaskClause+`
ORDER BY due_date ASC, priority DESC
`, before)
if err != nil {
@@ -92,9 +106,9 @@ func (s *Store) GetUndatedNativeTasks() ([]models.Task, error) {
recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes,
chain_id, chain_position, chain_unlocked,
bucket_id, bucket_state, bucket_last_active_at
- FROM native_tasks
+ FROM native_tasks t
WHERE completed = 0 AND due_date IS NULL
- AND (chain_id = '' OR chain_unlocked = 1)
+ AND `+visibleChainTaskClause+`
AND bucket_state != 'dormant'
ORDER BY priority DESC, created_at ASC
`)
diff --git a/internal/store/sqlite_test.go b/internal/store/sqlite_test.go
index c64e6b3..95996b3 100644
--- a/internal/store/sqlite_test.go
+++ b/internal/store/sqlite_test.go
@@ -193,6 +193,15 @@ func setupTestStoreWithNativeTasks(t *testing.T) *Store {
bucket_state TEXT DEFAULT '',
bucket_last_active_at DATETIME
);
+ -- The task queries join against this to check the owning chain's status
+ -- (see visibleChainTaskClause), so it has to exist even for tests that
+ -- only ever create plain, chain-less tasks.
+ CREATE TABLE IF NOT EXISTS task_chains (
+ id TEXT PRIMARY KEY,
+ project_id TEXT DEFAULT '',
+ status TEXT DEFAULT 'active',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
`
if _, err := db.Exec(schema); err != nil {
t.Fatalf("Failed to create schema: %v", err)