Files
Agent-System/scripts/tests/test_nightly_advance.py
weichen 8d70d48591 feat(nightly): scheduled 03:10 advance pipeline (plan/orchestrate/report)
- 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
2026-09-28 06:51:54 +00:00

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()