Skip to content

Tool approvals dangle silently and channel prompts stay live after a session restart #1522

Description

@Aaronontheweb

Summary

When a tool-approval prompt is outstanding and the session actor restarts (redeploy/crash) before the user responds, the approval is left half-finished: the approver is never told the prompt survived, and on the channel side the Approve/Deny buttons stay live-looking but dead. Root cause is an incomplete implementation from #1219 — the spec-required explicit approval-turn state machine was added but is write-only (never consulted), so the recovered-pending lifecycle was never wired up.

Symptoms

  • Restart with a pending approval: the session silently stays pending; the approver gets no notification that the prompt is still valid/clickable.
  • Abandonment (a new user message supersedes the prompt): the channel buttons are never disabled — ToolBatchAbandoned is journal-internal, and the PromptExpired nack explicitly skips the channel redraw, so a dead prompt stays clickable. A user can click it repeatedly and just get "expired" text each time while the buttons remain.
  • No expiry: approvalTimeout = Timeout.InfiniteTimeSpan; cleanup is purely lazy (only when a new message or a click happens to trigger it).

Root cause

PR #1219 ("persist approval turn context") correctly persisted the turn context and the parked command (so a post-restart redrive is faithful), but the intended approval-turn state machine (NoActive / Running / WaitingForApprovals / RecoveredWaitingForApprovals / Redriving / Abandoning) was written but never read — decisions run off the scattered _pendingToolInteractions / _resolvedToolApprovals dictionaries instead. The write-only ApprovalTurnState hierarchy is still present in src/Netclaw.Actors/Sessions/ToolApprovalState.cs (the standalone removal PR #1508 was closed so the cleanup folds into this larger fix — the dead code is the fossil this issue tracks finishing).

Concretely, on recovery both abandon paths gate on _pendingToolInteractions.Count == 0 (AbandonResolvedToolBatchAfterRecovery, HasInterruptedToolBatchAfterRecovery in LlmSessionActor.cs), so a recovered pending approval falls through and is never announced or cleaned up.

Spec gap

openspec/specs/session-state-machine/spec.md ("Approval turn state is explicit") requires handling to consult explicit state "instead of inferring the turn solely from … scattered pending dictionaries" — the implementation does the opposite. The archived change openspec/changes/archive/2026-06-04-redesign-session-approval-state-machine/tasks.md marks items 2.1–2.4 [x] despite the state never being consulted.

Proposed direction (decisions from design discussion)

  • Rebuild the approval-turn state as a load-bearing ApprovalTurnCoordinator that owns the dictionaries — one source of truth that handling consults, so it can't drift into a write-only breadcrumb again.
  • Policy: keep-clickable, no timeout — approvals keep their infinite lifetime and stay clickable across restarts.
  • On recovery with a pending approval: emit a new lifecycle output ("survived restart, still valid"); keep the prompt clickable.
  • On abandon/expiry: emit a lifecycle output so all three channels (Slack/Discord/Mattermost) disable the dead prompt's buttons.
  • Correct the synced specs via a new OpenSpec change (complete-tool-approval-prompt-lifecycle) and fix the falsely-checked tasks.

A detailed implementation plan (coordinator design, the ~40 dictionary-inference sites to migrate, the new ToolInteractionUpdate output, per-channel handlers, spec deltas, and a full test matrix) is in the first comment below.

Verified fine (not part of the fix)

  • The in-flight WaitForApprovalAsync is cleanly cancelled on every actor-stop path (direct via toolExecutionCt; sub-agent bridge transitively via cancellation registration + child supervision) — no leak, so only a regression test is warranted.
  • The command + trust context are already persisted (ToolBatchStarted.AssistantMessage + ToolApprovalRequested), so post-restart redrive is faithful — no data-recovery work needed.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingreliabilityRetries, resilience, graceful degradationsessionsLLM session actor, turn lifecycle, pipelines

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions