306 lines
15 KiB
Python
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:]))
|