Files
FreeCADWasmDev/tools/continue_web_task_cycle.py
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

471 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())