fix(hooks): fail-fast stop/exit-plan hooks when no reviewer is engaged (#103) - #108
Conversation
The Claude Stop hook (monocle hooks on-stop) and the ExitPlanMode PermissionRequest hook (monocle hooks exit-plan) issue a BLOCKING request that waits for reviewer feedback, with a settings.json timeout of 345600s (96h). When the engine is running but no reviewer is actively driving Monocle, the hook could block for up to 96h, so the agent appeared hung between turns. The "no socket at all" case was already fail-fast (client.Connect returns ErrNotRunning), but autospawn detaches the engine so it survives TUI close, kept alive by a grace period + idle timeout. During that window the socket exists and connects, so the blocking wait never returned even though no reviewer was attached. Fix (server-bounded wait): - protocol: add an optional MaxWaitMs to AwaitReviewMsg and PollFeedbackMsg. Zero preserves the historical unbounded behaviour (backward compatible). - engine: boundedWaitCancel derives a cancel channel that fires on the original disconnect OR after MaxWaitMs. On timeout WaitForFeedbackCancellable returns nil without consuming the queue (the same shape as a disconnect), so handleAwaitReview returns HasActivity=true with no verdict and handlePollFeedback returns HasFeedback=false. The bound is ignored while a pause was explicitly requested, preserving the pause flow. - hooks: on-stop and exit-plan now pass a 10s MaxWaitMs. Backstop: - reduce the two installed settings.json hook timeouts from 345600s to 3600s so even a stale engine binary that ignores MaxWaitMs cannot wedge the agent for days. Pause-flow tradeoff: when no pause is requested the wait is short (fail-fast, the requested behaviour); when the reviewer pressed P (pause_requested) the short bound is ignored and the engine blocks until they submit, so an attentive reviewer is never cut off. Closes #103 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Greptile SummaryFixes the agent-hung-between-turns problem caused by
Confidence Score: 5/5Safe to merge. The bounded-wait change is backward compatible (zero = unbounded), the pause flow is preserved by design, and the TOCTOU race from the previous review is addressed with a re-check at timer fire time backed by a dedicated test. The core No files require special attention.
|
| Filename | Overview |
|---|---|
| internal/core/engine_impl.go | Adds boundedWaitCancel with timer goroutine; both handleAwaitReview and handlePollFeedback now use it correctly with defer stop(); TOCTOU re-check at timer fire time is present and tested |
| internal/core/engine_test.go | Four new tests cover: fail-fast path, feedback-delivered-within-window, pause-set-before-call (initial snapshot), and the TOCTOU race (pause set after the goroutine starts). Coverage is thorough for handleAwaitReview; the handlePollFeedback late-pause path is not repeated separately since boundedWaitCancel is shared. |
| internal/protocol/messages.go | Adds MaxWaitMs int with omitempty to both PollFeedbackMsg and AwaitReviewMsg; zero value preserves historic unbounded behaviour — backward compatible |
| internal/adapters/claude.go | Extracts blockingHookTimeoutSecs = 3600 constant and applies it to both blocking hook entries, replacing the hardcoded 345600 |
| cmd/monocle/hooks.go | Adds blockingHookMaxWait = 10s constant; both on-stop and exit-plan hooks now populate MaxWaitMs in their requests |
| internal/core/socketserver_test.go | End-to-end test over real socket confirms bounded AwaitReview returns promptly with HasActivity=true and no verdict on timeout |
| internal/adapters/claude_test.go | Timeout assertions updated to use the new blockingHookTimeoutSecs constant; no logic changes |
| docs/reference/cli.mdx | Timeout table updated from 345600s to 3600s; explanatory paragraph added for the fail-fast / pause behaviour |
Sequence Diagram
sequenceDiagram
participant Agent as Claude Agent
participant Hook as on-stop / exit-plan hook
participant Engine as Monocle Engine
participant Reviewer as Reviewer (TUI)
Agent->>Hook: turn ends / ExitPlanMode
Hook->>Engine: "AwaitReviewMsg{Wait:true, MaxWaitMs:10000}"
alt "Reviewer present & submits within 10 s"
Reviewer->>Engine: Submit(verdict)
Engine-->>Hook: "HasActivity=true, Action=verdict"
Hook-->>Agent: block / allow (verdict)
else No reviewer — timer fires at 10 s
Engine-->>Hook: "HasActivity=true, Action="" (no verdict)"
Hook-->>Agent: allow (proceed normally)
else Reviewer pressed P (pause_requested)
Note over Engine: IsPauseRequested() re-checked at timer fire
Note over Engine: timer goroutine discards bound, keeps blocking
Reviewer->>Engine: Submit(verdict)
Engine-->>Hook: "HasActivity=true, Action=verdict"
Hook-->>Agent: block (reviewer verdict honoured)
end
Reviews (2): Last reviewed commit: "fix(core): honor late pause request and ..." | Re-trigger Greptile
…d wait Addresses Greptile review on PR #108. P1 (TOCTOU race): boundedWaitCancel sampled IsPauseRequested() only once, before arming the timer goroutine. A reviewer who opened the TUI and pressed P within the bound window would be ignored — the timer fired, the bounded cancel closed, WaitForFeedbackCancellable returned nil, and the hook reported "no activity / allow", silently discarding the explicit pause. Re-check IsPauseRequested() inside the goroutine when the timer fires: if a pause is now requested, do NOT close the bounded channel; fall through to wait only on the original cancel/stop so the wait keeps blocking until the reviewer submits, as the pause flow intends. P2 (defer stop): handleAwaitReview called the boundedWaitCancel stop() inline rather than deferring it, so a panic in WaitForFeedbackCancellable would leak the timer goroutine. Use defer stop() immediately after obtaining it, matching the symmetric handlePollFeedback. Adds TestHandleAwaitReview_LatePauseHonored, which exercises the race the existing TestHandleAwaitReview_PauseIgnoresBound did not cover: no pause when the wait starts, pause requested after the call begins but before the bound elapses, then asserts the call keeps blocking and returns the real verdict on submit. Verified the test fails without the P1 fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Problem
Monocle installs Claude hooks into
~/.claude/settings.jsonwithtimeout: 345600seconds (96h). TheStophook (monocle hooks on-stop) and thePermissionRequest→ExitPlanMode hook (monocle hooks exit-plan) connect to the engine socket and issue a blocking request that waits for reviewer feedback. When the engine is running but no reviewer is actively driving Monocle, the hook could block for up to 96h, so the agent appears hung between turns.Root cause
The "no socket at all" case was already fail-fast —
client.ConnectreturnsErrNotRunningwhen the socket file is missing, and each hook exits 0 on connect failure. The real gap: autospawn detaches the engine so it survives TUI close, kept alive by a grace period + idle timeout. During that window the socket exists and connects, so the server-side blocking wait (FeedbackQueue.WaitForFeedbackCancellable) never returns — it only unblocks on client disconnect or a reviewer submit, never on "no reviewer present".SubscriberCountis not a valid "reviewer present" signal (the TUI subscribes withPassive:trueand isn't counted), so the fix can't key off it.Fix — server-bounded wait (primary)
MaxWaitMsfield toAwaitReviewMsgandPollFeedbackMsg. Zero preserves the historical unbounded behaviour (backward compatible).boundedWaitCancelderives a cancel channel that fires on the original client disconnect OR afterMaxWaitMs. On timeout,WaitForFeedbackCancellablereturnsnilwithout consuming the queue — exactly the same shape as a client disconnect — sohandleAwaitReviewreturnsHasActivity=truewith no verdict andhandlePollFeedbackreturnsHasFeedback=false. Both are the "no feedback, turn proceeds" results the hooks already emit asallow/normal-stop.on-stopandexit-plannow pass a 10sMaxWaitMs.Fix — backstop
settings.jsonhook timeouts from345600to3600(1h), so even a stale engine binary that ignoresMaxWaitMscan't wedge the agent for days. Extracted to a namedblockingHookTimeoutSecsconstant.Pause-flow tradeoff (important)
The bound is honoured only when no pause is requested. If the reviewer pressed P (
pause_requested),boundedWaitCancelreturns the original cancel unchanged and the engine blocks until the reviewer submits, just as before — an attentive reviewer is never cut off mid-review. When no pause is requested, the wait fails fast (~10s), which is the explicitly requested behaviour. This is a deliberate, conservative split rather than a blanket short timeout.Tests
internal/core/engine_test.go: boundedhandleAwaitReview/handlePollFeedbackreturn promptly with the no-feedback result on a dirty session with no reviewer; a submission within the window is still delivered; and withpause_requestedset the short bound is ignored (call keeps blocking until submit).internal/core/socketserver_test.go: end-to-end over the real socket — boundedAwaitReviewon a dirty session returns promptly withHasActivity=trueand no verdict.internal/adapters/claude_test.go: timeout assertions updated to the new value via the constant.devbox run -- make build,make test, andmake lintall pass.Docs:
docs/reference/cli.mdxhook-timeout table updated (345600s → 3600s) with a note explaining the fail-fast / pause behaviour.Closes #103
🤖 Generated with Claude Code