"""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