diff --git a/.github/workflows/spawn_drift.yml b/.github/workflows/spawn_drift.yml index baa243f7..89bb7dd1 100644 --- a/.github/workflows/spawn_drift.yml +++ b/.github/workflows/spawn_drift.yml @@ -13,7 +13,7 @@ on: pull_request: paths: - "scripts/spawn.py" - - "tests/test_spawn_privacy.py" + - "tests/**" - "docs/pyautobrain/spawn_spec.md" - ".github/workflows/spawn_drift.yml" workflow_dispatch: @@ -33,8 +33,10 @@ jobs: with: python-version: "3.12" - run: pip install pytest - - name: spawn privacy invariant - run: python3 -m pytest tests/test_spawn_privacy.py -q + - name: spawn privacy invariant + template contract + # Whole dir, not one file: a suite named in the workflow by filename + # silently stops covering anything added beside it. + run: python3 -m pytest tests/ -q drift: # A drift run that regenerates from a leaking generator would "fix" the diff --git a/active.md b/active.md index 676c5a5d..83444f6f 100644 --- a/active.md +++ b/active.md @@ -2,7 +2,11 @@ ## jax-grad-smoke-timeout-budget - issue: https://github.com/PyAutoLabs/PyAutoHands/issues/226 -- status: library-dev — phase 1 (PyAutoHands runner) starting; issue carries the full two-level plan. +- status: library-shipped, workspace-pending — phase 1 PR OPEN (PyAutoHands#227, commit 8bbbf1f, pushed sha verified against local). Phase 2 (workspace budget) BLOCKED on measurement, see phase-2-gate below. +- library-pr: https://github.com/PyAutoLabs/PyAutoHands/pull/227 (no `pending-release` label — deliberate: 9 of the last 10 PyAutoHands PRs carry no label, only #213 used it for a genuine release dependency, and this runner change needs no release. Do not add it.) +- phase-1-result: `build_util.timeout_for(env)` resolves the cap parent-side for execute_script + execute_notebook; TimeoutExpired handlers now keep a truncated output tail + the cap in force; slow_skip banner says "default" cap. NO profile schema change — uses the existing `set:` key, since ALLOWED_OVERRIDE_KEYS is {pattern,set,unset}. Precedence profile > ambient global > 300, because run_all.py:256 exports BUILD_SCRIPT_TIMEOUT unconditionally so the parent cannot tell a deliberate operator cap from the default. 25 tests; CONTROL-TESTED — 11 of 25 fail against the unfixed behaviour, so they are not vacuous. Full suite 281 pass / 2 fail, both pre-existing on unmodified main 9601f18 (test_python_matrix_workflow.py, `experimental_python_314` key) — NOT caused by this task. +- phase-2-gate: do NOT set the jax_grad budget from local numbers. The `lp.py` CONTROL fails locally while PASSING in CI, so local runs are not trustworthy here; and pixelization/regularization abort early locally, so their local durations are truncated, not total. Phase 2 needs a workspace-smoke dispatch with phase 1 merged, reading ACTUAL CI durations from the now-preserved timeout output. Reference points: gradient.py 665.9s local PASS; knn.py calibration local 141.6s vs CI 200.0s (CI/local ~1.41) → gradient.py projects to ~940s CI. Comparing pixelization.py's completed duration against its 244.8s baseline is what settles the open regression question. +- heart-ack (2026-08-04): phase 1 shipped under human-acknowledged YELLOW (score 70, `red_reasons: []`). Acknowledged reasons, verbatim: (1) "workspace validation not passing (3 failed, 3 timeout, cloud#30858578587: autofit_test scripts/jax_assertions/multi_start_gradient_auto_convergence.py, autolens_test scripts/imaging/pixelization.py, autolens_test scripts/imaging/regularization.py, +3 more)"; (2) "manifest drift: tenant firewall (organ code) — 2 mismatch(es) vs PyAutoMind/repos.yaml"; (3) "release validation stale: source moved since rehearsal (PyAutoFit, PyAutoGalaxy, PyAutoLens)". None caused by this task — reason (1) is this task's OWN subject and is what phase 2 exists to clear. The ack covers THIS reason set only. - prompt: active/jax_grad_scripts_timeout_smoke_300s_cap.md - worktree: ~/Code/PyAutoLabs-wt/jax-grad-smoke-timeout-budget - origin: PyAutoHeart workspace-smoke run 30858578587 (2026-08-03T22:25Z) — 3 jax_grad scripts TIMEOUT at the 300s cap. Feeds the Heart YELLOW "workspace validation not passing" reason in front of release-drive-2026-08-03. @@ -18,10 +22,10 @@ ## mge-sigma-min-workspace-sweep - issue: https://github.com/PyAutoLabs/autolens_workspace/issues/466 -- status: phase 1 MERGED 2026-08-04T17:11:13Z (autolens_workspace#467 -> 92019316, issue #466 auto-closed); phase 2 PRs ALL OPEN 2026-08-04, awaiting merge. Standalone mode (upstream PyAutoGalaxy#549 already MERGED as 13d3023c). +- status: BOTH PHASES MERGED 2026-08-04. Phase 1 autolens_workspace#467 -> 92019316 (issue #466 auto-closed). Phase 2 autogalaxy_workspace#203 -> 8a7df7a6, HowToLens#67 -> 4ff3135c, HowToGalaxy#61 -> 51eed3d6, autogalaxy_assistant#10 -> f6966a64. Upstream PyAutoGalaxy#549 -> 13d3023c. All worktrees removed, all branches deleted local+origin, all five canonical checkouts back on main. Code work COMPLETE; two debts remain (below). - phase-2-prs: autogalaxy_workspace#203 (28 files, `pending-release`) / HowToLens#67 (2 files, `pending-release`) / HowToGalaxy#61 (2 files, `pending-release`) / autogalaxy_assistant#10 (2 files, NO pending-release label — that repo does not define one, gate stated in the PR body instead). All four on branch feature/mge-sigma-min-workspace-sweep. -- phase-2-ci: THREE GREEN — agw#203, htl#67, htg#61 all pass every check incl. both smoke matrix legs. aga#10 wiki-currency FAILS on `Version drift (--check-version)` ONLY. -- BLOCKER (pre-existing, NOT this task): autogalaxy_assistant `wiki/core/api_audit_baseline.json` was generated 2026-08-01 pinning autogalaxy 2026.7.29.2; PyPI latest is now 2026.8.4.1 (released 2026-08-04). EVERY PR to that repo fails --check-version until the baseline is re-pinned. Proof it is not ours: the PR touches only 2 .md files, and symbol audit (--scope all) reports 0 missing/broken, provenance shows warnings only (no content_sha256 error), idioms clean. +- phase-2-ci: THREE GREEN — agw#203, htl#67, htg#61 passed every check incl. both smoke matrix legs. aga#10 was MERGED WITH A RED wiki-currency check on explicit human instruction ("merge all"), failing `Version drift (--check-version)` ONLY — pre-existing, see DEBT 1. That red is now on autogalaxy_assistant main and persists for every future PR to that repo until the baseline is re-pinned. +- OUTSTANDING DEBT 1 of 2 — assistant baseline re-pin (pre-existing, NOT caused by this task, now inherited by main): autogalaxy_assistant `wiki/core/api_audit_baseline.json` was generated 2026-08-01 pinning autogalaxy 2026.7.29.2; PyPI latest is now 2026.8.4.1 (released 2026-08-04). EVERY PR to that repo fails --check-version until the baseline is re-pinned. Proof it is not ours: the PR touches only 2 .md files, and symbol audit (--scope all) reports 0 missing/broken, provenance shows warnings only (no content_sha256 error), idioms clean. - baseline-repin-TRAP: do NOT run `--write-baseline` from this workspace. It derives api_surface from the INSTALLED library, and local installs are the SOURCE checkouts (autogalaxy 2026.7.23.1), not the released 2026.8.4.1 that CI grades against — a local re-pin stamps the wrong version and turns the check green on a false premise. The re-pin must run against the released stack, as its own separate change. - phase-2-result: autogalaxy_workspace 10 helper calls + 8 ladders; HowToLens 3 ladders (2 lens floored, 1 SOURCE -> -4, traced to source_bulge); HowToGalaxy 3 ladders all image-plane; autogalaxy_assistant 2 prose pages. Every autogalaxy_workspace MGE verified to attach to `galaxy = af.Model(ag.Galaxy, ...)` — PyAutoGalaxy has no lensing so no source plane exists to exclude. Validation: pyflakes zero introduced undefined names, 16/16 affected scripts pass smoke, notebooks regenerated 1:1 in all three repos with no orphan churn. - assistant-provenance: wiki/core/concepts/linear_light_profiles_and_mge.md is provenance-stamped and a content_sha256 mismatch is a CI ERROR. Handled: added autogalaxy/analysis/model_util.py to the cited PyAutoGalaxy paths (where sigma_min lives), re-pinned PyAutoGalaxy 13d3023c + autogalaxy_workspace 1f821ba, bumped last_updated, re-stamped via `python3 autoassistant/audit_skill_apis.py --write-provenance`. Audit exits 0, 0 missing/broken. NOTE wiki-currency CI has paired-PR support — it checks out any cited source repo having a branch matching the PR head, so the paired autogalaxy_workspace branch is the grading ground truth. @@ -30,7 +34,7 @@ - RELEASE DEBT (created by merging phase 1): `sigma_min` exists only on PyAutoGalaxy main, NOT in any released PyAutoGalaxy. autolens_workspace main now calls it, so a user on a released install hits TypeError until a release carrying #549 ships. The `pending-release` gate was overridden on explicit human instruction 2026-08-04 with that consequence stated — same pattern as the 2026-08-03 override recorded under release-drive-2026-08-03, which is CLEAR TO RE-RUN. Publishing is the remedy, not a preference. - phase-1-result: 130 lens call sites take `sigma_min=dataset.pixel_scales[0] / 10.0`; 102 source sites untouched. 27 hand-rolled ladders: 10 image-plane floored, 17 source moved -2 -> -4. Classifier control-tested against the 88 explicitly-named sites first (88/88 agree, 0 disagree) before being trusted on the 143 bare `bulge` sites. Validation: 93 scripts byte-compile, pyflakes zero introduced undefined names across all 93, all 22 affected smoke scripts pass, notebooks regenerated 93-for-93 with no unrelated churn. - heart-ack (2026-08-04): shipped under human-acknowledged YELLOW (score 70, `red_reasons: []`). Acknowledged reasons: (1) "workspace validation not passing (3 failed, 3 timeout, cloud#30858578587: autofit_test scripts/jax_assertions/multi_start_gradient_auto_convergence.py, autolens_test scripts/imaging/pixelization.py, autolens_test scripts/imaging/regularization.py, +3 more)"; (2) "manifest drift: tenant firewall (organ code) — 2 mismatch(es) vs PyAutoMind/repos.yaml"; (3) "release validation stale: source moved since rehearsal (PyAutoFit, PyAutoGalaxy, PyAutoLens)". None caused by this task; (3) is partly this day's own PyAutoGalaxy#549 merge. The ack covers THIS reason set only. -- markdown-DEFERRED (human decision 2026-08-04): markdown/ NOT regenerated. 7 of the 30 curated markdown_examples.yaml entries are among the changed scripts (start_here.py, imaging/start_here.py, imaging/modeling.py, multi_dataset/start_here.py, multi_dataset/modeling.py, group/start_here.py, group/modeling.py). generate_markdown.py refuses to run under PYAUTO_TEST_MODE (it executes scripts for real to render images) and a fresh worktree has no output/ resume cache, so a pass means real fits. Those 7 pages show snippets WITHOUT sigma_min until regenerated — outstanding debt, do not assume it is done. +- OUTSTANDING DEBT 2 of 2 — markdown-DEFERRED (human decision 2026-08-04): markdown/ NOT regenerated. 7 of the 30 curated markdown_examples.yaml entries are among the changed scripts (start_here.py, imaging/start_here.py, imaging/modeling.py, multi_dataset/start_here.py, multi_dataset/modeling.py, group/start_here.py, group/modeling.py). generate_markdown.py refuses to run under PYAUTO_TEST_MODE (it executes scripts for real to render images) and a fresh worktree has no output/ resume cache, so a pass means real fits. Those 7 pages show snippets WITHOUT sigma_min until regenerated — outstanding debt, do not assume it is done. - smoke-trap: the 3 scripts declaring `ENV: full_datasets` (group/, imaging/, multi_dataset/ start_here.py) FAIL with an unrelated IndexError (dataset capped to 16px vs a 209px mask) if run with PYAUTO_SMALL_DATASETS=1. Reproduced on pristine main, so it is a runner-env artefact, NOT a code bug — honour the in-file ENV declaration when smoke-running these. - prompt: active/mge_sigma_min_workspace_sweep.md - worktree: ~/Code/PyAutoLabs-wt/mge-sigma-min-workspace-sweep diff --git a/docs/pyautobrain/spawn_spec.md b/docs/pyautobrain/spawn_spec.md index c479dcbb..d712213a 100644 --- a/docs/pyautobrain/spawn_spec.md +++ b/docs/pyautobrain/spawn_spec.md @@ -40,6 +40,7 @@ deliberately, never silently shipped into a template. | 5 | `active.md`, `planned.md`, `parked.md`, `condemned.md`, `ideas.md`, `queue.md`, `autonomy_log.md` | EMPTY → header line + schema pointer comment only (e.g. `# Active Tasks` + ``); autonomy_log keeps its schema header rows. **The header is GENERATED, never read from the live file** — `planned.md` and `ideas.md` carry no H1 at all, so "keep line 1" yields a registry entry (issue #118), and a heading-shape test cannot save it either: `## rhayes-audit-validation-phases-2-4` is a valid `##` heading. Each file's title lives in `EMPTY_TITLES`; an EMPTY-ruled file with no entry is an UNMATCHED-class human decision, not a guess | | 6 | `draft/**` (the work-type dirs `feature/ bug/ refactor/ docs/ test/ release/ maintenance/ research/ experiment/ triage/` now live under `draft/`) | SKELETON → keep a single `draft/.gitkeep`; drop all draft prompts and their work-type/target subdirs (a fresh Mind starts with an empty `draft/`; intake recreates the work-type subdirs on demand) | | 6b | `complete/AGENTS.md` | KEEP verbatim (the finished-work archive **schema** is template content; matched before rule 7's `complete/*` DROP) | +| 6c | `complete/index.md` | GENERATE → stamped by running the **generated tree's own** `scripts/lifecycle.py index --apply` after the tree is written (`lifecycle.py` resolves its root from `__file__`, and rule 1 already KEEPs it). The live `index.md` is still DROPped by rule 7 — this is a fresh empty-archive index, not a copy. Required because the template ships `lifecycle_drift.yml`, whose self-heal (PyAutoMind#116) regenerates this file on every push to the template's own `main`: if spawn did not produce it, each sync would be followed by a bot commit creating a file the next `--check` reports as drift, forever. Do NOT hold the text as a constant here — `lifecycle.py` owns the index format, and a second copy would drift from it | | 7 | `active/ complete/ z_features/ z_vault/ autoprompt/` + instance reference docs (`docs/**` now holds only reference material like `spawn_spec.md`) + instance root docs (`dashboard.md`, `overview.md`) + legacy pre-migration prompt dirs (`autolens/`) | DROP (lifecycle records + instance content) | | 8 | `skills/**`, `policy/**` | KEEP verbatim (`OWNERSHIP.md`, `create_issue/` are generic; `policy/` is org-agnostic safety text) | | 9 | `.github/**` | KEEP verbatim EXCEPT workflows that reference live secrets/repos beyond the org placeholder — those SUBSTITUTE `PyAutoLabs` → `YOURORG` and keep | diff --git a/draft/bug/autolens_workspace_test/jax_grad_local_assertions_fail_but_pass_in_ci.md b/draft/bug/autolens_workspace_test/jax_grad_local_assertions_fail_but_pass_in_ci.md new file mode 100644 index 00000000..74ede0c4 --- /dev/null +++ b/draft/bug/autolens_workspace_test/jax_grad_local_assertions_fail_but_pass_in_ci.md @@ -0,0 +1,78 @@ +# jax_grad scripts fail assertions locally that PASS in CI + +Type: bug +Target: autolens_workspace_test +Repos: +- autolens_workspace_test +Difficulty: medium +Autonomy: supervised +Priority: medium +Status: formalised + +Running the `jax_grad` scripts locally under the resolved smoke profile produces +deterministic assertion failures in scripts that **pass in CI on the same commit**. +Found while measuring script durations for PyAutoHands#226. + +## Evidence + +Run via `env_config.build_env_for_script` with the workspace root as CWD (i.e. the +exact env the runner builds — `PYAUTO_SMALL_DATASETS` unset, `PYAUTO_DISABLE_JAX` +unset, `PYAUTO_TEST_MODE=2`, verified by printing the resolved env): + +| script | local | CI (run 30858578587 / 30790463134) | +|---|---|---| +| `imaging/jax_grad/lp.py` | **FAIL 41.3s** | **PASS 39.6s / 40.0s** | +| `imaging/jax_grad/knn.py` | PASS 141.6s | PASS 200.0s / 175.8s | +| `imaging/jax_grad/pixelization.py` | **FAIL 57.5s** | PASS 244.8s (06:31Z) | +| `imaging/jax_grad/regularization.py` | **FAIL 131.5s** | (import gap, then TIMEOUT) | +| `point_source/jax_grad/gradient.py` | PASS 665.9s | TIMEOUT (300s cap) | + +`lp.py` is the decisive case: it **passes in CI on both runs** and fails locally. + +Failures are deterministic and bit-identical across repeated runs, e.g. +`pixelization.py`: + +``` +AssertionError: Eager (-8354.484097835004) and jitted (-8354.55843260181) evaluations +disagree — possible pure_callback constant-folding; do not trust jitted gradients. +``` + +(relative difference ~8.9e-6 against `assert_eager_jit_consistent`'s `rtol=1e-10`). + +`lp.py` fails with `All source-parameter gradients are ~zero — NNLS zeroed the source`; +`regularization.py` with an AD-vs-FD mismatch marginally over tolerance +(`abs_err=[0.045, 0.042, 0.057]` vs `tolerance=[0.031, 0.008, 0.003]`). + +## What is ruled out + +- **Not the small-datasets cap.** `full_datasets` correctly unsets + `PYAUTO_SMALL_DATASETS`; verified by resolving the env directly rather than + inferring from mask sizes. +- **Not a JAX version difference.** Local jax/jaxlib are 0.10.2 — identical to CI. +- **Not flake.** Repeated runs give bit-identical values. + +Prime remaining suspect: **numpy 2.2.6 local vs 2.4.6 in CI**, or another local venv +package differing from the CI install set. Not yet confirmed. + +## Why it matters + +This is an active trap for anyone validating these scripts locally. During #226 it +looked exactly like two fresh correctness regressions on current main +(`pure_callback` constant-folding, and an FD tolerance breach). Only running a +**control** — `lp.py`, known-passing in CI — revealed that the local environment +itself produces the failures, so none of the three local failures were evidence of +source defects. + +Whatever the cause, either the scripts or the documented local-run recipe should make +this reproducible, so a local FAIL means something. + +## Suggested scope + +1. Bisect the local-vs-CI package delta (start with numpy 2.2.6 -> 2.4.6) against + `lp.py`, the cleanest discriminator. +2. If numpy is the cause, decide whether the tolerances are under-specified for the + supported numpy range, or the local env should be pinned to the CI set. +3. Record the outcome in the workspace's local-run instructions. + + diff --git a/draft/bug/pyautoheart/script_timing_baselines_orphaned_and_window_filled.md b/draft/bug/pyautoheart/script_timing_baselines_orphaned_and_window_filled.md new file mode 100644 index 00000000..7fd3446d --- /dev/null +++ b/draft/bug/pyautoheart/script_timing_baselines_orphaned_and_window_filled.md @@ -0,0 +1,79 @@ +# Heart script_timing baselines are orphaned by path moves and filled with one repeated value + +Type: bug +Target: PyAutoHeart +Repos: +- PyAutoHeart +Difficulty: small +Autonomy: supervised +Priority: medium +Status: formalised + +Two independent defects in `PyAutoHeart/heart/checks/script_timing.py`, both found +while diagnosing the jax_grad smoke timeouts (PyAutoHands#226). Neither is fixed by +that task — it needed the baselines and found them unusable. + +## 1. Slugs are path-derived, so any script move orphans its history + +`slug_for()` (`heart/checks/script_timing.py:59`) builds the history filename from the +script's full workspace-relative path: + +``` +autolens_test__scripts__jax_grad__imaging_lp.json +``` + +The autolens_workspace_test #216 restructure moved `scripts/jax_grad/imaging_lp.py` to +`scripts/imaging/jax_grad/lp.py`. The slug changed with it, so: + +- every pre-restructure baseline is stranded under a filename nothing writes to again, +- no new-layout slug exists for ANY of these scripts, so Heart has been accumulating + **no** timing history for them since 2026-07-24, +- the regression check silently has nothing to compare against — it does not report + "no baseline", it just never fires. + +Verify: `ls ~/.pyauto-heart/timings/ | grep jax_grad` returns only old-layout names; +`grep -E "imaging__jax_grad|point_source__jax_grad"` returns nothing. + +The docstring already anticipates collisions ("so scripts in nested subdirs do not +collide on a shared leaf name") but not moves. A rename-aware scheme, or at minimum a +loud "no baseline for this slug" signal, would have surfaced this immediately. + +## 2. Every history is one value repeated 7 times + +Every file in `~/.pyauto-heart/timings/` holds the same number `baseline_window` (7) +times: + +``` +autolens_test__scripts__jax_grad__imaging_lp.json => [45.99, 45.99, 45.99, 45.99, 45.99, 45.99, 45.99] +autolens_test__scripts__jax_grad__point_source.json => [39.24, 39.24, 39.24, 39.24, 39.24, 39.24, 39.24] +``` + +`update_history()` appends one duration per call and truncates to the window, so seven +identical values means the window was seeded/filled from a single observation rather +than accumulated across seven runs. + +Consequence: `classify()` compares the latest duration against +`median(rolling_window)`, and a median over seven copies of one number IS that number. +So the yellow/red ratio is a **single-observation comparison** wearing the clothes of a +7-run median — it will read as stable regardless of real variance, and one unlucky run +becomes a "regression". + +## Why it matters + +These two combine badly. #226 needed exactly this data to answer "real slowdown, or a +cap that never fitted?" and could not: the only stored baseline for +`point_source/jax_grad/gradient.py` (39.24s) was both orphaned by the move AND a +single observation — and it turned out to describe a script that had since grown ~8x +by design. The diagnosis had to be rebuilt from CI job logs by hand. + +## Suggested scope + +- Decide the slug policy (rename-aware, or accept moves but emit a loud no-baseline + signal instead of silently skipping). +- Fix history accumulation so a window of 7 means 7 distinct runs; do not seed a + window by repetition. +- Consider recording the source run id alongside each duration so a baseline is + traceable to the run that produced it. + + diff --git a/scripts/spawn.py b/scripts/spawn.py index 50d5fb72..0e2ce782 100644 --- a/scripts/spawn.py +++ b/scripts/spawn.py @@ -461,9 +461,36 @@ def generate_mind(mind_root, out_dir): readme = out_dir / "README.md" if readme.exists(): readme.write_text(TEMPLATE_README_BANNER + readme.read_text()) + stamp_complete_index(out_dir) return warns +def stamp_complete_index(out_dir): + """Stamp the template's empty-archive `complete/index.md` (spec rule 6c). + + Runs the GENERATED tree's own `scripts/lifecycle.py`, which resolves its + root from `__file__` — so the index is produced by the same code, over the + same (empty) archive, as the template's own lifecycle self-heal. + + Without this the template is permanently drifted: it ships + `lifecycle_drift.yml`, whose self-heal (PyAutoMind#116) regenerates this + file on every push to the template's `main`. Each spawn sync was therefore + followed within seconds by a bot commit creating a file spawn did not + produce, which the next `--check` reported as drift — forever. + + Deliberately NOT a constant here: `lifecycle.py` owns the index format, and + a second copy of that text would drift from it. + """ + lifecycle = out_dir / "scripts" / "lifecycle.py" + if not lifecycle.exists(): # rules changed; nothing to stamp + return + (out_dir / "complete").mkdir(parents=True, exist_ok=True) + subprocess.run( + [sys.executable, str(lifecycle), "index", "--apply"], + cwd=out_dir, check=True, capture_output=True, + ) + + def generate_memory(memory_root, out_dir): plan, warns = plan_repo(memory_root, MEMORY_RULES) for rel, (action, src) in sorted(plan.items()): diff --git a/tests/test_spawn_template_contract.py b/tests/test_spawn_template_contract.py new file mode 100644 index 00000000..e7b29272 --- /dev/null +++ b/tests/test_spawn_template_contract.py @@ -0,0 +1,110 @@ +"""Contract between spawn's output and the workflows the template itself ships. + +The template is not inert: it carries `.github/workflows/lifecycle_drift.yml`, +whose self-heal (PyAutoMind#116) regenerates `complete/index.md` on every push +to the template's own `main`. So spawn must produce every file those workflows +produce, or each sync is followed within seconds by a bot commit creating a file +the next `--check` calls drift — permanently red. + +That is exactly what happened on 2026-08-04: sync `51f5ae58` at 17:28:51Z, bot +commit `79864dde` at 17:29:12Z, and the very next dispatch failed on +`only in published: complete/index.md`. +""" + +import importlib.util +import subprocess +from pathlib import Path + +import pytest + +SPAWN_PY = Path(__file__).resolve().parents[1] / "scripts" / "spawn.py" + +_spec = importlib.util.spec_from_file_location("spawn_contract", SPAWN_PY) +spawn = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(spawn) + +# A stub standing in for the real lifecycle.py: spawn must INVOKE the generated +# tree's own copy, so a stub that leaves a sentinel proves the wiring without +# depending on the real index format. +STUB_LIFECYCLE = """\ +import sys, pathlib +if sys.argv[1:] == ["index", "--apply"]: + p = pathlib.Path(__file__).resolve().parent.parent / "complete" / "index.md" + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("STAMPED-BY-LIFECYCLE\\n") +""" + + +def _fake_repo(root, files): + root.mkdir(parents=True, exist_ok=True) + for rel, body in files.items(): + p = root / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body) + env = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@e.invalid", + "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@e.invalid", + "PATH": "/usr/bin:/bin", "HOME": str(root)} + subprocess.run(["git", "init", "-q"], cwd=root, check=True, env=env) + subprocess.run(["git", "add", "-A"], cwd=root, check=True, env=env) + subprocess.run(["git", "commit", "-q", "-m", "t"], cwd=root, check=True, env=env) + + +MINIMAL_MIND = { + "README.md": "# Mind\n", "AGENTS.md": "# A\n", "CLAUDE.md": "# C\n", + "REFERENCE.md": "# R\n", "ROUTING.md": "# Ro\n", "LICENSE": "MIT\n", + ".gitignore": "tmp/\n", "AI_POLICY.md": "p\n", "CONTRIBUTING.md": "c\n", + "repos.yaml": "repos: {}\n", + "active.md": "# Active Tasks\n", "planned.md": "# Planned\n", + "parked.md": "# Parked\n", "condemned.md": "# Condemned\n", + "ideas.md": "# Ideas\n", "queue.md": "# Queue\n", + "autonomy_log.md": "| a | b |\n|---|---|\n| x | y |\n", + "complete/AGENTS.md": "# schema\n", + "scripts/lifecycle.py": STUB_LIFECYCLE, +} + + +def test_spawn_stamps_the_templates_complete_index(tmp_path): + """spawn must run the GENERATED tree's own lifecycle.py, not the live one.""" + mind = tmp_path / "PyAutoMind" + _fake_repo(mind, MINIMAL_MIND) + out = tmp_path / "out" + + spawn.generate_mind(mind, out) + + index = out / "complete" / "index.md" + assert index.exists(), "complete/index.md not stamped — drift loop reopens" + assert index.read_text() == "STAMPED-BY-LIFECYCLE\n", ( + "index was not produced by the generated tree's own lifecycle.py" + ) + + +def test_live_complete_index_is_never_copied(tmp_path): + """Rule 7 still DROPs the live index; 6c stamps a fresh empty one. + + A fresh-slate template must not inherit the live archive's index — that + would be a wall of instance task slugs. + """ + mind = tmp_path / "PyAutoMind" + files = dict(MINIMAL_MIND) + files["complete/index.md"] = "LIVE-ARCHIVE-INDEX with instance slugs\n" + files["complete/2026/07/rec.md"] = "a live record\n" + _fake_repo(mind, files) + out = tmp_path / "out" + + spawn.generate_mind(mind, out) + + text = (out / "complete" / "index.md").read_text() + assert "LIVE-ARCHIVE-INDEX" not in text + assert not (out / "complete" / "2026").exists() + + +def test_stamping_is_skipped_when_lifecycle_is_not_kept(tmp_path): + """If the rules ever stop KEEPing lifecycle.py, spawn must not crash.""" + mind = tmp_path / "PyAutoMind" + files = {k: v for k, v in MINIMAL_MIND.items() if k != "scripts/lifecycle.py"} + _fake_repo(mind, files) + out = tmp_path / "out" + + spawn.generate_mind(mind, out) # must not raise + + assert not (out / "complete" / "index.md").exists()