Skip to content

feat(tasks): task communication — wake on result, stop/resume, message tasks, any-agent delegation - #230

Merged
juacker merged 56 commits into
mainfrom
feat/task-communication
Oct 16, 2026
Merged

juacker merged 56 commits into
mainfrom
feat/task-communication

Conversation

@juacker

@juacker juacker commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator

Task communication: wake on result, stop/resume, message tasks, any-agent delegation

Implements plans/task-communication-backend.md + plans/task-communication-frontend.md (option 3b: per-workspace session_signals + stateless dispatcher::kick).

What changes for users

  • No more sleep-polling. workspace_assignTask returns immediately; when the task finishes, its result is written into the caller's session as an automated event and wakes the caller (or is delivered mid-run if it's busy).
  • Stop / resume. Stop is durable (survives restart) and cascades to subtasks. Sending a message to a stopped task resumes it in the same session (full context kept).
  • Message a running task. Queued until the next safe point; answers an open ask_user directly.
  • Questions bubble up. A task's ask_user marks it input_required and posts the question to its parent; the parent agent or the human can answer.
  • Any enabled agent can delegate, within limits: depth 4, 40 agent messages/hour per sender (budget_exceeded).
  • Chat shows who is talking. Task results and questions render as cards, agent messages as blocks with the sender's avatar; your own messages are unchanged.
  • Busy-session ticks are skipped (Run now shows a "session busy" notice). Restart recovery resumes pending work, max 3 runs at once.

Commits

One commit per plan step (B1–B6 backend, F2–F11 frontend), then review fixes. Each commit builds and passes its own tests.

Verification

  • Rust: fmt, clippy -D warnings, ~1000 filtered lib tests over every touched module, all integration tests, bindings fresh, cargo check on every backend commit.
  • Frontend: tsc, lint, full vitest, plus tsc + changed tests on every frontend commit.
  • Review: 3 rounds of correctness reviews (backend and frontend) plus one shape review; all findings fixed or explicitly accepted. Reviews in the manager workspace reviews/task-communication-*.

Known limitations (accepted)

  • Two code paths need an AppHandle/live CLI to test (spawn_run → start_cycle; Claude Code midrun.notified()).
  • A parked worker whose own session is deleted stays running.
  • Two questions in one task cycle share one card state.
  • Pending messages queued before upgrade are delivered automatically on first launch.

Upgrade note

Migrations 20261016* change the workspace DB; a build from before this PR can't read a migrated DB. Keep a copy of data.sqlite.

- Serialize workspace DB opens per file and run startup recovery once per
  process, so a second open cannot fail live runs and tasks.
- Migration 20261016000000: session_signals replaces
  assistant_message_queue (rows migrated as user_message signals) and a
  unique partial index enforces one active run per session.
- signals.rs owns pending/claimed/consumed user input; dispatcher::kick is
  the only path that starts a run for pending signals (RunTrigger::SignalWake)
  and runs after every send and every run end.
- Busy scheduler ticks are skipped and logged instead of deferred; a skipped
  manual Run now emits run_skipped. defer_running_instance is removed.
- Migration 20261016001000: assistant_messages gains origin_json and
  run_id; workspace_tasks gains lineage, cycle_no, stop/answer columns and
  failure_reason. `blocked` rows become failed/blocked, other failures get
  a reason, and a task session's first user row is marked as the
  creator's instruction.
- MessageOrigin (user | agent | task) on every message; TaskStatus and
  FailureReason replace the free-text task status. `blocked` is no longer
  a status: a `BLOCKED:` reply ends a cycle as failed{blocked}.
- workspace_assignTask returns the queued task immediately. The
  instruction is an agent_message signal, and dispatcher::kick starts each
  task cycle. finalize_task_cycle posts one task_result/task_failed event
  per cycle to the parent session (delivery key task-result:<id>:<cycle>)
  and wakes it. The summary is read from the cycle's own run rows.
- Runs stamp run_id on the rows they write; claim and mid-run consume
  stamp delivered input. Automated events reach the model with an origin
  header (API history, CLI input, mid-run and fresh-session context);
  stored content never contains it.
- AssistantUiEvent::TaskUpdated on every task status change; the
  workspace://task-attention event is removed. The attention summary
  counts failed/blocked as blocked.
- The prompt and tool descriptions stop telling the manager to poll.
  Results arrive as automated events, and SignalWake runs are described as
  message-driven.
- Bindings regenerated. Frontend: a no-op task_updated arm, failed-only
  attention, and fixture updates.
- runtime.rs keys live runs by (workspace, session): cancel_session,
  cancel_run by run id, and a per-run mid-run wake. kick wakes a busy
  session's live run; Claude Code delivers on that wake instead of the
  1.2 s queue poll (Codex keeps its steer poll).
- workspace_stopTask (agent tool) and workspace_stop_task (UI command):
  queued/running/input_required -> stopping with stop_requested_at. A live
  run is cancelled; a claimed run that has not registered yet sees the
  request before it starts and is cancelled unstarted; with no active run
  the cycle ends stopped at once. The finalizer ends a cycle stopped
  whenever a stop was requested, even if the provider finished first.
  Repeat stops change nothing; a stop of an ended task is already_terminal.
- assistant_cancel_run on a task session stops the task.
- dispatcher: a stopping task waits for its finalizer; a stopped task
  resumes only for input sent after it stopped.
- Restart recovery replaces the failed sweep: running/input_required/
  stopping tasks end stopped ("task interrupted by app restart") with one
  result event to the parent; a task whose run never started stays queued
  and its claimed input goes back to pending; a queued task with no session
  fails as before.
- WorkspaceTaskResponse gains stopRequestedAt; bindings regenerated;
  frontend gets stopWorkspaceTask and fixture updates only.
- workspace_sendTaskMessage (agent tool) and workspace_send_task_message
  (UI command) post a message to a task session as one content signal
  (agent_message from an agent, user_message from the user). On an
  input_required task it answers the open ask_user question and the signal
  is consumed by the live run; otherwise the dispatcher queues it for the
  running cycle or starts a new one. A stopped task resumes only for input
  sent after the stop (B3 gate). Responses carry the task, message, signal
  {id, status} and run.
- ask_user::submit_answer_for_session: the first answer to remove the
  pending entry wins; a later sender's message stays a pending signal.
  AskUserAnswer carries its origin; the panel answers as User.
- ask_user in a task session: running -> input_required, plus a
  task_input_required event to the parent session that wakes it. Once
  answered, input_required -> running with input_answered_by_json set.
  Both emit task_updated.
- WorkspaceTaskResponse gains answeredBy; SignalRef, SignalStatus and
  WorkspaceTaskSendResponse are exported. Bindings regenerated; frontend
  fixtures only.
M1 (frontend half): question rows no longer offer Mark reviewed.
m1: ?task= opens via openTask, clearing the Back stack.
m2: toasts only on a status/cycle transition; an old task fetched into a loaded workspace stays quiet.
m4: unanswered questions read "Not answered · <live status>".
m5: input_required shows the attention face and does not count as running for Run now.
m6: follow-on agent messages keep an sr-only sender name.
m7: Open task buttons are named by task title.
m8: result cards read Blocked when the live task's same cycle failed as blocked.
Deletions: useCancelRun out of TaskStopButton (local pending flag + stopping status);
legacy task-row status 'blocked' in isTaskAttention; resume hint only in the composer
placeholder; transcript assignee/session meta line.
Not done: m3 (two questions per cycle, needs list-level plumbing), m9, m10 (states
already get the base pill style), queued chip after stop.
Kept: TASK_STATUS_LABEL.blocked and TaskCard 'blocked' — old tool payloads still carry it.
major-1/2: open_cycle opens only a finished task (new, completed, failed,
or stopped with explicit input sent after the stop), decided inside the
claim transaction after the run insert. A kick between run end and the
finalizer, or a stop landing before the claim commits, now leaves the
cycle to its finalizer/stop. Restart recovery requeues a task whose run
never started with run_id NULL so the predicate still dispatches it.
major-3: assignment writes task row, session, link, instruction and
signal in one transaction; no connection stores the task failed directly.
major-5: start_cycle (stop-before-start) and the kick's mid-run wake are
pool-level and tested; restart tests reopen through init_workspace_db.
minor-1: a skipped tick persists next_run_at only.
minor-2: trigger_message_id removed; a chat run whose claimed input is
all user messages gets the UserMessage prompt again, else SignalWake.
minor-3: restart result events name the agent from the roster.
minor-4: stale test_support comment.
question-1: a stopped task resumes only on user/agent messages sent
after the stop request, so one sent while stopping resumes it.
- Open the assignment and agent-send transactions with BEGIN IMMEDIATE so
  parallel workspace_assignTask calls no longer fail with SQLITE_BUSY.
- Consume a task answer before handing it to the waiting ask_user; release
  it if no question was waiting.
- Fail tasks left queued/running by the pre-signal code in the lineage
  migration (reason restart).
- Write input_required and the parent's question in one transaction.
- Drop the user-message/signal-wake trigger split: chat claims are
  signal_wake.
- State polling and delegation limits once, in the tool descriptions, built
  from the constants; sendTaskMessage is not limited to the assigner.
- A worker whose run ends with unfinished subtasks keeps its cycle open and
  reports once, after them.
- Drop the per-turn fan-out and active-per-creator caps; the depth limit
  and the hourly sender budget (which counts assignments) remain.
- Drop session_signals.payload_json, lane, claimed_at and consumed_at,
  none of which was read, and the unreachable `dropped` status.
- The task send response reports `queued: bool`, as assistant_send_message
  does, instead of SignalRef/SignalStatus.
- One load_task_for_session (on the pool) and one now_ms; the repo's stop
  write is mark_stopping; the tools-side load_task wrapper is inlined.
sqlx 0.8 Pool::close closes idle connections without holding a permit but
releases one for each, so it can return while a connection checked out at
close time is still shutting down. On Windows that open data.sqlite made
remove_dir_all fail with os error 32. Wait for size() to reach 0, and
assert it in the delete test (failed 24/30 on Linux without the wait).
@juacker
juacker marked this pull request as ready for review October 16, 2026 13:48
@juacker
juacker merged commit f9b3642 into main Oct 16, 2026
2 checks passed
@juacker
juacker deleted the feat/task-communication branch October 16, 2026 13:48
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.

1 participant