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
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
ToolBatchAbandonedis journal-internal, and thePromptExpirednack 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.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/_resolvedToolApprovalsdictionaries instead. The write-onlyApprovalTurnStatehierarchy is still present insrc/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,HasInterruptedToolBatchAfterRecoveryinLlmSessionActor.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 changeopenspec/changes/archive/2026-06-04-redesign-session-approval-state-machine/tasks.mdmarks items 2.1–2.4[x]despite the state never being consulted.Proposed direction (decisions from design discussion)
ApprovalTurnCoordinatorthat owns the dictionaries — one source of truth that handling consults, so it can't drift into a write-only breadcrumb again.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
ToolInteractionUpdateoutput, per-channel handlers, spec deltas, and a full test matrix) is in the first comment below.Verified fine (not part of the fix)
WaitForApprovalAsyncis cleanly cancelled on every actor-stop path (direct viatoolExecutionCt; sub-agent bridge transitively via cancellation registration + child supervision) — no leak, so only a regression test is warranted.ToolBatchStarted.AssistantMessage+ToolApprovalRequested), so post-restart redrive is faithful — no data-recovery work needed.References
CurrentTurnScopeattempt, dropped), refactor(sessions): remove dead _approvalTurnState breadcrumb #1508 (dead_approvalTurnStateremoval, folded here)openspec/specs/{session-state-machine,session-resume,tool-approval-gates}/spec.md