Chat: derive "working" from a real liveness signal, not the transcript-tail guess (root cause of #38 + #39) #62

Open
opened 2026-07-09 00:23:32 +02:00 by dominik.polakovics · 2 comments

Summary

deriveState infers conversational state from only the last content block of
the main transcript .jsonl
— a guess with no positive "the agent is alive and
producing" signal. This one weakness produces two opposite, already-filed
symptoms:

  • False working → operator lockout (#38). A frozen / echo tail (after
    /clear, or an agent that stops after a tool_result with no final
    assistant:text) pins working forever; the composer's Interrupt no-ops on an
    idle prompt.
  • False idle / needs-input → background work invisible (#39). A workflow's
    background subagents write their own session files; the main transcript ends
    with assistant:text, so the run reads needs_input / idle while subagents are
    still running.

This is the root-cause umbrella for both. To be designed by the maintainer
— filing now to capture direction; I'll dig in later.

Where it lives

  • deriveStateinternal/provider/claudecode/chat.go:322 (last-block → state).
  • No liveness input: the tailer recomputes only on transcript file change
    (internal/chat/tailer.go:154), so a frozen tail is never re-evaluated; and it
    watches a single file, missing sidecar subagent sessions (#39).
  • The parser ignores stop_reason / isSidechain / parentUuid / in-flight
    Task (internal/provider/claudecode/chat_types.go, compat.md §5).

Candidate directions (to weigh, not yet decided)

  • A real liveness probe — tmux pane activity (is the claude pane still
    rendering?) or process CPU — a positive "producing" signal to gate working
    on, instead of / alongside the tail guess.
  • Parse stop_reason; treat an unresolved Task / isSidechain sub-task as
    "working" (#39).
  • Widen the tailer to observe sidecar session writes so background work refetches.
  • A staleness / timeout downgrade was considered and rejected for the frontend
    change (heuristic-on-a-heuristic: a legit long tool writes no bytes for minutes
    and would false-flip to idle) — recorded here so it is not re-proposed without a
    real liveness signal.

Relationships

  • Root cause of #38 (false-working lockout) and #39 (false-idle for
    background workflows) — consider folding #39 in, or closing both against this
    once designed.
  • The composer always-Send change (#61) works around #38 in the UI but
    explicitly does not touch this derivation; the wrong badge / state remains
    until this lands.
## Summary `deriveState` infers conversational state from **only the last content block of the main transcript `.jsonl`** — a guess with no positive "the agent is alive and producing" signal. This one weakness produces two opposite, already-filed symptoms: - **False `working` → operator lockout (#38).** A frozen / echo tail (after `/clear`, or an agent that stops after a `tool_result` with no final `assistant:text`) pins `working` forever; the composer's Interrupt no-ops on an idle prompt. - **False idle / needs-input → background work invisible (#39).** A workflow's background subagents write their own session files; the main transcript ends with `assistant:text`, so the run reads `needs_input` / idle while subagents are still running. This is the **root-cause umbrella** for both. **To be designed by the maintainer** — filing now to capture direction; I'll dig in later. ## Where it lives - `deriveState` — `internal/provider/claudecode/chat.go:322` (last-block → state). - No liveness input: the tailer recomputes only on transcript **file change** (`internal/chat/tailer.go:154`), so a frozen tail is never re-evaluated; and it watches a **single file**, missing sidecar subagent sessions (#39). - The parser ignores `stop_reason` / `isSidechain` / `parentUuid` / in-flight `Task` (`internal/provider/claudecode/chat_types.go`, `compat.md §5`). ## Candidate directions (to weigh, not yet decided) - A **real liveness probe** — tmux pane activity (is the claude pane still rendering?) or process CPU — a positive "producing" signal to gate `working` on, instead of / alongside the tail guess. - Parse `stop_reason`; treat an unresolved `Task` / `isSidechain` sub-task as "working" (#39). - Widen the tailer to observe sidecar session writes so background work refetches. - A **staleness / timeout downgrade was considered and rejected** for the frontend change (heuristic-on-a-heuristic: a legit long tool writes no bytes for minutes and would false-flip to idle) — recorded here so it is not re-proposed without a real liveness signal. ## Relationships - Root cause of **#38** (false-`working` lockout) and **#39** (false-idle for background workflows) — consider folding #39 in, or closing both against this once designed. - The composer **always-Send** change (**#61**) works around #38 in the UI but explicitly does **not** touch this derivation; the wrong badge / state remains until this lands.
Author
Owner

This was generated by AI during triage.

Triage: ready-for-human

Category: bug
Summary: deriveState infers working/needs-input from the last transcript
content-block type alone, with no positive liveness signal — confirmed still
live in the current code (deriveState, internal/provider/claudecode/chat.go):
a trailing user:text | tool_use | tool_result | assistant:thinking reports
StateWorking unconditionally, and stop_reason / isSidechain / parentUuid
/ in-flight Task are parsed nowhere in the transcript types. #51 (merged since
this issue was filed) generalized the provider seam but did not touch this
derivation — the root cause the memory record for the /clear lockout flags as
"still live" is exactly this code, unchanged.

Current behavior:
State is a pure function of the last content-block key. No process/pane
liveness signal, no stop_reason, no notion of an in-flight subagent Task
feeds it. Two opposite symptoms follow: a frozen tail after a tool with no
final assistant:text pins working forever (#38's lockout), and a workflow
whose subagents are still running but whose main transcript ends in
assistant:text reads as idle/needs-input (#39).

Why this is ready-for-human, not ready-for-agent: the issue's own
candidate directions are real architectural alternatives, not an implementation
detail — tmux pane-activity probe vs. process CPU probe vs. parsing
stop_reason/isSidechain/in-flight Task vs. widening the tailer to watch
sidecar session files, in any combination. A time-based staleness downgrade was
already considered and explicitly rejected (a legitimate long-running tool that
writes no bytes for minutes would false-flip to idle). Picking the mechanism is
a product/architecture call — the source of truth for "the agent is alive" has
correctness and performance trade-offs (a pane/CPU probe needs host access an
AFK agent may not have the same way; a transcript-only fix may not be able to
distinguish all cases) that only the maintainer should settle before an agent
brief can be written.

Acceptance criteria (for whichever direction is chosen):

  • A tool-ending turn with no trailing assistant:text no longer pins
    working forever once the agent has actually stopped producing.
  • A workflow with live background subagents reads as working, not
    idle/needs-input, for as long as those subagents run (#39).
  • The chosen signal has no false-flip window for a legitimate long-running
    tool that writes no bytes for minutes (the staleness-downgrade approach
    is out — already rejected for this reason).

Out of scope:

  • The composer lockout workaround itself (#61 — already ready-for-agent,
    independent of this fix).
  • #39 is filed as ready-for-human alongside this issue, sharing the same root
    cause — worth deciding at the same time whether to keep it separate or fold
    it in once a direction is picked.

Relabeling needs-triageready-for-human.

> *This was generated by AI during triage.* ## Triage: ready-for-human **Category:** bug **Summary:** `deriveState` infers `working`/`needs-input` from the last transcript content-block type alone, with no positive liveness signal — confirmed still live in the current code (`deriveState`, `internal/provider/claudecode/chat.go`): a trailing `user:text | tool_use | tool_result | assistant:thinking` reports `StateWorking` unconditionally, and `stop_reason` / `isSidechain` / `parentUuid` / in-flight `Task` are parsed nowhere in the transcript types. #51 (merged since this issue was filed) generalized the provider seam but did not touch this derivation — the root cause the memory record for the `/clear` lockout flags as "still live" is exactly this code, unchanged. **Current behavior:** State is a pure function of the last content-block key. No process/pane liveness signal, no `stop_reason`, no notion of an in-flight subagent `Task` feeds it. Two opposite symptoms follow: a frozen tail after a tool with no final `assistant:text` pins `working` forever (#38's lockout), and a workflow whose subagents are still running but whose main transcript ends in `assistant:text` reads as idle/needs-input (#39). **Why this is `ready-for-human`, not `ready-for-agent`:** the issue's own candidate directions are real architectural alternatives, not an implementation detail — tmux pane-activity probe vs. process CPU probe vs. parsing `stop_reason`/`isSidechain`/in-flight `Task` vs. widening the tailer to watch sidecar session files, in any combination. A time-based staleness downgrade was already considered and explicitly rejected (a legitimate long-running tool that writes no bytes for minutes would false-flip to idle). Picking the mechanism is a product/architecture call — the source of truth for "the agent is alive" has correctness and performance trade-offs (a pane/CPU probe needs host access an AFK agent may not have the same way; a transcript-only fix may not be able to distinguish all cases) that only the maintainer should settle before an agent brief can be written. **Acceptance criteria (for whichever direction is chosen):** - [ ] A tool-ending turn with no trailing `assistant:text` no longer pins `working` forever once the agent has actually stopped producing. - [ ] A workflow with live background subagents reads as `working`, not idle/needs-input, for as long as those subagents run (#39). - [ ] The chosen signal has no false-flip window for a legitimate long-running tool that writes no bytes for minutes (the staleness-downgrade approach is out — already rejected for this reason). **Out of scope:** - The composer lockout workaround itself (#61 — already `ready-for-agent`, independent of this fix). - #39 is filed as ready-for-human alongside this issue, sharing the same root cause — worth deciding at the same time whether to keep it separate or fold it in once a direction is picked. Relabeling `needs-triage` → `ready-for-human`.
Author
Owner

Design decided — 2026-07-10 grill (maintainer + agent), full decision record

The "to be designed by the maintainer" part is done. This issue becomes the feature itself; two prerequisites were filed alongside (#92 seam refactor, #93 dead-session sweep).

Decisions

  1. Mechanism: hook-edge lifecycle markers. Claude-code arms turn-lifecycle hooks in its existing per-run settings file (Setup): prompt-submit / pre-tool fires write a working edge, Stop writes an ended edge, into the run's spool next to the dialog marker. A positive "the agent is producing" signal — no host probes (pane/CPU false-idles during API waits), no transcript-staleness timeouts (already rejected above, stays rejected).

  2. Backstop: downgrades only on hard evidence. Driven by the future notification layer's asymmetry: false-working means a late ping, recoverable by just sending a message (composer is always-Send since #61, and the reply re-arms fresh edges); false-idle means a spurious ping, unrecoverable trust damage. So nothing infers a downgrade — no transcript-order checks, no timeouts. The only downgrade evidence is a dead session, enforced by #93's sweep (EndRun(death, "session gone") → state becomes ended through the ordinary lifecycle override). A missed Stop on a live agent stays pinned until the next interaction — accepted cost, self-heals.

  3. Seam consequence: liveness never becomes a seam concept. Composition moves wholly into the adapter via #92 (ReadChat(runID, runtimeDir, transcriptPath); PendingDialog/BlockedState leave the seam; core keeps only the StateEnded override). The liveness edges here are then a purely claudecode-private feature: arm in Setup, read in ReadChat, fold into the marker's SpoolSig digest so the tailer notices a Stop while the transcript is byte-frozen. Core and the seam are untouched by this issue.

  4. State table (the semantics #92's conformance scenarios enforce per-adapter):

    Last liveness edge Transcript tail State
    working-edge anything working
    ended-edge assistant:text needs_input
    ended-edge tool_result / other idle (the #38 fix)
    any pending dialog question (dialogs dominate)
    no marker ever anything today's tail guess, unchanged

    The last row is both the migration story (a session spawned before this lands has no liveness hooks in its settings file — it keeps legacy behavior until it ends; live sessions are never re-armed) and honest degradation (a signal-less adapter never writes edges and keeps tail-guess semantics through the same path). Edges gate working-vs-not; the tail only picks the non-working flavor; adapters may differ on flavor (codex maps complete/aborted → needs_input) — conformance pins working-vs-not strictly, flavor is adapter policy.

  5. Spike first (codex Tier-2 pattern). Before implementation, a live claude-code session driven over tmux pins in compat.md: does Stop fire while background subagents / a Workflow still run; does subagent tool-use fire PreToolUse into the parent's hooks (refreshing the working edge); is there a usable start-edge for background work (PreToolUse on Task) to pair with SubagentStop; does Esc-interrupt fire Stop (if not, the adapter may write the ended edge from its own Interrupt recipe — it drives the Esc); what /clear rotation does to markers. #39's acceptance criterion is scoped to what the spike proves: if background-subagent liveness is not reliably hook-observable, the criterion is renegotiated on the evidence (e.g. "no false working") rather than met with inference — consistent with decision 2.

  6. Codex needs no work — existence proof, not a follow-up. The originally planned "codex LiveSignals follow-up" issue is NOT filed: the codex adapter (#87/ADR-0037, merged) already derives state from rollout-native lifecycle events (task_started/task_complete/turn_aborted) — a real liveness signal through the transcript file itself, visible to the tailer as ordinary writes, including a clean interrupt marker. The residual value of codex hooks (PermissionRequest dialogs if the never-ask posture ever changes; a notification substrate) stays recorded in internal/compat/codex/compat.md §8 with the re-verification recipe.

Plan (this issue, after #92 + #93 merge)

  1. Live hook spike → compat.md §ground truth (may start immediately — no dependency on the blockers).
  2. Implement: arm lifecycle hooks in Setup, marker file in the spool, fold into claudecode's ReadChat composition per the state table, SpoolSig covers the marker, SweepSpools GCs it.
  3. Conformance: state-composition scenario fixtures in the Tier-1 suite (per-adapter, signal-agnostic), claude-code + codex both proven against the working-vs-not contract.
  4. ADR (next free number) records: adapter-owned state composition, the hard-evidence-only backstop and its notification rationale, the state table, LiveSignals narrowed to lifecycle plumbing — building on ADR-0037's explicit "omitted, never faked" deferral.

Acceptance (supersedes the triage comment's criteria)

  • A tool-ending turn with no trailing assistant:text reads not-working once the agent stopped (the #38 fix; UI interrupt affordance follows the honest state).
  • Background-subagent liveness per the spike's evidence (#39 — criterion scoped by decision 5).
  • No false-flip for a legitimate long-running quiet tool (no timeouts anywhere — holds by construction).
  • A crashed session downgrades via #93, not via inference.
  • Pre-existing sessions keep legacy behavior until they end (no re-arming).
  • Closes #38 and #39.

Sequencing

Blocked on #92 (adapter needs runID+runtimeDir at read time) and #93 (the backstop) for the implementation phase; the spike phase has no blockers. Relabeling ready-for-humanneeds-triage as the sequencing gate (the #87/#80 precedent): re-triage to ready-for-agent when #92 and #93 merge — the design decisions above were the human part, now done.

## Design decided — 2026-07-10 grill (maintainer + agent), full decision record The "to be designed by the maintainer" part is done. This issue becomes the feature itself; two prerequisites were filed alongside (#92 seam refactor, #93 dead-session sweep). ### Decisions 1. **Mechanism: hook-edge lifecycle markers.** Claude-code arms turn-lifecycle hooks in its existing per-run settings file (`Setup`): prompt-submit / pre-tool fires write a *working* edge, `Stop` writes an *ended* edge, into the run's spool next to the dialog marker. A positive "the agent is producing" signal — no host probes (pane/CPU false-idles during API waits), no transcript-staleness timeouts (already rejected above, stays rejected). 2. **Backstop: downgrades only on hard evidence.** Driven by the future notification layer's asymmetry: false-*working* means a late ping, recoverable by just sending a message (composer is always-Send since #61, and the reply re-arms fresh edges); false-*idle* means a spurious ping, unrecoverable trust damage. So nothing infers a downgrade — no transcript-order checks, no timeouts. The only downgrade evidence is a **dead session**, enforced by #93's sweep (`EndRun(death, "session gone")` → state becomes ended through the ordinary lifecycle override). A missed Stop on a live agent stays pinned until the next interaction — accepted cost, self-heals. 3. **Seam consequence: liveness never becomes a seam concept.** Composition moves wholly into the adapter via #92 (`ReadChat(runID, runtimeDir, transcriptPath)`; `PendingDialog`/`BlockedState` leave the seam; core keeps only the `StateEnded` override). The liveness edges here are then a purely claudecode-private feature: arm in `Setup`, read in `ReadChat`, fold into the marker's `SpoolSig` digest so the tailer notices a Stop while the transcript is byte-frozen. Core and the seam are untouched by this issue. 4. **State table** (the semantics #92's conformance scenarios enforce per-adapter): | Last liveness edge | Transcript tail | State | |---|---|---| | working-edge | anything | `working` | | ended-edge | `assistant:text` | `needs_input` | | ended-edge | `tool_result` / other | `idle` (the #38 fix) | | any | pending dialog | `question` (dialogs dominate) | | **no marker ever** | anything | today's tail guess, unchanged | The last row is both the **migration story** (a session spawned before this lands has no liveness hooks in its settings file — it keeps legacy behavior until it ends; live sessions are never re-armed) and **honest degradation** (a signal-less adapter never writes edges and keeps tail-guess semantics through the same path). Edges gate working-vs-not; the tail only picks the non-working flavor; adapters may differ on flavor (codex maps complete/aborted → needs_input) — conformance pins working-vs-not strictly, flavor is adapter policy. 5. **Spike first (codex Tier-2 pattern).** Before implementation, a live claude-code session driven over tmux pins in compat.md: does `Stop` fire while background subagents / a Workflow still run; does subagent tool-use fire `PreToolUse` into the parent's hooks (refreshing the working edge); is there a usable start-edge for background work (`PreToolUse` on Task) to pair with `SubagentStop`; does Esc-interrupt fire `Stop` (if not, the adapter may write the ended edge from its own Interrupt recipe — it drives the Esc); what `/clear` rotation does to markers. **#39's acceptance criterion is scoped to what the spike proves**: if background-subagent liveness is not reliably hook-observable, the criterion is renegotiated on the evidence (e.g. "no false working") rather than met with inference — consistent with decision 2. 6. **Codex needs no work — existence proof, not a follow-up.** The originally planned "codex LiveSignals follow-up" issue is NOT filed: the codex adapter (#87/ADR-0037, merged) already derives state from rollout-native lifecycle events (`task_started`/`task_complete`/`turn_aborted`) — a real liveness signal through the transcript file itself, visible to the tailer as ordinary writes, including a clean interrupt marker. The residual value of codex hooks (`PermissionRequest` dialogs if the never-ask posture ever changes; a notification substrate) stays recorded in `internal/compat/codex/compat.md` §8 with the re-verification recipe. ### Plan (this issue, after #92 + #93 merge) 1. Live hook spike → compat.md §ground truth (may start immediately — no dependency on the blockers). 2. Implement: arm lifecycle hooks in `Setup`, marker file in the spool, fold into claudecode's `ReadChat` composition per the state table, `SpoolSig` covers the marker, `SweepSpools` GCs it. 3. Conformance: state-composition scenario fixtures in the Tier-1 suite (per-adapter, signal-agnostic), claude-code + codex both proven against the working-vs-not contract. 4. ADR (next free number) records: adapter-owned state composition, the hard-evidence-only backstop and its notification rationale, the state table, LiveSignals narrowed to lifecycle plumbing — building on ADR-0037's explicit "omitted, never faked" deferral. ### Acceptance (supersedes the triage comment's criteria) - [ ] A tool-ending turn with no trailing `assistant:text` reads not-working once the agent stopped (the #38 fix; UI interrupt affordance follows the honest state). - [ ] Background-subagent liveness per the spike's evidence (#39 — criterion scoped by decision 5). - [ ] No false-flip for a legitimate long-running quiet tool (no timeouts anywhere — holds by construction). - [ ] A crashed session downgrades via #93, not via inference. - [ ] Pre-existing sessions keep legacy behavior until they end (no re-arming). - [ ] Closes #38 and #39. ### Sequencing Blocked on #92 (adapter needs runID+runtimeDir at read time) and #93 (the backstop) for the implementation phase; the spike phase has no blockers. Relabeling `ready-for-human` → `needs-triage` as the sequencing gate (the #87/#80 precedent): re-triage to `ready-for-agent` when #92 and #93 merge — the design decisions above were the human part, now done.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab#62
No description provided.