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>
215 lines
11 KiB
Python
215 lines
11 KiB
Python
"""Local task admission and ownership records under the supervisor writer lease."""
|
|
import copy
|
|
import json
|
|
import os
|
|
import re
|
|
|
|
|
|
def candidate_paths(job):
|
|
candidates = []
|
|
for key in ("localContract", "localContractPath"):
|
|
if isinstance(job.get(key), str):
|
|
candidates.append(job[key])
|
|
candidates.append(f"my-tools/local-workflows/{job['id']}.json")
|
|
if isinstance(job.get("resultPath"), str):
|
|
candidates.append(job["resultPath"])
|
|
return list(dict.fromkeys(candidates))
|
|
|
|
|
|
def prepared_contract(root, context, job):
|
|
from wbv2_autopilot import contained, read_json, sha
|
|
from wbv2_compilation_contract import compilation_requested, validate_compilation_shape
|
|
|
|
missing = {"checks", "cases", "sourceUnits", "routeIds", "sourceReferences", "dependencies", "artifacts", "changedFiles", "workflow"}
|
|
findings = []
|
|
for relative in candidate_paths(job):
|
|
try:
|
|
path = contained(root, relative)
|
|
if not path.is_file():
|
|
continue
|
|
document = read_json(path)
|
|
if not isinstance(document, dict):
|
|
findings.append({"path": relative, "reason": "contract must be an object"})
|
|
continue
|
|
wrapped = isinstance(document.get("result"), dict)
|
|
result = document["result"] if wrapped else document
|
|
compilation_only = compilation_requested(root, result)
|
|
if wrapped and (document.get("task") != context["task"] or document.get("job") != job["id"] or document.get("parentSha256") != context["parentSha256"]):
|
|
findings.append({"path": relative, "reason": "task, job or parent mismatch"})
|
|
continue
|
|
if not wrapped and document.get("task") not in {None, context["task"], job["id"]}:
|
|
findings.append({"path": relative, "reason": "result belongs to another task"})
|
|
continue
|
|
absent = sorted((missing - ({"cases"} if compilation_only else set())) - result.keys())
|
|
if result.get("status") != "ready" or absent:
|
|
findings.append({"path": relative, "reason": "implementation metadata is not an executable contract", "missingFields": absent})
|
|
continue
|
|
if not result["checks"] or (not compilation_only and not result.get("cases")):
|
|
findings.append({"path": relative, "reason": "checks and behavioral cases must be nonempty"})
|
|
continue
|
|
if compilation_only:
|
|
validate_compilation_shape(result)
|
|
contract = {
|
|
"schemaVersion": 1,
|
|
"task": context["task"],
|
|
"job": job["id"],
|
|
"parentSha256": context["parentSha256"],
|
|
"generator": "local-contract-admission-v1",
|
|
"origin": {"path": relative, "sha256": sha(path)},
|
|
"acceptance": "pending independent local validation",
|
|
"result": copy.deepcopy(result),
|
|
}
|
|
if compilation_only:
|
|
contract["result"]["contractKind"] = "compilation_only"
|
|
contract["result"].setdefault("cases", [])
|
|
contract["acceptance"] = "pending compilation; behavioral verification deferred until user use"
|
|
contract["result"]["dependencies"] = sorted(set(contract["result"]["dependencies"] + [relative]))
|
|
return contract, findings
|
|
except (OSError, ValueError, KeyError, TypeError):
|
|
findings.append({"path": relative, "reason": "invalid local contract"})
|
|
return None, findings
|
|
|
|
|
|
def input_signature(runner, context, jobs):
|
|
"""Cheap wakeup signal; SHA-256 admission still decides whether to execute."""
|
|
from wbv2_autopilot import contained
|
|
from wbv2_preparation import preparation_test_paths
|
|
|
|
paths = {"docs/EXECUTION_QUEUE.md", context["parentManifest"], "tests/golden/WBV2/web-product-backlog.json", "tests/golden/WBV2/dynamic-inventory.json", "my-tools/wbv2_autopilot_policy.json", "web/tests/e2e", "web/tests/unit"}
|
|
for job in jobs:
|
|
paths.update(job.get("ownedPaths", []))
|
|
paths.update(candidate_paths(job))
|
|
paths.add(f"tests/golden/WBV2/autopilot/local-plans/{job['id']}.json")
|
|
paths.update(runner.state.get("jobs", {}).get(job["id"], {}).get("localInputs", {}))
|
|
paths.update(preparation_test_paths(runner.root, job))
|
|
from wbv2_local_preparation import recipe_input_paths
|
|
paths.add("my-tools")
|
|
paths.update(recipe_input_paths(runner.root, jobs))
|
|
result = []
|
|
for relative in sorted(paths):
|
|
try:
|
|
stat = contained(runner.root, relative).stat()
|
|
result.append((relative, stat.st_ino, stat.st_size, stat.st_mtime_ns))
|
|
except (OSError, ValueError):
|
|
result.append((relative, None))
|
|
return tuple(result)
|
|
|
|
|
|
class TaskDispatch:
|
|
def __init__(self, runner):
|
|
self.runner = runner
|
|
|
|
def dependency_issue(self, job, jobs):
|
|
dependencies = job.get("dependsOn", [])
|
|
if not isinstance(dependencies, list) or not all(isinstance(value, str) for value in dependencies):
|
|
return "invalid task dependency list"
|
|
known = {item["id"]: item for item in jobs}
|
|
|
|
def visit(identifier, stack):
|
|
if identifier in stack:
|
|
return "task dependency cycle"
|
|
if identifier not in known:
|
|
return "unknown task dependency: " + identifier
|
|
predecessors = known[identifier].get("dependsOn", [])
|
|
if not isinstance(predecessors, list) or not all(isinstance(value, str) for value in predecessors):
|
|
return "invalid task dependency list"
|
|
for previous in predecessors:
|
|
problem = visit(previous, stack | {identifier})
|
|
if problem:
|
|
return problem
|
|
return None
|
|
|
|
problem = visit(job["id"], set())
|
|
if problem:
|
|
return problem
|
|
pending = [identifier for identifier in dependencies if not self.runner.receipt_valid(self.runner.state.get("jobs", {}).get(identifier, {}))]
|
|
return "waiting for verified dependencies: " + ", ".join(pending) if pending else None
|
|
|
|
def claim(self, context, job, jobs):
|
|
from wbv2_autopilot import contained, now, queue_context, read_json, sha, write_json
|
|
|
|
if queue_context(self.runner.root) != context:
|
|
raise InterruptedError("queue changed before task claim")
|
|
problem = self.dependency_issue(job, jobs)
|
|
if problem:
|
|
raise ValueError(problem)
|
|
relative = self.runner.local_contract_path(job)
|
|
if not relative:
|
|
raise ValueError("local workflow contract missing")
|
|
contract = read_json(contained(self.runner.root, relative))
|
|
if (contract.get("task"), contract.get("job"), contract.get("parentSha256")) != (context["task"], job["id"], context["parentSha256"]):
|
|
raise ValueError("task claim contract binding mismatch")
|
|
identifier = job["id"]
|
|
if not re.fullmatch(r"[A-Z0-9_-]+", identifier):
|
|
raise ValueError("unsafe task ID")
|
|
record = {"schemaVersion": 1, "task": context["task"], "job": identifier, "state": "claimed", "claimedAt": now(), "ownerPid": self.runner.state.get("pid"), "parentSha256": context["parentSha256"], "contract": relative, "contractSha256": sha(contained(self.runner.root, relative))}
|
|
write_json(self.runner.directory / "claims" / (identifier + ".json"), record)
|
|
self.runner.event("task.claimed", job=identifier, task=context["task"], contractSha256=record["contractSha256"])
|
|
return record
|
|
|
|
def finish(self, claim):
|
|
from wbv2_autopilot import now, write_json
|
|
|
|
result = self.runner.state["jobs"].get(claim["job"], {})
|
|
claim = {**claim, "state": result.get("state", "needs_reconcile"), "finishedAt": now(), "runDirectory": result.get("runDirectory"), "receipt": result.get("receipt"), "reason": result.get("reason")}
|
|
write_json(self.runner.directory / "claims" / (claim["job"] + ".json"), claim)
|
|
self.runner.event("task.released", job=claim["job"], state=claim["state"])
|
|
|
|
def recover(self):
|
|
from wbv2_autopilot import alive, contained, identity, queue_context, read_json, sha
|
|
|
|
runner = self.runner
|
|
if runner.policy.get("modelExecution", "disabled") != "disabled" or runner.state.get("executionMode") != "local_only":
|
|
return False
|
|
if runner.state.get("state") not in {"implementing", "validating", "needs_reconcile"} or (runner.directory / "pause.request").exists():
|
|
return False
|
|
for prefix in ("", "child"):
|
|
pid_key, identity_key = ("pid", "identity") if not prefix else ("childPid", "childIdentity")
|
|
pid = runner.state.get(pid_key)
|
|
if pid and pid != os.getpid() and alive(pid) and identity(pid) == runner.state.get(identity_key):
|
|
return False
|
|
child = runner.state.get("childPid")
|
|
if child:
|
|
try:
|
|
os.killpg(child, 0)
|
|
return False
|
|
except ProcessLookupError:
|
|
pass
|
|
except PermissionError:
|
|
return False
|
|
try:
|
|
context = queue_context(runner.root)
|
|
identifier = runner.state["activeJob"]
|
|
if not re.fullmatch(r"[A-Z0-9_-]+", identifier):
|
|
return False
|
|
job = next(job for job in runner.jobs(context) if job["id"] == identifier)
|
|
record = runner.state["jobs"][identifier]
|
|
claim = read_json(runner.directory / "claims" / (identifier + ".json"))
|
|
if claim.get("state") != "claimed" or claim.get("job") != identifier or claim.get("ownerPid") != runner.state.get("pid"):
|
|
return False
|
|
if claim.get("task") != context["task"] or claim.get("parentSha256") != context["parentSha256"]:
|
|
return False
|
|
if claim.get("contract") != runner.local_contract_path(job) or sha(contained(runner.root, claim["contract"])) != claim.get("contractSha256"):
|
|
return False
|
|
run = contained(runner.root, record["runDirectory"])
|
|
if not run.is_relative_to((runner.directory / "runs").resolve()):
|
|
return False
|
|
snapshot = read_json(run / "before-worker.json")
|
|
if not snapshot.get("protected") or not isinstance(snapshot.get("owned"), dict):
|
|
return False
|
|
if runner.protected_hashes(context) != snapshot["protected"]:
|
|
return False
|
|
if set(snapshot["owned"]) != set(job.get("ownedPaths", [])):
|
|
return False
|
|
for hashes in (snapshot["protected"], snapshot["owned"], record.get("localInputs", {})):
|
|
if any(sha(contained(runner.root, path)) != digest for path, digest in hashes.items()):
|
|
return False
|
|
record.update(state="retry_pending", reason="recovered local claim; unchanged inputs and no surviving owner")
|
|
runner.state.pop("childPid", None)
|
|
runner.state.pop("childIdentity", None)
|
|
runner.checkpoint("idle", reason=record["reason"], currentProcess={"phase": "local_recovery", "status": "finished"})
|
|
runner.event("task.recovered", job=identifier, previousRun=record["runDirectory"])
|
|
return True
|
|
except (OSError, ValueError, KeyError, TypeError, StopIteration):
|
|
return False
|