Skip to content

feat(acp): add --ttl to bound total harness lifetime - #5633

Draft
CryptoJones wants to merge 2 commits into
block:mainfrom
CryptoJones:feat/acp-ttl
Draft

feat(acp): add --ttl to bound total harness lifetime#5633
CryptoJones wants to merge 2 commits into
block:mainfrom
CryptoJones:feat/acp-ttl

Conversation

@CryptoJones

@CryptoJones CryptoJones commented Aug 12, 2026

Copy link
Copy Markdown

Summary

Adds --ttl / BUZZ_ACP_TTL: a wall-clock cap on the whole buzz-acp process. Default 0 (disabled), so existing behaviour is unchanged.

Every existing bound is scoped to a turn — --idle-timeout bounds one silent turn, --max-turn-duration bounds one turn's wall clock. --exit-after-inactivity looks like it covers the process, but last_activity is reset on every dispatch (dispatch_pending) and a heartbeat is a dispatch, so --heartbeat-interval continuously 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:

  1. at the deadline — fire the existing graceful shutdown, so in-flight prompts drain exactly as they do for SIGTERM;
  2. 30s later — force the exit.

The second stage is not belt-and-braces. The graceful signal is a watch channel observed only by the main run loop, and HarnessRelay::connect retries 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 ci passes 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:

Scenario Expected Result
Healthy relay, main loop running graceful exit at TTL exits at 60sTTL reached — shutting downwaiting for in-flight prompts
Stuck in initial relay-connect retry forced exit after grace exits at 92s (60s TTL + 30s grace)
Same, with the forced stage removed still running at 131s (the failure this design exists to prevent)

To reproduce the first case: run buzz-acp with BUZZ_ACP_TTL=60 against 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/

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>
@CryptoJones
CryptoJones requested a review from a team as a code owner August 12, 2026 06:35
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant