- scripts/nightly/plan.py: dependency-ordered plan from cleanup report + CURRENT + registry (integrate candidates -> active tasks -> cleanup-ready) - scripts/nightly/advance.py: orchestrator with reentry lock, daemon-health preflight, headless cursor-agent advancement under agentctl leases, queue-drain wait, env:dev rebuild + /health /ready on tip change, no-content-loss cleanup verification, daily report draft + archival - scripts/nightly-advance.sh: cron entrypoint - tests: 9 unit cases for plan builder
183 lines
7.2 KiB
Python
183 lines
7.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Unit tests for nightly advance plan builder (no repo side effects).
|
|
|
|
Runs against fixtures in scripts/tests/fixtures/nightly/. Covers:
|
|
- CURRENT row parsing (skip header/separators, keyed by task id)
|
|
- plan ordering: integrate_closeout -> feature_advance -> cleanup_verify
|
|
- done-but-blocked worktrees stay out of the plan
|
|
- registry fallback when CURRENT has no row
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
from tempfile import TemporaryDirectory
|
|
from unittest import mock
|
|
|
|
SCRIPTS = Path(__file__).resolve().parent.parent # scripts/
|
|
sys.path.insert(0, str(SCRIPTS))
|
|
|
|
import importlib.util
|
|
|
|
spec = importlib.util.spec_from_file_location(
|
|
"nightly_plan", SCRIPTS / "nightly" / "plan.py"
|
|
)
|
|
nightly_plan = importlib.util.module_from_spec(spec)
|
|
sys.modules["nightly_plan"] = nightly_plan
|
|
spec.loader.exec_module(nightly_plan)
|
|
|
|
FIXTURES = Path(__file__).parent / "fixtures" / "nightly"
|
|
|
|
CURRENT_MD = """# 当前焦点
|
|
|
|
## 并行认领
|
|
|
|
| owner | task | branch | worktree | mutex | status |
|
|
|---|---|---|---|---|---|
|
|
| Cursor | feat-a-2026-09-28 | feature/feat-a-2026-09-28 | .worktrees/feat-a-2026-09-28 | m1 | in_progress |
|
|
| pi | feat-b-2026-09-27 | feature/feat-b-2026-09-27 | .worktrees/feat-b-2026-09-27 | m2 | done |
|
|
| Codex | feat-c-2026-09-26 | fix/feat-c-2026-09-26 | (已清理) | m3 | done |
|
|
"""
|
|
|
|
CLEANUP_REPORT = {
|
|
"registry": "initialized",
|
|
"status": "ok",
|
|
"worktrees": [
|
|
{
|
|
"path": "/tmp/repo/.worktrees/integrate-feat-b-2026-09-27",
|
|
"branch": "feature/integrate-feat-b-2026-09-27",
|
|
"blockers": ["registry:TASK_ACTIVE"],
|
|
"notes": ["Close the registered task before removing its worktree"],
|
|
"tasks": [{"id": "integrate-feat-b-2026-09-27", "owner": "Cursor", "state": "active"}],
|
|
"verdict": "blocked",
|
|
},
|
|
{
|
|
"path": "/tmp/repo/.worktrees/feat-a-2026-09-28",
|
|
"branch": "feature/feat-a-2026-09-28",
|
|
"blockers": ["registry:TASK_ACTIVE", "dirty_or_ignored_files"],
|
|
"notes": ["ancestor_of_dev"],
|
|
"tasks": [{"id": "feat-a-2026-09-28", "owner": "Cursor", "state": "active"}],
|
|
"verdict": "blocked",
|
|
},
|
|
{
|
|
"path": "/tmp/repo/.worktrees/feat-d-2026-09-25",
|
|
"branch": "feature/feat-d-2026-09-25",
|
|
"blockers": [],
|
|
"notes": ["ancestor_of_dev", "registry_clear", "lifecycle_lock_idle"],
|
|
"tasks": [{"id": "feat-d-2026-09-25", "owner": "Cursor", "state": "done"}],
|
|
"verdict": "ready",
|
|
},
|
|
{
|
|
"path": "/tmp/repo/.worktrees/feat-done-blocked-2026-09-24",
|
|
"branch": "feature/feat-done-blocked-2026-09-24",
|
|
"blockers": ["dirty_or_ignored_files"],
|
|
"notes": ["ancestor_of_dev"],
|
|
"tasks": [{"id": "feat-done-blocked-2026-09-24", "owner": "Cursor", "state": "done"}],
|
|
"verdict": "blocked",
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
class ParseCurrentRows(unittest.TestCase):
|
|
def test_parses_and_skips_non_rows(self):
|
|
with TemporaryDirectory() as td:
|
|
(Path(td) / ".doc/ops").mkdir(parents=True)
|
|
(Path(td) / ".doc/ops/CURRENT.md").write_text(CURRENT_MD, encoding="utf-8")
|
|
rows = nightly_plan.parse_current_rows(Path(td))
|
|
self.assertIn("feat-a-2026-09-28", rows)
|
|
self.assertEqual(rows["feat-a-2026-09-28"]["status"], "in_progress")
|
|
self.assertEqual(rows["feat-b-2026-09-27"]["owner"], "pi")
|
|
self.assertEqual(rows["feat-c-2026-09-26"]["worktree"], "(已清理)")
|
|
|
|
def test_separator_and_header_rejected(self):
|
|
lines = CURRENT_MD.splitlines()
|
|
rows = {}
|
|
for line in lines:
|
|
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
|
if len(cells) < 6 or cells[0] in {"owner", "---"} or set(cells[0]) <= {"-", " "}:
|
|
continue
|
|
rows[cells[1]] = cells
|
|
self.assertNotIn("task", rows)
|
|
self.assertNotIn("owner", rows)
|
|
|
|
|
|
class BuildPlan(unittest.TestCase):
|
|
def _plan(self, cleanup=CLEANUP_REPORT, current=CURRENT_MD, registry=None):
|
|
with mock.patch.object(nightly_plan, "_agentctl") as ac, \
|
|
mock.patch.object(nightly_plan, "_git") as g:
|
|
ac.side_effect = lambda repo, *a: (
|
|
cleanup if a[:2] == ("cleanup", "report")
|
|
else (registry if a and a[0] == "status" else {})
|
|
)
|
|
g.return_value = "sha"
|
|
with TemporaryDirectory() as td:
|
|
(Path(td) / ".doc/ops").mkdir(parents=True)
|
|
(Path(td) / ".doc/ops/CURRENT.md").write_text(current, encoding="utf-8")
|
|
entries = nightly_plan.build_plan(Path(td))
|
|
return entries
|
|
|
|
def test_ordering_and_phases(self):
|
|
entries = self._plan()
|
|
phases = [e.phase for e in entries]
|
|
self.assertEqual(
|
|
phases,
|
|
["integrate_closeout", "feature_advance", "cleanup_verify"],
|
|
"expected integrate -> feature -> cleanup ordering",
|
|
)
|
|
|
|
def test_integrate_not_ancestor_gets_integrate_phase(self):
|
|
entries = self._plan()
|
|
integ = [e for e in entries if e.phase == "integrate_closeout"]
|
|
self.assertEqual(len(integ), 1)
|
|
self.assertEqual(integ[0].task_id, "integrate-feat-b-2026-09-27")
|
|
|
|
def test_done_ready_worktree_is_cleanup_verify(self):
|
|
entries = self._plan()
|
|
clean = [e for e in entries if e.phase == "cleanup_verify"]
|
|
self.assertEqual(clean[0].task_id, "feat-d-2026-09-25")
|
|
|
|
def test_done_blocked_out_of_plan(self):
|
|
entries = self._plan()
|
|
ids = [e.task_id for e in entries]
|
|
self.assertNotIn("feat-done-blocked-2026-09-24", ids)
|
|
|
|
def test_current_fallback_to_registry_status(self):
|
|
# feat-d has no CURRENT row here; registry state done + verdict ready
|
|
entries = self._plan()
|
|
clean = [e for e in entries if e.phase == "cleanup_verify"]
|
|
self.assertEqual(clean[0].status, "done")
|
|
|
|
def test_registry_unavailable_still_plans(self):
|
|
with mock.patch.object(nightly_plan, "registry_tasks", return_value={}):
|
|
entries = self._plan(registry={})
|
|
self.assertTrue(entries)
|
|
|
|
|
|
class Cli(unittest.TestCase):
|
|
def test_max_tasks_cap(self):
|
|
# Subprocess run: patch nothing, but point --repo at a fixture tree so
|
|
# _agentctl/_git never touch the real repo; build_plan must still work.
|
|
with TemporaryDirectory() as td:
|
|
(Path(td) / ".doc/ops").mkdir(parents=True)
|
|
(Path(td) / ".doc/ops/CURRENT.md").write_text(CURRENT_MD, encoding="utf-8")
|
|
out = Path(td) / "plan.json"
|
|
# No entries can be planned without a real registry; assert rc=0
|
|
# and empty plan rather than touching production.
|
|
rc = subprocess.run(
|
|
[sys.executable, "-B", str(SCRIPTS / "nightly/plan.py"),
|
|
"--repo", td, "--out", str(out), "--max-tasks", "1"],
|
|
capture_output=True, text=True,
|
|
).returncode
|
|
self.assertEqual(rc, 0)
|
|
data = json.loads(out.read_text())
|
|
self.assertEqual(data["count"], 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|