Skip to content

feat: unified task lifecycle (proposed/dismissed/todo/in_progress/in_review/done)#100

Open
Fullstop000 wants to merge 25 commits intomainfrom
feat/task-lifecycle
Open

feat: unified task lifecycle (proposed/dismissed/todo/in_progress/in_review/done)#100
Fullstop000 wants to merge 25 commits intomainfrom
feat/task-lifecycle

Conversation

@Fullstop000
Copy link
Copy Markdown
Owner

Summary

  • Collapses what would have been a two-PR proposal flow (closed feat: conversational task proposals (propose_task → card → accept) #93 + feat: context-snapshot task proposals (v2) — source message flows into sub-channel #96) into a single unified design: one tasks table with a six-state forward-only enum, one evolving TaskCard host message in the parent channel, the existing sub-channel pattern preserved for the actual work.
  • Owner is a label, not a gate: claimed_by renamed to owner; claim no longer auto-advances to in_progress; any channel member can advance any state; typed InvalidTaskTransition error → 422.
  • Cross-channel realtime: new TaskUpdateEvent broadcast (no membership gate) so the parent-channel card re-renders even when the viewer is not a member of the task's sub-channel.
  • Snapshot provenance: agent proposes a task tied to a chat message; ON DELETE SET NULL on the FK + CHECK on the four snapshot_* columns means provenance survives source-message deletion. Tested at both SQL and HTTP layers.
  • Full UI rewrite: new TaskCard component with six state-driven renderings, tasksStore Zustand slice, useTaskUpdateStream hook, RealtimeFrame extended with task_update variant. Wire field claimedBy preserved on task_event for chat-history compat; in-memory rename to owner happens at the parser boundary.

Spec / plan / four-round kimi review log committed: docs/plans/2026-04-24-task-lifecycle-unified-*.md. Decision recorded in docs/KNOWLEDGE.md; lifecycle reference rewritten in docs/BACKEND.md.

Supersedes #93 and #96.

Test plan

  • cargo test — 354 lib + 11 e2e + 51 store_tests + others, all green
  • cd ui && npx tsc --noEmit — clean
  • cd ui && npx vitest run — 17 files / 85 tests passing
  • TSK-005 (proposal flow) + TSK-006 (full lifecycle) Playwright smokes — deferred to a follow-up; the existing TSK-001..004 task cases on main may need a touchup against the new task_card wire kind.
  • /gstack-qa against a deployed dev server

🤖 Generated with Claude Code

Fullstop000 and others added 19 commits April 24, 2026 23:52
Collapse proposal + task into one `tasks` table with a six-state enum.
Merge `TaskProposalMessage` + `TaskEventMessage` into one evolving
`TaskCard` component. Keep the sub-channel. Supersedes PR #93 and #96.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Copilot AI review requested due to automatic review settings April 25, 2026 05:04
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 TaskUpdateEvent broadcast + WS task_update frame, with UI subscription and Zustand task patching.
  • Reworks task persistence + wire shapes (new tasks schema, owner rename, snapshot provenance, task_card host messages, sub-channel kickoff + task_event rows).
  • Updates UI rendering to TaskCard/TaskEventRow and 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 thread ui/src/App.tsx
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 thread src/store/tasks/mod.rs
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 thread src/store/tasks/mod.rs
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,
})
Fullstop000 and others added 6 commits April 25, 2026 13:15
- 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>
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