Skip to content

fix: expose terminal task_updated events as typed TaskUpdatedMessage#1016

Open
maxim092001 wants to merge 1 commit into
anthropics:mainfrom
maxim092001:fix/task-updated-terminal-event
Open

fix: expose terminal task_updated events as typed TaskUpdatedMessage#1016
maxim092001 wants to merge 1 commit into
anthropics:mainfrom
maxim092001:fix/task-updated-terminal-event

Conversation

@maxim092001
Copy link
Copy Markdown
Contributor

@maxim092001 maxim092001 commented Jun 2, 2026

Fixes #1019

Summary

Background tasks can leave consumers with stale active-task state. A background Bash task sometimes finishes by emitting only a generic system/task_updated message whose patch.status is terminal (completed/failed/stopped), with no typed TaskNotificationMessage:

TaskStartedMessage id=bs2r8eew4
SystemMessage subtype=task_updated id=bs2r8eew4 status=completed   # <- only this
AssistantMessage text=BG_WAIT_FINAL
ResultMessage active=['bs2r8eew4']                                  # consumer still thinks it's running
TIMEOUT active=['bs2r8eew4']

Consumers that track active task IDs from TaskStartedMessage and clear them only on TaskNotificationMessage then believe a finished task is still active. If they use that active set to bound receive_messages() (which is open-ended for the persistent client), they keep draining the stream until an outer timeout.

The parser previously mapped system/task_updated to a generic SystemMessage, so terminal task state — semantically lifecycle data — was never represented by a typed lifecycle message. This breaks the contract: a typed start event (TaskStartedMessage) was not guaranteed a typed terminal event.

Fix

Expose system/task_updated as a typed TaskUpdatedMessage(SystemMessage), mirroring the TypeScript SDK's SDKTaskUpdatedMessage:

field source
task_id data.task_id
patch data.patch (full dict preserved)
status patch.status
session_id data.session_id
uuid data.uuid

Plus:

  • TaskUpdatedStatusLiteral["running","completed","failed","stopped","cancelled"].
  • TERMINAL_TASK_STATUSES — shared frozenset of finished statuses, so consumers can clear active task IDs on a terminal status from either TaskNotificationMessage or TaskUpdatedMessage.
  • Lifecycle contract documented on both TaskUpdatedMessage and TaskNotificationMessage.

After this change, a bounded drain works without hanging:

if isinstance(message, (TaskNotificationMessage, TaskUpdatedMessage)) and message.status in TERMINAL_TASK_STATUSES:
    active.discard(message.task_id)
elif isinstance(message, ResultMessage) and not active:
    break

Defensive parsing

task_updated parsing uses .get() throughout, guards a non-dict patch, and derives status from patch.status — it can never raise on a lifecycle event (the observed CLI payload omits uuid/session_id). A patch carrying only end_time/result/error is left non-terminal (status=None) with the full patch preserved for callers that need more.

Backward compatibility

  • TaskUpdatedMessage subclasses SystemMessage, so existing isinstance(msg, SystemMessage) and case SystemMessage() checks still match; subtype and data remain populated with the raw payload.
  • Follows the existing convention where SystemMessage subclasses are covered structurally by the Message union rather than listed individually.

Tests

Added parser tests for: terminal completed; the minimal observed shape (no uuid/session_id); running/non-terminal; missing patch; patch present without status (preserved verbatim); non-dict/None patch (parametrized — never raises); all terminal statuses completed/failed/stopped/cancelled (parametrized); and SystemMessage backward-compat.

ruff check, ruff format, mypy src/, and the full suite (775 passed, 5 skipped) all green.

🤖 Generated with Claude Code

Background tasks sometimes finish by emitting only a generic
system/task_updated message whose patch.status is terminal, with no
typed TaskNotificationMessage. Consumers that track active task IDs from
TaskStartedMessage and clear them only on TaskNotificationMessage then
believe a finished task is still active and can hang draining the
persistent stream until an outer timeout.

Expose system/task_updated as a typed TaskUpdatedMessage(SystemMessage)
with task_id, patch, status, session_id, uuid — mirroring the TypeScript
SDK's SDKTaskUpdatedMessage. Parsing is defensive (all .get(), non-dict
patch guard, status derived from patch.status) so a lifecycle event can
never raise. Add TaskUpdatedStatus and a shared TERMINAL_TASK_STATUSES
frozenset so consumers can clear active task IDs on a terminal status
from either TaskNotificationMessage or TaskUpdatedMessage, and document
the lifecycle contract on both message types.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@maxim092001
Copy link
Copy Markdown
Contributor Author

Hey @qing-ant, could you please check it out? Thanks!

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.

Background task terminal completion not consistently exposed as a typed message (stale active-task state / hang)

2 participants