Skip to content

bug(activity): Claude sessions misreport state — active work shown as "ready", idle_prompt shown as "waiting_input", ready/idle flapping #2047

@harshitsinghbhandari

Description

@harshitsinghbhandari

Bug

The dashboard activity state for Claude Code sessions is frequently wrong. Three concrete, live-reproduced defects, all rooted in the same architectural problem: getClaudeActivityState trusts Claude's noisy native transcript (read first, aged off file mtime, classified by entry type) over the clean, hook-pushed .ao/activity.jsonl event log.

Source: live AO session ao-189 | Reported by: @harshitsinghbhandari | Analyzed against: 5d0b624f | AO: 0.9.1 | node v22.14.0
Confidence: High (reproduced live on a real session; root cause traced to file:line)

This is a focused, evidence-backed sibling of the umbrella redesign #1899. It is not fully covered there — #1899 enumerates 15 breakages but does not list the idle_prompt → waiting_input mapping defect or the assistant → ready shadowing of a freshly-pushed active. Related: #1899 (umbrella), #1894 (sticky-state wallclock decay), #1934 (do we need a separate ready state). In-flight hook-migration PRs that should converge with the fix: #2030 (opencode), #1950 (codex).

Reproduction (live .ao/activity.jsonl from session ao-189)

Defect A — active work shown as "Ready". While actively running tools / generating a response:

16:32:53.366  ready    ← Stop hook: previous turn ended
16:32:53.425  active
16:33:01.489  active    ← actively working (running a Bash tool) RIGHT NOW

Hook log correctly says active. Dashboard showed Ready. The pushed active was ignored.

Defect B — normal idle shown as "Waiting for input" for ~15 min.

16:15:46  ready
16:16:47  waiting_input   trigger: "Notification (idle_prompt)"   ← turn finished, awaiting user
16:31:34  active          ← user sent next message 15 min later

idle_prompt (Claude's "I'm done, waiting for you" nudge) was logged as waiting_input and, because actionable states are sticky (never decay), the dashboard showed "Waiting for input" for the entire 15-minute wait.

Defect C — ready/idle flapping (root cause shared with A): the activity clock is the log file's mtime, which Claude's noise re-snapshot writes (permission-mode, ai-title, pr-link) refresh even while idle → state oscillates ready⇄idle.

Root Cause

  1. mtime as the activity clock (Defects A & C). readLastJsonlEntry returns modifiedAt: fileStat.mtime (packages/core/src/utils.ts:170) and readLastActivityEntry likewise (packages/core/src/activity-log.ts:118). The native transcript's mtime is bumped by noise writes unrelated to real activity, so age-based classification is unstable. The clock should be the embedded per-entry entry.ts (which getActivityFallbackState already uses correctly — activity-log.ts:170).

  2. Native-first cascade + entry-type classification shadows pushed active (Defect A). getClaudeActivityState reads the native transcript first and returns before consulting the hook log. It maps assistant/summary entries to ready/idle — never active (packages/plugins/agent-claude-code/src/activity-detection.ts:429-431). While the agent is generating output (newest native entry = assistant), it is classified ready even though the hook log has a fresh active from PreToolUse/PostToolUse. The pushed truth loses to the polled noise.

  3. idle_prompt conflated with permission_prompt (Defect B). The activity-updater hook maps both notification types to waiting_input (packages/plugins/agent-claude-code/src/index.ts:451-459, and the Node/Windows variant ~573-581). Only permission_prompt / PermissionRequest is a genuine blocked-on-human state; idle_prompt is the turn-end "waiting for next instruction" signal and should be ready (decaying to idle), not a sticky waiting_input.

Fix

Make the hook-pushed .ao/activity.jsonl the source of truth for activity; demote the native transcript to cost/summary/uuid extraction, blocked detection, and a fallback only when hooks are absent. Concretely:

  • A tool_use is unambiguously working/active. A fresh pushed active event (PreToolUse/PostToolUse) must win — never be overridden by an assistant→ready read from the native transcript.
  • Classify off entry.ts, never fileStat.mtime.
  • Remap idle_prompt → ready (only permission_prompt/PermissionRequestwaiting_input).
  • Add hysteresis so state can't flap within one threshold window.
  • Bound sticky actionable staleness so a missed clearing event can't pin waiting_input forever.

This is the per-Claude instance of the unified single-clock reducer being designed (intended to be shared across claude-code/codex/opencode after #2030 and #1950 land), and aligns with #1899's inbox-vs-liveness split.

Impact

  • The dashboard's primary signal (is this agent working / done / blocked?) is untrustworthy for Claude sessions: active work reads as idle/ready, and finished-and-waiting reads as blocked.
  • Knock-on effects: notifier accuracy, stuck-detection, and any auto-reaction keyed on activity state inherit the wrong state.

Metadata

Metadata

Labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions