alerts: a restarts rule for a platform unit that cannot stay up (#284)
Human
Prompt
Yeah, let's do it all, and then follow or same-time do suite debt and optimisation
From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.
Summary
The agent reads each platform unit's NRestarts from systemd every tick and fires a restarts alert when one unit restarts more than 3 times in 10 minutes; it clears after 10 minutes with none. RuleState and Event gain an optional detail naming the unit.
Agent notes
Alert sentences for the rule are not written yet; they go through copy review with the rest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+362
−20
internal/host/alertsink_test.go+3 −3internal/host/metrics_loop.go+27 −9internal/host/metrics_loop_test.go+7 −7internal/host/restartwatch.go+133 −0internal/host/restartwatch_test.go+116 −0internal/metrics/alert.go+34 −1internal/metrics/alert_test.go+42 −0
CI failed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.011s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 334.503s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.082s
ok github.com/carlosframework/platform/cmd/moveprobe 92.235s
ok github.com/carlosframework/platform/cmd/zddprobe 0.380s
ok github.com/carlosframework/platform/internal/activator 227.752s
ok github.com/carlosframework/platform/internal/agentsock 0.016s
ok github.com/carlosframework/platform/internal/alerting 0.003s
ok github.com/carlosframework/platform/internal/analytics 0.028s
ok github.com/carlosframework/platform/internal/appconfig 2.360s
ok github.com/carlosframework/platform/internal/apperr 0.082s
ok github.com/carlosframework/platform/internal/applog 0.134s
ok github.com/carlosframework/platform/internal/awsinv 0.004s
ok github.com/carlosframework/platform/internal/boxchoice 0.004s
ok github.com/carlosframework/platform/internal/boxid 0.775s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.008s
ok github.com/carlosframework/platform/internal/caps 0.011s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.713s
ok github.com/carlosframework/platform/internal/changelog 0.289s
ok github.com/carlosframework/platform/internal/clamd 0.055s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 363.049s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 6.499s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.009s
ok github.com/carlosframework/platform/internal/dbverify 0.004s
ok github.com/carlosframework/platform/internal/dnsrec 0.003s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 2.210s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 26.044s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.120s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/genselect 0.015s
ok github.com/carlosframework/platform/internal/geo 0.015s
ok github.com/carlosframework/platform/internal/geoip 0.030s
ok github.com/carlosframework/platform/internal/host 95.731s
ok github.com/carlosframework/platform/internal/hostactors 0.331s
ok github.com/carlosframework/platform/internal/hostbox 2.211s
ok github.com/carlosframework/platform/internal/hostclaim 0.527s
ok github.com/carlosframework/platform/internal/hostevidence 0.017s
ok github.com/carlosframework/platform/internal/hostexec 4.921s
ok github.com/carlosframework/platform/internal/hostfence 0.032s
ok github.com/carlosframework/platform/internal/hostfiles 10.453s
ok github.com/carlosframework/platform/internal/hostfloor 0.017s
ok github.com/carlosframework/platform/internal/hostguard 0.965s
ok github.com/carlosframework/platform/internal/hostops 1.473s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.017s
ok github.com/carlosframework/platform/internal/hostquarantine 0.017s
ok github.com/carlosframework/platform/internal/hostrelocate 0.450s
ok github.com/carlosframework/platform/internal/hostresolve 0.135s
ok github.com/carlosframework/platform/internal/hostwrite 0.017s
ok github.com/carlosframework/platform/internal/infraview 0.017s
ok github.com/carlosframework/platform/internal/instancectl 1.831s
ok github.com/carlosframework/platform/internal/instanceexec 2.689s
ok github.com/carlosframework/platform/internal/instancefs 0.021s
ok github.com/carlosframework/platform/internal/instancekey 0.018s
ok github.com/carlosframework/platform/internal/instanceupdates 0.022s
ok github.com/carlosframework/platform/internal/layout 1.467s
ok github.com/carlosframework/platform/internal/lease 9.454s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.009s
ok github.com/carlosframework/platform/internal/ledger 0.013s
ok github.com/carlosframework/platform/internal/mail 0.169s
ok github.com/carlosframework/platform/internal/maintenance 0.083s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.566s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.080s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 20.985s
ok github.com/carlosframework/platform/internal/moves/archive 0.760s
ok github.com/carlosframework/platform/internal/objstore 0.158s
ok github.com/carlosframework/platform/internal/ops 0.162s
ok github.com/carlosframework/platform/internal/park 0.203s
ok github.com/carlosframework/platform/internal/privatefile 0.005s
2026/10/13 22:29:29 provision: listing apps/bab/blog/instances/: list refused — its records are unseen this pass
2026/10/13 22:29:29 provision: listing apps/bab/: listdirs refused — its records are unseen this pass
2026/10/13 22:29:29 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:29 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:29 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:29 provision: listing apps/bac/tito/instances/: list refused — its records are unseen this pass
2026/10/13 22:29:29 provision: listing apps/bac/tito/instances/: list refused — its records are unseen this pass
2026/10/13 22:29:29 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["sidecar","run"])
2026/10/13 22:29:29 provision: remove retired instance route shop.example: provision: claim admission unavailable
2026/10/13 22:29:29 provision: remove retired instance route shop.example: provision: claim admission unavailable
2026/10/13 22:29:29 provision: remove retired sidecar route worker.sidecar.internal: provision: claim admission unavailable
2026/10/13 22:29:29 provision: remove retired sidecar route worker.sidecar.internal: provision: claim admission unavailable
2026/10/13 22:29:29 provision: claim install acme.go.tito.io: provision: claim admission unavailable
2026/10/13 22:29:29 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:29 provision: claim install acme.go.tito.io: registry: claim row ownership changed or unknown
2026/10/13 22:29:29 provision: claim install bot.eleven.bac.sidecar.internal: provision: claim admission unavailable
2026/10/13 22:29:29 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["run"])
2026/10/13 22:29:29 provision: claim install bot.eleven.bac.sidecar.internal: registry: claim row ownership changed or unknown
2026/10/13 22:29:29 provision: refusing to mint route acme.go.tito.io (app "tito" acct "bac"): this box declares CARLOS_INBOUND=none (no-inbound) and cannot serve routed instance traffic
2026/10/13 22:29:29 provision: acme.go.tito.io: not minting this pass: the box's layout intent could not be read: no sealed config
2026/10/13 22:29:30 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: REFUSING to mint sidecar bot.eleven.bac.sidecar.internal: bot.eleven.bac.sidecar.internal-sidecar.env is staged on this box — the legacy file co-spawn and a native sidecar row are two mechanisms for one host; remove the file to migrate this host onto sidecar records
2026/10/13 22:29:30 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["run"])
2026/10/13 22:29:30 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["run"])
2026/10/13 22:29:30 provision: refusing to mint route acme.go.tito.io (app "tito" acct "bac"): this box declares CARLOS_INBOUND=none (no-inbound) and cannot serve routed instance traffic
2026/10/13 22:29:30 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: REFUSING to mint sidecar bot.eleven.bac.sidecar.internal: bot.eleven.bac.sidecar.internal-sidecar.env is staged on this box — the legacy file co-spawn and a native sidecar row are two mechanisms for one host; remove the file to migrate this host onto sidecar records
2026/10/13 22:29:30 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["run"])
2026/10/13 22:29:30 provision: acme.go.tito.io exists but is not the route this record describes (app "other"/"tito") — leaving it alone
2026/10/13 22:29:30 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: claim install acme.go.tito.io: registry: claim row ownership changed or unknown
2026/10/13 22:29:30 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: claim install acme.go.tito.io: registry: claim row ownership changed or unknown
2026/10/13 22:29:30 provision: bot.eleven.bac.sidecar.internal exists but is not a sidecar row this pass owns (kind "sidecar" provisioned false app "eleven" acct "bac") — leaving it alone
2026/10/13 22:29:30 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["run"])
2026/10/13 22:29:30 provision: claim install bot.eleven.bac.sidecar.internal: registry: claim row ownership changed or unknown
2026/10/13 22:29:30 provision: acme.go.tito.io exists but is not the route this record describes (app "other"/"tito") — leaving it alone
2026/10/13 22:29:30 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: adopted existing route acme.go.tito.io (app "tito" acct "bac") — this record now owns its channel and environment
2026/10/13 22:29:30 provision: bot.eleven.bac.sidecar.internal exists but is not a sidecar row this pass owns (kind "sidecar" provisioned false app "eleven" acct "bac") — leaving it alone
2026/10/13 22:29:30 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["sidecar","run"])
2026/10/13 22:29:30 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: skipping record apps/bac/tito/instances/acme.go.tito.io.json: malformed: provision: invalid maintenance projection or protected fields
2026/10/13 22:29:30 provision: skipping record apps/bac/tito/instances/new.go.tito.io.json: malformed: provision: invalid maintenance projection or protected fields
2026/10/13 22:29:30 provision: not removing acme.go.tito.io: its record exists but could not be read cleanly this pass
2026/10/13 22:29:30 provision: skipping record apps/bac/tito/instances/acme.go.tito.io.json: malformed: provision: invalid maintenance projection or protected fields
2026/10/13 22:29:30 provision: skipping record apps/bac/tito/instances/new.go.tito.io.json: malformed: provision: invalid maintenance projection or protected fields
2026/10/13 22:29:30 provision: minted instance route rl.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route ra.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route rb.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route rc.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route rg.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route rh.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route ri.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route rj.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route rk.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route rd.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route re.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route rf.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route ra.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route rb.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route rc.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route rd.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: skipping record apps/bac/tito/instances/rb.go.tito.io.json: read failed: read refused
2026/10/13 22:29:30 provision: skipping record apps/bac/tito/instances/rb.go.tito.io.json: read failed: read refused
2026/10/13 22:29:30 provision: minted instance route re.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route x.appa.example (app "appa" acct "bac")
2026/10/13 22:29:30 provision: minted instance route x.appb.example (app "appb" acct "bac")
2026/10/13 22:29:30 provision: minted instance route x.appd.example (app "appd" acct "bac")
2026/10/13 22:29:30 provision: minted instance route x.appg.example (app "appg" acct "bac")
2026/10/13 22:29:30 provision: minted instance route x.appi.example (app "appi" acct "bac")
2026/10/13 22:29:30 provision: minted instance route x.appc.example (app "appc" acct "bac")
2026/10/13 22:29:30 provision: minted instance route x.appe.example (app "appe" acct "bac")
2026/10/13 22:29:30 provision: minted instance route x.appf.example (app "appf" acct "bac")
2026/10/13 22:29:30 provision: minted instance route x.apph.example (app "apph" acct "bac")
2026/10/13 22:29:30 provision: minted instance route x.appj.example (app "appj" acct "bac")
2026/10/13 22:29:30 provision: bac/tito is in the trash — not minting instance routes for it
2026/10/13 22:29:30 provision: minted instance route r0083.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0034.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0059.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0073.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0107.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0125.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0017.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0024.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0030.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0039.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0066.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0069.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0006.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0011.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0057.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0067.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0099.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0109.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0112.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0124.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0012.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0019.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0088.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0098.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0036.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0103.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0001.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0048.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0082.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0123.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0032.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0050.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0055.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0074.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0081.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0090.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0053.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0093.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0121.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0004.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0049.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0078.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0118.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0005.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0041.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0075.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0095.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0119.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0129.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0027.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0033.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0108.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0009.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0094.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0101.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0022.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0038.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0003.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0060.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0063.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0111.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0014.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0044.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0051.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0084.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0085.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0043.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0072.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0104.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0105.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0116.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0035.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0052.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0068.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0054.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0062.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0076.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0000.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0025.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0007.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0037.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0040.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0071.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0045.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0047.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0064.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0065.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0070.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0096.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0110.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0114.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0117.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0015.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0020.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0077.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0126.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0016.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0021.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0086.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0089.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0091.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0097.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0106.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0113.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0013.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0029.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0042.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0056.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0115.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0127.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0046.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0092.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0128.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0002.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0008.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0058.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0087.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0120.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0018.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0079.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0080.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0100.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0102.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0010.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0026.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0122.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0061.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0023.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0028.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:30 provision: minted instance route r0031.go.tito.io (app "tito" acct "bac")
--- FAIL: TestPlan14RootReadCensus (9.72s)
plan14_root_reads_test.go:664: root read census: 10 discrepancies
changed root read internal/host/units/carlos-litestream.service:external; review class, task, sites and digest
changed root read internal/ops/ops.go:runLitestream; review class, task, sites and digest
changed root source envelope internal/host
changed root source envelope internal/host/units
changed root source envelope internal/metrics
changed root source envelope internal/ops
unclassified root read internal/host/restartwatch.go:platformUnitRestarts file:Command@60
unclassified root read internal/ops/ops.go:systemdDaemonReload file:Command@48
changed legacy declaration internal/host/units/carlos-litestream.service:external
changed legacy declaration internal/ops/ops.go:runLitestream
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: refusing to mint route acme.go.tito.io (app "tito" acct "bac"): this box declares CARLOS_INBOUND=none (no-inbound) and cannot serve routed instance traffic
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted service route svc.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted instance route whole.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted instance route console.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted service route ha.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted static route site.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted instance route shared.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: converged acme.go.tito.io channel stable -> edge
2026/10/13 22:29:41 provision: converged acme.go.tito.io channel edge -> canary/rehearsal
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: skipping record apps/bac/tito/instances/acme.go.tito.io.json: empty channel
2026/10/13 22:29:41 provision: not removing acme.go.tito.io: its record exists but could not be read cleanly this pass
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: converged acme.go.tito.io environment "" -> "staging"
2026/10/13 22:29:41 provision: converged acme.go.tito.io environment "staging" -> ""
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: converged acme.go.tito.io steering "" -> "latency"
2026/10/13 22:29:41 provision: converged acme.go.tito.io steering "latency" -> ""
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: converged acme.go.tito.io hibernate false -> true
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: converged acme.go.tito.io hibernate true -> false (replication "daily" cleared: only a hibernating route's rung is read)
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: converged acme.go.tito.io status-named false -> true
2026/10/13 22:29:41 provision: converged acme.go.tito.io status-named true -> false
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted service route ha.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: converged ha.go.tito.io upstreams addr "" -> "", addrs "127.0.0.1:7001,127.0.0.1:7002" -> "127.0.0.1:7001,127.0.0.1:7003" (probe "{\"path\":\"/\",\"status\":200,\"interval_ms\":2000,\"fail_after\":3,\"rise_after\":2}" -> "{\"path\":\"/\",\"status\":200,\"interval_ms\":2000,\"fail_after\":3,\"rise_after\":2}")
2026/10/13 22:29:41 provision: converged ha.go.tito.io upstreams addr "" -> "", addrs "127.0.0.1:7001,127.0.0.1:7003" -> "127.0.0.1:7003,127.0.0.1:7001" (probe "{\"path\":\"/\",\"status\":200,\"interval_ms\":2000,\"fail_after\":3,\"rise_after\":2}" -> "{\"path\":\"/\",\"status\":200,\"interval_ms\":2000,\"fail_after\":3,\"rise_after\":2}")
2026/10/13 22:29:41 provision: converged ha.go.tito.io upstreams addr "" -> "", addrs "127.0.0.1:7003,127.0.0.1:7001" -> "127.0.0.1:7003,127.0.0.1:7001" (probe "{\"path\":\"/\",\"status\":200,\"interval_ms\":2000,\"fail_after\":3,\"rise_after\":2}" -> "{\"path\":\"/healthz\",\"status\":200,\"interval_ms\":2000,\"fail_after\":3,\"rise_after\":2}")
2026/10/13 22:29:41 provision: minted service route whole.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: converged whole.go.tito.io upstreams addr "127.0.0.1:9443" -> "", addrs "" -> "127.0.0.1:9443,127.0.0.1:9444" (probe "" -> "{\"path\":\"/\",\"status\":200,\"interval_ms\":2000,\"fail_after\":3,\"rise_after\":2}")
2026/10/13 22:29:41 provision: converged whole.go.tito.io upstreams addr "" -> "", addrs "127.0.0.1:9443,127.0.0.1:9444" -> "" (probe "{\"path\":\"/\",\"status\":200,\"interval_ms\":2000,\"fail_after\":3,\"rise_after\":2}" -> "")
2026/10/13 22:29:41 provision: whole.go.tito.io: record describes a different route SHAPE than the row (kind "instance"/"service" unit ""/"" socket "/run/carlos/whole.go.tito.io.sock"/"" db "/data/whole.go.tito.io.db"/"") — not converged in place; `carlos remove whole.go.tito.io` purges this route's capability grants (registry.Delete's contract, not a bug) before the next pass re-mints it, so treat that as a last resort, not a routine fix
2026/10/13 22:29:41 provision: minted service route whole.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: acme.go.tito.io exists but is not the route this record describes (app "other"/"tito") — leaving it alone
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: removed retired instance route acme.go.tito.io (record gone)
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: skipping accounts/bac/account.json: malformed account manifest: invalid character 'o' in literal null (expecting 'u')
2026/10/13 22:29:41 provision: removed retired instance route acme.go.tito.io (record gone)
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: manifest bac/tito: read failed: provision: manifest bac/tito: invalid character 'o' in literal null (expecting 'u')
2026/10/13 22:29:41 provision: skipping record apps/bac/tito/instances/acme.go.tito.io.json: disagrees with its own path (host="wrong-host.go.tito.io" account="bac" app="tito")
2026/10/13 22:29:41 provision: skipping record apps/bac/tito/instances/acme$evil.go.tito.io.json: host "acme$evil.go.tito.io" is not a hostname
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: skipping record apps/bac/tito/instances/acme$evil.go.tito.io.json: host "acme$evil.go.tito.io" is not a hostname
2026/10/13 22:29:41 provision: not removing acme$evil.go.tito.io: its record exists but could not be read cleanly this pass
2026/10/13 22:29:41 provision: skipping record apps/bac/tito/instances/bad-service.go.tito.io.json: invalid shape: kind service with no addr: the platform only proxies to a service, so the record must name the upstream it proxies to
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: skipping record apps/bac/tito/instances/bad-service.go.tito.io.json: invalid shape: kind service with no addr: the platform only proxies to a service, so the record must name the upstream it proxies to
2026/10/13 22:29:41 provision: not removing bad-service.go.tito.io: its record exists but could not be read cleanly this pass
2026/10/13 22:29:41 provision: skipping record apps/bac/tito/instances/no-channel.go.tito.io.json: empty channel
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: skipping record apps/bac/tito/instances/no-channel.go.tito.io.json: empty channel
2026/10/13 22:29:41 provision: not removing no-channel.go.tito.io: its record exists but could not be read cleanly this pass
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: skipping record apps/bac/tito/instances/acme.go.tito.io.json: malformed: provision: invalid maintenance projection or protected fields
2026/10/13 22:29:41 provision: not removing acme.go.tito.io: its record exists but could not be read cleanly this pass
2026/10/13 22:29:41 provision: removed retired instance route ghost.go.tito.io (record gone)
2026/10/13 22:29:41 provision: duplicate host dup.go.tito.io: keeping apps/bac/tito/instances/dup.go.tito.io.json, skipping apps/bad/tito/instances/dup.go.tito.io.json
2026/10/13 22:29:41 provision: minted instance route dup.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: bac/tito is in the trash — not minting instance routes for it
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: bac/tito is in the trash — not minting instance routes for it
2026/10/13 22:29:41 provision: removed retired instance route acme.go.tito.io (app in the trash)
2026/10/13 22:29:41 provision: minted static route site.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: bac/tito is in the trash — not minting instance routes for it
2026/10/13 22:29:41 provision: removed retired static route site.go.tito.io (app in the trash)
2026/10/13 22:29:41 provision: trash marker for bac/tito unreadable: release: malformed trash marker for bac/tito: invalid character 'n' looking for beginning of object key string — not minting for this app
2026/10/13 22:29:41 provision: bac/tito is in the trash — not minting instance routes for it
2026/10/13 22:29:41 provision: removed retired instance route acme.go.tito.io (app in the trash)
2026/10/13 22:29:41 provision: bac/tito is in the trash — not minting instance routes for it
2026/10/13 22:29:41 provision: trash marker for bac/tito unreadable: release: malformed trash marker for bac/tito: invalid character 'n' looking for beginning of object key string — not minting for this app
2026/10/13 22:29:41 provision: adopted existing route acme.go.tito.io (app "tito" acct "bac") — this record now owns its channel and environment
2026/10/13 22:29:41 provision: converged acme.go.tito.io channel stable -> edge
2026/10/13 22:29:41 provision: adopted existing route acme.go.tito.io (app "tito" acct "bac") — this record now owns its channel and environment
2026/10/13 22:29:41 provision: converged acme.go.tito.io channel canary/rehearsal -> edge
2026/10/13 22:29:41 provision: converged acme.go.tito.io environment "staging" -> ""
2026/10/13 22:29:41 provision: acme.go.tito.io exists but is not the route this record describes (app "other"/"tito") — leaving it alone
2026/10/13 22:29:41 provision: acme.go.tito.io exists but is not the route this record describes (backing "unit" (box-scoped, not expressible in a record)) — leaving it alone
2026/10/13 22:29:41 provision: acme.go.tito.io exists but is not the route this record describes (tunnel "pi-fleet" (box-scoped, not expressible in a record)) — leaving it alone
2026/10/13 22:29:41 provision: acme.go.tito.io exists but is not the route this record describes (caps "port:25" (box-scoped, not expressible in a record)) — leaving it alone
2026/10/13 22:29:41 provision: acme.go.tito.io exists but is not the route this record describes (platform (owned by the domains reconcile pass)) — leaving it alone
2026/10/13 22:29:41 provision: acme.go.tito.io exists but is not the route this record describes (canary (owned by the domains reconcile pass)) — leaving it alone
2026/10/13 22:29:41 provision: acme.go.tito.io exists but is not the route this record describes (db "/data/other.db"/"/data/acme.go.tito.io.db") — leaving it alone
2026/10/13 22:29:41 provision: adopted existing route acme.go.tito.io (app "tito" acct "bac") — this record now owns its channel and environment
2026/10/13 22:29:41 provision: removed retired instance route acme.go.tito.io (record gone)
2026/10/13 22:29:41 provision: dry-run: would converge owned.go.tito.io channel stable -> edge
2026/10/13 22:29:41 provision: dry-run: would converge owned.go.tito.io environment "" -> "staging"
2026/10/13 22:29:41 provision: dry-run: would converge owned.go.tito.io hibernate false -> true
2026/10/13 22:29:41 provision: dry-run: would converge owned.go.tito.io seed
2026/10/13 22:29:41 provision: dry-run: would mint instance route acme.go.tito.io (app "tito" acct "bac" channel "stable")
2026/10/13 22:29:41 provision: dry-run: would adopt existing route adopt.go.tito.io (app "tito" acct "bac") — this record would own its channel and environment
2026/10/13 22:29:41 provision: dry-run: would converge adopt.go.tito.io channel stable -> edge
2026/10/13 22:29:41 provision: dry-run: would converge adopt.go.tito.io environment "" -> "staging"
2026/10/13 22:29:41 provision: dry-run: would remove retired instance route gone.go.tito.io (record gone)
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: adopted existing route adopt.go.tito.io (app "tito" acct "bac") — this record now owns its channel and environment
2026/10/13 22:29:41 provision: converged adopt.go.tito.io channel stable -> edge
2026/10/13 22:29:41 provision: converged adopt.go.tito.io environment "" -> "staging"
2026/10/13 22:29:41 provision: converged owned.go.tito.io channel stable -> edge
2026/10/13 22:29:41 provision: converged owned.go.tito.io environment "" -> "staging"
2026/10/13 22:29:41 provision: converged owned.go.tito.io hibernate false -> true
2026/10/13 22:29:41 provision: removed retired instance route gone.go.tito.io (record gone)
2026/10/13 22:29:41 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: adopted existing route story.carlosframework.com (app "story" acct "bad") — this record now owns its channel and environment
2026/10/13 22:29:41 provision: story.carlosframework.com exists but is not the route this record describes (kind "instance"/"static") — leaving it alone
2026/10/13 22:29:41 provision: minted static route site.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted service route pub.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted service route pubs.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted service route loop.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted service route named.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted service route priv.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted instance route unit.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted instance route exec.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted static route site.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted static route pisite.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: minted static route site.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:41 provision: removed retired static route site.go.tito.io (record gone)
2026/10/13 22:29:42 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:42 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:42 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:42 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:42 provision: minted instance route acme-uk.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:42 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:42 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:42 provision: converged acme.go.tito.io replicas "ie,uk" -> "uk"
2026/10/13 22:29:42 provision: converged acme.go.tito.io replicas "uk" -> ""
2026/10/13 22:29:42 provision: minted instance route acme-uk.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:42 provision: converged acme-uk.go.tito.io replica mark true -> false
2026/10/13 22:29:42 provision: minted instance route router.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:42 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:42 provision: acme.go.tito.io: row is on the nested layout (db "/data/instances/acme.go.tito.io/acme.go.tito.io.db" socket "/run/carlos/instances/acme.go.tito.io/app.sock"); the record's shared paths are not drift
2026/10/13 22:29:42 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:42 provision: minted instance route router.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:42 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:42 provision: minted instance route router.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:42 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:42 provision: minted instance route router.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:42 provision: router.go.tito.io: not minting this pass: the box's layout intent could not be read: store down
2026/10/13 22:29:42 provision: acme.go.tito.io: not minting this pass: the box's layout intent could not be read: store down
2026/10/13 22:29:42 provision: adopted existing route acme.go.tito.io (app "tito" acct "bac") — this record now owns its channel and environment
2026/10/13 22:29:42 provision: converged acme.go.tito.io channel stable -> edge
2026/10/13 22:29:42 provision: minted instance route acme.go.tito.io held landing by mv_acme (app "tito" acct "bac")
2026/10/13 22:29:42 provision: not removing acme.go.tito.io (record gone): participant of a running operation
2026/10/13 22:29:42 provision: removed retired instance route acme.go.tito.io (record gone)
2026/10/13 22:29:42 provision: minted instance route acme.go.tito.io held landing by mv_acme (app "tito" acct "bac")
2026/10/13 22:29:42 provision: refusing to mint held route acme.go.tito.io (app "tito" acct "bac"): this box declares CARLOS_INBOUND=none (no-inbound) and cannot serve routed instance traffic
2026/10/13 22:29:42 provision: held acme.go.tito.io landing by mv_acme (was ready)
2026/10/13 22:29:42 provision: acme.go.tito.io: held restoring by rb_other, not by mv_acme; leaving it
2026/10/13 22:29:42 provision: acme.go.tito.io: the row is not this pass's (app "other" acct ""); not holding it for mv_acme
2026/10/13 22:29:42 provision: acme.go.tito.io exists but is not the route this record describes (app "other"/"tito") — leaving it alone
2026/10/13 22:29:42 provision: dry-run: would mint instance route acme.go.tito.io held landing by mv_acme (app "tito" acct "bac" channel "stable")
2026/10/13 22:29:42 provision: acme.go.tito.io: not held landing by mv_acme: the row's hold changed under this pass; the next pass judges it afresh
2026/10/13 22:29:42 provision: held acme.go.tito.io landing by mv_acme (was ready)
2026/10/13 22:29:42 provision: removed retired sidecar route gone.eleven.bac.sidecar.internal (record gone)
2026/10/13 22:29:42 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["sidecar","run"])
2026/10/13 22:29:42 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["sidecar","run"])
2026/10/13 22:29:42 provision: converged sidecar bot.eleven.bac.sidecar.internal args ["sidecar","run"] -> ["worker","run"]
2026/10/13 22:29:42 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["sidecar","run"])
2026/10/13 22:29:42 provision: converged sidecar bot.eleven.bac.sidecar.internal ceilings 300/3600 -> 60/120
2026/10/13 22:29:42 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["sidecar","run"])
2026/10/13 22:29:42 provision: converged sidecar bot.eleven.bac.sidecar.internal channel stable -> canary/eleven
2026/10/13 22:29:42 provision: converged sidecar bot.eleven.bac.sidecar.internal environment "other" -> "relay"
2026/10/13 22:29:42 provision: converged sidecar bot.eleven.bac.sidecar.internal hibernate true -> false
2026/10/13 22:29:42 provision: skipping sidecar record apps/bac/eleven/sidecars/bot.json: read failed: failStore: reads disabled under apps/bac/eleven/sidecars/
2026/10/13 22:29:42 provision: skipping sidecar record apps/bac/eleven/sidecars/bot.json: malformed: invalid character 'n' looking for beginning of object key string
2026/10/13 22:29:42 provision: not removing sidecar bot.eleven.bac.sidecar.internal: its record exists but could not be read cleanly this pass
2026/10/13 22:29:42 provision: skipping sidecar record apps/bac/eleven/sidecars/bot.json: empty channel
2026/10/13 22:29:42 provision: skipping sidecar record apps/bac/eleven/sidecars/held.json: empty channel
2026/10/13 22:29:42 provision: not removing sidecar held.eleven.bac.sidecar.internal: its record exists but could not be read cleanly this pass
2026/10/13 22:29:42 provision: removed retired sidecar route old.eleven.bac.sidecar.internal (record gone)
2026/10/13 22:29:42 provision: bac/eleven has sidecar records but no sidecars manifest — not minting
2026/10/13 22:29:42 provision: bac/eleven is in the trash — not minting sidecar routes for it
2026/10/13 22:29:42 provision: sidecar bot.eleven.bac.sidecar.internal is not placeable here: it asks for "no-inbound" placement and this box accepts inbound work (CARLOS_INBOUND="")
2026/10/13 22:29:42 provision: sidecar hub.relay.bac.sidecar.internal is not placeable here: it asks for "no-inbound" placement and this box accepts inbound work (CARLOS_INBOUND="")
2026/10/13 22:29:42 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["sidecar","run"])
2026/10/13 22:29:42 provision: minted sidecar route hub.relay.bac.sidecar.internal (app "relay" acct "bac" args ["relay"])
2026/10/13 22:29:42 provision: sidecar bot.eleven.bac.sidecar.internal is not placeable here: it asks for "no-inbound" placement and this box accepts inbound work (CARLOS_INBOUND="")
2026/10/13 22:29:42 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["sidecar","run"])
2026/10/13 22:29:42 provision: sidecar bot.eleven.bac.sidecar.internal is not placeable here: it asks for "no-inbound" placement and this box accepts inbound work (CARLOS_INBOUND="")
2026/10/13 22:29:42 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["sidecar","run"])
2026/10/13 22:29:42 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["sidecar","run"])
2026/10/13 22:29:42 provision: sidecar bot.eleven.bac.sidecar.internal is not placeable here: it asks for "no-inbound" placement and this box accepts inbound work (CARLOS_INBOUND="")
2026/10/13 22:29:42 provision: removed retired sidecar route bot.eleven.bac.sidecar.internal (record gone)
2026/10/13 22:29:42 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["sidecar","run"])
2026/10/13 22:29:42 provision: sidecar bot.eleven.bac.sidecar.internal is not placeable here: it asks for "no-inbound" placement and this box accepts inbound work (CARLOS_INBOUND="")
2026/10/13 22:29:42 provision: skipping sidecar record apps/bac/relay/sidecars/hub.json: read failed: failStore: reads disabled under apps/bac/relay/sidecars/
2026/10/13 22:29:42 provision: REFUSING to mint sidecar bot.eleven.bac.sidecar.internal: bot.eleven.bac.sidecar.internal-sidecar.env is staged on this box — the legacy file co-spawn and a native sidecar row are two mechanisms for one host; remove the file to migrate this host onto sidecar records
2026/10/13 22:29:42 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["sidecar","run"])
2026/10/13 22:29:42 provision: dry-run: would mint sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" channel "stable" args ["sidecar","run"])
2026/10/13 22:29:42 provision: dry-run: would remove retired sidecar route gone.eleven.bac.sidecar.internal (record gone)
2026/10/13 22:29:42 provision: bot.eleven.bac.sidecar.internal exists but is not a sidecar row this pass owns (kind "sidecar" provisioned false app "eleven" acct "bac") — leaving it alone
2026/10/13 22:29:42 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:42 provision: converged acme.go.tito.io update settings "{\"updates\":\"manual\",\"channel\":\"stable\"}" -> ""
2026/10/13 22:29:42 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:42 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:42 provision: manifest bac/tito: read failed: provision: manifest bac/tito: unexpected end of JSON input
2026/10/13 22:29:42 provision: manifest bac/tito: read failed: provision: manifest bac/tito: unexpected end of JSON input
2026/10/13 22:29:42 provision: acme.go.tito.io: not minted this pass: the app's update policy could not be read
2026/10/13 22:29:42 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:29:42 provision: manifest bac/tito: read failed: provision: manifest bac/tito: unexpected end of JSON input
2026/10/13 22:29:42 provision: acme.go.tito.io: not minted this pass: the app's update policy could not be read
2026/10/13 22:29:42 provision: minted instance route acme.go.tito.io held landing by mv_acme (app "tito" acct "bac")
FAIL
FAIL github.com/carlosframework/platform/internal/provision 13.064s
ok github.com/carlosframework/platform/internal/publicrelease 0.007s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 4.032s
ok github.com/carlosframework/platform/internal/relay 7.221s
ok github.com/carlosframework/platform/internal/release 19.889s
ok github.com/carlosframework/platform/internal/rollout 3.739s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.006s
ok github.com/carlosframework/platform/internal/rootstate 0.024s
ok github.com/carlosframework/platform/internal/s3compat 0.004s
ok github.com/carlosframework/platform/internal/safefile 0.003s
ok github.com/carlosframework/platform/internal/schedule 2.308s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.398s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.224s
ok github.com/carlosframework/platform/internal/status 0.039s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.204s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.002s
ok github.com/carlosframework/platform/internal/updatewords 0.004s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.561s
FAIL
make: *** [Makefile:18: test] Error 1
amadan: step gate failed: exit status 2
amadan: job failed at step gate — its output is above, not at the tail of this log
diff --git a/internal/host/alertsink_test.go b/internal/host/alertsink_test.go| index bd162e674..d44bea351 100644 |
| --- a/internal/host/alertsink_test.go |
| +++ b/internal/host/alertsink_test.go |
| @@ -241,10 +241,10 @@ func TestAlertTickDeliversThroughSinks(t *testing.T) { |
| disk := 0.95 |
| latest := metrics.Sample{At: sinkTestAt, Host: metrics.HostSample{DiskFrac: &disk}} |
| - if err := alertTick(ctx, store, box, rules, st, latest, statePath, sinks); err != nil { |
| + if err := alertTick(ctx, store, box, rules, st, latest, alertReadings{}, statePath, sinks); err != nil { |
| t.Fatal(err) |
| } |
| - if err := alertTick(ctx, store, box, rules, st, latest, statePath, sinks); err != nil { |
| + if err := alertTick(ctx, store, box, rules, st, latest, alertReadings{}, statePath, sinks); err != nil { |
| t.Fatal(err) |
| } |
| if len(pub.in) != 1 { |
| @@ -257,7 +257,7 @@ func TestAlertTickDeliversThroughSinks(t *testing.T) { |
| // Clearing is a transition too. |
| low := 0.30 |
| cleared := metrics.Sample{At: sinkTestAt.Add(time.Minute), Host: metrics.HostSample{DiskFrac: &low}} |
| - if err := alertTick(ctx, store, box, rules, st, cleared, statePath, sinks); err != nil { |
| + if err := alertTick(ctx, store, box, rules, st, cleared, alertReadings{}, statePath, sinks); err != nil { |
| t.Fatal(err) |
| } |
| if len(pub.in) != 2 || !strings.Contains(deref(pub.in[1].Subject), "cleared") { |
diff --git a/internal/host/metrics_loop.go b/internal/host/metrics_loop.go| index 2e0dad037..a52b6c7c4 100644 |
| --- a/internal/host/metrics_loop.go |
| +++ b/internal/host/metrics_loop.go |
| @@ -33,6 +33,9 @@ type sampleProbes struct { |
| memFrac func() *float64 |
| diskFrac func() *float64 |
| units func() map[string]unitRaw |
| + // restarts is each platform unit's systemd restart counter (see |
| + // restartWatch). A nil func, or a nil map from it, is "could not ask". |
| + restarts func() map[string]int64 |
| now func() time.Time |
| } |
| @@ -481,16 +484,18 @@ func pruneStaleRaw(ctx context.Context, s release.Store, box string, now time.Ti |
| // rules (disk, lag; sustain 0) retry the same way, just starting from |
| // their very first over-threshold tick instead of a sustain deadline. |
| func alertTick(ctx context.Context, s release.Store, box string, rules metrics.Rules, |
| - st *metrics.AlertState, latest metrics.Sample, statePath string, |
| + st *metrics.AlertState, latest metrics.Sample, extra alertReadings, statePath string, |
| sinks *alertSinks) error { |
| obs := metrics.Observation{ |
| - At: latest.At, |
| - CPUFrac: latest.Host.CPUFrac, |
| - MemFrac: latest.Host.MemFrac, |
| - DiskFrac: latest.Host.DiskFrac, |
| - LagSecs: nil, // v1: no lag reading feeds the agent loop yet |
| - GeoIP: geoIPAlertReading(latest.At), |
| + At: latest.At, |
| + CPUFrac: latest.Host.CPUFrac, |
| + MemFrac: latest.Host.MemFrac, |
| + DiskFrac: latest.Host.DiskFrac, |
| + LagSecs: nil, // v1: no lag reading feeds the agent loop yet |
| + GeoIP: geoIPAlertReading(latest.At), |
| + Restarts: extra.restarts, |
| + RestartsUnit: extra.restartsUnit, |
| } |
| newState, events := metrics.Evaluate(rules, *st, obs) |
| @@ -524,6 +529,15 @@ func alertTick(ctx context.Context, s release.Store, box string, rules metrics.R |
| return publishErr |
| } |
| +// alertReadings are the readings alertTick takes that are not part of a |
| +// metrics.Sample: they describe the box's units rather than its resources, |
| +// and are not stored in the raw chunks. The zero value is "no reading", which |
| +// leaves every rule they feed exactly as it was. |
| +type alertReadings struct { |
| + restarts *float64 // see restartWatch.tick |
| + restartsUnit string |
| +} |
| + |
| // persistAlertState writes st to path as indented JSON via the repo's |
| // standard temp+rename idiom (internal/appconfig/sync.go's renderEnv, |
| // internal/release/store.go's DirStore.Put): a crashed write must never |
| @@ -616,7 +630,8 @@ func realSampleProbes(execRoot string, stampedUnits func() []string) sampleProbe |
| } |
| return realUnitReadings(execRoot, stamped) |
| }, |
| - now: time.Now, |
| + restarts: platformUnitRestarts, |
| + now: time.Now, |
| } |
| } |
| @@ -803,6 +818,7 @@ func metricsLoop(execRoot string, stampedUnits func() []string, sinks *alertSink |
| // alerted is what keeps a standing refusal from paging every minute. |
| // dataDir() is the bake dir the agent hands release.BakeDir. |
| var refusals refusalWatch |
| + var restarts restartWatch |
| for { |
| time.Sleep(60 * time.Second) |
| tick++ |
| @@ -812,7 +828,9 @@ func metricsLoop(execRoot string, stampedUnits func() []string, sinks *alertSink |
| sm.tick(probes) |
| latest := sm.buf[len(sm.buf)-1] |
| - if err := alertTick(ctx, s, box, rules, &st, latest, statePath, sinks); err != nil { |
| + var extra alertReadings |
| + extra.restarts, extra.restartsUnit = restarts.tick(latest.At, probes.restarts()) |
| + if err := alertTick(ctx, s, box, rules, &st, latest, extra, statePath, sinks); err != nil { |
| log.Printf("agent: alert tick: %v", err) |
| } |
diff --git a/internal/host/metrics_loop_test.go b/internal/host/metrics_loop_test.go| index 6ce15716a..2c0a19c5a 100644 |
| --- a/internal/host/metrics_loop_test.go |
| +++ b/internal/host/metrics_loop_test.go |
| @@ -143,7 +143,7 @@ func TestMetricsLoopStoreHonorsFleetPrefix(t *testing.T) { |
| firing := 0.95 |
| latest := metrics.Sample{At: at, Host: metrics.HostSample{DiskFrac: &firing}} |
| statePath := filepath.Join(t.TempDir(), "monitor", "alerts.json") |
| - if err := alertTick(ctx, s, box, rules, st, latest, statePath, nil); err != nil { |
| + if err := alertTick(ctx, s, box, rules, st, latest, alertReadings{}, statePath, nil); err != nil { |
| t.Fatal(err) |
| } |
| if _, _, err := under.Get(ctx, "fleets/pi/"+metrics.AlertKey(box)); err != nil { |
| @@ -222,7 +222,7 @@ func TestAlertTickPersistsPublishesAndPosts(t *testing.T) { |
| return nil |
| } |
| - if err := alertTick(ctx, store, box, rules, st, latest, statePath, webhookSinks("http://example.test/webhook", post)); err != nil { |
| + if err := alertTick(ctx, store, box, rules, st, latest, alertReadings{}, statePath, webhookSinks("http://example.test/webhook", post)); err != nil { |
| t.Fatal(err) |
| } |
| @@ -248,7 +248,7 @@ func TestAlertTickPersistsPublishesAndPosts(t *testing.T) { |
| } |
| // Second, identical tick: already firing, no new transition -> no post. |
| - if err := alertTick(ctx, store, box, rules, st, latest, statePath, webhookSinks("http://example.test/webhook", post)); err != nil { |
| + if err := alertTick(ctx, store, box, rules, st, latest, alertReadings{}, statePath, webhookSinks("http://example.test/webhook", post)); err != nil { |
| t.Fatal(err) |
| } |
| if len(posts) != 1 { |
| @@ -274,7 +274,7 @@ func TestAlertTickWebhookFailureDoesNotBlock(t *testing.T) { |
| } |
| post := func(url string, body []byte) error { return errors.New("webhook unreachable") } |
| - if err := alertTick(ctx, store, box, rules, st, latest, statePath, webhookSinks("http://example.test/webhook", post)); err != nil { |
| + if err := alertTick(ctx, store, box, rules, st, latest, alertReadings{}, statePath, webhookSinks("http://example.test/webhook", post)); err != nil { |
| t.Fatalf("alertTick must not fail on a webhook error: %v", err) |
| } |
| if _, err := os.Stat(statePath); err != nil { |
| @@ -316,7 +316,7 @@ func TestAlertTickPersistFailureDoesNotSuppressPublish(t *testing.T) { |
| return nil |
| } |
| - if err := alertTick(ctx, store, box, rules, st, latest, statePath, webhookSinks("http://example.test/webhook", post)); err != nil { |
| + if err := alertTick(ctx, store, box, rules, st, latest, alertReadings{}, statePath, webhookSinks("http://example.test/webhook", post)); err != nil { |
| t.Fatalf("alertTick must not fail just because the local state write failed: %v", err) |
| } |
| if !posted { |
| @@ -550,7 +550,7 @@ func TestAlertTickRetriesTransitionAfterPublishFailure(t *testing.T) { |
| } |
| failStore := &failingPutStore{Store: release.NewDirStore(t.TempDir())} |
| - if err := alertTick(ctx, failStore, box, rules, st, latest, statePath, webhookSinks("http://example.test/webhook", post)); err == nil { |
| + if err := alertTick(ctx, failStore, box, rules, st, latest, alertReadings{}, statePath, webhookSinks("http://example.test/webhook", post)); err == nil { |
| t.Fatal("expected alertTick to return the publish error against a Put-always-fails store") |
| } |
| if st.Rules["disk"].Firing { |
| @@ -563,7 +563,7 @@ func TestAlertTickRetriesTransitionAfterPublishFailure(t *testing.T) { |
| // Retry with the SAME observation against a working store: the pending |
| // transition must reproduce, publish, and post again. |
| workingStore := release.NewDirStore(t.TempDir()) |
| - if err := alertTick(ctx, workingStore, box, rules, st, latest, statePath, webhookSinks("http://example.test/webhook", post)); err != nil { |
| + if err := alertTick(ctx, workingStore, box, rules, st, latest, alertReadings{}, statePath, webhookSinks("http://example.test/webhook", post)); err != nil { |
| t.Fatalf("retry against a working store: %v", err) |
| } |
| if !st.Rules["disk"].Firing { |
diff --git a/internal/host/restartwatch.go b/internal/host/restartwatch.go| new file mode 100644 |
| index 000000000..14b89822d |
| --- /dev/null |
| +++ b/internal/host/restartwatch.go |
| @@ -0,0 +1,133 @@ |
| +package host |
| + |
| +// A unit that cannot stay up looks healthy from every other angle the agent |
| +// has. systemd restarts it, the sampler finds its cgroup on most ticks, and |
| +// its memory and CPU read as modest because it never lives long enough to use |
| +// either. Discussion #284 is that case: carlos-litestream was OOM-killed four |
| +// seconds into every start for two hours, restarted over 900 times, and the |
| +// only thing that noticed was a person asking whether backups were running. |
| +// |
| +// systemd counts those restarts itself (NRestarts), so the agent asks it once |
| +// a tick and alerts on how fast the number is moving. |
| + |
| +import ( |
| + "os/exec" |
| + "strconv" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// restartWindow is how far back a unit's restarts are counted. The restarts |
| +// rule fires on a count inside it (metrics.Rules.RestartCount) and clears |
| +// after the whole of it passes with none. |
| +const restartWindow = 10 * time.Minute |
| + |
| +// restartWatch remembers each watched unit's restart counter over |
| +// restartWindow. It lives for the life of the metrics loop; an agent restart |
| +// starts it empty, which is why a reading of zero waits for a full window. |
| +type restartWatch struct { |
| + marks map[string][]restartMark |
| + // quietSince is when the watch began, or last saw any unit restart or |
| + // have its counter reset, whichever is later. |
| + quietSince time.Time |
| +} |
| + |
| +type restartMark struct { |
| + at time.Time |
| + n int64 |
| +} |
| + |
| +// tick records counts (unit name to systemd's NRestarts) as of now and |
| +// returns the most restarts any one unit made inside the window, with that |
| +// unit's name. nil counts means systemd could not be asked: the reading is |
| +// nil and the history is kept. |
| +// |
| +// A zero reading is only given after a whole window in which the watch saw |
| +// nothing restart. Zero is what clears a firing alert, and neither a watch |
| +// that has just started nor a unit an operator has just restarted by hand has |
| +// shown that the loop is over. |
| +func (w *restartWatch) tick(now time.Time, counts map[string]int64) (reading *float64, unit string) { |
| + if counts == nil { |
| + return nil, "" |
| + } |
| + if w.marks == nil { |
| + w.marks = map[string][]restartMark{} |
| + w.quietSince = now |
| + } |
| + for name := range w.marks { |
| + if _, ok := counts[name]; !ok { |
| + delete(w.marks, name) // unloaded: nothing left to watch |
| + } |
| + } |
| + var worst int64 |
| + for name, n := range counts { |
| + marks := w.marks[name] |
| + if last := len(marks) - 1; last >= 0 && n != marks[last].n { |
| + w.quietSince = now |
| + if n < marks[last].n { |
| + // The counter went backwards: systemd zeroes it when the unit |
| + // is started by hand, so what came before is another run's |
| + // history. |
| + marks = nil |
| + } |
| + } |
| + marks = append(marks, restartMark{at: now, n: n}) |
| + // Keep one mark at or beyond the window's edge as the baseline. |
| + for len(marks) > 1 && now.Sub(marks[1].at) >= restartWindow { |
| + marks = marks[1:] |
| + } |
| + w.marks[name] = marks |
| + if d := n - marks[0].n; d > worst || (d == worst && d > 0 && name < unit) { |
| + worst, unit = d, name |
| + } |
| + } |
| + if worst == 0 && now.Sub(w.quietSince) < restartWindow { |
| + return nil, "" |
| + } |
| + v := float64(worst) |
| + return &v, unit |
| +} |
| + |
| +// platformUnitRestarts asks systemd for the restart counter of every loaded |
| +// platform service. nil when systemd cannot be asked. |
| +// |
| +// Template instances (carlos-app@<host>, carlos-instance@<id>) are left out: |
| +// those are tenants' processes, and a tenant's crash loop is not the fleet |
| +// operator's page. |
| +func platformUnitRestarts() map[string]int64 { |
| + out, err := exec.Command("systemctl", "show", "--property=Id", "--property=NRestarts", "carlos-*.service").Output() |
| + if err != nil { |
| + return nil |
| + } |
| + return parseUnitRestarts(string(out)) |
| +} |
| + |
| +// parseUnitRestarts reads `systemctl show --property=Id --property=NRestarts` |
| +// output: one blank-line-separated block per unit, the two properties in |
| +// whatever order systemd prints them. A block missing either is skipped. |
| +// Never nil: no loaded units is an answer, an empty one. |
| +func parseUnitRestarts(out string) map[string]int64 { |
| + counts := map[string]int64{} |
| + for _, block := range strings.Split(out, "\n\n") { |
| + id, n, haveN := "", int64(0), false |
| + for _, line := range strings.Split(block, "\n") { |
| + k, v, ok := strings.Cut(strings.TrimSpace(line), "=") |
| + if !ok { |
| + continue |
| + } |
| + switch k { |
| + case "Id": |
| + id = v |
| + case "NRestarts": |
| + if parsed, err := strconv.ParseInt(v, 10, 64); err == nil { |
| + n, haveN = parsed, true |
| + } |
| + } |
| + } |
| + if id == "" || !haveN || strings.Contains(id, "@") { |
| + continue |
| + } |
| + counts[id] = n |
| + } |
| + return counts |
| +} |
diff --git a/internal/host/restartwatch_test.go b/internal/host/restartwatch_test.go| new file mode 100644 |
| index 000000000..a58129801 |
| --- /dev/null |
| +++ b/internal/host/restartwatch_test.go |
| @@ -0,0 +1,116 @@ |
| +package host |
| + |
| +import ( |
| + "testing" |
| + "time" |
| +) |
| + |
| +// The #284 shape: one unit restarting every few seconds among quiet ones. The |
| +// reading is that unit's restarts inside the window, with its name. |
| +func TestRestartWatchCountsTheWorstUnitInsideTheWindow(t *testing.T) { |
| + var w restartWatch |
| + t0 := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) |
| + counts := func(ls, relay int64) map[string]int64 { |
| + return map[string]int64{"carlos-litestream.service": ls, "carlos-relay.service": relay, "carlos-edge.service": 0} |
| + } |
| + |
| + // First sight of a counter is a baseline, not a restart: a unit that has |
| + // restarted 900 times before the agent looked has not restarted "now". |
| + if r, _ := w.tick(t0, counts(900, 2)); r != nil { |
| + t.Fatalf("first tick gave a reading of %v, want none", *r) |
| + } |
| + r, unit := w.tick(t0.Add(time.Minute), counts(909, 2)) |
| + if r == nil || *r != 9 || unit != "carlos-litestream.service" { |
| + t.Fatalf("after one minute: reading=%v unit=%q, want 9 restarts of carlos-litestream.service", r, unit) |
| + } |
| + r, unit = w.tick(t0.Add(2*time.Minute), counts(912, 3)) |
| + if r == nil || *r != 12 || unit != "carlos-litestream.service" { |
| + t.Fatalf("after two minutes: reading=%v unit=%q, want 12", r, unit) |
| + } |
| + |
| + // The loop stops. Restarts age out of the window one tick at a time, and |
| + // zero arrives only when the whole window has been quiet. |
| + var last *float64 |
| + for m := 3; m <= 11; m++ { |
| + last, _ = w.tick(t0.Add(time.Duration(m)*time.Minute), counts(912, 3)) |
| + if last == nil || *last == 0 { |
| + t.Fatalf("minute %d: reading=%v, but minute 2's restarts are still inside the window", m, last) |
| + } |
| + } |
| + last, _ = w.tick(t0.Add(12*time.Minute), counts(912, 3)) |
| + if last == nil || *last != 0 { |
| + t.Fatalf("minute 12: reading=%v, want 0 after a quiet window", last) |
| + } |
| +} |
| + |
| +// Zero clears a firing alert, so a watch that has only just started (the |
| +// agent restarted mid-incident) must not say it. |
| +func TestRestartWatchGivesNoZeroBeforeAFullWindow(t *testing.T) { |
| + var w restartWatch |
| + t0 := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) |
| + quiet := map[string]int64{"carlos-litestream.service": 40} |
| + for m := 0; m < 10; m++ { |
| + if r, _ := w.tick(t0.Add(time.Duration(m)*time.Minute), quiet); r != nil { |
| + t.Fatalf("minute %d: reading=%v before the watch has seen a whole window", m, *r) |
| + } |
| + } |
| + if r, _ := w.tick(t0.Add(10*time.Minute), quiet); r == nil || *r != 0 { |
| + t.Fatalf("minute 10: reading=%v, want 0", r) |
| + } |
| +} |
| + |
| +// systemd zeroes NRestarts when a unit is started by hand. That is neither a |
| +// negative number of restarts nor proof the loop is over. |
| +func TestRestartWatchTreatsACounterResetAsANewRun(t *testing.T) { |
| + var w restartWatch |
| + t0 := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) |
| + at := func(m int) time.Time { return t0.Add(time.Duration(m) * time.Minute) } |
| + unit := func(n int64) map[string]int64 { return map[string]int64{"carlos-litestream.service": n} } |
| + |
| + w.tick(at(0), unit(500)) |
| + if r, _ := w.tick(at(1), unit(510)); r == nil || *r != 10 { |
| + t.Fatalf("reading=%v, want 10", r) |
| + } |
| + // An operator restarts it by hand: the counter is 0 again. |
| + if r, _ := w.tick(at(2), unit(0)); r != nil { |
| + t.Fatalf("a counter reset gave a reading of %v, want none", *r) |
| + } |
| + // Still broken: it loops again from zero, and is counted from zero. |
| + if r, _ := w.tick(at(3), unit(5)); r == nil || *r != 5 { |
| + t.Fatalf("after the reset: reading=%v, want 5", r) |
| + } |
| +} |
| + |
| +// systemd unavailable is "could not ask": no reading, and what the watch |
| +// already knew is kept for when it can ask again. |
| +func TestRestartWatchKeepsItsHistoryWhenSystemdCannotBeAsked(t *testing.T) { |
| + var w restartWatch |
| + t0 := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) |
| + w.tick(t0, map[string]int64{"carlos-litestream.service": 1}) |
| + if r, _ := w.tick(t0.Add(time.Minute), nil); r != nil { |
| + t.Fatalf("reading=%v with no answer from systemd", *r) |
| + } |
| + if r, _ := w.tick(t0.Add(2*time.Minute), map[string]int64{"carlos-litestream.service": 7}); r == nil || *r != 6 { |
| + t.Fatalf("reading=%v, want 6 against the baseline from before the gap", r) |
| + } |
| +} |
| + |
| +func TestParseUnitRestarts(t *testing.T) { |
| + // Verbatim shape from systemd 257: NRestarts before Id, a blank line |
| + // between units. |
| + out := "NRestarts=0\nId=carlos-edge.service\n\nNRestarts=912\nId=carlos-litestream.service\n\n" + |
| + "NRestarts=3\nId=carlos-app@shop.example.com.service\n\nId=carlos-adopt.service\n\nNRestarts=x\nId=carlos-relay.service\n" |
| + got := parseUnitRestarts(out) |
| + want := map[string]int64{"carlos-edge.service": 0, "carlos-litestream.service": 912} |
| + if len(got) != len(want) { |
| + t.Fatalf("got %v, want %v (a tenant's unit, a block with no counter and one with a bad counter are all skipped)", got, want) |
| + } |
| + for k, v := range want { |
| + if got[k] != v { |
| + t.Errorf("%s = %d, want %d", k, got[k], v) |
| + } |
| + } |
| + if got := parseUnitRestarts(""); got == nil || len(got) != 0 { |
| + t.Fatalf("no loaded units = %v, want an empty map, not nil", got) |
| + } |
| +} |
diff --git a/internal/metrics/alert.go b/internal/metrics/alert.go| index 24a404138..489952dc4 100644 |
| --- a/internal/metrics/alert.go |
| +++ b/internal/metrics/alert.go |
| @@ -19,6 +19,10 @@ type Rules struct { |
| // GeoIPSustain is how long a geoip reason must hold before the rule |
| // fires (geoip spec §10.4); it clears on the first reading of 0. |
| GeoIPSustain time.Duration // default 30 * time.Minute |
| + // RestartCount is how many restarts one unit may make inside the |
| + // observation's window before the restarts rule fires. It is a count, not |
| + // a fraction, and the rule clears only on a reading of 0. |
| + RestartCount float64 // fire >, default 3 |
| } |
| // DefaultRules are the thresholds shipped with the platform. |
| @@ -33,6 +37,7 @@ func DefaultRules() Rules { |
| ClearBy: 0.05, |
| // GeoIPSustain: geoip spec §10.4. |
| GeoIPSustain: 30 * time.Minute, |
| + RestartCount: 3, |
| } |
| } |
| @@ -86,6 +91,12 @@ type Observation struct { |
| // 1 stale, 2 expiring, 3 expired, 4 not loaded, 5 feed failing. nil on |
| // a box whose edge has not published one. |
| GeoIP *float64 |
| + // Restarts is the most restarts any one watched unit made inside the |
| + // reporter's window, and RestartsUnit names that unit. A reading of 0 |
| + // says a whole window passed with none; nil says the reporter could not |
| + // tell yet. |
| + Restarts *float64 |
| + RestartsUnit string |
| } |
| // RuleState is one rule's sustain/hysteresis bookkeeping. Since and FiredAt |
| @@ -103,6 +114,9 @@ type RuleState struct { |
| Since time.Time `json:"since,omitempty"` // continuously-above-threshold since (sustain tracking) |
| FiredAt time.Time `json:"fired_at,omitempty"` // set on fire, cleared on clear |
| Value float64 `json:"value,omitempty"` // reading at last transition |
| + // Detail names what the reading was about, for the rules whose value |
| + // alone does not say: the unit for "restarts". Empty for the rest. |
| + Detail string `json:"detail,omitempty"` |
| } |
| // AlertState is the JSON persisted to disk by the agent loop and published |
| @@ -112,7 +126,7 @@ type AlertState struct { |
| V int `json:"v"` // 1 |
| Box string `json:"box"` |
| UpdatedAt time.Time `json:"updated_at"` |
| - Rules map[string]RuleState `json:"rules"` // "disk","mem","cpu","lag" |
| + Rules map[string]RuleState `json:"rules"` // "disk","mem","cpu","lag","geoip","restarts" |
| } |
| // Event is emitted on a firing or clearing transition — never on every |
| @@ -122,6 +136,7 @@ type Event struct { |
| Firing bool `json:"firing"` // true=fired, false=cleared |
| Value float64 `json:"value"` |
| Box string `json:"box"` |
| + Detail string `json:"detail,omitempty"` // see RuleState.Detail |
| } |
| // AlertKey is the bucket key an AlertState for box is stored/published |
| @@ -274,5 +289,23 @@ func Evaluate(r Rules, st AlertState, obs Observation) (AlertState, []Event) { |
| } |
| } |
| + // restarts: a count inside a window the reporter keeps, so there is no |
| + // sustain here. It fires above RestartCount and clears only at 0, which is |
| + // a whole window without a restart: a unit that has merely slowed down |
| + // (systemd backs its restarts off) has not recovered. |
| + if obs.Restarts != nil { |
| + val := *obs.Restarts |
| + cur := out.Rules["restarts"] |
| + switch { |
| + case cur.Firing && val == 0: |
| + unit := cur.Detail |
| + out.Rules["restarts"] = RuleState{} |
| + events = append(events, Event{Rule: "restarts", Firing: false, Value: val, Box: st.Box, Detail: unit}) |
| + case !cur.Firing && val > r.RestartCount: |
| + out.Rules["restarts"] = RuleState{Firing: true, Since: obs.At, FiredAt: obs.At, Value: val, Detail: obs.RestartsUnit} |
| + events = append(events, Event{Rule: "restarts", Firing: true, Value: val, Box: st.Box, Detail: obs.RestartsUnit}) |
| + } |
| + } |
| + |
| return out, events |
| } |
diff --git a/internal/metrics/alert_test.go b/internal/metrics/alert_test.go| index e4f55fa2a..92e0a6d4c 100644 |
| --- a/internal/metrics/alert_test.go |
| +++ b/internal/metrics/alert_test.go |
| @@ -247,3 +247,45 @@ func TestGeoIPReasonChangeWhileFiring(t *testing.T) { |
| t.Fatalf("an unchanged reason re-fired: %v", ev) |
| } |
| } |
| + |
| +// The restarts rule fires above the count, names the unit, holds while the |
| +// unit is still restarting (however slowly), and clears only on a reading of |
| +// zero. A nil reading changes nothing in either direction. |
| +func TestRestartsRuleFiresAboveTheCountAndClearsOnlyAtZero(t *testing.T) { |
| + r := DefaultRules() |
| + t0 := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) |
| + f := func(v float64) *float64 { return &v } |
| + st := AlertState{V: 1, Box: "b"} |
| + |
| + st, ev := Evaluate(r, st, Observation{At: t0, Restarts: f(3), RestartsUnit: "carlos-litestream.service"}) |
| + if len(ev) != 0 || st.Rules["restarts"].Firing { |
| + t.Fatalf("fired at the count, not above it: %v", ev) |
| + } |
| + st, ev = Evaluate(r, st, Observation{At: t0.Add(time.Minute), Restarts: f(4), RestartsUnit: "carlos-litestream.service"}) |
| + if len(ev) != 1 || !ev[0].Firing || ev[0].Rule != "restarts" || ev[0].Value != 4 || ev[0].Detail != "carlos-litestream.service" { |
| + t.Fatalf("above the count: %+v", ev) |
| + } |
| + if rs := st.Rules["restarts"]; !rs.Firing || rs.Detail != "carlos-litestream.service" || !rs.FiredAt.Equal(t0.Add(time.Minute)) { |
| + t.Fatalf("state after firing: %+v", rs) |
| + } |
| + // Still restarting, now once a minute, and another unit is the worst. |
| + st, ev = Evaluate(r, st, Observation{At: t0.Add(2 * time.Minute), Restarts: f(1), RestartsUnit: "carlos-relay.service"}) |
| + if len(ev) != 0 || !st.Rules["restarts"].Firing || st.Rules["restarts"].Detail != "carlos-litestream.service" { |
| + t.Fatalf("a slower loop cleared or renamed the alert: %v %+v", ev, st.Rules["restarts"]) |
| + } |
| + st, ev = Evaluate(r, st, Observation{At: t0.Add(3 * time.Minute)}) |
| + if len(ev) != 0 || !st.Rules["restarts"].Firing { |
| + t.Fatalf("a nil reading changed the rule: %v", ev) |
| + } |
| + st, ev = Evaluate(r, st, Observation{At: t0.Add(13 * time.Minute), Restarts: f(0)}) |
| + if len(ev) != 1 || ev[0].Firing || ev[0].Detail != "carlos-litestream.service" { |
| + t.Fatalf("a zero reading must clear and name the unit that had fired: %+v", ev) |
| + } |
| + if rs := st.Rules["restarts"]; rs.Firing || rs.Detail != "" || !rs.FiredAt.IsZero() { |
| + t.Fatalf("state after clearing: %+v", rs) |
| + } |
| + // Quiet stays quiet. |
| + if _, ev = Evaluate(r, st, Observation{At: t0.Add(14 * time.Minute), Restarts: f(0)}); len(ev) != 0 { |
| + t.Fatalf("a quiet rule emitted: %v", ev) |
| + } |
| +} |