Skip to content

Claude headless claude -p wedges on turn-end guard in a primary home #2957

Description

@chenzengqiang

Summary

A one-shot claude -p run inside a firstmate primary checkout wedges into repeated forced continuations whenever supervision is needed and the watcher beacon is stale.

--claude mode of bin/fm-turnend-guard.sh intentionally ignores stop_hook_active (the 2026-07-21 incident). That is correct for a durable interactive session. It is wrong for a one-shot headless process: there is no next turn that can repair the watcher, so the guard keeps returning exit 2 until Claude's consecutive-block override fires.

bin/fm-claude-stop-autoarm.sh is registered on the same Stop event with "asyncRewake": true and "timeout": 28800. On a one-shot process that timeout can hold the CLI open for hours even after the 8-block cap. The hook's own header already calls this "the exact wedge grok 1.0.0 produced".

This is a second-order effect of #2485: the 8h auto-arm timeout SIGTERMs the process group, the watcher dies silently, and the next claude -p in the primary home is the thing that cannot be diagnosed. Related family: #2940 (Claude Stop contract edge not covered). Shape of the ask matches #1016 (a primary-home restriction that is too blunt and should be narrowed).

I have a local workaround and am not blocked. Happy to send a PR in whatever shape you prefer.

Why this is a Claude-only hole

docs/turnend-guard.md ## Compatibility limits documents headless behavior for every other harness:

  • OpenCode remains fail-open in headless opencode run
  • Cursor's stop step does not fire in headless cursor-agent -p
  • Grok: do not run the primary as a one-shot headless process
  • Kimi is out of scope entirely

Claude is the only harness whose Stop hook does fire in headless mode and can block the turn end. Everywhere else the missing case degrades to a no-op; here it degrades to a wedge.

--claude mode dropped the loop-guard to stop asyncRewake auto-arm from reopening the 2026-07-21 blind window. That accident requires a durable session. A one-shot run has no later turn to protect.

Upstream verification already uses claude -p inside a primary / scratch primary (docs/verification/supervision.md, docs/subagent-guard.md). "Just don't run headless in the primary home" is not the project's own practice.

## Regression coverage lists ~20 fm-turnend-guard cases and none for headless.

Measured, firstmate-independent repro (2026-08-24, Claude Code 2.1.233)

Temp dir, no firstmate files, one Stop hook that always exits 2:

{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"echo BLOCKED >&2; exit 2"}]}]}}
claude -p 'reply with exactly: ok' < /dev/null

No timeout wrapper.

Result:

  • elapsed 54s, process exited 0 (not an infinite hang)
  • Stop hook fired 10 times (1× stop_hook_active=false, then 9× true)
  • stdout empty — the model answer is consumed by the forced-continuation loop
  • so the official 8-consecutive-block cap does eventually end the turn, but a one-shot probe still burns a burst of model turns and can look wedged inside a 60s observer window

The original in-home incident (timeout 60) was killed before this cap. The "process never exits" observation is therefore not explained by the guard (capped) and points at the 28800s auto-arm hook holding the one-shot process.

Ancestry is a usable headless signal

A Stop hook under claude -p walked to claude -p reply with exactly: ok at depth 3 (HIT_PRINT_FLAG). Env CLAUDE_CODE_ENTRYPOINT=sdk-cli. This particular headless path did not stop at claude daemon run (#2314). #1641's warning against leaked env flags still applies; process structure is the preferred signal.

Proposed shape (please confirm or name another)

Treat a headless Claude session as default-mode semantics:

  1. New predicate fm_harness_session_is_headless on bin/fm-session-lock-lib.sh (ancestry ps -o args= contains -p / --print; undetectable ancestry returns 1 so current behavior is preserved).
  2. fm-turnend-guard.sh --claude: if headless, honor stop_hook_active (banner once, then allow). Skip the auto-arm cooperative wait / epoch ledger on that path.
  3. fm-claude-stop-autoarm.sh: stand down immediately on headless (exit 0). A watcher armed in the foreground of a one-shot process dies when claude -p exits, and the 28800s timeout can hold that process open.

I can implement that on a fork with tests + docs/turnend-guard.md / docs/watcher-continuity.md updates. If you would rather both Stop hooks silently exit 0 in headless, or docs-only for the guard, say so — I will follow that.

Local workaround (already in use)

claude -p --settings '{"disableAllHooks": true}' "..." < /dev/null

--settings clears the hook registry for that invocation only. Alternatives: cd out of the primary home, or claude --bare -p.

Artifacts from the original in-home incident

  • session 86b5d8f4-3bde-4ac5-a659-8fcad30f5a5f.jsonl lines 14–15: model printed ok, then TURN WOULD END BLIND (last beat: 14652s ago)
  • debug fa487699-d094-45dc-9c35-b22a1f0da1d6.txt: two [API REQUEST]s, first byte 247ms, then Hook Stop (Stop) error:

Happy to attach redacted excerpts if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-prTriage: real bug or VISION-aligned feature, open for a PR

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions