feat: wire the Spawn Drift self-heal — regenerate and propose a sync PR - #126
Merged
Conversation
Spawn Drift detected drift but nothing regenerated. The templates are declared a generated view, yet the only thing that ever regenerated them was a human typing `/spawn --apply` — so the scheduled leg went red, stayed red, and was cleared by hand. Every green run in its history was a manual dispatch fired 16-19 seconds after such a sync, which is why it could not fail. On schedule/dispatch the workflow now regenerates and opens (or refreshes) a sync PR on each drifted template repo. Deliberately a PR, not a bot push. These repos are force-synced generated views, so an automated push would be a force-push to a published `main`; #118 — a leak that sat public for eight days — is the argument for a human seeing what gets published. That keeps the sanctioned force-push a human act while removing the "nothing regenerates" gap. ## The safety interlock `--check` collapsed every failure into exit 1. Split into: 0 CLEAN published matches the regenerated tree 1 DRIFT content differs — mechanical, safe to PROPOSE 2 UNSAFE UNMATCHED file class or canary hit — a HUMAN DECISION Only exit 1 reaches the PR path. Exit 2 fails the job and opens nothing: a canary hit means the regenerated tree carries live instance content, so a sync PR would be proposing to publish a leak — #118 with a robot doing it. A test asserts UNSAFE outranks DRIFT when both are present. ## A latent bug this surfaced Running the workflow's shell rather than only reading it found that `stamp_complete_index()` (from #120) breaks on a RELATIVE `--write DIR`: the child resolves the script path after chdir'ing to `cwd`, so `--write regenerated` — exactly what a CI step naturally passes — died with "can't open file". Every invocation to date happened to use an absolute path, so it stayed latent. Fixed by resolving, with a regression test that fails without it. ## Verified by executing it, not just reading it Both `run:` blocks were extracted and run under `bash -e` (as Actions runs them) against a CI-shaped fixture, with `git push`/`gh` stubbed: * clean -> no PR * drift -> PR opened; the already-current repo correctly skipped * drift -> existing PR REFRESHED, no duplicate (stable force-updated branch) * canary -> job fails, code=2, PR step's `if` is false, nothing proposed ## Known external dependency Opening a PR on the template repos needs write access there; GITHUB_TOKEN is scoped to PyAutoMind. Uses `secrets.PAT_PYAUTOLABS`, the org's established cross-repo token. Whether that PAT grants write to the two template repos CANNOT be verified from a local session — only a real run reveals it. The step therefore fails with an explicit, actionable message rather than silently doing nothing. Refs #125 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Python exits 1 on an unhandled exception — the same code as EXIT_DRIFT. The self-heal would therefore read a crash as "the templates are stale" and try to propose a sync PR from whatever partial tree the crash left behind. Not hypothetical: stamp_complete_index() crashing on a relative --write path (fixed in the previous commit) produced exactly this exit-1-that-means-crash, and that is how it was noticed. Unhandled exceptions now exit 3, which the workflow's catch-all rejects. The traceback is still printed, so nothing is hidden. Refs #125 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1. HIGH — the change broke rule 9, which it had just established one PR ago.
Adding the PAT-dependent proposal step to spawn_drift.yml meant the template
would ship `secrets.PAT_PYAUTOLABS`, violating the no-configured-secret
condition. Confirmed against a post-merge generation.
Root cause in the TESTS, not just the rules: the .github fixtures were
hand-written miniatures, so `test_no_shipped_workflow_needs_a_configured_secret`
was checking a toy workflow that had no PAT while the real one had grown one.
The fixtures now READ THE REAL WORKFLOW FILES. Control-tested: shipping
spawn_drift.yml again now fails four tests, including the secret check.
Rule 9b revised KEEP-with-schedule-stripped -> DROP. The self-heal makes the
workflow depend on a PAT and on published *-template repos; a fresh org has
neither, so every path in it is unrunnable there. "When in doubt DROP" —
the generator and its guards still travel via scripts/ and tests/. The
schedule-stripping transform is retired as dead code with its tests.
2. HIGH — `gh pr view` matches merged and closed PRs, so once a sync PR was
merged the reused branch would report "refreshed" forever and silently never
open another. Now `gh pr list --state open --head`. Both paths dry-run
tested: open -> refresh, merged -> opens a new PR.
3. HIGH — every exit-code test compared a subprocess result with constants from
the same module, so swapping EXIT_DRIFT and EXIT_UNSAFE would have left them
green while the workflow still auto-proposed literal exit 1. Two tests now
read the real workflow and pin the CONSUMER against the producer.
Control-tested by swapping the constants.
4. MEDIUM — the interlock was incomplete. Fail-closed paths raise
SystemExit("message"), which Python turns into exit 1: indistinguishable
from EXIT_DRIFT. Those are human decisions like UNMATCHED, so they now map
to EXIT_UNSAFE. The earlier crash guard only covered exceptions.
5. MEDIUM — concurrent runs were last-writer-wins on the shared proposal
branch. Added a `concurrency` group that queues rather than cancels; a
cancelled run could leave a pushed branch with no PR.
6. LOW — `diff` exit 2 means trouble, not differences, but every nonzero status
entered the drift path, so an unreadable tree would trigger replacement and
force-push. Only exit 1 is drift now; 2 aborts.
Refs #125
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #125. The last piece of the #118 arc.
Overview
Spawn Driftdetected drift but nothing regenerated. The templates are declared a generated view, yet the only thing that ever regenerated them was a human typing/spawn --apply— so the scheduled leg went red, stayed red, and was cleared by hand. Every green run in its history was a manual dispatch fired 16–19 seconds after such a sync, which is precisely why it could not fail.On schedule/dispatch the workflow now regenerates and opens (or refreshes) a sync PR on each drifted template repo.
Deliberately a PR, not a bot push. These repos are force-synced generated views, so an automated push would be a force-push to a published
main. #118 — a leak that sat public for eight days — is the argument for a human seeing what gets published. This keeps the sanctioned force-push a human act while removing the "nothing regenerates" gap.The safety interlock
--checkcollapsed every failure into exit 1. Now:Only exit 1 proposes. A canary hit means the regenerated tree carries live instance content, so a sync PR would be proposing to publish a leak — #118 with a robot doing it.
Two defects found by executing the workflow, not reading it
Both
run:blocks were extracted and run underbash -eagainst a CI-shaped fixture withgit push/ghstubbed. That surfaced:stamp_complete_index()broke on a relative--write DIR— the child resolves the script path after chdir'ing tocwd, so--write regenerated(what a CI step naturally passes) died with "can't open file". Every invocation to date happened to use an absolute path.Independent review (Codex) — six findings, all real, all fixed
The most important: this change broke rule 9, which it had established one PR ago. Adding the PAT-dependent step meant the template would ship
secrets.PAT_PYAUTOLABS, violating the no-configured-secret condition. Confirmed against a post-merge generation.The root cause was in the tests: the
.githubfixtures were hand-written miniatures, sotest_no_shipped_workflow_needs_a_configured_secretwas checking a toy workflow with no PAT while the real one had grown one. Fixtures now read the real workflow files; control-tested — shippingspawn_drift.ymlagain fails four tests.Rule 9b accordingly revised
KEEP-with-schedule-stripped → DROP (your call): the self-heal makes the workflow depend on a PAT and on published*-templaterepos, so every path in it is unrunnable in a fresh org. The generator and its guards still travel viascripts/andtests/. The schedule-strip transform is retired as dead code.Also fixed:
gh pr viewmatches merged and closed PRs, so once a sync PR was merged the reused branch would report "refreshed" forever and never open another. Nowgh pr list --state open --head; both paths dry-run tested.EXIT_DRIFT/EXIT_UNSAFEwould have left them green while the workflow still auto-proposed literal exit 1. Two tests now pin the consumer against the producer by reading the real workflow. Control-tested by swapping the constants.SystemExit("message")→ exit 1, indistinguishable from drift. Those are human decisions like UNMATCHED, so they now map toEXIT_UNSAFE. The earlier crash guard only covered exceptions.concurrencygroup (queue, not cancel — a cancelled run could leave a pushed branch with no PR).diffexit 2 means trouble, not differences; only exit 1 is drift now, 2 aborts rather than force-pushing over a tree it could not read.Verification
lifecycle_drift.ymland no configured secrets.bash -e: clean → no PR; drift → PR opened with the already-current repo skipped; open PR → refreshed, no duplicate; merged PR → new one opened; canary → job fails,code=2, nothing proposed.API Changes
None outside this repo.
spawn.pyis a repo-local generator.--checkgains distinct exit codes; all failure codes remain non-zero, so anything treating it as a boolean is unaffected.unscheduled_workflow_body()is removed (added and retired within this issue).Known external dependency — cannot be verified locally
Opening a PR on the template repos needs write access there;
GITHUB_TOKENis scoped to PyAutoMind. This usessecrets.PAT_PYAUTOLABS, the org's established cross-repo token (nightly-release.yml,PyAutoHands/release.yml).Whether that PAT grants write to the two template repos can only be established by a real run. The step fails with an explicit, actionable message rather than silently doing nothing. If it turns out the PAT is not scoped for this, the fix is a token change, not a code change.
Ship notes
Heart YELLOW (
red_reasons: []), human-acknowledged for this exact set: workspace validation not passing (2 failed, cloud#30938311069, autolens interferometer notebooks) and tenant-firewall manifest drift. Neither relates to this change.Post-merge:
/spawn --applyto publish, then the first scheduled run (Monday 06:17 UTC) is the real test — the leg that has never passed on its own.🤖 Generated with Claude Code