Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer, web workflow engine and protocol, tooling, docs and task records. Large generated evidence, user-testing archives, build snapshots and imported test assets remain untracked. Co-Authored-By: Claude Code <noreply@anthropic.com>
233 lines
12 KiB
Python
233 lines
12 KiB
Python
"""Automatic local intake and sequential dispatch using synthetic case receipts."""
|
|
import contextlib
|
|
import copy
|
|
import io
|
|
import json
|
|
import os
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
import test_wbv2_local as fixtures
|
|
from wbv2_autopilot import POLICY, queue_context, sha, write_json
|
|
from wbv2_task_dispatch import TaskDispatch, input_signature, prepared_contract
|
|
|
|
|
|
class CandidateWakeupTests(unittest.TestCase):
|
|
def setUp(self):
|
|
import tempfile
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
temporary = tempfile.TemporaryDirectory()
|
|
self.addCleanup(temporary.cleanup)
|
|
self.root = Path(temporary.name)
|
|
self.runner = SimpleNamespace(root=self.root, state={"jobs": {}})
|
|
self.context = {"parentManifest": "tests/golden/parent/manifest.json"}
|
|
self.job = {"id": "AUDIT", "ownedPaths": []}
|
|
self.candidate = self.root / "web/tests/e2e/audit.spec.ts"
|
|
self.candidate.parent.mkdir(parents=True)
|
|
self.candidate.write_text("before")
|
|
self.report = self.root / "tests/golden/WBV2/autopilot/preparation/AUDIT.json"
|
|
self.report.parent.mkdir(parents=True)
|
|
self.report.write_text(json.dumps({"job": "AUDIT", "candidateTests": ["web/tests/e2e/audit.spec.ts"]}))
|
|
|
|
def signature(self):
|
|
return input_signature(self.runner, self.context, [self.job])
|
|
|
|
def test_editing_existing_candidate_changes_signature_without_directory_change(self):
|
|
previous_time = self.candidate.stat().st_mtime_ns
|
|
directory_time = self.candidate.parent.stat().st_mtime_ns
|
|
first = self.signature()
|
|
self.candidate.write_text("after!")
|
|
os.utime(self.candidate, ns=(previous_time + 1000000000, previous_time + 1000000000))
|
|
self.assertEqual(directory_time, self.candidate.parent.stat().st_mtime_ns)
|
|
self.assertNotEqual(first, self.signature())
|
|
self.assertEqual(self.signature(), self.signature())
|
|
|
|
def test_unrelated_and_unsafe_candidate_edits_do_not_change_signature(self):
|
|
outside = self.root / "outside.spec.ts"
|
|
outside.write_text("before")
|
|
unrelated = self.candidate.with_name("unrelated.spec.ts")
|
|
unrelated.write_text("before")
|
|
linked = self.candidate.with_name("linked.spec.ts")
|
|
linked.symlink_to(outside)
|
|
self.report.write_text(json.dumps({"job": "AUDIT", "candidateTests": [
|
|
"web/tests/e2e/audit.spec.ts", "../outside.spec.ts", str(outside),
|
|
"outside.spec.ts", "web/tests/e2e/linked.spec.ts"]}))
|
|
first = self.signature()
|
|
outside.write_text("outside changed")
|
|
unrelated.write_text("unrelated changed")
|
|
self.assertEqual(first, self.signature())
|
|
|
|
def test_invalid_or_symlinked_preparation_report_is_ignored(self):
|
|
from wbv2_preparation import preparation_test_paths
|
|
|
|
self.report.write_text(json.dumps({"job": "OTHER", "candidateTests": ["web/tests/e2e/audit.spec.ts"]}))
|
|
self.assertEqual(preparation_test_paths(self.root, self.job), ())
|
|
self.report.write_text(" " * 131073)
|
|
self.assertEqual(preparation_test_paths(self.root, self.job), ())
|
|
self.report.unlink()
|
|
self.report.symlink_to(self.candidate)
|
|
self.assertEqual(preparation_test_paths(self.root, self.job), ())
|
|
self.assertEqual(preparation_test_paths(self.root, {"id": "../../outside"}), ())
|
|
|
|
|
|
class AutoDispatchTests(unittest.TestCase):
|
|
setUp = fixtures.LocalWorkflowTests.setUp
|
|
file = fixtures.LocalWorkflowTests.file
|
|
ready_result = fixtures.LocalWorkflowTests.ready_result
|
|
|
|
def configure(self):
|
|
self.policy.update(modelExecution="disabled", localWorkflows={}, preflight=[], priority=[], localPollSeconds=5)
|
|
write_json(self.root / POLICY, self.policy)
|
|
self.file("my-tools/wbv2_validation.py", "# synthetic module\n")
|
|
|
|
def jobs(self, count=2):
|
|
self.configure()
|
|
jobs = []
|
|
result = self.ready_result(self.runner.directory / "prepared")
|
|
for index in range(count):
|
|
job = {"id": "LOCAL-" + str(index), "ownedPaths": ["source.cpp"], "resultPath": f"prepared-{index}.json"}
|
|
write_json(self.root / job["resultPath"], copy.deepcopy(result))
|
|
jobs.append(job)
|
|
write_json(self.root / "tests/golden/WBV2/web-product-backlog.json", {"queueTask": self.task, "tasks": jobs})
|
|
return jobs
|
|
|
|
def run_local(self, cycles):
|
|
with patch.object(self.runner, "remember_baseline"), patch.object(self.runner, "worker", side_effect=AssertionError("no model allowed")), contextlib.redirect_stdout(io.StringIO()):
|
|
return self.runner.run(cycles)
|
|
|
|
def test_two_prepared_jobs_are_automatically_imported_claimed_and_verified(self):
|
|
jobs = self.jobs()
|
|
jobs[0]["dependsOn"] = [jobs[1]["id"]]
|
|
write_json(self.root / "tests/golden/WBV2/web-product-backlog.json", {"queueTask": self.task, "tasks": jobs})
|
|
self.assertEqual(0, self.run_local(2))
|
|
for job in jobs:
|
|
record = self.runner.state["jobs"][job["id"]]
|
|
self.assertEqual("verified", record["state"])
|
|
self.assertTrue(self.runner.receipt_valid(record))
|
|
claim = json.loads((self.runner.directory / "claims" / (job["id"] + ".json")).read_text())
|
|
self.assertEqual("verified", claim["state"])
|
|
self.assertEqual(self.context["parentSha256"], claim["parentSha256"])
|
|
events = [json.loads(line) for line in (self.runner.directory / "events.jsonl").read_text().splitlines()]
|
|
self.assertEqual([jobs[1]["id"], jobs[0]["id"]], [event["job"] for event in events if event["event"] == "task.claimed"])
|
|
self.assertEqual(0, self.runner.state["tokensUsed"])
|
|
self.assertEqual(self.task, queue_context(self.root)["task"])
|
|
|
|
def test_changed_failed_input_is_retried_automatically(self):
|
|
jobs = self.jobs(1)
|
|
original = (self.root / "check.py").read_text()
|
|
self.file("check.py", "raise SystemExit(1)\n")
|
|
self.assertEqual(2, self.run_local(1))
|
|
self.assertEqual("blocked", self.runner.state["jobs"][jobs[0]["id"]]["state"])
|
|
# An active daemon reaches preflight on its next polling cycle.
|
|
self.runner.state["state"] = "waiting_local_work"
|
|
self.file("check.py", original)
|
|
self.assertEqual(0, self.run_local(1))
|
|
self.assertEqual("verified", self.runner.state["jobs"][jobs[0]["id"]]["state"])
|
|
|
|
def test_invalid_contract_does_not_prevent_next_independent_job(self):
|
|
jobs = self.jobs()
|
|
destination = self.runner.directory / "local-plans" / (jobs[0]["id"] + ".json")
|
|
write_json(destination, {"task": self.task, "job": jobs[0]["id"], "parentSha256": "0" * 64})
|
|
self.assertEqual(0, self.run_local(2))
|
|
self.assertEqual("blocked", self.runner.state["jobs"][jobs[0]["id"]]["state"])
|
|
self.assertEqual("verified", self.runner.state["jobs"][jobs[1]["id"]]["state"])
|
|
|
|
def test_incomplete_implemented_metadata_is_not_executable(self):
|
|
jobs = self.jobs(1)
|
|
write_json(self.root / jobs[0]["resultPath"], {"status": "implemented", "changedFiles": ["source.cpp"]})
|
|
contract, findings = prepared_contract(self.root, self.context, jobs[0])
|
|
self.assertIsNone(contract)
|
|
self.assertIn("checks", findings[0]["missingFields"])
|
|
self.assertIn("cases", findings[0]["missingFields"])
|
|
|
|
def test_dependency_cycles_and_unknown_dependencies_are_not_selected(self):
|
|
jobs = self.jobs()
|
|
jobs[0]["dependsOn"] = [jobs[1]["id"]]
|
|
jobs[1]["dependsOn"] = [jobs[0]["id"]]
|
|
self.assertEqual("task dependency cycle", TaskDispatch(self.runner).dependency_issue(jobs[0], jobs))
|
|
jobs[0]["dependsOn"] = ["MISSING"]
|
|
self.assertIn("unknown task dependency", TaskDispatch(self.runner).dependency_issue(jobs[0], jobs))
|
|
|
|
def test_queue_change_prevents_claim(self):
|
|
jobs = self.jobs(1)
|
|
write_json(self.root / self.parent, {"status": "done", "nextTask": "OTHER"})
|
|
with self.assertRaises((InterruptedError, ValueError)):
|
|
TaskDispatch(self.runner).claim(self.context, jobs[0], jobs)
|
|
self.assertFalse((self.runner.directory / "claims").exists())
|
|
|
|
def test_new_contract_wakes_wait_without_full_poll_delay(self):
|
|
jobs = self.jobs(1)
|
|
self.runner.state.update(state="waiting_local_work", jobs={})
|
|
before = input_signature(self.runner, self.context, jobs)
|
|
destination = self.root / "my-tools/local-workflows" / (jobs[0]["id"] + ".json")
|
|
timer = threading.Timer(0.05, lambda: write_json(destination, {"status": "ready"}))
|
|
timer.start()
|
|
try:
|
|
started = time.monotonic()
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
self.runner.wait_for_local_work(self.context, jobs)
|
|
self.assertLess(time.monotonic() - started, 2)
|
|
finally:
|
|
timer.join()
|
|
self.assertNotEqual(before, input_signature(self.runner, self.context, jobs))
|
|
|
|
def test_wrapped_contract_requires_exact_authority(self):
|
|
jobs = self.jobs(1)
|
|
result = json.loads((self.root / jobs[0]["resultPath"]).read_text())
|
|
write_json(self.root / jobs[0]["resultPath"], {"task": self.task, "job": jobs[0]["id"], "parentSha256": "0" * 64, "result": result})
|
|
contract, findings = prepared_contract(self.root, self.context, jobs[0])
|
|
self.assertIsNone(contract)
|
|
self.assertIn("mismatch", findings[0]["reason"])
|
|
|
|
def interrupted_fixture(self):
|
|
jobs = self.jobs(1)
|
|
contract, _ = prepared_contract(self.root, self.context, jobs[0])
|
|
relative = "tests/golden/WBV2/autopilot/local-plans/LOCAL-0.json"
|
|
write_json(self.root / relative, contract)
|
|
directory = self.runner.directory / "runs/LOCAL-0-interrupted"
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
protected = self.runner.protected_hashes(self.context)
|
|
write_json(directory / "before-worker.json", {"protected": protected, "owned": {"source.cpp": sha(self.root / "source.cpp")}})
|
|
self.runner.state.update(state="validating", pid=900001, identity="old", executionMode="local_only", activeJob="LOCAL-0", jobs={"LOCAL-0": {"state": "validating", "runDirectory": str(directory.relative_to(self.root)), "localInputs": {"check.py": sha(self.root / "check.py")}}})
|
|
write_json(self.runner.directory / "claims/LOCAL-0.json", {"state": "claimed", "ownerPid": 900001, "task": self.task, "job": "LOCAL-0", "parentSha256": self.context["parentSha256"], "contract": relative, "contractSha256": sha(self.root / relative)})
|
|
return jobs
|
|
|
|
def test_dead_local_writer_recovers_when_all_bindings_are_unchanged(self):
|
|
self.interrupted_fixture()
|
|
with patch("wbv2_autopilot.alive", return_value=False), contextlib.redirect_stdout(io.StringIO()):
|
|
self.assertTrue(TaskDispatch(self.runner).recover())
|
|
self.assertEqual("idle", self.runner.state["state"])
|
|
self.assertEqual("retry_pending", self.runner.state["jobs"]["LOCAL-0"]["state"])
|
|
|
|
def test_explicit_pause_or_input_drift_prevents_recovery(self):
|
|
self.interrupted_fixture()
|
|
(self.runner.directory / "pause.request").touch()
|
|
self.assertFalse(TaskDispatch(self.runner).recover())
|
|
(self.runner.directory / "pause.request").unlink()
|
|
self.file("source.cpp", "changed locally\n")
|
|
with patch("wbv2_autopilot.alive", return_value=False):
|
|
self.assertFalse(TaskDispatch(self.runner).recover())
|
|
|
|
def test_surviving_process_group_prevents_recovery(self):
|
|
self.interrupted_fixture()
|
|
self.runner.state.update(childPid=900002, childIdentity="old-child")
|
|
with patch("wbv2_autopilot.alive", return_value=False), patch("wbv2_task_dispatch.os.killpg", return_value=None):
|
|
self.assertFalse(TaskDispatch(self.runner).recover())
|
|
|
|
def test_recovery_rejects_an_unowned_or_wrong_parent_claim(self):
|
|
self.interrupted_fixture()
|
|
path = self.runner.directory / "claims/LOCAL-0.json"
|
|
claim = json.loads(path.read_text())
|
|
claim["parentSha256"] = "0" * 64
|
|
write_json(path, claim)
|
|
with patch("wbv2_autopilot.alive", return_value=False):
|
|
self.assertFalse(TaskDispatch(self.runner).recover())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|