Skip to content

fix(webhook): cap dispatch on running hook sessions, not registry keys - #286

Merged
lionello merged 4 commits into
masterfrom
fix/280-cap-counts-live-panes
Aug 19, 2026
Merged

fix(webhook): cap dispatch on running hook sessions, not registry keys#286
lionello merged 4 commits into
masterfrom
fix/280-cap-counts-live-panes

Conversation

@defangdevs

@defangdevs defangdevs commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Motivation

modules/src/webhook-spawn.sh capped standing-watch dispatch by counting
registry keys:

live=$("$JQ" -r '[.sessions | keys[] | select(startswith("hook-"))] | length' "$FILE")

Nothing expires a key. stopped is written by the pane epilogue
(modules/src/mark-stopped.sh) and only agent-box-session rm clears the
entry, so a hook session that finished weeks ago kept holding dispatch
capacity — the variable was even named live. At four such entries every
standing watch goes silently inert, and the drop is journal-only (#170). The
origin box measured 2 of the 4 default slots held by stopped=true entries
with zero live hook-* tmux sessions.

What changed

The cap now counts what the box is running, not what its bookkeeping
accumulated:

  • used capacity = live hook-* tmux sessions ∪ listed hook-* entries
    that are not stopped. The second half matters because the supervisor's
    reconcile loop restarts a non-stopped entry within ~2s, so it is load even
    in the second before it has a pane — a burst of dispatches cannot slip past
    the cap through that window. The first half matters because a pane no entry
    claims (hand-started, or delisted while still running) is a real agent.
  • a stopped entry is free capacity: nothing respawns it until someone
    runs agent-box-session restart.
  • the probe only runs once the key count already claims we are full, so the
    common path costs nothing.
  • conservative failure mode: if tmux cannot be run at all, the cap falls
    back to today's key count and says so on stderr. Over-counting drops a batch;
    reading a failed probe as "nothing is running" would uncap spawning
    altogether, which is worse than the bug.

User-visible effects

  • A standing watch keeps dispatching while finished hook-* entries are still
    listed. Nobody has to run agent-box-session rm to un-wedge a watch.
  • agent-box-session stop NAME now frees a dispatch slot (it did not before).
  • The drop message changed: N hook-* sessions are running or queued to start (max M); dropping this batch — 'agent-box-session ls' shows which; stopping one frees its slot and 'agent-box-session rm NAME' delists it for good.
  • README's dispatch bullet now says the cap counts running sessions.
  • No new options, no migration, no AWS/IAM/networking/cost impact.

The tmux-on-PATH finding (the constraint this fix had to design around)

webhook-spawn.sh runs as the receiver unit's LOCAL_WEBHOOK_SPAWN_CMD child,
and that unit's PATH is [ pkgs.jq pkgs.coreutils sessionCli ]
(modules/agent-box.nix.in, receiver path). I read the generated unit rather
than trusting the option list — tests/golden/web/units/agent-box-webhook-agent.service:

Environment="PATH=…/jq-1.8.2-bin/bin:…/coreutils-9.11/bin:…/agent-box-session/bin:
             …/coreutils-9.11/bin:…/findutils-4.10.0/bin:…/gnugrep-3.12/bin:
             …/gnused-4.10/bin:…/systemd-261/bin:…"

The deployed box agrees — systemctl show -p Environment --value agent-box-webhook-agent.service | tr ' ' '\n' there (systemd 258.7) prints one
unquoted assignment per line, and its PATH= is
jq:coreutils:agent-box-session:coreutils:findutils:gnugrep:gnused:systemd
(plus the sbin mirror). No tmux.

tmux is not on it (only systemd's default suffix is added), and
sessionCli is a writeShellScriptBin — it does not wrap PATH, so
agent-box-session ls would have found no tmux either and reported every
session as not-live. Reusing ls was therefore the trap, not the shortcut: it
swallows the tmux error (2>/dev/null || true), so "tmux missing" and "nothing
running" are the same output — which is exactly the reading that would have
uncapped spawns. Filed as #287, since ls still has that shape for its own
callers.

So, per the AGENT_BOX_*_BIN convention (the supervisor's
AGENT_BOX_GREP_BIN/AGENT_BOX_FIND_BIN, and the settings daemon's own
AGENT_BOX_TMUX_BIN), the receiver unit now pins
AGENT_BOX_TMUX_BIN = "${pkgs.tmux}/bin/tmux" instead of widening its PATH.
The golden diff is exactly that one line plus the payload script.

Two more things I checked:

  • socket dir: derived in-script as /run/agent-box-$USER, never inherited.
    An ambient TMUX_TMPDIR (which programs.tmux with secureSocket exports
    through /etc/profile) would point the probe at an empty directory where
    every hook session looks finished and the cap stops holding — issue agent-box-session: honors an inherited TMUX_TMPDIR and swallows kill-session failures #268's
    trap, same rule and same value as src/session-cli.sh.
  • the receiver's own sandbox: it runs ProtectSystem=strict with
    ReadWritePaths=[ /home/<user> ], so /run is read-only there. A tmux
    client connecting to a socket on a read-only mount is fine (sb_permission
    only returns EROFS for regular files, directories and symlinks), and
    list-sessions starts no server and writes nothing. The test asserts this
    rather than leaving it as reasoning: it re-runs the pinned tmux under those
    same properties via systemd-run and requires it to see the live hook
    session.

Test (tests/webhook.nix)

A new leg, placed before the syncSessionPlugin leg (which restarts the agent
unit) and self-cleaning:

  1. reads the receiver unit's Environment, asserts tmux is absent from that
    PATH
    and that the pinned AGENT_BOX_TMUX_BIN is executable;
  2. drives the wrapper with exactly that environment (sudo -u agent env -i HOME=… <receiver PATH> AGENT_BOX_TMUX_BIN=…), so what the cap can observe
    in the test is what it can observe in production;
  3. asserts the pinned tmux sees the live hook session from inside a
    ProtectSystem=strict unit;
  4. builds the The hook-session cap counts registry keys, so finished sessions permanently consume dispatch capacity #280 shape at AGENT_BOX_HOOK_SESSION_MAX=2 — one hook session
    running, one stopped — and asserts the next batch still spawns;
  5. asserts the reverse: the same cap with both slots genuinely running
    drops the batch and names the count on stderr (liveness is the only
    difference between 4 and 5), and no session is created;
  6. asserts the conservative fallback: AGENT_BOX_TMUX_BIN=/nonexistent/tmux at
    MAX=3 drops with cannot ask tmux …, while the same call with the working
    pinned binary spawns (2 running of 3).

Proof it fails pre-fix. The VM tests are x86-only and this box is aarch64,
so I could not run checks.x86_64-linux.webhook here; instead I ran the
pre-fix script from HEAD against the fixture step 4 builds (one live
hook-* pane via a stub tmux, one stopped entry, MAX=2):

$ bash webhook-spawn.head.sh   # HEAD:modules/src/webhook-spawn.sh
agent-box-webhook-spawn: 2 hook-* sessions already exist (max 2); dropping this batch — remove finished ones with 'agent-box-session rm NAME'
-> exit 1

and the same fixture against this branch's script:

$ bash modules/src/webhook-spawn.sh
SPAWNED: add hook-event-6b79 --prompt You are a fresh agent session started by this box's webhook dispatcher: …
-> exit 0

The other five cases were exercised the same way: live-at-cap drops (1 hook-* sessions are running or queued to start (max 1)), broken probe falls back
(cannot ask tmux which hook-* sessions are live (/nonexistent/tmux did not run); counting all 2 registry entries instead), a server-down probe still
counts non-stopped listed entries, and an unlisted live hook-* pane counts.

Checks run

Native (aarch64), one at a time — all exit 0:

nix build -L .#checks.aarch64-linux.module-generated-up-to-date
  agent-box-module-generated-up-to-date> modules/agent-box.nix is up to date
nix build -L .#checks.aarch64-linux.golden-snapshot            EXIT=0
nix build -L .#checks.aarch64-linux.multi-user                 EXIT=0
nix build -L .#checks.aarch64-linux.module-single-file         EXIT=0
nix build -L .#checks.aarch64-linux.webhook-route              EXIT=0
nix build -L .#checks.aarch64-linux.download-route             EXIT=0

x86 VM tests evaluate (this session's shell guard refuses a command containing
nix eval, so I used the equivalent --dry-run, which prints the same drv):

nix build --dry-run .#checks.x86_64-linux.webhook
  /nix/store/6rj8vnnfgsc97brg1j2hxbq4hgx74ic8-vm-test-run-agent-box-webhook.drv
nix build --dry-run .#checks.x86_64-linux.sessions
  /nix/store/db4qrkysnq60xrvr4hmjfr4y7pvh87rx-vm-test-run-agent-box-sessions.drv

Test-driver gates:

/home/agent/bin/check-testscript.sh tests/webhook.nix
--- ty ---
All checks passed!
--- ruff ---
All checks passed!

CI (x86, where the VM tests actually run) is green on this head:
run 32195596674
Validate module & VM pass in 10m58s, vm-test-run-agent-box-webhook
included. Its log shows the new leg end to end, e.g. the wrapper being driven
with the receiver's own PATH (jq:coreutils:agent-box-session:coreutils: findutils:gnugrep:gnused:systemd — no tmux) plus
AGENT_BOX_TMUX_BIN=…/tmux-3.7b/bin/tmux, the ProtectSystem=strict
systemd-run probe finding hook-defangdevs-capbusy-c25c in 0.24s, capfree
spawning at MAX=2 with two keys, and capblocked failing at the same MAX=2.

nix run .#assemble and nix run .#update-golden were run and their output is
committed (the golden diff is the one AGENT_BOX_TMUX_BIN line per receiver
unit plus the payload script).

Deliberately left out

  • The optional reap (delist stopped hook entries with no pane, prune
    their filter files, retry once). Capacity no longer depends on it — a
    stopped entry is already free — and doing it from the receiver would race
    the supervisor and the settings daemon on sessions.json, which has no
    locking yet (the lock change for sessions.json has no lock: a concurrent writer resurrects a deleted session #254 is still in flight). Registry clutter
    is a separate concern from dispatch capacity; if we want a sweeper it belongs
    in the supervisor, next to the filter-file prune it already does.
  • No new option for the cap: AGENT_BOX_HOOK_SESSION_MAX stays env-only,
    exactly as before.

Merge conflicts to expect

modules/agent-box.nix and tests/golden/** are generated, and other branches
in flight regenerate the same files (the sessions.json lock change for #254 is
one). Any conflict here is mechanical: take master's generated files, then
re-run nix run .#assemble and nix run .#update-golden. I have deliberately
not resolved it against unmerged work. Branched from master at e04d446.

Closes #280.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PsqGhkpjsb4kKAdVz25xKN

@lionello

Copy link
Copy Markdown
Collaborator

@defangdevs rebase

@defangdevs
defangdevs force-pushed the fix/280-cap-counts-live-panes branch from 39673f4 to 3361515 Compare August 19, 2026 15:33
@defangdevs

Copy link
Copy Markdown
Owner Author

Rebased onto master (the #290/#292 changes to `webhookSpawn`/the receiver unit's env conflicted with this PR's own edits to the same block). Merged the two: kept master's `AGENT_BOX_FLOCK_BIN`/`AGENT_BOX_DEFAULT_AGENT`/`AGENT_BOX_HOOK_SESSION_ARGS` exports and this PR's `AGENT_BOX_TMUX_BIN` pin, folded the two comment blocks together, and regenerated `modules/agent-box.nix` + the golden fixture. `module-generated-up-to-date`, `multi-user`, `module-single-file`, `download-route`, `webhook-route`, and `golden-snapshot` all pass natively here; `checks.x86_64-linux.webhook` evaluates cleanly (can't run the VM on this box). Force-pushed with `--force-with-lease`.

defangdevs added a commit to defangdevs/local-channels that referenced this pull request Aug 19, 2026
A standing watch could not react to being addressed by name. On
2026-08-19 a `@defangdevs rebase` comment on defangdevs/agent-box#286
reached the receiver and was turned down:

    not spawning for issue_comment on defangdevs/agent-box
    — the subscribed watch declined it (when/drop rules or ignoreSenders)

The decline was correct. Predicate leaves compared whole values with
`in`/`notIn`, and a GitHub @mention lives inside `comment.body` with no
structured field beside it, so no list of values could ever name it. Nor
was this consumer policy that agent-box could carry instead: the consumer
never sees the event, because `dispatch_event` returns before
LOCAL_WEBHOOK_SPAWN_CMD runs. The missing piece was a mechanism, so it
belongs here (local-channels#13).

This adds one comparison operator, not an expression grammar. A leaf may
carry `contains` / `notContains` instead of `in` / `notIn`:

    {"path": "comment.body", "contains": ["@defangdevs"]}

They test a STRING value for any of the listed substrings,
case-insensitively — GitHub logins are case-insensitive, so "@defangdevs"
is the same work request as "@defangdevs". A non-string or absent value
contains nothing, so it fails `contains` and passes `notContains`, the
direction `notIn` already takes on a missing path. A leaf carries exactly
one of the four operators; two would need a precedence rule the language
has no room for.

Substrings must be non-empty. An empty one is in every string, which
would smuggle back the "subscribe to everything" 0.13.0 spent a release
making unexpressible.

No regex. Payload text is hostile, and a pathological pattern in somebody
else's comment body would stall the daemon. Every case in hand needs only
a substring.

Contracts left alone: the filter file schema gains no key, `in`/`notIn`
evaluate exactly as before, and an entry without predicates keeps the
built-in CI carve-outs. Existing filter files stay valid unchanged.

## Verification

`python3 -m unittest discover -s tests` — 107 tests, OK.

New coverage: case-insensitive matching and its negation; non-string and
absent values on both operators; a leaf carrying two operators, a bare
string instead of a list, an empty substring and a non-string substring,
each rejected by `predicate_error` at subscribe time and matching nothing
if hand-edited into a file; and two dispatch-level tests driving the real
Dispatcher — a mention comment spawns a fresh session while a plain
comment does not, and the box's own echo of the mention does not.

Downstream: defangdevs/agent-box needs the pin bump plus the policy rule
that uses this (agent-box#296).

Closes #33.


Claude-Session: https://claude.ai/code/session_01PsqGhkpjsb4kKAdVz25xKN

Co-authored-by: defangdevs <defangdevs@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Defang Claude and others added 4 commits August 19, 2026 22:58
The standing-watch cap counted hook-* keys in sessions.json, and nothing
expires a key: `stopped` is written by the pane epilogue and only
`agent-box-session rm` clears it. So a hook session that finished weeks ago
kept holding dispatch capacity, and at four of them every standing watch went
inert with nothing running — journal-only, so it looks like a watch that
stopped working (#170). The origin box sat at 2 of 4 slots held by corpses,
with zero live hook-* tmux sessions.

The cap now counts what the box is running: live hook-* tmux sessions, plus
listed hook-* entries that are not `stopped` (the supervisor restarts those
within ~2s, so they are load even before they have a pane). A `stopped` entry
is free capacity. Panes no entry claims count too, so a hand-started or
delisted-while-running agent still holds a slot.

tmux is deliberately not on the receiver unit's PATH (jq, coreutils and the
session CLI are all of it), so the probe gets a pinned binary through the unit
environment — AGENT_BOX_TMUX_BIN, the convention the supervisor already uses
for grep/find and the settings daemon for tmux — rather than a wider PATH. The
socket dir is derived, never inherited (#268). If the probe cannot run at all,
the cap falls back to the old key count: over-counting drops a batch, whereas
reading a failed probe as "nothing is running" would uncap spawning.

No reap: delisting entries from the receiver would race the supervisor and the
settings daemon on sessions.json, and capacity no longer depends on it.

Closes #280.
systemd quotes an Environment value that needs it (hookSessionArgs' JSON
does), and the quote would otherwise ride along into the env this leg
builds for the wrapper.
Otherwise the stop can land on a spawn still in flight, and "finished
entry with no pane" — the state the cap change is about — is asserted
against a session that never had one.
The #170 leg now runs on the sessions the legs above accumulated, right
after the settings page deletes one of them. That delete is asynchronous —
the supervisor reconciles within ~2s — and since #280 a live hook-* pane
that no registry entry claims is counted as used capacity too, so a delete
still in flight would put the wrapper's number one above the registry's and
fail the assertion that the two agree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsqGhkpjsb4kKAdVz25xKN
@defangdevs

Copy link
Copy Markdown
Owner Author

Rebased onto master (83d5d88), which now carries #298 — the refusal record for the same cap. The two changes are complementary and both survive: #298 made a refusal visible, this branch makes the number it reports correct. One number is now decided once and used everywhere.

modules/src/webhook-spawn.sh (the only source conflict). Master's block and this branch's block both replaced the same if [ -s "$FILE" ] cap check. The merged block keeps, in order: BOX_STATE/REFUSED, the MAX validation, record_refusal(), then the pinned-tmux probe and the keys/used computation. The refusal path now reads

  if [ "$used" -ge "$MAX" ]; then
    echo "... $used hook-* sessions are running or queued to start (max $MAX) ..." >&2
    record_refusal "$used"

so webhook-spawn-refused.json records the capacity the cap actually applied, not the registry key count it replaced. record_refusal's $1 doc and the record's "//" note say which number live is; the section heading changed from "the ceiling on ACCUMULATED hook-* sessions" to CONCURRENT.

modules/src/webhook-cli.sh (not a conflict, but it would have contradicted the wrapper). hook_sessions() counted registry keys and its comment claimed it was "the same query the wrapper counts". After this change it is not: a stopped entry is free capacity, so status/ls would have printed "every standing watch is inert" on a box that dispatches fine — the #170 indicator failing in the other direction. It now counts hook-* entries that are not stopped. It still has no pinned tmux, so it cannot see a live pane no entry claims; that can only under-count, and the wrapper stays the authority (lastRefusal is the decision that was enforced), which the comment now says. --help, the dispatch paragraph, the capacity warning and the shipped AGENTS.md snippet were reworded from "may exist at once" to "may RUN at once" / "stopping one frees its slot".

tests/webhook.nix. Both legs are present. The #170 leg runs on the sessions the earlier legs accumulated and the #280 leg resets state and builds its own fixture, so the #170 leg now comes first (it was last on master; after the #280 leg it would have found zero hook keys and a refusal file already written). Beyond the move:

modules/agent-box.nix and tests/golden/** were regenerated with nix run .#assemble and nix run .#update-golden, never hand-merged.

Checks. Native on aarch64, all exit 0: module-generated-up-to-date, golden-snapshot, module-single-file, multi-user, webhook-route, download-route. check-testscript.sh tests/webhook.nix — ty and ruff clean. nix eval .#checks.x86_64-linux.webhook.drvPath and .sessions.drvPath evaluate (VM tests are x86-only; CI runs them). The merged wrapper was also driven against a local fixture (one live pane, one stopped entry): it spawns at MAX=2, drops at MAX=1 recording live: 1 with two keys listed, falls back to the key count with cannot ask tmux … when the probe binary is missing, and counts an unlisted live pane.

@lionello
lionello merged commit c5ec66b into master Aug 19, 2026
1 check passed
@lionello
lionello deleted the fix/280-cap-counts-live-panes branch August 19, 2026 23:14
@github-project-automation github-project-automation Bot moved this from Backlog to Done in Agent-Box Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

The hook-session cap counts registry keys, so finished sessions permanently consume dispatch capacity

2 participants