VPN Egress Orchestrator
Running many reachability tasks in parallel over one shared container. The stack tolerates this — it was configured for it — but only under a specific contract. Break the contract and agents kill each other's runs.
First: do you actually need subagents?
check-url.js takes any number of URLs in one invocation, sharing one
browser. Twenty URLs in one call is faster than twenty subagents, uses no
extra context, and produces one already-merged result.
MSYS_NO_PATHCONV=1 docker exec playwright-vpn \
node /scripts/check-url.js url1 url2 url3 … url20
Fan out only when each target needs real per-target work — reading a config to explain why one failed, cross-referencing a Caddyfile, chasing a redirect chain, judging whether a page rendered correctly. That's reasoning work, which parallelizes. Navigation alone does not.
If the task is "check these N URLs", stop here and use vpn-egress-testing.
The contract
1. The parent owns the stack. Always.
Subagents never run docker compose, tools/stack.sh up, or
tools/stack.sh stop.
Parallel agents managing one shared stack race: two up calls collide, and a
stop from the first agent to finish kills every run still in flight —
producing timeouts that look exactly like unreachable sites.
Before dispatching anything:
cd projects/vpn-egress && bash tools/stack.sh up
MSYS_NO_PATHCONV=1 docker exec playwright-vpn node /scripts/check-ip.js
Both must pass. check-ip.js reporting ok: false means every result you are
about to collect is worthless — fix it first (vpn-egress-setup).
Each shard also verifies the tunnel for itself before touching a URL, so an off-tunnel fan-out produces refusals rather than a batch of confident wrong answers. Checking here first is still worth it: it fails once, up front, instead of N times across N dispatched agents.
Do not run tools/exit.sh while a sweep is in flight. Changing the exit
city recreates gluetun, which destroys the network namespace every shard is
using — same class of damage as docker compose stop, and the half of the
results taken before the switch came from a different country than the half
after. Pick the exit city before dispatching.
2. Dispatch literal commands, not skill names
Put the exact command in each subagent's prompt, along with what pass and fail look like. Never "use the vpn-egress-testing skill" — subagents may have no Skill access, and one re-deriving commands will invent a variant that breaks the contract.
docker exec is available to any agent with Bash, which is why this works at
all.
3. Group output under one RUN_ID
Set a shared RUN_ID so every subagent's output lands under one directory and
you can find it all afterwards:
MSYS_NO_PATHCONV=1 docker exec -e RUN_ID=sweep-2026-07-30 playwright-vpn \
node /scripts/check-url.js --label=shard-1 https://a.example
Without it, each invocation gets its own timestamp+pid directory — still collision-free, just scattered.
4. Respect the concurrency ceiling
Four concurrent runs is verified good. All subagents docker exec into
the same playwright-vpn container, each launching its own Chromium against
one shared /dev/shm (1GB).
Beyond four, shard the work rather than raising the fan-out — give each of four agents more targets. Exceeding the shm budget produces timeouts, which are indistinguishable from unreachable sites, so the failure quietly corrupts your results instead of announcing itself.
Dispatch template
Give each subagent something self-contained:
Check these URLs from an external vantage point and diagnose each failure.
Run exactly this, from
projects/vpn-egress:MSYS_NO_PATHCONV=1 docker exec -e RUN_ID=<run-id> playwright-vpn \ node /scripts/check-url.js --label=<shard> --timeout=20000 <urls…>stdout is a JSON envelope; stderr is progress.
ok: trueneeds status < 400.
errorKind: "timeout"— nothing came back.errorKind: "refused"— actively rejected; port or backend problem.errorKind: "dns"— name doesn't resolve.- status 200 but
tls.issuerisCaddy Local Authority— reachable but no real browser will trust it; ACME issuance never completed. Report this as a failure, not a pass.Read the exit code before interpreting anything:
0— checked, at least one URL was reachable.1— checked, everything failed. A real result about the URLs.2— no answer was obtained. Either a usage error, or the tunnel gate refused to run (blocked: true,total: 0in the envelope). Report that you could not test. Never report these URLs as down.Do NOT run
docker compose,tools/stack.sh, ortools/exit.sh— the stack is already running and managed by the parent. Do NOT write your own check script. Do NOT add--allow-off-tunnelto make a refusal go away; it disables the guarantee that results came from outside the network.For each failing URL, investigate the cause and report it. Return the raw JSON envelope plus your per-URL diagnosis.
Substitute <run-id>, <shard>, and <urls…> before dispatching. The
subagent should never have to fill in a blank.
Merging results
A subagent's final report is not shown to the user — relay what matters yourself.
- Parse each returned envelope. Every result has the same shape, so they concatenate directly.
- Check
blockedon every envelope first. A shard withblocked: truetested nothing. Its URLs are unanswered, not down — fold them into the gap list in step 3, never into the failure list. - Reconcile against the input list. Name every URL no agent conclusively answered — an agent that crashed, timed out, or was blocked leaves a gap, and a gap silently dropped reads as a pass.
- Confirm every shard came from the same exit. Compare
tunnel.ipandtunnel.serveracross envelopes. If they differ, the tunnel moved mid-sweep and the results are not one comparable set — say so rather than merging them silently. - Report per-URL: status,
errorKind, andtls.issuerwhere it matters. - Separate reachable from correct. A 200 with an untrusted cert is a failure users will experience, and it is the most-missed one here.
- State the vantage point in the summary — exit city and country from
tunnel. A reachability claim without it can't be interpreted later.
Screenshots and JSON are under scripts/out/<RUN_ID>/. Read them if a
diagnosis needs evidence, then delete — they're disposable and gitignored.
Known bug — evidence can be overwritten when shards share a RUN_ID.
Screenshot filenames are de-duplicated per process, not per run directory,
so if the same host appears in two shards, the second overwrites the first's
screenshot with no error. The JSON results are unaffected. Until it's fixed,
shard by host (never split one host across shards) if screenshots matter.
Teardown
Only after all delegated work is done:
cd projects/vpn-egress && bash tools/stack.sh stop
Use the tool, not a raw docker compose stop. The containers may be owned by
a larger compose project that includes this repo's file, in which case a
plain docker compose stop run from this directory matches nothing and
silently succeeds without stopping anything. tools/stack.sh resolves the
owning project first.
stop, not down — keeps volumes and the Playwright install. Leave it
running if the user also wants the GUI browser.
Stopping while any subagent is still working produces timeouts that look like site failures. Confirm every agent has reported before tearing down.
Failure modes specific to fan-out
| Symptom | Cause |
|---|---|
| Several shards time out together, others fine | Concurrency ceiling exceeded — /dev/shm exhausted. Re-run the failed shard alone before believing it. |
| All shards fail at once, mid-run | Someone ran docker compose stop or tools/exit.sh, or the tunnel dropped. Check tools/stack.sh health and re-run. |
Shards return blocked: true and exit 2 |
The tunnel gate refused — egress wasn't confirmed. Nothing was tested. Fix the tunnel, then re-run; do not report those URLs as down. |
Shards disagree on tunnel.ip |
The exit moved mid-sweep. The results describe two different vantage points and must not be merged as one set. |
A shard reports MODULE_NOT_FOUND on a Windows path |
Its command lost MSYS_NO_PATHCONV=1. A dispatch-template bug, not a network result. |
| Screenshots missing or overwritten | A subagent wrote its own script with a fixed output path. They shouldn't be writing scripts. |
| Results contradict a known-good domain | Verify the browser is on the tunnel (check-ip.js) before trusting any of the batch. |
Related
vpn-egress-testing— one URL, or several in one call. The common case.vpn-egress-setup— stack won't start.projects/vpn-egress/docs/ARCHITECTURE.md— why one container is shared and what that constrains.