Skip to content

fix(webhook): a dispatched session owns what it was spawned for - #194

Open
defangdevs wants to merge 1 commit into
masterfrom
fix/hook-session-owns-its-topic
Open

fix(webhook): a dispatched session owns what it was spawned for#194
defangdevs wants to merge 1 commit into
masterfrom
fix/hook-session-owns-its-topic

Conversation

@defangdevs

Copy link
Copy Markdown
Owner

Fixes half of #192: the half that lives in this repo.

Motivation

local-webhook 0.10.0 already refuses to spawn a standing-watch session for a CI event that a live session peer's own subscription claims. On this box it never found an owner among hook-* sessions, for a dull reason: a dispatched session subscribes to nothing. ~/.local/state/local-webhook/ held filter.agent-{main,work,portability}.json and no filter.agent-hook-*.json at all.

So a hook session driving a fix is invisible to the brake, and every event its own work generates spawns another agent onto the same work. Yesterday, 15:02 and 15:03: hook-…-c022 (check_run.completed) and hook-…-6db6 (workflow_run failure) were the same run 30918663010, and both started reading job 92023216058 independently. Earlier the same day, three sessions converged on one failing run on PR #182.

AGENTS.md has asked sessions to subscribe since the feature landed. The portability session did, correctly, with a note and everything. Prose works when an agent reads it and does nothing when it doesn't — and a spawned session's first move is to start triaging, not to file paperwork about what it owns.

Change

agent-box-webhook-spawn writes the new session's filter file before launching it, so ownership is a fact the dispatcher can read rather than something the agent has to declare. The preamble tells the session what it now holds, and to look at what else is running before duplicating someone's work.

Decisions worth arguing with:

  • The event's own key, not the watch's topic. A github:defangdevs/* watch must not hand one session ownership of the whole org; the seed is github:owner/repo for the repo the event came from.
  • Unpinned, renewOnEvent: true, ttlHours: 2. Ownership holds while events keep arriving and lapses after silence. A pinned session filter is the documented anti-pattern (it keeps interrupting a session that forgot to remove itself); ownership also ends for good with the pid, since peer_scopes_live() checks liveness and a leftover file claims nothing.
  • Best effort, never a barrier. If the state dir is missing or unwritable the wrapper warns on stderr and spawns anyway — a session without a filter behaves exactly as every hook session did before this.

Known gap

The claim only counts once the session's plugin peer opens its socket, seconds after the wrapper returns. The dispatcher's own 60s spawn window covers that in practice; closing it properly wants a CI cooldown in local-channels, tracked in #192 along with the object-granularity limit (the brake is CI-only because topics are repo-granular while ownership is object-granular).

Not in this PR, also #192: the same incident had a second root cause, a 0.8.0/0.10.0 skew between the session plugin cache and the daemon's pinned webhook.py that made the brake inert even for sessions that did subscribe. Fixed operationally on the box; the recurrence risk is #193.

Checks run

On the box (aarch64), all passing:

  • nix build .#checks.aarch64-linux.module-generated-up-to-date
  • nix build .#checks.aarch64-linux.{multi-user,module-single-file,download-route,webhook-route}
  • nix eval .#checks.x86_64-linux.webhook.drvPath — the VM test evaluates; it is x86-only, so CI runs it.

The wrapper's generated shell was extracted and exercised directly against a temp state dir: seeded topic is the narrow key even when the watch topic is github:defangdevs/*, ttlHours: 2 / renewOnEvent: true, and both failure paths (no LOCAL_WEBHOOK_STATE_DIR, unwritable dir) still spawn with the subscription sentence omitted. The seeded file was then fed to 0.10.0's own reader with a live socket for that key: owned_by_live_session claims workflow_run and check_run on the seeded repo, claims nothing on another repo, and claims nothing once the pid is gone.

tests/webhook.nix gains three assertions: the seeded file's shape and the prompt sentence; an end-to-end pass where a stand-in peer for the dispatched session makes the same CI failure spawn nothing (asserting the named suppression in the journal, that the hook count stays 1, and that the event arrives in that session's channel with its seeded note); and a direct wrapper invocation proving a wildcard watch still seeds the narrow key.

Security / cost

No new surface. The wrapper already ran as the user with payload-derived env; the seeded topic goes through jq --arg, never shell or jq program text, and lands in the user's own 0700 state dir. Deliveries to the seeded topic are events for a repo the session was already spawned to work on. No AWS, IAM, or networking impact.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PsqGhkpjsb4kKAdVz25xKN

local-webhook 0.10.0 declines to spawn a standing-watch session for a CI
event that a live session peer's subscription already claims. It never
found an owner among hook-* sessions, because a dispatched session
subscribes to nothing: ~/.local/state/local-webhook/ held a filter file
for every hand-driven session and none for any hook-*. So every event a
hook session's own work produced spawned another agent onto it — the
second event type one failing run emits (check_run.completed, then
workflow_run a minute later: two sessions reading one job), or its pushed
fix going red again. AGENTS.md asks sessions to subscribe; nothing made a
spawned one do it.

agent-box-webhook-spawn now seeds the new session's filter file before
launching it, so the brake can see the ownership without the agent having
to declare it, and the preamble tells the session what it holds.

The seed is the event's own key, never the watch's topic — owning
github:defangdevs/* would mute CI spawns for every repo in the org — and
it is unpinned with renewOnEvent, so ownership holds while events keep
arriving and lapses after silence rather than pinning a session that
forgot to remove itself to a repo forever. Ownership ends with the pid:
peer_scopes_live() checks liveness, so a leftover file claims nothing.
Seeding is best effort and never blocks a spawn; a session without a
filter behaves exactly as every hook session did before.

Refs #192

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsqGhkpjsb4kKAdVz25xKN
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.

2 participants