Skip to content

fix(db): make task_action_counts trigger follow action task_id changes - #416

Merged
MH4GF merged 3 commits into
mainfrom
worktree-fix-task-action-counts-trigger
Jun 12, 2026
Merged

fix(db): make task_action_counts trigger follow action task_id changes#416
MH4GF merged 3 commits into
mainfrom
worktree-fix-task-action-counts-trigger

Conversation

@MH4GF

@MH4GF MH4GF commented Jun 12, 2026

Copy link
Copy Markdown
Owner

Summary

task_action_counts 維持トリガー trg_actions_count_update が action の task_id 付け替えに追従していなかった問題を修正する。task #914 / action #6262。

バグの実害

2026-06-10、pending action の task 796 → 797 移動の痕跡が Turso 本番 DB に残留した。
残留したのは task 796 の幽霊カウント (pending +1) と task 797 の負カウント (pending -1) の 2 行。
これにより task #796 の close が「1 pending/running/dispatched action(s)」エラーで阻害された (実 actions は 0 件)。
原因は旧トリガーの定義 AFTER UPDATE OF status ... WHEN OLD.status != NEW.status にある。
この定義は tq action update --task の発行する UPDATE actions SET task_id = ? では発火しない。

Changes

  • db/schema.sql — トリガーを AFTER UPDATE OF task_id, status + WHEN OLD.task_id != NEW.task_id OR OLD.status != NEW.status へ拡張。body は元から (OLD.task_id, OLD.status) decrement → (NEW.task_id, NEW.status) upsert increment なので変更不要
  • db/db.go — schema.sql は CREATE TRIGGER IF NOT EXISTS のため、既存 DB に新定義が入らない。Migrate() は WHEN 句マーカー OLD.task_id != NEW.task_id を欠く旧定義を sqlite_master から検出して DROP TRIGGER IF EXISTS する。直後の schema 実行が新定義を再作成する。旧定義の居た DB は過去ドリフトの可能性があるため、task_action_counts を actions から 1 トランザクションで全再構築する (rebuildTaskActionCounts)
  • docs/golden-rules.md — Rule 18 の「task_id is immutable」前提を削除し、トリガー列挙とマイグレーション修復経路を現状へ更新
  • .goldenrules-rule17-allowlist — db.go の行ずれ更新 + マイグレーション時スキャン 3 件 (sqlite_master 参照、一度きりの rebuild) を追加
  • テスト 3 件追加 (db/task_action_counts_test.go)

設計判断

  • マーカーは OLD.task_id 単体だと旧定義 body (WHERE task_id = OLD.task_id) にも含まれ誤判定するため、WHEN 句固有の比較式にしている。schema.sql の将来の再フォーマットでマーカーが壊れた場合は TestTaskActionCounts_MigrateRecreatesStaleTrigger の DDL アサーションが CI で落ちて検知される
  • DROP 後〜rebuild 完了前に Migrate が中断すると、次回実行は新トリガーを見て rebuild をスキップする。この窓はミリ秒オーダーかつ DB ごとに一度きりのため許容する (計画時にユーザー確認済み)
  • 常設のドリフト修復コマンドは見送り。マイグレーション時 rebuild が旧トリガーを持つ全 DB の既存ドリフトを一度で修復するため

Verification

ユニットテスト + lint:

go test ./...        # 全 green (新規: TestTaskActionCounts_TaskIDChange / TaskReassignReleasesCloseGuard / MigrateRecreatesStaleTrigger)
golangci-lint run    # 0 issues
./scripts/deadcode-check.sh  # OK

E2E (fresh build バイナリ + 一時 DB で本番事故シナリオを再現):

go build -o .claude/tmp/tq-e2e .
./.claude/tmp/tq-e2e --db .claude/tmp/e2e.db project create e2e /tmp
./.claude/tmp/tq-e2e --db .claude/tmp/e2e.db task create "task A" --project 1
./.claude/tmp/tq-e2e --db .claude/tmp/e2e.db task create "task B" --project 1
./.claude/tmp/tq-e2e --db .claude/tmp/e2e.db action create "noop" --task 1 --title "noop"
./.claude/tmp/tq-e2e --db .claude/tmp/e2e.db action update 1 --task 2
sqlite3 .claude/tmp/e2e.db "SELECT task_id, status, count FROM task_action_counts ORDER BY task_id"
# → 1|pending|0 / 2|pending|1 (カウンタが移動)
./.claude/tmp/tq-e2e --db .claude/tmp/e2e.db task update 1 --status done --note "e2e"
# → 成功 (修正前は幽霊カウントで拒否されていた)
./.claude/tmp/tq-e2e --db .claude/tmp/e2e.db task update 2 --status done --note "e2e"
# → close ガードで正しく拒否

マイグレーション経路の E2E も実施した。
旧トリガーを sqlite3 で仕込んだ DB に UPDATE actions SET task_id = 2 を発行し、ドリフトを再現した。
その後、新バイナリで task list を 1 回実行した。
結果、sqlite_master のトリガーは新定義となった。
counts は 2|pending|1 のみへ再構築され、task 1 の close も成功した。

デプロイ後の確認

本番 Turso DB は旧トリガーを持つため、新バイナリの初回 Migrate で再作成 + rebuild が一度走る。適用後に次で確認する:

turso db shell tq "SELECT sql FROM sqlite_master WHERE name = 'trg_actions_count_update'"
# OLD.task_id != NEW.task_id を含むこと

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Fixed task count tracking when actions are reassigned between tasks.
    • Database migration now automatically rebuilds counts for databases with outdated trigger schemas.
  • Documentation

    • Updated documentation to describe task reassignment behavior and automatic correction during database migration.
  • Tests

    • Added comprehensive test coverage for task reassignment scenarios and migration behavior validation.

MH4GF and others added 2 commits June 12, 2026 10:19
trg_actions_count_update fired only on status changes, so reassigning an
action to another task (`tq action update --task` issues
`UPDATE actions SET task_id = ?`) left a ghost count on the old task and
no count on the new one. The stale ghost row then blocked closing the old
task via the UpdateTaskFields close guard, which reads
GetTaskActionCount, even though the task had zero live actions.

- Extend the trigger to `AFTER UPDATE OF task_id, status` with
  `WHEN OLD.task_id != NEW.task_id OR OLD.status != NEW.status`; the body
  already decrements (OLD.task_id, OLD.status) and upserts
  (NEW.task_id, NEW.status), so it handles task moves as-is.
- schema.sql uses CREATE TRIGGER IF NOT EXISTS and cannot replace the old
  definition on existing DBs. Migrate() now drops the stale trigger before
  the schema exec recreates it. Staleness is detected by the WHEN-clause
  marker `OLD.task_id != NEW.task_id` in sqlite_master; the bare substring
  `OLD.task_id` would misdetect because the old trigger body also contains
  it in its UPDATE ... WHERE clause.
- A DB that carried the old trigger may hold drift it leaked, so the same
  migration rebuilds task_action_counts from actions in one transaction.
  The known production incident (ghost pending count after moving an
  action between tasks, blocking task close) heals on first run of the
  new binary. The crash window between DROP and rebuild is accepted: it
  is milliseconds long and at most once per DB.
- Update golden-rules Rule 18 notes: drop the "task_id is immutable"
  assumption (a production path does reassign it) and document the
  migration/rebuild path. Refresh rule-17 allowlist line numbers and add
  the three new migration-time scans (sqlite_master lookup and the
  one-shot rebuild).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Use DROP TRIGGER IF EXISTS in dropStaleCountUpdateTrigger. Two tq
  processes migrating the same DB can both read the stale definition
  from sqlite_master; without IF EXISTS the slower one fails Migrate
  with "no such trigger". With IF EXISTS every interleaving converges
  on the new trigger plus a rebuilt counts table.
- Extract the INSERT ... SELECT ... GROUP BY recount statement shared
  by rebuildTaskActionCounts and backfillTaskActionCounts into
  recountTaskActionCountsSQL so the two paths cannot drift.
- Refresh rule-17 allowlist line numbers for the shifted db.go lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@MH4GF, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 52 minutes and 5 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more credits in the billing tab to continue.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7dcdb269-e381-4432-a417-4477723d9b97

📥 Commits

Reviewing files that changed from the base of the PR and between 049739f and 8afde13.

📒 Files selected for processing (2)
  • .goldenrules-rule17-allowlist
  • db/db.go
📝 Walkthrough

Walkthrough

This PR extends task action count tracking to account for task_id reassignment. The trigger now fires on both task_id and status changes, the migration detects old trigger definitions and rebuilds counts, and comprehensive tests validate the new behavior and recovery path.

Changes

Task ID Reassignment Trigger and Migration Recovery

Layer / File(s) Summary
Trigger schema expansion for task_id tracking
db/schema.sql
The trg_actions_count_update trigger's AFTER UPDATE OF clause now includes task_id alongside status, and the WHEN condition checks for changes in either column, ensuring count rows move between tasks on reassignment.
Stale trigger detection and drop helper
db/db.go
Added errors import and dropStaleCountUpdateTrigger() function that inspects sqlite_master to detect old triggers lacking task_id coverage. Introduced rebuildTaskActionCounts() and recountTaskActionCountsSQL to delete and rebuild consistent counts after dropping a stale trigger.
Migration orchestration — detect and conditionally rebuild
db/db.go
(*DB).Migrate() calls dropStaleCountUpdateTrigger() early and conditionally invokes rebuildTaskActionCounts() only if a stale trigger was removed, repairing ghost counts from pre-migration task reassignments.
Backfill function update to shared recount logic
db/db.go
backfillTaskActionCounts() now executes recountTaskActionCountsSQL directly inside a transaction, removing the prior existence-check logic and ensuring consistent counts from initialization.
Comprehensive test coverage for task_id changes and migration
db/task_action_counts_test.go
Added TestTaskActionCounts_TaskIDChange to validate trigger-maintained counts move on task_id updates, TestTaskActionCounts_TaskReassignReleasesCloseGuard to verify close-guard behavior after reassignment, and TestTaskActionCounts_MigrateRecreatesStaleTrigger to simulate the old trigger with preTaskIDTriggerDDL, confirm it creates ghost counts, and verify migration rebuilds and repairs them.
Documentation and CI allowlist updates
docs/golden-rules.md, .goldenrules-rule17-allowlist
Updated Rule 18 documentation to describe task_id reassignment behavior and the migration recovery path; updated Rule 17 SCAN allowlist to match new query plans from detection and rebuild queries.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • MH4GF/tq#276: Introduced Rule 17 golden-rule enforcement for EXPLAIN QUERY PLAN scan detection; this PR updates the allowlist to match the new query plans introduced by the migration logic.
  • MH4GF/tq#296: Both PRs maintain the .goldenrules-rule17-allowlist file, with this PR remapping allowed SCAN entries while the prior PR annotated specific entries with explanatory text.

Poem

A rabbit hops through task_id's flow,
Where counts now track both high and low,
The trigger springs when tasks migrate,
Old ghosts begone—the counts are straight! 🐰✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main fix: extending the task_action_counts trigger to respond to action task_id changes, which aligns with all file changes and PR objectives.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-fix-task-action-counts-trigger

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
db/db.go (1)

442-451: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make the backfill probe and recount atomic.

Lines 443-450 split the emptiness check and the INSERT ... SELECT into separate autocommit statements. A concurrent action insert can repopulate task_action_counts between those calls, and the plain recount insert then collides with the (task_id, status) PK and aborts Migrate(). Wrap the probe + recount in withTxRetry (or make the recount an upsert) so live migrations cannot fail on concurrent writes.

Suggested fix
 func (db *DB) backfillTaskActionCounts() error {
-	var hasRows int
-	if err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM task_action_counts LIMIT 1)").Scan(&hasRows); err != nil {
-		return fmt.Errorf("check rows: %w", err)
-	}
-	if hasRows != 0 {
-		return nil
-	}
-	if _, err := db.Exec(recountTaskActionCountsSQL); err != nil {
-		return fmt.Errorf("insert: %w", err)
-	}
-	return nil
+	ctx := context.Background()
+	return db.withTxRetry(ctx, "backfillTaskActionCounts", func(tx *sql.Tx) error {
+		var hasRows int
+		if err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM task_action_counts LIMIT 1)").Scan(&hasRows); err != nil {
+			return fmt.Errorf("check rows: %w", err)
+		}
+		if hasRows != 0 {
+			return nil
+		}
+		if _, err := tx.ExecContext(ctx, recountTaskActionCountsSQL); err != nil {
+			return fmt.Errorf("insert: %w", err)
+		}
+		return nil
+	})
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@db/db.go` around lines 442 - 451, The probe-and-recount in
backfillTaskActionCounts is not atomic and can race with concurrent inserts;
wrap the emptiness check and the INSERT (recountTaskActionCountsSQL) inside a
transactional retry using withTxRetry so both the SELECT EXISTS and Exec run in
the same transaction (use the tx passed into the closure and call
tx.QueryRow/tx.Exec) or alternatively convert recountTaskActionCountsSQL into an
upsert; modify backfillTaskActionCounts to call withTxRetry and perform the
probe + insert inside that closure, referencing backfillTaskActionCounts,
withTxRetry, and recountTaskActionCountsSQL.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@db/db.go`:
- Around line 442-451: The probe-and-recount in backfillTaskActionCounts is not
atomic and can race with concurrent inserts; wrap the emptiness check and the
INSERT (recountTaskActionCountsSQL) inside a transactional retry using
withTxRetry so both the SELECT EXISTS and Exec run in the same transaction (use
the tx passed into the closure and call tx.QueryRow/tx.Exec) or alternatively
convert recountTaskActionCountsSQL into an upsert; modify
backfillTaskActionCounts to call withTxRetry and perform the probe + insert
inside that closure, referencing backfillTaskActionCounts, withTxRetry, and
recountTaskActionCountsSQL.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d91aba46-4f79-46e8-8129-a96b5cee7ce3

📥 Commits

Reviewing files that changed from the base of the PR and between b4b04b1 and 049739f.

📒 Files selected for processing (5)
  • .goldenrules-rule17-allowlist
  • db/db.go
  • db/schema.sql
  • db/task_action_counts_test.go
  • docs/golden-rules.md

backfillTaskActionCounts checked emptiness and ran the recount INSERT
as separate autocommit statements. A concurrent action insert between
them repopulates task_action_counts via the insert trigger, and the
recount then collides with the (task_id, status) primary key, aborting
Migrate. Wrap both statements in withTxRetry so the probe and recount
are atomic, matching rebuildTaskActionCounts.

Refresh rule-17 allowlist line numbers for the shifted db.go lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@MH4GF

MH4GF commented Jun 12, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai The backfill probe + recount race is fixed in 8afde13 — both statements now run inside withTxRetry, matching rebuildTaskActionCounts.

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@MH4GF Thanks for the confirmation — wrapping both statements in withTxRetry eliminates the TOCTOU window between the existence check and the recount, and keeps the backfill path consistent with rebuildTaskActionCounts. The fix looks good! 🎉


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

@MH4GF
MH4GF merged commit 1a1ff6b into main Jun 12, 2026
4 checks passed
@MH4GF
MH4GF deleted the worktree-fix-task-action-counts-trigger branch June 12, 2026 04:48
@MH4GF MH4GF mentioned this pull request Jun 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant