#!/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:]))