[6/7] Record task status transitions - #277
Open
alex-clickhouse wants to merge 2 commits into
Open
Conversation
alex-clickhouse
force-pushed
the
alex-clickhouse/task-events
branch
from
August 5, 2026 10:36
bf14292 to
6f94393
Compare
There was a problem hiding this comment.
Pull request overview
Adds first-class, append-only task status transition history to Nerve (task_events) and surfaces it through the API and UI to support “card aging” on the board and a per-task status timeline.
Changes:
- Introduces
task_events(migration v044) and records transitions transactionally fromupsert_task,update_task_status, andmove_task, including anactor. - Adds backend APIs to fetch task event history and to return
status_sincein the board payload for aging badges. - Adds frontend UI for a task status history panel and aging indicators on board cards, plus client/store wiring.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| web/src/stores/taskStore.ts | Stores/loads statusSince from board API response. |
| web/src/stores/taskStore.test.ts | Updates board API mocks to include status_since. |
| web/src/components/Tasks/TaskTimeline.tsx | New timeline component fetching and rendering task status events + dwell time. |
| web/src/components/Tasks/TaskDetailBody.tsx | Adds toggleable “status history” panel in task detail UI. |
| web/src/components/Tasks/Board/TaskBoard.tsx | Plumbs statusSince from store into board columns. |
| web/src/components/Tasks/Board/BoardColumn.tsx | Passes per-task statusSince down to cards. |
| web/src/components/Tasks/Board/BoardCard.tsx | Adds aging badge based on statusSince thresholds. |
| web/src/api/client.ts | Adds TaskEvent type, listTaskEvents(), and status_since typing for board response. |
| tests/test_task_events.py | New unit tests covering transition recording + no-op suppression + entry-time computation. |
| tests/test_task_board_api.py | Adds HTTP-level tests for /events and status_since presence on board. |
| nerve/gateway/routes/tasks.py | Adds /api/tasks/{id}/events and includes status_since in board response; sets web actor. |
| nerve/db/tasks.py | Records status events and computes per-task status entry timestamps for aging. |
| nerve/db/migrations/v044_task_events.py | Creates task_events table + indexes and seeds origin events for existing tasks. |
| nerve/agent/tools/handlers/tasks.py | Propagates actor/session id into DB status changes and upserts. |
| docs/tasks.md | Documents the new status history table and its recording rules. |
| docs/api.md | Documents new status_since board field and GET /api/tasks/{id}/events. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
alex-clickhouse
force-pushed
the
alex-clickhouse/task-events
branch
from
August 5, 2026 10:50
6f94393 to
b5ac16a
Compare
3 tasks
Nerve kept no history of status changes. tasks holds only the current value, memu_audit_log is memU-only, and the markdown ## Updates list only gains a line when a note is passed or on done/reopen — so a plain status flip left no trace at all, at day granularity and with no actor. Tolerable while status changes were rare and deliberate. The board makes them a drag, so they're frequent, and /move routes through exactly the note-less path: every drag was an untracked mutation. A board is also what prompts the questions this answers — how long has this been in progress, which cards are aging, how often does work bounce back out. v044 adds task_events (task_id, from_status, to_status, actor, created_at), seeded with one origin row per existing task. from_status is NULL for a creation, so aging can tell "created here" from "moved here". Recording happens in the same transaction as the status write, from all three paths that can change one: update_task_status, move_task, and the full-row upsert_task — which is how task_update flips status when it also writes a note, and would otherwise have been a silent gap. A no-op transition records nothing, and that one rule is what keeps the table meaningful. reindex() rewrites every row on startup through that same upsert, so without it the history would double on each run; likewise a pure reorder within a lane, which is the most frequent board interaction there is. The case where reindex *does* change a status — resetting an orphaned row to match its directory — is a real correction and gets a row. actor is the session id for agent-driven changes and "web" for the HTTP API, so a person dragging a card is distinguishable from the agent moving it. The task routes now build their tool context with that name rather than the generic "system" sentinel. On the UI: an aging badge on board cards after three days in a status, and a history panel in the task detail view showing each transition with its dwell time. Tasks whose last change predates this table report no entry time rather than a guessed one — the indicator stays silent instead of showing an age that isn't true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three review findings on task_events, and one the review implied. The aging badge only ever learned an entry time from a full board load, so after a drag or an inbound broadcast the lanes moved and statusSince did not. Dragging a five-day-old card into In Progress left it showing "5d" — the card you just picked up rendered as the most stalled thing on the board — and since nothing polls, it stayed wrong until a reload. Both paths now reset the entry time when the status actually changed, from the server's own updated_at. A reorder within a lane records no transition, so it deliberately does not reset; and a card the board has never seen still gets no entry, because an absent one keeps the indicator quiet rather than inventing an age. The docs listed the actors as session id, web and system, omitting the backfill that v044 seeds. The seed is right and the docs were wrong: a real creation also writes a NULL from_status, so the actor is the only thing telling a synthesized origin from one that happened. The migration's own comment claimed the opposite — that NULL from_status was the marker — which is the reading that would actually cause a bug, so it is corrected too, and a test now pins the distinction. The status-history toggle is icon-only and had no accessible name; title is not reliably announced. Its neighbour in TaskDetailModal already carries both. Not changed: get_status_entry_times pairing to_status with MAX(created_at). SQLite documents bare columns as coming from the extremal row when there is exactly one min/max aggregate, which is the case here; verified empirically at 0/200 mismatches against a 200/200 control with the aggregate removed. Ties are the only real exposure and are unreachable — created_at is written at microsecond precision, and post-migration each task has exactly one row. Also corrected the v044 docstring's claim that the timestamps are fixed width. isoformat() drops the fractional part on an exact second, so they are not; the ordering conclusion still holds, but for a different reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alex-clickhouse
force-pushed
the
alex-clickhouse/task-events
branch
from
August 5, 2026 12:07
b5ac16a to
a1db02c
Compare
alex-clickhouse
marked this pull request as ready for review
August 5, 2026 12:27
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The gap
Nerve kept no history of status changes.
tasksholds only the current value,memu_audit_logis memU-only,session_eventsis sessions. The markdown## Updateslist was the closest thing, and it only gains a line when a note is passed or on done/reopen:task_update(note="…")- 2026-08-05: <note>task_done/ reopenSo
pending → in_progress → deferred → in_progressleft no evidence it happened. Even the lines that were written are day-granularity with no actor.Tolerable while status changes were rare and deliberate. The board makes them a drag — and
/moveroutes through exactly that note-less path, so every drag was an untracked mutation. A board is also what prompts the questions this answers: how long has this been in progress, which cards are aging, how often does work bounce back out of review.What's here
task_events(task_id,from_status,to_status,actor,created_at), seeded with one origin row per existing task.from_statusis NULL for a creation, so aging can distinguish "created here" from "moved here".Recording happens in the same transaction as the status write, from all three paths that can change one:
update_task_statusmove_taskupsert_task— which is howtask_updateflips status when it also writes a note, and would otherwise have been a silent gapThe rule that makes it work
A no-op transition records nothing. That single check is what keeps the table meaningful rather than merely large:
reindex()rewrites every row on startup through that same upsert, with the status it already had. Without the check, history would double on every run.The one case where reindex does change a status — resetting an orphaned row to match its directory — is a genuine correction and gets a row. Both directions are pinned in
test_task_events.py.Actor
Session id for agent-driven changes,
webfor the HTTP API. The task routes now build their tool context with that name instead of the generic"system"sentinel —build_route_tool_contextalready took the parameter; nothing else keys off the old value.UI
Tasks whose last change predates this table report no entry time rather than a guessed one, so the indicator stays silent instead of showing an age that isn't true.
Testing
pytest tests/ -q— 3095 passed (22 new)npm test— 46 passed ·tsc -bclean ·npm run buildcleannpx eslint .— 148 vs 152 onmainOpen questions from the task write-up, deliberately unanswered here
db/retention.pyif this grows, though at a few rows per task per week it's a long way off.## Updateslines — not attempted. The dates are day-granularity and the format is loose; a fabricated timeline that looks precise seemed worse than starting clean.field,old,new) and is a bigger conversation.🤖 Generated with Claude Code