diff --git a/.gitattributes b/.gitattributes index f5c91ceed6..9a631847d7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,3 +4,11 @@ # assets, and collapse it in review UIs. Drift is still caught by the content-based # `git diff --quiet -- internal/api/dashboardspa/dist` gate in the Makefile. internal/api/dashboardspa/dist/** -diff linguist-generated + +# The typed Go API client under internal/api/genclient/client_gen.go is emitted by +# oapi-codegen (its header says "DO NOT EDIT") from the OpenAPI spec. Treat it as +# generated output like the SPA bundle above: skip textual diffing and the +# whitespace lint on the machine-generated source, and do not attribute its shape +# to authored complexity. Drift is still caught by content-based build/regeneration +# gates, since a blob-hash change is detected even with -diff. +internal/api/genclient/client_gen.go -diff linguist-generated diff --git a/.githooks/pre-commit b/.githooks/pre-commit index d3937a2c61..053ef24c09 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -4,8 +4,9 @@ set -euo pipefail staged_go_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.go' || true) staged_web_src=$(git diff --cached --name-only --diff-filter=ACM -- 'internal/api/dashboardspa/web/shared/src/' 'internal/api/dashboardspa/web/frontend/src/' 'internal/api/dashboardspa/web/frontend/index.html' 'internal/api/dashboardspa/web/frontend/public/' 'internal/api/dashboardspa/web/package.json' 'internal/api/dashboardspa/web/shared/package.json' 'internal/api/dashboardspa/web/frontend/package.json' 'internal/api/dashboardspa/web/frontend/vite.config.ts' 'internal/api/dashboardspa/web/frontend/tsconfig.json' 'internal/api/dashboardspa/web/shared/tsconfig.json' || true) staged_docs=$(git diff --cached --name-only --diff-filter=ACM -- '*.md' 'docs/**' 'engdocs/**' 'plans/**' 'specs/**' 'AGENTS.md' 'CONTRIBUTING.md' 'README.md' 'TESTING.md' || true) +staged_spec=$(git diff --cached --name-only --diff-filter=ACM -- 'internal/api/openapi.json' || true) -if [ -z "$staged_go_files" ] && [ -z "$staged_web_src" ] && [ -z "$staged_docs" ]; then +if [ -z "$staged_go_files" ] && [ -z "$staged_web_src" ] && [ -z "$staged_docs" ] && [ -z "$staged_spec" ]; then exit 0 fi @@ -44,12 +45,24 @@ if [ -n "$staged_docs" ]; then make check-docs fi -# Dashboard SPA rebuild: whenever the spec changes OR the SPA source -# changes, regenerate the TS types, typecheck, and rebuild the compiled -# bundle. Guarded on `npm` availability so contributors without Node -# tooling aren't blocked; CI enforces the full regeneration. +# Dashboard SPA rebuild: when internal/api/openapi.json changes, regenerate +# the generated TS API client from the new spec and stage it. When the spec +# OR the SPA source changes, typecheck and rebuild the compiled bundle. +# Guarded on `npm` availability so contributors without Node tooling aren't +# blocked; CI enforces the full regeneration via make dashboard-ci. if command -v npm >/dev/null 2>&1; then + # Re-read the index rather than reusing the pre-hook snapshot: the Go + # block above runs `go run ./cmd/genspec` and stages the regenerated + # internal/api/openapi.json, so a Go-only commit that moves the API + # surface only shows up here (#4627, #4607). spec_changed=$(git diff --cached --name-only --diff-filter=ACM -- 'internal/api/openapi.json' || true) + if [ -n "$spec_changed" ]; then + # Regenerate BEFORE typecheck/build below: a client that no longer + # matches the new spec must fail typecheck immediately instead of + # silently building/committing a stale client (#4627, #4607). + (cd internal/api/dashboardspa/web && npm ci --silent && npm run generate:client) + git add internal/api/dashboardspa/web/shared/src/generated/gc-supervisor-client + fi if [ -n "$spec_changed" ] || [ -n "$staged_web_src" ]; then # Typecheck BEFORE build: vite's build transpiles TS to JS and # silently ignores type errors. The Makefile target also runs the diff --git a/.githooks/pre-push b/.githooks/pre-push index 1b40142658..7629690d90 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -14,11 +14,13 @@ set -euo pipefail zero="0000000000000000000000000000000000000000" go_changed=0 +any_non_deletion=0 while read -r _local_ref local_sha _remote_ref remote_sha; do - # Branch deletion — nothing to test. + # Branch deletion — nothing to test, nothing to guard. if [ "$local_sha" = "$zero" ]; then continue fi + any_non_deletion=1 # New remote branch: no cheap base to diff against — run the suite. if [ "$remote_sha" = "$zero" ]; then go_changed=1 @@ -29,6 +31,21 @@ while read -r _local_ref local_sha _remote_ref remote_sha; do fi done +# Bead ownership/staleness guard (ga-fip9ps.1): re-checks bd claim state +# immediately before this push leaves the machine, once per invocation +# (not per ref) regardless of whether Go sources changed. A mayor ruling +# (reassign/close/hold/reroute) that landed after this push was queued +# must not be allowed to clobber a branch another agent has since taken +# over. Bypass: `git push --no-verify`. +if [ "$any_non_deletion" -eq 1 ]; then + repo_root="$(git rev-parse --show-toplevel)" + # shellcheck source=../scripts/push-ownership-guard.sh disable=SC1091 + . "$repo_root/scripts/push-ownership-guard.sh" + if ! assert_bead_still_claimed; then + exit 1 + fi +fi + if [ "$go_changed" -eq 0 ]; then exit 0 fi diff --git a/.github/requirements/mcp-agent-mail.in b/.github/requirements/mcp-agent-mail.in index 67fa1e2512..096e1b32cc 100644 --- a/.github/requirements/mcp-agent-mail.in +++ b/.github/requirements/mcp-agent-mail.in @@ -2,11 +2,17 @@ # publishes current wheel/sdist assets. mcp-agent-mail @ https://github.com/Dicklesworthstone/mcp_agent_mail/archive/32783f6848bd63c425c4b5004cee3350016635fb.tar.gz -# Security floor: GitPython 3.1.49 has GHSA-mv93-w799-cj2w (HIGH). -# Pinning floor at 3.1.50 to ensure the resolver picks the patched version -# even if mcp-agent-mail's transitive constraint allows older. Drop this -# line once mcp-agent-mail upstream pins GitPython>=3.1.50 itself. -gitpython>=3.1.50 +# Security floor: GitPython < 3.1.52 has multiple HIGH-severity command +# injection and path traversal advisories reported by the image gate. +# Pinning the floor ensures the resolver picks the patched version even if +# mcp-agent-mail's transitive constraint allows older. Drop this line once +# mcp-agent-mail upstream pins GitPython>=3.1.52 itself. +gitpython>=3.1.52 + +# Security floor: Pillow < 12.3.0 has multiple HIGH-severity image parsing +# vulnerabilities reported by the image gate. Drop this line once transitive +# constraints carry the patched version themselves. +pillow>=12.3.0 # Security floor: urllib3 < 2.7.0 has GHSA-mf9v-mfxr-j63j (HIGH, # decompression-bomb safeguards bypassed in parts of the streaming API) @@ -31,6 +37,15 @@ litellm>=1.84.0 # authlib's transitive constraint carries the patched version itself. joserfc>=1.6.8 +# Security floor: CVE-2026-52869 + CVE-2026-52870 (HIGH, fixed 1.27.2 — HTTP +# transports serve session requests without verifying the authenticated +# session, and experimental task handlers are reachable by any client) and +# CVE-2026-59950 (HIGH, fixed 1.28.1 — WebSocket transport lacks Host/Origin +# validation) in the mcp Python SDK < 1.28.1. mcp arrives transitively via +# fastmcp-slim (mcp<2.0,>=1.24.0); floor at 1.28.1 to clear all three. Drop +# once fastmcp-slim's transitive constraint carries the patched version itself. +mcp>=1.28.1 + # Authlib and FastMCP security floors live in # .github/requirements/mcp-agent-mail.overrides.txt because mcp-agent-mail # v0.3.2 still caps Authlib below the fixed release. diff --git a/.github/requirements/mcp-agent-mail.txt b/.github/requirements/mcp-agent-mail.txt index 716f55ce3d..64e8127a14 100644 --- a/.github/requirements/mcp-agent-mail.txt +++ b/.github/requirements/mcp-agent-mail.txt @@ -770,9 +770,9 @@ gitdb==4.0.12 \ --hash=sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571 \ --hash=sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf # via gitpython -gitpython==3.1.50 \ - --hash=sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc \ - --hash=sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9 +gitpython==3.1.54 \ + --hash=sha256:53f2085e24a2cda300eed7c3fc5f1559ae289634b725e98acaf4791940247aa0 \ + --hash=sha256:b90d7b3d9bc0238681d24369130826f0dcdb0ceaa45db67cf1d4ffa4c302dedf # via # -r .github/requirements/mcp-agent-mail.in # mcp-agent-mail @@ -1345,10 +1345,12 @@ markupsafe==3.0.3 \ --hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \ --hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50 # via jinja2 -mcp==1.27.0 \ - --hash=sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741 \ - --hash=sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83 - # via fastmcp-slim +mcp==1.28.1 \ + --hash=sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df \ + --hash=sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683 + # via + # -r .github/requirements/mcp-agent-mail.in + # fastmcp-slim mcp-agent-mail @ https://github.com/Dicklesworthstone/mcp_agent_mail/archive/32783f6848bd63c425c4b5004cee3350016635fb.tar.gz \ --hash=sha256:8ffe6d9ee8665e957a83a885e5f45d0ad2733f5a50a1e4ec4479e66ef625e35a # via -r .github/requirements/mcp-agent-mail.in @@ -1614,99 +1616,97 @@ pathspec==1.1.1 \ --hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \ --hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189 # via mcp-agent-mail -pillow==12.2.0 \ - --hash=sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9 \ - --hash=sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5 \ - --hash=sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987 \ - --hash=sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9 \ - --hash=sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b \ - --hash=sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f \ - --hash=sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd \ - --hash=sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e \ - --hash=sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e \ - --hash=sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe \ - --hash=sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795 \ - --hash=sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601 \ - --hash=sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1 \ - --hash=sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed \ - --hash=sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea \ - --hash=sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5 \ - --hash=sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97 \ - --hash=sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453 \ - --hash=sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98 \ - --hash=sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa \ - --hash=sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b \ - --hash=sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d \ - --hash=sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705 \ - --hash=sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8 \ - --hash=sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024 \ - --hash=sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0 \ - --hash=sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286 \ - --hash=sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150 \ - --hash=sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2 \ - --hash=sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3 \ - --hash=sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b \ - --hash=sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f \ - --hash=sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463 \ - --hash=sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940 \ - --hash=sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166 \ - --hash=sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed \ - --hash=sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f \ - --hash=sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795 \ - --hash=sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780 \ - --hash=sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7 \ - --hash=sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1 \ - --hash=sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5 \ - --hash=sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295 \ - --hash=sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b \ - --hash=sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354 \ - --hash=sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60 \ - --hash=sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65 \ - --hash=sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005 \ - --hash=sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c \ - --hash=sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be \ - --hash=sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5 \ - --hash=sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06 \ - --hash=sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae \ - --hash=sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c \ - --hash=sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c \ - --hash=sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612 \ - --hash=sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e \ - --hash=sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab \ - --hash=sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808 \ - --hash=sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f \ - --hash=sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e \ - --hash=sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909 \ - --hash=sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec \ - --hash=sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe \ - --hash=sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50 \ - --hash=sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4 \ - --hash=sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f \ - --hash=sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff \ - --hash=sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5 \ - --hash=sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb \ - --hash=sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414 \ - --hash=sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1 \ - --hash=sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032 \ - --hash=sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76 \ - --hash=sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136 \ - --hash=sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e \ - --hash=sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c \ - --hash=sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3 \ - --hash=sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea \ - --hash=sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f \ - --hash=sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104 \ - --hash=sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176 \ - --hash=sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24 \ - --hash=sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3 \ - --hash=sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4 \ - --hash=sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed \ - --hash=sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43 \ - --hash=sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421 \ - --hash=sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7 \ - --hash=sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06 \ - --hash=sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5 - # via mcp-agent-mail +pillow==12.3.0 \ + --hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \ + --hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \ + --hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \ + --hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \ + --hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \ + --hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \ + --hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \ + --hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \ + --hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \ + --hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \ + --hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \ + --hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \ + --hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \ + --hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \ + --hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \ + --hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \ + --hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \ + --hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \ + --hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \ + --hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \ + --hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \ + --hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \ + --hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \ + --hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \ + --hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \ + --hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \ + --hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \ + --hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \ + --hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \ + --hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \ + --hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \ + --hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \ + --hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \ + --hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \ + --hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \ + --hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \ + --hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \ + --hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \ + --hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \ + --hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \ + --hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \ + --hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \ + --hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \ + --hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \ + --hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \ + --hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \ + --hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \ + --hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \ + --hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \ + --hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \ + --hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \ + --hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \ + --hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \ + --hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \ + --hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \ + --hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \ + --hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \ + --hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \ + --hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \ + --hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \ + --hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \ + --hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \ + --hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \ + --hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \ + --hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \ + --hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \ + --hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \ + --hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \ + --hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \ + --hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \ + --hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \ + --hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \ + --hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \ + --hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \ + --hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \ + --hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \ + --hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \ + --hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \ + --hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \ + --hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \ + --hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \ + --hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \ + --hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \ + --hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \ + --hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \ + --hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \ + --hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7 + # via + # -r .github/requirements/mcp-agent-mail.in + # mcp-agent-mail platformdirs==4.9.6 \ --hash=sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a \ --hash=sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 480112aa5b..d6613e1c0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,7 @@ jobs: worker: ${{ steps.filter.outputs.worker == 'true' || steps.filter.outputs.shared == 'true' }} worker_phase2: ${{ steps.filter.outputs.worker_phase2 == 'true' || steps.filter.outputs.shared == 'true' }} cmd_gc_process: ${{ steps.filter.outputs.cmd_gc_process == 'true' || steps.filter.outputs.shared == 'true' }} + credential_provider: ${{ steps.filter.outputs.credential_provider == 'true' || steps.filter.outputs.shared == 'true' }} integration: ${{ steps.filter.outputs.integration == 'true' || steps.filter.outputs.shared == 'true' }} openclaw_bridge: ${{ steps.filter.outputs.openclaw_bridge }} # Raw cross-cutting signal and per-run coverage classification (metric). @@ -131,6 +132,12 @@ jobs: - 'cmd/gc/**' - 'internal/**' - 'examples/gastown/**' + credential_provider: + - 'go.mod' + - 'go.sum' + - 'internal/credentialprovider/**' + - 'internal/testenv/**' + - 'internal/testutil/**' integration: - 'go.mod' - 'go.sum' @@ -139,6 +146,7 @@ jobs: - '**/*.go' - 'scripts/test-integration-shard' - 'scripts/test-go-test-shard' + - 'scripts/runtime-tmux-tests.manifest' - 'scripts/go-test-observable' - 'examples/gastown/**' openclaw_bridge: @@ -176,11 +184,21 @@ jobs: runs-on: ${{ needs.runner-policy.outputs.runner_16vcpu }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 2 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version-file: go.mod - name: CI workflow policy run: make test-ci-policy + - name: Classify static-analysis scope + id: static-scope + env: + EVENT_NAME: ${{ github.event_name }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + scope="$(scripts/ci-static-scope)" + printf 'scope=%s\n' "$scope" >> "$GITHUB_OUTPUT" - name: go.mod replace guard run: make check-gomod-replace - name: Native dependency surface guard @@ -206,16 +224,36 @@ jobs: uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: .cache/golangci-lint + # Exact-key-only restore (no restore-keys fallback): a broad prefix + # let a partial/incompatible cache (missing testing.T.Fatal no-return + # facts) be inherited across differing keys and re-saved forward, + # repeatedly re-poisoning the cache with a SA5011 false-positive + # storm (ga-35qn7i, ga-v7x9vk, ga-l14mnf, ga-ndx34y). A miss now + # forces a fully fresh, never-poisoned recompute. key: ${{ runner.os }}-golangci-lint-${{ steps.glint-version.outputs.version }}-${{ steps.glint-version.outputs.goversion }}-${{ hashFiles('go.sum', '.golangci.yml') }} - restore-keys: | - ${{ runner.os }}-golangci-lint-${{ steps.glint-version.outputs.version }}-${{ steps.glint-version.outputs.goversion }}- - - name: Lint + - name: Lint affected packages + if: steps.static-scope.outputs.scope == 'changed' + env: + GOLANGCI_LINT_CACHE: ${{ github.workspace }}/.cache/golangci-lint + LINT_CHANGED_SCOPE: tracked + LINT_CHANGED_REF: ${{ github.event.pull_request.base.sha }} + run: make lint-affected + - name: Lint full repository + if: steps.static-scope.outputs.scope != 'changed' env: GOLANGCI_LINT_CACHE: ${{ github.workspace }}/.cache/golangci-lint run: make lint - - name: Format + - name: Format changed files + if: steps.static-scope.outputs.scope == 'changed' + env: + LINT_CHANGED_SCOPE: tracked + LINT_CHANGED_REF: ${{ github.event.pull_request.base.sha }} + run: make fmt-check-changed + - name: Format full repository + if: steps.static-scope.outputs.scope != 'changed' run: make fmt-check - name: Vet + if: steps.static-scope.outputs.scope != 'changed' run: make vet - name: Docs run: make check-docs @@ -559,6 +597,67 @@ jobs: if-no-files-found: warn retention-days: 7 + cmd-gc-productmetrics-testhook: + name: cmd/gc product metrics testhook + needs: + - runner-policy + - changes + if: needs.changes.outputs.cmd_gc_process == 'true' + runs-on: ${{ needs.runner-policy.outputs.runner_32vcpu }} + timeout-minutes: 5 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + - name: Ensure timing renderer is available + run: command -v jq >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends jq) + - name: Run product metrics testhook profile + env: + OBSERVABLE_TIMING_FILE: ${{ runner.temp }}/cmd-gc-productmetrics-testhook.json + OBSERVABLE_SHARD_ID: cmd-gc-productmetrics-testhook + OBSERVABLE_VARIANT: linux-productmetrics-testhook + OBSERVABLE_RUNNER_LABEL: ${{ needs.runner-policy.outputs.runner_32vcpu }} + EXTRA_TEST_ENV: >- + OBSERVABLE_TIMING_FILE="$${OBSERVABLE_TIMING_FILE}" + OBSERVABLE_SHARD_ID="$${OBSERVABLE_SHARD_ID}" + OBSERVABLE_VARIANT="$${OBSERVABLE_VARIANT}" + OBSERVABLE_RUNNER_LABEL="$${OBSERVABLE_RUNNER_LABEL}" + OBSERVABLE_COMMIT_SHA="$${GITHUB_SHA}" + OBSERVABLE_WORKFLOW="$${GITHUB_WORKFLOW}" + OBSERVABLE_RUN_ID="$${GITHUB_RUN_ID}" + OBSERVABLE_RUN_ATTEMPT="$${GITHUB_RUN_ATTEMPT}" + OBSERVABLE_JOB="$${GITHUB_JOB}" + OBSERVABLE_RUNNER_NAME="$${RUNNER_NAME}" + OBSERVABLE_RUNNER_OS="$${RUNNER_OS}" + OBSERVABLE_RUNNER_ARCH="$${RUNNER_ARCH}" + run: make test-productmetrics-testhook EXTRA_TEST_ENV="$EXTRA_TEST_ENV" + - name: Upload product metrics testhook timing + if: ${{ always() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: timing-cmd-gc-productmetrics-testhook-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/cmd-gc-productmetrics-testhook.json + if-no-files-found: error + retention-days: 7 + + credential-provider-windows: + name: Credential provider / Windows process tree + needs: + - runner-policy + - changes + if: needs.changes.outputs.credential_provider == 'true' + runs-on: windows-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + - name: Test credential-provider unit contract + run: go test -count=1 ./internal/credentialprovider + - name: Test Windows Job Object descendant cleanup + run: go test -tags=integration -count=1 ./internal/credentialprovider -run '^TestCredentialProviderWindowsJob' -timeout=45s + integration-shards: name: Integration / ${{ matrix.shard_name }} needs: @@ -885,9 +984,7 @@ jobs: run: mkdir -p "$WORKER_REPORT_DIR" - name: WorkerCore phase-2 conformance id: worker_core_phase2_tests - run: | - GC_WORKER_REPORT_DIR="$WORKER_REPORT_DIR" make test-worker-core-phase2 PROFILE="$PROFILE" - GC_WORKER_REPORT_DIR="$WORKER_REPORT_DIR" make test-worker-core-phase2-real-transport PROFILE="$PROFILE" + run: GC_WORKER_REPORT_DIR="$WORKER_REPORT_DIR" make test-worker-core-phase2-all PROFILE="$PROFILE" - name: Ensure WorkerCore phase-2 reports if: ${{ always() && steps.worker_core_phase2_tests.outcome != 'success' }} run: python3 .github/workflows/scripts/worker_report_stub.py "$WORKER_REPORT_DIR" "worker-core-phase2" @@ -923,9 +1020,7 @@ jobs: run: mkdir -p "$WORKER_REPORT_DIR" - name: WorkerCore phase-2 conformance id: worker_core_phase2_tests - run: | - GC_WORKER_REPORT_DIR="$WORKER_REPORT_DIR" make test-worker-core-phase2 PROFILE="$PROFILE" - GC_WORKER_REPORT_DIR="$WORKER_REPORT_DIR" make test-worker-core-phase2-real-transport PROFILE="$PROFILE" + run: GC_WORKER_REPORT_DIR="$WORKER_REPORT_DIR" make test-worker-core-phase2-all PROFILE="$PROFILE" - name: Ensure WorkerCore phase-2 reports if: ${{ always() && steps.worker_core_phase2_tests.outcome != 'success' }} run: python3 .github/workflows/scripts/worker_report_stub.py "$WORKER_REPORT_DIR" "worker-core-phase2" @@ -961,9 +1056,7 @@ jobs: run: mkdir -p "$WORKER_REPORT_DIR" - name: WorkerCore phase-2 conformance id: worker_core_phase2_tests - run: | - GC_WORKER_REPORT_DIR="$WORKER_REPORT_DIR" make test-worker-core-phase2 PROFILE="$PROFILE" - GC_WORKER_REPORT_DIR="$WORKER_REPORT_DIR" make test-worker-core-phase2-real-transport PROFILE="$PROFILE" + run: GC_WORKER_REPORT_DIR="$WORKER_REPORT_DIR" make test-worker-core-phase2-all PROFILE="$PROFILE" - name: Ensure WorkerCore phase-2 reports if: ${{ always() && steps.worker_core_phase2_tests.outcome != 'success' }} run: python3 .github/workflows/scripts/worker_report_stub.py "$WORKER_REPORT_DIR" "worker-core-phase2" @@ -1165,12 +1258,70 @@ jobs: - name: Typecheck test files (tsc -p tsconfig.test.json) run: npm run --workspace gas-city-dashboard-frontend typecheck:test working-directory: internal/api/dashboardspa/web + - name: Typecheck e2e specs (tsc -p e2e/tsconfig.json) + run: npm run --workspace gas-city-dashboard-frontend typecheck:e2e + working-directory: internal/api/dashboardspa/web - name: Vitest run: npm run --workspace gas-city-dashboard-frontend test working-directory: internal/api/dashboardspa/web - name: Build run: npm run build --silent working-directory: internal/api/dashboardspa/web + # Sync the freshly built bundle into the embedded dist/ the fakesupervisor + # serves, so the Playwright render smoke (Layer B) runs against the SPA + # this job just built, not a stale committed embed. + - name: Sync embedded SPA bundle + run: rm -rf internal/api/dashboardspa/dist && cp -rf internal/api/dashboardspa/web/frontend/dist internal/api/dashboardspa/dist + - name: Build seeded fakesupervisor (Layer B server) + run: go build -tags integration -o fakesupervisor . + working-directory: test/dashport/cmd/fakesupervisor + - name: Resolve Playwright version + id: playwright-version + run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT" + working-directory: internal/api/dashboardspa/web + # Cache the downloaded browser binaries keyed on the resolved + # @playwright/test version (from the lockfile-pinned install above). A cache + # hit skips the ~150 MB Chromium download; --with-deps in the install step + # below still runs (system libs aren't cached), so a hit is safe. + # Restore/save are split (rather than the combined actions/cache) so the + # save step can run with if: always() — a completed download still gets + # cached even if a later step in this job fails. + - name: Restore Playwright browsers cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }} + - name: Install Playwright Chromium + timeout-minutes: 12 + run: | + for attempt in 1 2 3; do + if npm run test:e2e:install:ci; then + exit 0 + fi + if [ "$attempt" = "3" ]; then + exit 1 + fi + sleep $((attempt * 10)) + done + working-directory: internal/api/dashboardspa/web/frontend + - name: Save Playwright browsers cache + if: always() + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }} + - name: Playwright render smoke (Layer B) + run: npm run test:e2e + working-directory: internal/api/dashboardspa/web/frontend + timeout-minutes: 10 + - name: Upload Playwright report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: playwright-report + path: internal/api/dashboardspa/web/frontend/playwright-report/ + retention-days: 7 + if-no-files-found: ignore # openclaw-bridge Node test suite. The bridge ships its own npm tests # (inbound at-least-once redelivery + gc client wire shapes); without this @@ -1356,6 +1507,8 @@ jobs: - ci-preflight - ci-integration - cmd-gc-process + - cmd-gc-productmetrics-testhook + - credential-provider-windows - worker-core-summary - worker-core-phase2-summary - pack-gate @@ -1377,6 +1530,8 @@ jobs: needs = json.loads(os.environ["NEEDS_JSON"]) allow_skipped = { "cmd-gc-process", + "cmd-gc-productmetrics-testhook", + "credential-provider-windows", "pack-gate", "docker-session", "k8s-session", diff --git a/.github/workflows/container-scan.yml b/.github/workflows/container-scan.yml index c82a9f9ad7..572a5d2ce8 100644 --- a/.github/workflows/container-scan.yml +++ b/.github/workflows/container-scan.yml @@ -181,7 +181,7 @@ jobs: mkdir -p "$bin_dir" BD_INSTALL_BIN_DIR="$bin_dir" .github/scripts/install-bd-archive.sh "$BD_VERSION" BR_INSTALL_BIN_DIR="$bin_dir" .github/scripts/install-br-archive.sh "$BR_VERSION" - go build -o gc ./cmd/gc + CGO_ENABLED=0 go build -o gc ./cmd/gc cp -f "$bin_dir/bd" bd cp -f "$bin_dir/br" br diff --git a/.github/workflows/mac-regression.yml b/.github/workflows/mac-regression.yml index 52a1758c23..6875237d6a 100644 --- a/.github/workflows/mac-regression.yml +++ b/.github/workflows/mac-regression.yml @@ -152,9 +152,13 @@ jobs: uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: .cache/golangci-lint + # Exact-key-only restore (no restore-keys fallback): a broad prefix + # let a partial/incompatible cache (missing testing.T.Fatal no-return + # facts) be inherited across differing keys and re-saved forward, + # repeatedly re-poisoning the cache with a SA5011 false-positive + # storm (ga-35qn7i, ga-v7x9vk, ga-l14mnf, ga-ndx34y). A miss now + # forces a fully fresh, never-poisoned recompute. key: ${{ runner.os }}-golangci-lint-${{ steps.glint-version.outputs.version }}-${{ steps.glint-version.outputs.goversion }}-${{ hashFiles('go.sum', '.golangci.yml') }} - restore-keys: | - ${{ runner.os }}-golangci-lint-${{ steps.glint-version.outputs.version }}-${{ steps.glint-version.outputs.goversion }}- - name: Lint env: GOLANGCI_LINT_CACHE: ${{ github.workspace }}/.cache/golangci-lint @@ -230,6 +234,9 @@ jobs: install-claude-cli: "false" - name: Run cmd/gc process shard run: make test-cmd-gc-process-shard CMD_GC_PROCESS_SHARD=${{ matrix.shard }} CMD_GC_PROCESS_TOTAL=12 + - name: Run product metrics testhook profile + if: ${{ matrix.shard == 6 }} + run: make test-productmetrics-testhook # Tier A acceptance — smoke-level gate on every PR. mac-acceptance: diff --git a/.github/workflows/remove-needs-info.yml b/.github/workflows/remove-needs-info.yml index 58233e7781..0cde73337d 100644 --- a/.github/workflows/remove-needs-info.yml +++ b/.github/workflows/remove-needs-info.yml @@ -43,11 +43,23 @@ jobs: for (const label of labels) { if (!currentNames.includes(label)) continue; - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: number, - name: label, - }); - console.log(`Removed '${label}' from #${number}`); + // Idempotent removal: concurrent triggers can race to remove the + // same label, leaving the loser with a 404 "Label does not + // exist". Treat an already-absent label as success rather than + // failing the job. + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: number, + name: label, + }); + console.log(`Removed '${label}' from #${number}`); + } catch (err) { + if (err.status === 404) { + console.log(`'${label}' already absent from #${number} (concurrent removal); nothing to do`); + continue; + } + throw err; + } } diff --git a/.github/workflows/remove-needs-triage.yml b/.github/workflows/remove-needs-triage.yml index 189c61ae09..dbc5f6646a 100644 --- a/.github/workflows/remove-needs-triage.yml +++ b/.github/workflows/remove-needs-triage.yml @@ -12,6 +12,8 @@ jobs: # pull_request_target is safe here because this job never checks out or runs # pull request code; it only removes labels from the issue/PR metadata. remove-triage-label: + # Olivia owns label, comment, and inbox removal as one recoverable transaction. + if: github.event.sender.login != 'gascityinc-olivia[bot]' runs-on: ubuntu-latest permissions: issues: write @@ -35,10 +37,25 @@ jobs: if (!current.data.some(l => l.name === target)) return; - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: number, - name: target, - }); - console.log(`Removed '${target}' from #${number} (triaged with '${added}')`); + // The check above narrows a race but cannot close it: adding + // several non-status labels at once fires one `labeled` event + // (and one job) each, and they run concurrently. Every run sees + // the label present here, then all race to remove it. The winner + // gets 204; the losers get 404 "Label does not exist". Treat an + // already-absent label as success so a benign lost race does not + // paint a red X on the PR's checks. + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: number, + name: target, + }); + console.log(`Removed '${target}' from #${number} (triaged with '${added}')`); + } catch (err) { + if (err.status === 404) { + console.log(`'${target}' already absent from #${number} (concurrent removal); nothing to do`); + return; + } + throw err; + } diff --git a/.github/workflows/scripts/test_remove_needs_triage_policy.py b/.github/workflows/scripts/test_remove_needs_triage_policy.py new file mode 100644 index 0000000000..ad3ef1c8c6 --- /dev/null +++ b/.github/workflows/scripts/test_remove_needs_triage_policy.py @@ -0,0 +1,19 @@ +import pathlib +import unittest + + +WORKFLOW = pathlib.Path(__file__).parents[1] / "remove-needs-triage.yml" + + +class RemoveNeedsTriagePolicyTests(unittest.TestCase): + def test_olivia_label_events_do_not_run_the_automatic_removal_job(self) -> None: + lines = WORKFLOW.read_text(encoding="utf-8").splitlines() + + self.assertIn( + " if: github.event.sender.login != 'gascityinc-olivia[bot]'", + lines, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.gitignore b/.gitignore index fc8b2b4033..4476873f71 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,17 @@ cmd/gc/.runtime/ /bin/ /dist/ /gc + +# Dashboard e2e (Layer B) build + run artifacts. The fakesupervisor is a +# compiled -tags integration binary; Playwright emits reports/results/browsers. +# The bare /fakesupervisor guard catches `go build ./test/.../fakesupervisor/` +# run from the repo root (which drops the binary in cwd), not just the -o form. +test/dashport/cmd/fakesupervisor/fakesupervisor +/fakesupervisor +internal/api/dashboardspa/web/frontend/test-results/ +internal/api/dashboardspa/web/frontend/playwright-report/ +internal/api/dashboardspa/web/frontend/blob-report/ +internal/api/dashboardspa/web/frontend/.playwright/ /genschema /bd /br diff --git a/.golangci.yml b/.golangci.yml index 98b233f8fa..5f6486ba8e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -20,9 +20,13 @@ formatters: - goimports linters: - # Default linters (errcheck, govet, ineffassign, staticcheck, unused) - # are always enabled. Add extras here. + # Keep govet explicit in configured lint. The changed-scope target bounds + # both this copy and the Go tool's vet to the same affected graph, preserving + # their distinct diagnostics without repeating either across the whole repo. + # Other default linters (errcheck, ineffassign, staticcheck, unused) remain + # enabled alongside these configured extras. enable: + - govet - errorlint - misspell - gocritic diff --git a/.goreleaser.yml b/.goreleaser.yml index da326bd1eb..754698b4f8 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -5,8 +5,15 @@ builds: binary: gc env: - CGO_ENABLED=0 + # compiledReleaseTag is the only linker-injected product-metrics identity + # input; it labels reporting (release vs canary via release_version shape) and + # can never redirect telemetry or bypass consent. Snapshot (edge/RC) builds + # inject a prerelease semver so they classify as canary; stable releases inject + # the clean tag so they classify as release. Archive/checksum names stay on the + # canonical {{ .Version }} (see snapshot.version_template) so the RC formula + # keeps matching gascity__*. ldflags: - - -s -w -X main.version={{ .Tag }} -X main.commit={{ .Commit }} -X main.date={{ .Date }} + - -s -w -X main.version={{ .Tag }} -X main.commit={{ .Commit }} -X main.date={{ .Date }} -X github.com/gastownhall/gascity/internal/productmetrics.compiledReleaseTag={{ if .IsSnapshot }}{{ .Version }}-canary-{{ .ShortCommit }}{{ else }}{{ .Version }}{{ end }} goos: - linux - darwin @@ -27,6 +34,14 @@ release: prerelease: auto replace_existing_artifacts: true +# Keep snapshot .Version identical to the canonical tag version so snapshot +# archives and checksums stay named gascity__* (the RC formula depends +# on the exact name). The default snapshot template appends -SNAPSHOT-, which +# would break that match. Snapshot binaries still classify as canary via the +# conditional compiledReleaseTag ldflag above, without renaming any artifact. +snapshot: + version_template: "{{ .Version }}" + # Homebrew tap distribution is generated by .github/workflows/release.yml after # GoReleaser uploads all release archives. The tap formula installs the release # assets directly; no source build or Go toolchain is required for users. diff --git a/.trivyignore.yaml b/.trivyignore.yaml index 55d31f2c82..9581e4fca7 100644 --- a/.trivyignore.yaml +++ b/.trivyignore.yaml @@ -1,118 +1,88 @@ vulnerabilities: - # Expiry horizon bulk-extended 2026-07-06: 2026-07-07 -> 2026-08-07. Re-audit - # confirmed every waived upstream is still pinned at its vulnerable version, so - # nothing is droppable yet: dolt v2.1.7 (Go 1.26.2 stdlib + old - # x/net/x/crypto/thrift), bundled bd v1.1.0 (beads repin: x-net/x-crypto; - # thrift+go-jose cleared by the v1.1.0 rebuild), kubectl base binary (external - # x/net), and gc (go.mod still - # golang.org/x/net v0.52.0 / golang.org/x/crypto v0.49.0). gh already cleared - # by cli/cli v2.94.0. Drop each entry per its own `statement:` once that - # upstream actually rebuilds. + # Expiry horizon bulk-extended 2026-07-06: 2026-07-07 -> 2026-08-07. # - # Go stdlib CVEs disclosed 2026-05-12. Fixed in Go 1.25.10 / 1.26.3 for - # the first batch (33811–42499); Go 1.26.4 / 1.25.11 for CVE-2026-42504. - # As of 2026-06-15: cli/cli v2.94.0 ships Go 1.26.4 (clears all entries); - # gh is installed unpinned via apt so a base-image rebuild picks it up - # automatically. dolthub/dolt v2.1.7 still uses Go 1.26.2 (pending - # upstream). gc builds against Go 1.26.4 (go.mod, via PR #3297). - # Durable full fix tracked in ga-frh27v; remove dolt entries once upstream - # ships a Go 1.26.4+ build. + # Rebuilt-from-source tools clear the Go-stdlib CVEs: contrib/k8s/Dockerfile.base + # rebuilds gh and dolt, and contrib/k8s/Dockerfile.agent rebuilds bd, all with the + # Go 1.26.5 toolchain, and each build asserts the artifact embeds patched grpc + # (`go version -m ... google.golang.org/grpc`). Those three paths are therefore + # NOT waived below: if a rebuilt bd/dolt/gh ever re-triggers a Go-stdlib CVE the + # scan must fail so the regression is visible, not silently waived. Only the + # still-external prebuilt binaries (br, kubectl) keep Go-stdlib waivers. + # + # The x/net, x/crypto, and thrift waivers further below are unaffected by the + # rebuild: it bumped only google.golang.org/grpc, so bd/dolt/gc still pin the + # vulnerable x/net/x/crypto module versions and keep those entries. + # + # Go stdlib CVEs disclosed 2026-05-12. Fixed in Go 1.25.10 / 1.26.3 for the + # first batch (33811–42499); Go 1.26.4 / 1.25.11 for CVE-2026-42504 and + # CVE-2026-27145; Go 1.26.5 / 1.25.12 for CVE-2026-39822. br and kubectl are + # external prebuilt binaries still on Go 1.26.2 stdlib; remove each once its + # upstream rebuilds against 1.26.5+. Durable full fix tracked in ga-frh27v. - id: CVE-2026-33811 paths: - - "usr/local/bin/bd" - "usr/local/bin/br" - - "usr/local/bin/dolt" - "usr/local/bin/kubectl" expired_at: 2026-08-07 - statement: Upstream bd, br, dolt, and kubectl embed Go 1.26.2 stdlib; remove once each rebuilds against 1.26.3+ (or 1.25.10+). gh cleared by v2.94.0 (Go 1.26.4) on base-image rebuild. + statement: External prebuilt br and kubectl still embed Go 1.26.2 stdlib; remove once each rebuilds against 1.26.3+ (or 1.25.10+). Rebuilt bd/dolt/gh (Go 1.26.5) clear this and are no longer waived. - id: CVE-2026-33814 paths: - - "usr/local/bin/bd" - "usr/local/bin/br" - - "usr/local/bin/dolt" - "usr/local/bin/kubectl" expired_at: 2026-08-07 - statement: Upstream bd, br, dolt, and kubectl embed Go 1.26.2 stdlib; remove once each rebuilds against 1.26.3+ (or 1.25.10+). gh cleared by v2.94.0 (Go 1.26.4) on base-image rebuild. + statement: External prebuilt br and kubectl still embed Go 1.26.2 stdlib; remove once each rebuilds against 1.26.3+ (or 1.25.10+). Rebuilt bd/dolt/gh (Go 1.26.5) clear this and are no longer waived. (gc's separate x/net http2 instance is waived below.) - id: CVE-2026-39820 paths: - - "usr/local/bin/bd" - "usr/local/bin/br" - - "usr/local/bin/dolt" - "usr/local/bin/kubectl" expired_at: 2026-08-07 - statement: Upstream bd, br, dolt, and kubectl embed Go 1.26.2 stdlib; remove once each rebuilds against 1.26.3+ (or 1.25.10+). gh cleared by v2.94.0 (Go 1.26.4) on base-image rebuild. + statement: External prebuilt br and kubectl still embed Go 1.26.2 stdlib; remove once each rebuilds against 1.26.3+ (or 1.25.10+). Rebuilt bd/dolt/gh (Go 1.26.5) clear this and are no longer waived. - id: CVE-2026-39822 paths: - - "usr/local/bin/bd" - "usr/local/bin/br" - - "usr/local/bin/dolt" - "usr/local/bin/kubectl" - - "usr/bin/gh" expired_at: 2026-08-07 - statement: Go stdlib os.Root symlink CVE disclosed 2026-07; fixed in Go 1.26.5 / 1.25.12. bd, br, dolt (1.26.2) and kubectl pend upstream rebuilds; gh (cli/cli v2.94.0, Go 1.26.4) clears when upstream ships a 1.26.5+ build. gc itself builds with Go 1.26.5 as of this change (no waiver). + statement: Go stdlib os.Root symlink CVE (fixed Go 1.26.5 / 1.25.12); external prebuilt br and kubectl still affected pending upstream rebuilds. Rebuilt bd/dolt/gh (Go 1.26.5) clear it and are no longer waived; gc also builds with Go 1.26.5. - id: CVE-2026-39823 paths: - - "usr/local/bin/bd" - "usr/local/bin/br" - - "usr/local/bin/dolt" - "usr/local/bin/kubectl" expired_at: 2026-08-07 - statement: Upstream bd, br, dolt, and kubectl embed Go 1.26.2 stdlib; remove once each rebuilds against 1.26.3+ (or 1.25.10+). gh cleared by v2.94.0 (Go 1.26.4) on base-image rebuild. + statement: External prebuilt br and kubectl still embed Go 1.26.2 stdlib; remove once each rebuilds against 1.26.3+ (or 1.25.10+). Rebuilt bd/dolt/gh (Go 1.26.5) clear this and are no longer waived. - id: CVE-2026-39825 paths: - - "usr/local/bin/bd" - "usr/local/bin/br" - - "usr/local/bin/dolt" - "usr/local/bin/kubectl" expired_at: 2026-08-07 - statement: Upstream bd, br, dolt, and kubectl embed Go 1.26.2 stdlib; remove once each rebuilds against 1.26.3+ (or 1.25.10+). gh cleared by v2.94.0 (Go 1.26.4) on base-image rebuild. + statement: External prebuilt br and kubectl still embed Go 1.26.2 stdlib; remove once each rebuilds against 1.26.3+ (or 1.25.10+). Rebuilt bd/dolt/gh (Go 1.26.5) clear this and are no longer waived. - id: CVE-2026-39826 paths: - - "usr/local/bin/bd" - "usr/local/bin/br" - - "usr/local/bin/dolt" - "usr/local/bin/kubectl" expired_at: 2026-08-07 - statement: Upstream bd, br, dolt, and kubectl embed Go 1.26.2 stdlib; remove once each rebuilds against 1.26.3+ (or 1.25.10+). gh cleared by v2.94.0 (Go 1.26.4) on base-image rebuild. + statement: External prebuilt br and kubectl still embed Go 1.26.2 stdlib; remove once each rebuilds against 1.26.3+ (or 1.25.10+). Rebuilt bd/dolt/gh (Go 1.26.5) clear this and are no longer waived. - id: CVE-2026-39836 paths: - - "usr/local/bin/bd" - "usr/local/bin/br" - - "usr/local/bin/dolt" - "usr/local/bin/kubectl" expired_at: 2026-08-07 - statement: Upstream bd, br, dolt, and kubectl embed Go 1.26.2 stdlib; remove once each rebuilds against 1.26.3+ (or 1.25.10+). gh cleared by v2.94.0 (Go 1.26.4) on base-image rebuild. + statement: External prebuilt br and kubectl still embed Go 1.26.2 stdlib; remove once each rebuilds against 1.26.3+ (or 1.25.10+). Rebuilt bd/dolt/gh (Go 1.26.5) clear this and are no longer waived. - id: CVE-2026-42499 paths: - - "usr/local/bin/bd" - "usr/local/bin/br" - - "usr/local/bin/dolt" - "usr/local/bin/kubectl" expired_at: 2026-08-07 - statement: Upstream bd, br, dolt, and kubectl embed Go 1.26.2 stdlib; remove once each rebuilds against 1.26.3+ (or 1.25.10+). gh cleared by v2.94.0 (Go 1.26.4) on base-image rebuild. + statement: External prebuilt br and kubectl still embed Go 1.26.2 stdlib; remove once each rebuilds against 1.26.3+ (or 1.25.10+). Rebuilt bd/dolt/gh (Go 1.26.5) clear this and are no longer waived. - id: CVE-2026-42504 paths: - - "usr/local/bin/bd" - "usr/local/bin/br" - - "usr/local/bin/dolt" - "usr/local/bin/kubectl" expired_at: 2026-08-07 - statement: Go stdlib MIME-header DoS (CVE-2026-42504), fixed in Go 1.26.4 / 1.25.11. bd, br, dolt, and kubectl still embed an older Go stdlib; remove once each rebuilds against 1.26.4+. gh cleared by v2.94.0 on rebuild; gc cleared by go.mod bump to 1.26.4 (PR #3297). - - id: CVE-2026-27145 - paths: - - "usr/local/bin/bd" - expired_at: 2026-08-07 - statement: Go stdlib x509 hostname verification issue (CVE-2026-27145), fixed in Go 1.26.4 / 1.25.11. bd v1.1.0 is the deliberate beads repin (still built with Go 1.26.2); remove once the bundled CLI rebuilds against 1.26.4+. - - id: CVE-2026-27145 - paths: - - "usr/local/bin/dolt" - expired_at: 2026-08-07 - statement: Go stdlib x509 hostname verification issue (CVE-2026-27145), fixed in Go 1.26.4 / 1.25.11. Dolt v2.1.7 still embeds Go 1.26.2; remove once upstream rebuilds against 1.26.4+. + statement: Go stdlib MIME-header DoS (CVE-2026-42504, fixed Go 1.26.4 / 1.25.11); external prebuilt br and kubectl still affected. Rebuilt bd/dolt/gh (Go 1.26.5) clear it and are no longer waived; gc cleared via go.mod toolchain. - id: CVE-2026-27145 paths: - "usr/local/bin/kubectl" expired_at: 2026-08-07 - statement: Go stdlib x509 hostname verification issue (CVE-2026-27145), fixed in Go 1.26.4 / 1.25.11. kubectl in the base image still embeds Go 1.26.2; remove once the bundled CLI rebuilds against 1.26.4+. + statement: Go stdlib x509 hostname verification issue (CVE-2026-27145), fixed in Go 1.26.4 / 1.25.11. kubectl in the base image still embeds Go 1.26.2; remove once the bundled CLI rebuilds against 1.26.4+. Rebuilt bd/dolt (Go 1.26.5) cleared and no longer waived. - id: CVE-2026-41602 paths: - "usr/local/bin/dolt" @@ -200,121 +170,113 @@ vulnerabilities: statement: Dolt v2.1.7 still bundles golang.org/x/crypto v0.48.0; remove once upstream rebuilds against the fixed release. # The golang.org/x/net (HTML/idna/http2) and golang.org/x/crypto/ssh CVEs in # the same series the dolt entries above waive are also reported against the - # bd CLI binary (steveyegge/beads v1.1.0, external), the gc binary - # (indirect golang.org/x/net v0.52.0 / golang.org/x/crypto v0.49.0), and the + # bd CLI binary (steveyegge/beads v1.1.0, external), the gc binary, and the # external kubectl binary (golang.org/x/net v0.49.0; the gc-controller image # adds kubectl on top of the agent image). With set -e the Container Scan # halts at the first failing image, so kubectl's x/net findings stayed masked - # until the bd/gc findings were waived. These are base-pre-existing: the - # Container Scan is already red on main (scheduled run 2026-06-24) and this PR - # does not change go.mod, so gc's transitive module versions are identical to - # base. Remove the bd/kubectl paths once those binaries rebuild upstream; - # remove the gc paths once gc's go.mod bumps golang.org/x/net >= 0.55.0 - # (>= 0.53.0 for CVE-2026-33814) and golang.org/x/crypto >= 0.52.0. + # until the bd/gc findings were waived. + # + # This change bumps gc's go.mod to golang.org/x/net v0.54.0 and + # golang.org/x/crypto v0.52.0, which clears the gc instance of every + # x/crypto/ssh CVE below (fixed in x/crypto 0.52.0) and CVE-2026-33814 + # (x/net http2, fixed in 0.53.0); the gc path is therefore dropped from those + # entries so the scan must prove gc is clean rather than mask it. gc is still + # waived for the x/net HTML/idna CVEs fixed only in x/net >= 0.55.0 + # (CVE-2026-25680/25681/27136/39821/42502/42506); drop the gc path from each + # once gc's go.mod bumps golang.org/x/net >= 0.55.0. bd (beads v1.1.0) and + # kubectl stay external and base-pre-existing (Container Scan already red on + # main, scheduled run 2026-06-24); remove their paths once they rebuild + # upstream. TestTrivyIgnoreDropsGCModuleWaiversPastThreshold enforces that no + # gc waiver outlives its go.mod fix version. - id: CVE-2026-25680 paths: - "usr/local/bin/bd" - "usr/local/bin/gc" - "usr/local/bin/kubectl" expired_at: 2026-08-07 - statement: golang.org/x/net HTML parsing DoS; base-pre-existing (also red on main 2026-06-24). Present in bd (beads v1.1.0), gc (indirect x/net v0.52.0), and kubectl (external, x/net v0.49.0). Remove once bd/kubectl rebuild upstream and gc bumps golang.org/x/net >= 0.55.0. + statement: golang.org/x/net HTML parsing DoS; base-pre-existing (also red on main 2026-06-24). Present in bd (beads v1.1.0), gc (indirect x/net v0.54.0), and kubectl (external, x/net v0.49.0). Remove once bd/kubectl rebuild upstream and gc bumps golang.org/x/net >= 0.55.0. - id: CVE-2026-25681 paths: - "usr/local/bin/bd" - "usr/local/bin/gc" - "usr/local/bin/kubectl" expired_at: 2026-08-07 - statement: golang.org/x/net HTML rendering issue; base-pre-existing (also red on main 2026-06-24). Present in bd (beads v1.1.0), gc (indirect x/net v0.52.0), and kubectl (external, x/net v0.49.0). Remove once bd/kubectl rebuild upstream and gc bumps golang.org/x/net >= 0.55.0. + statement: golang.org/x/net HTML rendering issue; base-pre-existing (also red on main 2026-06-24). Present in bd (beads v1.1.0), gc (indirect x/net v0.54.0), and kubectl (external, x/net v0.49.0). Remove once bd/kubectl rebuild upstream and gc bumps golang.org/x/net >= 0.55.0. - id: CVE-2026-27136 paths: - "usr/local/bin/bd" - "usr/local/bin/gc" - "usr/local/bin/kubectl" expired_at: 2026-08-07 - statement: golang.org/x/net HTML rendering issue; base-pre-existing (also red on main 2026-06-24). Present in bd (beads v1.1.0), gc (indirect x/net v0.52.0), and kubectl (external, x/net v0.49.0). Remove once bd/kubectl rebuild upstream and gc bumps golang.org/x/net >= 0.55.0. + statement: golang.org/x/net HTML rendering issue; base-pre-existing (also red on main 2026-06-24). Present in bd (beads v1.1.0), gc (indirect x/net v0.54.0), and kubectl (external, x/net v0.49.0). Remove once bd/kubectl rebuild upstream and gc bumps golang.org/x/net >= 0.55.0. - id: CVE-2026-39821 paths: - "usr/local/bin/bd" - "usr/local/bin/gc" - "usr/local/bin/kubectl" expired_at: 2026-08-07 - statement: golang.org/x/net/idna issue; base-pre-existing (also red on main 2026-06-24). Present in bd (beads v1.1.0), gc (indirect x/net v0.52.0), and kubectl (external, x/net v0.49.0). Remove once bd/kubectl rebuild upstream and gc bumps golang.org/x/net >= 0.55.0. + statement: golang.org/x/net/idna issue; base-pre-existing (also red on main 2026-06-24). Present in bd (beads v1.1.0), gc (indirect x/net v0.54.0), and kubectl (external, x/net v0.49.0). Remove once bd/kubectl rebuild upstream and gc bumps golang.org/x/net >= 0.55.0. - id: CVE-2026-42502 paths: - "usr/local/bin/bd" - "usr/local/bin/gc" - "usr/local/bin/kubectl" expired_at: 2026-08-07 - statement: golang.org/x/net HTML rendering issue; base-pre-existing (also red on main 2026-06-24). Present in bd (beads v1.1.0), gc (indirect x/net v0.52.0), and kubectl (external, x/net v0.49.0). Remove once bd/kubectl rebuild upstream and gc bumps golang.org/x/net >= 0.55.0. + statement: golang.org/x/net HTML rendering issue; base-pre-existing (also red on main 2026-06-24). Present in bd (beads v1.1.0), gc (indirect x/net v0.54.0), and kubectl (external, x/net v0.49.0). Remove once bd/kubectl rebuild upstream and gc bumps golang.org/x/net >= 0.55.0. - id: CVE-2026-42506 paths: - "usr/local/bin/bd" - "usr/local/bin/gc" - "usr/local/bin/kubectl" expired_at: 2026-08-07 - statement: golang.org/x/net HTML rendering issue; base-pre-existing (also red on main 2026-06-24). Present in bd (beads v1.1.0), gc (indirect x/net v0.52.0), and kubectl (external, x/net v0.49.0). Remove once bd/kubectl rebuild upstream and gc bumps golang.org/x/net >= 0.55.0. - - id: CVE-2026-33814 - paths: - - "usr/local/bin/gc" - expired_at: 2026-08-07 - statement: golang.org/x/net http2 issue; base-pre-existing (also red on main 2026-06-24). gc only (indirect x/net v0.52.0); bd/br/dolt/kubectl already covered by the stdlib waiver above. Remove once gc bumps golang.org/x/net >= 0.53.0. + statement: golang.org/x/net HTML rendering issue; base-pre-existing (also red on main 2026-06-24). Present in bd (beads v1.1.0), gc (indirect x/net v0.54.0), and kubectl (external, x/net v0.49.0). Remove once bd/kubectl rebuild upstream and gc bumps golang.org/x/net >= 0.55.0. - id: CVE-2026-39827 paths: - "usr/local/bin/bd" - - "usr/local/bin/gc" expired_at: 2026-08-07 - statement: golang.org/x/crypto/ssh CVE; base-pre-existing (also red on main 2026-06-24). Present in bd (beads v1.1.0) and gc (indirect x/crypto v0.49.0). Remove once bd rebuilds and gc bumps golang.org/x/crypto >= 0.52.0. + statement: golang.org/x/crypto/ssh CVE; present in bd (beads v1.1.0). gc cleared by the golang.org/x/crypto v0.52.0 bump in this change. Remove once bd rebuilds against x/crypto >= 0.52.0. - id: CVE-2026-39828 paths: - "usr/local/bin/bd" - - "usr/local/bin/gc" expired_at: 2026-08-07 - statement: golang.org/x/crypto/ssh CVE; base-pre-existing (also red on main 2026-06-24). Present in bd (beads v1.1.0) and gc (indirect x/crypto v0.49.0). Remove once bd rebuilds and gc bumps golang.org/x/crypto >= 0.52.0. + statement: golang.org/x/crypto/ssh CVE; present in bd (beads v1.1.0). gc cleared by the golang.org/x/crypto v0.52.0 bump in this change. Remove once bd rebuilds against x/crypto >= 0.52.0. - id: CVE-2026-39829 paths: - "usr/local/bin/bd" - - "usr/local/bin/gc" expired_at: 2026-08-07 - statement: golang.org/x/crypto/ssh CVE; base-pre-existing (also red on main 2026-06-24). Present in bd (beads v1.1.0) and gc (indirect x/crypto v0.49.0). Remove once bd rebuilds and gc bumps golang.org/x/crypto >= 0.52.0. + statement: golang.org/x/crypto/ssh CVE; present in bd (beads v1.1.0). gc cleared by the golang.org/x/crypto v0.52.0 bump in this change. Remove once bd rebuilds against x/crypto >= 0.52.0. - id: CVE-2026-39830 paths: - "usr/local/bin/bd" - - "usr/local/bin/gc" expired_at: 2026-08-07 - statement: golang.org/x/crypto/ssh CVE; base-pre-existing (also red on main 2026-06-24). Present in bd (beads v1.1.0) and gc (indirect x/crypto v0.49.0). Remove once bd rebuilds and gc bumps golang.org/x/crypto >= 0.52.0. + statement: golang.org/x/crypto/ssh CVE; present in bd (beads v1.1.0). gc cleared by the golang.org/x/crypto v0.52.0 bump in this change. Remove once bd rebuilds against x/crypto >= 0.52.0. - id: CVE-2026-39831 paths: - "usr/local/bin/bd" - - "usr/local/bin/gc" expired_at: 2026-08-07 - statement: golang.org/x/crypto/ssh CVE; base-pre-existing (also red on main 2026-06-24). Present in bd (beads v1.1.0) and gc (indirect x/crypto v0.49.0). Remove once bd rebuilds and gc bumps golang.org/x/crypto >= 0.52.0. + statement: golang.org/x/crypto/ssh CVE; still present in bd (beads v1.1.0). The gc waiver was dropped in the v1.4.0 resync — go.mod now pins golang.org/x/crypto v0.52.0, which fixes it, so gc no longer needs an exception. Remove this entry once bd rebuilds against x/crypto >= 0.52.0. - id: CVE-2026-39832 paths: - "usr/local/bin/bd" - - "usr/local/bin/gc" expired_at: 2026-08-07 - statement: golang.org/x/crypto/ssh/agent CVE; base-pre-existing (also red on main 2026-06-24). Present in bd (beads v1.1.0) and gc (indirect x/crypto v0.49.0). Remove once bd rebuilds and gc bumps golang.org/x/crypto >= 0.52.0. + statement: golang.org/x/crypto/ssh/agent CVE; present in bd (beads v1.1.0). gc cleared by the golang.org/x/crypto v0.52.0 bump in this change. Remove once bd rebuilds against x/crypto >= 0.52.0. - id: CVE-2026-39835 paths: - "usr/local/bin/bd" - - "usr/local/bin/gc" expired_at: 2026-08-07 - statement: golang.org/x/crypto/ssh CVE; base-pre-existing (also red on main 2026-06-24). Present in bd (beads v1.1.0) and gc (indirect x/crypto v0.49.0). Remove once bd rebuilds and gc bumps golang.org/x/crypto >= 0.52.0. + statement: golang.org/x/crypto/ssh CVE; present in bd (beads v1.1.0). gc cleared by the golang.org/x/crypto v0.52.0 bump in this change. Remove once bd rebuilds against x/crypto >= 0.52.0. - id: CVE-2026-42508 paths: - "usr/local/bin/bd" - - "usr/local/bin/gc" expired_at: 2026-08-07 - statement: golang.org/x/crypto/ssh/knownhosts CVE; base-pre-existing (also red on main 2026-06-24). Present in bd (beads v1.1.0) and gc (indirect x/crypto v0.49.0). Remove once bd rebuilds and gc bumps golang.org/x/crypto >= 0.52.0. + statement: golang.org/x/crypto/ssh/knownhosts CVE; present in bd (beads v1.1.0). gc cleared by the golang.org/x/crypto v0.52.0 bump in this change. Remove once bd rebuilds against x/crypto >= 0.52.0. - id: CVE-2026-46595 paths: - "usr/local/bin/bd" - - "usr/local/bin/gc" expired_at: 2026-08-07 - statement: golang.org/x/crypto/ssh CVE; base-pre-existing (also red on main 2026-06-24). Present in bd (beads v1.1.0) and gc (indirect x/crypto v0.49.0). Remove once bd rebuilds and gc bumps golang.org/x/crypto >= 0.52.0. + statement: golang.org/x/crypto/ssh CVE; present in bd (beads v1.1.0). gc cleared by the golang.org/x/crypto v0.52.0 bump in this change. Remove once bd rebuilds against x/crypto >= 0.52.0. - id: CVE-2026-46597 paths: - "usr/local/bin/bd" - - "usr/local/bin/gc" expired_at: 2026-08-07 - statement: golang.org/x/crypto/ssh CVE; base-pre-existing (also red on main 2026-06-24). Present in bd (beads v1.1.0) and gc (indirect x/crypto v0.49.0). Remove once bd rebuilds and gc bumps golang.org/x/crypto >= 0.52.0. + statement: golang.org/x/crypto/ssh CVE; present in bd (beads v1.1.0). gc cleared by the golang.org/x/crypto v0.52.0 bump in this change. Remove once bd rebuilds against x/crypto >= 0.52.0. diff --git a/AGENTS.md b/AGENTS.md index 75e10d3dfc..be716761e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -273,9 +273,12 @@ the canonical route, not the legacy route. `worker.SessionHandle`, `sessionlog`, and similar bypass paths in `cmd/gc`. The remaining manager-construction/direct-create bypasses are split by category: `internal/api/session_manager.go` constructs - `session.Manager` values for API handlers, and - `internal/api/session_resolution.go` still calls - `mgr.CreateSession(...)` directly. Session creation goes through the + `session.Manager` values for API handlers. + (`internal/api/session_resolution.go`'s named-session create was + converted to the worker boundary — it now routes through + `worker.Handle.Create(ctx, worker.CreateModeStarted)` via + `newResolvedWorkerSessionHandle`, no longer calling + `mgr.CreateSession(...)` directly.) Session creation goes through the single `Manager.CreateSession(ctx, session.CreateOptions{...})` entry point (`NewManagerWithOptions` is the sole Manager constructor). This list is not a sessionlog read-site inventory; stream and transcript @@ -506,6 +509,7 @@ bd close # Complete work - Run `bd prime` for detailed command reference and session close protocol - Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files - For controller or session reconciler incidents, use `gc trace` and follow `engdocs/contributors/reconciler-debugging.md` for the artifact collection workflow. +- When a bead needs to pause on a specific actor or condition, only `hold:mayor` and `hold:external` are canonical (set via `bd set-state hold=mayor|external --reason "..."`) — never invent a new ad hoc hold/blocked label. See `engdocs/contributors/hold-label-conventions.md`. ## Session Completion diff --git a/CHANGELOG.md b/CHANGELOG.md index 3268ec7e01..2af4afc0bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,20 +95,77 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 instead of a constant 5s that had been ON for 100% of ticks in a 30-55s regime. Consumers of `threshold_breach` should expect it to mean "lost cadence for a full interval", not "took more than 5 seconds". +## [1.4.0] - 2026-07-24 ### Upgrading Notes -- **Every graph-owning store scope needs a `Dir`-matched `control-dispatcher` - agent.** Control beads now route to the dispatcher that owns their store scope - (city vs. rig) rather than falling back to the city dispatcher, and that - routing is fail-closed: a graph owned by `rig:X` whose scope has no exactly - `Dir`-matched `control-dispatcher` agent now fails before instantiation with an - `OrderFailed` event instead of silently stranding its control lane on a - dispatcher that cannot read the rig store. Deployments that previously limped - along through shared-store mis-routing must add a matching rig-scoped - `control-dispatcher` agent; the reconciler logs `control bead in rig - store "X" has no configured control-dispatcher for its store scope` to name - the missing scope. +- **Configure one store-scoped `control-dispatcher` for every graph-owning + scope.** Formula control beads now route to the dispatcher whose `Dir` + matches the city or rig store that owns the graph. A rig-owned graph with no + matching dispatcher fails before instantiation instead of falling back to a + dispatcher that cannot read its work. +- **Run `gc doctor --fix` after upgrading an existing city.** The current + doctor converges pack imports, provider catalogs, project identity, retired + hold labels, and managed beads/Dolt metadata before the orchestrator starts. +- **Upgrading over an older install at a different path may need a manual + reseed.** If a machine already ran an older `gc` (for example a Homebrew + binary now replaced by a source build at a new path), `gc start` can keep the + stale supervisor running and can fail closed on a present-but-invalid + bundled-pack cache — only an *absent* cache self-heals. Run `gc import + install` to repopulate the cache, then let `gc start` auto-restart the + supervisor (or on Linux `systemctl --user restart gascity-supervisor`). +- **An unrelated stale registered city can block `gc start`; fix or unregister + that city — not the one you are starting.** A pre-1.3 city still registered + with un-migrated provider config (for example `workspace.provider = "claude"` + with no `[providers.claude]` block) can fail the registry scan and abort + startup, with a misleading hint to `gc init` the healthy city you were + actually starting. Run `gc doctor --fix` inside the offending stale city, or + `gc unregister ` to drop it. +- **macOS: a supervisor left running from a prior version may need a manual + restart.** macOS cannot resolve a direct (non-launchd) supervisor's + executable for binary-drift detection, so the automatic post-upgrade restart + may not complete. Run `gc supervisor stop --wait`, then `gc start`. + +### Added + +- **A run-centered dashboard and API.** Run detail now combines the formula + stage ladder, structured transcripts, token rate, and estimated burn rate. + Session and run reads use typed, paginated API surfaces backed by warm + projections instead of ad hoc wire shapes. +- **Durable usage and lifecycle observability.** Model, compute, and lifecycle + facts feed local usage history and OpenTelemetry metrics. End-of-interval + transcript sweeps keep live pool sessions' token and cost rates current even + when agents self-drive after their initial claim. +- **Privacy-scoped command-usage metrics in release artifacts.** Before the + first eligible interactive command is recorded, `gc` shows the complete + disclosure. Events contain only a canonical command ID, the `gc` release, + operating system, and an anonymous installation ID—never arguments, paths, + file contents, or environment values. `gc metrics status`, `example`, `on`, + and `off` expose the local controls; `DO_NOT_TRACK=1` and + `GC_DISABLE_USAGE_METRICS=1` provide environment-level opt-outs. +- **Broader runtime composition.** Provider routing, ACP/automatic runtime + selection, Herdr-backed sessions, and Kubernetes/subprocess/tmux execution + share the same session lifecycle and worker boundary. +- **Production workflow controls.** Formulas v2 gained stronger retry, + fan-out, drain, scope, artifact, and finalization behavior, plus better live + status and event evidence for operators. +- **An experimental OpenClaw bridge proof of concept.** The private package + under `contrib/openclaw-bridge` explores iMessage and Telegram connectors; it + is not a supported provider pack or a shipped connector artifact. + +### Changed + +- **Session lifecycle operations converge through the worker boundary.** Pool + demand, wake, resume, drain, close, and orphan recovery now reason from + persisted session/work identity rather than provider-specific shortcuts. +- **Beads remains the persistence boundary while storage becomes more + resilient.** Native and CLI-backed stores share transactional lifecycle + semantics, bounded cached reads, store-aware routing, and explicit degraded + results across city and rig scopes. +- **CI and local verification are sharded and event-driven.** The release gate + includes fast units, process tests, integration packages, tutorials, real + inference acceptance, and macOS regressions without one monolithic test + process. ### Fixed @@ -213,6 +270,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and the CI `bd` toolchain (`BD_VERSION`) to `v1.0.4`. No behavior change is expected — Gas City already defaults to `bd_compatibility = "bd-1.0.4"` semantics, and the config still accepts both `bd-1.0.4` and `bd-1.0.5`. +- **Pool sessions no longer lose or strand work while draining, restarting, or + reusing capacity.** Claim ownership, wake budgets, slot selection, and + confirmed-dead cleanup are now fenced against stale or partial observations. +- **Formula control routing retries transient configuration reads.** Attempt + spawn and fan-out no longer quarantine an in-flight run because of a + momentary config/include read failure; a successfully loaded configuration + that lacks the required scoped dispatcher still fails closed. +- **Managed beads and Dolt paths fail more honestly.** Provider health, + endpoint ownership, lock release, compaction, reindexing, stale data-dir + cleanup, and partial-store reads now preserve errors instead of silently + reporting complete state. +- **Events, nudges, waits, and session output remain bounded under load.** The + CLI drains paginated event windows, request paths avoid unbounded scans, tmux + sessions keep their shared server, and structured transcripts preserve tool + and error frames. +- **Live run cost fields populate for long-lived pool sessions.** + End-of-interval model-usage sweeps account for each transcript window once, + restoring `tokens/min` and `burn/hr` in run detail (PR #4436). +- **Customer Zero dashboard and claim regressions are closed.** Cross-city + attention reads now cancel stale requests and recover after startup; Health + reports per-metric availability with cross-platform sampling instead of + false zero/NaN values; and hook claims no longer fuzzy-update a vanished + session record (#4354, #4356, #4361). +- **Release-candidate gates are portable and reproducible.** Bash 3 scripts, + deep metrics fixtures, reusable pool slots, Tier C pack compatibility, and + container-tool vulnerability checks now exercise the same bounded behavior + expected from the shipped artifacts. ## [1.3.0] - 2026-06-18 @@ -848,7 +932,8 @@ community contributors. See the GitHub release page for the full narrative. semantics, watchdog reconciliation cadence, dirty-cache fallback reads. - Long tail of session lifecycle, wake-budget, and pool identity fixes. -[Unreleased]: https://github.com/gastownhall/gascity/compare/v1.3.0...HEAD +[Unreleased]: https://github.com/gastownhall/gascity/compare/v1.4.0...HEAD +[1.4.0]: https://github.com/gastownhall/gascity/releases/tag/v1.4.0 [1.3.0]: https://github.com/gastownhall/gascity/compare/v1.2.1...v1.3.0 [1.2.1]: https://github.com/gastownhall/gascity/compare/v1.2.0...v1.2.1 [1.2.0]: https://github.com/gastownhall/gascity/releases/tag/v1.2.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 15ffa92d0b..fab465506c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,10 +74,10 @@ Suggested prefixes: ## Code Style -- Follow standard Go conventions -- Keep functions focused and small -- Add tests for behavior changes -- Add comments only when the logic is not self-evident +- Follow standard Go conventions. +- Keep functions focused and small. +- Add tests for behavior changes. +- Add comments only when the logic is not self-evident. ## Design Philosophy diff --git a/Makefile b/Makefile index 328c91c5bc..ccffcba76b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -GOLANGCI_LINT_VERSION := 2.9.0 +GOLANGCI_LINT_VERSION := 2.12.0 BUILDX_VERSION := 0.21.2 # Detect OS and arch for binary download. @@ -94,7 +94,7 @@ endif endif endif -.PHONY: build check check-all check-bd check-docker check-docs check-dolt check-eventexport-isolation check-gomod-replace check-core-boundary check-native-dependency-surface check-routed-test-rows check-version-tag lint lint-full lint-new lint-changed fmt-check fmt vet test test-ci-policy test-mac test-fast-parallel test-fsys-darwin-compile test-pack-registry-live test-native-doltlite-beads test-cmd-gc-process test-cmd-gc-process-shard test-cmd-gc-process-parallel test-worker-core test-worker-core-phase2 test-worker-core-phase2-real-transport setup-worker-inference test-worker-inference test-worker-inference-phase3 test-acceptance test-bd-cli-contract test-acceptance-b test-acceptance-c test-acceptance-all test-tutorial-goldens test-tutorial-regression test-tutorial test-integration test-integration-shards test-integration-shards-parallel test-integration-shards-cover test-integration-packages test-integration-packages-cover test-integration-review-formulas test-integration-review-formulas-cover test-integration-review-formulas-basic test-integration-review-formulas-basic-cover test-integration-review-formulas-retries test-integration-review-formulas-retries-cover test-integration-review-formulas-recovery test-integration-review-formulas-recovery-cover test-integration-bdstore test-integration-bdstore-cover test-integration-rest test-integration-rest-cover test-integration-rest-smoke test-integration-rest-smoke-cover test-integration-rest-full test-integration-rest-full-cover test-local-full-parallel test-mail-wisp-insert test-mcp-mail test-openclaw-bridge test-docker test-k8s test-cover test-cover-mac test-cover-noncmdgc test-cover-cmdgc-shard cover check-self-contained install install-tools install-buildx setup clean generate check-schema docker-base docker-agent docker-controller docs-dev diagrams-excalidraw dashboard-smoke dashboard-e2e-go +.PHONY: build check check-all check-bd check-docker check-docs check-dolt check-eventexport-isolation check-gomod-replace check-core-boundary check-native-dependency-surface check-routed-test-rows check-version-tag lint lint-full lint-new lint-changed lint-affected fmt-check fmt-check-changed fmt vet test test-ci-policy test-mac test-fast-parallel test-fsys-darwin-compile test-pack-registry-live test-native-doltlite-beads test-cmd-gc-process test-cmd-gc-process-shard test-cmd-gc-process-parallel test-productmetrics-testhook test-worker-core test-worker-core-phase2 test-worker-core-phase2-all test-worker-core-phase2-real-transport setup-worker-inference test-worker-inference test-worker-inference-phase3 test-acceptance test-bd-cli-contract test-acceptance-b test-acceptance-c test-acceptance-all test-tutorial-goldens test-tutorial-regression test-tutorial test-integration test-integration-shards test-integration-shards-parallel test-integration-shards-cover test-integration-packages test-integration-packages-cover test-integration-review-formulas test-integration-review-formulas-cover test-integration-review-formulas-basic test-integration-review-formulas-basic-cover test-integration-review-formulas-retries test-integration-review-formulas-retries-cover test-integration-review-formulas-recovery test-integration-review-formulas-recovery-cover test-integration-bdstore test-integration-bdstore-cover test-integration-rest test-integration-rest-cover test-integration-rest-smoke test-integration-rest-smoke-cover test-integration-rest-full test-integration-rest-full-cover test-local-full-parallel test-mail-wisp-insert test-mcp-mail test-openclaw-bridge test-docker test-k8s test-cover test-cover-mac test-cover-noncmdgc test-cover-cmdgc-shard cover check-self-contained install install-tools install-buildx setup clean generate check-schema docker-base docker-agent docker-controller docs-dev diagrams-excalidraw dashboard-smoke dashboard-e2e-go dashboard-e2e-play dashboard-e2e .PHONY: check-release-dist-ignore ## build: compile gc binary with version metadata @@ -303,6 +303,8 @@ LINT_BASE ?= origin/main LINT_CHANGED_REF ?= HEAD LINT_CHANGED_SCOPE ?= worktree LINT_FLAGS ?= +CI_STATIC_SELECT := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))scripts/ci-static-select +CI_STATIC_GO ?= go ## lint: run full-repo golangci-lint lint: lint-full @@ -351,10 +353,18 @@ lint-changed: $(GOLANGCI_LINT) echo "lint-changed: $$(printf '%s\n' "$$pkgs" | tr '\n' ' ')"; \ $(GOLANGCI_LINT) run $(LINT_FLAGS) $$pkgs +## lint-affected: lint packages affected by changed Go build inputs or embedded files +lint-affected: $(GOLANGCI_LINT) + @"$(CI_STATIC_SELECT)" lint-affected "$(GOLANGCI_LINT)" "$(CI_STATIC_GO)" $(LINT_FLAGS) + ## fmt-check: fail if formatting would change files fmt-check: $(GOLANGCI_LINT) $(GOLANGCI_LINT) fmt --diff ./... +## fmt-check-changed: fail if formatting would change a regular changed Go file +fmt-check-changed: $(GOLANGCI_LINT) + @"$(CI_STATIC_SELECT)" fmt-check-changed "$(GOLANGCI_LINT)" + ## fmt: auto-fix formatting fmt: $(GOLANGCI_LINT) $(GOLANGCI_LINT) fmt ./... @@ -388,7 +398,7 @@ TEST_ENV = env -i \ LOGNAME="$$LOGNAME" \ SHELL="$$SHELL" \ LANG="$$LANG" \ - TMPDIR="$${TMPDIR:-/tmp}" \ + TMPDIR="$${TMPDIR:-/var/tmp}" \ OBSERVABLE_TEST_LOG="$${OBSERVABLE_TEST_LOG-}" \ OBSERVABLE_FAILURE_LINES="$${OBSERVABLE_FAILURE_LINES-}" \ GC_TEST_NO_SLICE="$${GC_TEST_NO_SLICE-}" \ @@ -429,6 +439,7 @@ test-ci-policy: $(TEST_ENV) PYTHONDONTWRITEBYTECODE=1 python3 -S -m unittest discover -s .github/workflows/scripts -p 'test_runner_policy.py' $(TEST_ENV) PYTHONDONTWRITEBYTECODE=1 python3 -S -m unittest discover -s .github/workflows/scripts -p 'test_ci_suite_coverage.py' $(TEST_ENV) GOFLAGS= GOENV=off GOWORK=off go test -count=1 ./scripts/cipolicy + $(TEST_ENV) GOFLAGS= GOENV=off GOWORK=off go test -count=1 -run '^(TestPreflightStaticScopesOrdinaryPRsWithoutWeakeningProtectedRuns|TestFullStaticLintExplicitlyOwnsConfiguredGolangCIGovet|TestChangedStaticTargetsScopeLintAndFormattingToTheDiff|TestCIStaticScopeClassifierFailsClosedOutsideValidatedPullRequestMerge)$$' ./scripts ## test: run fast unit tests (skip integration-tagged and GC_FAST_UNIT-gated process tests) ## The skipped cmd/gc process-backed scenarios remain covered by @@ -453,7 +464,7 @@ LOCAL_TEST_JOBS ?= $(shell ./scripts/test-local-job-count) ## test-fast-parallel: run the default fast suite with cmd/gc sharded locally test-fast-parallel: - $(TEST_ENV) LOCAL_TEST_JOBS=$(LOCAL_TEST_JOBS) CMD_GC_PROCESS_TOTAL=$(CMD_GC_PROCESS_TOTAL) ./scripts/test-local-parallel fast + $(TEST_ENV) GC_PUSH_GATE_NO_CAP="$${GC_PUSH_GATE_NO_CAP-}" PUSH_GATE_MAX_CONCURRENT="$${PUSH_GATE_MAX_CONCURRENT-}" PUSH_GATE_MAX_WAIT_SECONDS="$${PUSH_GATE_MAX_WAIT_SECONDS-}" PUSH_GATE_POLL_SECONDS="$${PUSH_GATE_POLL_SECONDS-}" LOCAL_TEST_JOBS=$(LOCAL_TEST_JOBS) CMD_GC_PROCESS_TOTAL=$(CMD_GC_PROCESS_TOTAL) ./scripts/test-local-parallel fast ## test-fsys-darwin-compile: cross-compile internal/fsys for macOS so ## unix.Stat_t field-type regressions fail in the default fast test path. @@ -480,7 +491,7 @@ update-bundled-gastown-pack: ## test-native-doltlite-beads: compile and run the native DoltLite read-store suite test-native-doltlite-beads: - $(TEST_ENV) CGO_ENABLED=0 go test -tags gascity_native_beads ./internal/beads -count=1 + $(TEST_ENV) CGO_ENABLED=0 go test -tags gascity_native_beads -run '^TestDoltlite' ./internal/beads -count=1 ## sync-bd-corpus: vendor the bd contract corpus from a beads release (BD_CORPUS_TAG=vX.Y.Z) sync-bd-corpus: @@ -490,6 +501,11 @@ sync-bd-corpus: ## process-backed lifecycle coverage routed out of the default fast loop test-cmd-gc-process: $(TEST_ENV) GC_FAST_UNIT=0 scripts/go-test-observable test-cmd-gc-process -- -timeout 25m ./cmd/gc + $(MAKE) test-productmetrics-testhook + +## test-productmetrics-testhook: run the focused tagged product-metrics contracts +test-productmetrics-testhook: + $(TEST_ENV) scripts/go-test-observable test-productmetrics-testhook -- -tags productmetrics_testhook -count=1 -run '^(TestProductMetricsTaggedBinaryProcessContracts|TestProductMetricsTesthookEndpointAcceptsOnlyLoopbackHTTPS|TestProductMetricsTaggedRunnerReadsInjectionOnlyAtInvocation|TestProductMetricsTesthookCAReadIsBounded|TestProductMetricsTaggedProcessFixtureIsEnabled|TestProductMetricsTestOnlyCensusEscapeIsNarrow)$$' ./cmd/gc CMD_GC_PROCESS_SHARD ?= 1 CMD_GC_PROCESS_TOTAL ?= 6 @@ -516,6 +532,11 @@ test-worker-core-phase2: test-worker-core-phase2-real-transport: $(TEST_ENV) PROFILE="$${PROFILE-}" GC_WORKER_REPORT_DIR="$${GC_WORKER_REPORT_DIR-}" go test -count=1 -tags integration ./cmd/gc -run '^TestPhase2WorkerCoreRealTransportProof$$' +## test-worker-core-phase2-all: run all phase-2 coverage while sharing package builds +test-worker-core-phase2-all: + $(TEST_ENV) PROFILE="$${PROFILE-}" GC_WORKER_REPORT_DIR="$${GC_WORKER_REPORT_DIR-}" go test -count=1 ./internal/worker/workertest ./internal/runtime/tmux -run '^TestPhase2' + $(TEST_ENV) PROFILE="$${PROFILE-}" GC_WORKER_REPORT_DIR="$${GC_WORKER_REPORT_DIR-}" go test -count=1 -tags integration ./cmd/gc -run '^TestPhase2(StartupMaterialization|InitialInputDelivery|InputResultFailureClassification|WorkerCoreRealTransportProof)$$' + WORKER_INFERENCE_PROFILE := $(if $(PROFILE),$(PROFILE),claude/tmux-cli) ## setup-worker-inference: install the provider CLI for PROFILE (default claude/tmux-cli) @@ -846,9 +867,10 @@ dashboard-build: dashboard-dev: cd internal/api/dashboardspa/web && npm run --workspace gas-city-dashboard-frontend dev -## dashboard-check: typecheck (src + test files) + build the SPA, then go test the embedded handler + BFF +## dashboard-check: typecheck (src + test + e2e specs) + build the SPA, then go test the embedded handler + BFF dashboard-check: dashboard-build cd internal/api/dashboardspa/web && npm run typecheck && npm run --workspace gas-city-dashboard-frontend typecheck:test + cd internal/api/dashboardspa/web && npm run --workspace gas-city-dashboard-frontend typecheck:e2e $(TEST_ENV) go test ./internal/api/dashboardspa/... ./internal/api/dashboardbff/... ## dashboard-smoke: serve the built SPA bundle via Vite preview and verify it responds @@ -876,6 +898,22 @@ dashboard-smoke: dashboard-build dashboard-e2e-go: $(TEST_ENV) go test -tags integration -timeout 10m ./test/dashport/... +## dashboard-e2e-play: Layer B of the dashboard e2e — the Playwright render smoke. +## Builds the SPA bundle (so the embedded dist/ the fakesupervisor serves is +## current), builds the seeded fakesupervisor binary with -tags integration, +## installs Chromium, and runs the render specs, which assert each view renders +## its seeded content with no React error boundary and no client-error POST. The +## Go webServer in playwright.config.ts launches the seeded fakesupervisor. +dashboard-e2e-play: dashboard-build + cd test/dashport/cmd/fakesupervisor && go build -tags integration -o fakesupervisor . + cd internal/api/dashboardspa/web && npm ci --silent + cd internal/api/dashboardspa/web/frontend && npm run test:e2e:install + cd internal/api/dashboardspa/web/frontend && npm run test:e2e + +## dashboard-e2e: run both dashboard e2e layers — the Go serve-level projection +## test (Layer A) and the Playwright browser render smoke (Layer B). +dashboard-e2e: dashboard-e2e-go dashboard-e2e-play + ## dashboard-ci: regenerate the typed API client + rebuild the SPA bundle, and ## fail if the generated gc-supervisor-client or the embedded dist/ is stale. ## Used by CI to enforce that the dashboard's generated client (from diff --git a/TESTING.md b/TESTING.md index 186b1a3d8b..6484c1e088 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,4 +1,271 @@ -# Gas City Testing Philosophy +# Gas City Testing Policy + +This file is the canonical, normative source for how Gas City tests are +designed, placed, reviewed, and timed. If an older plan, audit, or contributor +document conflicts with this policy, this file wins. Existing exceptions are +debt, not precedent. In this document, an **owner** is a tracking bead with a +current assignee. An approved waiver must also name its reason, replacement +proof, and expiry. + +The +[testing efficiency operating corpus](engdocs/contributors/testing-efficiency-workflow-corpus.md) +is the non-normative workflow, evidence catalog, and team handoff for applying +this policy. + +### Policy versus enforcement today + +The rules below are normative even where automation is still being built. Do +not describe a target as an existing gate. + +| Policy area | Mechanical status today | +|---|---| +| Sleep/process/listener/tmux/env/CWD growth | Checked by the source-resource ledger below | +| Runtime constructor and `runtime.Fake` conformance binding | Checked by the runtime provider ledger below; several explicit waivers remain | +| Other provider conformance | Shared suites exist, but exact production-constructor coverage is still a manual audit with known gaps | +| Sub-five-minute PR feedback and timing ratchets | Target; current Go timing artifacts measure test execution, not workflow queue/bootstrap/graph time (`ga-80po0c.4`) | +| Large/E2E ownership and cadence | Target; the executable manifest is owned by `ga-80po0c.6` | +| First-attempt flake and quarantine policy | Target; required Playwright retry and legacy unledgered skips remain noncompliant debt under `ga-80po0c` | + +## The outcome: protected PR feedback in under five minutes + +The developer-visible service-level objective is p95 **under five minutes** +from GitHub Actions PR-workflow creation until the required automated `CI` +summary reaches a terminal conclusion. Compare the latest 20 non-superseded +full-union runs on the same runner-policy cohort; include failed and timed-out +attempts, and exclude only obsolete-SHA concurrency cancellations. Queueing is +part of the developer-visible metric and is also reported separately. The +execution sub-budget, from the first required job entering `in_progress` until +`CI / required` completes, is p95 at most 4m30s. Current telemetry does not yet +enforce this SLO; `ga-80po0c.4` owns that gap. + +The budget changes where a proof runs, never whether an important risk is +proved: + +- Required PR lanes should contain fast, deterministic proofs plus the + relevant real boundaries. Current integration routing is coarse and the + dashboard job is unconditional; treat that as optimization debt, not the + desired endpoint. +- Broader real-provider and full-composition proofs run on `main` when they + cannot fit the PR budget. +- Credentialed, live-inference, cloud, and soak journeys belong in scheduled + or explicit profile lanes. The full live-inference profile matrix is + currently local-only; do not claim nightly coverage for it. + +A slow PR test may move to a later lane only after lower layers own its branch +and error-detail matrix. The later lane must retain any unique real-composition +risk. Moving a test without that ownership map is deleting quality, not +improving feedback. + +## The authoring rule: one risk, one smallest owning proof + +Start with a single sentence describing the regression the test must catch. +Then put that assertion at the smallest layer that can fail for the intended +reason. A higher layer may prove wiring across a boundary, but it must not +repeat the lower layer's branch matrix. + +Classify the observable promise first: + +1. **Behavior promised by a provider interface?** Add the case once to its + shared conformance suite and run that suite against every production + implementation with distinct behavior and every reusable fast substitute. +2. **Implementation-only decision or domain transition?** Write a unit test + next to the code. +3. **User-visible CLI parsing, output, or exit status?** Use testscript with + fast providers. +4. **Ordering or argument plumbing between components?** Write one focused + coordination test with recording collaborators. +5. **Real process, protocol, filesystem, database, browser, or provider + composition?** Keep one integration or end-to-end proof for that boundary. +6. **Documentation-to-code agreement?** Put the invariant in `test/docsync`. + +The question is not “where can this test be made to pass?” It is “which layer +uniquely owns this risk?” Search for an existing owner before adding a test. If +one exists, strengthen or parameterize it instead of creating another journey. +Conformance is a reusable testing pattern rather than a sixth execution tier: +one contract suite is intentionally executed against multiple implementations. + +### RED, GREEN, refactor, measure + +Every behavior change and bug fix follows this loop: + +1. **RED:** add the smallest owning test and observe it fail for the intended + reason. For a bug, reproduce the reported failure before changing code. +2. **GREEN:** make the narrowest production change that satisfies the test. +3. **Refactor:** improve names and boundaries, remove duplicate assertions, + and replace expensive collaborators with proved substitutes. + A behavior-neutral migration records a PR-description table from every + retired assertion to its new owner and retained real-boundary proof. Before + commit, delegate independent reviews of semantic parity, speed/resource + policy, and repository accuracy/enforceability. +4. **Measure:** repeat the focused test and run the affected shard. Record + before/after wall time when adding, moving, or materially changing tests. +5. **Verify the boundary:** run the focused owner plus the relevant + conformance, coordination, or integration owner. + +Never write the large end-to-end test first merely because the production code +has no seam. Refactor the code so the policy can be exercised directly, then +retain the smallest real-boundary proof that demonstrates the wiring. + +## Design production code for fast proofs + +Core logic receives dependencies; outer constructors choose production +implementations. Prefer an existing provider port. For one isolated side +effect, inject a function value. Introduce a new interface only when it is a +stable domain boundary **and** has at least two real implementations, +consistent with Gas City's no-premature-abstraction rule. + +| Source of nondeterminism | Fast seam | Keep real coverage for | +|---|---|---| +| Bead or domain persistence | `beads.Store`, usually `beads.MemStore` in consumer tests | Store conformance and provider lifecycle | +| Wall-time/deadline decisions | Injected clock, including `clock.Fake` | The real-clock adapter, not every consumer | +| Timers, sleeps, scheduling, backoff | Injected timer/sleeper/scheduler or `testing/synctest` | The timer adapter, not every consumer | +| Asynchronous completion | Channel, callback, event watcher, or notifier | One public protocol/event-stream composition | +| Subprocess execution | Narrow executor function/interface with scripted results | Argument-to-real-binary compatibility | +| Generated IDs or randomness | Injected generator with deterministic values | Format/entropy adapter contract | +| Filesystem operations | `fsys.FS`, normally `fsys.Fake` for consumer logic | `fsys.OSFS` conformance and OS-specific semantics | + +Environment variables, current working directory, global clocks, package-level +mutable state, and executable discovery belong at composition edges. Unit tests +must not need them to steer domain behavior. Use `t.TempDir()` when the real +filesystem is itself relevant; otherwise prefer `fsys.Fake`. + +## Choose meaningful failure edges, not Cartesian products + +Test each distinct obligation at its owner. For a typical operation, consider +only the applicable boundaries: + +- invalid input or an absent required value; +- collaborator unavailable before any side effect; +- partial success requiring rollback, idempotency, or recovery; +- cancellation or deadline propagation; +- a concurrency conflict or lost-update boundary; +- serialization or protocol incompatibility; +- restart/reconnect behavior at a real provider lifecycle boundary. + +Equivalence classes beat exhaustive combinations. If five commands use the +same store port, test the shared store failures in conformance, each command's +distinct response in a unit test, and one command-to-real-store composition. +Do not multiply every command by every provider by every error. Add another +combination only when it represents a different contract. An escaped +regression must first populate the missing equivalence class at the smallest +owner; retain its high-level reproduction only when the defect uniquely +depends on that composition. + +## Asynchronous tests wait for facts, not elapsed time + +New or modified tests must not use `time.Sleep` to wait for work to “probably” +finish, and must not add open-coded polling loops. Instead: + +- expose a completion/error notification and select on it with a context; +- capture the event cursor and subscribe before triggering work, then correlate + terminal success or failure by request/resource ID, close the subscription, + and reread durable state; +- use a fake clock or `testing/synctest` for timers, retries, and backoff; +- use a barrier/channel to prove a goroutine reached a state before releasing + it; and +- assert the terminal state immediately after the notification. + +Polling is allowed only at a true black-box boundary that exposes no completion +signal and where adding one would change the public contract. Such polling must +use a shared helper with a context-aware ticker or bounded backoff, fail with +the last observed state, and have one named boundary owner. Busy loops and a +fixed sleep before the helper are forbidden. The deadline rule below supplies +safety timeouts; those deadlines must not determine the normal test duration. + +## Test doubles and conformance are one contract + +A fast substitute is trustworthy only when it is held to the same observable +contract as production. When a provider method or invariant changes: + +1. change the shared conformance suite first; +2. run it against every behaviorally distinct production implementation or + composition for that port; +3. run it against every reusable fast substitute; and +4. keep implementation-specific tests only for behavior outside the shared + contract. + +Thin aliases that add no state, transformation, or behavior may use a focused +exact-constructor wiring proof instead of repeating the full suite. The checked +runtime ledger remains authoritative for runtime compositions. + +Skips do not count as conformance. A temporary incompatibility must be recorded +as an explicit waiver with a tracking-bead owner, reason, replacement proof, +and expiry. Constructor wrappers and provider compositions need coverage for the +actual production path; proving a nearby raw implementation is not +enough. + +Fakes need only model observable contract behavior used by consumers. They do +not simulate implementation internals. Add recording only when call order or +arguments are themselves the contract; a stateful fake is not automatically a +spy. + +## Keep the critical end-to-end portfolio deliberately small + +An end-to-end test is admitted only when all of these are true: + +- it protects a high-value user journey or high-blast-radius recovery path; +- the risk exists only when multiple real boundaries are composed; +- lower layers already own the branch and error-detail matrix; +- its assertions use stable public outcomes rather than internal timing; +- it has hermetic setup, targeted cleanup, actionable diagnostics, and a named + owner; and +- its lane and measured duration fit the cadence above. + +Each major effort must point to an existing critical journey or add the one +missing composition proof. It does not receive a new E2E for every acceptance +criterion. Before admitting an E2E, list the lower-layer owners it relies on +and the unique cross-boundary failure it catches. When two journeys catch the +same regression, keep the clearer and faster one. + +Record the journey, unique risk, lower-layer owners, path triggers, lane, +budget, diagnostics, and owner in the checked E2E/provider manifest owned by +`ga-80po0c.6`. Until that manifest lands, put the same fields in the PR +description. On-demand coverage does not count as a release proof without a +freshness gate for the exact release SHA. + +## Flakes are defects + +A deterministic product-test failure may not be retried into green on the same +tested SHA. Repetition is useful for diagnosis, but a required gate must retain +the worst product-test status across attempts. A pre-test runner/service outage +may be retried only when classified with attached infrastructure evidence; it +is reported separately. A code change produces a new SHA and a new result. The +failure has one tracking-bead owner until fixed. + +Quarantine is forbidden until a checked ledger exists. Any future quarantine +must include a tracking-bead owner, captured failure evidence, nonblocking +still-failing lane, replacement coverage, and expiry that fails CI; +quarantined coverage cannot satisfy a required gate. Capability-based local +skips likewise require an equipped CI execution or an explicit waiver. Do not +weaken assertions, increase sleeps, or broaden retries to hide an unknown race. +Remove redundant tests; repair unique tests. + +## Timing objectives and resource ratchets + +Test performance claims require evidence. For a focused change, run the test +repeatedly with the result cache disabled (for example, the command below) and +time the relevant sharded target. This is focused diagnostic evidence, not an +authoritative p95; the history format requires twenty comparable successful +samples for an authoritative p95. + +```bash +go test -count=10 -run '^TestName$' ./path +``` + +Compare like runner, OS, architecture, CPU count, cache condition, and suite +variant. A single warm-cache run is diagnostic, not a regression baseline. + +No change may knowingly push the protected graph above its SLO. Once trusted +history and workflow telemetry are authoritative, checked per-profile +baselines must fail material regressions and lower after sustained +improvements; increases require an expiring waiver. Until then, include +before/after observations in the PR and treat the timing tools below as +shard-balancing evidence rather than enforcement. + +The checked source-resource ledgers below are anti-growth ratchets for sleeps, +processes, listeners, environment mutation, and CWD mutation. Reductions lower +the checked baseline; new debt requires the same explicit, expiring policy +change as any other waiver. ## Checked source-level resource ratchets @@ -6,8 +273,9 @@ Go source through parsed syntax and import identity, while only `*_test.go` files contribute resource occurrences. The raw audit and source-debt rows freeze process, sleep, environment, CWD, slow-process, HTTP test-server, and -package-level `net.Listen`, `net.ListenConfig.Listen`, `net.ListenUnixgram`, -and direct `syscall.Listen` call/file totals. +package-level `net` stream/packet listeners, `net.ListenConfig` listeners, +direct `syscall.Listen`, explicit listener-owning helper identities, and typed +or literal tmux dependency call/file totals. Exact Medium rows name a repository-relative directory, package clause, top-level runnable owner, and resource list. Small-debt rows apply those exact owners without weakening the raw anti-growth ratchets. @@ -22,6 +290,43 @@ explicit policy change that requires the same staged-diff council review as other test-infrastructure changes. The guard makes ordinary drift visible; it does not claim that self-modifying source can be cryptographically forbidden. +`[[reviewed_hermetic_body]]` rows record a narrower fact than a Small-test +classification: the exact untagged top-level test body and every statically +resolved receiverless helper in the same package contain none of the resource +identities cataloged below. A row is exact, code-owned, and stale-checked; it +cannot use a wildcard, silently move to another test, or claim an effective +Small size while package setup remains Medium. The checked call graph follows +direct helper calls and references used as local function aliases across Go +files in the same package, and terminates safely on cycles. + +This is intentionally not a universal hermeticity proof. Cross-package calls, +method and interface dispatch, package-level callback indirection, and +resources absent from the catalog remain manual-review boundaries. In +particular, `TestPrepareWaitWakeState_ResolvesRigDependencyBeads` and +`TestDoSessionWake_PokesManagedControllerAfterStateChange` have reviewed +hermetic bodies but still run as Medium because `cmd/gc` owns a process-mutating +`TestMain`. `TestDoSessionWait_RegistersReadyWaitForRigDependency` has the same +reviewed-hermetic guarantee for the wait-registration use case. +`TestCmdSessionWait_AllowsRigDependencyBeads` remains the singular +CLI/config/file-store split-store composition proof for wait, while +`TestManagedBdRigProviderStoreRecoversAfterHardKillPortRebind` owns the real +managed-provider hard-kill/port-rebind boundary. Likewise, +`TestCmdSessionWake_PokesManagedControllerAndRequestsSuspendedStart` remains the +singular CLI/config/file-store/controller-socket composition proof for wake. +`TestDoMailInbox_RendersMessagesFromReader` owns inbox rendering through the +consumer's one-method reader port, while +`TestCmdMailInbox_NormalizesCanonicalManagedProviderEnvAndReadsInbox` remains +the singular CLI/mail/canonical-`GC_BEADS`/real-Dolt store-factory composition +proof. Full managed-city lifecycle and recovery stay with their focused +provider-store owners instead of being repeated by each command consumer. Body +review is not a reason to remove a retained boundary test. + +`TestDockerSessionProtocol` owns fast Docker CLI mapping, injected failures, +and cleanup transitions through a strict `PATH`-injected executable. The +real-Docker `scripts/test-docker-session` harness remains the composition owner +until each retained container invariant has a replacement contract and the +real proof is deliberately consolidated. + The canonical identity is package directory plus package clause plus top-level `Test`, `Benchmark`, `Fuzz`, or `TestMain` name. Nested function literals and subtests retain that top-level lexical owner. Methods, wrong signatures, and @@ -33,28 +338,71 @@ calls inside `TestMain`, never sibling tests. This bootstrap does **not** infer resources recursively through arbitrary helper calls or claim a complete shared-resource inventory. P0.4c currently covers the three `net/http/httptest` constructors that open loopback servers -and the exact package-level `net.Listen` and `net.ListenUnixgram` constructors, -`net.ListenConfig.Listen` on lexically identified receivers, and direct -`syscall.Listen`. Direct `syscall.Socket`/`Bind` setup calls, typed and -packet-specific `net` constructors, helper-backed listeners whose constructors -live outside test source, tmux, Dolt, and other shared-host resources remain -explicit follow-up catalogs. A Medium resource may describe a helper-backed -runtime cost, but only syntax-owned calls in that exact runnable declaration +and the exact package-level stream constructors `net.Listen`, `net.ListenTCP`, +and `net.ListenUnix`; packet constructors `net.ListenPacket`, `net.ListenUDP`, +`net.ListenIP`, `net.ListenUnixgram`, and `net.ListenMulticastUDP`; +`net.ListenConfig.Listen` and `ListenPacket` on lexically identified receivers; +and direct `syscall.Listen`. The tmux catalog recognizes canonical `test/tmuxtest` +namespace/lifecycle helpers, imported `internal/runtime/tmux` production +constructors, and literal `os/exec` tmux commands and probes. Its untagged +source census is 6 calls in 2 files, all owned by exact Medium `TestMain` +rows; build-tagged calls remain E1 Large inventory rather than being relabeled +Medium. `NewSocketParentDir`, `HoldAliveSentinel`, and the PID-directory +helpers remain part of the separate shared-host resource tail. Direct +`syscall.Socket`/`Bind` setup calls remain outside this catalog. + +The listener-helper catalog is an explicit function-identity proxy, not +recursive call-graph inference. It recognizes same-package calls to the +`cmd/gc` package `main` helpers `runSupervisor`, `startControllerSocket`, +`runController`, `registryBrowserLogin`, +`managedDoltPortAvailableForHost`, and `startNudgeWakeListener`; the +`test/dashport` package `dashport_test` helper `newHarness`; and same-package +or import-identified calls to `internal/runtime/runtimecapability.Run` and +`test/acceptance/helpers.WriteSupervisorConfig`. Same-package identity requires +the exact directory, package clause, receiverless function declaration, and +name; lexical shadows, same-named function values, wrong directories/packages, +and foreign imports do not count. Its untagged source and Small-debt census is +38 calls in 13 files. The 20 tagged calls in 10 files stay in E1 Large +inventory, with no Medium exemption, for 58 calls in 23 files across all +tracked test source. + +`net.FileListener`/`FilePacketConn` descriptor duplication, method-backed +`acp.(*Provider).Start` and `subprocess.(*Provider).Start` listeners, +conditional `cliauth.Client.Login` and `supervisor.LoadConfig` listener paths, +Dolt, and other shared-host resources remain explicit follow-up catalogs. A +Medium resource may describe a helper-backed runtime +cost, but only syntax-owned calls in that exact runnable declaration leave Small-debt accounting. The `ListenConfig` matcher uses lexical Go types to follow same-file values, pointers, parameters, aliases, and typed factory results rooted in the imported `net.ListenConfig` type; it does not load cross-file package bodies or host toolchain export data. -`ga-80po0c.2.2` owns the listener, tmux, Dolt, and shared-host catalogs. E1 +The tmux helper match is an explicit dependency/namespace proxy; it does not +claim a recursive inventory of the helper's environment mutations. Function +aliases and wrappers, variable or absolute command names, `sh -c`, `exec.Cmd` +literals, `Guard` methods, and same-package bare constructors remain deliberate +manual-review boundaries. `ga-80po0c.2.2` owns the listener, tmux, Dolt, and +shared-host catalogs. E1 separately owns Large journey and provider entries. The scanner recognizes direct calls to `os/exec.Command{,Context}` and -`time.Sleep`; package-level `net.Listen` and `net.ListenUnixgram`; -`net.ListenConfig.Listen` on identified receivers; direct `syscall.Listen`; +`time.Sleep`; package-level `net.Listen`, `ListenTCP`, `ListenUnix`, +`ListenPacket`, `ListenUDP`, `ListenIP`, `ListenUnixgram`, and +`ListenMulticastUDP`; `net.ListenConfig.Listen` and `ListenPacket` on identified +receivers; direct `syscall.Listen`; `net/http/httptest.NewServer`, -`NewTLSServer`, and `NewUnstartedServer`; `os.Setenv`, `os.Unsetenv`, -`os.Clearenv`, and `os.Chdir`; and -`Setenv` or `Chdir` on function parameters typed exactly as `*testing.T` or -`testing.TB`. It also recognizes the receiverless +`NewTLSServer`, and `NewUnstartedServer`; and `os.Setenv`, `os.Unsetenv`, +`os.Clearenv`, and `os.Chdir`. `Setenv` and `Chdir` on a `testing.T` or +`testing.TB` receiver are deliberately excluded: they restore the prior value +when the test ends, so they are not ambient environment or cwd debt. A receiver +identifier for those two methods that cannot be resolved lexically still fails +closed with a scan error rather than being silently skipped. For tmux it +recognizes `ConfigureProcessEnv`, +`KillAllTestSessions`, `NewGuard`, `NewGuardWithSocket`, and `RequireTmux` from +`test/tmuxtest`; `NewProvider`, `NewProviderWithConfig`, +`NewSeamBackedWithConfig`, `NewTmux`, and `NewTmuxWithConfig` from +`internal/runtime/tmux`; and literal `os/exec.Command("tmux", ...)`, +`CommandContext(ctx, "tmux", ...)`, and `LookPath("tmux")` calls. It also +recognizes the listener-helper identities listed above and the receiverless `skipSlowCmdGCTest(*testing.T, string)` definition and its same-package calls. An unresolved cross-file call counts only when that directory and package own the canonical helper. Import, parameter, and same-file helper matches use @@ -64,8 +412,10 @@ Local shadows and wrong signatures do not count. Parenthesized call expressions retain the same ownership. Targeted dot imports of `net`, `os/exec`, `time`, `os`, `syscall`, `testing`, -or `net/http/httptest` are rejected with file and import context because their -resources cannot be attributed safely; blank imports remain harmless. +`net/http/httptest`, `internal/runtime/runtimecapability`, +`internal/runtime/tmux`, `test/acceptance/helpers`, or `test/tmuxtest` are +rejected with file and import context because their resources cannot be +attributed safely; blank imports remain harmless. Explicit constraints follow Go's leading-header rules: a pre-package `//go:build` line is effective, while a legacy `// +build` line must live in a leading `//` comment block separated from the @@ -87,9 +437,12 @@ go test -count=1 ./internal/testpolicy/resourcecensus -run '^TestRepositoryLedge ``` The historical regex totals remain visible as point-in-time audit evidence. -They can be higher because comments and strings matched, or lower where the old -needle covered only `t.Setenv` or direct `os.Chdir` and the AST census now -recognizes the full families above. Historical `cmd/gc` needles also included +They can be higher because comments and strings matched, or because the needle +counted testing-receiver helpers such as `t.Setenv` and `t.Chdir` that the AST +census deliberately excludes — which is why the historical environment and cwd +needles now sit far above the live baselines. They can also be lower where the +old needle covered only one spelling and the AST census now recognizes the full +`os` families above. Historical `cmd/gc` needles also included build-tagged files; the live `cmd/gc+untagged` ratchets do not. `internal/bdflags/freshness_test.go` is integration-tagged because it invokes the externally installed `bd` CLI; its process call remains visible in the @@ -98,41 +451,59 @@ all-source audit while staying outside untagged and Small debt. | Ledger kind | Source scope | Resource baseline | Tracking owner | Invariant / resource owner | Migration | Expiry | | --- | --- | --- | --- | --- | --- | --- | -| Audit baseline | all tracked test source | fixed_sleep: 444 calls / 159 files (historical regex census: 447 / 157) | ga-80po0c.2 | tracked test source totals remain visible as audit evidence; ga-80po0c.2 owns this point-in-time source census | P0.4a | 2026-10-01 | -| Audit baseline | all tracked test source | subprocess: 526 calls / 154 files (historical regex census: 495 / 135) | ga-80po0c.2 | tracked test source totals remain visible as audit evidence; ga-80po0c.2 owns this point-in-time source census | P0.4a | 2026-10-01 | -| Medium owner | `cmd/gc` package `main` | TestMain: environment | ga-80po0c.2.1 | cmd/gc TestMain is the checked package-level Medium owner; only environment calls lexically inside TestMain leave Small debt | P0.4b | 2026-10-01 | +| Audit baseline | all tracked test source | fixed_sleep: 428 calls / 156 files (historical regex census: 447 / 157) | ga-80po0c.2 | tracked test source totals remain visible as audit evidence; ga-80po0c.2 owns this point-in-time source census | P0.4a | 2026-10-01 | +| Audit baseline | all tracked test source | listener_helper: 58 calls / 23 files | ga-80po0c.2.2.3 | all-source listener-helper call/file totals cannot drift without an explicit checked policy update; ga-80po0c.2.2.3 owns this all-source audit; tagged calls stay Large and receive no Medium exemption | P0.4c-listener-helper | 2026-10-01 | +| Audit baseline | all tracked test source | subprocess: 538 calls / 165 files (historical regex census: 495 / 135) | ga-80po0c.2 | tracked test source totals remain visible as audit evidence; ga-80po0c.2 owns this point-in-time source census | P0.4a | 2026-10-01 | +| Medium owner | `cmd/gc` package `main` | TestMain: environment, tmux | ga-80po0c.2.1 | cmd/gc TestMain is the checked package-level Medium owner for process environment and tmux namespace setup; only declared environment and tmux calls lexically inside TestMain leave Small debt | P0.4b/P0.4c-tmux | 2026-10-01 | | Medium owner | `internal/api` package `api` | TestEveryEmittedErrorCodeIsRegistered: subprocess | ga-80po0c.2.1 | internal/api tracked-source error URN guard is a checked Medium owner; only the git ls-files call lexically inside TestEveryEmittedErrorCodeIsRegistered leaves Small debt | P0.4b | 2026-10-01 | +| Medium owner | `internal/doctor` package `doctor` | TestCustomTypesCheck_TableDrift: subprocess | ga-80po0c.2.1 | doctor custom-types config-CSV-vs-table drift detect+heal proof is a checked Medium owner; the bd and dolt subprocesses are confined to TestCustomTypesCheck_TableDrift, which manufactures and heals real table drift against a throwaway store | P0.4b | 2026-10-01 | +| Medium owner | `internal/runtime/herdr` package `herdr` | TestServerAliveDetectsLiveServer: net_listen | ga-80po0c.2.2.2 | herdr live-server liveness regression is a checked Medium stream-listener owner; the Unix stream listener is confined to TestServerAliveDetectsLiveServer and closed by test cleanup | P0.4c-listener | 2026-10-01 | +| Medium owner | `internal/runtime/herdr` package `herdr` | TestServerAliveRejectsStaleSocket: net_listen | ga-80po0c.2.2.2 | herdr stale-socket liveness regression is a checked Medium stream-listener owner; the Unix stream listener is confined to TestServerAliveRejectsStaleSocket and closed before liveness detection | P0.4c-listener | 2026-10-01 | +| Medium owner | `internal/runtime/tmux` package `tmux` | TestMain: environment, tmux | ga-80po0c.2.2.1 | runtime tmux TestMain is the checked Medium owner for isolated tmux process and socket cleanup; only declared environment and tmux calls lexically inside TestMain leave Small debt | P0.4c-tmux | 2026-10-01 | +| Medium owner | `scripts` package `scripts_test` | TestDockerSessionProtocol: subprocess | ga-80po0c.23.1 | Docker session adapter protocol proof is a checked Medium owner; the one adapter subprocess is confined to TestDockerSessionProtocol and Docker itself is a strict PATH-injected fake | W6 | 2026-10-01 | | Medium owner | `scripts` package `scripts_test` | TestProviderOverridesAndSuiteContractsCrossMakeIsolation: subprocess | ga-80po0c.2.1 | Make/provider and suite-contract proof is a checked Medium owner; the six isolated Make invocations are confined to TestProviderOverridesAndSuiteContractsCrossMakeIsolation | P0.1 | 2026-10-01 | -| Small debt ratchet | `cmd/gc` untagged test source | cwd: 287 calls / 44 files (historical regex census: 284 / 43) | ga-80po0c.2.1 | untagged Small cmd/gc cwd call/file totals cannot grow; reductions must lower this baseline; non-Medium lexical owners restore or eliminate every cwd mutation | D5/D6 | 2026-10-01 | -| Small debt ratchet | `cmd/gc` untagged test source | environment: 4362 calls / 203 files (historical regex census: 4339 / 199) | ga-80po0c.2.1 | untagged Small cmd/gc environment call/file totals cannot grow; reductions must lower this baseline; non-Medium lexical owners restore or eliminate every process-environment mutation | D5/D6/E6 | 2026-10-01 | -| Small debt ratchet | `cmd/gc` untagged test source | slow_process_gate: 76 calls / 25 files (historical regex census: 75 / 25) | ga-80po0c.2.1 | untagged Small cmd/gc slow-process marker totals cannot grow; reductions must lower this baseline; each non-Medium marked caller retains an explicit process-suite migration owner | D5/D6/E6 | 2026-10-01 | -| Small debt ratchet | all untagged test source | fixed_sleep: 290 calls / 114 files (historical regex census: 289 / 114) | ga-80po0c.2.1 | untagged Small fixed-sleep call/file totals cannot grow; reductions must lower this baseline; non-Medium lexical owners replace elapsed wall time with lifecycle signals | W1-W5 | 2026-10-01 | -| Small debt ratchet | all untagged test source | http_test_server: 315 calls / 70 files (historical regex census: 300 / 66) | ga-80po0c.2.2 | untagged Small HTTP test server call/file totals cannot grow; reductions must lower this baseline; non-Medium lexical owners move server-backed tests to exact Medium ownership or replace the listener | P0.4c | 2026-10-01 | -| Small debt ratchet | all untagged test source | net_listen: 92 calls / 34 files | ga-80po0c.2.2 | untagged Small net.Listen call/file totals cannot grow; reductions must lower this baseline; non-Medium lexical owners move listener-backed tests to exact Medium ownership or replace the listener | P0.4c | 2026-10-01 | -| Small debt ratchet | all untagged test source | net_listen_config: 1 calls / 1 files | ga-80po0c.2.2 | untagged Small net.ListenConfig.Listen call/file totals cannot grow; reductions must lower this baseline; non-Medium lexical owners move ListenConfig-backed tests to exact Medium ownership or replace the listener | P0.4c | 2026-10-01 | -| Small debt ratchet | all untagged test source | net_listen_unixgram: 3 calls / 2 files | ga-80po0c.2.2 | untagged Small net.ListenUnixgram call/file totals cannot grow; reductions must lower this baseline; non-Medium lexical owners move Unix datagram listener-backed tests to exact Medium ownership or replace the listener | P0.4c | 2026-10-01 | -| Small debt ratchet | all untagged test source | subprocess: 397 calls / 107 files (historical regex census: 394 / 105) | ga-80po0c.2.1 | untagged Small subprocess call/file totals cannot grow; reductions must lower this baseline; non-Medium lexical owners remove or replace each process call site | D1/D2/D5/D6/E6 | 2026-10-01 | +| Small debt ratchet | `cmd/gc` untagged test source | cwd: 174 calls / 16 files (historical regex census: 284 / 43) | ga-80po0c.2.1 | untagged Small cmd/gc cwd call/file totals cannot grow; reductions must lower this baseline; non-Medium lexical owners restore or eliminate every cwd mutation | D5/D6 | 2026-10-01 | +| Small debt ratchet | `cmd/gc` untagged test source | environment: 122 calls / 13 files (historical regex census: 4348 / 200) | ga-80po0c.2.1 | untagged Small cmd/gc environment call/file totals cannot grow; reductions must lower this baseline; non-Medium lexical owners restore or eliminate every process-environment mutation | D5/D6/E6 | 2026-10-01 | +| Small debt ratchet | `cmd/gc` untagged test source | slow_process_gate: 58 calls / 24 files (historical regex census: 75 / 25) | ga-80po0c.2.1 | untagged Small cmd/gc slow-process marker totals cannot grow; reductions must lower this baseline; each non-Medium marked caller retains an explicit process-suite migration owner | D5/D6/E6 | 2026-10-01 | +| Small debt ratchet | all untagged test source | fixed_sleep: 289 calls / 111 files (historical regex census: 287 / 113) | ga-80po0c.2.1 | untagged Small fixed-sleep call/file totals cannot grow; reductions must lower this baseline; non-Medium lexical owners replace elapsed wall time with lifecycle signals | W1-W5 | 2026-10-01 | +| Small debt ratchet | all untagged test source | http_test_server: 332 calls / 70 files (historical regex census: 300 / 66) | ga-80po0c.2.2 | untagged Small HTTP test server call/file totals cannot grow; reductions must lower this baseline; non-Medium lexical owners move server-backed tests to exact Medium ownership or replace the listener | P0.4c | 2026-10-01 | +| Small debt ratchet | all untagged test source | listener_helper: 38 calls / 13 files | ga-80po0c.2.2.3 | untagged Small listener-helper call/file totals cannot grow; reductions must lower this baseline; non-Medium lexical owners replace helper-backed listeners or declare exact isolated ownership | P0.4c-listener-helper | 2026-10-01 | +| Small debt ratchet | all untagged test source | net_listen: 92 calls / 34 files | ga-80po0c.2.2.2 | untagged Small stream-listener call/file totals cannot grow; reductions must lower this baseline; non-Medium lexical owners move stream-listener tests to exact Medium ownership or replace the listener | P0.4c-listener | 2026-10-01 | +| Small debt ratchet | all untagged test source | net_listen_config: 1 calls / 1 files | ga-80po0c.2.2.2 | untagged Small net.ListenConfig listener call/file totals cannot grow; reductions must lower this baseline; non-Medium lexical owners move ListenConfig-backed tests to exact Medium ownership or replace the listener | P0.4c-listener | 2026-10-01 | +| Small debt ratchet | all untagged test source | net_listen_packet: 3 calls / 2 files | ga-80po0c.2.2.2 | untagged Small packet-listener call/file totals cannot grow; reductions must lower this baseline; non-Medium lexical owners move packet-listener tests to exact Medium ownership or replace the listener | P0.4c-listener | 2026-10-01 | +| Small debt ratchet | all untagged test source | subprocess: 394 calls / 111 files (historical regex census: 394 / 105) | ga-80po0c.2.1 | untagged Small subprocess call/file totals cannot grow; reductions must lower this baseline; non-Medium lexical owners remove or replace each process call site | D1/D2/D5/D6/E6 | 2026-10-01 | | Small debt ratchet | all untagged test source | syscall_listen: 1 calls / 1 files | ga-80po0c.2.2 | untagged Small syscall.Listen call/file totals cannot grow; reductions must lower this baseline; non-Medium lexical owners move syscall-backed listener tests to exact Medium ownership or replace the listener | P0.4c | 2026-10-01 | -| Source debt ratchet | `cmd/gc` untagged test source | cwd: 287 calls / 44 files (historical regex census: 98 / 13) | ga-80po0c.2.3 | untagged cmd/gc cwd call/file totals cannot grow; reductions must lower this baseline; cmd/gc callers restore or eliminate every recognized cwd mutation | D5/D6 | 2026-10-01 | -| Source debt ratchet | `cmd/gc` untagged test source | environment: 4368 calls / 203 files (historical regex census: 3960 / 184) | ga-80po0c.2.3 | untagged cmd/gc environment call/file totals cannot grow; reductions must lower this baseline; cmd/gc callers restore or eliminate every recognized process-environment mutation | D5/D6/E6 | 2026-10-01 | -| Source debt ratchet | `cmd/gc` untagged test source | slow_process_gate: 76 calls / 25 files (historical regex census: 78 / 27) | ga-80po0c.2.3 | untagged cmd/gc slow-process marker totals cannot grow; reductions must lower this baseline; the helper definition and every marked caller retain an explicit process-suite migration owner | D5/D6/E6 | 2026-10-01 | -| Source debt ratchet | all untagged test source | fixed_sleep: 290 calls / 114 files (historical regex census: 295 / 114) | ga-80po0c.2 | untagged fixed-sleep call/file totals cannot grow; reductions must lower this baseline; each owning test replaces elapsed wall time with its lifecycle signal | W1-W5 | 2026-10-01 | -| Source debt ratchet | all untagged test source | http_test_server: 315 calls / 70 files (historical regex census: 255 / 56) | ga-80po0c.2.2 | untagged HTTP test server call/file totals cannot grow; reductions must lower this baseline; each owning test closes its loopback server and removes duplicate server-backed coverage | P0.4c | 2026-10-01 | -| Source debt ratchet | all untagged test source | net_listen: 92 calls / 34 files | ga-80po0c.2.2 | untagged net.Listen call/file totals cannot grow; reductions must lower this baseline; each owning test closes its listener and removes duplicate listener-backed coverage | P0.4c | 2026-10-01 | -| Source debt ratchet | all untagged test source | net_listen_config: 1 calls / 1 files | ga-80po0c.2.2 | untagged net.ListenConfig.Listen call/file totals cannot grow; reductions must lower this baseline; each owning test closes its configured listener and removes duplicate listener-backed coverage | P0.4c | 2026-10-01 | -| Source debt ratchet | all untagged test source | net_listen_unixgram: 3 calls / 2 files | ga-80po0c.2.2 | untagged net.ListenUnixgram call/file totals cannot grow; reductions must lower this baseline; each owning test closes its Unix datagram listener and removes duplicate listener-backed coverage | P0.4c | 2026-10-01 | -| Source debt ratchet | all untagged test source | subprocess: 399 calls / 108 files (historical regex census: 380 / 98) | ga-80po0c.2 | untagged subprocess call/file totals cannot grow; reductions must lower this baseline; each process-owning test removes or replaces its source call site | D1/D2/D5/D6/E6 | 2026-10-01 | +| Small debt ratchet | all untagged test source | tmux: 0 calls / 0 files | ga-80po0c.2.2.1 | untagged Small tmux dependency call/file totals cannot grow; reductions must lower this baseline; non-Medium lexical owners replace tmux with a fake executor or declare exact isolated ownership | P0.4c-tmux | 2026-10-01 | +| Source debt ratchet | `cmd/gc` untagged test source | cwd: 174 calls / 16 files (historical regex census: 98 / 13) | ga-80po0c.2.3 | untagged cmd/gc cwd call/file totals cannot grow; reductions must lower this baseline; cmd/gc callers restore or eliminate every recognized cwd mutation | D5/D6 | 2026-10-01 | +| Source debt ratchet | `cmd/gc` untagged test source | environment: 128 calls / 13 files (historical regex census: 3960 / 184) | ga-80po0c.2.3 | untagged cmd/gc environment call/file totals cannot grow; reductions must lower this baseline; cmd/gc callers restore or eliminate every recognized process-environment mutation | D5/D6/E6 | 2026-10-01 | +| Source debt ratchet | `cmd/gc` untagged test source | slow_process_gate: 58 calls / 24 files (historical regex census: 78 / 27) | ga-80po0c.2.3 | untagged cmd/gc slow-process marker totals cannot grow; reductions must lower this baseline; the helper definition and every marked caller retain an explicit process-suite migration owner | D5/D6/E6 | 2026-10-01 | +| Source debt ratchet | all untagged test source | fixed_sleep: 289 calls / 111 files (historical regex census: 295 / 114) | ga-80po0c.2 | untagged fixed-sleep call/file totals cannot grow; reductions must lower this baseline; each owning test replaces elapsed wall time with its lifecycle signal | W1-W5 | 2026-10-01 | +| Source debt ratchet | all untagged test source | http_test_server: 332 calls / 70 files (historical regex census: 255 / 56) | ga-80po0c.2.2 | untagged HTTP test server call/file totals cannot grow; reductions must lower this baseline; each owning test closes its loopback server and removes duplicate server-backed coverage | P0.4c | 2026-10-01 | +| Source debt ratchet | all untagged test source | listener_helper: 38 calls / 13 files | ga-80po0c.2.2.3 | untagged listener-helper call/file totals cannot grow; reductions must lower this baseline; each owning test replaces helper-backed listeners or moves the retained boundary to exact Medium ownership | P0.4c-listener-helper | 2026-10-01 | +| Source debt ratchet | all untagged test source | net_listen: 94 calls / 35 files (historical regex census: 92 / 34) | ga-80po0c.2.2.2 | untagged stream-listener call/file totals cannot grow; reductions must lower this baseline; each owning test closes its stream listener and removes duplicate listener-backed coverage | P0.4c-listener | 2026-10-01 | +| Source debt ratchet | all untagged test source | net_listen_config: 1 calls / 1 files | ga-80po0c.2.2.2 | untagged net.ListenConfig listener call/file totals cannot grow; reductions must lower this baseline; each owning test closes its configured listener and removes duplicate listener-backed coverage | P0.4c-listener | 2026-10-01 | +| Source debt ratchet | all untagged test source | net_listen_packet: 3 calls / 2 files | ga-80po0c.2.2.2 | untagged packet-listener call/file totals cannot grow; reductions must lower this baseline; each owning test closes its packet listener and removes duplicate listener-backed coverage | P0.4c-listener | 2026-10-01 | +| Source debt ratchet | all untagged test source | subprocess: 399 calls / 114 files (historical regex census: 380 / 98) | ga-80po0c.2 | untagged subprocess call/file totals cannot grow; reductions must lower this baseline; each process-owning test removes or replaces its source call site | D1/D2/D5/D6/E6 | 2026-10-01 | | Source debt ratchet | all untagged test source | syscall_listen: 1 calls / 1 files | ga-80po0c.2.2 | untagged syscall.Listen call/file totals cannot grow; reductions must lower this baseline; each owning test closes its listening file descriptor and removes duplicate listener-backed coverage | P0.4c | 2026-10-01 | +| Source debt ratchet | all untagged test source | tmux: 6 calls / 2 files | ga-80po0c.2.2.1 | untagged tmux dependency call/file totals cannot grow; reductions must lower this baseline; each owning test confines tmux processes and sockets to its isolated namespace and cleanup | P0.4c-tmux | 2026-10-01 | + +| Reviewed hermetic body | Effective runnable size | Medium reason | Retained real composition owner | +| --- | --- | --- | --- | +| `cmd/gc` package `main` — TestDoMailInbox_RendersMessagesFromReader | medium | package TestMain mutates process state | `cmd/gc` package `main` — TestCmdMailInbox_NormalizesCanonicalManagedProviderEnvAndReadsInbox | +| `cmd/gc` package `main` — TestDoSessionWait_RegistersReadyWaitForRigDependency | medium | package TestMain mutates process state | `cmd/gc` package `main` — TestCmdSessionWait_AllowsRigDependencyBeads | +| `cmd/gc` package `main` — TestDoSessionWake_PokesManagedControllerAfterStateChange | medium | package TestMain mutates process state | `cmd/gc` package `main` — TestCmdSessionWake_PokesManagedControllerAndRequestsSuspendedStart | +| `cmd/gc` package `main` — TestPrepareWaitWakeState_ResolvesRigDependencyBeads | medium | package TestMain mutates process state | `cmd/gc` package `main` — TestCmdSessionWait_AllowsRigDependencyBeads | -## Three tiers, clear boundaries +## Five test categories, clear boundaries ### 1. Unit tests (`*_test.go` next to the code) Test what the CODE does. Internal behavior, edge cases, precise failure injection. These are fast and run everywhere. -- Use `t.TempDir()` for filesystem tests +- Use `fsys.Fake` for consumer logic; use `t.TempDir()` when real filesystem + semantics own the risk - Use `require` for preconditions (fail immediately), `assert` for checks - Construct exact broken states in Go — corrupt files, concurrent writes, duplicate IDs, missing directories @@ -140,14 +511,13 @@ injection. These are fast and run everywhere. - Same package as the code under test (access to unexported functions) ```go -func TestBeadStore_CorruptLine(t *testing.T) { - dir := t.TempDir() - os.WriteFile(filepath.Join(dir, "beads.jsonl"), - []byte("{\"id\":\"gc-1\"}\nthis is not json\n"), 0644) - store := beads.NewStore(dir) - items, err := store.List() - require.NoError(t, err) - assert.Len(t, items, 1) // skips bad line, doesn't crash +func TestFileStoreOpenCorruptedJSON(t *testing.T) { + f := fsys.NewFake() + f.Files["/city/.gc/beads.json"] = []byte("{not json!!!") + + _, err := beads.OpenFileStore(f, "/city/.gc/beads.json") + require.Error(t, err) + assert.ErrorContains(t, err, "opening file store") } ``` @@ -172,7 +542,7 @@ fast unit-only baseline; the integration contribution comes from the shard-specific `coverage.integration-*.txt` profiles and their matching Codecov flags. -#### Sharded local runners +### Cross-category runners, timing, and resource isolation For broad local runs, prefer the repo's sharded wrappers over raw `go test` commands. They use the same buckets as CI, run under a scrubbed environment, @@ -188,6 +558,9 @@ make test-fast-parallel # Full process-backed cmd/gc suite, sharded. make test-cmd-gc-process-parallel +# Focused product-metrics testhook profile. +make test-productmetrics-testhook + # CI integration buckets, sharded. make test-integration-shards-parallel @@ -230,6 +603,105 @@ Raw `go test` is still appropriate for a focused package or a single failing test. Do not use it as the default for full local sweeps when a sharded target exists. +The `productmetrics_testhook` profile is a required, path-gated CI lane with +six named owners, including the real CLI re-exec process contract. Its tagged +process owner is intentionally absent from ordinary untagged `cmd/gc` shard +enumeration. The serial `make test-cmd-gc-process` target runs the ordinary +suite and then this profile; `make test-cmd-gc-process-parallel` and +`make test-local-full-parallel` add one independent +`productmetrics-testhook` job beside the ordinary shards. +The existing macOS `mac-cmd-gc-process` matrix runs the same profile once on +shard 6 so the Darwin production composition remains covered without another +Mac runner. + +#### PR static-check scope + +The `preflight-static` job has two fail-safe scopes. Only an effective +`pull_request` event whose default checkout is validated as GitHub's two-parent +synthetic merge, with its first parent equal to the event's exact base SHA, may +use the changed scope. The checkout keeps the default `GITHUB_SHA` and uses +`fetch-depth: 2` so that validation is local and exact. A missing or different +base, a non-merge checkout, or an unknown event selects the full scope. + +Pushes to `main`, schedules, manual dispatches, and every other non-PR event run +the full static suite. Reusable workflows inherit their caller's event; the +reusable call itself grants no changed-scope exemption. An effective +`pull_request` event may still qualify after the same synthetic-merge +validation, while an invocation such as the current RC `workflow_dispatch` +remains full. The classifier never guesses a base from `origin/main` or a +merge-base calculation. + +Even a validated PR merge runs the full scope when its diff touches static +analysis or build policy: + +- `go.mod`, `go.sum`, `go.work`, or `go.work.sum` +- any root `.golangci.*` configuration or `Makefile` +- `.github/workflows/**`, `.github/actions/**`, or `.githooks/**` +- `vendor/**` or `scripts/cipolicy/**` +- `scripts/ci-static-scope` and `scripts/ci-static-select` + +The two scopes own different commands: + +| Scope | Commands | Selection guarantee | +| --- | --- | --- | +| Changed PR | `make lint-affected`, `make fmt-check-changed` | Lint and vet every package owning a changed Go build input or embedded file, every native package that could consume a changed path, and all transitive reverse dependents; format-check only changed regular `.go` files that still exist. | +| Full/fail-safe | `make lint`, `make fmt-check`, `make vet` | Analyze and format-check the whole repository, then run standalone `go vet ./...`. | + +Affected-package discovery examines every changed path. It selects packages +for changed Go-tool build inputs (`.go`, `.c`, `.cc`, `.cpp`, `.cxx`, `.m`, +`.h`, `.hh`, `.hpp`, `.hxx`, `.f`, `.F`, `.for`, `.f90`, `.s`, `.S`, `.sx`, +`.swig`, `.swigcxx`, and `.syso`) and maps changed embedded files to every +owning package using `EmbedFiles`, `TestEmbedFiles`, and `XTestEmbedFiles` from +the canonical records in one complete +`go list -mod=readonly -test -json ./...` graph. +Additions, modifications, deletions, and both sides of cross-package moves are +included. Git rename coalescing is disabled so a move cannot hide the old +package. Native compiler include and linker inputs can have recognized or +arbitrary names and may live outside their consuming package. Every changed +path therefore selects every package with native Go-tool sources, plus their +reverse dependents. This is the smallest sound scope available without trying +to duplicate compiler-specific dependency discovery. An unrelated non-build, +non-embedded path remains a no-op when the graph has no native package that +could consume it. + +Reverse dependents are included because analyzers such as `govet` consume +exported facts, including through test-only imports. If the package graph +cannot be loaded completely, affected lint fails safe to `./...` instead of +trusting a partial graph. This includes a deleted required embed input. A +deleted glob member no longer appears in the current resolved embed inventory, +so a deletion that may match any current `EmbedPatterns`, `TestEmbedPatterns`, +or `XTestEmbedPatterns` entry fails safe even when a nested package still owns +the deleted build-input directory. Any other deletion beneath a package that +has neither a current embed owner nor a current direct package owner also fails +safe to full scope. These guards run before native shared-input shortcuts, +including for recognized headers. File selection is NUL-delimited. Formatting +remains limited to +changed `.go` paths, excludes deletions and symlinks, accepts only existing +regular files, and never invokes the formatter with an empty file list. + +`lint-affected` is the conservative PR target. It runs the configured +golangci linters, including golangci's `govet`, then runs the Go tool's `vet` +over the exact same affected package closure. The bounded duplicate preserves +both tools' distinct diagnostics without repeating either analysis across the +whole repository. It also retains standalone-vet diagnostics in generated +files and unchanged reverse dependents. If selection fails, the same pair runs +over `./...`; fallback never disables configured linters. `lint-changed` +remains the faster local/pre-commit target and intentionally checks only +packages that contain changed Go files. Both accept `LINT_CHANGED_SCOPE` and +`LINT_CHANGED_REF`; CI uses `tracked` and the event's exact PR base SHA. + +The golangci configuration enables `govet` explicitly in both scopes. +Golangci's `govet` execution is not assumed to be semantically equivalent to +standalone `go vet`: generated-file exclusions and analyzer/configuration drift +can differ. Full-scope runs therefore retain standalone `go vet ./...`, while +the changed lane invokes standalone vet on its conservative closure. + +`make test-ci-policy` runs independently of changed/full static selection and +always executes the focused workflow-scope, golangci-`govet`, affected-target, +and fail-closed-classifier contracts. A self-binding test in the existing CI +policy package rejects any Makefile change that removes this focused Go suite +from the target. + #### Historical timing summaries The opt-in timing artifacts produced by `scripts/go-test-observable` can be @@ -315,6 +787,60 @@ result planner-authoritative. Those are responsibilities of the later trusted default-branch workflow. Until that workflow lands, use the database as deterministic storage-boundary evidence only. +#### Local timing-plan dry runs + +The local planner consumes the current runnable inventory, the canonical +schema-v1 timing snapshot above, and planner configuration without changing +the active shard topology: + +```bash +go run ./scripts/test-timing-plan.go \ + --inventory runnable-inventory-v1.json \ + --history timing-history-v1.json \ + --config timing-plan-config-v1.json \ + > timing-plan-v1.json +``` + +Inventory and configuration are independently versioned. The minimal inventory +is `{"schema":1,"units":[{"unit_id":"package:TestName"}]}`. Configuration +schema v1 supplies one exact comparable profile, a shard count, a p95 cap, and +shared conservative fallback estimates for that suite/profile invocation. The +profile key is the complete `(job, variant, runner label, OS, architecture, +CPU count)` tuple; profiles are never merged or selected by a nearest-runner +heuristic. All three inputs reject missing or unsupported schemas, unknown +fields, trailing JSON values, and `null` where a contractual array is required. + +The current inventory is the only authority for runnable membership. Every +inventory unit is assigned exactly once, and stale timing rows cannot add work. +An exact profile match contributes history. If the requested profile is absent, +the command still produces a complete static plan and records +`history_profile_status: "profile-missing"`; multiple copies of one comparable +profile are malformed and fail. Snapshot counts, identities, nullable +statistics, observations, and authority flags are validated before planning, +including rows for units no longer in the inventory. + +History becomes planner-usable in two stages: + +- Before five successful samples, p50, p75, and variance use the configured + static fallback. At five samples, the empirical values become usable. +- Before twenty successful samples, p95 is + `max(static_p95, 1.5 * selected_p75)`. At twenty samples, empirical p95 + becomes usable. + +Units are sorted deterministically by descending p75, p95, variance, and p50, +then stable unit ID, and placed in the shortest p75 shard that remains within +the aggregate p95 cap. No unit is dropped: an individually oversized unit is +marked `p95-cap-exceeded`, while unavoidable aggregate overflow is marked +`shard-p95-cap-exceeded`. Equivalent shuffled inputs therefore emit identical +canonical JSON. + +The output is explicitly marked `authority: "dry-run"`. This command reads only +the three named files and writes the plan to stdout. It does not read GitHub +state, authenticate protected provenance, write timing history, publish +`ci-metrics`, perform path gating or hysteresis, decide required lanes, or +activate workflow/shard execution. Those remain deferred to the trusted +control-plane workflow. + In timing artifact schema v1, `commit_sha` is the exact Git revision checked out and tested (`GITHUB_SHA`). On `pull_request` runs, GitHub sets it to the synthetic merge commit, not the contributor branch head. Consumers must not interpret it as @@ -353,11 +879,81 @@ that invoke `go test` directly — `test-acceptance*`, `test-integration`, `test-integration-huma`, `test-worker-*`, `test-cover`, and similar — run unconfined even on slice-provisioned hosts. +#### Cross-invocation concurrency bound via push-gate slots + +The three resource-control axes are orthogonal: (1) within-run job sizing +(`LOCAL_TEST_JOBS`/`scripts/test-local-job-count`, above), (2) per-invocation +resource isolation (`gascity-test.slice`, above), (3) cross-invocation +concurrency bound — this section. Axes 1 and 2 both operate *within* a +single `test-local-parallel` invocation; neither stops multiple invocations +(a push, a direct `make`, and a CI job, say) from landing on the same host +at once. Two measured incidents (2026-07-14, load 88.07 with 5 concurrent +`test-fast-parallel` runs + 2 gates + 1 `make test`; a later run at load +53.6-82.1 with ~20 concurrent gate processes) showed exactly that: nothing +bounded how many heavy-suite invocations could run concurrently, producing +false-red failures (timeouts, OOM-adjacent slowdowns) indistinguishable +from real regressions. + +`scripts/test-local-parallel` — the one place all four heavy targets +(`fast`, `cmd-gc-process`, `integration`, `full`) funnel through — acquires +one of `PUSH_GATE_MAX_CONCURRENT` (default 2) numbered `flock(1)` slots +under `/.gc/gate-slots` (or, outside a city, the repository's +common git dir — `/.git/gate-slots` in a normal clone, and the one +shared common dir for all of a repo's linked worktrees) before running any +jobs, and holds it for the invocation's entire +lifetime. The mechanism (`scripts/push-gate-lock-lib.sh`) is adapted from +`packs/maintainer-pr-review/scripts/run-lock-lib.sh`'s +`mpr_acquire_global_slot` in the gc-management meta-repo, with one +deliberate difference: mpr's caller fails fast, but this gate's caller is +synchronous and human/agent-facing, so on contention it polls with a +bounded wait (`PUSH_GATE_MAX_WAIT_SECONDS`, default 600s; polling every +`PUSH_GATE_POLL_SECONDS`, default 15s), printing an immediate diagnostic +naming current slot holders the moment it starts waiting. Exhausting the +wait maps to `exit 75` (`EX_TEMPFAIL`) — distinct from a real test failure +and from `scripts/push-ownership-guard.sh`'s unrelated `exit 1` contract for +bead-ownership staleness. That 75 is only visible to callers that invoke +`scripts/test-local-parallel` directly: the four Makefile targets and +`.githooks/pre-push` (`exec make test-fast-parallel`) run it under `make`, +which reports `make: *** [test-fast-parallel] Error 75` and then exits 2. +Through those paths the distinguishing signal is the stderr text, not the +process exit code. The kernel releases the lock automatically when the +holding process exits — success, failure, or crash alike — so a stale slot +can never survive a dead holder; no PID-file liveness probing is involved. +FD inheritance into test jobs is severed at the fan-out boundary, so a slot +that stays locked past its gate means a leaked descendant is still holding +the descriptor (`lsof` on the slot file names it), not a stale file to +delete. The gate needs `flock(1)`, which `docs/getting-started/installation.md` +already lists as required; if it is absent the run proceeds uncapped with a +warning rather than blocking. `GC_PUSH_GATE_NO_CAP=1` bypasses the cap +entirely for one invocation. + +The slot mechanics are covered by `scripts/test-push-gate-lock.sh`, run +directly as the `push-gate-lock-selftest` job inside `test-local-parallel` +itself (`fast` and `full` modes) rather than through a `go test` trampoline. +A trampoline's `exec.Command` call would itself add a tracked subprocess +occurrence to `internal/testpolicy/resourcecensus`'s baselines — including +the `scope=all` audit row, which fails on any change, growth or shrinkage +alike, with no per-file exemption available — so driving the script as a +plain shell job avoids that ratchet entirely instead of bumping it. + +Only `scripts/test-local-parallel` is wired to this gate — the same targets +axis 2 leaves unconfined (`test-acceptance*`, `test-integration`, +`test-integration-huma`, `test-worker-*`, `test-cover`, and similar direct +`go test` invocations) are outside this bound too. + +This mechanism does not extend `bd` claim-lease heartbeats across the +wait+run phases. An earlier draft of the originating bead (`ga-owh20p`) +assumed an existing bd-heartbeat workaround needed extending for this +purpose; no such mechanism exists in this codebase (`bd heartbeat` leases +are node-local and ephemeral, never committed to Dolt, so extending them +here would be a no-op). The underlying claim-staleness concern this would +have addressed is tracked separately under `ga-aw5356`, not here. + ### 2. Testscript (`.txtar` files in `cmd/gc/testdata/`) -Test what the USER sees. Run the real `gc` binary, assert on stdout/stderr. -These are the tutorial regression tests — each `.txtar` corresponds to a -tutorial's shell interactions. +Test what the USER sees. Exercise the real CLI entrypoint by re-executing the +package test binary, then assert on stdout/stderr. These are tutorial regression +tests, not production-binary integration tests. - Uses `github.com/rogpeppe/go-internal/testscript` - Testscript defaults missing backend env vars to local fakes: @@ -379,7 +975,7 @@ stdout 'City initialized' exec gc rig add $WORK/tower-of-hanoi stdout 'Adding rig' -exec bd create 'Build a Tower of Hanoi app' +exec gc bd create 'Build a Tower of Hanoi app' stdout 'status: open' -- $WORK/tower-of-hanoi/.git/HEAD -- @@ -387,7 +983,7 @@ ref: refs/heads/main ``` When to use: CLI output format, command success/failure, user-facing error -messages, tutorial flows end to end. +messages, and tutorial CLI flows. **The env var rule:** if you need more than two env vars to set up a failure scenario, it's a unit test, not a testscript. In testscript, omitting the @@ -395,8 +991,11 @@ session/beads env vars now means "use the fake defaults," not "use real tmux." ### 3. Integration tests (`//go:build integration`) -Test that real pieces fit together. Need real tmux, real filesystem, real -agent sessions. Run separately — not in CI by default. +Test that real pieces fit together. These may need real tmux, a real +filesystem, real agent sessions, or a real server. Integration shards currently +run behind a coarse Go/shared-path gate; broader REST coverage runs on `main`. +Use explicit profile commands for credentialed live providers until their +scheduled matrix is wired. ```go //go:build integration @@ -406,10 +1005,15 @@ func TestRealTmuxSession(t *testing.T) { } ``` -When to use: proving the fakes are honest, smoke testing the real infra, -testing tmux session lifecycle with real processes. +When to use: proving real-boundary composition and testing lifecycle behavior +that exists only with real processes. Shared conformance proves fake parity. -Run with: `go test -tags integration ./test/...` +For the broad suite, run `make test-integration-shards-parallel`. Raw `go test` +is for a focused package or test, for example: + +```bash +go test -tags integration -run '^TestHumaBinary$' ./test/integration/ +``` **Supervisor binary smoke test** (`test/integration/huma_binary_test.go`): builds `gc`, boots the supervisor against an isolated `GC_HOME`, waits @@ -438,10 +1042,9 @@ The live API contract test has a few load-bearing rules: reaching into internal Go state. - Treat asynchronous operations as two-step contracts: the HTTP call returns quickly with `202 Accepted` and a `request_id`, then a `request.result.*` - or `request.failed` event appears. Focused Huma binary tests should use - `/v0/events/stream` for the critical async paths; broader coverage may poll - event-list endpoints when the thing being tested is the API surface rather - than SSE framing. + or `request.failed` event appears. Subscribe to `/v0/events/stream` before + the mutation and wait for the correlated terminal event. If the event-list + API itself is under test, query it once after that notification. - Prefer self-provisioned fixtures. The test should create its own city, rig, provider/agent/session, beads, mail, formulas, convoys, and order-history fixtures where practical, then clean them up through the API. @@ -486,9 +1089,52 @@ Run it in isolation with `make dashboard-e2e-go` package: the CI `packages` integration shard (`go list ./...` under `scripts/test-integration-shard packages`, invoked by `make test-integration-shards-parallel`) picks it up automatically alongside the -REST/formula shards — no dedicated shard registration is needed. The -structured-transcript view is not covered here; it lands with its serving path -(PR #3931) and is asserted then. +REST/formula shards — no dedicated shard registration is needed. Its structured +transcript coverage verifies REST-to-SSE cursor handoff, exact replay +suppression, inclusive-tail upserts, and reset parity through the real supervisor +wire. + +The opt-in browser layer runs the embedded production SPA in Chromium against +that same `test/dashport` listener. Run it with `make dashboard-e2e-play` after +installing the pinned Playwright browser, or run both layers with `make +dashboard-e2e`. `TestStructuredTranscriptBrowser` asserts the rendered DOM, +request URLs, SSE upsert/reset behavior, duplicate suppression, and a clean +console/network/error-boundary surface. It adds no second HTTP listener and is +not part of the default integration shard because browser binaries are an +explicit local/CI provisioning choice. + +#### Dashboard Playwright render smoke (`internal/api/dashboardspa/web/frontend/e2e`) + +Layer B is a Chromium render smoke over the **same** `testdata/dashport/` corpus, +loaded through the same importable loader (`test/dashport/corpus`) that Layer A +uses — one fixture source of truth. A small `//go:build integration` binary, +`test/dashport/cmd/fakesupervisor`, serves the seeded stack via +`api.ServeSeededCity` on a loopback listener; the Playwright `webServer` launches +it and points `baseURL` at it, so the SPA and its same-origin `/v0` + `/api` +surfaces are hosted by one handler (no CORS or base-URL override). Each spec +drives a route (Home, Runs, the seeded run detail — the regression view —, +Agents, Beads, Mail, Activity, Health) and asserts three things: the seeded +content renders, **no** React error boundary +(`components/ErrorBoundary.tsx`) is shown, and **no** client-error POST +(`/api/client-errors`) fires. It removes all vitest mocks — the built bundle runs +in a real browser against a real HTTP supervisor, so it exercises the full +fetch → generated client → projection helper → render path. + +It is a **Tier 3** browser tier — it needs a built SPA bundle + Chromium, so it +is NOT in the Go integration shard set. Run it with `make dashboard-e2e-play` +(builds the SPA, builds the fakesupervisor with `-tags integration`, installs +Chromium via `npx playwright install chromium`, then runs the specs); +`make dashboard-e2e` runs both layers. In CI it runs as appended steps in the +existing **`dashboard`** job (`.github/workflows/ci.yml`), which already has Go + +Node provisioned; a `playwright-report` artifact is uploaded on failure. Add new +routes/assertions by editing `e2e/render-smoke.spec.ts`; keep +`e2e/fixtures/expected.ts` aligned **manually** with the exported constants in +`test/dashport/corpus/corpus.go` (there is no automated parity check — the two +are kept in sync by convention). + +The current CI Playwright configuration retries once. That is legacy, +noncompliant debt under `ga-80po0c`; do not copy it or treat a retry-pass as +first-attempt reliability. #### Live worker inference tests (`//go:build acceptance_c`) @@ -506,8 +1152,9 @@ Supported profiles are `claude/tmux-cli`, `codex/tmux-cli`, `--model google/gemini-2.5-flash` by default; set `GC_WORKER_INFERENCE_OPENCODE_MODEL` to override it and provide `GOOGLE_GENERATIVE_AI_API_KEY`, `GEMINI_API_KEY`, or `GOOGLE_API_KEY` for auth. -Nightly CI runs the configured profile matrix with its credentials and uploads -worker report artifacts. +The full profile matrix is not wired into nightly CI today. Nightly runs a +separate focused Ollama Tier C subset; use the commands above for these live +profiles until scheduled coverage is added. ### 4. Documentation sync tests (`test/docsync`) @@ -525,10 +1172,7 @@ Run them directly with: go test ./test/docsync ``` -Gas City's own tests for this code live in `gascity_test.go` (adapter -unit tests) and `test/integration/bdstore_test.go` (conformance). - -#### Two flavors of integration tests +### Additional integration guidance **Low-level** (`internal/runtime/tmux/tmux_test.go`): test raw tmux operations (NewSession, HasSession, KillSession) directly against the @@ -541,7 +1185,10 @@ run it against real tmux. Validates the tutorial experience: `gc init`, **BdStore conformance** (`test/integration/bdstore_test.go`): runs the beads conformance suite against `BdStore` backed by a real dolt server. Proves the full stack: dolt server → bd CLI → BdStore → beads.Store. -Requires dolt and bd installed; skips otherwise. +Its current caller skips before the suite because of the pinned `bd` version, +so it is a known gap, not a passing production-constructor proof. A local +capability skip is only convenience; required coverage needs an equipped lane +or an explicit expiring waiver. #### Session safety for end-to-end tests @@ -608,7 +1255,7 @@ if !strings.HasPrefix(ops[0], "ensure-ready") { | Question | Test type | |---|---| -| Does the beads store handle corrupt JSONL? | Conformance | +| Does `Store.Get` return `ErrNotFound` for a missing ID? | Conformance | | Does `gc start` call ensure-ready before init? | Coordination | | Does the mail provider deliver to the right inbox? | Conformance | | Do all three Effective* methods use the qualified name? | Coordination | @@ -623,18 +1270,19 @@ correct results. ### Conformance testing Provider interfaces may expose shared conformance suites in -`*test/conformance.go` packages. Suite availability does not prove that every -implementation or production constructor executes the suite: each consumer -must bind its exact constructor without a pre-run skip. The table names the -shared suites and their current named consumers; the runtime ledger below is -the constructor-specific source of truth. - -| Interface | Conformance suite | Current named consumers | +`*test/conformance.go` packages. Suite availability does not prove exact +production-path coverage. Each behaviorally distinct implementation or +composition must execute the suite without a pre-run skip; thin aliases may use +a focused exact-constructor wiring proof. The table names current callers, not +proof status. The runtime ledger below is the only mechanically checked +constructor-specific inventory today. + +| Interface | Conformance suite | Current suite callers | |---|---|---| -| `beads.Store` | `internal/beads/beadstest/conformance.go` | MemStore, FileStore, BdStore | +| `beads.Store` | `internal/beads/beadstest/conformance.go` | MemStore, FileStore, exec-backed stores; BdStore caller currently skips; NativeDolt caller uses a test-only storage fixture | | `runtime.Provider` | `internal/runtime/runtimetest/conformance.go` | See the checked runtime ledger below | -| `mail.Provider` | `internal/mail/mailtest/conformance.go` | beadmail, exec | -| `events.Recorder` | `internal/events/eventstest/conformance.go` | FileRecorder, exec | +| `mail.Provider` | `internal/mail/mailtest/conformance.go` | beadmail, exec, Fake | +| `events.Provider` | `internal/events/eventstest/conformance.go` | FileRecorder, exec, Fake | | `fsys.FS` | `internal/fsys/fsystest/conformance.go` | OSFS, Fake | The `fsys.FS` suite currently proves the portable namespace core: parent and @@ -685,19 +1333,34 @@ construction boundary because that is the wrapper returned directly by the runtime registry. This ledger does not recursively claim the wrapper's internal tmux, K8s, or hybrid constructors. -`runtime.NewFake` is source-bound to the shared runtime contract below. -`ga-80po0c.1.2` still owns the separate subprocess constructor bindings. E1 -(`ga-80po0c.6`) owns the Large provider/E2E manifest and required lane/cadence -execution; it does not own constructor-to-contract source binding. +`runtime.NewFake`, `auto.New`, `exec.NewSeamBacked`, +`subprocess.NewSeamBackedWithDir`, and `acp.NewSeamBackedWithDir` are +source-bound to the shared runtime contract below. The auto proof runs the +exact production composition once with two fresh in-memory fakes and owns no +subprocess or listener; focused auto tests retain base-versus-ACP routing and +optional-capability coverage instead of duplicating the full suite for each +route. The seam-backed proofs are the only full exec, subprocess, and ACP +runtime contracts: duplicate raw contracts are avoided, and parent-owned +fixtures are reused while each contract case receives a fresh production +wrapper. `TestSeamBackedCapabilitiesParity` separately guards exec's +handshake-derived stream and TTY flags because the shared contract does not +assert optional capability fidelity. Focused raw provider and seam tests remain +for these packages, including legacy overlap that later consolidation may +remove case by case. The default subprocess constructor remains a separate +H5-owned gap because its reachable empty-city-path branch uses shared temporary +state. The default ACP constructor is also an H5-owned gap because it always +uses shared `os.TempDir()/gc-acp` state. E1 (`ga-80po0c.6`) owns the Large +provider/E2E manifest and required lane/cadence execution; it does not own +constructor-to-contract source binding. This table is rendered from `internal/testutil/providerledger` and checked by `go test ./internal/testutil/providerledger`; edit the Go ledger, then use the expected block printed on drift. | Provider path | Roles | Reusable type | Port | Constructor | Discovery | Contract | Status | |---|---|---|---|---|---|---|---| -| `runtime.builtin.acp` | production_provider | — | `runtime.Provider` | `internal/runtime/acp.NewSeamBacked` | runtime.builtin/exact:acp | `runtime.Provider` | waived by ga-80po0c.3 through 2026-08-12: full conformance covers the raw ACP provider, not the NewSeamBacked production composition | -| `runtime.builtin.acp` | production_provider | — | `runtime.Provider` | `internal/runtime/acp.NewSeamBackedWithDir` | runtime.builtin/exact:acp | `runtime.Provider` | waived by ga-80po0c.3 through 2026-08-12: full conformance covers the raw ACP provider, not the NewSeamBackedWithDir production composition | -| `runtime.builtin.exec` | production_provider | — | `runtime.Provider` | `internal/runtime/exec.NewSeamBacked` | runtime.builtin/prefix:exec: | `runtime.Provider` | waived by ga-80po0c.3 through 2026-08-12: full conformance covers the raw exec provider, not the production seam-backed prefix composition | +| `runtime.builtin.acp` | production_provider | — | `runtime.Provider` | `internal/runtime/acp.NewSeamBacked` | runtime.builtin/exact:acp | `runtime.Provider` | waived by ga-80po0c.3 through 2026-08-12: NewSeamBacked always uses shared os.TempDir()/gc-acp state; the WithDir proof does not exercise that composition | +| `runtime.builtin.acp` | production_provider | — | `runtime.Provider` | `internal/runtime/acp.NewSeamBackedWithDir` | runtime.builtin/exact:acp | `runtime.Provider` | proved by internal/runtime/acp/conformance_test.go#TestACPConformance | +| `runtime.builtin.exec` | production_provider | — | `runtime.Provider` | `internal/runtime/exec.NewSeamBacked` | runtime.builtin/prefix:exec: | `runtime.Provider` | proved by internal/runtime/exec/exec_test.go#TestExecConformance | | `runtime.builtin.exec` | production_provider | — | `runtime.Provider` | `internal/runtime/t3bridge.NewSeamBacked` | runtime.builtin/prefix:exec: | `runtime.Provider` | waived by ga-80po0c.3 through 2026-08-12: the legacy gc-session-t3 prefix branch selects the T3 bridge composition, which has no full shared runtime contract | | `runtime.builtin.fail` | production_provider, reusable_double | `internal/runtime.Fake` | `runtime.Provider` | `internal/runtime.NewFailFake` | runtime.builtin/exact:fail; reusable: internal/runtime/fake.go | `runtime.Provider` | not applicable: intentional faulting double: a successful lifecycle cannot be exercised, so the successful-provider contract is not applicable | | `runtime.builtin.fake` | production_provider, reusable_double | `internal/runtime.Fake` | `runtime.Provider` | `internal/runtime.NewFake` | runtime.builtin/exact:fake; reusable: internal/runtime/fake.go | `runtime.Provider` | proved by internal/runtime/fake_conformance_test.go#TestFakeConformance | @@ -705,11 +1368,11 @@ This table is rendered from `internal/testutil/providerledger` and checked by `g | `runtime.builtin.hybrid` | production_provider | — | `runtime.Provider` | `cmd/gc.newHybridProvider` | runtime.builtin/exact:hybrid | `runtime.Provider` | waived by ga-80po0c.3 through 2026-08-12: cmd/gc.newHybridProvider is the selected registry construction boundary; its internal tmux, K8s, and hybrid constructors are not claimed here, and the wrapper has no full shared runtime contract | | `runtime.builtin.k8s` | production_provider | — | `runtime.Provider` | `internal/runtime/k8s.NewSeamBacked` | runtime.builtin/exact:k8s | `runtime.Provider` | waived by ga-80po0c.3 through 2026-08-12: the actual K8s production composition has no full shared runtime contract | | `runtime.builtin.ssh` | production_provider | — | `runtime.Provider` | `internal/runtime/ssh.NewSeamBacked` | runtime.builtin/prefix:ssh: | `runtime.Provider` | waived by ga-80po0c.3 through 2026-08-12: the production SSH composition has no full shared runtime contract | -| `runtime.builtin.subprocess` | production_provider | — | `runtime.Provider` | `internal/runtime/subprocess.NewSeamBacked` | runtime.builtin/exact:subprocess | `runtime.Provider` | waived by ga-80po0c.1.2 through 2026-08-12: NewSeamBacked exact production-constructor proof binding is deferred to ga-80po0c.1.2 | -| `runtime.builtin.subprocess` | production_provider | — | `runtime.Provider` | `internal/runtime/subprocess.NewSeamBackedWithDir` | runtime.builtin/exact:subprocess | `runtime.Provider` | waived by ga-80po0c.1.2 through 2026-08-12: NewSeamBackedWithDir exact production-constructor proof binding is deferred to ga-80po0c.1.2 | +| `runtime.builtin.subprocess` | production_provider | — | `runtime.Provider` | `internal/runtime/subprocess.NewSeamBacked` | runtime.builtin/exact:subprocess | `runtime.Provider` | waived by ga-80po0c.3 through 2026-08-12: NewSeamBacked selects a distinct reachable empty-cityPath branch with shared /tmp state; the WithDir proof does not exercise that composition | +| `runtime.builtin.subprocess` | production_provider | — | `runtime.Provider` | `internal/runtime/subprocess.NewSeamBackedWithDir` | runtime.builtin/exact:subprocess | `runtime.Provider` | proved by internal/runtime/subprocess/seam_conformance_test.go#TestSubprocessSeamConformance | | `runtime.builtin.t3bridge` | production_provider | — | `runtime.Provider` | `internal/runtime/t3bridge.NewSeamBacked` | runtime.builtin/exact:t3bridge | `runtime.Provider` | waived by ga-80po0c.3 through 2026-08-12: the production T3 bridge composition has focused tests but no full shared runtime contract | | `runtime.builtin.tmux` | production_provider | — | `runtime.Provider` | `internal/runtime/tmux.NewSeamBackedWithConfig` | runtime.builtin/exact:tmux | `runtime.Provider` | waived by ga-80po0c.3 through 2026-08-12: the existing full conformance run skips when the tmux executable is absent | -| `runtime.composition.auto` | production_provider | — | `runtime.Provider` | `internal/runtime/auto.New` | source: cmd/gc/providers.go#resolveSessionTransportProvider — conditional transport composition is outside the runtime registry | `runtime.Provider` | waived by ga-80po0c.3 through 2026-08-12: the production auto base/ACP composition has no full shared runtime contract | +| `runtime.composition.auto` | production_provider | — | `runtime.Provider` | `internal/runtime/auto.New` | source: cmd/gc/providers.go#resolveSessionTransportProvider — conditional transport composition is outside the runtime registry | `runtime.Provider` | proved by internal/runtime/auto/conformance_test.go#TestAutoConformance (default-route conformance; ACP route covered by focused auto routing tests) | Conformance tests verify the behavioral contract (create/read/update/delete, @@ -723,16 +1386,18 @@ lands, and what remains tracked but non-gating. ### Provider seam inventory -All five provider seams, their lifecycle dependencies, and coordination -test coverage. This table is the checklist for new provider implementations. +Core provider and lifecycle seams, their dependencies, and coordination test +coverage. This table is a checklist for new provider implementations; the +shared-suite callers and checked runtime ledger above are the conformance +source of truth. | Seam | Implementations | Lifecycle deps | Coordination tested? | |---|---|---|---| | **Runtime** (`runtime.Provider`) | See checked runtime ledger above | None (stateless start/stop) | Via lifecycle start order test | -| **Beads** (`beads.Store`) | MemStore, FileStore, BdStore | ensure-ready → init → hooks | `TestLifecycleCoordination_*` | -| **Mail** (`mail.Provider`) | beadmail, exec | Depends on beads store | No — not a lifecycle seam; conformance sufficient | -| **Events** (`events.Recorder`) | FileRecorder, exec | None (append-only) | No — stateless append, conformance sufficient | -| **Dolt** (internal) | dolt.EnsureRunning, dolt.StopCity | ensure → init, stop after agents | Covered by beads lifecycle (exec spy) | +| **Beads** (`beads.Store`) | See shared-suite callers above; production selection includes NativeDoltStore and BdStore | ensure-ready → init → hooks | `TestLifecycleCoordination_*` | +| **Mail** (`mail.Provider`) | beadmail, exec, Fake | Depends on beads store | No — not a lifecycle seam; conformance sufficient | +| **Events** (`events.Provider`) | FileRecorder, exec, Fake | None | No — provider conformance covers record, query, and watch behavior | +| **Managed beads lifecycle** (`cmd/gc`) | `ensureBeadsProvider`, `shutdownBeadsProvider` | ensure → init, stop after agents | Covered by beads lifecycle (exec spy) | **Adding a new provider:** When adding a new implementation of any seam: 1. Run the conformance suite against it (mandatory) @@ -751,15 +1416,45 @@ operation completes in < 1s on an idle machine but fails under CI CPU saturation. The only exception is a timer that is itself the subject under test (e.g., testing that a function honours a 100ms deadline). +### Floors, ceilings, and inputs + +`GoroutineRaceTimeout` and `ExecRaceTimeout` are **floors** — the minimum a +deadline may be. They are not a target to set every wait to. + +Some packages additionally define a **hang budget**: the point at which the +package gives up and declares a wait wedged. `cmd/gc` has one (`hangBudget` in +`cmd/gc/hangbudget_test.go`), derived from `GoroutineRaceTimeout` rather than +declared independently, so there is one source of truth. Which to reach for: + +- **Does any assertion depend on how long the wait took?** Keep an explicit + deadline and comment which bound it asserts. This is the "subject under test" + exception above. +- **Is the wait purely a hang detector** — the real assertions come after it + returns? Use the package's hang budget (`awaitClose`/`awaitCond` in `cmd/gc`). + Sizing it is not a correctness knob: these helpers return the instant their + condition is met, so raising the budget does not slow a passing run and + lowering it does not make the suite stricter. It only changes how long a + genuinely wedged test takes to report. +- **Otherwise**, use `GoroutineRaceTimeout` / `ExecRaceTimeout` directly. + +Two things are never migrated to a hang budget: + +- **A value the test feeds the system** — a timeout passed *into* the code under + test defines the scenario being exercised, not how patiently the test watches. + Widening one makes the test prove less. +- **The window of a negative assertion** ("nothing arrived within X"). There the + window *is* the assertion; budget-governing it makes the test slower and + weaker. + ## Decision guide | Question you're testing | Tier | |---|---| -| Does `bd create` print the right output? | Testscript | +| Does `gc bd create` print the right output? | Testscript | | Does `gc start` fail gracefully without tmux? | Testscript (`GC_SESSION=fail`) | | Does `gc rig add` fail for a missing path? | Testscript (real missing path) | -| Does the beads store skip corrupted JSONL lines? | Unit test | -| Does claim return ErrAlreadyClaimed on double-claim? | Unit test | +| Does FileStore reject corrupted JSON? | Unit test | +| Does FileStore roll back after a save failure? | Unit test | | Does concurrent bead creation avoid corruption? | Unit test | | Does startup roll back if step 3 of 5 fails? | Unit test | | Does a real tmux session start and respond to send-keys? | Integration | @@ -774,38 +1469,43 @@ test (e.g., testing that a function honours a 100ms deadline). ## Test doubles -No mock libraries. No `gomock`. No `mockgen`. Every test double is a -hand-written concrete type that lives in the same package as the -interface it implements. +No mock libraries. No `gomock`. No `mockgen`. Reusable test doubles are +hand-written concrete types kept beside the port they implement. Small +consumer-local stubs and function fakes may remain beside their consumer when +they are not reusable provider implementations. -### The four test doubles +### Reusable fast substitutes | Double | Interface | Package | Strategy | |---|---|---|---| | `runtime.Fake` | `runtime.Provider` | `internal/runtime` | In-memory state + spy + broken mode | | `fsys.Fake` | `fsys.FS` | `internal/fsys` | In-memory maps + spy + per-path error injection | | `beads.MemStore` | `beads.Store` | `internal/beads` | Real logic, in-memory backing (also used by `FileStore` internally) | +| `mail.Fake` | `mail.Provider` | `internal/mail` | In-memory message state + broken mode | +| `events.Fake` | `events.Provider` | `internal/events` | In-memory event log + event-driven watchers + read/watch failure mode | ### Spy pattern -Every fake records calls as `[]Call` structs. Tests verify both the -result AND the call sequence: +Some fakes also record calls as `[]Call` structs. Verify interactions only when +the arguments or ordering are the behavior under test; otherwise assert the +resulting state. Use a synchronized snapshot accessor when calls may still be +concurrent: ```go sp := runtime.NewFake() -_ = sp.Start(context.Background(), "mayor", runtime.Config{}) -_ = sp.Attach("mayor") +_ = sp.Start(context.Background(), "worker-a", runtime.Config{}) +_ = sp.Attach("worker-a") // Verify call sequence recorded by the fake runtime. want := []string{"Start", "Attach"} -for i, c := range sp.Calls { +for i, c := range sp.SnapshotCalls() { if c.Method != want[i] { ... } } ``` ### Error injection strategies -Three patterns, used where they fit: +Use the narrowest pattern that expresses the failure boundary: **Per-path errors** (`fsys.Fake`) — fine-grained, fail specific operations: ```go @@ -813,29 +1513,35 @@ f := fsys.NewFake() f.Errors["/city/rigs"] = fmt.Errorf("disk full") ``` -**Modal errors** (`runtime.Fake`) — all-or-nothing broken mode: +**Modal errors** (`runtime.Fake`, `mail.Fake`) — whole-provider +unavailability. `events.NewFailFake()` fails reads and watches but still records +events because `Recorder.Record` cannot return an error: ```go -f := runtime.NewFake() -f.Broken = true // Start/Stop/Attach and related operations return errors +f := runtime.NewFailFake() ``` ### Compile-time interface checks -Every fake has a compile-time assertion in its test file: +An explicit compile-time assertion is useful for a provider or adapter: ```go var _ Provider = (*Fake)(nil) ``` +The conformance factory also proves interface assignability. Neither form +proves behavioral parity by itself; the shared conformance suite does. + ### Fakes live next to the interface -Fakes are exported types in the same package as their interface. This -makes them importable by cross-package unit tests (e.g., `cmd/gc` -imports `runtime.NewFake()`). +Reusable provider fakes are exported types in the same package as their +interface. This makes them importable by cross-package unit tests (for example, +`cmd/gc` imports `runtime.NewFake()`). One-off stubs stay local to avoid growing +a global support API. ## The do*() function pattern -Every CLI command splits into two functions: +Many CLI commands use this split when command wiring and testable behavior need +separate owners: - **`cmdFoo()`** — wires up real dependencies (reads cwd, loads config, calls `newSessionProvider()`), then calls `doFoo()`. @@ -844,12 +1550,14 @@ Every CLI command splits into two functions: Unit tests call `doFoo()` directly with fakes: ```go -sp := runtime.NewFake() -code := doSessionAttach(sp, "mayor", &stdout, &stderr) +mp := mail.NewFake() +_, _ = mp.Send("alice", "worker-a", "Build complete", "Ready for review") +code := doMailInbox(mp, "worker-a", &stdout, &stderr) ``` -Testscript tests call `gc foo` which routes through `cmdFoo()` → -`doFoo()`. +Testscript tests call `gc foo` through the real command construction path. Do +not introduce a `do*()` wrapper mechanically when a smaller injected function +or existing domain API is the clearer seam. ### When to use each @@ -871,8 +1579,8 @@ conditionally (socket flags, env vars, flag lists). The test verifies the args array, not the subprocess outcome. **When NOT to use:** When the logic under test is the orchestration -sequence (which methods are called in what order). Use the `startOps` -interface pattern instead. +sequence (which methods are called in what order). Use a narrow coordination +port or recording collaborator instead. **Example:** `tmux.executor` — `fakeExecutor` captures the `[]string` args passed to each tmux command. Tests verify socket flags, UTF-8 diff --git a/cmd/gc/agent_build_params.go b/cmd/gc/agent_build_params.go index 8d37aea060..aff7e01b34 100644 --- a/cmd/gc/agent_build_params.go +++ b/cmd/gc/agent_build_params.go @@ -6,10 +6,12 @@ import ( "os/exec" "time" + "github.com/gastownhall/gascity/internal/agentutil" "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/fsys" "github.com/gastownhall/gascity/internal/materialize" + "github.com/gastownhall/gascity/internal/poolplan" "github.com/gastownhall/gascity/internal/runtime" workdirutil "github.com/gastownhall/gascity/internal/workdir" ) @@ -53,7 +55,7 @@ type agentBuildParams struct { // poolSessionCreateBudget caps ordinary fresh pool session bead // materialization in a single desired-state build. Existing session beads // may still be reused, and dependency-floor prerequisites are exempt. - poolSessionCreateBudget *poolSessionCreateBudget + poolSessionCreateBudget *poolplan.CreateBudget // poolScaleCheckPartialTemplates holds pool templates whose scale_check // returned a partial result this build cycle. selectOrPlanPoolSessionBead @@ -131,7 +133,7 @@ func newAgentBuildParams(cityName, cityPath string, cfg *config.City, sp runtime sessionProvider: cfg.Session.Provider, } if store != nil { - params.poolSessionCreateBudget = newPoolSessionCreateBudget(cfg.Daemon.MaxWakesPerTickOrDefault()) + params.poolSessionCreateBudget = poolplan.NewCreateBudget(cfg.Daemon.MaxWakesPerTickOrDefault()) } // Load the shared skill catalog once per build cycle. Transient load // failures (filesystem race during dolt sync / heavy I/O) used to @@ -254,10 +256,7 @@ func effectiveOverlayDirs(cityDirs []string, rigDirs map[string][]string, rigNam // back to the template, so `gc internal materialize-skills` exits 1. // For regular agents, qualifiedName already equals the template name. func templateNameFor(cfgAgent *config.Agent, qualifiedName string) string { - if cfgAgent.PoolName != "" { - return cfgAgent.PoolName - } - if t := cfgAgent.QualifiedName(); t != "" && t != qualifiedName { + if t := agentutil.RoutedToIdentity(cfgAgent); t != "" && t != qualifiedName { return t } return qualifiedName diff --git a/cmd/gc/api_state.go b/cmd/gc/api_state.go index d8c1ef2217..8b5bb4b152 100644 --- a/cmd/gc/api_state.go +++ b/cmd/gc/api_state.go @@ -903,7 +903,14 @@ func (cs *controllerState) noteRolloutDrift(next *config.City) { sig string // drift signature; "" means in sync logLine string ) - if nextFlags, err := rollout.Resolve(next, rollout.ResolveOptions{}); err != nil { + // Resolve ONLY the conditional_writes gate. next carries every [beads] key, + // so an invalid SIBLING gate (e.g. a guarded_release typo) would fail + // rollout.Resolve(next) and be misattributed here as a conditional_writes + // failure — falsely flagging a valid conditional_writes as invalid on + // reload. A CW-scoped view isolates this notice to its own gate; the CW env + // override still applies (Resolve reads it regardless of config). + cwOnly := &config.City{Beads: config.BeadsConfig{ConditionalWrites: next.Beads.ConditionalWrites}} + if nextFlags, err := rollout.Resolve(cwOnly, rollout.ResolveOptions{}); err != nil { sig = "invalid:" + err.Error() notice = &rollout.Notice{ Kind: rollout.NoticePendingRestart, @@ -1729,18 +1736,28 @@ func (cs *controllerState) DeleteAgent(name string) error { // failure mapper renders invalid_request and the sync mapper renders a 4xx rather // than a 500. func assertRigPathWithinCity(cityPath, resolved string) error { - // Lexical check first: rejects "../" escapes and absolute paths that resolve - // to a sibling/parent of the city. - if err := relWithinCity(cityPath, resolved); err != nil { - return err - } - // Symlink-aware check: a "../"-free lexical path can still escape through a - // symlinked ancestor (e.g. /link -> /outside, then a clone into - // link/rig). Canonicalize the city root and the nearest EXISTING ancestor of - // the (not-yet-created) target and re-check containment on the real paths. - realCity, err := filepath.EvalSymlinks(cityPath) + // Canonicalize BOTH sides with the same tolerant resolution, then check + // containment once. + // + // MERGE INTENT (v1.4.0 resync): this replaces a two-pass check whose first + // pass compared the raw cityPath against an already-canonicalized target. + // Callers derive `resolved` via resolveStoreScopeRoot, which upstream taught + // to resolve symlinks — but only when the path already exists, since + // EvalSymlinks fails on a not-yet-created rig dir. So whether either side + // was canonical depended on filesystem state, and a "lexical" comparison + // between them was not meaningful in either direction: with the city + // unresolved a contained rig was rejected (macOS /var -> /private/var), and + // normalizing only the city inverted the same failure for absent targets. + // + // realPathForContainment resolves the nearest EXISTING ancestor and rejoins + // the tail, so it is correct for both an existing city and an absent target. + // This is strictly stronger than the old lexical pass, not weaker: a "../" + // escape, an out-of-city absolute path, and an escape through a symlinked + // ancestor (/link -> /outside) all still resolve outside the city root + // and fail here. + realCity, err := realPathForContainment(cityPath) if err != nil { - realCity = filepath.Clean(cityPath) + return fmt.Errorf("%w: resolving city root %s: %w", configedit.ErrValidation, cityPath, err) } realTarget, err := realPathForContainment(resolved) if err != nil { @@ -1823,7 +1840,7 @@ func (cs *controllerState) CreateRig(r config.Rig) error { // The config.Rig result is consumed across the StateMutator boundary by // spawnRigProvision; unparam only sees cmd/gc's error-path test call sites, // which discard it, hence the directive. -func (cs *controllerState) ProvisionRigFromGit(ctx context.Context, r config.Rig, gitURL string, onStep func(step, detail string, warn bool), onManifest func(api.RigProvisionManifest)) (config.Rig, error) { //nolint:unparam +func (cs *controllerState) ProvisionRigFromGit(ctx context.Context, r config.Rig, gitURL string, onStep func(step, detail string, warn bool), onManifest func(api.RigProvisionManifest) error) (config.Rig, error) { //nolint:unparam gitURL = strings.TrimSpace(gitURL) if gitURL == "" { return config.Rig{}, fmt.Errorf("%w: git_url is required", configedit.ErrValidation) @@ -1875,9 +1892,16 @@ func (cs *controllerState) ProvisionRigFromGit(ctx context.Context, r config.Rig // Record-then-create (C4c §2.2): manifest the dir we are about to create // BEFORE the clone, so a crash mid-clone still leaves the debris findable by - // the boot sweep and a runtime failure tears down the partial clone. + // the boot sweep and a runtime failure tears down the partial clone. This + // persist is fail-closed: if the durable write does not land we must NOT + // clone, or the created directory would be un-manifested and neither the + // boot sweep nor a re-clone pre-drop could discover it — wedging the + // request_id/name on every retry. No resource has been created yet, so + // aborting here leaves clean ground. if onManifest != nil { - onManifest(api.RigProvisionManifest{RigName: r.Name, CreatedDir: r.Path}) + if err := onManifest(api.RigProvisionManifest{RigName: r.Name, CreatedDir: r.Path}); err != nil { + return config.Rig{}, fmt.Errorf("recording rig-provision manifest before clone: %w", err) + } } if onStep != nil { @@ -1905,13 +1929,21 @@ func (cs *controllerState) ProvisionRigFromGit(ctx context.Context, r config.Rig } // Provision succeeded: extend the manifest with the managed Dolt database - // this add minted (if any), so the rollback path can drop it. + // this add minted (if any), so the rollback path can drop it. Unlike the + // pre-clone checkpoint, a persist failure here is NOT fatal: the rig is now + // fully provisioned, so if the process crashes before the durable succeeded + // write the boot sweep's completeness probe reconciles it FORWARD (never + // tears it down), and the runtime rollback path uses the in-memory manifest. + // Failing a healthy provision on a transient metadata write would destroy a + // good rig, so log and continue. if onManifest != nil { - onManifest(api.RigProvisionManifest{ + if err := onManifest(api.RigProvisionManifest{ RigName: r.Name, CreatedDir: r.Path, DoltDB: cs.provisionedManagedDoltDatabase(r.Path), - }) + }); err != nil { + log.Printf("api: rig %q provisioned but persisting the post-init manifest failed (non-fatal; forward-reconciled on retry/sweep): %v", r.Name, err) + } } return provisioned, nil } @@ -2003,7 +2035,7 @@ var controllerDropManagedDoltDatabase = func(cs *controllerState, ctx context.Co if err := fatalPortResolutionError(resolution); err != nil { return fmt.Errorf("resolving dolt port: %w", err) } - client, err := newSQLCleanupDoltClient(host, strconv.Itoa(resolution.Port)) + client, err := newSQLCleanupDoltClient(cs.cityPath, host, strconv.Itoa(resolution.Port)) if err != nil { return fmt.Errorf("opening dolt connection: %w", err) } @@ -2250,7 +2282,8 @@ func (cs *controllerState) rigProvisionDeps(editCfg *config.City, r config.Rig, WriteRoutes: func(cp string, c *config.City) error { return writeAllRigRoutes(collectRigRoutes(cp, c)) }, - ProbeBranch: func(p string) string { return git.New(p).ProbeDefaultBranch() }, + ProbeBranch: func(p string) string { return git.New(p).ProbeDefaultBranch() }, + ResolveRegistryPack: cachedRegistryPackSource, NormalizeScopes: func(cp string, c *config.City) error { return normalizeCanonicalBdScopeFiles(cp, c, io.Discard) }, diff --git a/cmd/gc/api_state_rig_containment_test.go b/cmd/gc/api_state_rig_containment_test.go new file mode 100644 index 0000000000..f9975ebfb9 --- /dev/null +++ b/cmd/gc/api_state_rig_containment_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/gastownhall/gascity/internal/configedit" +) + +// TestAssertRigPathWithinCity pins the containment property after the v1.4.0 +// resync collapsed the old two-pass check (lexical, then symlink-aware) into a +// single pass that canonicalizes both sides. +// +// The old first pass compared a raw cityPath against an already-canonicalized +// target, which produced false rejections once resolveStoreScopeRoot began +// resolving symlinks. Collapsing the passes fixed that, but it also removed a +// defense-in-depth layer guarding a remote-API dir-create/file-plant primitive, +// and nothing named pinned the rejection behavior. This test does. +func TestAssertRigPathWithinCity(t *testing.T) { + city := t.TempDir() + outside := t.TempDir() + if err := os.MkdirAll(filepath.Join(city, "rigs"), 0o755); err != nil { + t.Fatal(err) + } + + t.Run("contained existing path is allowed", func(t *testing.T) { + target := filepath.Join(city, "rigs") + if err := assertRigPathWithinCity(city, resolveStoreScopeRoot(city, target)); err != nil { + t.Fatalf("contained path rejected: %v", err) + } + }) + + // The regression the resync introduced: the rig dir does not exist yet, so + // EvalSymlinks fails on it and only the city side could be canonicalized. + t.Run("contained absent path is allowed", func(t *testing.T) { + target := filepath.Join(city, "rigs", "not-created-yet") + if err := assertRigPathWithinCity(city, resolveStoreScopeRoot(city, target)); err != nil { + t.Fatalf("absent contained path rejected: %v", err) + } + }) + + t.Run("dot-dot escape is rejected", func(t *testing.T) { + target := filepath.Join(city, "..", "escaped") + err := assertRigPathWithinCity(city, resolveStoreScopeRoot(city, target)) + if err == nil || !errors.Is(err, configedit.ErrValidation) { + t.Fatalf("../ escape not rejected: %v", err) + } + }) + + t.Run("absolute path outside the city is rejected", func(t *testing.T) { + err := assertRigPathWithinCity(city, resolveStoreScopeRoot(city, outside)) + if err == nil || !errors.Is(err, configedit.ErrValidation) { + t.Fatalf("outside absolute path not rejected: %v", err) + } + }) + + // The case the symlink-aware pass was originally added for: a "../"-free + // path that still escapes through a symlinked ancestor. + t.Run("escape through a symlinked ancestor is rejected", func(t *testing.T) { + link := filepath.Join(city, "link") + if err := os.Symlink(outside, link); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + target := filepath.Join(link, "rig") + err := assertRigPathWithinCity(city, resolveStoreScopeRoot(city, target)) + if err == nil || !errors.Is(err, configedit.ErrValidation) { + t.Fatalf("symlinked-ancestor escape not rejected: %v", err) + } + }) +} diff --git a/cmd/gc/api_state_rig_rollback_test.go b/cmd/gc/api_state_rig_rollback_test.go index f7af1c8570..5a3ee1a553 100644 --- a/cmd/gc/api_state_rig_rollback_test.go +++ b/cmd/gc/api_state_rig_rollback_test.go @@ -11,6 +11,7 @@ import ( "github.com/gastownhall/gascity/internal/api" "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/configedit" + "github.com/gastownhall/gascity/internal/git" "github.com/gastownhall/gascity/internal/rig" "github.com/gastownhall/gascity/internal/ssrf" ) @@ -176,7 +177,7 @@ func TestProvisionRigFromGitRejectsPreexistingPath(t *testing.T) { config.Rig{Name: "taken", Path: existing}, "https://example.com/r.git", nil, - func(api.RigProvisionManifest) { manifested = true }, + func(api.RigProvisionManifest) error { manifested = true; return nil }, ) if err == nil || !errors.Is(err, configedit.ErrValidation) { t.Fatalf("ProvisionRigFromGit preexisting = %v, want a validation error", err) @@ -202,7 +203,7 @@ func TestProvisionRigFromGitManifestsThenWrapsCloneError(t *testing.T) { config.Rig{Name: "httpfail"}, "http://myhost.example/repo.git", // scheme-rejected by git.Clone, no network nil, - func(m api.RigProvisionManifest) { manifests = append(manifests, m) }, + func(m api.RigProvisionManifest) error { manifests = append(manifests, m); return nil }, ) if err == nil || !errors.Is(err, rig.ErrCloneFailed) { t.Fatalf("ProvisionRigFromGit clone-fail = %v, want wrapped rig.ErrCloneFailed", err) @@ -213,6 +214,43 @@ func TestProvisionRigFromGitManifestsThenWrapsCloneError(t *testing.T) { } } +// TestProvisionRigFromGitAbortsWhenPreCloneManifestPersistFails locks the C4c +// fail-closed contract for record-then-create: if durably persisting the +// created_dir manifest fails BEFORE the clone, ProvisionRigFromGit must abort +// without cloning. The pre-fix behavior logged the persist error and cloned +// anyway, creating an un-manifested rig directory that neither the boot sweep +// nor a re-clone pre-drop could discover — wedging the request_id/name on every +// retry. The clone is stubbed so no network is touched and the assertion is +// purely "did we reach the clone". +func TestProvisionRigFromGitAbortsWhenPreCloneManifestPersistFails(t *testing.T) { + origResolver := ssrf.HostResolver + ssrf.HostResolver = func(string) ([]net.IP, error) { return []net.IP{net.ParseIP("140.82.112.3")}, nil } + defer func() { ssrf.HostResolver = origResolver }() + + cloneCalled := false + origClone := rigCloneGit + rigCloneGit = func(context.Context, string, string, git.CloneOptions) error { + cloneCalled = true + return nil + } + defer func() { rigCloneGit = origClone }() + + cs := &controllerState{cityPath: t.TempDir()} + persistErr := errors.New("SetMetadataBatch failed") + _, err := cs.ProvisionRigFromGit(context.Background(), + config.Rig{Name: "wedge"}, + "https://example.com/repo.git", + nil, + func(api.RigProvisionManifest) error { return persistErr }, + ) + if err == nil || !errors.Is(err, persistErr) { + t.Fatalf("ProvisionRigFromGit with a failing pre-clone manifest = %v, want the persist error", err) + } + if cloneCalled { + t.Fatal("clone ran after the pre-clone manifest persist failed (an un-manifested dir could wedge the name)") + } +} + // TestEnsurePublicGitHostFailsClosed proves the clone-path fence blocks a // resolution error (fail-closed strict), where the fail-open pack fence would // allow it. @@ -268,7 +306,7 @@ func TestProvisionRigFromGitRejectsEscapingRelativePath(t *testing.T) { config.Rig{Name: "evil", Path: "../../etc/evil"}, "https://example.com/r.git", nil, - func(api.RigProvisionManifest) { manifested = true }, + func(api.RigProvisionManifest) error { manifested = true; return nil }, ) if err == nil || !errors.Is(err, configedit.ErrValidation) { t.Fatalf("escaping relative path = %v, want a validation error", err) @@ -310,7 +348,7 @@ func TestProvisionRigFromGitRejectsSymlinkedParent(t *testing.T) { config.Rig{Name: "rig", Path: "link/rig"}, "https://example.com/r.git", nil, - func(api.RigProvisionManifest) { manifested = true }, + func(api.RigProvisionManifest) error { manifested = true; return nil }, ) if err == nil || !errors.Is(err, configedit.ErrValidation) { t.Fatalf("symlinked-parent path = %v, want a validation error", err) diff --git a/cmd/gc/api_state_rollout_test.go b/cmd/gc/api_state_rollout_test.go index eeeb8402a9..ce31871d4f 100644 --- a/cmd/gc/api_state_rollout_test.go +++ b/cmd/gc/api_state_rollout_test.go @@ -202,6 +202,48 @@ func TestControllerStateRolloutDrift(t *testing.T) { } } +// TestNoteRolloutDriftIsolatedFromSiblingGate is the regression for the +// cross-gate contamination a shared rollout.Resolve introduces: an out-of-enum +// value on a SIBLING gate (beads.guarded_release) fails the whole-config +// resolve, which noteRolloutDrift must NOT misattribute to conditional_writes. +// Before the CW-scoped resolve, a valid conditional_writes reload alongside a +// guarded_release typo falsely reported conditional_writes as invalid. +func TestNoteRolloutDriftIsolatedFromSiblingGate(t *testing.T) { + var logs []string + cs := &controllerState{ + rolloutFlags: rollout.ForTest(rollout.WithBeadsConditionalWrites(rollout.Require)), + rolloutLogf: func(f string, a ...any) { logs = append(logs, fmt.Sprintf(f, a...)) }, + } + + // Valid conditional_writes that DRIFTS (require→auto) with an invalid sibling + // guarded_release: the notice must describe the conditional_writes DRIFT, not + // claim conditional_writes is invalid. + cs.noteRolloutDrift(&config.City{Beads: config.BeadsConfig{ + ConditionalWrites: "auto", + GuardedRelease: "requre", + }}) + n := cs.RolloutDriftNotices() + if len(n) != 1 || n[0].FlagKey != rollout.KeyBeadsConditionalWrites { + t.Fatalf("want one conditional_writes notice, got %+v", n) + } + if strings.Contains(n[0].Message, "invalid") { + t.Errorf("sibling guarded_release typo misattributed as conditional_writes invalid: %q", n[0].Message) + } + if n[0].ConfigValue != "auto" || !strings.Contains(n[0].Message, "resolves to") { + t.Errorf("want a conditional_writes drift notice for auto, got %+v", n[0]) + } + + // Valid conditional_writes that is IN SYNC with the boot latch, again with an + // invalid sibling: drift must clear entirely — no spurious notice at all. + cs.noteRolloutDrift(&config.City{Beads: config.BeadsConfig{ + ConditionalWrites: "require", + GuardedRelease: "requre", + }}) + if got := cs.RolloutDriftNotices(); got != nil { + t.Errorf("in-sync conditional_writes with an invalid sibling should clear drift, got %+v", got) + } +} + // TestControllerStateRolloutDriftThroughReloadSeams proves the PRODUCTION reload // seams — update() and updateConfigAndProviderOnly() — actually invoke // noteRolloutDrift, and that a reload never re-latches the boot gate. Deleting diff --git a/cmd/gc/api_state_test.go b/cmd/gc/api_state_test.go index 36e2e449ed..409e37a43f 100644 --- a/cmd/gc/api_state_test.go +++ b/cmd/gc/api_state_test.go @@ -3518,11 +3518,7 @@ func TestControllerStateEstablishesBeadEventCursorBeforePrimingStores(t *testing close(returned) }() - select { - case <-ep.latestCalled: - case <-time.After(5 * time.Second): - t.Fatal("event watcher did not establish an initial cursor") - } + awaitClose(t, ep.latestCalled, "event watcher establishing an initial cursor") select { case <-returned: t.Fatal("newControllerState returned before the initial event cursor was established") @@ -3533,11 +3529,7 @@ func TestControllerStateEstablishesBeadEventCursorBeforePrimingStores(t *testing } close(ep.allowLatest) - select { - case <-returned: - case <-time.After(5 * time.Second): - t.Fatal("newControllerState did not return after the initial event cursor was established") - } + awaitClose(t, returned, "newControllerState returning after the initial event cursor was established") } func TestControllerStateBeadEventWatcherReplaysEventsAfterCachePrime(t *testing.T) { diff --git a/cmd/gc/assigned_work_scope.go b/cmd/gc/assigned_work_scope.go index 800fe4932f..633f975399 100644 --- a/cmd/gc/assigned_work_scope.go +++ b/cmd/gc/assigned_work_scope.go @@ -153,6 +153,7 @@ func filterAssignedWorkBeadsForPoolDemand( if template == "" { continue } + template = agentutil.NormalizePoolRouteTarget(cfg, template) agentCfg := findAgentByTemplate(cfg, template) if agentCfg == nil { continue diff --git a/cmd/gc/assigned_work_scope_test.go b/cmd/gc/assigned_work_scope_test.go index 3a2a0593a6..1c7a6a7b6b 100644 --- a/cmd/gc/assigned_work_scope_test.go +++ b/cmd/gc/assigned_work_scope_test.go @@ -197,6 +197,54 @@ func TestFilterAssignedWorkBeadsForPoolDemandKeepsPersistedBoundRoute(t *testing } } +func TestFilterAssignedWorkBeadsForPoolDemandNormalizesInstanceSuffixedRouteTarget(t *testing.T) { + cfg := &config.City{ + Agents: []config.Agent{{ + Name: "worker", + MinActiveSessions: intPtr(1), + MaxActiveSessions: intPtr(3), + }}, + } + work := []beads.Bead{{ + ID: "instance-routed", + Status: "in_progress", + Assignee: "worker-dead", + Metadata: map[string]string{ + "gc.routed_to": "worker-1", + }, + }} + + got := filterAssignedWorkBeadsForPoolDemand(cfg, "", nil, work, []string{""}) + + if len(got) != 1 || got[0].ID != "instance-routed" { + t.Fatalf("filtered work = %#v, want instance-suffixed route target normalized to the base template and kept", got) + } +} + +func TestFilterAssignedWorkBeadsForPoolDemandLeavesUnmatchedInstanceSuffixAlone(t *testing.T) { + cfg := &config.City{ + Agents: []config.Agent{{ + Name: "worker", + MinActiveSessions: intPtr(1), + MaxActiveSessions: intPtr(3), + }}, + } + work := []beads.Bead{{ + ID: "out-of-range-routed", + Status: "in_progress", + Assignee: "worker-dead", + Metadata: map[string]string{ + "gc.routed_to": "worker-99", + }, + }} + + got := filterAssignedWorkBeadsForPoolDemand(cfg, "", nil, work, []string{""}) + + if len(got) != 0 { + t.Fatalf("filtered work = %#v, want out-of-range instance suffix left unmatched and dropped", got) + } +} + func TestFilterAssignedWorkBeadsForPoolDemandDropsDirectAssigneeFromUnreachableStore(t *testing.T) { cityPath := t.TempDir() rigPath := filepath.Join(cityPath, "riga") diff --git a/cmd/gc/bd_env.go b/cmd/gc/bd_env.go index 4fc04bcbda..68b68a7afe 100644 --- a/cmd/gc/bd_env.go +++ b/cmd/gc/bd_env.go @@ -241,13 +241,20 @@ func issuePrefixForScope(scopeRoot, cityPath string, cfg *config.City) string { if cfg == nil { return "" } - scopeRoot = filepath.Clean(scopeRoot) - if filepath.Clean(cityPath) == scopeRoot { + // MERGE INTENT (v1.4.0 resync): normalize both sides of the comparison the + // same way. resolveStoreScopeRoot now resolves symlinks (upstream added it so + // a city reached through a linked path yields the same scope root), while this + // fork-only function still cleaned without resolving. On macOS t.TempDir() + // returns /var/... which is a symlink to /private/var/..., so the resolved rig + // path never matched the unresolved scope root and every configured prefix + // silently fell through to "". + scopeRoot = normalizeStoreScopeRoot(scopeRoot) + if normalizeStoreScopeRoot(cityPath) == scopeRoot { return config.EffectiveHQPrefix(cfg) } for i := range cfg.Rigs { rigPath := resolveStoreScopeRoot(cityPath, cfg.Rigs[i].Path) - if filepath.Clean(rigPath) == scopeRoot { + if normalizeStoreScopeRoot(rigPath) == scopeRoot { return cfg.Rigs[i].EffectivePrefix() } } diff --git a/cmd/gc/bead_format.go b/cmd/gc/bead_format.go index 0bd99934da..66145e144e 100644 --- a/cmd/gc/bead_format.go +++ b/cmd/gc/bead_format.go @@ -10,9 +10,11 @@ import ( "github.com/gastownhall/gascity/internal/beads" ) -// parseBeadFormat extracts --format/--json flags from raw args (needed because -// DisableFlagParsing is true). Returns the format ("text", "json", or "toon") -// and the remaining positional args with the flag removed. +// parseBeadFormat extracts --format/--json flags from raw args. It backs the +// fake `bd` binary used by the testscript harness (bd_testscript_test.go); the +// gc `beads` commands themselves parse these flags through cobra. Returns the +// format ("text", "json", or "toon") and the remaining positional args with the +// flag removed. func parseBeadFormat(args []string) (string, []string) { format := "text" var rest []string @@ -39,8 +41,10 @@ type beadFilters struct { all bool } -// parseBeadFilters extracts --label=X and --status=X from args, returning -// the filters and the remaining args with those flags removed. +// parseBeadFilters extracts --label, --status, and --all from args, returning +// the filters and the remaining args with those flags removed. Like +// parseBeadFormat it backs the testscript fake-bd harness; the gc `beads list` +// command parses these flags through cobra. func parseBeadFilters(args []string) (beadFilters, []string) { var f beadFilters var rest []string diff --git a/cmd/gc/bead_worktree_liveness.go b/cmd/gc/bead_worktree_liveness.go new file mode 100644 index 0000000000..165a35342e --- /dev/null +++ b/cmd/gc/bead_worktree_liveness.go @@ -0,0 +1,185 @@ +package main + +import ( + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/gastownhall/gascity/internal/pathutil" + sessionpkg "github.com/gastownhall/gascity/internal/session" +) + +// A per-bead worktree is protected from reaping when a process is actively +// working inside it, even though its bead is closed and its tree is momentarily +// git-clean. This is the "closed-bead != end-of-use" guard: bead status and +// git-cleanliness say nothing about whether an agent is mid-stage in the tree +// right now (a source anchor closes at plan-delivery while later review stages +// keep committing in the same tree; a tree is transiently clean between a push +// and the next stage). Deleting such a tree destroys live work — the founding +// incident behind gastownhall/gascity#4492. +// +// The design is modeled on the dolt_cleanup fail-closed precedent +// (dolt_cleanup_discovery.go): the /proc//cwd signal is authoritative, and +// when it cannot be gathered the caller must protect every worktree rather than +// risk deleting live work. + +// liveWorktreeState captures the working directories of every live process the +// reaper could observe on this host, plus whether the enumeration itself +// succeeded. +type liveWorktreeState struct { + // cwds is the set of canonicalized (symlink-resolved, absolute) working + // directories of live processes. Deduplicated. + cwds []string + // scanned reports whether the process table was enumerated at all. False + // means liveness is indeterminate — the host has no /proc, or the + // top-level walk failed — and the reaper must fail closed by protecting + // every candidate worktree. + scanned bool +} + +// collectLiveWorktreeStateFn is the seam the reaper calls to gather live +// process cwds. Indirected through a package-level var so tests can inject a +// deterministic set (including the fail-closed scanned=false case) without +// standing up real processes. +var collectLiveWorktreeStateFn = collectLiveWorktreeState + +// collectLiveWorktreeState walks /proc//cwd for every process on the host +// and records their canonical working directories. On a host without /proc (or +// when the top-level /proc walk fails outright) it returns scanned=false so the +// caller fails closed and reaps nothing. +// +// Per-process readlink failures are skipped, not fatal: a process may exit +// mid-walk, and a process owned by another user may have a cwd this process +// cannot resolve. The fleet runs every agent as the same user, so agent +// worktree cwds are always visible here; the active-session-directory +// cross-check plus the git-clean and closed-bead gates back-stop any process +// this scan cannot see. This matches the dolt reaper's posture: the /proc +// signal protects, it never authorizes a deletion the other gates would refuse. +func collectLiveWorktreeState() liveWorktreeState { + entries, err := os.ReadDir("/proc") + if err != nil { + return liveWorktreeState{scanned: false} + } + seen := make(map[string]struct{}) + var cwds []string + for _, entry := range entries { + if !entry.IsDir() { + continue + } + if _, err := strconv.Atoi(entry.Name()); err != nil { + continue // not a PID directory + } + link, err := os.Readlink(filepath.Join("/proc", entry.Name(), "cwd")) + if err != nil || link == "" { + continue + } + // A cwd whose inode has been unlinked carries a trailing " (deleted)" + // marker. The directory is gone, so it can never match a live worktree + // path on disk — drop it rather than canonicalize a bogus path. (The + // rare live directory literally named "... (deleted)" would be dropped + // too; that only ever loses protection for a pathological path the + // fleet never creates, and the git-clean gate still applies.) + if strings.HasSuffix(link, " (deleted)") { + continue + } + canon := pathutil.NormalizePathForCompare(link) + if canon == "" { + continue + } + if _, ok := seen[canon]; ok { + continue + } + seen[canon] = struct{}{} + cwds = append(cwds, canon) + } + return liveWorktreeState{cwds: cwds, scanned: true} +} + +// worktreeIsLive reports whether any live signal sits at or beneath +// worktreePath: a live process cwd, or a recorded active-session working +// directory. "At or beneath" means the worktree is protected when a process is +// running in it OR in any subdirectory of it (an agent whose cwd is a nested +// test/build subdir of its assigned tree still counts). It returns the matching +// path as a human-readable reason for dry-run/operator output. +// +// The caller is responsible for the fail-closed case: worktreeIsLive assumes +// the live set was successfully gathered. When liveWorktreeState.scanned is +// false the caller must protect unconditionally and never reach this function +// for a reap decision. +func worktreeIsLive(worktreePath string, live liveWorktreeState, sessionDirs []string) (bool, string) { + wt := pathutil.NormalizePathForCompare(worktreePath) + if wt == "" { + return false, "" + } + for _, cwd := range live.cwds { + if pathAtOrUnder(wt, cwd) { + return true, "live process cwd " + cwd + } + } + for _, dir := range sessionDirs { + d := pathutil.NormalizePathForCompare(dir) + if d == "" { + continue + } + if pathAtOrUnder(wt, d) { + return true, "active session dir " + d + } + } + return false, "" +} + +// pathAtOrUnder reports whether candidate equals root or is lexically contained +// beneath it. Both arguments must already be normalized (symlink-resolved, +// absolute, cleaned) — collectLiveWorktreeState normalizes cwds once at +// gather-time and worktreeIsLive normalizes the worktree once, so this avoids +// re-resolving symlinks on every pair in what can be a large process × worktree +// cross-product each tick. +func pathAtOrUnder(root, candidate string) bool { + if root == "" || candidate == "" { + return false + } + if root == candidate { + return true + } + rel, err := filepath.Rel(root, candidate) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +// liveSessionWorktreeDirs collects the recorded working directories of every +// open (non-closed) session in the snapshot: the canonical worker_dir first +// (via WorkerDirFromInfo), plus the raw work_dir and gc.work_dir mirrors so a +// session whose canonical dir is momentarily unstamped still contributes a +// protecting path. The result is the "active session set" the reaper +// cross-checks against — a belt-and-suspenders signal alongside the +// authoritative /proc cwd scan, since session metadata is stamped at +// create/dispatch and is not continuously refreshed. Deduplicated; empty +// entries dropped. +func liveSessionWorktreeDirs(snapshot *sessionBeadSnapshot) []string { + if snapshot == nil { + return nil + } + seen := make(map[string]struct{}) + var dirs []string + add := func(p string) { + p = strings.TrimSpace(p) + if p == "" || !filepath.IsAbs(p) { + return + } + if _, ok := seen[p]; ok { + return + } + seen[p] = struct{}{} + dirs = append(dirs, p) + } + for _, info := range snapshot.OpenInfos() { + add(sessionpkg.WorkerDirFromInfo(info)) + add(info.WorkDir) + add(info.WorkDirCanonical) + add(info.WorkerDir) + } + return dirs +} diff --git a/cmd/gc/bead_worktree_liveness_test.go b/cmd/gc/bead_worktree_liveness_test.go new file mode 100644 index 0000000000..7e9a75ab48 --- /dev/null +++ b/cmd/gc/bead_worktree_liveness_test.go @@ -0,0 +1,137 @@ +package main + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/gastownhall/gascity/internal/pathutil" + sessionpkg "github.com/gastownhall/gascity/internal/session" +) + +func TestWorktreeIsLive_ProcessCWDEqualsWorktree(t *testing.T) { + wt := t.TempDir() + live := liveWorktreeState{scanned: true, cwds: []string{pathutil.NormalizePathForCompare(wt)}} + got, reason := worktreeIsLive(wt, live, nil) + if !got { + t.Fatalf("worktreeIsLive = false, want true when a process cwd equals the worktree (reason %q)", reason) + } +} + +func TestWorktreeIsLive_ProcessCWDUnderWorktree(t *testing.T) { + wt := t.TempDir() + nested := filepath.Join(wt, "test", "integration") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatalf("mkdir nested: %v", err) + } + live := liveWorktreeState{scanned: true, cwds: []string{pathutil.NormalizePathForCompare(nested)}} + got, reason := worktreeIsLive(wt, live, nil) + if !got { + t.Fatalf("worktreeIsLive = false, want true when a process cwd is a nested subdir (reason %q)", reason) + } +} + +func TestWorktreeIsLive_ProcessCWDAboveWorktreeIsNotLive(t *testing.T) { + parent := t.TempDir() + wt := filepath.Join(parent, "wt") + if err := os.MkdirAll(wt, 0o755); err != nil { + t.Fatalf("mkdir wt: %v", err) + } + // A process sitting in the PARENT of the worktree is not "working in" it. + live := liveWorktreeState{scanned: true, cwds: []string{pathutil.NormalizePathForCompare(parent)}} + if got, _ := worktreeIsLive(wt, live, nil); got { + t.Fatal("worktreeIsLive = true, want false when the only live cwd is an ancestor of the worktree") + } +} + +func TestWorktreeIsLive_SiblingCWDIsNotLive(t *testing.T) { + base := t.TempDir() + wt := filepath.Join(base, "wt") + sibling := filepath.Join(base, "other") + for _, d := range []string{wt, sibling} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", d, err) + } + } + live := liveWorktreeState{scanned: true, cwds: []string{pathutil.NormalizePathForCompare(sibling)}} + if got, _ := worktreeIsLive(wt, live, nil); got { + t.Fatal("worktreeIsLive = true, want false for a sibling directory cwd") + } +} + +func TestWorktreeIsLive_SessionDirProtects(t *testing.T) { + wt := t.TempDir() + live := liveWorktreeState{scanned: true} // no live process cwds + got, reason := worktreeIsLive(wt, live, []string{wt}) + if !got { + t.Fatalf("worktreeIsLive = false, want true when an active session dir equals the worktree (reason %q)", reason) + } +} + +func TestWorktreeIsLive_NothingMatches(t *testing.T) { + wt := t.TempDir() + other := t.TempDir() + live := liveWorktreeState{scanned: true, cwds: []string{pathutil.NormalizePathForCompare(other)}} + if got, _ := worktreeIsLive(wt, live, []string{other}); got { + t.Fatal("worktreeIsLive = true, want false when no live signal is at or under the worktree") + } +} + +func TestCollectLiveWorktreeState_IncludesOwnCWD(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skipf("collectLiveWorktreeState relies on /proc; GOOS=%s has none", runtime.GOOS) + } + live := collectLiveWorktreeState() + if !live.scanned { + t.Fatal("collectLiveWorktreeState scanned = false on linux, want true") + } + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd: %v", err) + } + want := pathutil.NormalizePathForCompare(cwd) + for _, c := range live.cwds { + if c == want { + return // found this process's own cwd in the live set + } + } + t.Fatalf("collectLiveWorktreeState did not include this process's cwd %q in %d entries", want, len(live.cwds)) +} + +func TestLiveSessionWorktreeDirs_CollectsAndDedups(t *testing.T) { + abs1 := t.TempDir() + abs2 := t.TempDir() + snapshot := newSessionBeadSnapshotFromInfos([]sessionpkg.Info{ + {ID: "s1", WorkerDir: abs1}, + {ID: "s2", WorkDir: abs2}, + {ID: "s3", WorkerDir: abs1}, // duplicate of s1 → deduped + {ID: "s4", WorkDir: "relative/path"}, // non-absolute → dropped + {ID: "s5"}, // empty → dropped + }) + got := liveSessionWorktreeDirs(snapshot) + + want := map[string]bool{ + pathutil.NormalizePathForCompare(abs1): false, + pathutil.NormalizePathForCompare(abs2): false, + } + for _, d := range got { + nd := pathutil.NormalizePathForCompare(d) + if _, ok := want[nd]; !ok { + t.Errorf("unexpected dir %q (normalized %q)", d, nd) + continue + } + want[nd] = true + } + for d, seen := range want { + if !seen { + t.Errorf("expected dir %q missing from result %v", d, got) + } + } +} + +func TestLiveSessionWorktreeDirs_NilSnapshot(t *testing.T) { + if got := liveSessionWorktreeDirs(nil); got != nil { + t.Fatalf("liveSessionWorktreeDirs(nil) = %v, want nil", got) + } +} diff --git a/cmd/gc/bead_worktree_reaper.go b/cmd/gc/bead_worktree_reaper.go index 5990640116..c8837ff3c7 100644 --- a/cmd/gc/bead_worktree_reaper.go +++ b/cmd/gc/bead_worktree_reaper.go @@ -7,26 +7,87 @@ import ( "os" "path/filepath" "strings" + "time" + "github.com/gastownhall/gascity/internal/beadmeta" "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/config" + convoycore "github.com/gastownhall/gascity/internal/convoy" "github.com/gastownhall/gascity/internal/events" "github.com/gastownhall/gascity/internal/git" + "github.com/gastownhall/gascity/internal/pathutil" "github.com/gastownhall/gascity/internal/sling" ) -// reapClosedBeadWorktrees scans per-bead git worktrees under -// cityPath/.gc/worktrees// and removes any that are associated with a -// closed bead and pass all safety gates (no uncommitted work, no unpushed -// commits, no stashes). Named session home directories are never removed. -// Returns the number of worktrees successfully removed. +// reapDecision records one worktree the reaper acted on or declined to act on, +// for the dry-run report and event stream. +type reapDecision struct { + BeadID string + Path string + Rig string + Branch string + // Reason explains a protected decision (why the worktree was left in + // place). Empty for a reap/would-reap decision. + Reason string +} + +// reapReport is the outcome of one reapClosedBeadWorktrees pass. Reaped holds +// the worktrees removed (or, in dry-run, the ones that would be removed); +// Protected holds worktrees left in place with the reason (too young/quarantined, +// referenced by a non-terminal bead in another molecule, live process, active +// session, unsafe git state, or an indeterminate age/liveness/borrow-veto scan). +type reapReport struct { + Reaped []reapDecision + Protected []reapDecision + DryRun bool +} + +// reapClosedBeadWorktrees discovers per-bead git worktrees under +// cityPath/.gc/worktrees// and removes any whose associated bead is closed +// and that pass every safety gate. It returns a reapReport describing what was +// reaped and what was protected. +// +// Discovery is authoritative at any nesting depth: for each rig it runs +// `git worktree list --porcelain` from the rig's own repository (the repo that +// owns these worktrees, per worktree-setup.sh's `git -C worktree add`), +// rather than a single-level directory scan. Per-bead worktrees are nested +// under agent-home directories (depth-2, sometimes deeper); the old +// os.ReadDir(.gc/worktrees//) scan saw only the agent homes and reaped +// nothing (gastownhall/gascity#4492 root cause A). +// +// Safety gates, in order, all fail closed toward keeping the worktree: +// 1. Named agent-home directories are never removed. +// 2. The bead named by the worktree must exist and be closed. +// 3. Freshness quarantine: a worktree younger than +// cfg.Daemon.AutoReapClosedBeadWorktreesMinAge is exempt, protecting +// against the race between worktree creation and its owning bead's +// work-dir metadata being stamped by the next reconcile pass. An +// indeterminate age (the ".git" pointer file cannot be stat'd) protects. +// 4. Borrow-veto scan: batched once per rig per tick, this finds any +// non-terminal bead — in any molecule — whose gc.work_dir/work_dir +// metadata still points at the worktree's path and protects it if so. +// A query error protects every remaining candidate in that rig's tick. +// 5. Liveness: no live process cwd and no active-session working directory may +// sit at or beneath the worktree. If the liveness scan is indeterminate +// (no /proc), NOTHING is reaped this pass — the reaper cannot prove any +// tree is idle (root cause B: closed-bead != end-of-use). +// 6. Git state: no uncommitted changes, no unpushed commits, no stashes. +// +// When dryRun is true the reaper performs all discovery and classification and +// emits bead.worktree.reap_skipped events describing what it would reap and +// what it protected, but removes nothing. liveSessionDirs is the active-session +// working-directory set the liveness gate cross-checks against, alongside the +// authoritative /proc cwd scan. func reapClosedBeadWorktrees( cityPath string, cfg *config.City, rigBeadStores map[string]beads.Store, + liveSessionDirs []string, + dryRun bool, rec events.Recorder, stderr io.Writer, -) int { +) reapReport { + report := reapReport{DryRun: dryRun} if stderr == nil { stderr = io.Discard } @@ -34,7 +95,7 @@ func reapClosedBeadWorktrees( rec = events.Discard } if cfg == nil || len(rigBeadStores) == 0 { - return 0 + return report } // Build a guard set of session home names so agent template directories @@ -46,34 +107,60 @@ func reapClosedBeadWorktrees( } } + // Authoritative liveness signal, gathered once for the whole pass. When the + // scan is indeterminate the reaper protects every candidate (fail closed). + live := collectLiveWorktreeStateFn() + wtRoot := filepath.Join(cityPath, ".gc", "worktrees") - reaped := 0 for rigName, store := range rigBeadStores { if store == nil { continue } + rigRoot := rigRootByName(cfg, rigName) + if rigRoot == "" { + // No configured filesystem path for this rig — cannot resolve the + // owning repository, so we cannot safely enumerate or remove. + continue + } rigWorktreeDir := filepath.Join(wtRoot, rigName) - entries, err := os.ReadDir(rigWorktreeDir) + + worktrees, err := git.New(rigRoot).WorktreeList() if err != nil { - if !os.IsNotExist(err) { - fmt.Fprintf(stderr, "reapClosedBeadWorktrees: reading %s: %v\n", rigWorktreeDir, err) //nolint:errcheck - } + fmt.Fprintf(stderr, "reapClosedBeadWorktrees: listing worktrees for rig %s (%s): %v\n", rigName, rigRoot, err) //nolint:errcheck continue } - for _, entry := range entries { - if !entry.IsDir() { + + // Pass 1: discover reap-eligible candidates — closed bead, and old + // enough to be past the freshness quarantine (FR-5). Every other gate + // (borrow-veto, liveness, git safety) is deferred to pass 2 so the + // borrow-veto scan below can run as a single batched query per rig + // (FR-3) instead of once per worktree. + var candidates []reapCandidate + for _, wt := range worktrees { + worktreePath := wt.Path + + // Only per-bead worktrees under this rig's .gc/worktrees// + // subtree are in scope. This excludes the rig's main working tree + // and any worktree checked out elsewhere. + if !pathutil.PathWithin(rigWorktreeDir, worktreePath) || pathutil.SamePath(rigWorktreeDir, worktreePath) { continue } - name := entry.Name() + // Defense in depth: never act on a path that is not strictly under + // the city worktree root. + if !isStrictlyUnderDir(wtRoot, worktreePath) { + continue + } + + base := filepath.Base(worktreePath) // Session home guard: never touch agent template directories. - if sessionHomes[name] { + if sessionHomes[base] { continue } - // Extract a bead ID candidate from the directory name. - beadID := extractBeadIDFromWorktreeName(cfg, name) + // Extract a bead ID candidate from the worktree's leaf name. + beadID := extractBeadIDFromWorktreeName(cfg, base) if beadID == "" { continue } @@ -85,49 +172,131 @@ func reapClosedBeadWorktrees( continue } - worktreePath := filepath.Join(rigWorktreeDir, name) - - // Scope gate: only act on paths strictly under the worktree root. - if !isStrictlyUnderDir(wtRoot, worktreePath) { + // Freshness quarantine (FR-5): a worktree younger than the + // configured minimum age is exempt from reaping, protecting + // against the race between worktree creation and its owning + // bead's work-dir metadata being stamped by the next reconcile + // pass. Age is fail-closed — an indeterminate age protects. + minAge := cfg.Daemon.AutoReapClosedBeadWorktreesMinAge() + age, ok := computeWorktreeAge(worktreePath) + reason := "" + switch { + case !ok: + reason = "worktree age indeterminate (failing closed)" + case minAge > 0 && age < minAge: + reason = fmt.Sprintf("worktree too young to reap (quarantine): age=%s min_age=%s", age.Round(time.Second), minAge) + } + if reason != "" { + branch, _ := git.New(worktreePath).CurrentBranch() + fmt.Fprintf(stderr, //nolint:errcheck + "reapClosedBeadWorktrees: protecting %s (bead %s closed but %s)\n", + worktreePath, beadID, reason, + ) + recordReapSkipped(rec, beadID, worktreePath, rigName, reason) + report.Protected = append(report.Protected, reapDecision{ + BeadID: beadID, Path: worktreePath, Rig: rigName, Branch: branch, Reason: reason, + }) continue } - // Safety checks: run from the worktree directory so git status - // and stash list apply to the worktree's branch. - wg := git.New(worktreePath) - hasUncommitted := wg.HasUncommittedWork() - hasUnpushed, _ := wg.HasUnpushedCommitsResult() - hasStashes, _ := wg.HasStashesResult() + candidates = append(candidates, reapCandidate{beadID: beadID, worktreePath: worktreePath}) + } + + if len(candidates) == 0 { + continue + } - if hasUncommitted || hasUnpushed || hasStashes { - reason := fmt.Sprintf("uncommitted=%v unpushed=%v stashes=%v", hasUncommitted, hasUnpushed, hasStashes) + // Borrow-veto scan (FR-1/FR-2/FR-3): one batched query for every + // surviving candidate in this rig instead of one query per candidate. + // A query error fails closed — every remaining candidate in this + // rig's tick is protected (NFR-1). + referencingBeads, listErr := scanBorrowVetoReferences(store, candidates) + if listErr != nil { + reason := fmt.Sprintf("borrow-veto scan failed (failing closed): %v", listErr) + for _, c := range candidates { + branch, _ := git.New(c.worktreePath).CurrentBranch() fmt.Fprintf(stderr, //nolint:errcheck - "reapClosedBeadWorktrees: skipping %s (bead %s closed but unsafe: %s)\n", - worktreePath, beadID, reason, + "reapClosedBeadWorktrees: protecting %s (bead %s closed but %s)\n", + c.worktreePath, c.beadID, reason, ) - if raw, err := json.Marshal(events.BeadWorktreeReapSkippedPayload{ - BeadID: beadID, - Path: worktreePath, - Rig: rigName, - Reason: reason, - }); err == nil { - rec.Record(events.Event{ - Type: events.BeadWorktreeReapSkipped, - Actor: "gc", - Subject: beadID, - Payload: raw, - }) + recordReapSkipped(rec, c.beadID, c.worktreePath, rigName, reason) + report.Protected = append(report.Protected, reapDecision{ + BeadID: c.beadID, Path: c.worktreePath, Rig: rigName, Branch: branch, Reason: reason, + }) + } + continue + } + + // Pass 2: apply the borrow-veto verdict, then the existing + // liveness/git-safety gates, to each surviving candidate. + for _, c := range candidates { + worktreePath := c.worktreePath + beadID := c.beadID + + // Borrow-veto (FR-1/FR-2/FR-7): protect when any non-terminal + // bead — regardless of molecule — still references this path via + // work-dir metadata. + reason := "" + if refs := referencingBeads[worktreePath]; len(refs) > 0 { + reason = fmt.Sprintf("borrow-veto: referenced by non-terminal bead(s) %s", strings.Join(refs, ", ")) + } + + // Liveness gate (fail closed). Protect the tree when a live process + // or active session is working in it, or when liveness could not be + // determined at all. + if reason == "" { + switch { + case !live.scanned: + reason = "liveness scan unavailable (failing closed, protecting all)" + default: + if isLive, why := worktreeIsLive(worktreePath, live, liveSessionDirs); isLive { + reason = "live: " + why + } } + } + + // Git safety gates, only if not already protected. + if reason == "" { + wg := git.New(worktreePath) + hasUncommitted := wg.HasUncommittedWork() + hasUnpushed, _ := wg.HasUnpushedCommitsResult() + hasStashes, _ := wg.HasStashesResult() + if hasUncommitted || hasUnpushed || hasStashes { + reason = fmt.Sprintf("unsafe git state: uncommitted=%v unpushed=%v stashes=%v", hasUncommitted, hasUnpushed, hasStashes) + } + } + + branch, _ := git.New(worktreePath).CurrentBranch() + + if reason != "" { + fmt.Fprintf(stderr, //nolint:errcheck + "reapClosedBeadWorktrees: protecting %s (bead %s closed but %s)\n", + worktreePath, beadID, reason, + ) + recordReapSkipped(rec, beadID, worktreePath, rigName, reason) + report.Protected = append(report.Protected, reapDecision{ + BeadID: beadID, Path: worktreePath, Rig: rigName, Branch: branch, Reason: reason, + }) continue } - // Capture branch before removal — the worktree dir will be gone after. - branch, _ := wg.CurrentBranch() + if dryRun { + const whatIf = "dry-run: would reap (closed bead, clean tree, no live process)" + fmt.Fprintf(stderr, //nolint:errcheck + "reapClosedBeadWorktrees: %s: %s for closed bead %s\n", + whatIf, worktreePath, beadID, + ) + recordReapSkipped(rec, beadID, worktreePath, rigName, whatIf) + report.Reaped = append(report.Reaped, reapDecision{ + BeadID: beadID, Path: worktreePath, Rig: rigName, Branch: branch, + }) + continue + } - // Remove the worktree. git worktree remove must be run from the - // main repo root, not from within the worktree being removed. - mainRepo := git.New(cityPath) - if err := mainRepo.WorktreeRemove(worktreePath, false); err != nil { + // Remove the worktree from the OWNING rig repository. git worktree + // remove must be run from the main repo root, not from within the + // worktree being removed. + if err := git.New(rigRoot).WorktreeRemove(worktreePath, false); err != nil { fmt.Fprintf(stderr, "reapClosedBeadWorktrees: removing %s: %v\n", worktreePath, err) //nolint:errcheck continue } @@ -148,10 +317,110 @@ func reapClosedBeadWorktrees( Payload: raw, }) } - reaped++ + report.Reaped = append(report.Reaped, reapDecision{ + BeadID: beadID, Path: worktreePath, Rig: rigName, Branch: branch, + }) } } - return reaped + return report +} + +// reapCandidate is a worktree that survived the closed-bead check and the +// freshness quarantine in pass 1, awaiting the batched borrow-veto scan and +// the remaining safety gates in pass 2. +type reapCandidate struct { + beadID string + worktreePath string +} + +// computeWorktreeAge returns how long ago worktreePath was created, using the +// mtime of its ".git" pointer file (written once by `git worktree add` and not +// rewritten during normal use) as a creation-time proxy. Worktree structs carry +// no timestamp of their own. ok is false when the file cannot be stat'd, so the +// caller can fail closed instead of treating an indeterminate age as zero. +func computeWorktreeAge(worktreePath string) (age time.Duration, ok bool) { + info, err := os.Stat(filepath.Join(worktreePath, ".git")) + if err != nil { + return 0, false + } + return time.Since(info.ModTime()), true +} + +// scanBorrowVetoReferences issues one batched beads.Store.List query and +// returns, for each candidate's worktree path, the IDs of any non-terminal +// beads — in any molecule — whose gc.work_dir or legacy work_dir metadata +// still points at that path (FR-1/FR-2/FR-3). Terminal status is decided by +// convoycore.IsTerminalStatus, not a bare "!= closed" check, so a tombstoned +// reference does not veto. Path matching is symlink/alias-normalized on both +// sides via pathutil.NormalizePathForCompare, matching the liveness gate, so a +// metadata path recorded in a different-but-equivalent form still vetoes. +// A query error is returned as-is; the caller must fail closed and protect +// every candidate in the rig (NFR-1). +func scanBorrowVetoReferences(store beads.Store, candidates []reapCandidate) (map[string][]string, error) { + // The query excludes closed beads at the store level (IsTerminalStatus + // would discard them anyway) and skips label hydration this scan never + // reads. TierBoth is explicit so the reaper's safety contract does not + // depend on a wrapping store expanding the default tier for it. + all, err := store.List(beads.ListQuery{AllowScan: true, SkipLabels: true, TierMode: beads.TierBoth}) + if err != nil { + return nil, err + } + byNorm := make(map[string]string, len(candidates)) // normalized -> raw candidate path + for _, c := range candidates { + byNorm[pathutil.NormalizePathForCompare(c.worktreePath)] = c.worktreePath + } + refs := make(map[string][]string) + for _, b := range all { + if convoycore.IsTerminalStatus(b.Status) { + continue + } + for _, key := range [...]string{beadmeta.WorkDirMetadataKey, beadmeta.LegacyWorkDirMetadataKey} { + p := strings.TrimSpace(b.Metadata[key]) + if p == "" { + continue + } + if raw, hit := byNorm[pathutil.NormalizePathForCompare(p)]; hit { + refs[raw] = append(refs[raw], b.ID) + break + } + } + } + return refs, nil +} + +// recordReapSkipped emits a bead.worktree.reap_skipped event carrying the +// reason a worktree was protected or (in dry-run) flagged as would-reap. +func recordReapSkipped(rec events.Recorder, beadID, path, rig, reason string) { + raw, err := json.Marshal(events.BeadWorktreeReapSkippedPayload{ + BeadID: beadID, + Path: path, + Rig: rig, + Reason: reason, + }) + if err != nil { + return + } + rec.Record(events.Event{ + Type: events.BeadWorktreeReapSkipped, + Actor: "gc", + Subject: beadID, + Payload: raw, + }) +} + +// rigRootByName returns the configured filesystem path of the rig with the +// given name, or "" when the rig is unknown or has no path. This is the +// repository that owns the rig's per-bead worktrees. +func rigRootByName(cfg *config.City, rigName string) string { + if cfg == nil { + return "" + } + for i := range cfg.Rigs { + if cfg.Rigs[i].Name == rigName { + return strings.TrimSpace(cfg.Rigs[i].Path) + } + } + return "" } // extractBeadIDFromWorktreeName scans consecutive dash-separated segment pairs diff --git a/cmd/gc/bead_worktree_reaper_borrow_veto_test.go b/cmd/gc/bead_worktree_reaper_borrow_veto_test.go new file mode 100644 index 0000000000..e1288ae6c6 --- /dev/null +++ b/cmd/gc/bead_worktree_reaper_borrow_veto_test.go @@ -0,0 +1,268 @@ +package main + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/gastownhall/gascity/internal/beadmeta" + "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/events" +) + +// reapBorrowVetoCountingStore wraps a beads.Store and records every List call, +// so tests can assert the borrow-veto scan issues one batched query per rig per +// tick (FR-3) rather than one query per candidate, and can pin the shape of +// that query. +type reapBorrowVetoCountingStore struct { + beads.Store + calls int + queries []beads.ListQuery +} + +func (s *reapBorrowVetoCountingStore) List(q beads.ListQuery) ([]beads.Bead, error) { + s.calls++ + s.queries = append(s.queries, q) + return s.Store.List(q) +} + +// reapBorrowVetoErrorStore wraps a beads.Store and makes every List call +// fail, so tests can assert the borrow-veto scan fails closed (NFR-1) on a +// query error. +type reapBorrowVetoErrorStore struct { + beads.Store + err error +} + +func (s *reapBorrowVetoErrorStore) List(beads.ListQuery) ([]beads.Bead, error) { + return nil, s.err +} + +// TestReapClosedBeadWorktrees_ProtectsViaCrossMoleculeBorrowVeto is the +// canonical FR-1/FR-2 test: a worktree's nominally-owning bead is closed, but +// an unrelated bead in a different molecule still carries gc.work_dir +// metadata pointing at the same path and is not terminal. The worktree must +// be protected, and per FR-7 the reason must name the referencing bead. +func TestReapClosedBeadWorktrees_ProtectsViaCrossMoleculeBorrowVeto(t *testing.T) { + cityPath, rigRoot := initReapRig(t) + wt := addClosedWorktree(t, rigRoot, cityPath, "builder", "ga-owner01") + store := beads.NewMemStoreFrom(1, []beads.Bead{ + {ID: "ga-owner01", Status: "closed"}, + {ID: "ga-other02", Status: "open", Metadata: map[string]string{beadmeta.WorkDirMetadataKey: wt}}, + }, nil) + cfg := reapTestConfig(rigRoot) + injectLiveness(t, liveWorktreeState{scanned: true}) + + var stderr bytes.Buffer + report := reapClosedBeadWorktrees(cityPath, cfg, map[string]beads.Store{"mrig": store}, nil, false, events.Discard, &stderr) + + if len(report.Reaped) != 0 { + t.Fatalf("Reaped = %+v, want 0 when an unrelated open bead still references the path\nstderr:\n%s", report.Reaped, stderr.String()) + } + if len(report.Protected) != 1 { + t.Fatalf("Protected = %+v, want exactly 1 borrow-veto entry", report.Protected) + } + if !strings.Contains(report.Protected[0].Reason, "ga-other02") { + t.Errorf("Reason = %q, want it to name the referencing bead ga-other02", report.Protected[0].Reason) + } + if _, err := os.Stat(wt); err != nil { + t.Fatalf("borrow-veto-protected worktree %s was removed: %v", wt, err) + } +} + +// TestReapClosedBeadWorktrees_ProtectsViaNonCanonicalWorkDirPath is the +// canonical test that the veto compares paths by normalized form, not raw +// string equality: the referencing bead's gc.work_dir holds an uncleaned +// spelling of the very path git reports for the worktree. Raw == would miss +// it and reap a still-borrowed tree — a fail-open on the gate this PR adds. +func TestReapClosedBeadWorktrees_ProtectsViaNonCanonicalWorkDirPath(t *testing.T) { + cityPath, rigRoot := initReapRig(t) + wt := addClosedWorktree(t, rigRoot, cityPath, "builder", "ga-owner07") + // String concat, not filepath.Join: Join would Clean the "/./" away and + // hand the store the same canonical form git reports, defeating the test. + uncleaned := wt + "/./" + if uncleaned == wt { + t.Fatalf("test setup: %q is not a distinct spelling of %q", uncleaned, wt) + } + store := beads.NewMemStoreFrom(1, []beads.Bead{ + {ID: "ga-owner07", Status: "closed"}, + {ID: "ga-other08", Status: "open", Metadata: map[string]string{beadmeta.WorkDirMetadataKey: uncleaned}}, + }, nil) + cfg := reapTestConfig(rigRoot) + injectLiveness(t, liveWorktreeState{scanned: true}) + + var stderr bytes.Buffer + report := reapClosedBeadWorktrees(cityPath, cfg, map[string]beads.Store{"mrig": store}, nil, false, events.Discard, &stderr) + + if len(report.Reaped) != 0 { + t.Fatalf("Reaped = %+v, want 0: an uncleaned gc.work_dir spelling must still veto\nstderr:\n%s", report.Reaped, stderr.String()) + } + if len(report.Protected) != 1 { + t.Fatalf("Protected = %+v, want exactly 1 borrow-veto entry", report.Protected) + } + if !strings.Contains(report.Protected[0].Reason, "ga-other08") { + t.Errorf("Reason = %q, want it to name the referencing bead ga-other08", report.Protected[0].Reason) + } + if _, err := os.Stat(wt); err != nil { + t.Fatalf("borrow-veto-protected worktree %s was removed: %v", wt, err) + } +} + +// TestReapClosedBeadWorktrees_ProtectsViaSymlinkedWorkDirPath is the +// symlink half of the same property: the referencing bead reaches the +// worktree through a symlinked ancestor, so the two paths differ in form but +// resolve to the same tree. Skipped on Windows, where symlink creation +// generally needs elevation. +func TestReapClosedBeadWorktrees_ProtectsViaSymlinkedWorkDirPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation requires elevation on Windows") + } + cityPath, rigRoot := initReapRig(t) + wt := addClosedWorktree(t, rigRoot, cityPath, "builder", "ga-owner09") + + // Symlink a fresh directory at the worktree's parent, then address the + // worktree through it. filepath.EvalSymlinks collapses the two spellings. + link := filepath.Join(t.TempDir(), "wtlink") + if err := os.Symlink(filepath.Dir(wt), link); err != nil { + t.Skipf("symlink unsupported here: %v", err) + } + viaLink := filepath.Join(link, filepath.Base(wt)) + if viaLink == wt { + t.Fatalf("test setup: %q is not a distinct spelling of %q", viaLink, wt) + } + + store := beads.NewMemStoreFrom(1, []beads.Bead{ + {ID: "ga-owner09", Status: "closed"}, + {ID: "ga-other10", Status: "open", Metadata: map[string]string{beadmeta.WorkDirMetadataKey: viaLink}}, + }, nil) + cfg := reapTestConfig(rigRoot) + injectLiveness(t, liveWorktreeState{scanned: true}) + + var stderr bytes.Buffer + report := reapClosedBeadWorktrees(cityPath, cfg, map[string]beads.Store{"mrig": store}, nil, false, events.Discard, &stderr) + + if len(report.Reaped) != 0 { + t.Fatalf("Reaped = %+v, want 0: a symlinked gc.work_dir path must still veto\nstderr:\n%s", report.Reaped, stderr.String()) + } + if len(report.Protected) != 1 || !strings.Contains(report.Protected[0].Reason, "ga-other10") { + t.Fatalf("Protected = %+v, want a borrow-veto entry naming ga-other10", report.Protected) + } + if _, err := os.Stat(wt); err != nil { + t.Fatalf("borrow-veto-protected worktree %s was removed: %v", wt, err) + } +} + +// TestReapClosedBeadWorktrees_ProtectsViaLegacyWorkDirKey proves FR-1's +// legacy-key fallback: a referencing bead using the deprecated "work_dir" key +// (instead of canonical "gc.work_dir") still vetoes the reap. +func TestReapClosedBeadWorktrees_ProtectsViaLegacyWorkDirKey(t *testing.T) { + cityPath, rigRoot := initReapRig(t) + wt := addClosedWorktree(t, rigRoot, cityPath, "builder", "ga-owner03") + store := beads.NewMemStoreFrom(1, []beads.Bead{ + {ID: "ga-owner03", Status: "closed"}, + {ID: "ga-legacy04", Status: "in_progress", Metadata: map[string]string{beadmeta.LegacyWorkDirMetadataKey: wt}}, + }, nil) + cfg := reapTestConfig(rigRoot) + injectLiveness(t, liveWorktreeState{scanned: true}) + + var stderr bytes.Buffer + report := reapClosedBeadWorktrees(cityPath, cfg, map[string]beads.Store{"mrig": store}, nil, false, events.Discard, &stderr) + + if len(report.Reaped) != 0 { + t.Fatalf("Reaped = %+v, want 0 when a legacy work_dir reference exists\nstderr:\n%s", report.Reaped, stderr.String()) + } + if len(report.Protected) != 1 || !strings.Contains(report.Protected[0].Reason, "ga-legacy04") { + t.Fatalf("Protected = %+v, want a legacy-key borrow-veto entry naming ga-legacy04", report.Protected) + } +} + +// TestReapClosedBeadWorktrees_TerminalReferenceDoesNotVeto proves FR-2 uses +// convoycore.IsTerminalStatus, not a bare "!= closed" check: a referencing +// bead in "tombstone" status is terminal and must not block the reap. +func TestReapClosedBeadWorktrees_TerminalReferenceDoesNotVeto(t *testing.T) { + cityPath, rigRoot := initReapRig(t) + wt := addClosedWorktree(t, rigRoot, cityPath, "builder", "ga-owner05") + store := beads.NewMemStoreFrom(1, []beads.Bead{ + {ID: "ga-owner05", Status: "closed"}, + {ID: "ga-tomb06", Status: "tombstone", Metadata: map[string]string{beadmeta.WorkDirMetadataKey: wt}}, + }, nil) + cfg := reapTestConfig(rigRoot) + injectLiveness(t, liveWorktreeState{scanned: true}) + + var stderr bytes.Buffer + report := reapClosedBeadWorktrees(cityPath, cfg, map[string]beads.Store{"mrig": store}, nil, false, events.Discard, &stderr) + + if len(report.Reaped) != 1 || report.Reaped[0].BeadID != "ga-owner05" { + t.Fatalf("Reaped = %+v, want ga-owner05 reaped: a tombstoned reference must not veto\nstderr:\n%s", report.Reaped, stderr.String()) + } +} + +// TestReapClosedBeadWorktrees_FailsClosedOnBorrowVetoQueryError proves NFR-1: +// a query error during the borrow-veto scan protects every candidate in that +// rig's tick, not just the one being evaluated when the error surfaced. +func TestReapClosedBeadWorktrees_FailsClosedOnBorrowVetoQueryError(t *testing.T) { + cityPath, rigRoot := initReapRig(t) + wt1 := addClosedWorktree(t, rigRoot, cityPath, "builder", "ga-errA001") + wt2 := addClosedWorktree(t, rigRoot, cityPath, "builder-2", "ga-errB002") + base := beads.NewMemStoreFrom(1, []beads.Bead{ + {ID: "ga-errA001", Status: "closed"}, + {ID: "ga-errB002", Status: "closed"}, + }, nil) + store := &reapBorrowVetoErrorStore{Store: base, err: errors.New("store unreachable")} + cfg := reapTestConfig(rigRoot) + injectLiveness(t, liveWorktreeState{scanned: true}) + + var stderr bytes.Buffer + report := reapClosedBeadWorktrees(cityPath, cfg, map[string]beads.Store{"mrig": store}, nil, false, events.Discard, &stderr) + + if len(report.Reaped) != 0 { + t.Fatalf("Reaped = %+v, want 0 when the borrow-veto query errors (fail closed)\nstderr:\n%s", report.Reaped, stderr.String()) + } + if len(report.Protected) != 2 { + t.Fatalf("Protected = %+v, want both candidates protected on query error", report.Protected) + } + for _, wt := range []string{wt1, wt2} { + if _, err := os.Stat(wt); err != nil { + t.Errorf("worktree %s was removed despite a borrow-veto query error: %v", wt, err) + } + } +} + +// TestReapClosedBeadWorktrees_BorrowVetoScanIsBatchedPerRig proves FR-3: with +// multiple reap-eligible candidates in the same rig and tick, the borrow-veto +// scan issues exactly one List call for that rig, not one per candidate. +func TestReapClosedBeadWorktrees_BorrowVetoScanIsBatchedPerRig(t *testing.T) { + cityPath, rigRoot := initReapRig(t) + addClosedWorktree(t, rigRoot, cityPath, "builder", "ga-batchA1") + addClosedWorktree(t, rigRoot, cityPath, "builder-2", "ga-batchB2") + addClosedWorktree(t, rigRoot, cityPath, "builder-3", "ga-batchC3") + base := beads.NewMemStoreFrom(1, []beads.Bead{ + {ID: "ga-batchA1", Status: "closed"}, + {ID: "ga-batchB2", Status: "closed"}, + {ID: "ga-batchC3", Status: "closed"}, + }, nil) + store := &reapBorrowVetoCountingStore{Store: base} + cfg := reapTestConfig(rigRoot) + injectLiveness(t, liveWorktreeState{scanned: true}) + + var stderr bytes.Buffer + report := reapClosedBeadWorktrees(cityPath, cfg, map[string]beads.Store{"mrig": store}, nil, false, events.Discard, &stderr) + + if len(report.Reaped) != 3 { + t.Fatalf("Reaped = %+v, want all 3 candidates reaped\nstderr:\n%s", report.Reaped, stderr.String()) + } + if store.calls != 1 { + t.Fatalf("List calls = %d, want exactly 1 batched call for 3 candidates in one rig", store.calls) + } + q := store.queries[0] + if q.TierMode != beads.TierBoth { + t.Errorf("TierMode = %v, want beads.TierBoth: the scan must see ephemeral (wisp-tier) borrowers without relying on a wrapping store to expand the default tier", q.TierMode) + } + if q.IncludeClosed { + t.Errorf("IncludeClosed = true, want false: closed beads are discarded by IsTerminalStatus anyway, so pulling each rig's full closed history every tick is pure cost") + } +} diff --git a/cmd/gc/bead_worktree_reaper_freshness_test.go b/cmd/gc/bead_worktree_reaper_freshness_test.go new file mode 100644 index 0000000000..42da129c09 --- /dev/null +++ b/cmd/gc/bead_worktree_reaper_freshness_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/events" +) + +// TestReapClosedBeadWorktrees_ProtectsFreshWorktreeUnderDefaultQuarantine +// proves FR-5: a worktree created moments ago is protected under the default +// quarantine window even though every other gate (closed bead, idle, clean +// git state) would allow reaping. +func TestReapClosedBeadWorktrees_ProtectsFreshWorktreeUnderDefaultQuarantine(t *testing.T) { + cityPath, rigRoot := initReapRig(t) + wt := addClosedWorktreeWithAge(t, rigRoot, cityPath, "builder", "ga-fresh01", 0) + store := beads.NewMemStoreFrom(1, []beads.Bead{{ID: "ga-fresh01", Status: "closed"}}, nil) + cfg := reapTestConfig(rigRoot) // no MinAgeMinutes set -> default quarantine window + injectLiveness(t, liveWorktreeState{scanned: true}) + + var stderr bytes.Buffer + report := reapClosedBeadWorktrees(cityPath, cfg, map[string]beads.Store{"mrig": store}, nil, false, events.Discard, &stderr) + + if len(report.Reaped) != 0 { + t.Fatalf("Reaped = %+v, want 0 for a fresh worktree under quarantine\nstderr:\n%s", report.Reaped, stderr.String()) + } + if len(report.Protected) != 1 { + t.Fatalf("Protected = %+v, want exactly 1 quarantine-protected entry", report.Protected) + } + reason := report.Protected[0].Reason + if !strings.Contains(reason, "young") && !strings.Contains(reason, "quarantine") && !strings.Contains(reason, "age") { + t.Errorf("Reason = %q, want it to mention freshness/quarantine/age", reason) + } + if _, err := os.Stat(wt); err != nil { + t.Fatalf("fresh worktree %s was removed or unstattable: %v", wt, err) + } +} + +// TestReapClosedBeadWorktrees_ReapsWorktreeOlderThanDefaultMinAge proves a +// worktree older than the default quarantine window is not blocked by FR-5 +// and proceeds to the rest of the gate chain (and is reaped, since every +// other gate passes). +func TestReapClosedBeadWorktrees_ReapsWorktreeOlderThanDefaultMinAge(t *testing.T) { + cityPath, rigRoot := initReapRig(t) + wt := addClosedWorktree(t, rigRoot, cityPath, "builder", "ga-old0001") // backdated 24h by the shared helper + store := beads.NewMemStoreFrom(1, []beads.Bead{{ID: "ga-old0001", Status: "closed"}}, nil) + cfg := reapTestConfig(rigRoot) + injectLiveness(t, liveWorktreeState{scanned: true}) + + var stderr bytes.Buffer + report := reapClosedBeadWorktrees(cityPath, cfg, map[string]beads.Store{"mrig": store}, nil, false, events.Discard, &stderr) + + if len(report.Reaped) != 1 || report.Reaped[0].BeadID != "ga-old0001" { + t.Fatalf("Reaped = %+v, want exactly ga-old0001\nstderr:\n%s", report.Reaped, stderr.String()) + } + if _, err := os.Stat(wt); !os.IsNotExist(err) { + t.Fatalf("worktree %s still present after reap (stat err=%v)", wt, err) + } +} + +// TestReapClosedBeadWorktrees_ZeroMinAgeDisablesQuarantine proves an explicit +// zero for AutoReapClosedBeadWorktreesMinAgeMinutes disables the freshness +// gate entirely: a just-created worktree is immediately eligible. +func TestReapClosedBeadWorktrees_ZeroMinAgeDisablesQuarantine(t *testing.T) { + cityPath, rigRoot := initReapRig(t) + wt := addClosedWorktreeWithAge(t, rigRoot, cityPath, "builder", "ga-nomin01", 0) + store := beads.NewMemStoreFrom(1, []beads.Bead{{ID: "ga-nomin01", Status: "closed"}}, nil) + cfg := reapTestConfig(rigRoot) + zero := 0 + cfg.Daemon.AutoReapClosedBeadWorktreesMinAgeMinutes = &zero + injectLiveness(t, liveWorktreeState{scanned: true}) + + var stderr bytes.Buffer + report := reapClosedBeadWorktrees(cityPath, cfg, map[string]beads.Store{"mrig": store}, nil, false, events.Discard, &stderr) + + if len(report.Reaped) != 1 || report.Reaped[0].BeadID != "ga-nomin01" { + t.Fatalf("Reaped = %+v, want exactly ga-nomin01 with quarantine disabled\nstderr:\n%s", report.Reaped, stderr.String()) + } + if _, err := os.Stat(wt); !os.IsNotExist(err) { + t.Fatalf("worktree %s still present after reap (stat err=%v)", wt, err) + } +} + +// TestReapClosedBeadWorktrees_ProtectsWhenAgeIndeterminate proves the age +// computation itself fails closed: if the worktree's .git pointer file is +// unstattable (a race with something else removing it mid-scan), the reaper +// protects rather than treating the age as zero or unlimited. +func TestReapClosedBeadWorktrees_ProtectsWhenAgeIndeterminate(t *testing.T) { + cityPath, rigRoot := initReapRig(t) + wt := addClosedWorktree(t, rigRoot, cityPath, "builder", "ga-noage01") + if err := os.Remove(filepath.Join(wt, ".git")); err != nil { + t.Fatalf("remove .git pointer file: %v", err) + } + store := beads.NewMemStoreFrom(1, []beads.Bead{{ID: "ga-noage01", Status: "closed"}}, nil) + cfg := reapTestConfig(rigRoot) + injectLiveness(t, liveWorktreeState{scanned: true}) + + var stderr bytes.Buffer + report := reapClosedBeadWorktrees(cityPath, cfg, map[string]beads.Store{"mrig": store}, nil, false, events.Discard, &stderr) + + if len(report.Reaped) != 0 { + t.Fatalf("Reaped = %+v, want 0 when worktree age is indeterminate\nstderr:\n%s", report.Reaped, stderr.String()) + } + if len(report.Protected) != 1 { + t.Fatalf("Protected = %+v, want exactly 1 fail-closed entry", report.Protected) + } +} diff --git a/cmd/gc/bead_worktree_reaper_integration_test.go b/cmd/gc/bead_worktree_reaper_integration_test.go new file mode 100644 index 0000000000..9d9ccd5d9d --- /dev/null +++ b/cmd/gc/bead_worktree_reaper_integration_test.go @@ -0,0 +1,250 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/config" + "github.com/gastownhall/gascity/internal/events" + "github.com/gastownhall/gascity/internal/pathutil" +) + +// reapTestRigName is the single rig name used across the reaper tests. It is a +// const rather than a parameter so the helpers do not carry an argument that +// every caller passes identically. +const reapTestRigName = "mrig" + +// initReapRig builds a rig git repository with an initial commit pushed to a +// bare origin, and returns the city path (whose .gc/worktrees/ tree will hold +// per-bead worktrees) and the rig repo root. Because the seed commit is on +// origin/main, worktrees branched from it have no unpushed commits and so pass +// the reaper's git-safety gate absent any liveness signal. +func initReapRig(t *testing.T) (cityPath, rigRoot string) { + t.Helper() + base := t.TempDir() + cityPath = filepath.Join(base, "city") + rigRoot = filepath.Join(base, "rig") + remote := filepath.Join(base, "remote.git") + + mustGit(t, "", "init", "--bare", remote) + mustGit(t, "", "-c", "init.defaultBranch=main", "init", rigRoot) + if err := os.WriteFile(filepath.Join(rigRoot, "README.md"), []byte("seed\n"), 0o644); err != nil { + t.Fatalf("seed file: %v", err) + } + mustGit(t, rigRoot, "add", ".") + mustGit(t, rigRoot, "-c", "commit.gpgsign=false", "commit", "-m", "init") + mustGit(t, rigRoot, "remote", "add", "origin", remote) + mustGit(t, rigRoot, "push", "-u", "origin", "main") + return cityPath, rigRoot +} + +// addClosedWorktree adds a per-bead worktree nested under an agent-home +// directory (depth-2: .gc/worktrees///), matching the +// real do-work layout the reaper must now discover. It branches from HEAD (on +// origin/main), so the tree is clean with no unpushed commits. The worktree's +// creation time is backdated well past the freshness-quarantine default (FR-5) +// so existing callers exercise the liveness/git-safety/borrow-veto gates +// without incidentally tripping quarantine; tests of the quarantine gate +// itself use addClosedWorktreeWithAge directly. +func addClosedWorktree(t *testing.T, rigRoot, cityPath, agentHome, beadID string) string { + t.Helper() + return addClosedWorktreeWithAge(t, rigRoot, cityPath, agentHome, beadID, 24*time.Hour) +} + +// addClosedWorktreeWithAge is addClosedWorktree with an explicit backdated +// age for the worktree's on-disk creation signal, letting freshness-gate +// tests place a worktree on either side of the quarantine boundary. age == 0 +// leaves the real (just-created) mtime in place. +func addClosedWorktreeWithAge(t *testing.T, rigRoot, cityPath, agentHome, beadID string, age time.Duration) string { + t.Helper() + wtPath := filepath.Join(cityPath, ".gc", "worktrees", reapTestRigName, agentHome, beadID) + if err := os.MkdirAll(filepath.Dir(wtPath), 0o755); err != nil { + t.Fatalf("mkdir worktree parent: %v", err) + } + mustGit(t, rigRoot, "worktree", "add", "-b", "wt-"+beadID, wtPath) + if age > 0 { + backdateWorktreeGitFile(t, wtPath, age) + } + return wtPath +} + +// backdateWorktreeGitFile sets the mtime of a worktree's .git pointer file +// (written once by `git worktree add` and not rewritten during normal use) +// back by age, so the reaper's age-computation helper — which uses that +// file's mtime as a creation-time proxy — sees a worktree older than age. +func backdateWorktreeGitFile(t *testing.T, worktreePath string, age time.Duration) { + t.Helper() + gitFile := filepath.Join(worktreePath, ".git") + backdated := time.Now().Add(-age) + if err := os.Chtimes(gitFile, backdated, backdated); err != nil { + t.Fatalf("backdate %s: %v", gitFile, err) + } +} + +func reapTestConfig(rigRoot string) *config.City { + return &config.City{ + Workspace: config.Workspace{Name: "test", Prefix: "ga"}, + Rigs: []config.Rig{{Name: reapTestRigName, Path: rigRoot}}, + } +} + +// injectLiveness overrides the reaper's process-table scan for the duration of +// the test so liveness is deterministic, and restores it on cleanup. +func injectLiveness(t *testing.T, state liveWorktreeState) { + t.Helper() + prev := collectLiveWorktreeStateFn + collectLiveWorktreeStateFn = func() liveWorktreeState { return state } + t.Cleanup(func() { collectLiveWorktreeStateFn = prev }) +} + +// TestReapClosedBeadWorktrees_ReapsIdleNestedWorktree proves the depth fix: a +// per-bead worktree nested two levels under .gc/worktrees// (which the old +// single-level os.ReadDir scan never saw) is discovered via porcelain and +// reaped when its bead is closed, the tree is clean, and nothing is live. +func TestReapClosedBeadWorktrees_ReapsIdleNestedWorktree(t *testing.T) { + cityPath, rigRoot := initReapRig(t) + wt := addClosedWorktree(t, rigRoot, cityPath, "builder", "ga-idle01") + store := beads.NewMemStoreFrom(1, []beads.Bead{{ID: "ga-idle01", Status: "closed"}}, nil) + cfg := reapTestConfig(rigRoot) + injectLiveness(t, liveWorktreeState{scanned: true}) // scanned, but no live cwds + + var stderr bytes.Buffer + report := reapClosedBeadWorktrees(cityPath, cfg, map[string]beads.Store{"mrig": store}, nil, false, events.Discard, &stderr) + + if len(report.Reaped) != 1 || report.Reaped[0].BeadID != "ga-idle01" { + t.Fatalf("Reaped = %+v, want exactly ga-idle01\nstderr:\n%s", report.Reaped, stderr.String()) + } + if _, err := os.Stat(wt); !os.IsNotExist(err) { + t.Fatalf("worktree %s still present after reap (stat err=%v)", wt, err) + } +} + +// TestReapClosedBeadWorktrees_ProtectsLiveWorktree is the canonical failing +// test for gastownhall/gascity#4492: a closed-bead worktree that is git-clean +// and fully pushed — and therefore reapable by every pre-existing gate — is NOT +// removed when a live process is working inside it. This is the exact shape of +// the would-reap-19-live incident. +func TestReapClosedBeadWorktrees_ProtectsLiveWorktree(t *testing.T) { + cityPath, rigRoot := initReapRig(t) + // Nest under a pooled agent home (builder-2), mirroring the real fleet + // layout where the would-reap-19-live incident occurred. + wt := addClosedWorktree(t, rigRoot, cityPath, "builder-2", "ga-live01") + store := beads.NewMemStoreFrom(1, []beads.Bead{{ID: "ga-live01", Status: "closed"}}, nil) + cfg := reapTestConfig(rigRoot) + // A live process cwd sits inside the worktree (e.g. a nested build/test dir + // would too — here the tree root itself). + injectLiveness(t, liveWorktreeState{scanned: true, cwds: []string{pathutil.NormalizePathForCompare(wt)}}) + + var stderr bytes.Buffer + report := reapClosedBeadWorktrees(cityPath, cfg, map[string]beads.Store{"mrig": store}, nil, false, events.Discard, &stderr) + + if len(report.Reaped) != 0 { + t.Fatalf("Reaped = %+v, want 0 for a live worktree\nstderr:\n%s", report.Reaped, stderr.String()) + } + if len(report.Protected) != 1 || !strings.Contains(report.Protected[0].Reason, "live") { + t.Fatalf("Protected = %+v, want 1 live-protected entry", report.Protected) + } + if _, err := os.Stat(wt); err != nil { + t.Fatalf("live worktree %s was removed or unstattable: %v", wt, err) + } +} + +// TestReapClosedBeadWorktrees_ProtectsViaActiveSessionDir proves the +// active-session cross-check: even with no live process cwd, a worktree that +// matches an open session's recorded working directory is protected. +func TestReapClosedBeadWorktrees_ProtectsViaActiveSessionDir(t *testing.T) { + cityPath, rigRoot := initReapRig(t) + wt := addClosedWorktree(t, rigRoot, cityPath, "builder", "ga-sess01") + store := beads.NewMemStoreFrom(1, []beads.Bead{{ID: "ga-sess01", Status: "closed"}}, nil) + cfg := reapTestConfig(rigRoot) + injectLiveness(t, liveWorktreeState{scanned: true}) // no live cwds + + var stderr bytes.Buffer + report := reapClosedBeadWorktrees(cityPath, cfg, map[string]beads.Store{"mrig": store}, []string{wt}, false, events.Discard, &stderr) + + if len(report.Reaped) != 0 { + t.Fatalf("Reaped = %+v, want 0 when an active session claims the worktree", report.Reaped) + } + if len(report.Protected) != 1 || !strings.Contains(report.Protected[0].Reason, "session") { + t.Fatalf("Protected = %+v, want 1 session-protected entry", report.Protected) + } + if _, err := os.Stat(wt); err != nil { + t.Fatalf("session-claimed worktree %s was removed: %v", wt, err) + } +} + +// TestReapClosedBeadWorktrees_FailsClosedWhenLivenessUnavailable proves the +// fail-closed backstop: when the process-table scan is indeterminate, an +// otherwise perfectly reapable worktree is protected rather than removed. +func TestReapClosedBeadWorktrees_FailsClosedWhenLivenessUnavailable(t *testing.T) { + cityPath, rigRoot := initReapRig(t) + wt := addClosedWorktree(t, rigRoot, cityPath, "builder", "ga-fail01") + store := beads.NewMemStoreFrom(1, []beads.Bead{{ID: "ga-fail01", Status: "closed"}}, nil) + cfg := reapTestConfig(rigRoot) + injectLiveness(t, liveWorktreeState{scanned: false}) // liveness indeterminate + + var stderr bytes.Buffer + report := reapClosedBeadWorktrees(cityPath, cfg, map[string]beads.Store{"mrig": store}, nil, false, events.Discard, &stderr) + + if len(report.Reaped) != 0 { + t.Fatalf("Reaped = %+v, want 0 when liveness scan is unavailable (fail closed)", report.Reaped) + } + if len(report.Protected) != 1 || !strings.Contains(report.Protected[0].Reason, "liveness scan unavailable") { + t.Fatalf("Protected = %+v, want 1 fail-closed entry", report.Protected) + } + if _, err := os.Stat(wt); err != nil { + t.Fatalf("worktree %s removed despite indeterminate liveness: %v", wt, err) + } +} + +// TestReapClosedBeadWorktrees_DryRunRemovesNothing proves dry-run classifies +// but never deletes: the would-reap set is populated and reported, an event is +// emitted, yet the worktree remains on disk. +func TestReapClosedBeadWorktrees_DryRunRemovesNothing(t *testing.T) { + cityPath, rigRoot := initReapRig(t) + wt := addClosedWorktree(t, rigRoot, cityPath, "builder", "ga-dry001") + store := beads.NewMemStoreFrom(1, []beads.Bead{{ID: "ga-dry001", Status: "closed"}}, nil) + cfg := reapTestConfig(rigRoot) + injectLiveness(t, liveWorktreeState{scanned: true}) + + var stderr bytes.Buffer + report := reapClosedBeadWorktrees(cityPath, cfg, map[string]beads.Store{"mrig": store}, nil, true, events.Discard, &stderr) + + if !report.DryRun { + t.Fatal("report.DryRun = false, want true") + } + if len(report.Reaped) != 1 || report.Reaped[0].BeadID != "ga-dry001" { + t.Fatalf("Reaped (would-reap) = %+v, want exactly ga-dry001", report.Reaped) + } + if _, err := os.Stat(wt); err != nil { + t.Fatalf("dry-run removed or broke worktree %s: %v", wt, err) + } + if !strings.Contains(stderr.String(), "dry-run: would reap") { + t.Fatalf("stderr = %q, want a dry-run would-reap line", stderr.String()) + } +} + +// TestReapClosedBeadWorktrees_SkipsOpenBead confirms a worktree whose bead is +// still open is untouched and not reported as reaped or protected. +func TestReapClosedBeadWorktrees_SkipsOpenBead(t *testing.T) { + cityPath, rigRoot := initReapRig(t) + wt := addClosedWorktree(t, rigRoot, cityPath, "builder", "ga-open01") + store := beads.NewMemStoreFrom(1, []beads.Bead{{ID: "ga-open01", Status: "open"}}, nil) + cfg := reapTestConfig(rigRoot) + injectLiveness(t, liveWorktreeState{scanned: true}) + + var stderr bytes.Buffer + report := reapClosedBeadWorktrees(cityPath, cfg, map[string]beads.Store{"mrig": store}, nil, false, events.Discard, &stderr) + + if len(report.Reaped) != 0 || len(report.Protected) != 0 { + t.Fatalf("open bead touched: Reaped=%+v Protected=%+v", report.Reaped, report.Protected) + } + if _, err := os.Stat(wt); err != nil { + t.Fatalf("worktree for open bead %s was removed: %v", wt, err) + } +} diff --git a/cmd/gc/beads_provider_health_publication_test.go b/cmd/gc/beads_provider_health_publication_test.go new file mode 100644 index 0000000000..ad8a9dc6f3 --- /dev/null +++ b/cmd/gc/beads_provider_health_publication_test.go @@ -0,0 +1,129 @@ +package main + +import ( + "errors" + "slices" + "testing" + "time" +) + +func TestReconcileHealthyManagedRuntimePublication(t *testing.T) { + ownershipErr := errors.New("ownership unavailable") + publishErr := errors.New("publication unavailable") + waitErr := errors.New("store unavailable") + + tests := []struct { + name string + currentPort string + owned bool + ownershipErr error + publishErr error + waitErr error + waitForScopes bool + wantCalls []string + wantErr error + wantErrText string + }{ + { + name: "already published", + currentPort: "3307", + owned: true, + waitForScopes: true, + wantCalls: []string{"current-port"}, + }, + { + name: "ownership error", + ownershipErr: ownershipErr, + waitForScopes: true, + wantCalls: []string{"current-port", "lifecycle-owned"}, + wantErr: ownershipErr, + wantErrText: "determine managed dolt ownership: ownership unavailable", + }, + { + name: "unowned", + waitForScopes: true, + wantCalls: []string{"current-port", "lifecycle-owned"}, + }, + { + name: "publication error", + owned: true, + publishErr: publishErr, + waitForScopes: true, + wantCalls: []string{"current-port", "lifecycle-owned", "publish-if-owned"}, + wantErr: publishErr, + wantErrText: "healthy but failed to publish managed dolt runtime state: publication unavailable", + }, + { + name: "publishes without waiting", + owned: true, + wantCalls: []string{"current-port", "lifecycle-owned", "publish-if-owned"}, + }, + { + name: "publishes and waits", + owned: true, + waitForScopes: true, + wantCalls: []string{"current-port", "lifecycle-owned", "publish-if-owned", "wait-scopes-ready"}, + }, + { + name: "readiness error", + owned: true, + waitErr: waitErr, + waitForScopes: true, + wantCalls: []string{"current-port", "lifecycle-owned", "publish-if-owned", "wait-scopes-ready"}, + wantErr: waitErr, + wantErrText: "healthy but store not ready after publishing managed dolt runtime state: store unavailable", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + const cityPath = "/city" + var calls []string + record := func(call, gotCityPath string) { + t.Helper() + if gotCityPath != cityPath { + t.Fatalf("%s cityPath = %q, want %q", call, gotCityPath, cityPath) + } + calls = append(calls, call) + } + deps := healthyManagedRuntimePublicationDeps{ + currentPort: func(gotCityPath string) string { + record("current-port", gotCityPath) + return tt.currentPort + }, + lifecycleOwned: func(gotCityPath string) (bool, error) { + record("lifecycle-owned", gotCityPath) + return tt.owned, tt.ownershipErr + }, + publishIfOwned: func(gotCityPath string) error { + record("publish-if-owned", gotCityPath) + return tt.publishErr + }, + waitScopesReady: func(gotCityPath string, timeout time.Duration) error { + record("wait-scopes-ready", gotCityPath) + if timeout != 10*time.Second { + t.Errorf("waitScopesReady timeout = %v, want 10s", timeout) + } + return tt.waitErr + }, + } + + err := reconcileHealthyManagedRuntimePublication(cityPath, tt.waitForScopes, deps) + if tt.wantErr == nil { + if err != nil { + t.Fatalf("reconcileHealthyManagedRuntimePublication() error = %v", err) + } + } else { + if !errors.Is(err, tt.wantErr) { + t.Fatalf("reconcileHealthyManagedRuntimePublication() error = %v, want errors.Is(_, %v)", err, tt.wantErr) + } + if err.Error() != tt.wantErrText { + t.Errorf("reconcileHealthyManagedRuntimePublication() error = %q, want %q", err, tt.wantErrText) + } + } + if !slices.Equal(calls, tt.wantCalls) { + t.Errorf("dependency calls = %v, want %v", calls, tt.wantCalls) + } + }) + } +} diff --git a/cmd/gc/beads_provider_lifecycle.go b/cmd/gc/beads_provider_lifecycle.go index 08d200f44d..64fb7896d0 100644 --- a/cmd/gc/beads_provider_lifecycle.go +++ b/cmd/gc/beads_provider_lifecycle.go @@ -860,6 +860,12 @@ func shutdownBeadsProvider(cityPath string) error { // providers that run bd init elsewhere (for example gc-beads-k8s inside the // pod) must set it in their own wrapper before invoking bd init. func initBeadsForDir(cityPath, dir, prefix, doltDatabase string) error { + return initBeadsForDirWithExecutor(cityPath, dir, prefix, doltDatabase, runProviderOpWithEnv) +} + +type providerOpExecutor func(script string, environ []string, args ...string) error + +func initBeadsForDirWithExecutor(cityPath, dir, prefix, doltDatabase string, execute providerOpExecutor) error { if cityUsesBdStoreContract(cityPath) && gcDoltSkip() { if err := seedDeferredManagedBeadsErr(cityPath, dir, prefix, doltDatabase); err != nil { return err @@ -881,7 +887,7 @@ func initBeadsForDir(cityPath, dir, prefix, doltDatabase string) error { if err != nil { return err } - if err := runProviderOpWithEnv(script, env, args...); err != nil { + if err := execute(script, env, args...); err != nil { if isBdAlreadyInitializedError(err) { return nil } @@ -911,14 +917,14 @@ func initBeadsForDir(cityPath, dir, prefix, doltDatabase string) error { } } env := overlayEnvEntries(baseEnv, overrides) - if err := runProviderOpWithEnv(script, env, args...); err != nil { + if err := execute(script, env, args...); err != nil { if isBdAlreadyInitializedError(err) { return finalizeCanonicalBdScopeInit(cityPath, dir, prefix, canonicalDoltDatabase) } if shouldRetryExecBdInit(err) { for attempt := 0; attempt < 3; attempt++ { time.Sleep(time.Second) - retryErr := runProviderOpWithEnv(script, env, args...) + retryErr := execute(script, env, args...) if retryErr == nil { return finalizeCanonicalBdScopeInit(cityPath, dir, prefix, canonicalDoltDatabase) } @@ -943,11 +949,11 @@ func initBeadsForDir(cityPath, dir, prefix, doltDatabase string) error { env := overlayEnvEntries(baseEnv, map[string]string{ "BEADS_DIR": filepath.Join(dir, ".beads"), }) - if err := runProviderOpWithEnv(script, env, args...); err != nil { + if err := execute(script, env, args...); err != nil { if shouldRetryExecBdInit(err) { for attempt := 0; attempt < 3; attempt++ { time.Sleep(time.Second) - retryErr := runProviderOpWithEnv(script, env, args...) + retryErr := execute(script, env, args...) if retryErr == nil { return nil } @@ -969,7 +975,7 @@ func initBeadsForDir(cityPath, dir, prefix, doltDatabase string) error { if err != nil { return err } - return runProviderOpWithEnv(script, providerEnv, args...) + return execute(script, providerEnv, args...) } if shouldInitDefaultRigBdStore(cityPath, dir, provider) { return initDefaultRigBdStore(cityPath, dir, prefix, doltDatabase) @@ -1032,10 +1038,10 @@ func finalizeCanonicalBdScopeInit(cityPath, dir, prefix, doltDatabase string) er if err != nil { return err } - return verifyCanonicalBdScopeStoreReady(store) + return verifyCanonicalBdScopeStoreReady(store, time.Sleep) } -func verifyCanonicalBdScopeStoreReady(store beads.Store) error { +func verifyCanonicalBdScopeStoreReady(store beads.Store, sleep func(time.Duration)) error { var lastErr error for attempt := 0; attempt < 20; attempt++ { _, err := store.List(beads.ListQuery{AllowScan: true, Limit: 1}) @@ -1043,7 +1049,7 @@ func verifyCanonicalBdScopeStoreReady(store beads.Store) error { return nil } lastErr = err - time.Sleep(500 * time.Millisecond) + sleep(500 * time.Millisecond) } if lastErr == nil { lastErr = fmt.Errorf("store verification failed") @@ -1093,6 +1099,35 @@ func initFileStoreForDir(cityPath, dir string) error { return ensurePersistedScopeLocalFileStore(dir) } +type healthyManagedRuntimePublicationDeps struct { + currentPort func(string) string + lifecycleOwned func(string) (bool, error) + publishIfOwned func(string) error + waitScopesReady func(string, time.Duration) error +} + +func reconcileHealthyManagedRuntimePublication(cityPath string, waitForScopes bool, deps healthyManagedRuntimePublicationDeps) error { + if deps.currentPort(cityPath) != "" { + return nil + } + owned, err := deps.lifecycleOwned(cityPath) + if err != nil { + return fmt.Errorf("determine managed dolt ownership: %w", err) + } + if !owned { + return nil + } + if err := deps.publishIfOwned(cityPath); err != nil { + return fmt.Errorf("healthy but failed to publish managed dolt runtime state: %w", err) + } + if waitForScopes { + if err := deps.waitScopesReady(cityPath, 10*time.Second); err != nil { + return fmt.Errorf("healthy but store not ready after publishing managed dolt runtime state: %w", err) + } + } + return nil +} + // healthBeadsProvider checks the bead store's backing service health. // For exec providers, fires the "health" operation. For bd (dolt), runs // a three-layer health check and attempts recovery on failure. For file @@ -1171,21 +1206,15 @@ func healthBeadsProviderContext(ctx context.Context, cityPath string, waitForSco return fmt.Errorf("recovered but store not ready: %w", waitErr) } } - } else if providerUsesBdStoreContract(provider) && currentManagedDoltPort(cityPath) == "" { - owned, ownershipErr := managedDoltLifecycleOwned(cityPath) - if ownershipErr != nil { - return fmt.Errorf("determine managed dolt ownership: %w", ownershipErr) + } else if providerUsesBdStoreContract(provider) { + deps := healthyManagedRuntimePublicationDeps{ + currentPort: currentManagedDoltPort, + lifecycleOwned: managedDoltLifecycleOwned, + publishIfOwned: publishManagedDoltRuntimeStateIfOwned, + waitScopesReady: waitForAllBeadsScopesReadyAfterRecovery, } - if !owned { - return nil - } - if pubErr := publishManagedDoltRuntimeStateIfOwned(cityPath); pubErr != nil { - return fmt.Errorf("healthy but failed to publish managed dolt runtime state: %w", pubErr) - } - if waitForScopes { - if waitErr := waitForAllBeadsScopesReadyAfterRecovery(cityPath, 10*time.Second); waitErr != nil { - return fmt.Errorf("healthy but store not ready after publishing managed dolt runtime state: %w", waitErr) - } + if err := reconcileHealthyManagedRuntimePublication(cityPath, waitForScopes, deps); err != nil { + return err } } return nil @@ -1336,10 +1365,51 @@ func currentDoltPort(cityPath string) string { writeDoltPortFile(cityPath, port, "", io.Discard) return port } + if port := currentOwnedManagedDoltPortMirror(cityPath, pidAlive, managedDoltRuntimeProcessOwned); port != "" { + return port + } removeDoltPortFile(cityPath) return "" } +// currentOwnedManagedDoltPortMirror preserves an existing raw-bd compatibility +// mirror while its matching managed process is still owned but temporarily not +// reachable. It never creates or rewrites a mirror from an unreachable state. +func currentOwnedManagedDoltPortMirror( + cityPath string, + processAlive func(int) bool, + processOwned func(doltRuntimeState, managedDoltRuntimeLayout) bool, +) string { + owned, err := managedDoltLifecycleOwned(cityPath) + if err != nil || !owned || processAlive == nil || processOwned == nil { + return "" + } + data, err := os.ReadFile(filepath.Join(cityPath, ".beads", "dolt-server.port")) + if err != nil { + return "" + } + portText := strings.TrimSpace(string(data)) + port, err := strconv.Atoi(portText) + if err != nil || !validDoltPort(port) { + return "" + } + + for _, statePath := range []string{ + providerManagedDoltStatePath(cityPath), + managedDoltStatePath(cityPath), + } { + state, err := readDoltRuntimeStateFile(statePath) + if err != nil || state.Port != port { + continue + } + layout, ok := validDoltRuntimeStateIdentity(state, cityPath) + if ok && processAlive(state.PID) && processOwned(state, layout) { + return strconv.Itoa(port) + } + } + return "" +} + func managedDoltStatePath(cityPath string) string { return filepath.Join(cityPath, ".gc", "runtime", "packs", "dolt", "dolt-state.json") } @@ -1368,25 +1438,34 @@ func currentManagedDoltPort(cityPath string) string { } func validDoltRuntimeState(state doltRuntimeState, cityPath string) bool { - if !state.Running || state.Port <= 0 || state.PID <= 0 { - return false - } - expectedDataDir := filepath.Join(cityPath, ".beads", "dolt") - if !samePath(strings.TrimSpace(state.DataDir), expectedDataDir) { - return false - } - if !pidAlive(state.PID) { + layout, ok := validDoltRuntimeStateIdentity(state, cityPath) + if !ok || !pidAlive(state.PID) { return false } if !doltPortReachable(strconv.Itoa(state.Port)) { return false } - holderPID := findPortHolderPID(strconv.Itoa(state.Port)) - if holderPID > 0 && holderPID != state.PID { - return false + return managedDoltRuntimeProcessOwned(state, layout) +} + +func validDoltRuntimeStateIdentity(state doltRuntimeState, cityPath string) (managedDoltRuntimeLayout, bool) { + if !state.Running || state.Port <= 0 || state.PID <= 0 { + return managedDoltRuntimeLayout{}, false + } + expectedDataDir := filepath.Join(cityPath, ".beads", "dolt") + if !samePath(strings.TrimSpace(state.DataDir), expectedDataDir) { + return managedDoltRuntimeLayout{}, false } layout, err := resolveManagedDoltRuntimeLayout(cityPath) if err != nil { + return managedDoltRuntimeLayout{}, false + } + return layout, true +} + +func managedDoltRuntimeProcessOwned(state doltRuntimeState, layout managedDoltRuntimeLayout) bool { + holderPID := findPortHolderPID(strconv.Itoa(state.Port)) + if holderPID > 0 && holderPID != state.PID { return false } owned, deleted := inspectManagedDoltOwnership(state.PID, layout) @@ -1482,7 +1561,6 @@ func removeScopeLocalDoltServerArtifacts(dir string) error { "dolt-server.pid", "dolt-server.lock", "dolt-server.log", - "dolt-server.port", } { if err := os.Remove(filepath.Join(dir, ".beads", name)); err != nil && !os.IsNotExist(err) { return err @@ -1762,6 +1840,7 @@ func desiredCityDoltConfigState(cityPath string, cityDolt config.DoltConfig, cit EndpointOrigin: contract.EndpointOriginCityCanonical, DoltHost: cityHost, DoltPort: cityPort, + DoltMode: "server", } state.DoltUser = preservedDoltUser(cityPath, state) state.EndpointStatus = preservedEndpointStatus(cityPath, state, contract.EndpointStatusUnverified) @@ -1772,6 +1851,7 @@ func desiredCityDoltConfigState(cityPath string, cityDolt config.DoltConfig, cit IssuePrefix: cityPrefix, EndpointOrigin: contract.EndpointOriginManagedCity, EndpointStatus: contract.EndpointStatusVerified, + DoltMode: "server", } } @@ -1781,6 +1861,7 @@ func desiredRigDoltConfigState(cityPath string, rig config.Rig, cityState contra state := contract.ConfigState{ IssuePrefix: rig.EffectivePrefix(), EndpointOrigin: contract.EndpointOriginExplicit, + DoltMode: "server", } state.DoltHost, state.DoltPort = configuredExternalDoltTargetForRig(rig) state.DoltUser = preservedDoltUser(rig.Path, state) @@ -1795,6 +1876,7 @@ func inheritedRigDoltConfigState(rigPath, prefix string, cityState contract.Conf state := contract.ConfigState{ IssuePrefix: prefix, EndpointOrigin: contract.EndpointOriginInheritedCity, + DoltMode: cityState.DoltMode, } if cityState.EndpointOrigin == contract.EndpointOriginCityCanonical { state.DoltHost = cityState.DoltHost diff --git a/cmd/gc/beads_provider_lifecycle_test.go b/cmd/gc/beads_provider_lifecycle_test.go index 53a82c5501..c31e238e62 100644 --- a/cmd/gc/beads_provider_lifecycle_test.go +++ b/cmd/gc/beads_provider_lifecycle_test.go @@ -18,6 +18,7 @@ import ( "testing" "time" + bdpack "github.com/gastownhall/gascity/examples/bd" "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/beads/contract" "github.com/gastownhall/gascity/internal/citylayout" @@ -1006,12 +1007,18 @@ exit 0 t.Fatal(err) } setScopedBeadsProviderForTest(t, cityPath, "exec:"+script) + publishedState := filepath.Join(cityPath, ".gc", "runtime", "packs", "dolt", "dolt-state.json") + for _, path := range []string{providerState, publishedState} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("runtime state %s should be absent before provider start, stat err = %v", path, err) + } + } if err := ensureBeadsProvider(cityPath); err != nil { t.Fatalf("ensureBeadsProvider: %v", err) } - published, err := os.ReadFile(filepath.Join(cityPath, ".gc", "runtime", "packs", "dolt", "dolt-state.json")) + published, err := os.ReadFile(publishedState) if err != nil { t.Fatalf("ReadFile(dolt-state.json): %v", err) } @@ -2890,6 +2897,186 @@ func TestCurrentDoltPortIgnoresReachablePortFileWhenManagedStateIsStopped(t *tes } } +func TestCurrentOwnedManagedDoltPortMirrorPreservesMatchingOwnedProviderState(t *testing.T) { + cityDir := setupBdContractCityForTest(t) + beadsDir := filepath.Join(cityDir, ".beads") + dataDir := filepath.Join(beadsDir, "dolt") + if err := os.MkdirAll(dataDir, 0o755); err != nil { + t.Fatal(err) + } + + const port = 3307 + providerState := doltRuntimeState{ + Running: true, + PID: os.Getpid(), + Port: port, + DataDir: dataDir, + } + if err := writeDoltRuntimeStateFile(providerManagedDoltStatePath(cityDir), providerState); err != nil { + t.Fatal(err) + } + stalePublishedState := providerState + stalePublishedState.Port = port + 1 + if err := writeDoltRuntimeStateFile(managedDoltStatePath(cityDir), stalePublishedState); err != nil { + t.Fatal(err) + } + portFile := filepath.Join(beadsDir, "dolt-server.port") + if err := os.WriteFile(portFile, []byte(fmt.Sprintf("%d\n", port)), 0o644); err != nil { + t.Fatal(err) + } + + probeCalls := 0 + aliveCalls := 0 + got := currentOwnedManagedDoltPortMirror(cityDir, func(pid int) bool { + aliveCalls++ + if pid != os.Getpid() { + t.Fatalf("liveness PID = %d, want %d", pid, os.Getpid()) + } + return true + }, func(state doltRuntimeState, layout managedDoltRuntimeLayout) bool { + probeCalls++ + if state.Port != port || state.PID != os.Getpid() { + t.Fatalf("ownership state = %+v, want pid %d port %d", state, os.Getpid(), port) + } + if !samePath(layout.DataDir, dataDir) { + t.Fatalf("ownership layout data dir = %q, want %q", layout.DataDir, dataDir) + } + return true + }) + if aliveCalls != 1 { + t.Fatalf("process-liveness probe calls = %d, want 1", aliveCalls) + } + if probeCalls != 1 { + t.Fatalf("owned-process probe calls = %d, want 1", probeCalls) + } + if got != strconv.Itoa(port) { + t.Fatalf("currentOwnedManagedDoltPortMirror() = %q, want existing owned mirror %d", got, port) + } + data, err := os.ReadFile(portFile) + if err != nil { + t.Fatalf("read preserved port mirror: %v", err) + } + if got := strings.TrimSpace(string(data)); got != strconv.Itoa(port) { + t.Fatalf("preserved port mirror = %q, want %d", got, port) + } +} + +func TestCurrentOwnedManagedDoltPortMirrorRejectsUnverifiedState(t *testing.T) { + tests := []struct { + name string + mirrorPort int + providerPort int + publishedPort int + wrongProviderData bool + processAlive bool + processOwned bool + wantAliveCalls int + wantProbeCalls int + }{ + { + name: "process is not owned", + mirrorPort: 3307, + providerPort: 3307, + processAlive: true, + processOwned: false, + wantAliveCalls: 1, + wantProbeCalls: 1, + }, + { + name: "process is not alive", + mirrorPort: 3307, + providerPort: 3307, + processAlive: false, + processOwned: true, + wantAliveCalls: 1, + wantProbeCalls: 0, + }, + { + name: "mirror and state ports differ", + mirrorPort: 3307, + providerPort: 3308, + processOwned: true, + wantProbeCalls: 0, + }, + { + name: "state data directory differs", + mirrorPort: 3307, + providerPort: 3307, + wrongProviderData: true, + processAlive: true, + processOwned: true, + wantProbeCalls: 0, + }, + { + name: "provider and published states both differ from mirror", + mirrorPort: 3309, + providerPort: 3307, + publishedPort: 3308, + processAlive: true, + processOwned: true, + wantProbeCalls: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cityDir := setupBdContractCityForTest(t) + beadsDir := filepath.Join(cityDir, ".beads") + dataDir := filepath.Join(beadsDir, "dolt") + if err := os.MkdirAll(dataDir, 0o755); err != nil { + t.Fatal(err) + } + + providerDataDir := dataDir + if tt.wrongProviderData { + providerDataDir = filepath.Join(cityDir, "other-dolt-data") + } + if tt.providerPort != 0 { + if err := writeDoltRuntimeStateFile(providerManagedDoltStatePath(cityDir), doltRuntimeState{ + Running: true, + PID: os.Getpid(), + Port: tt.providerPort, + DataDir: providerDataDir, + }); err != nil { + t.Fatal(err) + } + } + if tt.publishedPort != 0 { + if err := writeDoltRuntimeStateFile(managedDoltStatePath(cityDir), doltRuntimeState{ + Running: true, + PID: os.Getpid(), + Port: tt.publishedPort, + DataDir: dataDir, + }); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(beadsDir, "dolt-server.port"), []byte(fmt.Sprintf("%d\n", tt.mirrorPort)), 0o644); err != nil { + t.Fatal(err) + } + + probeCalls := 0 + aliveCalls := 0 + got := currentOwnedManagedDoltPortMirror(cityDir, func(int) bool { + aliveCalls++ + return tt.processAlive + }, func(doltRuntimeState, managedDoltRuntimeLayout) bool { + probeCalls++ + return tt.processOwned + }) + if got != "" { + t.Fatalf("currentOwnedManagedDoltPortMirror() = %q, want empty", got) + } + if probeCalls != tt.wantProbeCalls { + t.Fatalf("owned-process probe calls = %d, want %d", probeCalls, tt.wantProbeCalls) + } + if aliveCalls != tt.wantAliveCalls { + t.Fatalf("process-liveness probe calls = %d, want %d", aliveCalls, tt.wantAliveCalls) + } + }) + } +} + // TestInitBeadsForDir_file verifies that unmarked file cities stay in legacy shared mode. func TestInitBeadsForDir_file(t *testing.T) { t.Setenv("GC_BEADS", "file") @@ -3629,58 +3816,80 @@ exit 0 } } -func TestInitBeadsForDirExecGcBeadsBdPreservesCityRuntimeEnv(t *testing.T) { - cityDir := t.TempDir() - writeMinimalCityToml(t, cityDir) - logFile := filepath.Join(t.TempDir(), "env.log") - script := filepath.Join(t.TempDir(), "gc-beads-bd") - content := fmt.Sprintf(`#!/bin/sh -set -eu -case "$1" in - init) - printf '%%s|%%s|%%s|%%s -' "${GC_CITY_PATH:-}" "${GC_CITY_RUNTIME_DIR:-}" "${GC_PACK_STATE_DIR:-}" "${GC_DOLT_DATA_DIR:-}" > %q - exit 0 - ;; - *) - exit 2 - ;; -esac -`, logFile) - if err := os.WriteFile(script, []byte(content), 0o755); err != nil { - t.Fatal(err) +func TestInitBeadsForDirBuildsCanonicalBdInitProviderOp(t *testing.T) { + tests := []struct { + name string + provider func(string) string + wantScript func(string) string + }{ + { + name: "logical bd uses the stable city wrapper", + provider: func(string) string { return "bd" }, + wantScript: gcBeadsBdScriptPath, + }, + { + name: "explicit canonical wrapper keeps its configured path", + provider: func(cityDir string) string { + return "exec:" + filepath.Join(cityDir, "custom", "gc-beads-bd") + }, + wantScript: func(cityDir string) string { + return filepath.Join(cityDir, "custom", "gc-beads-bd") + }, + }, } - t.Setenv("GC_BEADS", "exec:"+script) - t.Setenv("GC_BEADS_SCOPE_ROOT", cityDir) - t.Setenv("GC_CITY_PATH", "/wrong-city") - t.Setenv("GC_CITY_RUNTIME_DIR", "/wrong-runtime") - t.Setenv("GC_PACK_STATE_DIR", "/wrong-pack") - t.Setenv("GC_DOLT_DATA_DIR", "/wrong-data") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cityDir := t.TempDir() + cityConfig := fmt.Sprintf(`[workspace] +name = "demo" - if err := initBeadsForDir(cityDir, cityDir, "gc", "hq"); err != nil { - t.Fatalf("initBeadsForDir: %v", err) - } +[beads] +provider = %q +`, tt.provider(cityDir)) + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte(cityConfig), 0o644); err != nil { + t.Fatal(err) + } - data, err := os.ReadFile(logFile) - if err != nil { - t.Fatalf("read env log: %v", err) - } - parts := strings.Split(strings.TrimSpace(string(data)), "|") - if len(parts) != 4 { - t.Fatalf("captured env = %q, want 4 fields", strings.TrimSpace(string(data))) - } - if parts[0] != cityDir { - t.Fatalf("GC_CITY_PATH = %q, want %q", parts[0], cityDir) - } - if parts[1] != filepath.Join(cityDir, ".gc", "runtime") { - t.Fatalf("GC_CITY_RUNTIME_DIR = %q, want %q", parts[1], filepath.Join(cityDir, ".gc", "runtime")) - } - if parts[2] != citylayout.PackStateDir(cityDir, "dolt") { - t.Fatalf("GC_PACK_STATE_DIR = %q, want %q", parts[2], citylayout.PackStateDir(cityDir, "dolt")) - } - if parts[3] != filepath.Join(cityDir, ".beads", "dolt") { - t.Fatalf("GC_DOLT_DATA_DIR = %q, want %q", parts[3], filepath.Join(cityDir, ".beads", "dolt")) + stopAfterCapture := errors.New("stop after capturing provider op") + var calls int + var gotScript string + var gotEnv, gotArgs []string + execute := func(script string, environ []string, args ...string) error { + calls++ + gotScript = script + gotEnv = append([]string(nil), environ...) + gotArgs = append([]string(nil), args...) + return stopAfterCapture + } + + err := initBeadsForDirWithExecutor(cityDir, cityDir, "gc", "hq", execute) + if !errors.Is(err, stopAfterCapture) { + t.Fatalf("initBeadsForDirWithExecutor error = %v, want %v", err, stopAfterCapture) + } + if calls != 1 { + t.Fatalf("provider calls = %d, want 1", calls) + } + if got, want := gotScript, tt.wantScript(cityDir); got != want { + t.Fatalf("script = %q, want %q", got, want) + } + if want := []string{"init", cityDir, "gc", "hq"}; !reflect.DeepEqual(gotArgs, want) { + t.Fatalf("args = %#v, want %#v", gotArgs, want) + } + + env := runtimeEnvEntriesToMap(gotEnv) + for key, want := range map[string]string{ + "GC_CITY_PATH": cityDir, + "GC_CITY_RUNTIME_DIR": filepath.Join(cityDir, ".gc", "runtime"), + "GC_PACK_STATE_DIR": citylayout.PackStateDir(cityDir, "dolt"), + "GC_DOLT_DATA_DIR": filepath.Join(cityDir, ".beads", "dolt"), + "BEADS_DIR": filepath.Join(cityDir, ".beads"), + } { + if got := env[key]; got != want { + t.Errorf("%s = %q, want %q", key, got, want) + } + } + }) } } @@ -3921,132 +4130,6 @@ func TestInitBeadsForDir_bd_skip(t *testing.T) { } } -func TestInitBeadsForDirBdMaterializedScriptPreservesCityPath(t *testing.T) { - cityDir := t.TempDir() - writeMinimalCityToml(t, cityDir) - if err := os.MkdirAll(filepath.Join(cityDir, ".gc"), 0o755); err != nil { - t.Fatal(err) - } - materializeBuiltinPacksForTest(t, cityDir) - - binDir := filepath.Join(t.TempDir(), "bin") - if err := os.MkdirAll(binDir, 0o755); err != nil { - t.Fatal(err) - } - fakeBd := filepath.Join(binDir, "bd") - fakeBdScript := `#!/bin/sh -set -eu -case "${1:-}" in - init) - mkdir -p "$PWD/.beads" - printf '{"database":"dolt","backend":"dolt","dolt_mode":"server","dolt_database":"hq","project_id":"test-project"}\n' > "$PWD/.beads/metadata.json" - exit 0 - ;; - config|migrate|list) - exit 0 - ;; - *) - exit 0 - ;; -esac -` - if err := os.WriteFile(fakeBd, []byte(fakeBdScript), 0o755); err != nil { - t.Fatal(err) - } - fakeDolt := filepath.Join(binDir, "dolt") - if err := os.WriteFile(fakeDolt, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { - t.Fatal(err) - } - - configureTestDoltIdentityEnv(t) - t.Setenv("GC_BEADS", "bd") - t.Setenv("GC_BEADS_SCOPE_ROOT", cityDir) - t.Setenv("PATH", strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator))) - if err := initBeadsForDir(cityDir, cityDir, "gc", "hq"); err != nil { - t.Fatalf("initBeadsForDir: %v", err) - } -} - -func TestInitBeadsForDirBdMaterializedScriptIgnoresAmbientCityRuntimeEnv(t *testing.T) { - cityDir := t.TempDir() - writeMinimalCityToml(t, cityDir) - if err := os.MkdirAll(filepath.Join(cityDir, ".gc"), 0o755); err != nil { - t.Fatal(err) - } - materializeBuiltinPacksForTest(t, cityDir) - - binDir := filepath.Join(t.TempDir(), "bin") - if err := os.MkdirAll(binDir, 0o755); err != nil { - t.Fatal(err) - } - - captureFile := filepath.Join(t.TempDir(), "bd-init-env.txt") - fakeBd := filepath.Join(binDir, "bd") - fakeBdScript := fmt.Sprintf(`#!/bin/sh -set -eu -case "${1:-}" in - init) - mkdir -p "$PWD/.beads" - printf '{"database":"dolt","backend":"dolt","dolt_mode":"server","dolt_database":"hq","project_id":"test-project"}\n' > "$PWD/.beads/metadata.json" - printf '%%s|%%s|%%s|%%s\n' \ - "${GC_CITY_PATH:-}" \ - "${GC_CITY_RUNTIME_DIR:-}" \ - "${GC_PACK_STATE_DIR:-}" \ - "${BEADS_DIR:-}" > %q - exit 0 - ;; - config|migrate|list) - exit 0 - ;; - *) - exit 0 - ;; -esac -`, captureFile) - if err := os.WriteFile(fakeBd, []byte(fakeBdScript), 0o755); err != nil { - t.Fatal(err) - } - - fakeDolt := filepath.Join(binDir, "dolt") - if err := os.WriteFile(fakeDolt, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { - t.Fatal(err) - } - - configureTestDoltIdentityEnv(t) - t.Setenv("GC_BEADS", "bd") - t.Setenv("GC_BEADS_SCOPE_ROOT", cityDir) - t.Setenv("PATH", strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator))) - t.Setenv("GC_CITY_PATH", "/wrong-city") - t.Setenv("GC_CITY_RUNTIME_DIR", "/wrong-runtime") - t.Setenv("GC_PACK_STATE_DIR", "/wrong-pack") - t.Setenv("BEADS_DIR", "/wrong/.beads") - - if err := initBeadsForDir(cityDir, cityDir, "gc", "hq"); err != nil { - t.Fatalf("initBeadsForDir: %v", err) - } - - data, err := os.ReadFile(captureFile) - if err != nil { - t.Fatalf("read capture file: %v", err) - } - parts := strings.Split(strings.TrimSpace(string(data)), "|") - if len(parts) != 4 { - t.Fatalf("captured env = %q, want 4 fields", strings.TrimSpace(string(data))) - } - if parts[0] != cityDir { - t.Fatalf("GC_CITY_PATH = %q, want %q", parts[0], cityDir) - } - if parts[1] != filepath.Join(cityDir, ".gc", "runtime") { - t.Fatalf("GC_CITY_RUNTIME_DIR = %q, want %q", parts[1], filepath.Join(cityDir, ".gc", "runtime")) - } - if parts[2] != citylayout.PackStateDir(cityDir, "dolt") { - t.Fatalf("GC_PACK_STATE_DIR = %q, want %q", parts[2], citylayout.PackStateDir(cityDir, "dolt")) - } - if parts[3] != filepath.Join(cityDir, ".beads") { - t.Fatalf("BEADS_DIR = %q, want %q", parts[3], filepath.Join(cityDir, ".beads")) - } -} - // TestRunProviderOp_exit2 verifies exit 2 is treated as success (not needed). func TestRunProviderOp_exit2(t *testing.T) { script := writeTestScript(t, "", 2, "") @@ -4555,81 +4638,53 @@ exit 1 } } -func TestGcBeadsBdInitRetriesRootStoreVerification(t *testing.T) { - cityPath := t.TempDir() - writeMinimalCityToml(t, cityPath) - if err := os.MkdirAll(filepath.Join(cityPath, ".beads"), 0o700); err != nil { - t.Fatal(err) +type rootStoreVerificationRetryStore struct { + *beads.MemStore + failuresRemaining int + listQueries []beads.ListQuery +} + +func (s *rootStoreVerificationRetryStore) List(query beads.ListQuery) ([]beads.Bead, error) { + s.listQueries = append(s.listQueries, query) + if s.failuresRemaining > 0 { + s.failuresRemaining-- + return nil, errors.New("root store not ready") } - if err := os.WriteFile(filepath.Join(cityPath, ".beads", "metadata.json"), []byte(`{"database":"dolt","backend":"dolt","dolt_mode":"server","dolt_database":"mc","project_id":"test-project"}`), 0o644); err != nil { - t.Fatal(err) + return s.MemStore.List(query) +} + +func TestVerifyCanonicalBdScopeStoreReadyRetries(t *testing.T) { + store := &rootStoreVerificationRetryStore{ + MemStore: beads.NewMemStore(), + failuresRemaining: 2, } + var delays []time.Duration - materializeBuiltinPacksForTest(t, cityPath) + if err := verifyCanonicalBdScopeStoreReady(store, func(delay time.Duration) { + delays = append(delays, delay) + }); err != nil { + t.Fatalf("verifyCanonicalBdScopeStoreReady: %v", err) + } - binDir := filepath.Join(t.TempDir(), "bin") - if err := os.MkdirAll(binDir, 0o755); err != nil { - t.Fatal(err) + if got, want := len(store.listQueries), 3; got != want { + t.Fatalf("List attempts = %d, want %d", got, want) + } + wantQuery := beads.ListQuery{AllowScan: true, Limit: 1} + for attempt, query := range store.listQueries { + if !reflect.DeepEqual(query, wantQuery) { + t.Errorf("List attempt %d query = %#v, want %#v", attempt+1, query, wantQuery) + } + } + wantDelays := []time.Duration{500 * time.Millisecond, 500 * time.Millisecond} + if !reflect.DeepEqual(delays, wantDelays) { + t.Fatalf("retry delays = %v, want %v", delays, wantDelays) } +} - listCountFile := filepath.Join(t.TempDir(), "bd-list-count") - fakeBd := filepath.Join(binDir, "bd") - fakeBdScript := `#!/bin/sh -set -eu -count_file="` + listCountFile + `" -cmd="${1:-}" -case "$cmd" in - list) - count=0 - if [ -f "$count_file" ]; then - count=$(cat "$count_file") - fi - count=$((count + 1)) - printf '%s\n' "$count" > "$count_file" - if [ "$count" -lt 3 ]; then - exit 1 - fi - exit 0 - ;; - config) - exit 0 - ;; - *) - exit 0 - ;; -esac -` - if err := os.WriteFile(fakeBd, []byte(fakeBdScript), 0o755); err != nil { - t.Fatal(err) - } - - fakeDolt := filepath.Join(binDir, "dolt") - if err := os.WriteFile(fakeDolt, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { - t.Fatal(err) - } - - configureTestDoltIdentityEnv(t) - t.Setenv("GC_BEADS", "bd") - t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) - t.Setenv("PATH", strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator))) - - if err := initBeadsForDir(cityPath, cityPath, "mc", "mc"); err != nil { - t.Fatalf("initBeadsForDir: %v", err) - } - - data, err := os.ReadFile(listCountFile) - if err != nil { - t.Fatalf("read list retry count: %v", err) - } - if strings.TrimSpace(string(data)) != "3" { - t.Fatalf("expected bd list to retry until third attempt, got %q", strings.TrimSpace(string(data))) - } -} - -func writeGcBeadsBdInitEnvCaptureScript(t *testing.T, captureFile string) string { - t.Helper() - script := filepath.Join(t.TempDir(), "gc-beads-bd") - body := fmt.Sprintf(`#!/bin/sh +func writeGcBeadsBdInitEnvCaptureScript(t *testing.T, captureFile string) string { + t.Helper() + script := filepath.Join(t.TempDir(), "gc-beads-bd") + body := fmt.Sprintf(`#!/bin/sh set -eu op="$1" shift @@ -4994,33 +5049,6 @@ exit 2 } } -func TestHealthBeadsProviderPublishesManagedRuntimeStateWhenHealthyButUnpublished(t *testing.T) { - skipSlowCmdGCTest(t, "starts the real gc-beads-bd lifecycle script; run make test-cmd-gc-process for full coverage") - cityPath, _ := setupManagedBdWaitTestCity(t) - - if err := os.Remove(managedDoltStatePath(cityPath)); err != nil && !os.IsNotExist(err) { - t.Fatalf("remove published dolt runtime state: %v", err) - } - if got := currentManagedDoltPort(cityPath); got != "" { - t.Fatalf("currentManagedDoltPort() = %q, want empty after removing published state", got) - } - - if err := healthBeadsProvider(cityPath); err != nil { - t.Fatalf("healthBeadsProvider() error = %v", err) - } - - state, err := readDoltRuntimeStateFile(managedDoltStatePath(cityPath)) - if err != nil { - t.Fatalf("read published dolt runtime state: %v", err) - } - if !state.Running { - t.Fatalf("published.Running = false, want true") - } - if got := currentManagedDoltPort(cityPath); got == "" { - t.Fatal("currentManagedDoltPort() = empty, want published managed port") - } -} - func TestEnsureBeadsProviderExecGcBeadsBdProjectsCanonicalPackStateDir(t *testing.T) { cityPath := t.TempDir() if err := os.MkdirAll(filepath.Join(cityPath, ".beads"), 0o755); err != nil { @@ -7150,6 +7178,7 @@ func TestGcBeadsBdInitRetriesPlainInitWhenSchemaStillMissingAfterSuccess(t *test if err := os.MkdirAll(binDir, 0o755); err != nil { t.Fatal(err) } + writeExecutable(t, filepath.Join(binDir, "sleep"), "#!/bin/sh\nexit 0\n") initCountFile := filepath.Join(t.TempDir(), "bd-init-count") initArgsFile := filepath.Join(t.TempDir(), "bd-init-args") @@ -7296,6 +7325,7 @@ func TestGcBeadsBdInitDropsMetadataBeforeRetryingInitAfterForcedFallback(t *test if err := os.MkdirAll(binDir, 0o755); err != nil { t.Fatal(err) } + writeExecutable(t, filepath.Join(binDir, "sleep"), "#!/bin/sh\nexit 0\n") initCountFile := filepath.Join(t.TempDir(), "bd-init-count") initArgsFile := filepath.Join(t.TempDir(), "bd-init-args") @@ -7998,9 +8028,19 @@ EOF printf 'port_holder_deleted_inodes\tfalse\n' ;; "dolt-state existing-managed") + city="" + port="" while [ "$#" -gt 0 ]; do case "$1" in - --city|--host|--port|--user|--timeout-ms) + --city) + city="$2" + shift 2 + ;; + --port) + port="$2" + shift 2 + ;; + --host|--user|--timeout-ms) shift 2 ;; *) @@ -8010,6 +8050,19 @@ EOF esac done printf 'gc dolt-state existing-managed\n' >> "$invocation_file" + pack_dir="$city/.gc/runtime/packs/dolt-from-gc" + pid_file="$pack_dir/dolt.pid" + state_file="$pack_dir/dolt-provider-state.json" + if [ -s "$pid_file" ] && [ -f "$state_file" ]; then + managed_pid=$(cat "$pid_file") + printf 'managed_pid\t%%s\n' "$managed_pid" + printf 'managed_owned\ttrue\n' + printf 'deleted_inodes\tfalse\n' + printf 'state_port\t%%s\n' "$port" + printf 'ready\ttrue\n' + printf 'reusable\ttrue\n' + exit 0 + fi printf 'managed_pid\t0\n' printf 'managed_owned\tfalse\n' printf 'deleted_inodes\tfalse\n' @@ -8296,6 +8349,10 @@ case "${1:-}" in exit 0 ;; sql-server) + if [ "${GC_FAKE_DOLT_FAIL_SQL_SERVER:-}" = "true" ]; then + echo "unexpected dolt sql-server invocation" >&2 + exit 97 + fi config_file="" prev="" for arg in "$@"; do @@ -8549,25 +8606,27 @@ func TestGcBeadsBdStartWaitsForConcurrentStarterSuccess(t *testing.T) { t.Fatal(err) } invocationFile := filepath.Join(t.TempDir(), "gc-invocation") - startedFile := filepath.Join(t.TempDir(), "starter-ready") - nowFile := filepath.Join(t.TempDir(), "gc-now-ms") + existingCountFile := filepath.Join(t.TempDir(), "existing-managed-count") + nowCountFile := filepath.Join(t.TempDir(), "now-ms-count") + flockInvocationFile := filepath.Join(t.TempDir(), "flock-invocation") + sleepInvocationFile := filepath.Join(t.TempDir(), "sleep-invocation") fakeGC := filepath.Join(binDir, "gc") fakeGCScript := fmt.Sprintf(`#!/bin/sh set -eu invocation_file=%q -started_file=%q -now_file=%q +existing_count_file=%q +now_count_file=%q subcmd="$1 $2" shift 2 case "$subcmd" in "dolt-state now-ms") - if [ -f "$now_file" ]; then - now=$(cat "$now_file") - else - now=1000000 + count=0 + if [ -f "$now_count_file" ]; then + count=$(cat "$now_count_file") fi - printf '%%s\n' "$now" - printf '%%s\n' $((now + 250)) > "$now_file" + count=$((count + 1)) + printf '%%s\n' "$count" > "$now_count_file" + printf '%%s\n' $((1000000 + (count - 1) * 250)) ;; "dolt-state runtime-layout") city="" @@ -8603,21 +8662,34 @@ case "$subcmd" in esac done printf 'gc dolt-state existing-managed\n' >> "$invocation_file" - if [ -f "$started_file" ]; then - printf 'managed_pid\t4242\n' - printf 'managed_owned\ttrue\n' - printf 'deleted_inodes\tfalse\n' - printf 'state_port\t3311\n' - printf 'ready\ttrue\n' - printf 'reusable\ttrue\n' - else - printf 'managed_pid\t0\n' - printf 'managed_owned\tfalse\n' - printf 'deleted_inodes\tfalse\n' - printf 'state_port\t0\n' - printf 'ready\tfalse\n' - printf 'reusable\tfalse\n' + count=0 + if [ -f "$existing_count_file" ]; then + count=$(cat "$existing_count_file") fi + count=$((count + 1)) + printf '%%s\n' "$count" > "$existing_count_file" + case "$count" in + 1) + printf 'managed_pid\t0\n' + printf 'managed_owned\tfalse\n' + printf 'deleted_inodes\tfalse\n' + printf 'state_port\t0\n' + printf 'ready\tfalse\n' + printf 'reusable\tfalse\n' + ;; + 2) + printf 'managed_pid\t4242\n' + printf 'managed_owned\ttrue\n' + printf 'deleted_inodes\tfalse\n' + printf 'state_port\t3311\n' + printf 'ready\ttrue\n' + printf 'reusable\ttrue\n' + ;; + *) + echo "unexpected existing-managed invocation $count" >&2 + exit 68 + ;; + esac ;; "dolt-state probe-managed") while [ "$#" -gt 0 ]; do @@ -8656,9 +8728,6 @@ case "$subcmd" in done printf 'gc dolt-state query-probe ' >> "$invocation_file" - if [ -f "$started_file" ]; then - exit 0 - fi exit 1 ;; "dolt-state write-provider") @@ -8708,52 +8777,55 @@ case "$subcmd" in exit 64 ;; esac -`, invocationFile, startedFile, nowFile, layout.PackStateDir, layout.DataDir, layout.LogFile, layout.StateFile, layout.PIDFile, layout.LockFile, layout.ConfigFile) +`, invocationFile, existingCountFile, nowCountFile, layout.PackStateDir, layout.DataDir, layout.LogFile, layout.StateFile, layout.PIDFile, layout.LockFile, layout.ConfigFile) if err := os.WriteFile(fakeGC, []byte(fakeGCScript), 0o755); err != nil { t.Fatal(err) } - fakeDolt := filepath.Join(binDir, "dolt") - if err := os.WriteFile(fakeDolt, []byte("#!/bin/sh\nset -eu\ncase \"${1:-}\" in\n config)\n exit 0\n ;;\n *)\n printf 'dolt %s\\n' \"$*\" >> \"$GC_FAKE_DOLT_INVOCATION_FILE\"\n exit 1\n ;;\nesac\n"), 0o755); err != nil { + fakeFlock := filepath.Join(binDir, "flock") + fakeFlockScript := fmt.Sprintf(`#!/bin/sh +set -eu +invocation_file=%q +if [ "$#" -ne 2 ] || [ "$1" != "-n" ] || [ "$2" != "9" ]; then + echo "unexpected flock args: $*" >&2 + exit 64 +fi +printf 'flock -n 9\n' >> "$invocation_file" +exit 1 +`, flockInvocationFile) + if err := os.WriteFile(fakeFlock, []byte(fakeFlockScript), 0o755); err != nil { t.Fatal(err) } - invokedDolt := filepath.Join(t.TempDir(), "dolt-invocation") - - readyFile := filepath.Join(t.TempDir(), "holder-ready") - holder := exec.Command("sh", "-c", ` + fakeSleep := filepath.Join(binDir, "sleep") + fakeSleepScript := fmt.Sprintf(`#!/bin/sh set -eu -lock_file="$1" -ready_file="$2" -started_file="$3" -: > "$lock_file" -exec 9>"$lock_file" -flock 9 -printf 'ready\n' > "$ready_file" -sleep 4 -printf 'ready\n' > "$started_file" -sleep 1 -`, "sh", layout.LockFile, readyFile, startedFile) - holder.Env = sanitizedBaseEnv("PATH=" + os.Getenv("PATH")) - if err := holder.Start(); err != nil { - t.Fatalf("start lock holder: %v", err) +invocation_file=%q +if [ "$#" -ne 1 ]; then + echo "unexpected sleep args: $*" >&2 + exit 64 +fi +case "$1" in + 0.5|0.500) + ;; + *) + echo "unexpected sleep duration: $1" >&2 + exit 64 + ;; +esac +printf 'sleep %%s\n' "$1" >> "$invocation_file" +`, sleepInvocationFile) + if err := os.WriteFile(fakeSleep, []byte(fakeSleepScript), 0o755); err != nil { + t.Fatal(err) } - defer func() { - _ = holder.Process.Kill() - _ = holder.Wait() - }() - deadline := time.Now().Add(5 * time.Second) - for { - if _, err := os.Stat(readyFile); err == nil { - break - } - if time.Now().After(deadline) { - t.Fatal("timed out waiting for lock holder to acquire flock") - } - time.Sleep(25 * time.Millisecond) + fakeDolt := filepath.Join(binDir, "dolt") + if err := os.WriteFile(fakeDolt, []byte("#!/bin/sh\nset -eu\ncase \"${1:-}\" in\n config)\n exit 0\n ;;\n *)\n printf 'dolt %s\\n' \"$*\" >> \"$GC_FAKE_DOLT_INVOCATION_FILE\"\n exit 1\n ;;\nesac\n"), 0o755); err != nil { + t.Fatal(err) } + invokedDolt := filepath.Join(t.TempDir(), "dolt-invocation") env := sanitizedBaseEnv( "GC_CITY_PATH="+cityPath, - "GC_DOLT_PORT=3311", + "GC_DOLT_PORT=3399", + "GC_DOLT_CONCURRENT_START_READY_TIMEOUT_MS=1000", "GC_BIN="+fakeGC, "GC_FAKE_DOLT_INVOCATION_FILE="+invokedDolt, "PATH="+strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)), @@ -8767,8 +8839,32 @@ sleep 1 if got := strings.TrimSpace(string(mustReadFile(t, layout.PIDFile))); got != "4242" { t.Fatalf("pid file = %q, want 4242", got) } - if _, err := os.Stat(startedFile); err != nil { - t.Fatalf("concurrent starter success marker missing after start returned: %v", err) + state, err := readDoltRuntimeStateFile(layout.StateFile) + if err != nil { + t.Fatalf("read provider runtime state: %v", err) + } + if !state.Running || state.PID != 4242 || state.Port != 3311 { + t.Fatalf("provider runtime state = {Running:%v PID:%d Port:%d}, want {Running:true PID:4242 Port:3311}", state.Running, state.PID, state.Port) + } + if got := strings.TrimSpace(string(mustReadFile(t, existingCountFile))); got != "2" { + t.Fatalf("existing-managed observations = %q, want 2", got) + } + invocations := string(mustReadFile(t, invocationFile)) + firstExisting := strings.Index(invocations, "gc dolt-state existing-managed\n") + probe := strings.Index(invocations, "gc dolt-state probe-managed\n") + query := strings.Index(invocations, "gc dolt-state query-probe\n") + secondExisting := strings.LastIndex(invocations, "gc dolt-state existing-managed\n") + if firstExisting < 0 || firstExisting == secondExisting || probe < firstExisting || query < probe || secondExisting < query { + t.Fatalf("initial non-reusable observation must fail its probe before reusable state is observed:\n%s", invocations) + } + if got := strings.Count(invocations, "gc dolt-state query-probe\n"); got != 1 { + t.Fatalf("query-probe observations = %d, want 1:\n%s", got, invocations) + } + if got, want := string(mustReadFile(t, flockInvocationFile)), strings.Repeat("flock -n 9\n", 6); got != want { + t.Fatalf("flock transcript = %q, want %q", got, want) + } + if got, want := string(mustReadFile(t, sleepInvocationFile)), strings.Repeat("sleep 0.5\n", 6)+"sleep 0.500\n"; got != want { + t.Fatalf("sleep transcript = %q, want %q", got, want) } if invocation, err := os.ReadFile(invokedDolt); err == nil && strings.TrimSpace(string(invocation)) != "" { t.Fatalf("dolt should not have been invoked while concurrent starter won:\n%s", string(invocation)) @@ -8809,9 +8905,9 @@ case "$subcmd" in now=$(cat "$now_file") else now=1000000 + printf '%%s\n' "$now" > "$now_file" fi printf '%%s\n' "$now" - printf '%%s\n' $((now + 250)) > "$now_file" ;; "dolt-state runtime-layout") city="" @@ -8960,6 +9056,42 @@ esac if err := os.WriteFile(fakeDolt, []byte("#!/bin/sh\nset -eu\ncase \"${1:-}\" in\n config)\n exit 0\n ;;\n *)\n printf 'dolt %s\\n' \"$*\" >> \"$GC_FAKE_DOLT_INVOCATION_FILE\"\n exit 1\n ;;\nesac\n"), 0o755); err != nil { t.Fatal(err) } + fakeSleep := filepath.Join(binDir, "sleep") + fakeSleepScript := fmt.Sprintf(`#!/bin/sh +set -eu +now_file=%q +started_file=%q +if [ "$#" -ne 1 ]; then + echo "sleep: expected exactly one duration" >&2 + exit 64 +fi +case "$1" in + 0.5|0.500) + ;; + *) + echo "sleep: unexpected duration $1" >&2 + exit 64 + ;; +esac +if [ ! -f "$now_file" ]; then + exit 0 +fi +now=$(cat "$now_file") +case "$now" in + ''|*[!0-9]*) + echo "sleep: invalid fake clock $now" >&2 + exit 65 + ;; +esac +now=$((now + 500)) +printf '%%s\n' "$now" > "$now_file" +if [ "$now" -ge 1011000 ]; then + : > "$started_file" +fi +`, nowFile, startedFile) + if err := os.WriteFile(fakeSleep, []byte(fakeSleepScript), 0o755); err != nil { + t.Fatal(err) + } invokedDolt := filepath.Join(t.TempDir(), "dolt-invocation") readyFile := filepath.Join(t.TempDir(), "holder-ready") @@ -8967,15 +9099,12 @@ esac set -eu lock_file="$1" ready_file="$2" -started_file="$3" : > "$lock_file" exec 9>"$lock_file" flock 9 printf 'ready\n' > "$ready_file" -sleep 11 -printf 'ready\n' > "$started_file" -sleep 1 -`, "sh", layout.LockFile, readyFile, startedFile) +exec sleep 60 +`, "sh", layout.LockFile, readyFile) holder.Env = sanitizedBaseEnv("PATH=" + os.Getenv("PATH")) if err := holder.Start(); err != nil { t.Fatalf("start lock holder: %v", err) @@ -9009,11 +9138,12 @@ sleep 1 if err != nil { t.Fatalf("gc-beads-bd start failed while slow concurrent starter was making progress: %v\n%s", err, out) } - if got := strings.TrimSpace(string(mustReadFile(t, layout.PIDFile))); got != "4242" { - t.Fatalf("pid file = %q, want 4242", got) + readyAt, err := strconv.Atoi(strings.TrimSpace(string(mustReadFile(t, nowFile)))) + if err != nil { + t.Fatalf("parse simulated concurrent-ready clock: %v", err) } - if _, err := os.Stat(startedFile); err != nil { - t.Fatalf("concurrent starter success marker missing after start returned: %v", err) + if elapsed := readyAt - 1000000; elapsed <= 10000 || elapsed >= 12000 { + t.Fatalf("concurrent starter became ready after %dms, want more than 10000ms and less than the 12000ms deadline", elapsed) } if invocation, err := os.ReadFile(invokedDolt); err == nil && strings.TrimSpace(string(invocation)) != "" { t.Fatalf("dolt should not have been invoked while concurrent starter won:\n%s", string(invocation)) @@ -9658,77 +9788,14 @@ func TestGcBeadsBdStartIsIdempotentWhenAlreadyRunning(t *testing.T) { if err := os.MkdirAll(binDir, 0o755); err != nil { t.Fatal(err) } - - countFile := filepath.Join(t.TempDir(), "dolt-start-count") - fakeDolt := filepath.Join(binDir, "dolt") - port := freeLoopbackPort(t) - fakeScript := `#!/bin/sh -set -eu -count_file="` + countFile + `" -case "${1:-}" in - config) - exit 0 - ;; - sql-server) - count=0 - if [ -f "$count_file" ]; then - count=$(cat "$count_file") - fi - count=$((count + 1)) - printf '%s\n' "$count" > "$count_file" - config_file="" - prev="" - for arg in "$@"; do - if [ "$prev" = "--config" ]; then - config_file="$arg" - break - fi - prev="$arg" - done - port=$(awk '/port:/ {print $2; exit}' "$config_file") - data_dir=$(awk '/data_dir:/ {print $2; exit}' "$config_file" | tr -d '"') - exec python3 - "$port" "$data_dir" <<'INNERPY' -import os -import signal -import socket -import sys -import time -port = int(sys.argv[1]) -data_dir = sys.argv[2] -if data_dir: - os.makedirs(data_dir, exist_ok=True) - os.chdir(data_dir) -sock = socket.socket() -sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) -sock.bind(("0.0.0.0", port)) -sock.listen(128) -sock.settimeout(1.0) -def _stop(*_args): - raise SystemExit(0) -signal.signal(signal.SIGTERM, _stop) -signal.signal(signal.SIGINT, _stop) -while True: - try: - conn, _ = sock.accept() - conn.close() - except socket.timeout: - continue -INNERPY - ;; - *) - exit 0 - ;; -esac - ` - if err := os.WriteFile(fakeDolt, []byte(fakeScript), 0o755); err != nil { - t.Fatal(err) - } - gcBin := currentGCBinaryForTests(t) + invocationFile := filepath.Join(t.TempDir(), "gc-invocation") + fakeGC := writeFakeManagedConfigWriterGC(t, binDir, invocationFile) + writeFakeManagedConfigWriterDolt(t, binDir) env := sanitizedBaseEnv( "GC_CITY_PATH="+cityPath, - "GC_BIN="+gcBin, - "GC_DOLT_PORT="+port, + "GC_BIN="+fakeGC, + "GC_FAKE_DOLT_FAIL_SQL_SERVER=true", "PATH="+strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)), ) @@ -9749,7 +9816,10 @@ esac _ = stop.Run() }) - firstPIDData, err := os.ReadFile(filepath.Join(cityPath, ".gc", "runtime", "packs", "dolt", "dolt.pid")) + runtimeDir := filepath.Join(cityPath, ".gc", "runtime", "packs", "dolt-from-gc") + pidPath := filepath.Join(runtimeDir, "dolt.pid") + statePath := filepath.Join(runtimeDir, "dolt-provider-state.json") + firstPIDData, err := os.ReadFile(pidPath) if err != nil { t.Fatalf("read first pid file: %v", err) } @@ -9757,29 +9827,37 @@ esac if firstPID == "" { t.Fatal("first pid file is empty") } - initialStartCount := readDoltStartCountForTest(t, countFile) + firstState, err := os.ReadFile(statePath) + if err != nil { + t.Fatalf("read first state file: %v", err) + } + if !strings.Contains(string(firstState), "\"pid\":"+firstPID) { + t.Fatalf("provider state file should record pid %s, got: %s", firstPID, firstState) + } runStart() - secondPIDData, err := os.ReadFile(filepath.Join(cityPath, ".gc", "runtime", "packs", "dolt", "dolt.pid")) + secondPIDData, err := os.ReadFile(pidPath) if err != nil { t.Fatalf("read second pid file: %v", err) } - secondPID := strings.TrimSpace(string(secondPIDData)) - if secondPID != firstPID { - t.Fatalf("repeated start changed pid from %q to %q", firstPID, secondPID) + if !bytes.Equal(secondPIDData, firstPIDData) { + t.Fatalf("repeated start changed pid file from %q to %q", firstPIDData, secondPIDData) } - - if got := readDoltStartCountForTest(t, countFile); got != initialStartCount { - t.Fatalf("dolt sql-server launch count = %d, want unchanged from initial %d", got, initialStartCount) + secondState, err := os.ReadFile(statePath) + if err != nil { + t.Fatalf("read second state file: %v", err) + } + if !bytes.Equal(secondState, firstState) { + t.Fatalf("repeated start changed provider state:\nfirst: %s\nsecond: %s", firstState, secondState) } - state, err := os.ReadFile(filepath.Join(cityPath, ".gc", "runtime", "packs", "dolt", "dolt-provider-state.json")) - if err != nil { - t.Fatalf("read state file: %v", err) + invocation := string(mustReadFile(t, invocationFile)) + if got := strings.Count(invocation, "gc dolt-state existing-managed\n"); got != 2 { + t.Fatalf("existing-managed invocation count = %d, want 2:\n%s", got, invocation) } - if !strings.Contains(string(state), "\"pid\":"+firstPID) { - t.Fatalf("provider state file should preserve original pid %s, got: %s", firstPID, state) + if got := strings.Count(invocation, "gc dolt-state start-managed\n"); got != 1 { + t.Fatalf("start-managed invocation count = %d, want 1:\n%s", got, invocation) } } @@ -9923,167 +10001,99 @@ esac } func TestGcBeadsBdEnsureReadyDoesNotRestartAfterTransientTCPProbeFailure(t *testing.T) { - skipSlowCmdGCTest(t, "starts the real gc-beads-bd lifecycle script; run make test-cmd-gc-process for full coverage") - cityPath := t.TempDir() - if err := os.MkdirAll(filepath.Join(cityPath, ".gc"), 0o755); err != nil { - t.Fatal(err) + embedded, err := bdpack.PackFS.ReadFile("assets/scripts/gc-beads-bd.sh") + if err != nil { + t.Fatalf("read embedded gc-beads-bd.sh: %v", err) } - - materializeBuiltinPacksForTest(t, cityPath) - script := gcBeadsBdScriptPath(cityPath) - - binDir := filepath.Join(t.TempDir(), "bin") - if err := os.MkdirAll(binDir, 0o755); err != nil { - t.Fatal(err) + prelude, _, found := strings.Cut(string(embedded), "\n# --- Main ---\n") + if !found { + t.Fatal("embedded gc-beads-bd.sh is missing the main boundary") } - countFile := filepath.Join(t.TempDir(), "dolt-start-count") - fakeDolt := filepath.Join(binDir, "dolt") - port := freeLoopbackPort(t) - fakeScript := fmt.Sprintf(`#!/bin/sh -set -eu -count_file=%q -case "${1:-}" in - config) - exit 0 - ;; - sql-server) - count=0 - if [ -f "$count_file" ]; then - count=$(cat "$count_file") - fi - count=$((count + 1)) - printf '%%s\n' "$count" > "$count_file" - config_file="" - prev="" - for arg in "$@"; do - if [ "$prev" = "--config" ]; then - config_file="$arg" - break - fi - prev="$arg" - done - port=$(awk '/port:/ {print $2; exit}' "$config_file") - exec python3 - "$port" <<'INNERPY' -import signal -import socket -import sys -import time -port = int(sys.argv[1]) -sock = socket.socket() -sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) -sock.bind(("0.0.0.0", port)) -sock.listen(128) -sock.settimeout(1.0) -def _stop(*_args): - raise SystemExit(0) -signal.signal(signal.SIGTERM, _stop) -signal.signal(signal.SIGINT, _stop) -while True: - try: - conn, _ = sock.accept() - conn.close() - except socket.timeout: - continue -INNERPY - ;; - *) - exit 0 - ;; -esac -`, countFile) - if err := os.WriteFile(fakeDolt, []byte(fakeScript), 0o755); err != nil { - t.Fatal(err) - } + stablePID := strconv.Itoa(os.Getpid()) + stateDir := t.TempDir() + pidFile := filepath.Join(stateDir, "dolt.pid") + traceFile := filepath.Join(stateDir, "trace") + startMarker := filepath.Join(stateDir, "op-start-called") + harness := prelude + ` + +# Replace only the operating-system leaves around the real readiness loop. +TRACE_FILE="$GC_TEST_TRACE_FILE" +PID_FILE="$GC_TEST_PID_FILE" +DOLT_PORT=15000 +TCP_ATTEMPTS=0 + +trace() { printf '%s\n' "$1" >> "$TRACE_FILE"; } +is_remote() { return 1; } +find_dolt_pid() { trace "find:$GC_TEST_PID"; printf '%s\n' "$GC_TEST_PID"; } +verify_our_server() { trace "identity:$1"; [ "$1" = "$GC_TEST_PID" ]; } +load_state_field() { + trace "load:$1" + [ "$1" = port ] || return 1 + printf '%s\n' "$GC_TEST_STATE_PORT" +} +save_state() { trace "save:$1:$2:$DOLT_PORT"; } +has_deleted_data_inodes() { trace "deleted:$1"; return 1; } +tcp_check_port() { + TCP_ATTEMPTS=$((TCP_ATTEMPTS + 1)) + trace "tcp:$TCP_ATTEMPTS:$1" + [ "$TCP_ATTEMPTS" -ge 2 ] +} +do_query_probe() { trace "query:$DOLT_PORT"; return 0; } +sleep() { trace sleep; return 0; } +op_start() { + trace start + : > "$GC_TEST_START_MARKER" + return 97 +} + +op_ensure_ready +` - // Must use sanitizedBaseEnv, not append(os.Environ(), ...). Raw - // inheritance leaks GC_CITY_RUNTIME_DIR / GC_PACK_STATE_DIR / - // GC_DOLT_STATE_FILE from the user's shell into this script, aiming - // dolt-provider-state.json at the user's real registered city - // instead of this test's t.TempDir() — confirmed in the wild on a - // dev workstation where a previous run of this test clobbered a - // live city. Regression guard for gastownhall/gascity#938. - poisonRuntimeDir := filepath.Join(t.TempDir(), "poison-runtime") - poisonPackStateDir := filepath.Join(poisonRuntimeDir, "packs", "dolt") - poisonStateFile := filepath.Join(poisonPackStateDir, "dolt-provider-state.json") - t.Setenv("GC_CITY_RUNTIME_DIR", poisonRuntimeDir) - t.Setenv("GC_PACK_STATE_DIR", poisonPackStateDir) - t.Setenv("GC_DOLT_STATE_FILE", poisonStateFile) - baseEnv := sanitizedBaseEnv( - "GC_CITY_PATH="+cityPath, + cmd := exec.Command("sh") + cmd.Stdin = strings.NewReader(harness) + cmd.Env = sanitizedBaseEnv( "GC_BIN=", - "GC_DOLT_PORT="+port, - "PATH="+strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)), + "GC_TEST_PID="+stablePID, + "GC_TEST_PID_FILE="+pidFile, + "GC_TEST_TRACE_FILE="+traceFile, + "GC_TEST_START_MARKER="+startMarker, + "GC_TEST_STATE_PORT=15432", ) - - runScript := func(env []string, args ...string) { - t.Helper() - cmd := exec.Command(script, args...) - cmd.Env = env - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("gc-beads-bd %s failed: %v\n%s", strings.Join(args, " "), err, out) - } + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("run gc-beads-bd ensure-ready function harness: %v\n%s", err, out) } - runScript(baseEnv, "start") - t.Cleanup(func() { - stop := exec.Command(script, "stop") - stop.Env = baseEnv - _ = stop.Run() - }) - - firstPIDData, err := os.ReadFile(filepath.Join(cityPath, ".gc", "runtime", "packs", "dolt", "dolt.pid")) + persistedPID, err := os.ReadFile(pidFile) if err != nil { - t.Fatalf("read first pid file: %v", err) - } - firstPID := strings.TrimSpace(string(firstPIDData)) - if firstPID == "" { - t.Fatal("first pid file is empty") + t.Fatalf("read persisted pid: %v", err) } - initialStartCount := readDoltStartCountForTest(t, countFile) - - shimDir := filepath.Join(t.TempDir(), "shim") - if err := os.MkdirAll(shimDir, 0o755); err != nil { - t.Fatal(err) + if got := strings.TrimSpace(string(persistedPID)); got != stablePID { + t.Fatalf("persisted pid = %q, want original live pid %q", got, stablePID) } - probeFile := filepath.Join(shimDir, "nc-once") - shimPath := filepath.Join(shimDir, "nc") - shim := fmt.Sprintf(`#!/bin/sh -set -eu -probe_file=%q -if [ ! -f "$probe_file" ]; then - : > "$probe_file" - exit 1 -fi -exit 0 -`, probeFile) - if err := os.WriteFile(shimPath, []byte(shim), 0o755); err != nil { - t.Fatal(err) + if _, err := os.Stat(startMarker); !os.IsNotExist(err) { + t.Fatalf("op_start was called after a transient readiness failure, stat err = %v", err) } - envWithShim := sanitizedBaseEnv( - "GC_CITY_PATH="+cityPath, - "GC_BIN=", - "GC_DOLT_PORT="+port, - "PATH="+strings.Join([]string{shimDir, binDir, os.Getenv("PATH")}, string(os.PathListSeparator)), - ) - runScript(envWithShim, "ensure-ready") - - secondPIDData, err := os.ReadFile(filepath.Join(cityPath, ".gc", "runtime", "packs", "dolt", "dolt.pid")) + trace, err := os.ReadFile(traceFile) if err != nil { - t.Fatalf("read second pid file: %v", err) - } - secondPID := strings.TrimSpace(string(secondPIDData)) - if secondPID != firstPID { - t.Fatalf("ensure-ready changed pid from %q to %q after transient tcp probe failure", firstPID, secondPID) - } - - if got := readDoltStartCountForTest(t, countFile); got != initialStartCount { - t.Fatalf("dolt sql-server launch count = %d, want unchanged from initial %d", got, initialStartCount) + t.Fatalf("read readiness trace: %v", err) } - if _, err := os.Stat(poisonStateFile); !os.IsNotExist(err) { - t.Fatalf("ensure-ready leaked ambient GC_* state to %q, stat err = %v", poisonStateFile, err) + wantTrace := strings.Join([]string{ + "find:" + stablePID, + "identity:" + stablePID, + "load:port", + "deleted:" + stablePID, + "tcp:1:15432", + "sleep", + "deleted:" + stablePID, + "tcp:2:15432", + "query:15432", + "deleted:" + stablePID, + "save:" + stablePID + ":true:15432", + }, "\n") + "\n" + if got := string(trace); got != wantTrace { + t.Fatalf("readiness trace:\n%s\nwant:\n%s", got, wantTrace) } } @@ -10499,81 +10509,6 @@ func TestStartBeadsLifecycleRegistersAutoGCOnlyDoltConfig(t *testing.T) { } } -func TestStartBeadsLifecycleManagedDeferredDoesNotRequireRuntimeState(t *testing.T) { - // The post-init Dolt catalog verifier needs a real MySQL-speaking - // server. This test wires only a bare TCP listener as the "managed - // Dolt port", which is enough for the rest of the lifecycle but not - // for SHOW DATABASES. Stub the verifier — coverage for the verifier - // itself lives in focused unit tests below; this lifecycle test only - // needs to prove startup does not require pre-existing runtime state. - originalVerifier := verifyManagedDoltDatabaseExistsAfterInit - verifyManagedDoltDatabaseExistsAfterInit = func(_, _, _ string) error { return nil } - t.Cleanup(func() { verifyManagedDoltDatabaseExistsAfterInit = originalVerifier }) - - cityPath := t.TempDir() - rigPath := filepath.Join(cityPath, "rig") - if err := os.MkdirAll(rigPath, 0o755); err != nil { - t.Fatal(err) - } - callLog := filepath.Join(cityPath, "op-calls.log") - providerState := filepath.Join(cityPath, ".gc", "runtime", "packs", "dolt", "dolt-provider-state.json") - ln := listenOnRandomPort(t) - defer func() { _ = ln.Close() }() - port := ln.Addr().(*net.TCPAddr).Port - scriptBody := fmt.Sprintf(`#!/bin/sh -echo "$@" >> %q -if [ "$1" = "start" ]; then - mkdir -p "$(dirname %q)" - cat > %q <<'JSON' - {"running":true,"pid":%d,"port":%d,"data_dir":%q,"started_at":"2026-04-14T00:00:00Z"} - JSON - fi -if [ "$1" = "init" ]; then - mkdir -p "$2/.beads" -fi -exit 0 - `, callLog, providerState, providerState, os.Getpid(), port, filepath.Join(cityPath, ".beads", "dolt")) - script := writeManagedBdTestScript(t, scriptBody) - writeExecStoreCityConfig(t, cityPath, "test-city", "", []config.Rig{{Name: "rig", Path: rigPath, Prefix: "rg"}}) - seedDeferredManagedBeads(cityPath, cityPath, "tc", "hq") - seedDeferredManagedBeads(cityPath, rigPath, "rg", "rg") - if err := writeDoltRuntimeStateFile(providerState, doltRuntimeState{ - Running: true, - PID: os.Getpid(), - Port: port, - DataDir: filepath.Join(cityPath, ".beads", "dolt"), - StartedAt: time.Now().UTC().Format(time.RFC3339), - }); err != nil { - t.Fatal(err) - } - - t.Setenv("GC_BEADS", "exec:"+script) - t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) - cfg := &config.City{ - Workspace: config.Workspace{Name: "test-city"}, - Rigs: []config.Rig{{Name: "rig", Path: rigPath, Prefix: "rg"}}, - } - - if err := startBeadsLifecycle(cityPath, "test-city", cfg, io.Discard); err != nil { - t.Fatalf("startBeadsLifecycle: %v", err) - } - - data, err := os.ReadFile(callLog) - if err != nil { - t.Fatalf("reading call log: %v", err) - } - ops := string(data) - for _, needle := range []string{ - "start", - "init " + cityPath + " tc hq", - "init " + rigPath + " rg rg", - } { - if !strings.Contains(ops, needle) { - t.Fatalf("call log missing %q:\n%s", needle, ops) - } - } -} - func TestHealthBeadsProviderDoesNotRecoverExternalLoopbackTarget(t *testing.T) { cityPath := t.TempDir() callLog := filepath.Join(cityPath, "op-calls.log") @@ -11046,7 +10981,7 @@ prefix = "fe" t.Fatal(err) } - probeLog := filepath.Join(t.TempDir(), "dolt-probe.log") + bdInitLog := filepath.Join(t.TempDir(), "bd-init.args") fakeBd := filepath.Join(binDir, "bd") fakeBdScript := `#!/bin/sh set -eu @@ -11066,26 +11001,16 @@ dolt.auto-start: true dolt_server_port: 3307 YAML : > "$last/.beads/dolt-server.pid" - : > "$last/.beads/dolt-server.lock" - : > "$last/.beads/dolt-server.log" - printf '3307\n' > "$last/.beads/dolt-server.port" - exit 0 - ;; - list) - db=$(python3 -c 'import json, pathlib, sys; meta = json.loads(pathlib.Path(sys.argv[1]).read_text()); print(meta.get("dolt_database", ""), end="")' "$PWD/.beads/metadata.json") - printf '%s\t%s\n' "${GC_FAKE_BD_CALLER:-unknown}" "$db" >> "` + probeLog + `" - exit 0 - ;; - migrate) - python3 -c 'import json, pathlib, sys; path = pathlib.Path(sys.argv[1]); data = json.loads(path.read_text()); data["project_id"] = "normalized-project-id"; path.write_text(json.dumps(data, indent=2) + "\n")' "$PWD/.beads/metadata.json" - exit 0 - ;; - config|list) - exit 0 - ;; + : > "$last/.beads/dolt-server.lock" + : > "$last/.beads/dolt-server.log" + printf '3307\n' > "$last/.beads/dolt-server.port" + printf '%s\n' "$*" > "` + bdInitLog + `" + exit 0 + ;; *) - exit 0 - ;; + echo "unexpected bd command: $*" >&2 + exit 64 + ;; esac ` if err := os.WriteFile(fakeBd, []byte(fakeBdScript), 0o755); err != nil { @@ -11097,35 +11022,24 @@ esac t.Fatal(err) } - realGC := currentGCBinaryForTests(t) + reexecGC := reexecGCTestBinaryForTests(t) gcWrapper := filepath.Join(binDir, "gc-wrapper") gcWrapperScript := fmt.Sprintf(`#!/bin/sh set -eu real_gc=%q -if [ "${1:-}" = "dolt-state" ] && [ "${2:-}" = "ensure-project-id" ]; then - metadata="" - shift 2 - while [ "$#" -gt 0 ]; do - case "$1" in - --metadata) - metadata="$2" - shift 2 - ;; - --city|--host|--port|--user|--database) - shift 2 - ;; - *) - shift - ;; - esac - done - if [ -n "$metadata" ] && [ -f "$metadata" ]; then - python3 -c 'import json, pathlib, sys; path = pathlib.Path(sys.argv[1]); data = json.loads(path.read_text()); data["project_id"] = "stubbed-project-id"; path.write_text(json.dumps(data, indent=2) + "\n")' "$metadata" - fi - exit 0 -fi -exec "$real_gc" "$@" -`, realGC) +case "${1:-} ${2:-}" in + "dolt-state ensure-project-id") + exit 0 + ;; + "dolt-config normalize-scope") + exec "$real_gc" "$@" + ;; + *) + echo "unexpected gc helper command: $*" >&2 + exit 64 + ;; +esac +`, reexecGC) if err := os.WriteFile(gcWrapper, []byte(gcWrapperScript), 0o755); err != nil { t.Fatal(err) } @@ -11133,6 +11047,7 @@ exec "$real_gc" "$@" cmd := exec.Command(script, "init", rigPath, "fe", "fe") cmd.Env = sanitizedBaseEnv(append(gcBeadsBdTestHomeEnv(t), "GC_CITY_PATH="+cityPath, + "GC_BEADS=bd", "GC_BIN="+gcWrapper, "PATH="+strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)), )...) @@ -11140,21 +11055,26 @@ exec "$real_gc" "$@" if err != nil { t.Fatalf("gc-beads-bd init failed: %v\n%s", err, out) } + bdInitData, err := os.ReadFile(bdInitLog) + if err != nil { + t.Fatalf("ReadFile(bd init call): %v", err) + } + if args := strings.Fields(string(bdInitData)); len(args) == 0 || args[0] != "init" { + t.Fatalf("bd init call = %q, want init invocation before normalization", strings.TrimSpace(string(bdInitData))) + } metaData, err := os.ReadFile(filepath.Join(rigPath, ".beads", "metadata.json")) if err != nil { t.Fatalf("ReadFile(rig metadata): %v", err) } - metaText := string(metaData) - for _, forbidden := range []string{"dolt_host", "dolt_user", "dolt_password", "dolt_server_host", "dolt_server_port", "dolt_server_user", "dolt_port", "wrong-db"} { - if strings.Contains(metaText, forbidden) { - t.Fatalf("rig metadata still contains %q:\n%s", forbidden, metaText) - } + var metadata struct { + DoltDatabase string `json:"dolt_database"` } - for _, want := range []string{`"database": "dolt"`, `"backend": "dolt"`, `"dolt_mode": "server"`, `"dolt_database": "fe"`} { - if !strings.Contains(metaText, want) { - t.Fatalf("rig metadata missing %q:\n%s", want, metaText) - } + if err := json.Unmarshal(metaData, &metadata); err != nil { + t.Fatalf("Unmarshal(rig metadata): %v", err) + } + if metadata.DoltDatabase != "fe" { + t.Fatalf("rig dolt_database = %q, want fresh-init scope %q", metadata.DoltDatabase, "fe") } rigCfg, err := os.ReadFile(filepath.Join(rigPath, ".beads", "config.yaml")) @@ -11162,39 +11082,15 @@ exec "$real_gc" "$@" t.Fatalf("ReadFile(rig config): %v", err) } cfgText := string(rigCfg) - for _, want := range []string{"issue_prefix: fe", "gc.endpoint_origin: inherited_city", "gc.endpoint_status: verified"} { + for _, want := range []string{"issue_prefix: fe", "gc.endpoint_origin: inherited_city"} { if !strings.Contains(cfgText, want) { t.Fatalf("rig config missing %q:\n%s", want, cfgText) } } - for _, forbidden := range []string{"dolt.host:", "dolt.port:", "dolt_server_port"} { - if strings.Contains(cfgText, forbidden) { - t.Fatalf("rig config still contains %q:\n%s", forbidden, cfgText) - } - } - - for _, name := range []string{"dolt-server.pid", "dolt-server.lock", "dolt-server.log", "dolt-server.port"} { - if _, err := os.Stat(filepath.Join(rigPath, ".beads", name)); !os.IsNotExist(err) { - t.Fatalf("rig %s should be removed after init, stat err = %v", name, err) - } - } - - t.Setenv("GC_FAKE_BD_CALLER", "raw") - _ = runRawBDFromDir(t, fakeBd, rigPath, "list") - - t.Setenv("GC_FAKE_BD_CALLER", "gc") - t.Setenv("PATH", strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator))) - var stdout, stderr bytes.Buffer - if code := doBd([]string{"--city", cityPath, "--rig", "frontend", "list"}, &stdout, &stderr); code != 0 { - t.Fatalf("gc bd list = %d; stdout=%q stderr=%q", code, stdout.String(), stderr.String()) - } - probeData, err := os.ReadFile(probeLog) - if err != nil { - t.Fatalf("read probe log: %v", err) - } - if got := strings.TrimSpace(string(probeData)); got != "raw\tfe\ngc\tfe" { - t.Fatalf("probe log = %q, want repaired rig database for both raw bd and gc bd", got) + artifact := filepath.Join(rigPath, ".beads", "dolt-server.port") + if _, err := os.Stat(artifact); !os.IsNotExist(err) { + t.Fatalf("fresh-init local server artifact should be removed, stat err = %v", err) } } @@ -11239,57 +11135,77 @@ func TestNormalizeCanonicalBdScopeFilesMaterializesMissingMetadata(t *testing.T) } } -func TestGcBeadsBdStartFallsBackToShellManagedConfigWriterWhenGCBinUnset(t *testing.T) { - skipSlowCmdGCTest(t, "starts the materialized gc-beads-bd shell fallback; run make test-cmd-gc-process for full coverage") - cityPath := t.TempDir() - if err := os.MkdirAll(filepath.Join(cityPath, ".gc"), 0o755); err != nil { - t.Fatal(err) +func TestGcBeadsBdWriteConfigYamlFallsBackToShellWhenGCBinUnset(t *testing.T) { + scriptData, err := bdpack.PackFS.ReadFile("assets/scripts/gc-beads-bd.sh") + if err != nil { + t.Fatalf("read embedded gc-beads-bd.sh: %v", err) + } + prelude, _, ok := strings.Cut(string(scriptData), "\n# --- Main ---\n") + if !ok { + t.Fatal("embedded gc-beads-bd.sh missing main marker") } - materializeBuiltinPacksForTest(t, cityPath) - script := gcBeadsBdScriptPath(cityPath) + testDir := t.TempDir() + configFile := filepath.Join(testDir, "dolt-config.yaml") + dataDir := filepath.Join(testDir, "dolt-data") + const ( + wantHost = "127.0.0.42" + wantPort = 13306 + wantLogLevel = "debug" + ) - binDir := filepath.Join(t.TempDir(), "bin") - if err := os.MkdirAll(binDir, 0o755); err != nil { - t.Fatal(err) + binDir := t.TempDir() + sentinelGC := filepath.Join(binDir, "gc") + if err := os.WriteFile(sentinelGC, []byte(`#!/bin/sh +printf '%s\n' "$*" > "${0}.called" +printf 'PATH gc sentinel invoked\n' >&2 +exit 97 +`), 0o755); err != nil { + t.Fatalf("write PATH gc sentinel: %v", err) } - invocationFile := filepath.Join(t.TempDir(), "gc-invocation") - _ = writeFakeManagedConfigWriterGC(t, binDir, invocationFile) - writeFakeManagedConfigWriterDolt(t, binDir) - - env := sanitizedBaseEnv( - "GC_CITY_PATH="+cityPath, - "PATH="+strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)), + harness := prelude + ` +if [ "${GC_BIN+x}" = x ]; then + printf 'GC_BIN must be unset\n' >&2 + exit 96 +fi +CONFIG_FILE="$1" +DATA_DIR="$2" +DOLT_HOST="$3" +DOLT_PORT="$4" +DOLT_LOGLEVEL="$5" +write_config_yaml +` + harnessPath := filepath.Join(testDir, "write-config-harness.sh") + if err := os.WriteFile(harnessPath, []byte(harness), 0o755); err != nil { + t.Fatalf("write shell config harness: %v", err) + } + cmd := exec.Command("sh", harnessPath, configFile, dataDir, wantHost, strconv.Itoa(wantPort), wantLogLevel) + cmd.Env = sanitizedBaseEnv( + "PATH=" + strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)), ) - cmd := exec.Command(script, "start") - cmd.Env = env - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("gc-beads-bd start failed: %v\n%s", err, out) + out, runErr := cmd.CombinedOutput() + if invocation, err := os.ReadFile(sentinelGC + ".called"); err == nil { + t.Fatalf("PATH gc sentinel was called with %q while GC_BIN was empty\n%s", strings.TrimSpace(string(invocation)), out) + } else if !os.IsNotExist(err) { + t.Fatalf("read PATH gc sentinel record: %v", err) } - t.Cleanup(func() { - stop := exec.Command(script, "stop") - stop.Env = env - _ = stop.Run() - }) - - if _, err := os.Stat(invocationFile); !os.IsNotExist(err) { - t.Fatalf("PATH gc should not be used for hidden helpers when GC_BIN is unset, stat err = %v", err) + if runErr != nil { + t.Fatalf("write_config_yaml shell fallback failed: %v\n%s", runErr, out) } - configData, err := os.ReadFile(filepath.Join(cityPath, ".gc", "runtime", "packs", "dolt", "dolt-config.yaml")) - if err != nil { - t.Fatalf("ReadFile(dolt-config.yaml): %v", err) + + cfg := readManagedDoltConfigForTest(t, configFile) + if got := cfg.Listener.Host; got != wantHost { + t.Fatalf("listener.host = %q, want %q", got, wantHost) } - if strings.Contains(string(configData), "# rendered by fake gc") { - t.Fatalf("dolt-config.yaml should be rendered by shell fallback, not PATH gc:\n%s", string(configData)) + if got := cfg.Listener.Port; got != wantPort { + t.Fatalf("listener.port = %d, want %d", got, wantPort) } - state, err := readDoltRuntimeStateFile(filepath.Join(cityPath, ".gc", "runtime", "packs", "dolt", "dolt-provider-state.json")) - if err != nil { - t.Fatalf("readDoltRuntimeStateFile: %v", err) + if got := cfg.DataDir; got != dataDir { + t.Fatalf("data_dir = %q, want %q", got, dataDir) } - if state.Port == 0 { - t.Fatalf("provider state port = %d, want non-zero", state.Port) + if got := cfg.LogLevel; got != wantLogLevel { + t.Fatalf("log_level = %q, want %q", got, wantLogLevel) } } diff --git a/cmd/gc/build_desired_state.go b/cmd/gc/build_desired_state.go index b728b3a09b..fd507d108a 100644 --- a/cmd/gc/build_desired_state.go +++ b/cmd/gc/build_desired_state.go @@ -13,11 +13,13 @@ import ( "sync/atomic" "time" + "github.com/gastownhall/gascity/internal/agentutil" "github.com/gastownhall/gascity/internal/beadmeta" "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/fsys" "github.com/gastownhall/gascity/internal/hooks" + "github.com/gastownhall/gascity/internal/poolplan" "github.com/gastownhall/gascity/internal/runtime" sessionauto "github.com/gastownhall/gascity/internal/runtime/auto" "github.com/gastownhall/gascity/internal/session" @@ -63,6 +65,15 @@ type DesiredStateResult struct { // Consumers that decide whether a specific agent should run must use // this scope before treating a bead as reachable work for that agent. AssignedWorkStoreRefs []string + // ReadyUnassignedRoutedWorkBeads contains the ready, routed, unassigned + // work selected as concrete default pool demand for this tick. It remains + // separate from AssignedWorkBeads so assignment/wake semantics stay + // assignee-only; the idle-claim backstop uses it to re-nudge an already + // running pool slot after that slot is rebound to newly routed work. + ReadyUnassignedRoutedWorkBeads []beads.Bead + // ReadyUnassignedRoutedWorkStoreRefs is index-aligned with + // ReadyUnassignedRoutedWorkBeads and uses canonical city:/rig: refs. + ReadyUnassignedRoutedWorkStoreRefs []string // NamedSessionDemand records which named-session identities have active // direct assignee demand (Assignee == identity). The reconciler merges this // into poolDesired so that on-demand named sessions remain config-eligible. @@ -132,204 +143,42 @@ var ( // contending pools so stable template sort order does not always win. var poolSessionCreateFairShareCounter atomic.Uint64 -type poolSessionCreateBudget struct { - mu sync.Mutex - remaining int - templateRemaining map[string]int - spare int -} - -func newPoolSessionCreateBudget(limit int) *poolSessionCreateBudget { - if limit <= 0 { - return nil - } - return &poolSessionCreateBudget{remaining: limit} -} - -func (b *poolSessionCreateBudget) configureFairShare(states []PoolDesiredState, seed uint64) { - if b == nil { +func (bp *agentBuildParams) configurePoolSessionCreateFairShare(states []PoolDesiredState) { + if bp == nil || bp.poolSessionCreateBudget == nil { return } - b.mu.Lock() - defer b.mu.Unlock() - shares, spare := fairPoolSessionCreateShares(states, b.remaining, seed) - b.templateRemaining = shares - b.spare = spare -} - -func fairPoolSessionCreateShares(states []PoolDesiredState, limit int, seed uint64) (map[string]int, int) { - if limit <= 0 { - return nil, 0 - } - type demand struct { - template string - count int - floor bool - } - var demands []demand + demands := make([]poolplan.Demand, 0, len(states)) for _, state := range states { - count := 0 - floor := false + demand := poolplan.Demand{Template: state.Template} for _, request := range state.Requests { // Requests with a session bead ID represent in-flight capacity and - // should not reserve fresh-create budget for this template. - if request.Tier == "new" && request.SessionBeadID == "" { - count++ - if request.FloorGuarantee { - floor = true - } - } - } - if count > 0 { - demands = append(demands, demand{template: state.Template, count: count, floor: floor}) - } - } - if len(demands) <= 1 { - return nil, 0 - } - shares := make(map[string]int, len(demands)) - remaining := limit - // start rotates the per-tick allocation by seed so neither the floor - // reservation (Phase 1) nor the elastic round-robin (Phase 2) deterministically - // favors the same (e.g. alphabetically-first) templates every tick. Without - // this rotation, when floor-bearing templates exceed the budget the same - // late-order floor templates would be starved on every tick and never spawn - // their floor (the starvation pattern fixed in fair wake-budget selection). - start := int(seed % uint64(len(demands))) - // Reserve a slice of the budget for elastic (non-floor) demand so a large - // floor set can't consume the whole budget in Phase 1 and starve elastic - // pools to zero. Without this, when floor-bearing demand >= the budget, an - // elastic pool with real demand (e.g. a high-queue rig executor sitting - // behind ~budget floor pools) gets zero create tokens every tick and never - // spawns a single session. Floors keep priority (3/4 of the budget) but the - // reserve guarantees elastic progress; for tiny budgets (< 4) the reserve is - // 0, preserving the original floor-first behavior. - elasticDemand := 0 - for _, d := range demands { - if !d.floor { - elasticDemand += d.count - } - } - elasticReserve := limit / 4 - if elasticReserve > elasticDemand { - elasticReserve = elasticDemand - } - floorBudget := limit - elasticReserve - // Phase 1: guarantee one create token per floor-bearing template - // (min_active_sessions floor) before elastic scale-check demand competes for - // the budget. Without this, a cold pool's lone floor request loses the - // round-robin to a warm pool's large demand and its floor never spawns. - // Reserved in seed-rotated order, capped at floorBudget so floors can't zero - // the elastic reserve; if floor-bearing templates exceed floorBudget, a - // different subset is prioritized each tick so none is permanently starved. - floorUsed := 0 - for off := 0; off < len(demands); off++ { - if floorUsed >= floorBudget { - break - } - d := demands[(start+off)%len(demands)] - if d.floor { - shares[d.template]++ - remaining-- - floorUsed++ - } - } - // Phase 2a: hand the reserved elastic slice to elastic (non-floor) demand - // before the general round-robin, so floors deferred out of Phase 1 can't - // reclaim it. Seed-rotated, capped at each template's request count. - elasticGiven := 0 - for elasticGiven < elasticReserve && remaining > 0 { - progressed := false - for offset := 0; offset < len(demands) && remaining > 0 && elasticGiven < elasticReserve; offset++ { - d := demands[(start+offset)%len(demands)] - if d.floor || shares[d.template] >= d.count { + // must not reserve fresh-create budget for this template. + if request.Tier != "new" || request.SessionBeadID != "" { continue } - shares[d.template]++ - remaining-- - elasticGiven++ - progressed = true + demand.FreshCreates++ + demand.HasFloor = demand.HasFloor || request.FloorGuarantee } - if !progressed { - break - } - } - // Phase 2b: round-robin the remaining budget across all demand, capped at - // each template's request count (a reserved floor token counts toward that - // cap, so a floor-only template is not topped up further here). - for remaining > 0 { - progressed := false - for offset := 0; offset < len(demands) && remaining > 0; offset++ { - d := demands[(start+offset)%len(demands)] - if shares[d.template] >= d.count { - continue - } - shares[d.template]++ - remaining-- - progressed = true - } - if !progressed { - break - } - } - return shares, remaining -} - -func (b *poolSessionCreateBudget) tryClaim(template string) bool { - if b == nil { - return true - } - b.mu.Lock() - defer b.mu.Unlock() - if b.remaining <= 0 { - return false - } - if b.templateRemaining != nil { - switch { - case b.templateRemaining[template] > 0: - b.templateRemaining[template]-- - case b.spare > 0: - b.spare-- - default: - return false + if demand.FreshCreates > 0 { + demands = append(demands, demand) } } - b.remaining-- - return true -} - -func (b *poolSessionCreateBudget) release() { - if b == nil { - return - } - b.mu.Lock() - defer b.mu.Unlock() - b.remaining++ - if b.templateRemaining != nil { - b.spare++ - } -} - -func (bp *agentBuildParams) configurePoolSessionCreateFairShare(states []PoolDesiredState) { - if bp == nil || bp.poolSessionCreateBudget == nil { - return - } seed := poolSessionCreateFairShareCounter.Add(1) - 1 - bp.poolSessionCreateBudget.configureFairShare(states, seed) + bp.poolSessionCreateBudget.ConfigureFairShare(demands, seed) } func (bp *agentBuildParams) tryClaimPoolSessionCreate(template string) bool { if bp == nil || bp.poolSessionCreateBudget == nil { return true } - return bp.poolSessionCreateBudget.tryClaim(template) + return bp.poolSessionCreateBudget.TryClaim(template) } func (bp *agentBuildParams) releasePoolSessionCreate() { if bp == nil || bp.poolSessionCreateBudget == nil { return } - bp.poolSessionCreateBudget.release() + bp.poolSessionCreateBudget.Release() } func evaluatePendingPools( @@ -632,14 +481,20 @@ func buildDesiredStateWithSessionBeads( namedOnDemandTemplates[template] = true } defaultNamedScaleTargets = append(defaultNamedScaleTargets, ownTarget) - // Cross-store cold-wake for named-backing pools (vp-cl4): mirror the - // generic-pool guard (vp-s37 / #3078 line ~598). A cold rig pool that - // backs a named session and has no custom scale_check must also probe - // the city store so that routed demand delivered there (vp-kvp) can - // wake the pool. Same guard conditions apply: healthy own rig store, - // not city-aliased, not city-scoped. The named-session target list + // Cross-store demand for named-backing pools (vp-cl4): mirror the + // generic-pool guard (vp-s37 / #3078 below). A rig pool that backs + // a named session and has no custom scale_check must also probe + // the city store so that routed demand delivered there (vp-kvp) + // counts, warm or cold — like the generic-pool probe below, this + // is NOT gated on isCold: a warm named-backing pool that only + // probed its rig store would drop to zero demand between city + // beads and be orphan-drained, then re-glimpse city demand on the + // next cold tick and respawn (the same spawn/drain treadmill, + // amplitude clamped to 1 by the namedOnDemandTemplates clamp). + // Same guard conditions apply: healthy own rig store, not + // city-aliased, not city-scoped. The named-session target list // mirrors these probes only for partial-query retention bookkeeping. - if isCold && !storeScopedControlDispatcher && ownTarget.storeKey != "city" && ownTarget.store != nil && ownTarget.err == nil && ownTarget.store != store { + if !storeScopedControlDispatcher && ownTarget.storeKey != "city" && ownTarget.store != nil && ownTarget.err == nil && ownTarget.store != store { cityTarget := defaultScaleCheckTarget{template: template, store: store, storeKey: "city"} if namedSessionMode != "always" { defaultScaleTargets = append(defaultScaleTargets, cityTarget) @@ -668,17 +523,35 @@ func buildDesiredStateWithSessionBeads( if store != nil && !hasCustomScaleCheck { ownTarget := defaultScaleCheckTargetForAgent(cityPath, cfg, &cfg.Agents[i], store, rigStores) defaultScaleTargets = append(defaultScaleTargets, ownTarget) - // Cross-store cold-wake (FR-S0.1 / vp-s37): a cold rig pool's routed - // demand may live in the city store (vp-kvp cross-store delivery), - // which the own-rig probe above cannot see while the pool sleeps — - // so a sleeping rig pool would never wake to discover it. Add a - // city-store probe for cold rig pools so their demand reflects - // routed work in either store. No clamp: unlike a custom-scale_check - // pool — where the probe is clamped so it cannot override the custom - // count (see coldWakeTemplates below) — the default probe IS the + // Cross-store demand (FR-S0.1 / vp-s37): a rig pool's routed demand + // may live in the city store (vp-kvp cross-store delivery), which + // the own-rig probe above cannot see. Add a city-store probe so the + // pool's demand reflects routed work in either store — matching the + // claim path, where a rig agent's work_query already federates + // across stores and claims city-delivered work. + // + // NOT gated on isCold. This probe began as a cold-wake assist (a + // sleeping pool can't discover city-store demand), but gating it on + // isCold left a WARM rig pool structurally blind to the same + // demand: its count stayed pinned at the rig-store total while + // routed beads sat unclaimed in the city store, and a pool at the + // warm/cold boundary oscillated pool_desired N↔0 (cold ticks + // glimpsed city demand and spawned; warm ticks went blind and the + // reconciler orphan-drained the sessions it had just started). + // Observed in production: a warm worker pool pinned at + // poolDesired=1 against 1 rig-store + 9 city-store routed beads, + // serializing every live workflow behind one session and starving + // all dep-downstream control beads. Demand a pool can claim is + // demand it must be able to count, warm or cold. + // + // No clamp: unlike a custom-scale_check pool — where the probe is + // clamped so it cannot override the custom count (see + // coldWakeTemplates below) — the default probe IS the // authoritative count, so it scales to total routed demand (bounded // by max_active and the daemon's max_wakes_per_tick), matching the - // retired cold-pool-spawner's scale-to-want. A city-scoped pool's + // retired cold-pool-spawner's scale-to-want. Counts sum across + // store groups and the beads are distinct per store, so probing + // both yields the correct union demand. A city-scoped pool's // own target is already the city store, so it needs no extra probe. // // Gated on a healthy own rig store: when the rig store is missing or @@ -687,20 +560,32 @@ func buildDesiredStateWithSessionBeads( // unreachable, and the partial flag must keep suppressing drain // decisions rather than be overridden by a spurious city-store wake. // - // ownTarget.store != store guards the case where the rig store - // aliases the city store (an unbound rig falling back to the city - // scope): a separate "city" group over the same store would - // double-count the same beads, since defaultScaleCheckCounts dedups - // per group, not across groups. Current store-map builders skip - // such rigs, so this is defense-in-depth against future callers. + // ownTarget.store != store is a same-pointer optimization: it skips + // appending a "city" probe when the rig store IS the identical Store + // object as the city store (which would re-probe one store, not form + // a real cross-store union). It is NOT the alias-safety guard — a rig + // store that aliases the city store as a DISTINCT Store value (an + // unbound rig falling back to the city scope) passes this inequality, + // so the "city" group can still surface the same beads. countedBeads + // dedups those per template ACROSS store groups by bead ID (see its + // definition below), and is the load-bearing defense now that the + // city probe is no longer cold-gated. Current store-map builders skip + // such rigs, so today this is defense-in-depth against future callers. // Control dispatchers are deliberately store-scoped: a rig copy cannot // claim a route from the city store. Keep their cold-wake probe on the // owning store instead of applying generic cross-store pool delivery. - if isCold && !storeScopedControlDispatcher && ownTarget.storeKey != "city" && ownTarget.store != nil && ownTarget.err == nil && ownTarget.store != store { + if !storeScopedControlDispatcher && ownTarget.storeKey != "city" && ownTarget.store != nil && ownTarget.err == nil && ownTarget.store != store { defaultScaleTargets = append(defaultScaleTargets, defaultScaleCheckTarget{template: template, store: store, storeKey: "city"}) } continue } + // Custom-scale_check pools deliberately KEEP the cold-only probe (unlike + // the default-probe branches above, which count cross-store demand warm + // or cold): the custom check is the authoritative count while the pool + // is awake, and this probe is clamped to 1 (coldWakeTemplates) so it can + // only wake a sleeping pool, never override the custom count. A custom + // scale_check that should scale on cross-store routed demand must count + // it itself, or the pool will churn at the warm/cold boundary. if store != nil && isCold && !storeScopedControlDispatcher { for _, source := range activeStores { defaultScaleTargets = append(defaultScaleTargets, defaultScaleCheckTarget{template: template, store: source.store, storeKey: source.ref}) @@ -721,6 +606,11 @@ func buildDesiredStateWithSessionBeads( var assignedWorkBeads []beads.Bead var assignedWorkStores []beads.Store var assignedWorkStoreRefs []string + var unassignedRoutedBeads []beads.Bead + var unassignedRoutedStores []beads.Store + var unassignedRoutedStoreRefs []string + var readyUnassignedRoutedWorkBeads []beads.Bead + var readyUnassignedRoutedWorkStoreRefs []string var readyAssigned map[storeScopedBeadKey]bool var storePartial bool var scaleCheckCounts map[string]int @@ -776,7 +666,7 @@ func buildDesiredStateWithSessionBeads( // string, so the route must be canonicalized before demand is counted or // the cold pool never wakes for it. subPhaseStart = time.Now() - unassignedRoutedBeads, unassignedRoutedStores, unassignedRoutedStoreRefs := collectOpenUnassignedRoutedWork(cfg, store, rigStores, suspendedRigPaths, stderr) + unassignedRoutedBeads, unassignedRoutedStores, unassignedRoutedStoreRefs = collectOpenUnassignedRoutedWork(cfg, store, rigStores, suspendedRigPaths, stderr) canonicalizeLegacyBoundUnassignedRoutedWork(cfg, unassignedRoutedBeads, unassignedRoutedStores, stderr) repairControlDispatcherRoutesForStoreScope(cityPath, cfg, unassignedRoutedBeads, unassignedRoutedStores, unassignedRoutedStoreRefs, stderr) // canonicalizeLegacyBound* above rewrote gc.routed_to on open ready @@ -798,7 +688,7 @@ func buildDesiredStateWithSessionBeads( }) if len(defaultScaleTargets) > 0 { subPhaseStart = time.Now() - defaultCounts, defaultDemand, partialTemplates, errs := defaultScaleCheckCountsAndDemand(defaultScaleTargets, demandReadyCache) + defaultCounts, defaultDemand, partialTemplates, errs := defaultScaleCheckCountsAndDemand(cfg, defaultScaleTargets, demandReadyCache) recordDemandSubPhase(trace, "demand_snapshot.default_scale_demand", subPhaseStart, map[string]any{ "targets": len(defaultScaleTargets), }) @@ -845,6 +735,11 @@ func buildDesiredStateWithSessionBeads( } } } + readyUnassignedRoutedWorkBeads, readyUnassignedRoutedWorkStoreRefs = selectReadyUnassignedRoutedWork( + unassignedRoutedBeads, + unassignedRoutedStoreRefs, + scaleCheckDemandByTemplate, + ) if len(defaultNamedScaleTargets) > 0 { var namedErrs []error var partialTemplates map[string]bool @@ -955,20 +850,18 @@ func buildDesiredStateWithSessionBeads( if assignee != identity { continue } - if spec.Agent.SupportsExpandedSessionIdentities() { - // Defense in depth (ga-i1d0tr Candidate B): a bare-template Assignee - // is only a legitimate "this IS my identity" match for a template - // with exactly one possible live identity. For a template that - // supports expanded per-instance identities (a multi-slot pool or - // namepool coexisting with this named session), a bare-template - // Assignee means some other path wrote the wrong value — a pool - // slot's claim, a human running `bd update --assignee=