#!/usr/bin/env python3 """Advance the Web task ledger to the next executable leaf task. The command is deliberately deterministic and single-shot: it never marks a task complete. It continues an existing ``in_progress`` task, or, when that task is already terminal, claims the first dependency-ready leaf and rebuilds the isolated context. An agent/external scheduler can call it after each implementation batch without asking for user confirmation. """ from __future__ import annotations import argparse import hashlib import json import os import subprocess import sys from datetime import datetime, timezone from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from web_context import load_cards, predecessor_ids # noqa: E402 from implementation_first import capability_source_fingerprint, implementation_first_enabled, load_registry, user_validation_deferred # noqa: E402 from scheduler_progress import batch_retry_details, read as read_progress # noqa: E402 from failure_reconciliation import pending_keys, pending_business_repair # noqa: E402 from prepare_feature_capability_batch import stalled_batch_limit # noqa: E402 ROOT = Path(__file__).resolve().parents[1] STATE = ROOT / "docs/current/task-state.json" CHECKPOINT = ROOT / "docs/current/continuation-state.json" CONTEXT_DIR = ROOT / "docs/current/context" CANONICAL_MANIFESTS = ( ROOT / "docs/p6/python-api-manifest.json", ROOT / "docs/p6/object-contract-matrix.json", ROOT / "docs/p6/workbench-command-matrix.json", ) MODULE_PIPELINE = ROOT / "docs/current/module-batch-pipeline.json" CAPABILITY_REGISTRY = ROOT / "docs/current/feature-capability-registry.json" PROBE_ROOT = ROOT / "freecad-source/src/WebAssembly/FreeCADCoreProbe" STALLED_BATCH_SIZE = max(1, int(os.environ.get("FREECAD_STALLED_FEATURE_BATCH_SIZE", "4"))) STALLED_BATCH_SECONDS = max(30.0, float(os.environ.get("FREECAD_STALLED_FEATURE_BATCH_SECONDS", "180"))) MIXED_FEATURE_BATCH_SIZE = max(2, int(os.environ.get("FREECAD_MIXED_FEATURE_BATCH_SIZE", "8"))) def run(*args: str) -> None: subprocess.run([sys.executable, *args], cwd=ROOT, check=True) def refresh_feature_registry_if_sources_changed() -> bool: """Refresh the cheap capability projection only after probe source edits. Feature batches without an outer build target still need their static routed/required projection refreshed. Comparing mtimes avoids rescanning probe contents on every cycle while ensuring a just-edited native adapter is visible to the next planner pass. """ if not implementation_first_enabled(): return False try: registry_mtime = CAPABILITY_REGISTRY.stat().st_mtime_ns except OSError: registry_mtime = 0 source_files = list(PROBE_ROOT.glob("*Probe.cpp")) + list(PROBE_ROOT.glob("*DesktopCommands.cpp")) if not source_files: return False try: changed = any(path.stat().st_mtime_ns > registry_mtime for path in source_files) except OSError: return False if not changed: return False result = subprocess.run( [sys.executable, "tools/generate_feature_capability_registry.py"], cwd=ROOT, text=True, capture_output=True, check=False, ) if result.returncode != 0: # Keep the existing projection on a transient source read error; the # normal scheduler error path will record the next actionable failure. return False if result.stdout: print(f"FEATURE_REGISTRY_REFRESH: {result.stdout.strip()}") return True def selected_routed_ids(batch: dict) -> set[str]: """Return currently routed IDs intersecting the selected feature batch.""" if batch.get("strategy") != "feature-first-deferred-validation": return set() try: payload = json.loads(CAPABILITY_REGISTRY.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return set() routed = { str(row.get("id")) for row in payload.get("records", []) if isinstance(row, dict) and row.get("implementationStatus") == "routed" } return { str(row[0]) for row in batch.get("records", []) if isinstance(row, list) and row and str(row[0]) in routed } def planner_script() -> str: return "tools/prepare_implementation_business_batch.py" if implementation_first_enabled() else "tools/prepare_full_parity_module_batch.py" def run_continuation_check() -> bool: """Validate the checkpoint and report whether a repair changed projections.""" try: run("tools/check_continuation_state.py") return False except subprocess.CalledProcessError: subprocess.run([sys.executable, "tools/compact_continuation_docs.py"], cwd=ROOT, check=False) # Compression changes handoff/card hashes; regenerate the active packet # before retrying validation so the failure loop cannot use stale data. subprocess.run([sys.executable, "tools/prepare_web_task_context.py"], cwd=ROOT, check=False) retry_check = subprocess.run( [sys.executable, "tools/check_continuation_state.py"], cwd=ROOT, check=False ) if retry_check.returncode == 0: return False repair = subprocess.run( [sys.executable, "tools/repair_continuation_checkpoint.py"], cwd=ROOT, text=True, capture_output=True, check=False, ) if repair.returncode != 0: raise print(repair.stdout, end="") subprocess.run([sys.executable, "tools/prepare_web_task_context.py"], cwd=ROOT, check=False) run("tools/check_continuation_state.py") return True def feature_batch_replan_size(batch: dict) -> int | None: if not implementation_first_enabled(): return None selected = batch.get("selected") if isinstance(batch.get("selected"), dict) else {} selected_count = int(selected.get("recordCount", 0) or 0) if selected.get("family") == "desktop-command-mixed" and selected_count > MIXED_FEATURE_BATCH_SIZE: return MIXED_FEATURE_BATCH_SIZE if selected_count <= STALLED_BATCH_SIZE: return None try: pipeline = json.loads(MODULE_PIPELINE.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): pipeline = {} return stalled_batch_limit(batch, pipeline) def batch_requires_refresh() -> bool: """Return true for a stale/undersized projection before validation runs.""" batch_path = ROOT / "docs/current/module-batch-manifest.json" if not batch_path.exists(): return True try: batch = json.loads(batch_path.read_text(encoding="utf-8")) checkpoint = json.loads(CHECKPOINT.read_text(encoding="utf-8")) task_state = json.loads(STATE.read_text(encoding="utf-8")) selected = batch.get("selected") if isinstance(batch.get("selected"), dict) else {} minimum = int(checkpoint.get("batchPolicy", {}).get("minRecords", 2)) completed_batches = checkpoint.get("latestBatch", {}).get("completed", []) batch_checkpoint = checkpoint.get("batch_checkpoint") if isinstance(checkpoint.get("batch_checkpoint"), dict) else {} if not batch_checkpoint and isinstance(task_state.get("batch_checkpoint"), dict): batch_checkpoint = task_state["batch_checkpoint"] batch_id = batch.get("batchId") if user_validation_deferred() and selected.get("tier") in {"business-acceptance", "failure-reconciliation"}: return True if implementation_first_enabled() and batch_retry_details(batch): return True if implementation_first_enabled() and selected.get("tier") == "feature-workflow": from prepare_implementation_business_batch import acceptance_preempts if acceptance_preempts(batch): return True if implementation_first_enabled() and selected.get("tier") == "failure-reconciliation": remaining = pending_keys(batch.get("failureKeys", [])) return (batch.get("status") != "ready" or not batch.get("failureKeys") or len(remaining) != len(batch["failureKeys"]) or pending_business_repair(batch["failureKeys"])) if implementation_first_enabled() and selected.get("tier") == "business-acceptance": from prepare_business_acceptance_batch import inventory pending = {row["id"] for row in inventory()["pending"]} ids = {str(row[0]) for row in batch.get("records", []) if row} if batch.get("status") != "ready" or not ids or not ids <= pending: return True generated = datetime.fromisoformat(str(batch.get("generatedAt", "")).replace("Z", "+00:00")).timestamp() history = read_progress(ROOT / "docs/current/scheduler-progress.json").get("history", []) return any(row.get("batchId") == batch_id and row.get("tier") == "business-acceptance" and row.get("outcome") != "provider_wait" and float(row.get("at", 0)) >= generated for row in history) if implementation_first_enabled() and batch.get("strategy") != "feature-first-deferred-validation": return True if implementation_first_enabled() and batch.get("status") == "no_ready_batch": return batch.get("sourceFingerprint") != capability_source_fingerprint() # A feature batch can become partially routed from native source edits # even when no outer build target exists. Replan immediately so the # next turn receives only the remaining records instead of rereading # already implemented commands. if implementation_first_enabled() and selected_routed_ids(batch): return True if feature_batch_replan_size(batch) is not None: return True implementation_batches = load_registry().get("batches", {}) if implementation_first_enabled() else {} if isinstance(implementation_batches, dict) and batch_id in implementation_batches: return True if batch_records_have_evidence(batch): return True if batch_id in completed_batches or ( batch_checkpoint.get("batchId") == batch_id and str(batch_checkpoint.get("status", "")) in {"pass", "pass_with_boundaries", "completed", "blocked_native_probe"} ): return True if batch.get("status") != "ready" or batch.get("taskId") not in (None, checkpoint.get("activeTask")): return True return int(selected.get("recordCount", 0)) < minimum and batch.get("singletonFallback") is not True except (OSError, json.JSONDecodeError, TypeError, ValueError): return True def batch_records_have_evidence(batch: dict) -> bool: """Detect a completed batch even when its compact checkpoint was interrupted.""" if implementation_first_enabled(): # Current business completion is owned by implementation receipts and # business acceptance, not the legacy API/object evidence inventories. return False records = batch.get("records") if isinstance(batch.get("records"), list) else [] ids = {row[0] for row in records if isinstance(row, list) and row} if not ids: return False found: dict[str, dict] = {} batch_id = batch.get("batchId") if isinstance(batch_id, str): for evidence_path in (ROOT / "docs/p6").glob(f"*{batch_id}*runtime.json"): try: evidence = json.loads(evidence_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): continue evidence_state = str(evidence.get("status") or (evidence.get("result") if isinstance(evidence.get("result"), str) else "") or "") if not (evidence_state in {"pass", "passed", "pass_with_boundaries", "completed", "blocked_native_probe", "pass_with_restricted_and_pending"} or evidence_state.startswith("pass_with_")): continue result_payload = evidence.get("result") if isinstance(evidence.get("result"), dict) else {} evidence_rows = evidence.get("records", []) if isinstance(evidence.get("records"), list) else result_payload.get("records", []) for row in evidence_rows if isinstance(evidence_rows, list) else []: if isinstance(row, dict): record_id = row.get("apiId") or row.get("id") if isinstance(record_id, str) and record_id in ids: found[record_id] = row if len(found) == len(ids): return True for path in CANONICAL_MANIFESTS: try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): continue rows = payload.get("records", []) if isinstance(payload, dict) else [] for row in rows: if isinstance(row, dict) and row.get("id") in ids: found[str(row["id"])] = row if len(found) != len(ids): return False return all(isinstance(row.get("dispositionEvidence"), list) and bool(row["dispositionEvidence"]) for row in found.values()) def context_requires_refresh(active: str) -> bool: """Use the materialized manifest as an incremental context fingerprint.""" manifest_path = CONTEXT_DIR / f"{active}.json" try: manifest = json.loads(manifest_path.read_text(encoding="utf-8")) if manifest.get("task_id") != active or manifest.get("active_task") != active: return True for entry in manifest.get("files", []): if entry.get("mode") != "read": continue path = ROOT / str(entry.get("path", "")) if not path.is_file(): return True stat = path.stat() # New packets carry size/mtime metadata, so the common unchanged # path avoids rereading every context file just to hash it. Keep a # hash fallback for older manifests and unusual timestamp changes. recorded_size = entry.get("bytes") recorded_mtime = entry.get("mtime_ns") if recorded_size is None or recorded_mtime is None: # Upgrade legacy packets once so subsequent cycles can use the # cheap metadata path. return True if stat.st_size == int(recorded_size) and stat.st_mtime_ns == int(recorded_mtime): continue if hashlib.sha256(path.read_bytes()).hexdigest() != entry.get("sha256"): return True return False except (OSError, json.JSONDecodeError, TypeError): return True def prepare_module_batch() -> None: batch_path = ROOT / "docs/current/module-batch-manifest.json" if batch_path.exists(): try: batch = json.loads(batch_path.read_text(encoding="utf-8")) checkpoint = json.loads(CHECKPOINT.read_text(encoding="utf-8")) task_state = json.loads(STATE.read_text(encoding="utf-8")) selected = batch.get("selected") if isinstance(batch.get("selected"), dict) else {} minimum = int(checkpoint.get("batchPolicy", {}).get("minRecords", 2)) completed_batches = checkpoint.get("latestBatch", {}).get("completed", []) implementation_batches = load_registry().get("batches", {}) if implementation_first_enabled() else {} batch_checkpoint = checkpoint.get("batch_checkpoint") if isinstance(checkpoint.get("batch_checkpoint"), dict) else {} if not batch_checkpoint and isinstance(task_state.get("batch_checkpoint"), dict): batch_checkpoint = task_state["batch_checkpoint"] # A completed implementation batch may have explicitly selected # the next family. Preserve that selection across CONTINUE turns; # the planner's default ordering would otherwise replay the same # unresolved boundary batch indefinitely. if ( batch.get("status") == "ready" and not (user_validation_deferred() and selected.get("tier") in {"business-acceptance", "failure-reconciliation"}) and (not implementation_first_enabled() or batch.get("strategy") == "feature-first-deferred-validation" or selected.get("tier") == "failure-reconciliation" and bool(batch.get("failureKeys")) and len(pending_keys(batch["failureKeys"])) == len(batch["failureKeys"]) and not pending_business_repair(batch.get("failureKeys", []))) and not batch_retry_details(batch) and batch.get("batchId") and batch.get("batchId") == checkpoint.get("batchId") and batch.get("batchId") not in completed_batches and not (isinstance(implementation_batches, dict) and batch.get("batchId") in implementation_batches) and not batch_records_have_evidence(batch) and not selected_routed_ids(batch) and feature_batch_replan_size(batch) is None and not ( batch_checkpoint.get("batchId") == batch.get("batchId") and str(batch_checkpoint.get("status", "")) in {"pass", "pass_with_boundaries", "completed", "blocked_native_probe"} ) and (int(selected.get("recordCount", 0)) >= minimum or batch.get("singletonFallback") is True) ): print( f"MODULE_BATCH_PRESERVED: batchId={batch.get('batchId')} " f"module={selected.get('module')} tier={selected.get('tier')} " f"family={selected.get('family')} records={selected.get('recordCount')}" ) return except (OSError, json.JSONDecodeError): pass result = subprocess.run( [sys.executable, planner_script()], cwd=ROOT, text=True, capture_output=True, check=False, ) if result.stdout: print(result.stdout, end="") if result.returncode not in (0, 2): raise subprocess.CalledProcessError(result.returncode, result.args, result.stdout, result.stderr) if batch_path.exists(): try: batch = json.loads(batch_path.read_text(encoding="utf-8")) checkpoint = json.loads(CHECKPOINT.read_text(encoding="utf-8")) checkpoint["batchManifest"] = "docs/current/module-batch-manifest.json" checkpoint["batchId"] = batch.get("batchId") checkpoint["batchSelection"] = batch.get("selected") selected = batch.get("selected") if isinstance(batch.get("selected"), dict) else {} if selected: checkpoint["nextAction"] = ( f"继续执行 Part batch {batch.get('batchId')} 的 " f"{selected.get('module')}/{selected.get('tier')}/{selected.get('family')} " f"{selected.get('recordCount')} 条记录;复用统一 native dispatcher、Facade 和 Worker," "仅以真实 FreeCAD/WASM evidence 晋级,其余保留显式边界。" ) CHECKPOINT.write_text(json.dumps(checkpoint, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") except (OSError, json.JSONDecodeError): pass def update_checkpoint(active: str, card_text: str) -> None: checkpoint = json.loads(CHECKPOINT.read_text(encoding="utf-8")) checkpoint["activeTask"] = active checkpoint["phase"] = "CONTINUE" checkpoint["lastCheckpoint"] = "CONTINUE" checkpoint["lastTransition"] = datetime.now(timezone.utc).isoformat() contract = next((line.split("合同:", 1)[1].strip() for line in card_text.splitlines() if "合同:" in line), "执行当前任务卡中的合同") checkpoint["nextAction"] = contract CHECKPOINT.write_text(json.dumps(checkpoint, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--dry-run", action="store_true", help="report the next leaf without changing the ledger") args = parser.parse_args() state = json.loads(STATE.read_text(encoding="utf-8")) statuses = state.get("tasks", {}) active = state.get("active_task") # Only parse cards that can participate in this transition. The full card # inventory is reserved for the explicit post-implementation validator. relevant_ids = [active] if active else [] relevant_ids.extend(task_id for task_id, status in statuses.items() if status == "planned") cards = load_cards(relevant_ids) if active and statuses.get(active) == "in_progress": if args.dry_run: print(f"TASK_CYCLE_ACTIVE: {active} (dry-run; no files changed)") return 0 refresh_feature_registry_if_sources_changed() # The outer scheduler prepares the projection before each Codex turn. # Rebuild only when the batch/checkpoint fingerprint has changed. if batch_requires_refresh(): prepare_module_batch() checkpoint_repaired = run_continuation_check() # A repair can change the checkpoint projection, so check once more # after validation while preserving an already-ready batch. if checkpoint_repaired and batch_requires_refresh(): prepare_module_batch() batch_path = ROOT / "docs/current/module-batch-manifest.json" batch = json.loads(batch_path.read_text(encoding="utf-8")) if batch_path.exists() else {} if implementation_first_enabled() and batch.get("status") in {"no_ready_batch", "waiting_for_retry"}: # The feature registry and runtime-issue ledger are fingerprinted # by the planner. Do not rewrite context/checkpoint while idle; # that would make the next cycle stale and create perpetual work. print(f"TASK_CYCLE_FEATURE_IDLE: active={active} sourceFingerprint={batch.get('sourceFingerprint')}") return 0 if context_requires_refresh(active): run("tools/prepare_web_task_context.py") else: print(f"CONTEXT_PACKET_REUSED: active={active}") # Keep the incremental contract check on every cycle, but let it use # the manifest's metadata fast path for unchanged inputs. if not implementation_first_enabled(): run("tools/validate_incremental_context.py") active_card = cards.get(active) if active_card: update_checkpoint(active, active_card[2]) print(f"TASK_CYCLE_CONTINUE: active={active}") return 0 candidates: list[tuple[str, Path, str]] = [] for task_id, (path, card_status, text) in cards.items(): if statuses.get(task_id, card_status) != "planned": continue if path.parent == ROOT / "docs/tasks/p6": continue predecessors = predecessor_ids(text) if all(statuses.get(item) == "completed" for item in predecessors): priority = 0 if task_id.startswith("FW-P6-FPA-IMPL-") else 1 candidates.append((f"{priority}:{task_id}", path, text)) if not candidates: print("TASK_CYCLE_NO_READY_LEAF: no dependency-ready planned leaf task") return 2 _, path, text = sorted(candidates, key=lambda item: item[0])[0] next_task = next(task_id for task_id, (candidate, _, _) in cards.items() if candidate == path) if args.dry_run: print(f"TASK_CYCLE_NEXT: {next_task} ({path.relative_to(ROOT)})") return 0 reason = "completed" if active and statuses.get(active) == "completed" else "interrupted" run("tools/rotate_web_task_context.py", "--reason", reason, "--next-task", next_task) run("tools/sync_web_task_state.py", "--active", next_task) update_checkpoint(next_task, text) run("tools/check_continuation_state.py") prepare_module_batch() run("tools/prepare_web_task_context.py") if not implementation_first_enabled(): run("tools/validate_incremental_context.py") print(f"TASK_CYCLE_CLAIMED: {next_task} ({path.relative_to(ROOT)})") return 0 if __name__ == "__main__": raise SystemExit(main())