feat(acp): add --ttl to bound total harness lifetime - #5633
Draft
CryptoJones wants to merge 2 commits into
Draft
Conversation
buzz-acp has no upper bound on process lifetime. `--idle-timeout` bounds a
single silent turn and `--max-turn-duration` bounds one turn's wall clock, but
neither stops a harness that keeps receiving work. `--exit-after-inactivity`
comes closest, yet it resets on every dispatch, so a self-prompting harness
(`--heartbeat-interval`) permanently defeats it: the heartbeat is itself the
activity that restarts the clock.
That combination makes an unattended harness effectively immortal. A harness
backgrounded by a script reparents to init when its parent exits, so ending the
session that spawned it does not stop it — it keeps waking on its heartbeat and
issuing model calls indefinitely, against a billed provider, with nobody on the
other end.
Add `--ttl` / `BUZZ_ACP_TTL`, a wall-clock cap on the whole process. Default 0
(disabled), so existing behaviour is unchanged; values below 60s are rejected
because they would routinely kill the harness mid-turn and present as a flaky
agent rather than as a misconfiguration.
The TTL runs in two stages off one absolute deadline fixed before the relay
connect:
* at the deadline, fire the existing graceful shutdown so in-flight prompts
drain exactly as they do for SIGTERM;
* 30s later, force the exit.
The second stage is not belt-and-braces. The graceful signal is a watch channel
that only the main run loop observes, and the initial relay connect retries with
backoff well before that loop starts. A TTL armed after the connect, or without
the forced stage, is silently ignored by a harness stuck in startup — verified
by running one against an unreachable relay, where it sailed past its deadline
until the backstop was added.
Signed-off-by: Aaron K. Clark (CryptoJones) <cryptojones@owasp.org>
CONTRIBUTING requires new config variables to be documented. Adds the env var to the configuration table and a short note on why a process bound is not the same as a turn bound: `--idle-timeout` and `--max-turn-duration` cap a single turn, and `--exit-after-inactivity` is reset by every dispatch — including a heartbeat, which is what makes an unattended self-prompting harness unbounded. Signed-off-by: Aaron K. Clark (CryptoJones) <cryptojones@owasp.org>
CryptoJones
marked this pull request as draft
August 12, 2026 07:10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
--ttl/BUZZ_ACP_TTL: a wall-clock cap on the wholebuzz-acpprocess. Default0(disabled), so existing behaviour is unchanged.Every existing bound is scoped to a turn —
--idle-timeoutbounds one silent turn,--max-turn-durationbounds one turn's wall clock.--exit-after-inactivitylooks like it covers the process, butlast_activityis reset on every dispatch (dispatch_pending) and a heartbeat is a dispatch, so--heartbeat-intervalcontinuously resets the clock meant to stop an unattended harness.The result is a harness with no terminating condition. Scripts typically background
buzz-acp, so when the parent exits the process reparents to init — ending the session that launched it does not stop it. It keeps waking on its heartbeat and issuing model calls against a billed provider with nobody reading the output. Nothing errors; the turns succeed. The only external symptom is spend.How it's implemented. Two stages off one absolute deadline fixed before the relay connect:
The second stage is not belt-and-braces. The graceful signal is a
watchchannel observed only by the main run loop, andHarnessRelay::connectretries with backoff well before that loop starts, so a TTL armed after the connect is silently ignored by a harness stuck in startup. My first version did exactly that: it sailed past its deadline and was still alive at 131s on a 60s TTL, with unit tests green. Deriving both stages from one absolute instant also guarantees the forced exit can never precede the graceful signal when startup is slow.Values below 60s are rejected — they would routinely kill the harness mid-turn and present as a flaky agent rather than a misconfiguration.
Related issue
#5636 — opened alongside this PR with the problem statement and alternatives considered.
Searched open PRs and issues for duplicates: none found. The few open items touching ACP timeouts are turn-scoped and unrelated to process lifetime.
Testing
just cipasses locally.cargo test -p buzz-acp --lib— 740 passed. Four new unit tests cover the default, CLI/env plumbing, and minimum-value validation.Manual verification, against a stub relay (WebSocket + NIP-42 AUTH +
POST /query), since the interesting behaviour is timing that unit tests cannot observe:TTL reached — shutting down→waiting for in-flight promptsTo reproduce the first case: run
buzz-acpwithBUZZ_ACP_TTL=60against any reachable relay and confirm it exits on its own at 60s.No UI changes.
Proudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/