You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Watcher's half of the cohort split. Fourth and last of the four migrations opened by notifier#43 — and the only one that is not a move. Watcher stays on this VM; archiver and replicator leave, and the broker leaves with neither of them. What migrates here is watcher's couplings, not its data.
Why this one is shaped differently
notifier#43 moved a service to a new host. Watcher is the host: node watcher / tag:watcher / 100.120.218.69 / watcher.exe.xyz is this box, and it also carries archiver, replicator, the archiver-operated broker, and the shared Postgres 16 cluster. Migrating watcher by subtraction means:
no pg_dump, no restore, no cutover outage, no Fernet-key-equivalent gate. notifier's R2 — the risk that actually carried that migration — has no analogue here.
the bus becomes the migration. Notifier was one outbound HTTP client with a 10 s timeout. Watcher publishes four streams and consumes two, in both directions, as long-lived in-process loops. Loopback Redis becomes a WireGuard hop.
the epic completes only when archiver and replicator have left. Phases below are ordered by that dependency, not by watcher's own readiness.
The one thing inherited wholesale is the closing note of notifier#43: a service split is not a disk-pressure remedy. That VM went 971 M → 1.1 G free and stayed at 95 %. This one is at 1.1 G free / 95 % used today. The measured reclaim is set out in R7 — and as on notifier, it is the venvs, not the databases, that carry it. The retention work in Phase 5 reclaims about as much as both departures combined, which is why it is a phase and not a footnote.
ARCHIVER_REPO_PATH (default /home/exedev/archiver) — conftest runs archiver's alembic to build the information schema
312 MB is misleading.procrastinate_events is 183 MB and procrastinate_jobs is 118 MB; every domain table together is ~11 MB (audit_log 1.5 MB, fetch_commands 504 kB, watched_items 96 kB, change_revisions 88 kB). 96 % of the database is job churn with no retention policy anywhere — scripts/cleanup.sh prunes the uv cache and dangling Docker images and does not touch Postgres.
The bus — the coupling that this epic is actually about
Redis runs here today on 127.0.0.1:6379, appendonly no, no requirepass, archiver-operated (archiver#109).
Watcher consumes 2:content.blobs (fact stream, single-member group watcher) and info.registry (config/state, groupless, AsyncBusTailReader replaying from 0-0 at every boot, then tailing).
Live streams confirmed on the broker: content.fetch-policy, content.revisions, content.replicate, content.artifacts, content.blobs, info.registry, plus replicator's replicator:cmd:fetch:* keys.
The client is built for loopback and only loopback.src/core/bus.py:147 is the entire construction:
No socket_timeout, no socket_connect_timeout, no health_check_interval, no retry_on_timeout, no reconnect policy. On loopback those defaults never mattered. Across a WireGuard hop they are the difference between a logged reconnect and a wedged consumer — see R1.
Inbound — who reaches watcher
HTTP:/api/v1 is gated by require_api_key (src/api/main.py:138). The dashboard is not gated at all — register_dashboard mounts every route, mutating HTMX routes included, with no dependency. See D3.
Bus: archiver and replicator consume watcher's four published streams. Unlike notifier — which was inbound-only over the tailnet — watcher's ACL needs both directions.
Outbound
Notifier at http://notifier:9000 / :9001 over the tailnet (already migrated, #280). GCS for the cannobserv wheelhouse and blob reads. Origin fetches are replicator's, not watcher's — co-core owns fetch → extract → fingerprint and watcher no longer fetches at all.
Not coupled
Nothing in src/ mirrors to a sibling (#159, #236). The archiver SDK and its path dependency were removed in #254; the only remaining sibling-repo dependency is ARCHIVER_REPO_PATH for conftest's alembic run, and that is a checkout dependency, not a service dependency — it survives archiver's service leaving, as long as the checkout does.
Decisions
#
Decision
Rationale
D1
Watcher stays; archiver and replicator move out.
Watcher is the largest tenant, holds the Postgres cluster, and is the only one with an operator UI on the proxy. Moving the other two is strictly less work than moving it, and leaves tag:watcher describing what it says.
D2
The broker moves to a neutral node, owned by neither watcher nor archiver.
Today's topology has archiver operating a broker that watcher, archiver and replicator all depend on. Leaving it on archiver's new VM would make one participant the mesh's centre and every other service's availability a function of archiver's deploys. A dedicated node costs one more VM and removes that.
D3
Exposure unchanged: 0.0.0.0:8000 behind the exe.dev proxy's login gate. No tailnet-only bind, no nftables restriction.
Verified: eth0 is 10.42.0.42 on this VM and notifier's audit reported 10.42.0.42 on that one — 10.42.0.0/16 is a per-VM namespace, not a shared tenant LAN, so the 0.0.0.0 bind exposes nothing to other tenants. The only external path is the proxy, which is login-gated by default. Taking this decision means skipping notifier's D3 and most of R1 — no tailnet_bind.sh in watcher's ExecStart, no boot race in the HTTP path, loopback curl keeps working.
D4
The dashboard stays unauthenticated; the network boundary is the boundary.
Recorded as a decision, not an oversight. The consequence is stated plainly in R6: the exe.dev public/private bit is the entire authentication story for the admin UI, and it is one share set-public away from being wrong.
D5
Retention before anything else. Prune procrastinate_events/procrastinate_jobs and give them a standing policy.
Originally scoped as a cutover-dump argument; with D1 there is no dump, so the justification is now purely disk — 301 MB of 1.1 G free. Weaker, still real, and it is the only phase here that reclaims anything meaningful.
notifier#43's hardest-won operational lesson is that tags bind at device registration — retagging needs logout + fresh up, cheap on a new node and not on one peers depend on. D1 means we never have to. As archiver and replicator leave, tag:watcher narrows from "three services" to "watcher" by subtraction, and the existing tag:watcher → tag:notifier:9000,9001 rule becomes precise without being edited.
D7
Broker auth is requirepass at minimum, Redis ACL users preferred.
observo-primary is a user-owned node on this tailnet. A tailnet-bound broker with no password is reachable by every node the ACL admits. Per-service Redis ACL users would additionally scope watcher to its six streams. Broker owner's call — recorded here as watcher's requirement.
D8
Retain the archiver checkout at /home/exedev/archiver after archiver's service leaves.
Exactly notifier#43's precedent, for the same reasons: ARCHIVER_REPO_PATH needs it for conftest's alembic, and SOCRATICODE_LINKED_PROJECTS resolves through it. A read-only convenience, not a deploy target.
What the split does not change
Stated once so it is not assumed away:
Single process stays load-bearing. One uvicorn runs the API, the embedded Procrastinate worker, the content.blobs fact consumer and the cache sweeper. A remote broker does not make --workers N any safer; it makes R1 worse, because a stalled read now has a network failure mode it never had on loopback.
Cohort agreement on D2 — the broker's home is not watcher's to decide alone. Needs archiver's and replicator's owners.
exe.dev API token. Not present in this repo's .env; notifier's was scoped new/ls/whoamiwithout rm, which was the right privilege. Note new has no --region flag and set-region was outside that scope — the reason notifier landed in pdx while this VM is lax.
SSH access to the new broker node. notifier#43 stalled on exactly this: ssh was not in the token's cmds allow-list and no local key was registered. Decide the admin path before provisioning, not after.
A tag:broker auth key — single-tag, Pre-approved, Tagged, non-Ephemeral, generated before first join. notifier#43's F1: a dual-tag key applies its tag set wholesale and cannot be narrowed by --advertise-tags.
Tailscale ACL updated before key generation.
Proposed policy addition:
{
"tagOwners": {
"tag:broker": ["autogroup:admin"],
"tag:archiver": ["autogroup:admin"],
"tag:replicator": ["autogroup:admin"]
},
"acls": [
// Every bus participant reaches the broker. The broker initiates nothing.
{ "action": "accept",
"src": ["tag:watcher", "tag:archiver", "tag:replicator"],
"dst": ["tag:broker:6379"] },
// Existing, unchanged — narrows in meaning as the other two leave this node.
{ "action": "accept", "src": ["tag:watcher"], "dst": ["tag:notifier:9000,9001"] },
{ "action": "accept", "src": ["autogroup:member"], "dst": ["*:*"] }
]
}
Plan
Phase 1 — Bus client hardening (independent; do first)
No infrastructure dependency. Everything here is worth doing even if D2 never happens.
Give src/core/bus.py:147 an explicit connection policy: socket_connect_timeout, socket_timeout, health_check_interval, retry_on_timeout. TDD, per methodology.
Establish what AsyncBusTailReader does when the connection drops mid-tail — resume from last id, restart at 0-0, or stop silently. Restarting at 0-0 is correct but expensive (apply-iff-generation > makes replay idempotent, Consume registry announcements and reconcile watched_items; drop the Archiver SDK #254); stopping silently is the failure that looks like an empty registry, which the same doc calls out as the mistake that fails silently. Test the answer, don't assume it.
Same question for the content.blobs group consumer, where XREADGROUP should resume cleanly — verify rather than assume.
Make a broker partition loud. WATCHER_BUS_REDIS_URL unset already skips loudly; a set URL that is unreachable must be equally loud and must not present as an idle system.
Phase 2 — Provision the broker node (blocked on prerequisites)
new --name broker via POST https://exe.dev/exec. Region lax to sit beside watcher, its highest-frequency participant — and note the region caveat above.
Setup script: sudo-prefixed throughout, logging to a user-writable path. notifier#43's P1 — the script began exec >>/var/log/…, which the unprivileged exedev user cannot open, and under set -euo pipefail that aborted before anything ran, leaving the auth key unshredded in /exe.dev/setup.
exeuntu ships Tailscale pre-installed; only tailscaled needs enabling. The curl | sh in notifier's script was never necessary.
Join as broker with the single-tag key via --auth-key=file:, then shred.
Install and configure redis-server: tailnet bind, requirepass/ACL users per D7, and a persistence policy the broker owner is happy with (appendonly no today).
The broker's own boot race is real and is R1 from notifier#43 verbatim — a tailnet-bound Redis started before tailscaled assigns the address is precisely observo#473, which cost two weeks of silently-starved jobs. After=tailscaled.service plus a wait that probes /proc/net/fib_trie, never ip addr (observo#479: the systemd sandbox SIGSYS-kills ip and blocks AF_NETLINKsilently, so the probe exits 0 and detects nothing). Redis 7's - bind prefix makes an optional bind possible here — unlike uvicorn — but an optional bind that silently comes up loopback-only is the wrong trade for a broker. Fail loudly.
Drop After=…redis-server.service / Wants=redis-server.service from deploy/watcher.service. The soft-ordering rationale in that comment block is about a local broker and stops being true; replace it rather than deleting it silently.
Extend tests/deploy/test_installed_unit_matches_repo.py to pin the new invariants.
Measure round-trip publish latency and the content.blobs consumer's steady-state behaviour over the tailnet. notifier's lax→pdx was 31 ms direct P2P after a DERP-relayed cold start at 85 ms — the cold path is the number that matters for a consumer loop waking after idle.
Phase 4 — Archiver and replicator leave (external dependency)
Tracked in their own repos, not here. Watcher's side is: confirm every stream it publishes still has its consumer, and that the ACL admits both directions.
As each leaves, remove its database and role from this cluster: archiver, archiver_test, role archiver — replicator has none. Remove the departed venvs (regenerate with uv sync), and /var/lib/replicator/blobs. Retain both checkouts per D8.
Verify tag:watcher now describes one service.
Phase 5 — Retention (D5; independent, do early)
Decide a retention window for procrastinate_events and procrastinate_jobs. Procrastinate 3.7.2 ships JobManager.delete_old_jobs (procrastinate/manager.py:247); nothing in this repo calls it.
Add it to the weekly scripts/cleanup.sh path so the 301 MB cannot silently rebuild.
Reclaim: a one-off VACUUM FULL after the first prune. Check free space first — VACUUM FULL rewrites the table and needs room for the copy, and there is 1.1 G free. Delete rows first, vacuum second; pg_repack if the margin is uncomfortable.
Record the actual reclaim honestly against the 95 % starting point, per notifier#43's closing note.
Phase 6 — Docs
AGENTS.md: Infrastructure (the service/port table describes a VM that will no longer exist), Server Lifecycle, The bus (broker is no longer "on this VM"), Environment Files.
docs/ARCHITECTURE.md → Redis and the bus: "Archiver operates redis-server on this VM" becomes a neutral node; Sibling services: archiver is no longer co-located.
docs/ENVIRONMENT.md: WATCHER_BUS_REDIS_URL row still reads redis://localhost:6379/0.
docs/COMMANDS.md: the Archiver-sibling test setup survives D8 — say so explicitly, because "archiver moved off this VM" reads like the checkout went with it.
docs/DEPLOYMENT.md, README.md, and src/core/db_safety.py's docstring. Note what notifier#43 got right here: the dev-and-prod-on-one-box arrangement that makes alembic's carve-out necessary does not change — what stopped being shared is the host, not that.
New docs/reference/tailscale.md describing this node, deferring the general primer to observo's copy.
Risks
R1 — the bus client has no failure policy, and the single process makes that structural.src/core/bus.py:147 is a bare Redis.from_url with library defaults. Loopback masked it completely. On a WireGuard hop, a stalled read has no bound, and because one uvicorn process runs the API and the content.blobs consumer and the embedded worker, a wedged consumer coroutine can sit inside a process whose /health stays green. This is the highest risk in the epic and the reason Phase 1 runs first and independently. Notifier's equivalent — NotifierClient's 10 s timeout — already existed before its migration; watcher's does not exist yet.
R2 — info.registry is groupless and replays from 0-0 every boot. The design is deliberate (#254): reading from $ fails silently, showing a booting worker an empty registry indistinguishable from a genuinely empty one. A remote broker adds reconnects, which is a new way to re-enter that path mid-life rather than at boot. Replay is idempotent under apply-iff-generation >, so the correctness risk is low and the cost risk is real — full replay on every blip. Phase 1 step 2 is the gate.
R3 — content.fetch crossing a network means fetches can stop quietly. A partition stops command publication; replicator falls back to its conservative default pacing and nothing obviously breaks. The symptom of a broken bus is a slow system, not a failing one. Phase 1 step 4 exists for this.
R4 — this epic's completion is not in this repo. Phases 4 and 6 cannot close until archiver and replicator have migrated, each of which is its own epic in its own repo under the cross-repo policy. Watcher's Phases 1, 2, 3 and 5 are independently deliverable; the tracking issue should not read as blocked on them.
R5 — VACUUM FULL on a 95 %-full disk. 183 MB + 118 MB of table to rewrite with 1.1 G free. Order matters: delete first, vacuum second, check df between.
R6 — D3 and D4 together mean the exe.dev public/private bit is the entire boundary on an unauthenticated admin UI. One share set-public — deliberate or mistaken — puts every dashboard route, mutations included, on the open internet, and nothing local would notice. Verify the current setting (ssh exe.dev share …) as part of this epic rather than assuming it, and record the result. Accepted per D3/D4; recorded so it is a known position rather than a discovery.
R7 — the disk expectation, measured rather than assumed. notifier#43's closing note applies directly, and the numbers here say the same thing:
What leaves
Size
replicator/.venv
181 MB
archiver/.venv
131 MB
DBs archiver + archiver_test
18 MB
/var/lib/replicator/blobs
2 MB (backend is gcs; local dir is spillover)
Total
~330 MB
Phase 5 retention, for comparison
~300 MB
Replicator has no database in this cluster at all — it holds Redis plus a GCS blob backend. So the departures and the prune are worth roughly the same, and both together take a 19 G disk from ~1.1 G free to ~1.7 G — 95 % → ~91 %. Real, and not a fix. Do not plan this epic around the split solving the 95 %.
R8 — no rollback rehearsal is possible for the bus cutover. notifier's rollback was two URLs and a restart, with the old data untouched. Reverting Phase 3 means repointing WATCHER_BUS_REDIS_URL back at a local broker that, by Phase 4, no longer exists. Sequence Phase 3 before Phase 4's decommission, and keep the local broker running until the remote one has carried real traffic.
Success criteria
src/core/bus.py builds its client with explicit timeouts, health checks and a retry policy, test-guarded
Reconnect behaviour is established by test for both consumers — content.blobs (grouped) and info.registry (groupless, 0-0)
An unreachable-but-configured broker is loud, and does not present as an idle system
Broker node is tagged, non-expiring, tailnet-bound, password- or ACL-protected, and survives a reboot with NRestarts=0
Watcher publishes all four streams and consumes both across the tailnet, with cold-path and warm-path latency measured and recorded
tag:watcher describes exactly one service
procrastinate_events + procrastinate_jobs under a standing retention policy invoked by scripts/cleanup.sh; reclaim recorded honestly against 95 %
ARCHIVER_REPO_PATH still resolves and the full suite is green, archiver's service having left
The exe.dev proxy's public/private setting for this VM verified and recorded
Docs describe a watcher-only VM and a neutral broker
Follow-ups (separate issues)
Archiver repo — its own migration epic, plus the broker-ownership handover implied by D2. Cross-repo policy: recommend, get approval, file there.
Replicator repo — its own migration epic.
Dashboard authentication — deliberately out of scope per D4. Worth its own issue on the merits, independent of any VM boundary.
Broker persistence and monitoring policy — appendonly no today; the neutral node's owner should restate it deliberately rather than inherit it.
Pattern and hard-won operational lessons inherited from notifier#43. Watcher's side of that migration closed in #280.
Watcher's half of the cohort split. Fourth and last of the four migrations opened by notifier#43 — and the only one that is not a move. Watcher stays on this VM; archiver and replicator leave, and the broker leaves with neither of them. What migrates here is watcher's couplings, not its data.
Why this one is shaped differently
notifier#43 moved a service to a new host. Watcher is the host: node
watcher/tag:watcher/100.120.218.69/watcher.exe.xyzis this box, and it also carries archiver, replicator, the archiver-operated broker, and the shared Postgres 16 cluster. Migrating watcher by subtraction means:pg_dump, no restore, no cutover outage, no Fernet-key-equivalent gate. notifier's R2 — the risk that actually carried that migration — has no analogue here.The one thing inherited wholesale is the closing note of notifier#43: a service split is not a disk-pressure remedy. That VM went 971 M → 1.1 G free and stayed at 95 %. This one is at 1.1 G free / 95 % used today. The measured reclaim is set out in R7 — and as on notifier, it is the venvs, not the databases, that carry it. The retention work in Phase 5 reclaims about as much as both departures combined, which is why it is a phase and not a footnote.
Audit — what actually couples
Watcher's footprint on the shared VM
watcher.service(:8000, prod DB),watcher-cleanup.timer/.service(weekly)/etc/watcher/.env→DATABASE_URL,WATCHER_MIGRATION_DATABASE_URL,WATCHER_BUS_REDIS_URL,GOOGLE_APPLICATION_CREDENTIALS,GCS_BLOB_CREDENTIALS/etc/watcher/notifier.env(0600 root:root, #277/#278)/etc/watcher/co-gcs-blob-reader.json,/etc/watcher/co-pypi-reader.json.env→ 7 ×GH_TOKEN_*,TEST_DATABASE_URL,ANTHROPIC_API_KEY,WATCHER_DEV_NOTIFIER_*watcher(schema owner) +watcher_app(DML-only, #259/#270); DBswatcher(312 MB),watcher_test,watcher_a1..a4_test/run/watcher/build-idwatcher,tag:watcher,100.120.218.69, non-expiringARCHIVER_REPO_PATH(default/home/exedev/archiver) — conftest runs archiver's alembic to build theinformationschema312 MB is misleading.
procrastinate_eventsis 183 MB andprocrastinate_jobsis 118 MB; every domain table together is ~11 MB (audit_log1.5 MB,fetch_commands504 kB,watched_items96 kB,change_revisions88 kB). 96 % of the database is job churn with no retention policy anywhere —scripts/cleanup.shprunes the uv cache and dangling Docker images and does not touch Postgres.The bus — the coupling that this epic is actually about
Redis runs here today on
127.0.0.1:6379,appendonly no, norequirepass, archiver-operated (archiver#109).content.fetch(Phase 4: build the content.fetch producer against Replicator's issuer contract #241),content.fetch-policy(Phase 4 cutover blocker: per-domain rate limiting stops working the moment Watcher stops fetching #245),content.revisions(Publish observed source revisions to the bus; retire the Archiver HTTP write path and the scratch cache #253),info.watch-status(Publish scheduler state as a broadcast LWW status stream (info.watch-status) #264).content.blobs(fact stream, single-member groupwatcher) andinfo.registry(config/state, groupless,AsyncBusTailReaderreplaying from0-0at every boot, then tailing).content.fetch-policy,content.revisions,content.replicate,content.artifacts,content.blobs,info.registry, plus replicator'sreplicator:cmd:fetch:*keys.The client is built for loopback and only loopback.
src/core/bus.py:147is the entire construction:No
socket_timeout, nosocket_connect_timeout, nohealth_check_interval, noretry_on_timeout, no reconnect policy. On loopback those defaults never mattered. Across a WireGuard hop they are the difference between a logged reconnect and a wedged consumer — see R1.Inbound — who reaches watcher
/api/v1is gated byrequire_api_key(src/api/main.py:138). The dashboard is not gated at all —register_dashboardmounts every route, mutating HTMX routes included, with no dependency. See D3.POST /api/v1/watched-itemshas had no caller since archiver#158; the registry reconcile is the creation path (Consume registry announcements and reconcile watched_items; drop the Archiver SDK #254).Outbound
Notifier at
http://notifier:9000/:9001over the tailnet (already migrated, #280). GCS for the cannobserv wheelhouse and blob reads. Origin fetches are replicator's, not watcher's —co-coreowns fetch → extract → fingerprint and watcher no longer fetches at all.Not coupled
Nothing in
src/mirrors to a sibling (#159, #236). The archiver SDK and its path dependency were removed in #254; the only remaining sibling-repo dependency isARCHIVER_REPO_PATHfor conftest's alembic run, and that is a checkout dependency, not a service dependency — it survives archiver's service leaving, as long as the checkout does.Decisions
tag:watcherdescribing what it says.0.0.0.0:8000behind the exe.dev proxy's login gate. No tailnet-only bind, no nftables restriction.eth0is10.42.0.42on this VM and notifier's audit reported10.42.0.42on that one —10.42.0.0/16is a per-VM namespace, not a shared tenant LAN, so the0.0.0.0bind exposes nothing to other tenants. The only external path is the proxy, which is login-gated by default. Taking this decision means skipping notifier's D3 and most of R1 — notailnet_bind.shin watcher'sExecStart, no boot race in the HTTP path, loopbackcurlkeeps working.share set-publicaway from being wrong.procrastinate_events/procrastinate_jobsand give them a standing policy.watcher/tag:watcher/100.120.218.69.logout+ freshup, cheap on a new node and not on one peers depend on. D1 means we never have to. As archiver and replicator leave,tag:watchernarrows from "three services" to "watcher" by subtraction, and the existingtag:watcher → tag:notifier:9000,9001rule becomes precise without being edited.requirepassat minimum, Redis ACL users preferred.observo-primaryis a user-owned node on this tailnet. A tailnet-bound broker with no password is reachable by every node the ACL admits. Per-service Redis ACL users would additionally scope watcher to its six streams. Broker owner's call — recorded here as watcher's requirement./home/exedev/archiverafter archiver's service leaves.ARCHIVER_REPO_PATHneeds it for conftest's alembic, andSOCRATICODE_LINKED_PROJECTSresolves through it. A read-only convenience, not a deploy target.What the split does not change
Stated once so it is not assumed away:
content.blobsfact consumer and the cache sweeper. A remote broker does not make--workers Nany safer; it makes R1 worse, because a stalled read now has a network failure mode it never had on loopback.WATCHER_BUS_ENABLEDstill gates everything (Gate the bus producers behind a unit-only flag, as ARCHIVER_BUS_CONSUMER does for the consumer #262). A remote URL is still configuration, not permission. The flag stays unit-only, and URL-without-flag still aborts startup./etc/watcher/notifier.envis untouched (tests/conftest.py does not scrub NOTIFIER_BASE_URL/API_KEY - a prod-sourced pytest run dispatches to production notifier #277/Watcher's test/dev processes hold a production notifier API key — ~1289 fixture notifications delivered to prod Slack/Mailgun (notifier#22) #278). Notifier already moved; watcher's side of that is done.Prerequisites
.env; notifier's was scopednew/ls/whoamiwithoutrm, which was the right privilege. Notenewhas no--regionflag andset-regionwas outside that scope — the reason notifier landed inpdxwhile this VM islax.sshwas not in the token'scmdsallow-list and no local key was registered. Decide the admin path before provisioning, not after.tag:brokerauth key — single-tag, Pre-approved, Tagged, non-Ephemeral, generated before first join. notifier#43's F1: a dual-tag key applies its tag set wholesale and cannot be narrowed by--advertise-tags.Proposed policy addition:
{ "tagOwners": { "tag:broker": ["autogroup:admin"], "tag:archiver": ["autogroup:admin"], "tag:replicator": ["autogroup:admin"] }, "acls": [ // Every bus participant reaches the broker. The broker initiates nothing. { "action": "accept", "src": ["tag:watcher", "tag:archiver", "tag:replicator"], "dst": ["tag:broker:6379"] }, // Existing, unchanged — narrows in meaning as the other two leave this node. { "action": "accept", "src": ["tag:watcher"], "dst": ["tag:notifier:9000,9001"] }, { "action": "accept", "src": ["autogroup:member"], "dst": ["*:*"] } ] }Plan
Phase 1 — Bus client hardening (independent; do first)
No infrastructure dependency. Everything here is worth doing even if D2 never happens.
src/core/bus.py:147an explicit connection policy:socket_connect_timeout,socket_timeout,health_check_interval,retry_on_timeout. TDD, per methodology.AsyncBusTailReaderdoes when the connection drops mid-tail — resume from last id, restart at0-0, or stop silently. Restarting at0-0is correct but expensive (apply-iff-generation >makes replay idempotent, Consume registry announcements and reconcile watched_items; drop the Archiver SDK #254); stopping silently is the failure that looks like an empty registry, which the same doc calls out as the mistake that fails silently. Test the answer, don't assume it.content.blobsgroup consumer, whereXREADGROUPshould resume cleanly — verify rather than assume.WATCHER_BUS_REDIS_URLunset already skips loudly; a set URL that is unreachable must be equally loud and must not present as an idle system.Phase 2 — Provision the broker node (blocked on prerequisites)
new --name brokerviaPOST https://exe.dev/exec. Regionlaxto sit beside watcher, its highest-frequency participant — and note the region caveat above.sudo-prefixed throughout, logging to a user-writable path. notifier#43's P1 — the script beganexec >>/var/log/…, which the unprivilegedexedevuser cannot open, and underset -euo pipefailthat aborted before anything ran, leaving the auth key unshredded in/exe.dev/setup.tailscaledneeds enabling. Thecurl | shin notifier's script was never necessary.brokerwith the single-tag key via--auth-key=file:, then shred.redis-server: tailnet bind,requirepass/ACL users per D7, and a persistence policy the broker owner is happy with (appendonly notoday).After=tailscaled.serviceplus a wait that probes/proc/net/fib_trie, neverip addr(observo#479: the systemd sandbox SIGSYS-killsipand blocksAF_NETLINKsilently, so the probe exits 0 and detects nothing). Redis 7's-bind prefix makes an optional bind possible here — unlike uvicorn — but an optional bind that silently comes up loopback-only is the wrong trade for a broker. Fail loudly.Phase 3 — Cut watcher over to the remote broker
WATCHER_BUS_REDIS_URLatredis://:<secret>@broker:6379/0. KeepWATCHER_BUS_ENABLED=1in the unit — both halves assigned, or startup aborts (Gate the bus producers behind a unit-only flag, as ARCHIVER_BUS_CONSUMER does for the consumer #262).After=…redis-server.service/Wants=redis-server.servicefromdeploy/watcher.service. The soft-ordering rationale in that comment block is about a local broker and stops being true; replace it rather than deleting it silently.tests/deploy/test_installed_unit_matches_repo.pyto pin the new invariants.scripts/dev_server.shandtests/conftest.pyclearing behaviour for a remoteWATCHER_DEV_BUS_REDIS_URL— a scratch bus is now a scratch database on a remote broker, and the Gate the bus producers behind a unit-only flag, as ARCHIVER_BUS_CONSUMER does for the consumer #262 hazard is unchanged in kind but wider in blast radius.content.blobsconsumer's steady-state behaviour over the tailnet. notifier's lax→pdx was 31 ms direct P2P after a DERP-relayed cold start at 85 ms — the cold path is the number that matters for a consumer loop waking after idle.Phase 4 — Archiver and replicator leave (external dependency)
archiver,archiver_test, rolearchiver— replicator has none. Remove the departed venvs (regenerate withuv sync), and/var/lib/replicator/blobs. Retain both checkouts per D8.tag:watchernow describes one service.Phase 5 — Retention (D5; independent, do early)
procrastinate_eventsandprocrastinate_jobs. Procrastinate 3.7.2 shipsJobManager.delete_old_jobs(procrastinate/manager.py:247); nothing in this repo calls it.scripts/cleanup.shpath so the 301 MB cannot silently rebuild.VACUUM FULLafter the first prune. Check free space first —VACUUM FULLrewrites the table and needs room for the copy, and there is 1.1 G free. Delete rows first, vacuum second;pg_repackif the margin is uncomfortable.Phase 6 — Docs
AGENTS.md: Infrastructure (the service/port table describes a VM that will no longer exist), Server Lifecycle, The bus (broker is no longer "on this VM"), Environment Files.docs/ARCHITECTURE.md→ Redis and the bus: "Archiver operatesredis-serveron this VM" becomes a neutral node; Sibling services: archiver is no longer co-located.docs/ENVIRONMENT.md:WATCHER_BUS_REDIS_URLrow still readsredis://localhost:6379/0.docs/COMMANDS.md: the Archiver-sibling test setup survives D8 — say so explicitly, because "archiver moved off this VM" reads like the checkout went with it.docs/DEPLOYMENT.md,README.md, andsrc/core/db_safety.py's docstring. Note what notifier#43 got right here: the dev-and-prod-on-one-box arrangement that makes alembic's carve-out necessary does not change — what stopped being shared is the host, not that.docs/reference/tailscale.mddescribing this node, deferring the general primer to observo's copy.Risks
R1 — the bus client has no failure policy, and the single process makes that structural.
src/core/bus.py:147is a bareRedis.from_urlwith library defaults. Loopback masked it completely. On a WireGuard hop, a stalled read has no bound, and because one uvicorn process runs the API and thecontent.blobsconsumer and the embedded worker, a wedged consumer coroutine can sit inside a process whose/healthstays green. This is the highest risk in the epic and the reason Phase 1 runs first and independently. Notifier's equivalent —NotifierClient's 10 s timeout — already existed before its migration; watcher's does not exist yet.R2 —
info.registryis groupless and replays from0-0every boot. The design is deliberate (#254): reading from$fails silently, showing a booting worker an empty registry indistinguishable from a genuinely empty one. A remote broker adds reconnects, which is a new way to re-enter that path mid-life rather than at boot. Replay is idempotent under apply-iff-generation >, so the correctness risk is low and the cost risk is real — full replay on every blip. Phase 1 step 2 is the gate.R3 —
content.fetchcrossing a network means fetches can stop quietly. A partition stops command publication; replicator falls back to its conservative default pacing and nothing obviously breaks. The symptom of a broken bus is a slow system, not a failing one. Phase 1 step 4 exists for this.R4 — this epic's completion is not in this repo. Phases 4 and 6 cannot close until archiver and replicator have migrated, each of which is its own epic in its own repo under the cross-repo policy. Watcher's Phases 1, 2, 3 and 5 are independently deliverable; the tracking issue should not read as blocked on them.
R5 —
VACUUM FULLon a 95 %-full disk. 183 MB + 118 MB of table to rewrite with 1.1 G free. Order matters: delete first, vacuum second, checkdfbetween.R6 — D3 and D4 together mean the exe.dev public/private bit is the entire boundary on an unauthenticated admin UI. One
share set-public— deliberate or mistaken — puts every dashboard route, mutations included, on the open internet, and nothing local would notice. Verify the current setting (ssh exe.dev share …) as part of this epic rather than assuming it, and record the result. Accepted per D3/D4; recorded so it is a known position rather than a discovery.R7 — the disk expectation, measured rather than assumed. notifier#43's closing note applies directly, and the numbers here say the same thing:
replicator/.venvarchiver/.venvarchiver+archiver_test/var/lib/replicator/blobsgcs; local dir is spillover)Replicator has no database in this cluster at all — it holds Redis plus a GCS blob backend. So the departures and the prune are worth roughly the same, and both together take a 19 G disk from ~1.1 G free to ~1.7 G — 95 % → ~91 %. Real, and not a fix. Do not plan this epic around the split solving the 95 %.
R8 — no rollback rehearsal is possible for the bus cutover. notifier's rollback was two URLs and a restart, with the old data untouched. Reverting Phase 3 means repointing
WATCHER_BUS_REDIS_URLback at a local broker that, by Phase 4, no longer exists. Sequence Phase 3 before Phase 4's decommission, and keep the local broker running until the remote one has carried real traffic.Success criteria
src/core/bus.pybuilds its client with explicit timeouts, health checks and a retry policy, test-guardedcontent.blobs(grouped) andinfo.registry(groupless,0-0)NRestarts=0tag:watcherdescribes exactly one serviceprocrastinate_events+procrastinate_jobsunder a standing retention policy invoked byscripts/cleanup.sh; reclaim recorded honestly against 95 %ARCHIVER_REPO_PATHstill resolves and the full suite is green, archiver's service having leftFollow-ups (separate issues)
appendonly notoday; the neutral node's owner should restate it deliberately rather than inherit it.Pattern and hard-won operational lessons inherited from notifier#43. Watcher's side of that migration closed in #280.