feat: unified task lifecycle (proposed/dismissed/todo/in_progress/in_review/done)#100
Open
Fullstop000 wants to merge 25 commits intomainfrom
Open
feat: unified task lifecycle (proposed/dismissed/todo/in_progress/in_review/done)#100Fullstop000 wants to merge 25 commits intomainfrom
Fullstop000 wants to merge 25 commits intomainfrom
Conversation
Incorporates 16 findings from kimi R1 review: migration uses table-rebuild (SQLite can't ALTER CHECK), wire task_card defined before create paths use it, claim/status fusion broken explicitly, owner-gate removed from update_task_status, endpoints keyed by (channel, task_number), realtime uses a dedicated TaskUpdateEvent broadcaster, wire field claimedBy preserved for chat history compat. See unified-review-log.md for the finding-by-finding triage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
17 R2 findings applied, 1 dismissed (migration scope-out). Fixes include: - task_card / task_event helpers return (InsertedMessage, String) tuples matching the existing create_system_message_tx signature - proposed -> todo no longer fires task_event (kickoff is the anchor) - claim/unclaim events route to sub-channel via post_task_event_tx (closes R1 #4 fully) - UI transport extended with TaskUpdateFrame + RealtimeSession subscribeTaskUpdates; useTaskUpdateStream uses the existing session singleton (no separate WebSocket) - 422 handler uses typed InvalidTaskTransition error + downcast_ref - TaskEventAction::Dismissed arms added to as_str/as_agent_sentence - Store::open initializes task_updates_tx broadcaster - groupTasksByStatus initializes all six keys - MessageList routing parses msg.content JSON for kind (no top-level kind) - Migration dropped entirely per user scope decision Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
10 R3 findings applied. Key fixes:
- Removed TaskEventAction::Dismissed + post_parent_dismissed_event_tx
(spec forbids task_event on pre-acceptance transitions — card mutation
via SSE task_update is the sole signal for proposed -> dismissed)
- update_task_status returns Err(InvalidTaskTransition{..}.into())
so handler downcast_ref actually matches (closes dead-code path)
- Rust SSE wrapper key renamed payload -> event to match existing
trace/event convention and RealtimeFrame TS type
- MessageList extracts TaskCardContainer sub-component (useTask is a
hook — cannot be called conditionally)
- Claim/unclaim SQL shown explicitly with owner-only UPDATE
- create_tasks splits parent_events + sub_events vectors (avoids
misrouting kickoff to parent subscribers)
- TaskInfo struct + SELECTs include sub_channel_name
- normalize_sqlite_timestamp moved in Task 1 not Task 11 (avoids
mid-implementation build break)
- TaskCardWirePayload TS interface declared explicitly
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
R4 verdict: CONVERGED (0 MUST-FIX, 0 SHOULD-FIX, 3 NICE-TO-HAVE). Applied cleanups: - File Structure overview aligned with Task 1 scope decision (no migration) - File Structure overview aligned with R3-1 removal of Dismissed enum variant and absence of separate update_task_dismiss function - MessageList inlines the event-row conversion instead of referencing an undefined evToRow helper Four review rounds complete (R1 16 + R2 18 + R3 10 + R4 3 = 47 findings, 46 applied + 1 user-dismissed for scope). Plan is implementable as-is. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend TaskStatus with Proposed + Dismissed, rename claimed_by to owner, add per-task snapshot columns (all-or-none CHECK) and source_message_id. Forward-only transition validator replaces the prior InProgress<->Done/ InReview<->InProgress edges; Task 4 will wire the new validator into update_task_status. Claim/unclaim SQL still references the old 'claimed_by' column and will fail until Task 3 cascades the rename. Enum-level unit test passes in isolation (cargo test -- store::tasks::tests::task_status_transitions). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan originally assumed PRs #93/#96 were merged. They're closed instead, and feat/task-lifecycle is off main, which doesn't contain their code. Added a concrete inventory of what exists vs what the plan assumes exists so each implementer subagent can skip no-op cleanup steps without re-discovering the delta. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Extend TaskInfo with id, owner (renamed from claimed_by), created_at, updated_at, source_message_id, and the four snapshot_* fields so the single DTO covers both live kanban rows and task cards re-carved from a source chat message. - Switch TaskInfo.status to the TaskStatus enum and introduce task_info_from_row as the single row-mapper for every SELECT in src/store/tasks/mod.rs — adding a task column means one SQL edit plus one row.get() edit, not four. - create_tasks now reads back created_at/updated_at via RETURNING so the returned DTO matches the DB row exactly without a second SELECT. - Add TaskCardWirePayload (the parent-channel task_card host message shape) + post_task_card_message_tx (parent-channel fanout) + post_task_event_tx (sub-channel fanout). Both return (InsertedMessage, String) so callers stage events in a pending_events vec and fan out via emit_system_stream_events after tx.commit() — no mutex held across the stream send. - Guard the wire contract with a regression test that pins task_event's claimedBy JSON key; renaming to owner would break every persisted task-event message in existing workspaces. A second test round-trips TaskCardWirePayload through serde_json. - Helpers carry #[allow(dead_code)] ahead of Task 3 / Task 5 wiring. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 3 of the task-lifecycle-unified plan. Adds `create_proposed_task`: the agent-driven create path. Inserts a row with `status='proposed'`, no sub-channel, and a required snapshot bundle (source_message_id + 4 snapshot_* fields enforced all-or-none by the schema CHECK constraint). Posts a `task_card` host message in the parent channel — no `task_event` fires pre-acceptance. Refactors `create_tasks` (human direct-create): now posts a `task_card` host message to the parent channel in place of the old `task_event(Created)` emission. The task card is the canonical parent-channel surface that re-renders on every `task_update` SSE event. Parent- and sub-channel events are split into separate fanout vectors because `emit_system_stream_events` tags every event with the single `&Channel` it receives — mixing would misroute sub-channel events. Adds `normalize_sqlite_timestamp` helper to canonicalize either SQLite's native `"YYYY-MM-DD HH:MM:SS"` or RFC3339 input to the canonical `"YYYY-MM-DDTHH:MM:SSZ"` form the snapshot spec requires. Marked `#[allow(dead_code)]` until Task 6 (HTTP handler) consumes it. Adds `get_task_info_tx` — a tx-scoped variant of `get_task_info` so create paths can load the canonical 15-column `TaskInfo` (with joined `sub_channel_name`) without releasing the write lock. Tests: - create_proposed_task_inserts_snapshot_and_posts_task_card - create_proposed_task_rejects_partial_snapshot_via_check_constraint - create_task_direct_mints_sub_channel_and_has_null_snapshot_fields - create_tasks_posts_task_card_host_message_to_parent_channel (replaces the old `task_event(Created)` assertion) - normalize_sqlite_timestamp_handles_sqlite_native - normalize_sqlite_timestamp_handles_rfc3339 - normalize_sqlite_timestamp_rejects_garbage Pre-existing failures in `update_tasks_claim`/`update_task_unclaim`/ `update_task_status` (claimed_by→owner cascade) are Task 4/5 scope and unchanged by this commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…koff
Rewrite update_task_status as a state machine that uses TaskStatus::can_transition_to
for forward-only validation. On Proposed -> Todo, mint the task sub-channel and post
the kickoff message; on Proposed -> Dismissed, pure state mutation; on post-acceptance
transitions, fire task_event in the sub-channel; on -> Done, archive the sub-channel.
Drop the claimed_by == requester_name owner-gate per spec — owner is a label, not a
gate; membership is the only authorization.
Add typed InvalidTaskTransition error with #[error(...)] derive so HTTP handlers can
downcast_ref instead of string-prefix matching.
Extract mint_sub_channel_tx and post_kickoff_message_tx helpers from create_tasks for
reuse by the Proposed -> Todo path. create_tasks now also emits the kickoff
'Task opened: {title}' single-line variant for direct-created tasks.
Four new tests covering the forward-only graph, the Proposed -> Todo full flow with
kickoff format assertions, the no-event-on-dismissal contract, and the typed error
shape on invalid transitions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… to sub-channel update_tasks_claim: - Set owner only; no longer fuses to status='in_progress'. - Allowed on Todo/InProgress/InReview (anyone can re-claim/steal). Rejected on terminal/proposal states (Proposed/Dismissed/Done). - Status-IN guard makes the precondition race-free. - task_event(Claimed) posts in the sub-channel via post_task_event_tx (R3 finding #3 closure). Sub-channel membership sync preserved. update_task_unclaim: - Set owner = NULL only; status stays put (no longer reverts to 'todo'). - TOCTOU guard: WHERE owner = ? prevents unclaiming a stolen claim. - task_event(Unclaimed) posts in the sub-channel. Three pre-existing tests updated to walk Todo -> InProgress -> InReview explicitly (claim no longer auto-advances). One channel test bumped its synthetic message seq to 2 because create_tasks now posts kickoff at seq=1. Full lib suite: 354 passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sitions
POST /agent/{agent_id}/tasks/propose — agent proposes a task tied to a
specific chat message. Handler resolves the source via
get_conversation_message_view, normalizes the timestamp through
normalize_sqlite_timestamp, and calls store.create_proposed_task. Source
message in a different channel returns 409; missing message returns 404.
map_status_error helper downcasts InvalidTaskTransition to 422 (the
transition is well-formed but disallowed); other errors stay 400. Applied
to the two update-status handlers (agent + public).
E2E suite updated for the unified model:
- task_lifecycle test now walks Todo->InProgress->InReview->Done explicitly
and asserts ONE task_card in parent + kickoff + 4 task_events in
sub-channel (parent no longer carries lifecycle events under the unified
model).
- batched_create_tasks asserts task_card kind, not legacy task_event(Created).
- test_task_board_e2e has the explicit InProgress step.
- createdByName field renamed to createdBy in two tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New TaskUpdateEvent type (task_id, channel_id, task_number, status, owner,
sub_channel_id, updated_at) carries the minimum delta a frontend tasksById
store needs to patch its in-memory view of a task. Camel-case serialization
matches the existing realtime envelope contract.
Store gains a third broadcast channel (task_updates_tx, capacity 256)
alongside stream_tx and trace_tx. emit_task_update fires from every
mutation site after tx.commit() + drop(conn): create_tasks (per task),
create_proposed_task, update_tasks_claim (per successful claim),
update_task_unclaim, and update_task_status. NoReceivers errors are
silently dropped — DB rows are the source of truth.
realtime_session adds a third tokio::select! branch that forwards
TaskUpdateEvent as { type: "task_update", event: ... }. No membership
gate — task updates fan out to every connected client so the
parent-channel task_card host re-renders even when the viewer is not a
member of the task's sub-channel.
Two new tests pin the broadcast contract: one for create-time fanout,
one for the per-step transition fanout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New propose_task tool:
- Params: { channel, title, source_message_id }
- Backend impl POSTs to /agent/{id}/tasks/propose; server snapshots the
source message (sender, content, created_at) for verbatim provenance.
- Tool docstring tells the agent: use when a user's message describes work
that should become a tracked task; the proposal awaits human acceptance.
update_task_status docstring updated to spell out the forward-only state
machine: proposed -> todo|dismissed; todo -> in_progress -> in_review -> done.
No reverse edges in v1.
UpdateTaskStatusParams.status doc-comment lists the full graph so the JSON
schema the agent sees describes the contract directly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings the frontend in line with the unified task lifecycle the backend shipped in T1–T8. Six TaskStatus values (proposed/dismissed/todo/ in_progress/in_review/done) flow end-to-end; the parent-channel TaskCard host message owns current state for every task, sub-channel feeds narrate events as inline rows, and a dedicated tasksStore + realtime task_update subscription keep cards live without polling. Highlights: - data/tasks.ts: TaskInfo widened to camelCase wire shape (owner, createdBy, snapshot* fields). claimedByName/createdByName retired. - transport: TaskUpdateFrame + subscribeTaskUpdates wire path. - store/tasksStore.ts + hooks/useTask.ts + hooks/useTaskUpdateStream.ts: in-memory tasksById slice patched on every task_update broadcast. - chat/TaskCard.tsx + TaskCardContainer.tsx: state-driven six-branch card replacing TaskEventMessage. Pure presentational; container wires CTAs to data/tasks.ts helpers. - chat/TaskEventRow.tsx: flat sub-channel narrative row (claimed / unclaimed / status_changed). Replaces card-vs-pill morph thread. - MessageList.tsx: routes system messages on parsed kind=task_card vs parseTaskEvent. Single parse per message, no double-decoding. - TasksPanel + TaskDetail: filter kanban to four committed columns; proposed/dismissed live on the parent channel TaskCard only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two regression tests for the durable contract: - task_snapshot_check_constraint_rejects_partial_population: an INSERT with only some snapshot_* columns populated must fail the schema CHECK. Otherwise a 'stub' snapshot could leak into chat history without full provenance. - source_message_delete_nulls_pointer_preserves_snapshot: ON DELETE SET NULL on tasks.source_message_id, but the four snapshot_* fields persist verbatim. Provenance survives source-message deletion. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Agent proposes a task tied to a chat message via /internal/agent/{id}/tasks/propose,
the source message is then deleted at the SQL layer, and the public
GET /api/conversations/{id}/tasks/{n} endpoint must still serve the snapshot
fields (snapshotSenderName, snapshotContent) with sourceMessageId nulled out.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…re surfaces KNOWLEDGE.md gets a 2026-04-25 decision entry covering the six-state forward-only enum, owner-as-label, the task_card host message, the cross-channel TaskUpdateEvent broadcast, the pointer-vs-truth invariant, and rejected alternatives. BACKEND.md replaces the obsolete 'task event system messages' section with the full lifecycle reference: state machine, sub-channel ownership, provenance snapshot semantics, the two wire kinds (task_card in parent, task_event in sub-channel), kickoff format, cross-channel fan-out, and the atomicity guarantees. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three pre-existing tests pinned the old behaviors that the unified lifecycle replaced: - claim_task_emits_claimed_event_to_parent_channel (claim event was in parent) - unclaim_task_emits_unclaimed_event (unclaim event was in parent) - update_task_status_emits_status_changed_event (status event was in parent) Plus test_task_claim_and_status (asserted first-claim-wins + auto-advance to in_progress + Todo->InReview skip — all changed in T5/T4). The new contract — events in the sub-channel, claim doesn't auto-advance — is covered end-to-end by task_lifecycle_emits_four_events_in_parent_channel (e2e_tests.rs) and the proposed_to_* / claim_sets_owner_does_not_advance_status tests in src/store/tasks/mod.rs. Cross-channel broadcast contract is pinned by task_create_emits_task_update_event and task_status_transitions_emit_task_update_per_step. Full suite green: 354 lib + 11 e2e + 51 store + others. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Unifies the task system into a single forward-only 6-state lifecycle (proposed → dismissed|todo → in_progress → in_review → done), shifting the parent channel to a single task_card host message per task and using a new cross-channel realtime task_update frame to keep UI state in sync even without sub-channel membership.
Changes:
- Adds cross-channel
TaskUpdateEventbroadcast + WStask_updateframe, with UI subscription and Zustand task patching. - Reworks task persistence + wire shapes (new
tasksschema,ownerrename, snapshot provenance,task_cardhost messages, sub-channel kickoff +task_eventrows). - Updates UI rendering to
TaskCard/TaskEventRowand simplifies task-event log derivation + tests/docs.
Reviewed changes
Copilot reviewed 47 out of 47 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| ui/src/transport/types.ts | Adds TaskUpdateFrame and RealtimeFrame task_update variant. |
| ui/src/transport/session.ts | Adds subscribeTaskUpdates and routes task_update frames to dedicated subscribers. |
| ui/src/transport/index.ts | Re-exports realtime transport types including TaskUpdateFrame/TraceFrame. |
| ui/src/store/tasksStore.ts | Introduces global tasks Zustand slice + realtime patch application. |
| ui/src/pages/MainPanel.tsx | Mounts useTaskUpdateStream at app level. |
| ui/src/hooks/useTasks.ts | Pushes polled list results into global tasks store. |
| ui/src/hooks/useTaskUpdateStream.ts | Subscribes to task_update frames and patches store. |
| ui/src/hooks/useTaskEventLog.ts | Refactors to return ordered flat task_event rows (no per-task state reduction). |
| ui/src/hooks/useTaskEventLog.test.ts | Updates tests to match flat event-row derivation. |
| ui/src/hooks/useTask.ts | Adds useTask(taskId) selector hook over global store. |
| ui/src/data/tasks.ts | Expands lifecycle enum + makes TaskInfo a full wire-shape with required fields + snapshot provenance; updates grouping helper. |
| ui/src/data/requests.ts | Expands allowed status values for update-status requests. |
| ui/src/components/tasks/TasksPanel.tsx | Updates UI fields from claimedByName/createdByName to owner/createdBy. |
| ui/src/components/tasks/TaskDetail.tsx | Extends status label/advance logic for proposed/dismissed; updates owner field usage. |
| ui/src/components/tasks/TaskDetail.test.tsx | Updates tests for required TaskInfo fields + new owner/createdBy semantics. |
| ui/src/components/chat/TaskEventRow.tsx | Adds inline narrative rendering for sub-channel task_event rows. |
| ui/src/components/chat/TaskEventRow.test.tsx | Adds unit tests for event-row formatting. |
| ui/src/components/chat/TaskEventRow.css | Styles new inline task-event narrative rows. |
| ui/src/components/chat/TaskEventMessage.tsx | Removes legacy per-task card/thread renderer. |
| ui/src/components/chat/TaskEventMessage.test.tsx | Removes tests for legacy task-event thread renderer. |
| ui/src/components/chat/TaskEventMessage.css | Removes legacy animated card/pill CSS. |
| ui/src/components/chat/TaskCardContainer.tsx | Adds container bridging task_card host message → live task store row + CTAs. |
| ui/src/components/chat/TaskCard.tsx | Adds new unified parent-channel TaskCard renderer across lifecycle states. |
| ui/src/components/chat/TaskCard.test.tsx | Adds unit tests for each TaskCard status branch and busy disabling. |
| ui/src/components/chat/TaskCard.css | Adds styles for the unified parent-channel TaskCard. |
| ui/src/components/chat/MessageList.tsx | Routes system messages by JSON kind to TaskCardContainer or TaskEventRow. |
| ui/src/components/chat/MessageList.test.tsx | Updates tests for new system-message routing behavior. |
| ui/src/App.tsx | Tightens subscribeAll frame narrowing in global seq listener. |
| tests/store_tests.rs | Updates/rewrites store-layer tests for new semantics (task_card host + snapshot invariants + task_update broadcast). |
| tests/e2e_tests.rs | Updates e2e flows for decoupled claim/start and new parent vs sub-channel message model; adds snapshot deletion HTTP test. |
| src/store/tasks/mod.rs | Implements unified lifecycle in store (new statuses, owner, proposed task creation, task_card + kickoff, cross-channel task_update emit). |
| src/store/tasks/events.rs | Adds task_card wire payload + helpers for posting task_card/task_event messages. |
| src/store/stream.rs | Adds TaskUpdateEvent type. |
| src/store/schema.sql | Updates tasks table schema for unified lifecycle + snapshot provenance + constraints. |
| src/store/mod.rs | Adds task_updates_tx, subscription, and emit helper for cross-channel task updates. |
| src/store/channels.rs | Adjusts task-channel test fixture seq assumptions due to new kickoff message. |
| src/server/transport/realtime.rs | Forwards cross-channel task_update frames to all WS clients. |
| src/server/mod.rs | Adds internal agent route for /tasks/propose. |
| src/server/handlers/tasks.rs | Adds propose-task handler with snapshot capture + maps invalid transitions to 422. |
| src/bridge/types.rs | Adds ProposeTaskParams tool params and updates status docs. |
| src/bridge/mod.rs | Adds propose_task MCP tool. |
| src/bridge/backend.rs | Implements backend call to internal /tasks/propose route. |
| docs/plans/2026-04-24-task-lifecycle-unified-review-log.md | Adds design/plan review log for unified lifecycle work. |
| docs/plans/2026-04-24-task-lifecycle-unified-design.md | Adds unified lifecycle design spec. |
| docs/KNOWLEDGE.md | Records the unified task lifecycle decision and invariants. |
| docs/BACKEND.md | Updates backend reference to the unified lifecycle, wire surfaces, and realtime fan-out. |
Comment on lines
+241
to
+244
| // Only StreamEvent frames carry MessageCreated payloads; trace and | ||
| // task_update frames have their own dedicated subscribers and reach | ||
| // here too because subscribeAll is wildcard. Bail early so the type | ||
| // guard narrows `frame` to the StreamEvent branch. |
Comment on lines
+46
to
+48
| * Cheap discriminator for system-message JSON payloads. Returns the `kind` | ||
| * field if the content parses as a JSON object, else null. Avoids re-parsing | ||
| * downstream when the routing branch needs the typed payload. |
Comment on lines
+250
to
+253
| return !!( | ||
| task.snapshotContent ?? | ||
| task.snapshotSenderName ?? | ||
| task.snapshotCreatedAt |
Comment on lines
971
to
+975
| tx.execute( | ||
| "UPDATE tasks SET status = ?1, updated_at = datetime('now') WHERE channel_id = ?2 AND task_number = ?3", | ||
| params![new_status.as_str(), channel.id, task_number], | ||
| "UPDATE tasks \ | ||
| SET status = ?1, sub_channel_id = COALESCE(?2, sub_channel_id), updated_at = datetime('now') \ | ||
| WHERE channel_id = ?3 AND task_number = ?4", | ||
| params![ |
Comment on lines
+1031
to
+1035
| if let Some(sc) = sub_channel_for_emit { | ||
| self.emit_system_stream_events(&sc, sub_pending)?; | ||
| } | ||
| self.emit_task_update(&task, &channel.id); | ||
| Ok(task) |
Comment on lines
+19
to
+26
| return session.subscribeTaskUpdates((frame) => { | ||
| applyUpdate({ | ||
| taskId: frame.taskId, | ||
| status: frame.status as TaskStatus, | ||
| owner: frame.owner, | ||
| subChannelId: frame.subChannelId, | ||
| updatedAt: frame.updatedAt, | ||
| }) |
- TSK-001: drive claim/start as separate user actions on the parent-channel
TaskCard (data-testid="task-card-claim-btn" then "task-card-start-btn"),
not the combined "Start" CTA in TaskDetail. Adds the kanban-then-chat hop
needed to populate the global tasksStore so `useTask(taskId)` resolves.
- TSK-003: rewire status advancement through the TaskCard CTAs (claim →
start → review → done) since claim is decoupled from status; verify
done collapses to `task-card-done-pill`; STATUS_LABEL now renders
in_progress as "in progress" so update the detail-pill assertion.
- TSK-004: replace deleted `task-thread-*` testids with `task-card-*`,
drop the dead `.task-event-done-row` selector, walk the unified state
machine via card CTAs.
- TSK-005 (new): agent proposal end-to-end. Seeds a source message, calls
POST /internal/agent/{agent}/tasks/propose via the request fixture,
asserts the parent-channel TaskCard renders in `proposed` with the
snapshot blockquote, accepts via [create], advances to in_progress to
surface the deep-link, and verifies the kickoff system message in the
sub-channel carries all three sections.
- TSK-006 (new): full lifecycle smoke from direct create through the done
pill. Exercises every CTA branch on a single card and confirms the pill
is a working sub-channel link.
- helpers/api.ts: add `sendAsUserGetId` (returns the inserted message id)
and `proposeTaskAsAgent` (typed wrapper for the propose endpoint).
- tasks.md: refresh TSK-001..004 step descriptions; add TSK-005/006 entries.
Verified: `cd ui && npx tsc --noEmit` passes; `npx playwright test
TSK-{001..006}.spec.ts --list` lists all six tests cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…cold load TaskCardContainer reads the live task via useTask(taskId), which only resolves once tasksById has the row. Before this fix, tasksById was populated only by the kanban tab's useTasks polling, so a fresh load that landed on a chat channel with task_card system messages in history rendered them as blank slots until the user clicked over to the Tasks tab. Hoist the same useTasks(conversationId) call into ChatPanel as a side effect. Realtime task_update events keep the store fresh thereafter; the 5s poll inside useTasks is the belt-and-suspenders fallback for missed broadcasts. TasksPanel still calls its own useTasks for the kanban list, but the two panels are mutually exclusive (activeTab gates them) so there's no double-poll in practice. Caught during the QA spec audit (51b0f0e) — the test workaround was to hop through the Tasks tab before reading the chat card; now that hop is unnecessary. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TSK-001 called ensureMixedRuntimeTrio in beforeAll but never used the trio. The trio creation fails on machines without Kimi auth (`session/new failed: Authentication required`), which would block the entire spec from ever running locally. Drop the dead dependency. TSK-005 only needs one agent for proposal attribution (the agent is never run). Replace the trio with a one-shot ensureProposerAgent helper that creates a single codex bot — no kimi dependency. TSK-005 step 6 also targeted .message-item for the kickoff system message, but kickoff renders as .system-message-divider (role=status). Switch the locator to .system-message-divider to match what the UI actually renders. Verified: TSK-001..006 all pass (6/6, 12.1s) against vite+backend on this worktree. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before: TaskCard had its own border + popover background, reading as a floating "panel" overlaid on the chat instead of a message in the stream. After QA the user flagged this — wanted the card to flow inline like any other message. Wrap TaskCardContainer's output in a `.message-item.message-task` shell matching the convention every other row already uses: avatar (creator's color/initial), header (creator name + "OPENED TASK #N" + timestamp), then the card body. Drop the standalone border/background from `.task-card` in chat (still scoped under `.message-task` to avoid colliding with the kanban tile `.task-card` in TasksPanel.css, which keeps its panel look). Promote `formatTime` and `senderColor` from MessageItem.tsx to exports so the container can reuse them without duplicating the hash + palette. Verified: 8/8 TaskCard unit tests pass; 6/6 TSK Playwright specs pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before: when an agent's run reaches `FinishReason::Natural` without ever emitting Text or calling a tool, the user sees nothing — no chat reply, no error, no signal anything happened. Common cause: codex auth dies mid-run (the underlying CLI prints ERROR to stderr but the JSON-RPC turn still terminates "naturally"). User-reported during /qa: "I @-tagged the bot and nothing happened." Track per-session output count in event_forwarder. Increment on Text and ToolCall events. On `Completed`, if the count is zero, emit a `TraceEventKind::Error` and set `ACTIVITY_ERROR` with detail "Finished without responding (reason: <FinishReason>)" so the user gets a visible signal instead of a phantom run. UI: Sidebar agent dot and ProfilePanel both gain an `error` branch rendering a destructive-red status indicator. activityDotClass routes `activity === 'error'` over the underlying status (ready/working) so a just-errored agent doesn't look identical to an idle one. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs in the silent-finish path verified live in the .dogfood env
against a real codex agent with broken auth:
1. **Counter was too lenient.** Counted Text + ToolCall events, but a
codex run that 401s still streams a single "I'll help with that"
Text fragment before dying. produced=1 made the detector skip the
case. Plain Text events stream into the agent's trace panel — they
never reach the chat. Switch to counting ToolCall events only:
`send_message`, `propose_task`, etc. are all tool calls, so any
chat-visible side effect is captured. Pure-trace text no longer
masks a silent failure.
2. **Activity flip got overwritten.** Drivers emit `Lifecycle::Active`
right after `Completed` (the process is back to idle), and the
forwarder's Active branch unconditionally set ACTIVITY_ONLINE. Race:
the silent-finish detector sets ERROR, then the immediate Active
event resets it to ONLINE before the user can see the signal.
Add `activity_log::current_activity` and gate the Active branch:
if the agent is already ERROR, leave it. Productive ToolCall /
Thinking events still overwrite ERROR via the WORKING/THINKING
branches, so the next successful run clears the error naturally.
Verified end-to-end in `.dogfood/`:
- Backend on isolated `HOME=/tmp/chorus-shadow-*` (no `~/.codex`)
- Codex agent created → run completes with `tool_calls=0` →
"silent run finish" warning fires →
activity stays `error` with detail "Finished without responding
(reason: Natural)"
- UI sidebar dot renders in `var(--color-destructive)` (orange);
ProfilePanel shows the full detail text.
Tests added (5/5 forwarder tests pass):
- silent_completed_run_flips_activity_to_error
- productive_completed_run_stays_online (now uses ToolCall, not Text)
- text_only_completed_run_is_still_silent (codex-401 reproduction)
- lifecycle_active_does_not_overwrite_silent_finish_error (race guard)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
Summary
taskstable with a six-state forward-only enum, one evolvingTaskCardhost message in the parent channel, the existing sub-channel pattern preserved for the actual work.claimed_byrenamed toowner; claim no longer auto-advances toin_progress; any channel member can advance any state; typedInvalidTaskTransitionerror → 422.TaskUpdateEventbroadcast (no membership gate) so the parent-channel card re-renders even when the viewer is not a member of the task's sub-channel.ON DELETE SET NULLon the FK +CHECKon the foursnapshot_*columns means provenance survives source-message deletion. Tested at both SQL and HTTP layers.TaskCardcomponent with six state-driven renderings,tasksStoreZustand slice,useTaskUpdateStreamhook,RealtimeFrameextended withtask_updatevariant. Wire fieldclaimedBypreserved ontask_eventfor chat-history compat; in-memory rename toownerhappens at the parser boundary.Spec / plan / four-round kimi review log committed:
docs/plans/2026-04-24-task-lifecycle-unified-*.md. Decision recorded indocs/KNOWLEDGE.md; lifecycle reference rewritten indocs/BACKEND.md.Supersedes #93 and #96.
Test plan
cargo test— 354 lib + 11 e2e + 51 store_tests + others, all greencd ui && npx tsc --noEmit— cleancd ui && npx vitest run— 17 files / 85 tests passingtask_cardwire kind./gstack-qaagainst a deployed dev server🤖 Generated with Claude Code