Skip to content

[6/7] Record task status transitions - #277

Open
alex-clickhouse wants to merge 2 commits into
alex-clickhouse/task-detail-modalfrom
alex-clickhouse/task-events
Open

[6/7] Record task status transitions#277
alex-clickhouse wants to merge 2 commits into
alex-clickhouse/task-detail-modalfrom
alex-clickhouse/task-events

Conversation

@alex-clickhouse

Copy link
Copy Markdown
Collaborator

Stacked on #276#275#274#273#272main.

The gap

Nerve kept no history of status changes. tasks holds only the current value, memu_audit_log is memU-only, session_events is sessions. The markdown ## Updates list was the closest thing, and it only gains a line when a note is passed or on done/reopen:

Transition Recorded before?
task_update(note="…") - 2026-08-05: <note>
task_done / reopen
plain status flip nothing

So pending → in_progress → deferred → in_progress left 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 /move routes 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_status is 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_status
  • move_task
  • 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

The 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.
  • A pure reorder within a lane is the most frequent board interaction there is, and changes no status.

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, web for the HTTP API. The task routes now build their tool context with that name instead of the generic "system" sentinel — build_route_tool_context already took the parameter; nothing else keys off the old value.

UI

  • Aging badge on board cards after 3 days in a status (amber at 7, red at 14). Coarse on purpose: the signal is "this has stalled", and a badge on every card would be noise.
  • History panel in the task detail view — each transition with its dwell time, which is the part that actually explains why something took as long as it did.

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/ -q3095 passed (22 new)
  • npm test — 46 passed · tsc -b clean · npm run build clean
  • npx eslint . — 148 vs 152 on main

Open questions from the task write-up, deliberately unanswered here

  • Retention — unbounded today. Worth wiring into db/retention.py if this grows, though at a few rows per task per week it's a long way off.
  • Backfill from the markdown ## Updates lines — not attempted. The dates are day-granularity and the format is loose; a fabricated timeline that looks precise seemed worse than starting clean.
  • Scope — status-only. Title/tag/deadline edits aren't recorded; folding them in would want a different shape (field, old, new) and is a bigger conversation.

🤖 Generated with Claude Code

@alex-clickhouse
alex-clickhouse force-pushed the alex-clickhouse/task-events branch from bf14292 to 6f94393 Compare August 5, 2026 10:36
@alex-clickhouse
alex-clickhouse requested a review from Copilot August 5, 2026 10:38
@alex-clickhouse alex-clickhouse changed the title Record task status transitions [6/6] Record task status transitions Aug 5, 2026

Copilot AI 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.

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 from upsert_task, update_task_status, and move_task, including an actor.
  • Adds backend APIs to fetch task event history and to return status_since in 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.

Comment thread nerve/db/tasks.py
Comment thread docs/tasks.md Outdated
Comment thread web/src/components/Tasks/TaskDetailBody.tsx
Comment thread web/src/stores/taskStore.ts
@alex-clickhouse
alex-clickhouse force-pushed the alex-clickhouse/task-events branch from 6f94393 to b5ac16a Compare August 5, 2026 10:50
@alex-clickhouse alex-clickhouse changed the title [6/6] Record task status transitions [6/7] Record task status transitions Aug 5, 2026
alex-clickhouse and others added 2 commits August 5, 2026 11:51
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
alex-clickhouse force-pushed the alex-clickhouse/task-events branch from b5ac16a to a1db02c Compare August 5, 2026 12:07
@alex-clickhouse
alex-clickhouse marked this pull request as ready for review August 5, 2026 12:27
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.

2 participants