Files
FreeCADWasmDev/tools/prepare_feature_capability_batch.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

306 lines
15 KiB
Python

#!/usr/bin/env python3
"""Select feature work or a focused runtime issue for implementation-first mode."""
from __future__ import annotations
from record_runtime_issue import ACTIVE_STATUSES, runtime_capabilities
import hashlib
import argparse
import json
import os
import sys
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from implementation_first import capability_source_fingerprint, implementation_ready_ids, user_validation_deferred
from scheduler_progress import cooling_ids, retry_details, ineffective_feature_seconds
from migration_workflows import DELIVERY_BATCH_SIZE, DELIVERY_MODULE_ORDER, delivery_route, desktop_command, workflow_family
ROOT = Path(__file__).resolve().parents[1]
REGISTRY = ROOT / "docs/current/feature-capability-registry.json"
ISSUES = ROOT / "docs/current/runtime-issue-ledger.json"
STATE = ROOT / "docs/current/task-state.json"
OUTPUT = ROOT / "docs/current/module-batch-manifest.json"
PIPELINE = ROOT / "docs/current/module-batch-pipeline.json"
PROGRESS = ROOT / "docs/current/scheduler-progress.json"
FEATURE_BATCH_SIZE = max(1, min(32, int(os.environ.get("FREECAD_FEATURE_BATCH_SIZE", "16"))))
RUNTIME_ISSUE_BATCH_SIZE = max(1, min(FEATURE_BATCH_SIZE, int(os.environ.get("FREECAD_RUNTIME_ISSUE_BATCH_SIZE", "4"))))
MIXED_FEATURE_BATCH_SIZE = max(2, min(FEATURE_BATCH_SIZE, int(os.environ.get("FREECAD_MIXED_FEATURE_BATCH_SIZE", "8"))))
STALLED_BATCH_SIZE = max(1, min(FEATURE_BATCH_SIZE, int(os.environ.get("FREECAD_STALLED_FEATURE_BATCH_SIZE", "4"))))
# Shrink a feature batch before a second long model turn is spent on the same
# unregistered commands. The batch remains explicit and is retried later.
STALLED_BATCH_SECONDS = max(30.0, float(os.environ.get("FREECAD_STALLED_FEATURE_BATCH_SECONDS", "90")))
MODULE_ORDER = {
"Part": 0,
"PartDesign": 1,
"Sketcher": 2,
"TechDraw": 3,
"Assembly": 4,
"BIM": 5,
"Draft": 6,
"Mesh": 7,
"Fem": 8,
"CAM": 9,
"Import": 10,
}
def load(path: Path) -> dict:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return value if isinstance(value, dict) else {}
def stalled_batch_limit(current: dict | None = None, pipeline: dict | None = None) -> int | None:
current = load(OUTPUT) if current is None else current
pipeline = load(PIPELINE) if pipeline is None else pipeline
selected = current.get("selected") if isinstance(current.get("selected"), dict) else {}
readiness = pipeline.get("staticReadiness") if isinstance(pipeline.get("staticReadiness"), dict) else {}
selected_count = int(selected.get("recordCount", 0) or 0)
if (
current.get("status") != "ready"
or current.get("strategy") != "feature-first-deferred-validation"
or selected_count <= STALLED_BATCH_SIZE
or selected.get("family") == "runtime-issue"
):
return None
same_pipeline = current.get("batchId") == pipeline.get("batchId")
if same_pipeline and (pipeline.get("status") != "implementation_incomplete" or int(readiness.get("ready", 0) or 0) != 0):
return None
history = load(PROGRESS).get("history", [])
if ineffective_feature_seconds(current, history) >= STALLED_BATCH_SECONDS:
return STALLED_BATCH_SIZE
ids = {str(row[0]) for row in current.get("records", []) if row}
if any(ids.intersection(row.get("progressedIds", [])) or
row.get("batchId") == current.get("batchId") and row.get("outcome") == "progress" for row in history):
return None
attempts = [row for row in history if row.get("batchId") == current.get("batchId")]
if attempts and not any(row.get("outcome") != "provider_wait" for row in attempts):
return None
if not same_pipeline:
return None
try:
generated = datetime.fromisoformat(str(current.get("generatedAt", "")).replace("Z", "+00:00"))
except ValueError:
return None
age = (datetime.now(timezone.utc) - generated).total_seconds()
return STALLED_BATCH_SIZE if age >= STALLED_BATCH_SECONDS else None
def select_group_batch(ordered: list[tuple[tuple[str, str], list[dict]]], limit_override: int | None = None) -> tuple[str, str, list[dict], list[str]]:
(module, first_family), first_rows = ordered[0]
limit = RUNTIME_ISSUE_BATCH_SIZE if first_family == "runtime-issue" else min(FEATURE_BATCH_SIZE, limit_override or FEATURE_BATCH_SIZE)
if first_family.startswith("module-business-"):
limit = min(limit, DELIVERY_BATCH_SIZE)
# A record can be present in both the runtime issue projection and its
# capability group. Deduplicate at the final batch boundary so preflight
# never rejects an otherwise executable batch.
selected = []
selected_ids: set[str] = set()
for row in first_rows:
record_id = str(row.get("id", ""))
if record_id and record_id not in selected_ids:
selected.append(row)
selected_ids.add(record_id)
if len(selected) >= limit:
break
source_families = [first_family]
if (first_family != "runtime-issue" and not first_family.startswith("module-business-") and len(selected) < limit
and all(row.get("implementationStatus") == "routed" for row in selected)):
# Only already-routed work shares a build batch across source families.
# Missing behavior needs a coherent source packet, not unrelated fillers.
limit = max(len(selected), min(limit, MIXED_FEATURE_BATCH_SIZE))
for (candidate_module, candidate_family), rows in ordered[1:]:
if candidate_module != module or candidate_family == "runtime-issue":
continue
if any(row.get("implementationStatus") != "routed" for row in rows):
continue
if candidate_family.startswith("module-business-") != first_family.startswith("module-business-"):
continue
remaining = limit - len(selected)
if remaining <= 0:
break
for row in rows:
record_id = str(row.get("id", ""))
if not record_id or record_id in selected_ids:
continue
selected.append(row)
selected_ids.add(record_id)
if len(selected) >= limit:
break
source_families.append(candidate_family)
family = first_family if len(source_families) == 1 else "desktop-command-mixed"
return module, family, selected, source_families
def feature_batch_id(module: str, family: str, rows: list[dict]) -> str:
identity = "|".join([module, family, *(str(row.get("id")) for row in rows)])
return hashlib.sha256(identity.encode("utf-8")).hexdigest()[:24]
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
mode = parser.add_mutually_exclusive_group()
mode.add_argument("--delivery-only", action="store_true")
mode.add_argument("--runtime-only", action="store_true")
options = parser.parse_args(argv or [])
registry = load(REGISTRY)
issues = load(ISSUES)
state = load(STATE)
records = registry.get("records", []) if isinstance(registry.get("records"), list) else []
source_fingerprint = capability_source_fingerprint()
implemented = implementation_ready_ids()
by_id = {str(row.get("id")): row for row in records if isinstance(row, dict) and row.get("id")}
# Product faults retain their business IDs, not unrelated desktop aliases.
by_id.update({key: row for key, row in runtime_capabilities(ROOT).items() if key.startswith("product:")})
groups: dict[tuple[str, str], list[dict]] = defaultdict(list)
runtime_issue_ids: set[str] = set()
cooling = cooling_ids(candidates=set(by_id))
deferred_ids: set[str] = set()
for issue in issues.get("issues", []) if isinstance(issues.get("issues"), list) else []:
if not isinstance(issue, dict) or str(issue.get("status", "open")) not in ACTIVE_STATUSES:
continue
if user_validation_deferred() and issue.get("status") == "fix_deployed_pending_use":
continue
capability_id = str(issue.get("capabilityId") or "")
row = by_id.get(capability_id)
if row:
runtime_issue_ids.add(capability_id)
if options.delivery_only:
continue
if capability_id in cooling:
deferred_ids.add(capability_id)
continue
groups[(str(row.get("module")), "runtime-issue")].append({**row, "issueId": issue.get("id")})
for row in records:
if not isinstance(row, dict):
continue
if options.runtime_only or options.delivery_only and not delivery_route(row):
continue
if row.get("id") in implemented or row.get("id") in runtime_issue_ids:
continue
if row.get("id") in cooling:
deferred_ids.add(str(row["id"]))
continue
# A static route is not a build receipt. Keep routed records in the
# queue until the target-level pipeline (or the React-only fast path)
# records them as implementation_ready. Otherwise the last routed
# batch is silently lost when the planner enters its idle state.
source_stem = Path(str(row.get("sourcePath") or "commands")).stem.lower()
family = (f"module-business-{workflow_family(row)}" if row.get("category") and not desktop_command(row)
else f"desktop-command-{source_stem}")
groups[(str(row.get("module")), family)].append(row)
if not groups:
payload = {
"schema": "freecad-web-feature-capability-batch/v1",
"status": "no_ready_batch",
"generatedAt": datetime.now(timezone.utc).isoformat(),
"strategy": "feature-first-deferred-validation",
"sourceFingerprint": source_fingerprint,
"reason": "no ready records in the requested selection scope" if options.delivery_only or options.runtime_only else "all registered Web capabilities have implementation receipts; waiting for focused runtime issues",
"selectionScope": "delivery" if options.delivery_only else "runtime" if options.runtime_only else "full",
"records": [],
"queue": [],
**retry_details(deferred_ids),
}
current = load(OUTPUT)
reused = current.get("status") == payload["status"] and current.get("sourceFingerprint") == source_fingerprint
if not reused:
OUTPUT.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
print(json.dumps({"status": payload["status"], "featureCommands": len(records), "deferredRecords": len(deferred_ids), "reused": reused}, ensure_ascii=False))
return 0
ordered = sorted(
groups.items(),
key=lambda item: (
0 if item[0][1] == "runtime-issue" else 1,
0 if any(delivery_route(row) for row in item[1]) else 1 if item[0][1].startswith("desktop-command-") else 2,
DELIVERY_MODULE_ORDER.get(item[0][0], 20 + MODULE_ORDER.get(item[0][0], 50)) if options.delivery_only else MODULE_ORDER.get(item[0][0], 50),
-len(item[1]),
item[0][0],
),
)
nominal_module, nominal_family, nominal_rows, _ = select_group_batch(ordered)
candidate = {"status": "ready", "strategy": "feature-first-deferred-validation",
"batchId": feature_batch_id(nominal_module, nominal_family, nominal_rows),
"selected": {"module": nominal_module, "family": nominal_family, "recordCount": len(nominal_rows)},
"records": [[row["id"]] for row in nominal_rows]}
repeated_seconds = ineffective_feature_seconds(candidate, load(PROGRESS).get("history", []))
routed = any(row.get("implementationStatus") == "routed" for row in nominal_rows)
adaptive_limit = stalled_batch_limit(candidate) if not routed else None
if adaptive_limit is None and not routed and load(OUTPUT).get("batchId") == candidate["batchId"]:
adaptive_limit = stalled_batch_limit()
module, family, selected, source_families = select_group_batch(ordered, adaptive_limit)
batch_id = feature_batch_id(module, family, selected)
compact = [
[row.get("id"), row.get("command"), "implementation_required", row.get("sourcePath") or (f"web/src/{row['worker']}" if row.get("worker") else "web/src/workbench-shell.tsx"), row.get("sourceLine") or 1]
for row in selected
]
sizing_reason = (
"stalled-zero-readiness" if adaptive_limit
else "delivery-workflow-cap" if family.startswith("module-business-")
else "mixed-family-cap" if len(source_families) > 1 and len(selected) < FEATURE_BATCH_SIZE
else "default"
)
payload = {
"schema": "freecad-web-feature-capability-batch/v1",
"status": "ready",
"generatedAt": datetime.now(timezone.utc).isoformat(),
"taskId": state.get("active_task"),
"selectionScope": "delivery" if options.delivery_only else "runtime" if options.runtime_only else "full",
"businessWorkflow": workflow_family(selected[0]) if family.startswith("module-business-") else None,
"batchId": batch_id,
"strategy": "feature-first-deferred-validation",
"sourceFingerprint": source_fingerprint,
"deferredRecords": len(deferred_ids),
"minimumRecords": 1,
"singletonFallback": len(selected) == 1,
"batchSizing": {
"default": FEATURE_BATCH_SIZE,
"selected": len(selected),
"reason": sizing_reason,
"stallSeconds": STALLED_BATCH_SECONDS if adaptive_limit else 0,
"effectiveNoProgressSeconds": repeated_seconds,
"historyBatchId": candidate["batchId"],
},
"selected": {
"module": module,
"tier": "feature-workflow",
"family": family,
"sourceFamilies": source_families,
"recordCount": len(selected),
"dispositionCounts": {"implementation_required": len(selected)},
},
"fields": ["id", "command", "status", "source", "sourceLine"],
"records": compact,
"queue": [
{
"module": key[0],
"tier": "feature-workflow",
"family": key[1],
"unresolved": len(rows),
"nextSize": min(RUNTIME_ISSUE_BATCH_SIZE if key[1] == "runtime-issue" else FEATURE_BATCH_SIZE, len(rows)),
}
for key, rows in ordered[:12]
],
"boundaries": [
"This queue is driven by Web capabilities and runtime issues, not the Python symbol inventory.",
"Implementation does not imply validation pass.",
"Only the affected module is rebuilt; no full gate is run.",
],
}
OUTPUT.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
print(json.dumps({"status": "ready", "batchId": batch_id, "module": module, "family": family, "records": len(selected)}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))