board-duckdb-migration-cooldown-loop
Root cause (tick #78): fleet-cooldown-policy.py parsed tasks.md directly and treated any unchecked/in-progress checkbox line as a real pending task — so the in_progress matrix row BOARD-V2 plus the E2E-001 checkbox fixture added 4 fake "pending" tasks on every run, and the policy rewrote fleet.toml with cooldown_seconds = 900 each time (16 reversions 7200 -> 900).
Fix in three parts:
migrate-board-to-duckdb.py (the migration is BOARD-V2)Running the migration closes the open task and produces the parquet source of truth. The parser drops checkbox rows (E2E-001...) from the parquet; the display board keeps them because the fixture renderer auto-inserts them back:
def parse_board(text):
tasks, checkboxes = [], []
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#") or line.startswith("|"):
continue
m = CHECKBOX_RE.match(raw) # ^\s*[-*]\s*\[([ xX~])\]
if not m:
continue
tm = TASK_RE.match(raw[m.end():].strip())
status = {" ": "pending", "~": "in_progress", "x": "done"}[m.group(1)]
row = dict(task_id=tm.group(1), title=tm.group(2).strip(), status=status,
kind=("checkbox" if raw.startswith((" ", "\t")) else "task"),
updated_at=now_iso())
(checkboxes if row["kind"] == "checkbox" else tasks).append(row)
return tasks, checkboxes
def migrate(board_file="tasks.md", parquet_file="tasks.parquet", backup_file="tasks.md.bak"):
original = open(board_file).read()
tasks, checkboxes = parse_board(original)
for t in tasks: # executing the open task
if t["task_id"] == OPEN_TASK_ID and t["status"] != "done":
t["status"] = "done" # BOARD-V2 -> done
write_tasks_parquet(tasks, parquet_file) # checkbox rows NOT written
append_event("migrate", task_id=OPEN_TASK_ID,
detail=f"parquet_tasks={len(tasks)} checkbox_dropped={len(checkboxes)}")
open(backup_file, "w").write(original) # tasks.md.bak (tracked)
open(board_file, "w").write(render_board(tasks, checkboxes)) # fixtures re-insert E2E-001...
render_board re-inserts fixture checkbox rows under their matrix task:
for cid, ctitle in FIXTURE_CHECKBOXES.get(t["task_id"], []):
if cid not in known:
lines.append(f" - [ ] {cid} {ctitle}") # auto-insert E2E-001/002/003
tasks.parquet, not the markdown — fleet-cooldown-policy.pyOnly kind == 'task' AND status == 'pending' counts as real pending. in_progress (BOARD-V2) is excluded by the status filter; checkbox rows don't exist in the parquet at all. Idempotent + atomic writes make a reversion loop impossible:
def real_pending_count(rows):
return sum(1 for r in rows if r["status"] == "pending" and r["kind"] == "task")
def compute_target(real_pending):
return 7200 if real_pending == 0 else 900 # 0 real pending -> 7200
def run_policy(parquet_file="tasks.parquet", fleet_file="fleet.toml", apply=True):
rows = read_tasks_parquet(parquet_file) # fails loudly if missing
pending = real_pending_count(rows)
target = compute_target(pending)
current = read_fleet_cooldown(fleet_file)
changed = current != target
if apply and changed: # atomic tmpfile + os.replace
write_fleet(fleet_file, target, pending)
append_event("policy.rewrite", detail=f"cooldown {current} -> {target}, real_pending={pending}")
elif apply:
append_event("policy.noop", detail=f"cooldown already {target}, real_pending={pending}")
return target, pending, changed
get-h3 runs the migration, then tracks schema.sql + tasks.parquet + events.parquet + tasks.md.bak and idempotently gitignores board.db and *.jsonl (both are rebuildable/derived and must never be tracked):
for f in schema.sql tasks.parquet events.parquet tasks.md.bak; do git add -f "$f"; done
for pat in "board.db" "*.jsonl"; do
grep -qxF "$pat" .gitignore || echo "$pat" >> .gitignore
done
Verified with DuckDB 1.5.5 (venv), Python 3.14, git 2.53. Full suite: **11/11 tests pass** (`unittest discover`), plus CLI end-to-end runs.
**E2E demo (the exact 16-reversion scenario):**
```
=== BEFORE (buggy: policy parsed tasks.md) ===
fake real_pending (buggy) = 4 -> cooldown target 900
rows counted: ['BOARD-V2', 'E2E-001', 'E2E-002', 'E2E-003'] # the bug
=== AFTER (fix): 17 policy runs ===
run 1: real_pending=0 target=7200 changed=True
runs 2-17: real_pending=0 target=7200 changed=False # 1 rewrite + 16 NOOPs
events audit: Counter({'policy.noop': 16, 'migrate': 1, 'policy.rewrite': 1})
fleet.toml: cooldown_seconds = 7200, pending_tasks = 0
```
The previously "16 cooldown reversions" are now 16 idempotent noops — `7200` sticks.
**Edge cases tested:**
- `in_progress` matrix row (`BOARD-V2`) is **not** counted → 7200 (the core regression).
- Checkbox rows (`E2E-001/002/003`) never reach `tasks.parquet`; fixtures auto-insert them back into `tasks.md` after migration; `BOARD-V2` flips `in_progress -> done`.
- One genuine `pending` task → target 900.
- Missing `tasks.parquet` → policy raises `FileNotFoundError` instead of silently writing 900.
- Idempotency: unchanged cooldown → `fleet.toml` mtime untouched, `policy.noop` logged.
- `tasks.md.bak` preserves the pre-migration board (`- [~] BOARD-V2 ...` intact).
- get-h3 in a fresh repo: tracked = `schema.sql, tasks.parquet, events.parquet, tasks.md.bak, .gitignore`; ignored = `board.db, *.jsonl`; `tasks.md` correctly untracked.
- Empty parquet write/read (0 rows) and events append across 18 audit records work.
---{"model": "deepseek-v4-flash", "problem_class": "board-duckdb-migration-cooldown-loop", "result": "passed", "tests": 11}