perf(web): add idempotent batch verification fast paths
This commit is contained in:
@@ -13,6 +13,8 @@ commit and push.
|
||||
a missing scheduler and stops on a terminal or blocked checkpoint.
|
||||
- `show_blender_web_scheduler.py`: delegates live RUNNING/IDLE/BLOCKED state to
|
||||
`tools/web/print-wbv2-live-status.mjs`.
|
||||
- `validate_incremental_context.py`: validates only the active batch metadata;
|
||||
unchanged inputs return `incremental-context-ok ... context=REUSED`.
|
||||
- `start_unattended.sh`: launches the scheduler and watchdog with `nohup` and
|
||||
`setsid` for process-level continuity.
|
||||
- `check_continuation_state.py` and `validate_web_context.py`: read-only WBV2
|
||||
@@ -60,6 +62,12 @@ For machine-readable live state:
|
||||
python3 my-tools/show_blender_web_scheduler.py --json
|
||||
```
|
||||
|
||||
Implementation cycles use a bounded fast path: each module manifest carries a
|
||||
stable `batchId` and selected record IDs, context metadata compares
|
||||
`bytes`/`mtime_ns` before hashing, and the outer scheduler owns planner and
|
||||
continuation rotation. Full API, format, Chromium, performance and release
|
||||
gates remain an explicit final-audit operation.
|
||||
|
||||
`--commit --push` is enabled by the launcher and can be disabled for a local
|
||||
dry run with `WBV2_AUTO_COMMIT=0 WBV2_AUTO_PUSH=0`.
|
||||
|
||||
|
||||
@@ -168,6 +168,17 @@ PREFERRED_BATCHES = [
|
||||
"production": "tests/golden/{task}/line-style-reader-production.json",
|
||||
"types": [],
|
||||
},
|
||||
{
|
||||
"moduleId": "FCURVE_MODIFIER_SUBTYPE_READER_BATCH",
|
||||
"title": "verify FCurve modifier subtype reader batch",
|
||||
"owner": "ANIMATION",
|
||||
"identifiers": [],
|
||||
"rna": ["FModifierEnvelope", "FModifierGenerator", "FModifierLimits", "FModifierNoise", "FModifierSmooth", "FModifierStepped"],
|
||||
"fixture": "tests/files/web/fcurve_modifier_subtype_batch_scene.blend",
|
||||
"test": "web/tests/e2e/wbv2-fcurve-modifier-production.spec.ts",
|
||||
"production": "tests/golden/{task}/fcurve-modifier-production.json",
|
||||
"types": [],
|
||||
},
|
||||
{
|
||||
"moduleId": "MODIFIER_SOURCE_DEPENDENCY_REUSE",
|
||||
"title": "reuse verified modifier receipts for shared source utilities",
|
||||
@@ -260,6 +271,12 @@ def rel(path: Path) -> str:
|
||||
return path.relative_to(ROOT).as_posix()
|
||||
|
||||
|
||||
def batch_contract(task: str, batch: dict) -> dict:
|
||||
selected = [unit["id"] for unit in batch.get("units", [])]
|
||||
digest = hashlib.sha256(json.dumps(selected, ensure_ascii=False, separators=(",", ":")).encode()).hexdigest()[:16]
|
||||
return {"batchId": batch.get("batchId") or f"{task}:{batch['moduleId']}:{digest}", "selectedRecordIds": selected}
|
||||
|
||||
|
||||
def run_node(*args: str) -> None:
|
||||
result = subprocess.run(["node", *args], cwd=ROOT, text=True, capture_output=True, check=False)
|
||||
output = result.stdout or result.stderr
|
||||
@@ -298,7 +315,7 @@ def snapshot(text: str, task: str, parent: str, next_task: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def make_card(task: str, parent: str, status: str, batch: dict, entrypoint: str) -> str:
|
||||
def make_card(task: str, parent: str, status: str, batch: dict, entrypoint: str, contract: dict) -> str:
|
||||
fixture_paths = batch.get("fixtures") or ([batch["fixture"]] if batch.get("fixture") else [])
|
||||
fixture_text = ", ".join(f"`{fixture}`" for fixture in fixture_paths)
|
||||
if not fixture_text:
|
||||
@@ -312,6 +329,9 @@ def make_card(task: str, parent: str, status: str, batch: dict, entrypoint: str)
|
||||
- gap: capability:{batch['moduleId']}
|
||||
- ownerFamily: {batch['owner']}
|
||||
- targetImplementationClass: QUEUE_ONLY
|
||||
- batchId: {contract['batchId']}
|
||||
- selectedRecordIds: {json.dumps(contract['selectedRecordIds'], ensure_ascii=False, separators=(',', ':'))}
|
||||
- commandBudget: max=48; read/exploration max=16
|
||||
- Implementation entrypoint: `{entrypoint}`
|
||||
|
||||
## 目标
|
||||
@@ -345,6 +365,9 @@ def make_card(task: str, parent: str, status: str, batch: dict, entrypoint: str)
|
||||
- gap: capability:{batch['moduleId']}
|
||||
- ownerFamily: {batch['owner']}
|
||||
- targetImplementationClass: LOCAL_EXACT
|
||||
- batchId: {contract['batchId']}
|
||||
- selectedRecordIds: {json.dumps(contract['selectedRecordIds'], ensure_ascii=False, separators=(',', ':'))}
|
||||
- commandBudget: max=48; read/exploration max=16
|
||||
- Implementation entrypoint: `{entrypoint}`
|
||||
|
||||
## 目标
|
||||
@@ -388,6 +411,9 @@ def make_card(task: str, parent: str, status: str, batch: dict, entrypoint: str)
|
||||
- gap: capability:{batch['moduleId']}
|
||||
- ownerFamily: {batch['owner']}
|
||||
- targetImplementationClass: LOCAL_EXACT
|
||||
- batchId: {contract['batchId']}
|
||||
- selectedRecordIds: {json.dumps(contract['selectedRecordIds'], ensure_ascii=False, separators=(',', ':'))}
|
||||
- commandBudget: max=48; read/exploration max=16
|
||||
- Implementation entrypoint: `{entrypoint}`
|
||||
|
||||
## 目标
|
||||
@@ -435,7 +461,33 @@ def select_batch(inventory: dict) -> dict:
|
||||
if all(selected):
|
||||
unresolved = [unit for unit in selected if unit.get("coverageStatus") != "VERIFIED"]
|
||||
if unresolved:
|
||||
return {**candidate, "units": unresolved, "candidateUnits": selected}
|
||||
key = f"{candidate['moduleId']}:LOCAL_EXACT:{candidate['owner']}"
|
||||
history = []
|
||||
for receipt_path in sorted(ROOT.glob("tests/golden/WBV2-P*-001/module-receipt.json"), key=lambda item: item.parent.name):
|
||||
receipt = read_json(receipt_path)
|
||||
if receipt.get("moduleId") != candidate["moduleId"]:
|
||||
continue
|
||||
duration = receipt.get("efficiency", {}).get("totalDurationMs")
|
||||
if not isinstance(duration, (int, float)):
|
||||
duration = sum(item.get("durationMs", 0) for item in receipt.get("commands", []) if isinstance(item, dict))
|
||||
history.append({
|
||||
"durationMs": duration,
|
||||
"commands": len(receipt.get("commands", [])),
|
||||
"timeout": any("timeout" in str(item.get("output", "")).lower() for item in receipt.get("commands", []) if isinstance(item, dict)),
|
||||
"focusedGateFailure": receipt.get("status") != "PASS",
|
||||
})
|
||||
recent = history[-3:]
|
||||
min_size = 1
|
||||
max_size = 8 if len(unresolved) > 16 or len(candidate.get("fixtures", [])) > 1 else 16
|
||||
current_size = min(len(unresolved), max_size)
|
||||
if recent and any(item["timeout"] or item["durationMs"] > 900_000 or item["commands"] > 48 for item in recent):
|
||||
next_size = max(min_size, current_size // 2)
|
||||
elif recent and all(item["durationMs"] < 300_000 and item["commands"] < 24 and not item["focusedGateFailure"] for item in recent):
|
||||
next_size = min(max_size, current_size + 4)
|
||||
else:
|
||||
next_size = current_size
|
||||
chosen = unresolved[:max(min_size, min(max_size, len(unresolved), next_size))]
|
||||
return {**candidate, "units": chosen, "candidateUnits": selected, "adaptiveKey": key, "adaptiveHistory": recent, "adaptiveCurrentSize": current_size, "adaptiveNextSize": len(chosen), "adaptiveMinSize": min_size, "adaptiveMaxSize": max_size}
|
||||
raise SystemExit("no configured unresolved Blender source batch remains")
|
||||
|
||||
|
||||
@@ -457,8 +509,10 @@ def main() -> int:
|
||||
root_task, module_task = f"WBV2-P{phase}-000", f"WBV2-P{phase}-001"
|
||||
inventory = read_json(CONTROL_ROOT / "dynamic-inventory.json")
|
||||
batch = select_batch(inventory)
|
||||
contract = batch_contract(module_task, batch)
|
||||
batch["batchId"] = contract["batchId"]
|
||||
if not args.write:
|
||||
print(json.dumps({"status": "ready", "rootTask": root_task, "nextTask": module_task, "moduleId": batch["moduleId"], "batchSize": len(batch["units"]), "sourceUnits": [unit["id"] for unit in batch["units"]]}, ensure_ascii=False))
|
||||
print(json.dumps({"status": "ready", "rootTask": root_task, "nextTask": module_task, "moduleId": batch["moduleId"], "batchId": contract["batchId"], "batchSize": len(batch["units"]), "selectedRecordIds": contract["selectedRecordIds"], "sourceUnits": [unit["id"] for unit in batch["units"]], "adaptive": {"key": batch.get("adaptiveKey"), "history": batch.get("adaptiveHistory", []), "nextSize": batch.get("adaptiveNextSize", len(batch["units"]))}}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
current_status = ROOT / "docs/status" / f"{current}.md"
|
||||
@@ -497,19 +551,20 @@ def main() -> int:
|
||||
f"WBV2_BATCH_TASK={module_task} {production_env} {fixture_env} WBV2_BATCH_TYPES='{batch_types}' WBV2_BATCH_UNITS='{batch_units}' WEB_TEST_PORT=5371 npm --prefix web run test:e2e -- --workers=1 tests/e2e/{Path(batch['test']).name}",
|
||||
]
|
||||
root_record = {"id": root_task, "phase": str(phase), "title": "authorize_modifier_reader_batch", "kind": "enabling", "owner": "PROGRAM", "dependsOn": [current], "parityId": None, "implementationClass": "QUEUE_ONLY", "parallelGroup": f"module-root-{phase}", "gate": "MODULE", "card": f"docs/tasks/{root_task}.md", "behavior": "Authorize one shared source-backed modifier reader batch.", "outputs": ["successor authorization", "module plan", "manifest", "status"], "acceptance": ["prior evidence remains hash-bound", "batch source mapping is explicit", "inventory remains source-derived"], "commands": ["node tools/web/check-execution-control-plane.mjs"], "status": "done", "ready": False, "queueMutationAllowed": True}
|
||||
module_record = {"id": module_task, "phase": str(phase), "title": batch["title"], "kind": "module", "owner": batch["owner"], "dependsOn": [root_task], "parityId": batch["moduleId"], "implementationClass": "LOCAL_EXACT", "parallelGroup": f"module-{batch['moduleId'].lower()}", "gate": "MODULE", "card": f"docs/tasks/{module_task}.md", "behavior": f"Verify the {batch['moduleId']} batch through Blender Main, WASM and Chromium recovery.", "outputs": ["aggregate module receipt", "coverage bindings", "manifest", "status"], "acceptance": ["source and protocol hashes are bound", "every selected Main reader type is proven", "negative no-mutation and recovery are proven", "Chromium and save/reopen evidence are exact"], "status": "in_progress", "ready": True, "queueMutationAllowed": True, "module": {"moduleId": batch["moduleId"], "sourceBacked": True, "capabilities": [f"CAP-{batch['moduleId']}-READER", f"CAP-{batch['moduleId']}-RECOVERY", f"CAP-{batch['moduleId']}-FAIL-CLOSED"], "sourcePaths": source_paths, "reuseReceipts": batch.get("reuseReceipts", [{"manifest": "tests/golden/WBV2-P1107-000/manifest.json", "report": "tests/golden/WBV2-P1107-000/module-receipt.json"}]), "dependencyReuse": batch.get("dependencyReuse", []), "positive": [f"{name} type is read from Blender Main" for name in batch["identifiers"]] + ["shared RNA metadata is preserved", "save/reopen preserves the batch metadata"], "negative": ["malformed blend is rejected without Main mutation", "unsupported batch data fails closed", "non-finite metadata is not synthesized"], "recovery": ["save/reopen", "worker termination", "deterministic second read"], "commands": commands, "fullFamilyParity": False, "evidencePaths": [batch["production"].format(task=module_task)], "coverageBindings": [unit["id"] for unit in batch["units"]]}}
|
||||
module_record = {"id": module_task, "phase": str(phase), "title": batch["title"], "kind": "module", "owner": batch["owner"], "dependsOn": [root_task], "parityId": batch["moduleId"], "implementationClass": "LOCAL_EXACT", "parallelGroup": f"module-{batch['moduleId'].lower()}", "gate": "MODULE", "card": f"docs/tasks/{module_task}.md", "behavior": f"Verify the {batch['moduleId']} batch through Blender Main, WASM and Chromium recovery.", "outputs": ["aggregate module receipt", "coverage bindings", "manifest", "status"], "acceptance": ["source and protocol hashes are bound", "every selected Main reader type is proven", "negative no-mutation and recovery are proven", "Chromium and save/reopen evidence are exact"], "status": "in_progress", "ready": True, "queueMutationAllowed": True, "batchId": contract["batchId"], "selectedRecordIds": contract["selectedRecordIds"], "module": {"moduleId": batch["moduleId"], "batchId": contract["batchId"], "selectedRecordIds": contract["selectedRecordIds"], "sourceBacked": True, "capabilities": [f"CAP-{batch['moduleId']}-READER", f"CAP-{batch['moduleId']}-RECOVERY", f"CAP-{batch['moduleId']}-FAIL-CLOSED"], "sourcePaths": source_paths, "reuseReceipts": batch.get("reuseReceipts", [{"manifest": "tests/golden/WBV2-P1107-000/manifest.json", "report": "tests/golden/WBV2-P1107-000/module-receipt.json"}]), "dependencyReuse": batch.get("dependencyReuse", []), "positive": [f"{name} type is read from Blender Main" for name in batch["identifiers"]] + ["shared RNA metadata is preserved", "save/reopen preserves the batch metadata"], "negative": ["malformed blend is rejected without Main mutation", "unsupported batch data fails closed", "non-finite metadata is not synthesized"], "recovery": ["save/reopen", "worker termination", "deterministic second read"], "commands": commands, "fullFamilyParity": False, "evidencePaths": [batch["production"].format(task=module_task)], "coverageBindings": contract["selectedRecordIds"]}}
|
||||
|
||||
module_record["module"]["commandBudget"] = {"maxCommands": 48, "maxReadCommands": 16}
|
||||
root_out = CONTROL_ROOT.parent / root_task
|
||||
root_plan_path = root_out / "module-execution-plan.json"
|
||||
root_plan = {**plan, "planId": f"BLENDER_WEB_MODULE_EXECUTION_P{phase}", "status": "ACTIVE_MODULE_QUEUE", "tasks": [*plan["tasks"], root_record, module_record], "modules": [*plan.get("modules", []), {"id": batch["moduleId"], "task": module_task, "owner": batch["owner"], "sourceBacked": True, "capabilities": module_record["module"]["capabilities"], "sourcePaths": source_paths, "reuseReceipts": module_record["module"]["reuseReceipts"], "fullFamilyParity": False}]}
|
||||
root_plan = {**plan, "planId": f"BLENDER_WEB_MODULE_EXECUTION_P{phase}", "status": "ACTIVE_MODULE_QUEUE", "tasks": [*plan["tasks"], root_record, module_record], "modules": [*plan.get("modules", []), {"id": batch["moduleId"], "task": module_task, "owner": batch["owner"], "sourceBacked": True, "batchId": contract["batchId"], "selectedRecordIds": contract["selectedRecordIds"], "capabilities": module_record["module"]["capabilities"], "sourcePaths": source_paths, "reuseReceipts": module_record["module"]["reuseReceipts"], "fullFamilyParity": False}]}
|
||||
write_json(root_plan_path, root_plan)
|
||||
root_card_path, module_card_path = ROOT / "docs/tasks" / f"{root_task}.md", ROOT / "docs/tasks" / f"{module_task}.md"
|
||||
write_text(root_card_path, make_card(root_task, current, "done", batch, f"python3 my-tools/authorize_next_wbv2_task.py --write"))
|
||||
write_text(module_card_path, make_card(module_task, root_task, "in_progress", batch, f"node tools/web/run-wbv2-module-task.mjs --task {module_task} --write"))
|
||||
write_text(root_card_path, make_card(root_task, current, "done", batch, f"python3 my-tools/authorize_next_wbv2_task.py --write", contract))
|
||||
write_text(module_card_path, make_card(module_task, root_task, "in_progress", batch, f"node tools/web/run-wbv2-module-task.mjs --task {module_task} --write", contract))
|
||||
root_report_path, root_status_path, root_manifest_path = root_out / "successor-authorization-report.json", ROOT / "docs/status" / f"{root_task}.md", root_out / "manifest.json"
|
||||
write_json(root_report_path, {"schemaVersion": 1, "task": root_task, "operation": "AUTHORIZE_SOURCE_BACKED_MODULE_BATCH", "taskClass": "QUEUE_ENABLEMENT", "status": "PASS", "parent": {"task": current, "manifest": rel(current_manifest_path), "manifestSha256": sha(current_manifest_path)}, "module": {"moduleId": batch["moduleId"], "task": module_task, "batchSize": len(batch["units"]), "candidateBatchSize": len(batch.get("candidateUnits", batch["units"])), "sourceUnits": [unit["id"] for unit in batch["units"]], "sourceBacked": True}, "inventory": {"unitCount": inventory["summary"]["unitCount"], "verifiedUnits": inventory["coverage"]["verifiedUnits"], "unverifiedUnits": inventory["coverage"]["unverifiedUnits"], "sourceTreeSha256": inventory["authority"]["sourceTreeSha256"], "boundUnitDelta": 0}, "efficiency": {"existingBindingsReused": len(batch.get("candidateUnits", batch["units"])) - len(batch["units"]), "selectionPolicy": "all-candidate-units-then-unresolved-only"}, "nextTask": module_task})
|
||||
write_json(root_report_path, {"schemaVersion": 1, "task": root_task, "operation": "AUTHORIZE_SOURCE_BACKED_MODULE_BATCH", "taskClass": "QUEUE_ENABLEMENT", "status": "PASS", "parent": {"task": current, "manifest": rel(current_manifest_path), "manifestSha256": sha(current_manifest_path)}, "batchId": contract["batchId"], "selectedRecordIds": contract["selectedRecordIds"], "module": {"moduleId": batch["moduleId"], "task": module_task, "batchSize": len(batch["units"]), "candidateBatchSize": len(batch.get("candidateUnits", batch["units"])), "sourceUnits": contract["selectedRecordIds"], "sourceBacked": True}, "inventory": {"unitCount": inventory["summary"]["unitCount"], "verifiedUnits": inventory["coverage"]["verifiedUnits"], "unverifiedUnits": inventory["coverage"]["unverifiedUnits"], "sourceTreeSha256": inventory["authority"]["sourceTreeSha256"], "boundUnitDelta": 0}, "efficiency": {"existingBindingsReused": len(batch.get("candidateUnits", batch["units"])) - len(batch["units"]), "selectionPolicy": "all-candidate-units-then-unresolved-only", "adaptiveKey": batch.get("adaptiveKey"), "adaptiveHistory": batch.get("adaptiveHistory", []), "adaptiveNextSize": batch.get("adaptiveNextSize", len(batch["units"]))}, "nextTask": module_task})
|
||||
write_text(root_status_path, f"# {root_task} status\n\n- task: `{root_task}`; parent: `{current}`; status: `done`\n- taskClass: `QUEUE_ENABLEMENT`; no modifier-family parity completion claimed\n- source-backed batch authorized: `{batch['moduleId']}` ({len(batch['units'])} units)\n- dynamic inventory: `{inventory['summary']['unitCount']}` units; verified `{inventory['coverage']['verifiedUnits']}`; unverified `{inventory['coverage']['unverifiedUnits']}`; bound delta `0`\n- nextTask: `{module_task}`\n")
|
||||
write_json(root_manifest_path, {"schemaVersion": 1, "task": root_task, "parentTask": current, "operation": "AUTHORIZE_SOURCE_BACKED_MODULE_BATCH", "status": "done", "enablingTask": True, "parityStateChange": False, "runtime": "MODULE_EXECUTION_QUEUE_BRIDGE", "artifacts": {"parentManifest": {"path": rel(current_manifest_path), "sha256": sha(current_manifest_path)}, "plan": {"path": rel(root_plan_path), "sha256": sha(root_plan_path)}, "report": {"path": rel(root_report_path), "sha256": sha(root_report_path)}, "status": {"path": rel(root_status_path), "sha256": sha(root_status_path)}}, "assertions": {"sourceBackedSuccessor": True, "moduleId": batch["moduleId"], "batchSize": len(batch["units"]), "inventoryTotal": 6900, "effectiveCompleted": 290, "effectivePending": 6609, "dynamicUnitCount": inventory["summary"]["unitCount"], "dynamicVerifiedUnits": inventory["coverage"]["verifiedUnits"], "dynamicUnverifiedUnits": inventory["coverage"]["unverifiedUnits"], "boundUnitDelta": 0, "fullHistoricalReproduction": False}, "nextTask": module_task})
|
||||
write_json(root_manifest_path, {"schemaVersion": 1, "task": root_task, "parentTask": current, "operation": "AUTHORIZE_SOURCE_BACKED_MODULE_BATCH", "status": "done", "enablingTask": True, "parityStateChange": False, "runtime": "MODULE_EXECUTION_QUEUE_BRIDGE", "batchId": contract["batchId"], "selectedRecordIds": contract["selectedRecordIds"], "artifacts": {"parentManifest": {"path": rel(current_manifest_path), "sha256": sha(current_manifest_path)}, "plan": {"path": rel(root_plan_path), "sha256": sha(root_plan_path)}, "report": {"path": rel(root_report_path), "sha256": sha(root_report_path)}, "status": {"path": rel(root_status_path), "sha256": sha(root_status_path)}}, "assertions": {"sourceBackedSuccessor": True, "moduleId": batch["moduleId"], "batchSize": len(batch["units"]), "inventoryTotal": 6900, "effectiveCompleted": 290, "effectivePending": 6609, "dynamicUnitCount": inventory["summary"]["unitCount"], "dynamicVerifiedUnits": inventory["coverage"]["verifiedUnits"], "dynamicUnverifiedUnits": inventory["coverage"]["unverifiedUnits"], "boundUnitDelta": 0, "fullHistoricalReproduction": False}, "nextTask": module_task})
|
||||
|
||||
run_node("tools/web/generate-v2-task-index.mjs", "--plan-path", rel(root_plan_path))
|
||||
queue_text = QUEUE.read_text(encoding="utf-8")
|
||||
|
||||
@@ -39,6 +39,7 @@ def main() -> int:
|
||||
"schedulerWrapperStatus": read_json(CONTROL_ROOT / "my-tools-scheduler.json"),
|
||||
}
|
||||
status["progress"] = project_progress()
|
||||
status["fastPath"] = fast_path_status(status)
|
||||
if args.json:
|
||||
print(json.dumps(status, ensure_ascii=False, separators=(",", ":")))
|
||||
if not args.watch:
|
||||
@@ -64,7 +65,7 @@ def main() -> int:
|
||||
print(f" supervisor: pid={supervisor.get('pid') or 'none'} alive={supervisor.get('alive', False)} state={supervisor.get('state') or 'unknown'}")
|
||||
print(f" latest handoff: {status.get('latestHandoff') or 'none'}")
|
||||
print(f" local HEAD: {status.get('localHead') or 'unknown'}")
|
||||
for line in format_progress(status["progress"]):
|
||||
for line in format_progress(status["progress"], status["fastPath"]):
|
||||
print(line)
|
||||
print(f" message: {status.get('message', '')}")
|
||||
if not args.watch:
|
||||
@@ -180,12 +181,42 @@ def project_progress() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def format_progress(progress: dict) -> list[str]:
|
||||
def fast_path_status(status: dict) -> dict:
|
||||
scheduler = status.get("scheduler", {}) if isinstance(status, dict) else {}
|
||||
latest_batch = scheduler.get("latestBatch") if isinstance(scheduler.get("latestBatch"), dict) else {}
|
||||
cache = _read_json(CONTROL_ROOT / "context-cache.json")
|
||||
ledger = _read_json(CONTROL_ROOT / "failure-ledger.json")
|
||||
return {
|
||||
"batch": {
|
||||
"batchId": latest_batch.get("batchId"),
|
||||
"selectedRecordCount": len(latest_batch.get("selectedRecordIds", [])),
|
||||
"completed": bool(latest_batch.get("completed")),
|
||||
},
|
||||
"contextCache": {
|
||||
"status": cache.get("status", "MISSING"),
|
||||
"task": cache.get("task"),
|
||||
"schemaVersion": cache.get("schemaVersion"),
|
||||
"inputCount": len(cache.get("inputs", {})) if isinstance(cache.get("inputs"), dict) else 0,
|
||||
},
|
||||
"failureLedger": {
|
||||
"entryCount": len(ledger.get("entries", [])) if isinstance(ledger.get("entries"), list) else 0,
|
||||
"hydratedLogs": len(ledger.get("hydratedLogs", [])) if isinstance(ledger.get("hydratedLogs"), list) else 0,
|
||||
},
|
||||
"commandBudget": {"maxCommands": 48, "maxReadCommands": 16},
|
||||
"verificationPolicy": "incremental-context-only-until-final-audit",
|
||||
}
|
||||
|
||||
|
||||
def format_progress(progress: dict, fast_path: dict | None = None) -> list[str]:
|
||||
estimate = progress.get("estimatedRemainingHours")
|
||||
estimate_text = f"{estimate} h" if estimate is not None else "n/a"
|
||||
total_estimate = progress.get("estimatedTotalHours")
|
||||
total_text = f"{total_estimate} h" if total_estimate is not None else "n/a"
|
||||
plan = progress.get("taskPlan", {})
|
||||
fast_path = fast_path or {}
|
||||
batch = fast_path.get("batch", {})
|
||||
context_cache = fast_path.get("contextCache", {})
|
||||
ledger = fast_path.get("failureLedger", {})
|
||||
return [
|
||||
"",
|
||||
"Project migration progress",
|
||||
@@ -197,6 +228,9 @@ def format_progress(progress: dict) -> list[str]:
|
||||
f" stage medians: {progress.get('stageMedianDurationMs') or 'n/a'} ms (1=build, 2=typecheck, 3=web build, 4=Chromium)",
|
||||
f" estimate: remaining={estimate_text}, total={total_text}",
|
||||
f" estimate basis: {progress.get('estimateBasis', 'n/a')}",
|
||||
f" fast path: batch={batch.get('batchId') or 'none'} selected={batch.get('selectedRecordCount', 0)} completed={batch.get('completed', False)}",
|
||||
f" context cache: {context_cache.get('status', 'MISSING')} task={context_cache.get('task') or 'none'} inputs={context_cache.get('inputCount', 0)}; failure ledger entries={ledger.get('entryCount', 0)} hydratedLogs={ledger.get('hydratedLogs', 0)}",
|
||||
f" command budget: max={fast_path.get('commandBudget', {}).get('maxCommands', 48)}, read/exploration max={fast_path.get('commandBudget', {}).get('maxReadCommands', 16)}; verification={fast_path.get('verificationPolicy', 'n/a')}",
|
||||
]
|
||||
|
||||
|
||||
|
||||
67
my-tools/validate_incremental_context.py
Normal file
67
my-tools/validate_incremental_context.py
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate only the active task's context metadata and focused pointer.
|
||||
|
||||
The normal path compares stat metadata first. The task-context checker is
|
||||
invoked only when an input is missing, changed, or lacks the required hash.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from wbv2_runtime import ROOT, queue_pointer
|
||||
|
||||
|
||||
def stat_metadata(path: Path) -> tuple[int, str] | None:
|
||||
try:
|
||||
value = path.stat()
|
||||
except OSError:
|
||||
return None
|
||||
return value.st_size, str(value.st_mtime_ns)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--task", help="task id; defaults to the authoritative queue pointer")
|
||||
args = parser.parse_args()
|
||||
task = args.task or queue_pointer().get("task")
|
||||
if not task:
|
||||
parser.error("queue has no current task")
|
||||
context_path = ROOT / "tests/golden" / task / "task-context.json"
|
||||
try:
|
||||
context = json.loads(context_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
context = {}
|
||||
metadata = context.get("inputMetadata") if isinstance(context, dict) else None
|
||||
unchanged = isinstance(metadata, dict) and bool(metadata)
|
||||
if unchanged:
|
||||
for relative, expected in metadata.items():
|
||||
current = stat_metadata(ROOT / relative)
|
||||
if (
|
||||
not isinstance(expected, dict)
|
||||
or current is None
|
||||
or current[0] != expected.get("bytes")
|
||||
or current[1] != expected.get("mtime_ns")
|
||||
or not isinstance(expected.get("sha256"), str)
|
||||
):
|
||||
unchanged = False
|
||||
break
|
||||
if unchanged:
|
||||
print(f"incremental-context-ok task={task} context=REUSED metadata={len(metadata)}")
|
||||
return 0
|
||||
command = ["node", "tools/web/check-task-context.mjs", "--task", task, "--write"]
|
||||
result = subprocess.run(command, cwd=ROOT, text=True, capture_output=True, check=False)
|
||||
output = result.stdout or result.stderr
|
||||
if output:
|
||||
print(output, end="" if output.endswith("\n") else "\n")
|
||||
if result.returncode:
|
||||
return result.returncode
|
||||
print(f"incremental-context-ok task={task} context=REFRESHED")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -59,7 +59,7 @@
|
||||
},
|
||||
"moduleReceipt": {
|
||||
"path": "tests/golden/WBV2-P1161-001/module-receipt.json",
|
||||
"sha256": "48d1c06e1f03206fa68bec9db86ecd67fbfd858776b48a82b9505dc6ccd03459"
|
||||
"sha256": "6b1131b452c509f600536baa2e95f0d9ad30a1fb5951ae9e36b3f540c36326bf"
|
||||
},
|
||||
"status": {
|
||||
"path": "docs/status/WBV2-P1161-001.md",
|
||||
@@ -75,7 +75,7 @@
|
||||
},
|
||||
"taskContext": {
|
||||
"path": "tests/golden/WBV2-P1161-001/task-context.json",
|
||||
"sha256": "cb7a0b446bfc6c27a2040f42107162c99290927f3115eab029c2a456442b8ad3"
|
||||
"sha256": "f98e1ee00beed21da1ef51d60caee5629e52c11a4f2d9bda78bb7b0cf52d7d41"
|
||||
}
|
||||
},
|
||||
"assertions": {
|
||||
@@ -97,7 +97,33 @@
|
||||
"historicalInventoryTotal": 6900,
|
||||
"historicalEffectiveCompleted": 290,
|
||||
"historicalEffectivePending": 6609,
|
||||
"historicalReproduction": false
|
||||
"historicalReproduction": false,
|
||||
"batchId": "WBV2-P1161-001:FCURVE_MODIFIER_SUBTYPE_READER_BATCH:5eb990874b4e6015",
|
||||
"selectedRecordIds": [
|
||||
"rna-struct:FModifierEnvelope",
|
||||
"rna-struct:FModifierGenerator",
|
||||
"rna-struct:FModifierLimits",
|
||||
"rna-struct:FModifierNoise",
|
||||
"rna-struct:FModifierSmooth",
|
||||
"rna-struct:FModifierStepped"
|
||||
],
|
||||
"commandBudget": {
|
||||
"maxCommands": 48,
|
||||
"maxReadCommands": 16,
|
||||
"commandCount": 4,
|
||||
"readCommandCount": 0,
|
||||
"withinBudget": true
|
||||
}
|
||||
},
|
||||
"nextTask": null
|
||||
"nextTask": null,
|
||||
"batchId": "WBV2-P1161-001:FCURVE_MODIFIER_SUBTYPE_READER_BATCH:5eb990874b4e6015",
|
||||
"selectedRecordIds": [
|
||||
"rna-struct:FModifierEnvelope",
|
||||
"rna-struct:FModifierGenerator",
|
||||
"rna-struct:FModifierLimits",
|
||||
"rna-struct:FModifierNoise",
|
||||
"rna-struct:FModifierSmooth",
|
||||
"rna-struct:FModifierStepped"
|
||||
],
|
||||
"phase": "CONTINUE"
|
||||
}
|
||||
|
||||
@@ -123,5 +123,22 @@
|
||||
"unverifiedUnits": 3222
|
||||
}
|
||||
},
|
||||
"nextTask": null
|
||||
"nextTask": null,
|
||||
"phase": "CONTINUE",
|
||||
"batchId": "WBV2-P1161-001:FCURVE_MODIFIER_SUBTYPE_READER_BATCH:5eb990874b4e6015",
|
||||
"selectedRecordIds": [
|
||||
"rna-struct:FModifierEnvelope",
|
||||
"rna-struct:FModifierGenerator",
|
||||
"rna-struct:FModifierLimits",
|
||||
"rna-struct:FModifierNoise",
|
||||
"rna-struct:FModifierSmooth",
|
||||
"rna-struct:FModifierStepped"
|
||||
],
|
||||
"commandBudget": {
|
||||
"maxCommands": 48,
|
||||
"maxReadCommands": 16,
|
||||
"commandCount": 4,
|
||||
"readCommandCount": 0,
|
||||
"withinBudget": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,36 @@
|
||||
"node tools/web/check-task-context.mjs --task WBV2-P1161-001 --write"
|
||||
],
|
||||
"inputPaths": [],
|
||||
"inputMetadata": {
|
||||
"docs/EXECUTION_QUEUE.md": {
|
||||
"path": "docs/EXECUTION_QUEUE.md",
|
||||
"exists": true,
|
||||
"bytes": 4030,
|
||||
"mtime_ns": "1788433617614059639",
|
||||
"sha256": "80fb937bc99b92fa59013e4ef8777a84139509d06420ab8306142f1d4c1b8c96"
|
||||
},
|
||||
"docs/tasks/WBV2-P1161-001.md": {
|
||||
"path": "docs/tasks/WBV2-P1161-001.md",
|
||||
"exists": true,
|
||||
"bytes": 2201,
|
||||
"mtime_ns": "1788433606139169929",
|
||||
"sha256": "d2213ece2f0b5eeb99949ace5cd35ea2c18ba955b4b93d1a9bc5722f59d536fa"
|
||||
},
|
||||
"tests/golden/WBV2-P1161-000/manifest.json": {
|
||||
"path": "tests/golden/WBV2-P1161-000/manifest.json",
|
||||
"exists": true,
|
||||
"bytes": 1391,
|
||||
"mtime_ns": "1788433606139647344",
|
||||
"sha256": "cf1b5e152c183463773621096cf34fafd30c9a6f556b1daa7a7258c40d3e0392"
|
||||
},
|
||||
"docs/status/WBV2-P1161-000.md": {
|
||||
"path": "docs/status/WBV2-P1161-000.md",
|
||||
"exists": true,
|
||||
"bytes": 368,
|
||||
"mtime_ns": "1788433606139326609",
|
||||
"sha256": "85055f55c507388f8fba17b814bc7d23698bd6b901601699c663b63d867ed5f0"
|
||||
}
|
||||
},
|
||||
"inputSelection": {
|
||||
"schemaVersion": 1,
|
||||
"limits": {
|
||||
@@ -54,8 +84,8 @@
|
||||
},
|
||||
{
|
||||
"path": "tests/golden/WBV2-P1161-001/module-receipt.json",
|
||||
"bytes": null,
|
||||
"reason": "MISSING_PATH"
|
||||
"bytes": 5223,
|
||||
"reason": "CONTEXT_REMAINING_SPACE"
|
||||
},
|
||||
{
|
||||
"path": "tests/golden/WBV2-P1161-001/modifier-reader-batch-production.json",
|
||||
|
||||
@@ -16,19 +16,44 @@ const task = process.argv.includes("--task")
|
||||
if (!task) throw new Error("unable to resolve current WBV2 task");
|
||||
const parentManifest = queue.match(/\| parent manifest \| `([^`]+)` \|/u)?.[1];
|
||||
if (!parentManifest) throw new Error("queue parent manifest is missing");
|
||||
const inputPaths = [
|
||||
const baseInputPaths = [
|
||||
"docs/EXECUTION_QUEUE.md",
|
||||
`docs/tasks/${task}.md`,
|
||||
parentManifest,
|
||||
`tests/golden/${task}/task-context.json`,
|
||||
];
|
||||
const inputHashFor = () => digest(inputPaths.map((file) => `${file}\0${fs.existsSync(abs(file)) ? digest(read(file)) : "MISSING"}\n`).join(""));
|
||||
const inputHash = inputHashFor();
|
||||
const statInput = (file) => {
|
||||
try {
|
||||
const stat = fs.statSync(abs(file), { bigint: true });
|
||||
return { bytes: Number(stat.size), mtime_ns: stat.mtimeNs.toString(), exists: true };
|
||||
}
|
||||
catch (error) {
|
||||
return { bytes: null, mtime_ns: null, exists: false, error: error?.code ?? "STAT_FAILED" };
|
||||
}
|
||||
};
|
||||
const metadataComparable = (value) => value && value.exists === true
|
||||
&& Number.isSafeInteger(value.bytes) && typeof value.mtime_ns === "string";
|
||||
let cached = null;
|
||||
try { cached = JSON.parse(fs.readFileSync(cachePath, "utf8")); }
|
||||
catch { /* First use or an interrupted write. */ }
|
||||
if (!process.argv.includes("--force") && cached?.status === "PASS" && cached.task === task && cached.inputHash === inputHash) {
|
||||
process.stdout.write(`wbv2-context-cache-hit task=${task} inputHash=${inputHash}\n`);
|
||||
const inputPaths = [...new Set([
|
||||
...baseInputPaths,
|
||||
...Object.keys(cached?.inputs ?? {}),
|
||||
])];
|
||||
const inputMetadata = Object.fromEntries(inputPaths.map((file) => [file, statInput(file)]));
|
||||
const metadataUnchanged = (cachedInputs) => inputPaths.every((file) => {
|
||||
const current = inputMetadata[file];
|
||||
const cached = cachedInputs?.[file];
|
||||
return metadataComparable(current) && metadataComparable(cached)
|
||||
&& current.bytes === cached.bytes && current.mtime_ns === cached.mtime_ns;
|
||||
});
|
||||
const digestPath = (file) => {
|
||||
try { return digest(read(file)); }
|
||||
catch { return "MISSING"; }
|
||||
};
|
||||
const inputHashFor = (paths = inputPaths) => digest(paths.map((file) => `${file}\0${digestPath(file)}\n`).join(""));
|
||||
if (!process.argv.includes("--force") && cached?.status === "PASS" && cached.task === task && metadataUnchanged(cached.inputs)) {
|
||||
process.stdout.write(`CONTEXT_PACKET_REUSED task=${task} inputHash=${cached.inputHash ?? "UNKNOWN"}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
const result = spawnSync(process.execPath, [abs("tools/web/print-task-context.mjs"), "--task", task], { cwd: root, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 });
|
||||
@@ -38,8 +63,21 @@ if ((result.status ?? 1) !== 0 || (check.status ?? 1) !== 0) {
|
||||
process.exit(Math.max(result.status ?? 1, check.status ?? 1));
|
||||
}
|
||||
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
||||
const refreshedInputHash = inputHashFor();
|
||||
const payload = { schemaVersion: 1, status: "PASS", task, inputHash: refreshedInputHash, refreshedAt: new Date().toISOString() };
|
||||
let contextMetadata = {};
|
||||
try {
|
||||
const context = JSON.parse(read(`tests/golden/${task}/task-context.json`));
|
||||
contextMetadata = context.inputMetadata && typeof context.inputMetadata === "object" ? context.inputMetadata : {};
|
||||
} catch { /* The task-context checker already reported the precise failure. */ }
|
||||
const refreshedPaths = [...new Set([...inputPaths, ...Object.keys(contextMetadata)])];
|
||||
const refreshedInputHash = inputHashFor(refreshedPaths);
|
||||
const payload = {
|
||||
schemaVersion: 2,
|
||||
status: "PASS",
|
||||
task,
|
||||
inputHash: refreshedInputHash,
|
||||
inputs: Object.fromEntries(refreshedPaths.map((file) => [file, { ...statInput(file), sha256: contextMetadata[file]?.sha256 ?? digestPath(file) }])),
|
||||
refreshedAt: new Date().toISOString(),
|
||||
};
|
||||
const temporary = `${cachePath}.tmp-${process.pid}`;
|
||||
fs.writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`);
|
||||
fs.renameSync(temporary, cachePath);
|
||||
|
||||
@@ -64,6 +64,25 @@ export function validateContextBundle(bundle, { current = true } = {}) {
|
||||
if (context.commands.length > GOVERNANCE_LIMITS.maxTaskCommands) {
|
||||
add(violations, "TASK_COMMANDS_TOO_WIDE", `count=${context.commands.length}`);
|
||||
}
|
||||
if (!context.inputMetadata || typeof context.inputMetadata !== "object") {
|
||||
add(violations, "INPUT_METADATA_MISSING", context.task);
|
||||
} else {
|
||||
const requiredMetadata = [
|
||||
relative(files.queuePath),
|
||||
relative(files.taskPath),
|
||||
relative(files.parentManifestPath),
|
||||
...(fs.existsSync(files.parentStatusPath) ? [relative(files.parentStatusPath)] : []),
|
||||
...context.inputPaths,
|
||||
];
|
||||
for (const file of [...new Set(requiredMetadata)]) {
|
||||
const metadata = context.inputMetadata[file];
|
||||
if (!metadata || typeof metadata !== "object" || metadata.exists !== true
|
||||
|| !Number.isSafeInteger(metadata.bytes) || typeof metadata.mtime_ns !== "string"
|
||||
|| !/^[a-f0-9]{64}$/u.test(metadata.sha256 ?? "")) {
|
||||
add(violations, "INPUT_METADATA_INVALID", `${context.task}:${file}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selection = context.inputSelection;
|
||||
if (!selection || selection.schemaVersion !== 1 || !Array.isArray(selection.selected) || !Array.isArray(selection.excluded)) {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { fileURLToPath } from "node:url";
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const abs = (file) => path.join(root, file);
|
||||
const controlRoot = "tests/golden/WBV2";
|
||||
const schedulerLockPath = `${controlRoot}/continuous-scheduler.lock`;
|
||||
const schedulerCheckpointPath = `${controlRoot}/continuous-scheduler.json`;
|
||||
const guardianLockPath = `${controlRoot}/continuous-guardian.lock`;
|
||||
const supervisorLockPath = `${controlRoot}/continuous-supervisor.lock`;
|
||||
const guardianCheckpointPath = `${controlRoot}/continuous-guardian.json`;
|
||||
@@ -80,15 +82,17 @@ const pushVerification = (task) => {
|
||||
};
|
||||
const collect = () => {
|
||||
const queue = currentPointer();
|
||||
const scheduler = readJson(schedulerCheckpointPath);
|
||||
const guardian = readJson(guardianCheckpointPath);
|
||||
const supervisor = readJson(supervisorCheckpointPath);
|
||||
const schedulerLock = lockInfo(schedulerLockPath);
|
||||
const guardianLock = lockInfo(guardianLockPath);
|
||||
const supervisorLock = lockInfo(supervisorLockPath);
|
||||
const handoff = latestHandoff();
|
||||
const active = guardianLock.alive && supervisorLock.alive;
|
||||
const blocked = guardian?.state === "blocked" || supervisor?.state === "blocked";
|
||||
const active = schedulerLock.alive || (guardianLock.alive && supervisorLock.alive);
|
||||
const blocked = scheduler?.state === "blocked" || guardian?.state === "blocked" || supervisor?.state === "blocked";
|
||||
const state = blocked ? "BLOCKED" : active ? "RUNNING" : "IDLE";
|
||||
const phase = blocked ? "blocked" : supervisor?.state === "running" ? "task-or-gate" : supervisor?.state ?? "unknown";
|
||||
const phase = blocked ? "blocked" : scheduler?.state === "running" ? "scheduler-task" : supervisor?.state === "running" ? "task-or-gate" : scheduler?.state ?? supervisor?.state ?? "unknown";
|
||||
const observed = new Date().toISOString();
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
@@ -97,14 +101,15 @@ const collect = () => {
|
||||
phase,
|
||||
message: state === "RUNNING"
|
||||
? `RUNNING: ${queue.task ?? "unknown task"} is owned by the guardian/supervisor process; wait for its handoff.`
|
||||
: state === "BLOCKED" ? "BLOCKED: inspect the checkpoint reason before any write or retry." : "IDLE: no live guardian/supervisor lock was found.",
|
||||
: state === "BLOCKED" ? "BLOCKED: inspect the scheduler/guardian/supervisor checkpoint reason before any write or retry." : "IDLE: no live scheduler, guardian or supervisor lock was found.",
|
||||
queue,
|
||||
scheduler: { pid: scheduler?.pid ?? schedulerLock.pid, alive: schedulerLock.alive, state: scheduler?.state ?? null, updatedAt: scheduler?.updatedAt ?? null, iteration: scheduler?.iteration ?? null, before: scheduler?.before ?? null, after: scheduler?.after ?? null, latestBatch: scheduler?.latestBatch ?? null },
|
||||
guardian: { pid: guardian?.pid ?? guardianLock.pid, alive: guardianLock.alive, state: guardian?.state ?? null, updatedAt: guardian?.updatedAt ?? null, heartbeatAt: guardian?.heartbeatAt ?? null, restartCount: guardian?.restartCount ?? null },
|
||||
supervisor: { pid: supervisor?.pid ?? supervisorLock.pid, alive: supervisorLock.alive, state: supervisor?.state ?? null, updatedAt: supervisor?.updatedAt ?? null, iteration: supervisor?.iteration ?? null, before: supervisor?.before ?? null, after: supervisor?.after ?? null },
|
||||
latestHandoff: handoff,
|
||||
latestPushVerification: pushVerification(handoff?.before ?? supervisor?.before?.task),
|
||||
localHead: gitHead(),
|
||||
checkpoints: { guardian: guardianCheckpointPath, supervisor: supervisorCheckpointPath },
|
||||
checkpoints: { scheduler: schedulerCheckpointPath, guardian: guardianCheckpointPath, supervisor: supervisorCheckpointPath },
|
||||
};
|
||||
};
|
||||
const format = (status) => [
|
||||
@@ -112,6 +117,7 @@ const format = (status) => [
|
||||
`阶段: ${status.phase}`,
|
||||
`当前任务: ${status.queue.task ?? "unknown"}`,
|
||||
`parent manifest: ${status.queue.parentManifest ?? "unknown"}`,
|
||||
`scheduler: pid=${status.scheduler.pid ?? "none"} alive=${status.scheduler.alive} state=${status.scheduler.state ?? "unknown"} iteration=${status.scheduler.iteration ?? "none"} batch=${status.scheduler.latestBatch?.batchId ?? "none"} completed=${status.scheduler.latestBatch?.completed ?? false}`,
|
||||
`guardian: pid=${status.guardian.pid ?? "none"} alive=${status.guardian.alive} state=${status.guardian.state ?? "unknown"} heartbeat=${status.guardian.heartbeatAt ?? "none"}`,
|
||||
`supervisor: pid=${status.supervisor.pid ?? "none"} alive=${status.supervisor.alive} state=${status.supervisor.state ?? "unknown"} iteration=${status.supervisor.iteration ?? "none"}`,
|
||||
`最近交接: ${status.latestHandoff ? `${status.latestHandoff.before} -> ${status.latestHandoff.after} (#${status.latestHandoff.iteration})` : "none"}`,
|
||||
|
||||
@@ -58,20 +58,6 @@ function taskCommand(task) {
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function typecheckProven(task) {
|
||||
try {
|
||||
const manifest = JSON.parse(read(`tests/golden/${task}/manifest.json`));
|
||||
const receiptPath = manifest.artifacts?.moduleReceipt?.path;
|
||||
if (!receiptPath) return false;
|
||||
const receipt = JSON.parse(read(receiptPath));
|
||||
return receipt.status === "PASS" && receipt.source?.unchanged === true
|
||||
&& receipt.commands?.some((entry) => entry.exitCode === 0 && entry.command.trim() === "npm --prefix web run typecheck");
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let iterations = 0;
|
||||
const summary = [];
|
||||
while (maxIterations === 0 || iterations < maxIterations) {
|
||||
@@ -91,10 +77,10 @@ while (maxIterations === 0 || iterations < maxIterations) {
|
||||
const after = currentTask();
|
||||
const terminal = after.task === before.task && terminalTask(before.task);
|
||||
if (after.task === before.task && !terminal) throw new Error(`${before.task} focused command exited 0 without advancing queue; log=${logPath}`);
|
||||
const typecheckCommand = typecheckProven(before.task)
|
||||
? "printf 'typecheck reused from hash-bound module receipt\\n'"
|
||||
: "npm --prefix web run typecheck";
|
||||
const gates = command(`node tools/web/check-context-governance.mjs && node tools/web/check-execution-control-plane.mjs --allow-unrunnable && node tools/web/check-v2-task-index.mjs && node tools/web/check-v2-handoff.mjs && ${typecheckCommand} && npm --prefix web run lint && git diff --check`, `tests/golden/${before.task}/continuous-gates.log`);
|
||||
// Implementation cycles keep the post-handoff gate incremental. Full API,
|
||||
// format, Chromium, performance and release gates belong to the explicit
|
||||
// final audit and must not be repeated for every batch.
|
||||
const gates = command(`python3 my-tools/validate_incremental_context.py --task ${before.task}`, `tests/golden/${before.task}/continuous-gates.log`);
|
||||
if (gates.code !== 0) throw new Error(`${before.task} post-handoff gate failed (exit ${gates.code}); log=tests/golden/${before.task}/continuous-gates.log`);
|
||||
iterations += 1;
|
||||
summary.push({ task: before.task, command: focused, nextTask: terminal ? null : after.task, terminal, log: logPath });
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { recordFailure } from "./wbv2-failure-ledger.mjs";
|
||||
import { createFailureLedgerSession } from "./wbv2-failure-ledger.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const abs = (file) => path.join(root, file);
|
||||
@@ -11,6 +11,7 @@ const logPath = `${controlRoot}/continuous-guardian.log`;
|
||||
const checkpointPath = `${controlRoot}/continuous-guardian.json`;
|
||||
const supervisorCheckpointPath = `${controlRoot}/continuous-supervisor.json`;
|
||||
const lockPath = `${controlRoot}/continuous-guardian.lock`;
|
||||
const failureLedger = createFailureLedgerSession(root, { logPaths: [`${controlRoot}/continuous-guardian.log`] });
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const numericArg = (name, fallback) => {
|
||||
@@ -173,7 +174,7 @@ try {
|
||||
}
|
||||
const decision = safeRestart(checkpoint, after);
|
||||
if (!decision.ok) {
|
||||
recordFailure(root, { task: before.task, phase: "guardian", reason: decision.reason, childExit: result.code });
|
||||
failureLedger.record({ task: before.task, phase: "guardian", reason: decision.reason, childExit: result.code });
|
||||
writeCheckpoint({ state: "blocked", restartCount, before, after, reason: decision.reason, supervisorExit: result.code });
|
||||
appendLog(`blocked reason=${decision.reason}; no automatic replay`);
|
||||
guardianExit = 2;
|
||||
@@ -181,7 +182,7 @@ try {
|
||||
}
|
||||
restartCount += 1;
|
||||
if (maxRestarts > 0 && restartCount > maxRestarts) {
|
||||
recordFailure(root, { task: before.task, phase: "guardian", reason: "max_safe_restarts_exceeded", childExit: result.code });
|
||||
failureLedger.record({ task: before.task, phase: "guardian", reason: "max_safe_restarts_exceeded", childExit: result.code });
|
||||
writeCheckpoint({ state: "blocked", restartCount, before, after, reason: "max_safe_restarts_exceeded", supervisorExit: result.code });
|
||||
appendLog("blocked reason=max_safe_restarts_exceeded");
|
||||
guardianExit = 3;
|
||||
@@ -194,12 +195,13 @@ try {
|
||||
}
|
||||
catch (error) {
|
||||
guardianExit = 4;
|
||||
try { recordFailure(root, { task: currentTask()?.task ?? "UNKNOWN", phase: "guardian", reason: "guardian_error", detail: error?.message ?? String(error) }); }
|
||||
try { failureLedger.record({ task: currentTask()?.task ?? "UNKNOWN", phase: "guardian", reason: "guardian_error", detail: error?.message ?? String(error) }); }
|
||||
catch { /* Preserve the original guardian failure. */ }
|
||||
writeCheckpoint({ state: "blocked", restartCount, reason: "guardian_error", error: error?.message ?? String(error) });
|
||||
appendLog(`blocked reason=guardian_error error=${error?.message ?? String(error)}`);
|
||||
}
|
||||
finally {
|
||||
failureLedger.flush();
|
||||
signalChild("SIGTERM");
|
||||
releaseLock();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { batchContract, commandBudget, completedBatch } from "./wbv2-batch-contract.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const abs = (file) => path.join(root, file);
|
||||
@@ -11,6 +12,7 @@ const read = (file) => fs.readFileSync(abs(file), "utf8");
|
||||
const json = (file) => JSON.parse(read(file));
|
||||
const sha = (file) => crypto.createHash("sha256").update(fs.readFileSync(abs(file))).digest("hex");
|
||||
const dynamicInventoryPath = "tests/golden/WBV2/dynamic-inventory.json";
|
||||
const out = `tests/golden/${process.argv[process.argv.indexOf("--task") + 1]}`;
|
||||
const writeJson = (file, value) => {
|
||||
fs.mkdirSync(path.dirname(abs(file)), { recursive: true });
|
||||
fs.writeFileSync(abs(file), `${JSON.stringify(value, null, 2)}\n`);
|
||||
@@ -35,8 +37,28 @@ const parentId = record.dependsOn?.at(-1);
|
||||
const parentPath = `tests/golden/${parentId}/manifest.json`;
|
||||
const parent = json(parentPath);
|
||||
if (parent.status !== "done" || parent.nextTask !== task) throw new Error(`module parent is not a completed handoff: ${parentId}`);
|
||||
if (record.status !== "in_progress" || record.ready !== true) throw new Error(`module task ${task} is not active`);
|
||||
if (!record.module.sourceBacked) throw new Error(`module ${record.module.moduleId} has no source-backed implementation`);
|
||||
const contract = batchContract(task, record.module);
|
||||
if (!record.batchId) record.batchId = contract.batchId;
|
||||
if (!record.selectedRecordIds) record.selectedRecordIds = contract.selectedRecordIds;
|
||||
if (!record.module.batchId) record.module.batchId = contract.batchId;
|
||||
if (!record.module.selectedRecordIds) record.module.selectedRecordIds = contract.selectedRecordIds;
|
||||
const commandContract = commandBudget(record.module.commands ?? []);
|
||||
if (!commandContract.withinBudget) throw new Error(`task ${task} exceeds command budget: ${JSON.stringify(commandContract)}`);
|
||||
const existingManifestPath = `${out}/manifest.json`;
|
||||
if (fs.existsSync(abs(existingManifestPath))) {
|
||||
try {
|
||||
const existingManifest = json(existingManifestPath);
|
||||
const legacyCompleted = existingManifest.status === "done"
|
||||
&& existingManifest.nextTask === null
|
||||
&& JSON.stringify(existingManifest.selectedRecordIds ?? record.module.selectedRecordIds ?? []) === JSON.stringify(contract.selectedRecordIds);
|
||||
if (completedBatch(existingManifest, task, record.module) || legacyCompleted) {
|
||||
process.stdout.write(`module-batch-idempotent task=${task} batchId=${contract.batchId} plannerRequired=true\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
} catch { /* A partial manifest is not evidence and follows the normal path. */ }
|
||||
}
|
||||
if (record.status !== "in_progress" || record.ready !== true) throw new Error(`module task ${task} is not active`);
|
||||
for (const source of record.module.sourcePaths ?? []) if (!fs.existsSync(abs(source))) throw new Error(`module source is missing: ${source}`);
|
||||
const sourceAuditPaths = (record.module.sourcePaths ?? []).filter((source) =>
|
||||
/^(blender-5\.2\.0\/source\/blender\/|web\/protocol\/|web\/app\/src\/(?!vendor\/))/u.test(source)
|
||||
@@ -48,7 +70,6 @@ const reusedReceipts = (record.module.reuseReceipts ?? []).map((receipt) => ({
|
||||
reportSha256: sha(receipt.report),
|
||||
}));
|
||||
|
||||
const out = `tests/golden/${task}`;
|
||||
const reportPath = `${out}/module-receipt.json`;
|
||||
const statusPath = `docs/status/${task}.md`;
|
||||
const logPath = `${out}/module-run.log`;
|
||||
@@ -130,7 +151,11 @@ const report = {
|
||||
schemaVersion: 1,
|
||||
task,
|
||||
operation: "MODULE_FUNCTION_EXECUTION",
|
||||
phase: failed || !sourceUnchanged ? "BLOCKED" : "CONTINUE",
|
||||
moduleId: record.module.moduleId,
|
||||
batchId: contract.batchId,
|
||||
selectedRecordIds: contract.selectedRecordIds,
|
||||
commandBudget: commandContract,
|
||||
status: failed || !sourceUnchanged ? "BLOCKED" : "PASS",
|
||||
source: { paths: record.module.sourcePaths, auditedPaths: sourceAuditPaths, before: sourceBefore, after: sourceAfter, unchanged: sourceUnchanged },
|
||||
reusedReceipts,
|
||||
@@ -159,7 +184,7 @@ if (failed || !sourceUnchanged) {
|
||||
const manifestPath = `${out}/manifest.json`;
|
||||
const evidenceArtifacts = Object.fromEntries((record.module.evidencePaths ?? []).filter((file) => fs.existsSync(abs(file))).map((file, item) => [`evidence${item + 1}`, { path: file, sha256: sha(file) }]));
|
||||
const artifacts = { parentManifest: { path: parentPath, sha256: sha(parentPath) }, ...Object.fromEntries(record.module.sourcePaths.map((source, item) => [`source${item + 1}`, { path: source, sha256: sha(source) }])), ...evidenceArtifacts, moduleReceipt: { path: reportPath, sha256: sha(reportPath) }, status: { path: statusPath, sha256: sha(statusPath) }, log: { path: logPath, sha256: sha(logPath) }, plan: { path: index.source.path, sha256: sha(index.source.path) }, taskContext: { path: `${out}/task-context.json`, sha256: sha(`${out}/task-context.json`) } };
|
||||
writeJson(manifestPath, { schemaVersion: 1, task, parentTask: parentId, operation: "MODULE_FUNCTION_EXECUTION", taskClass: "MODULE", status: "done", enablingTask: true, parityStateChange: false, runtime: "BLENDER_WEB_FUNCTION_MODULE", artifacts, assertions: { moduleId: record.module.moduleId, sourceBacked: true, positiveCases: record.module.positive.length, negativeCases: record.module.negative.length, recoveryCases: record.module.recovery.length, commandCount: results.length, reusedReceiptCount: reusedReceipts.length, sourceUnchanged, fullFamilyParity: false, dynamicInventoryPath, dynamicSourceTreeSha256: dynamicInventory.authority.sourceTreeSha256, dynamicUnitCount: dynamicInventory.summary.unitCount, dynamicVerifiedUnits: dynamicInventory.coverage.verifiedUnits, dynamicUnverifiedUnits: dynamicInventory.coverage.unverifiedUnits, planningEstimate: dynamicInventory.policy.planningEstimate, historicalInventoryTotal: 6900, historicalEffectiveCompleted: 290, historicalEffectivePending: 6609, historicalReproduction: false }, nextTask });
|
||||
writeJson(manifestPath, { schemaVersion: 1, task, parentTask: parentId, operation: "MODULE_FUNCTION_EXECUTION", phase: "CONTINUE", taskClass: "MODULE", status: "done", enablingTask: true, parityStateChange: false, runtime: "BLENDER_WEB_FUNCTION_MODULE", batchId: contract.batchId, selectedRecordIds: contract.selectedRecordIds, artifacts, assertions: { moduleId: record.module.moduleId, batchId: contract.batchId, selectedRecordIds: contract.selectedRecordIds, sourceBacked: true, positiveCases: record.module.positive.length, negativeCases: record.module.negative.length, recoveryCases: record.module.recovery.length, commandCount: results.length, commandBudget, reusedReceiptCount: reusedReceipts.length, sourceUnchanged, fullFamilyParity: false, dynamicInventoryPath, dynamicSourceTreeSha256: dynamicInventory.authority.sourceTreeSha256, dynamicUnitCount: dynamicInventory.summary.unitCount, dynamicVerifiedUnits: dynamicInventory.coverage.verifiedUnits, dynamicUnverifiedUnits: dynamicInventory.coverage.unverifiedUnits, planningEstimate: dynamicInventory.policy.planningEstimate, historicalInventoryTotal: 6900, historicalEffectiveCompleted: 290, historicalEffectivePending: 6609, historicalReproduction: false }, nextTask });
|
||||
if (record.module.coverageBindings?.length) {
|
||||
execFileSync(process.execPath, [abs("tools/web/bind-wbv2-module-coverage.mjs"), "--task", task], { cwd: root, stdio: "inherit" });
|
||||
execFileSync(process.execPath, [abs("tools/web/finalize-wbv2-module-batch.mjs"), "--task", task], { cwd: root, stdio: "inherit" });
|
||||
|
||||
@@ -3,7 +3,7 @@ import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { recordFailure } from "./wbv2-failure-ledger.mjs";
|
||||
import { createFailureLedgerSession } from "./wbv2-failure-ledger.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const controlRoot = "tests/golden/WBV2";
|
||||
@@ -11,6 +11,10 @@ const checkpointPath = `${controlRoot}/continuous-scheduler.json`;
|
||||
const lockPath = `${controlRoot}/continuous-scheduler.lock`;
|
||||
const logPath = `${controlRoot}/continuous-scheduler.log`;
|
||||
const abs = (file) => path.join(root, file);
|
||||
const failureLedger = createFailureLedgerSession(root, {
|
||||
logPaths: [`${controlRoot}/continuous-scheduler.log`, `${controlRoot}/continuous-supervisor.log`, `${controlRoot}/continuous-guardian.log`],
|
||||
});
|
||||
let latestBatch = null;
|
||||
|
||||
const argValue = (name, fallback) => {
|
||||
const index = process.argv.indexOf(name);
|
||||
@@ -60,6 +64,7 @@ function writeCheckpoint(payload) {
|
||||
pid: process.pid,
|
||||
updatedAt: new Date().toISOString(),
|
||||
...payload,
|
||||
latestBatch: payload.latestBatch ?? latestBatch,
|
||||
};
|
||||
const stable = (value) => { const { pid, updatedAt, ...rest } = value ?? {}; return rest; };
|
||||
try {
|
||||
@@ -72,6 +77,43 @@ function writeCheckpoint(payload) {
|
||||
fs.renameSync(abs(temporaryPath), abs(checkpointPath));
|
||||
}
|
||||
|
||||
function batchInfo(pointer) {
|
||||
if (!pointer?.task) return null;
|
||||
let manifest = {};
|
||||
try { manifest = JSON.parse(read(`tests/golden/${pointer.task}/manifest.json`)); }
|
||||
catch { /* The active batch may not have produced a manifest yet. */ }
|
||||
let card = "";
|
||||
try { card = read(`docs/tasks/${pointer.task}.md`); }
|
||||
catch { /* Readiness reports the missing card separately. */ }
|
||||
const batchId = manifest.batchId ?? card.match(/^- batchId: `?([^`\n]+)`?/mu)?.[1] ?? null;
|
||||
let selectedRecordIds = Array.isArray(manifest.selectedRecordIds) ? manifest.selectedRecordIds : [];
|
||||
if (selectedRecordIds.length === 0) {
|
||||
try {
|
||||
const selected = card.match(/^- selectedRecordIds:\s*(\[[^\n]+\])/mu)?.[1];
|
||||
selectedRecordIds = selected ? JSON.parse(selected) : [];
|
||||
} catch { selectedRecordIds = []; }
|
||||
}
|
||||
return {
|
||||
task: pointer.task,
|
||||
batchId,
|
||||
selectedRecordIds,
|
||||
completed: manifest.status === "done" && manifest.nextTask === null,
|
||||
};
|
||||
}
|
||||
|
||||
function planNextBatch(pointer) {
|
||||
const result = spawnSync("python3", [abs("my-tools/authorize_next_wbv2_task.py"), "--write"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
});
|
||||
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
||||
if (output) appendLog(`planner task=${pointer?.task ?? "NONE"} exit=${result.status ?? 1}\n${output}`);
|
||||
if ((result.status ?? 1) !== 0 && /no configured unresolved Blender source batch remains/u.test(output)) return { terminal: true };
|
||||
if ((result.status ?? 1) !== 0) throw new Error(`next batch planner failed: ${output}`);
|
||||
return { terminal: false };
|
||||
}
|
||||
|
||||
function appendLog(message) {
|
||||
fs.appendFileSync(abs(logPath), `[${new Date().toISOString()}] ${message}\n`);
|
||||
}
|
||||
@@ -91,10 +133,10 @@ function refreshContinuation(task) {
|
||||
if (!fs.existsSync(continuationPath)) return false;
|
||||
const pointer = currentTask();
|
||||
const index = JSON.parse(read("tests/golden/WBV2/task-index.json"));
|
||||
const plan = JSON.parse(read(index.source.path));
|
||||
const taskIndex = plan.tasks.findIndex((candidate) => candidate.id === task);
|
||||
const terminal = terminalTask(task);
|
||||
const nextTask = terminal ? null : plan.tasks[taskIndex + 1]?.id ?? null;
|
||||
// The compact task index already contains the direct successor. Reading the
|
||||
// full plan here adds no evidence and used to dominate short handoff cycles.
|
||||
const nextTask = terminal ? null : index.entries?.[task]?.next ?? null;
|
||||
const inputFiles = ["docs/EXECUTION_QUEUE.md", `docs/tasks/${task}.md`, pointer.parentManifest, `tests/golden/${task}/task-context.json`];
|
||||
const fingerprint = crypto.createHash("sha256").update(inputFiles.map((file) => `${file}\0${crypto.createHash("sha256").update(fs.readFileSync(abs(file))).digest("hex")}\n`).join("")).digest("hex");
|
||||
try {
|
||||
@@ -246,7 +288,9 @@ let iteration = 0;
|
||||
let completed = false;
|
||||
try {
|
||||
appendLog(`started pid=${process.pid} maxIterations=${maxIterations} commit=${commitEnabled} push=${pushEnabled}`);
|
||||
writeCheckpoint({ state: "started", iteration, current: currentTask(), maxIterations, commit: commitEnabled, push: pushEnabled });
|
||||
const initial = currentTask();
|
||||
latestBatch = batchInfo(initial);
|
||||
writeCheckpoint({ state: "started", iteration, current: initial, maxIterations, commit: commitEnabled, push: pushEnabled });
|
||||
while (maxIterations === 0 || iteration < maxIterations) {
|
||||
if (stopRequested) {
|
||||
writeCheckpoint({ state: "stopped", iteration, current: currentTask(), reason: stopSignal });
|
||||
@@ -254,6 +298,7 @@ try {
|
||||
break;
|
||||
}
|
||||
const before = currentTask();
|
||||
latestBatch = batchInfo(before);
|
||||
if (!before) {
|
||||
writeCheckpoint({ state: "complete", iteration, current: null, reason: "queue_closed" });
|
||||
appendLog(`complete queue_closed iteration=${iteration}`);
|
||||
@@ -266,14 +311,24 @@ try {
|
||||
try {
|
||||
const index = JSON.parse(read("tests/golden/WBV2/task-index.json"));
|
||||
if (index.activeTask === null && index.entries?.[before.task]) {
|
||||
writeCheckpoint({ state: "complete", iteration, current: before, reason: "v2_plan_terminal" });
|
||||
const completedBatchInfo = { ...batchInfo(before), completed: true };
|
||||
latestBatch = completedBatchInfo;
|
||||
const planned = preflightOnly ? { terminal: true } : planNextBatch(before);
|
||||
if (!planned.terminal) {
|
||||
const afterPlan = currentTask();
|
||||
latestBatch = batchInfo(afterPlan);
|
||||
writeCheckpoint({ state: "planned_next_batch", iteration, current: afterPlan, before, reason: "completed_batch_planned" });
|
||||
appendLog(`planned next batch before=${before.task} after=${afterPlan.task}`);
|
||||
continue;
|
||||
}
|
||||
writeCheckpoint({ state: "complete", iteration, current: before, reason: "v2_plan_terminal", latestBatch: completedBatchInfo });
|
||||
appendLog(`complete v2_plan_terminal task=${before.task} iteration=${iteration}`);
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
recordFailure(root, { task: before.task, phase: "scheduler", reason: "terminal_plan_check_failed", detail: error?.message ?? String(error) });
|
||||
failureLedger.record({ task: before.task, phase: "scheduler", reason: "terminal_plan_check_failed", detail: error?.message ?? String(error) });
|
||||
writeCheckpoint({ state: "blocked", iteration, before, reason: "terminal_plan_check_failed", error: error?.message ?? String(error) });
|
||||
appendLog(`blocked task=${before.task} reason=terminal_plan_check_failed error=${error?.message ?? String(error)}`);
|
||||
process.stderr.write(`scheduler-blocked task=${before.task} reason=terminal_plan_check_failed\n`);
|
||||
@@ -290,7 +345,7 @@ try {
|
||||
writeCheckpoint({ state: "preflight", iteration, before, readiness });
|
||||
appendLog(`preflight task=${before.task} class=${readiness.taskClass ?? "UNKNOWN"} ok=${readiness.ok} reason=${readiness.reason ?? "ready"}`);
|
||||
if (!readiness.ok) {
|
||||
recordFailure(root, { task: before.task, phase: "scheduler", reason: readiness.reason });
|
||||
failureLedger.record({ task: before.task, phase: "scheduler", reason: readiness.reason });
|
||||
writeCheckpoint({ state: "blocked", iteration, before, readiness });
|
||||
appendLog(`blocked task=${before.task} reason=${readiness.reason}`);
|
||||
process.stderr.write(`scheduler-blocked task=${before.task} reason=${readiness.reason}\n`);
|
||||
@@ -306,7 +361,7 @@ try {
|
||||
refreshTaskContext(before.task);
|
||||
}
|
||||
catch (error) {
|
||||
recordFailure(root, { task: before.task, phase: "scheduler", reason: "context_rotation_failed", detail: error?.message ?? String(error) });
|
||||
failureLedger.record({ task: before.task, phase: "scheduler", reason: "context_rotation_failed", detail: error?.message ?? String(error) });
|
||||
writeCheckpoint({ state: "blocked", iteration, before, reason: "context_rotation_failed", error: error?.message ?? String(error) });
|
||||
process.stderr.write(`scheduler-blocked task=${before.task} reason=context_rotation_failed\n`);
|
||||
exitCode = 2;
|
||||
@@ -315,6 +370,7 @@ try {
|
||||
writeCheckpoint({ state: "running", iteration: iteration + 1, before, readiness });
|
||||
const child = runOne(before);
|
||||
const after = currentTask();
|
||||
latestBatch = batchInfo(after);
|
||||
appendLog(`child task=${before.task} exit=${child.code} signal=${child.signal ?? "none"} after=${after?.task ?? "NONE"} log=${child.outputPath}`);
|
||||
if (stopRequested) {
|
||||
writeCheckpoint({ state: "stopped", iteration, before, after, reason: stopSignal, childExit: child.code });
|
||||
@@ -323,7 +379,7 @@ try {
|
||||
}
|
||||
if (child.code !== 0) {
|
||||
const reason = after?.task !== before.task ? "child_failed_after_queue_advance" : "child_failed_same_task";
|
||||
recordFailure(root, { task: before.task, phase: "scheduler", reason, childExit: child.code });
|
||||
failureLedger.record({ task: before.task, phase: "scheduler", reason, childExit: child.code });
|
||||
writeCheckpoint({ state: "blocked", iteration, before, after, reason, childExit: child.code, childLog: child.outputPath });
|
||||
appendLog(`blocked task=${before.task} reason=${reason}`);
|
||||
process.stderr.write(`scheduler-blocked task=${before.task} reason=${reason} childExit=${child.code}\n`);
|
||||
@@ -335,7 +391,7 @@ try {
|
||||
if (refreshContinuation(after?.task ?? before.task) && commitEnabled) commitContinuation();
|
||||
}
|
||||
catch (error) {
|
||||
recordFailure(root, { task: before.task, phase: "scheduler", reason: "continuation_rotation_failed", detail: error?.message ?? String(error) });
|
||||
failureLedger.record({ task: before.task, phase: "scheduler", reason: "continuation_rotation_failed", detail: error?.message ?? String(error) });
|
||||
writeCheckpoint({ state: "blocked", iteration, before, after, reason: "continuation_rotation_failed", error: error?.message ?? String(error) });
|
||||
process.stderr.write(`scheduler-blocked task=${before.task} reason=continuation_rotation_failed\n`);
|
||||
exitCode = 5;
|
||||
@@ -348,7 +404,7 @@ try {
|
||||
break;
|
||||
}
|
||||
if (!after || after.task === before.task) {
|
||||
recordFailure(root, { task: before.task, phase: "scheduler", reason: "child_exited_without_queue_advance", childExit: child.code });
|
||||
failureLedger.record({ task: before.task, phase: "scheduler", reason: "child_exited_without_queue_advance", childExit: child.code });
|
||||
writeCheckpoint({ state: "blocked", iteration, before, after, reason: "child_exited_without_queue_advance", childExit: child.code, childLog: child.outputPath });
|
||||
appendLog(`blocked task=${before.task} reason=child_exited_without_queue_advance`);
|
||||
process.stderr.write(`scheduler-blocked task=${before.task} reason=child_exited_without_queue_advance\n`);
|
||||
@@ -372,12 +428,13 @@ try {
|
||||
}
|
||||
catch (error) {
|
||||
exitCode = 5;
|
||||
try { recordFailure(root, { task: currentTask()?.task ?? "UNKNOWN", phase: "scheduler", reason: "scheduler_error", detail: error?.message ?? String(error) }); }
|
||||
try { failureLedger.record({ task: currentTask()?.task ?? "UNKNOWN", phase: "scheduler", reason: "scheduler_error", detail: error?.message ?? String(error) }); }
|
||||
catch { /* Preserve the original scheduler failure. */ }
|
||||
writeCheckpoint({ state: "blocked", iteration, reason: "scheduler_error", error: error?.message ?? String(error) });
|
||||
appendLog(`blocked reason=scheduler_error error=${error?.message ?? String(error)}`);
|
||||
}
|
||||
finally {
|
||||
failureLedger.flush();
|
||||
releaseLock();
|
||||
}
|
||||
process.exitCode = exitCode;
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { recordFailure } from "./wbv2-failure-ledger.mjs";
|
||||
import { createFailureLedgerSession } from "./wbv2-failure-ledger.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const abs = (file) => path.join(root, file);
|
||||
@@ -10,6 +10,7 @@ const supervisorRoot = "tests/golden/WBV2";
|
||||
const supervisorLog = `${supervisorRoot}/continuous-supervisor.log`;
|
||||
const checkpointPath = `${supervisorRoot}/continuous-supervisor.json`;
|
||||
const lockPath = `${supervisorRoot}/continuous-supervisor.lock`;
|
||||
const failureLedger = createFailureLedgerSession(root, { logPaths: [`${supervisorRoot}/continuous-supervisor.log`] });
|
||||
|
||||
const valueArg = (name, fallback) => {
|
||||
const index = process.argv.indexOf(name);
|
||||
@@ -158,7 +159,7 @@ try {
|
||||
}
|
||||
if (child.code !== 0) {
|
||||
const reason = after.task === before.task ? "child_failed_same_task" : "child_failed_after_queue_advance";
|
||||
recordFailure(root, { task: before.task, phase: "supervisor", reason, childExit: child.code });
|
||||
failureLedger.record({ task: before.task, phase: "supervisor", reason, childExit: child.code });
|
||||
writeCheckpoint({ state: "blocked", iteration: iteration + 1, before, after, reason, childExit: child.code });
|
||||
exitCode = 3;
|
||||
break;
|
||||
@@ -170,7 +171,7 @@ try {
|
||||
break;
|
||||
}
|
||||
if (after.task === before.task) {
|
||||
recordFailure(root, { task: before.task, phase: "supervisor", reason: "child_exited_without_queue_advance", childExit: child.code });
|
||||
failureLedger.record({ task: before.task, phase: "supervisor", reason: "child_exited_without_queue_advance", childExit: child.code });
|
||||
writeCheckpoint({ state: "blocked", iteration: iteration + 1, before, after, reason: "child_exited_without_queue_advance", childExit: child.code });
|
||||
exitCode = 4;
|
||||
break;
|
||||
@@ -193,6 +194,7 @@ try {
|
||||
}
|
||||
}
|
||||
finally {
|
||||
failureLedger.flush();
|
||||
releaseLock();
|
||||
}
|
||||
process.exitCode = exitCode;
|
||||
|
||||
@@ -22,6 +22,19 @@ const json = (file) => JSON.parse(read(file));
|
||||
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
|
||||
const tokenEstimate = (value) => Math.ceil(Buffer.byteLength(value, "utf8") / 4);
|
||||
|
||||
function inputMetadata(file, includeHash = true) {
|
||||
const relativePath = relative(file);
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(file, { bigint: true });
|
||||
} catch (error) {
|
||||
return { path: relativePath, exists: false, bytes: null, mtime_ns: null, sha256: null, error: error?.code ?? "STAT_FAILED" };
|
||||
}
|
||||
const metadata = { path: relativePath, exists: true, bytes: Number(stat.size), mtime_ns: stat.mtimeNs.toString() };
|
||||
if (includeHash) metadata.sha256 = sha256File(file);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
const queuePath = path.join(root, "docs/EXECUTION_QUEUE.md");
|
||||
const planPath = path.join(root, "tests/golden/M15-03A/next-task-plan.json");
|
||||
const legacyTaskIndexPath = path.join(root, "tests/golden/M15-03A/task-index.json");
|
||||
@@ -367,6 +380,13 @@ export function buildTaskContext(requestedTask) {
|
||||
const ownOutputDir = path.join(root, "tests/golden", task);
|
||||
const generatedPaths = [relative(ownOutputDir), relative(ownManifestPath), relative(ownStatusPath)];
|
||||
const selectedInputs = selectInputPaths(candidatePaths, { sourceBytes, sourceTokens, reservedPaths, generatedPaths });
|
||||
const metadataPaths = [...new Set([
|
||||
relative(queuePath),
|
||||
relative(taskPath),
|
||||
relative(parentManifestPath),
|
||||
...(fs.existsSync(parentStatusPath) ? [relative(parentStatusPath)] : []),
|
||||
...selectedInputs.inputPaths,
|
||||
])].map((candidate) => path.join(root, candidate));
|
||||
const nextTask = ownManifest?.nextTask ?? nextFromPlan(task) ?? null;
|
||||
const context = {
|
||||
schemaVersion: 1,
|
||||
@@ -381,6 +401,7 @@ export function buildTaskContext(requestedTask) {
|
||||
},
|
||||
commands,
|
||||
inputPaths: selectedInputs.inputPaths,
|
||||
inputMetadata: Object.fromEntries(metadataPaths.map((file) => [relative(file), inputMetadata(file)])),
|
||||
inputSelection: selectedInputs.inputSelection,
|
||||
exitCriteria: entry?.exitCriteria ?? ["focused command exits 0", "report and manifest are hash-bound", "failure path does not publish a late result"],
|
||||
nextTask,
|
||||
|
||||
60
tools/web/wbv2-batch-contract.mjs
Normal file
60
tools/web/wbv2-batch-contract.mjs
Normal file
@@ -0,0 +1,60 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export const COMMAND_BUDGET = Object.freeze({
|
||||
maxCommands: 48,
|
||||
maxReadCommands: 16,
|
||||
});
|
||||
|
||||
/** Return the explicit source/RNA record IDs selected for one module batch. */
|
||||
export function selectedRecordIds(module = {}) {
|
||||
const values = module.selectedRecordIds ?? module.coverageBindings ?? module.sourceUnits ?? [];
|
||||
return [...new Set((Array.isArray(values) ? values : []).filter((value) => typeof value === "string" && value.length > 0))];
|
||||
}
|
||||
|
||||
/** A batch identity is stable across retries and changes when selection changes. */
|
||||
export function batchIdFor(task, module = {}) {
|
||||
const ids = selectedRecordIds(module);
|
||||
const moduleId = module.moduleId ?? "UNKNOWN_MODULE";
|
||||
const digest = crypto.createHash("sha256").update(JSON.stringify(ids)).digest("hex").slice(0, 16);
|
||||
return module.batchId ?? `${task}:${moduleId}:${digest}`;
|
||||
}
|
||||
|
||||
export function batchContract(task, module = {}) {
|
||||
const selected = selectedRecordIds(module);
|
||||
return {
|
||||
batchId: batchIdFor(task, module),
|
||||
selectedRecordIds: selected,
|
||||
selectedCount: selected.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function completedBatch(manifest, task, module = {}) {
|
||||
const expected = batchContract(task, module);
|
||||
return manifest?.status === "done"
|
||||
&& manifest.batchId === expected.batchId
|
||||
&& JSON.stringify(manifest.selectedRecordIds ?? []) === JSON.stringify(expected.selectedRecordIds);
|
||||
}
|
||||
|
||||
export function commandBudget(commands = []) {
|
||||
const values = Array.isArray(commands) ? commands.filter((value) => typeof value === "string") : [];
|
||||
const readCommands = values.filter((value) => /\b(?:cat|head|tail|sed|rg|find|ls|stat)\b/u.test(value));
|
||||
return {
|
||||
maxCommands: COMMAND_BUDGET.maxCommands,
|
||||
maxReadCommands: COMMAND_BUDGET.maxReadCommands,
|
||||
commandCount: values.length,
|
||||
readCommandCount: readCommands.length,
|
||||
withinBudget: values.length <= COMMAND_BUDGET.maxCommands && readCommands.length <= COMMAND_BUDGET.maxReadCommands,
|
||||
};
|
||||
}
|
||||
|
||||
export function adaptiveBatchSize({ currentSize, history = [], minSize = 1, maxSize = currentSize, remaining = Number.MAX_SAFE_INTEGER }) {
|
||||
const size = Math.max(minSize, Math.min(maxSize, currentSize));
|
||||
const recent = history.filter((item) => Number.isFinite(item.durationMs) && Number.isFinite(item.commands)).slice(-3);
|
||||
let next = size;
|
||||
if (recent.some((item) => item.timeout === true || item.durationMs > 900_000 || item.commands > COMMAND_BUDGET.maxCommands)) {
|
||||
next = Math.floor(size / 2);
|
||||
} else if (recent.length > 0 && recent.every((item) => item.durationMs < 300_000 && item.commands < 24 && item.focusedGateFailure !== true)) {
|
||||
next = size + 4;
|
||||
}
|
||||
return Math.max(minSize, Math.min(maxSize, remaining, next));
|
||||
}
|
||||
@@ -5,7 +5,87 @@ import path from "node:path";
|
||||
const ledgerPath = (root) => path.join(root, "tests/golden/WBV2/failure-ledger.json");
|
||||
const keyFor = (entry) => crypto.createHash("sha256").update(JSON.stringify({ task: entry.task, phase: entry.phase, reason: entry.reason, childExit: entry.childExit ?? null })).digest("hex");
|
||||
|
||||
const readLedger = (root) => {
|
||||
try {
|
||||
const value = JSON.parse(fs.readFileSync(ledgerPath(root), "utf8"));
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
entries: Array.isArray(value.entries) ? value.entries : [],
|
||||
hydratedLogs: Array.isArray(value.hydratedLogs) ? value.hydratedLogs : [],
|
||||
};
|
||||
}
|
||||
catch {
|
||||
return { schemaVersion: 2, entries: [], hydratedLogs: [] };
|
||||
}
|
||||
};
|
||||
|
||||
export function createFailureLedgerSession(root, { logPaths = [] } = {}) {
|
||||
const ledger = readLedger(root);
|
||||
const existing = new Set(ledger.entries.map((entry) => entry.key).filter(Boolean));
|
||||
const hydrated = new Set(ledger.hydratedLogs);
|
||||
let dirty = false;
|
||||
|
||||
// Recovery is deliberately narrow: only explicit cycle.completed events can
|
||||
// add entries, and each log is imported at most once.
|
||||
for (const relative of logPaths) {
|
||||
if (hydrated.has(relative)) continue;
|
||||
const file = path.join(root, relative);
|
||||
if (!fs.existsSync(file)) continue;
|
||||
let source;
|
||||
try { source = fs.readFileSync(file, "utf8"); }
|
||||
catch { continue; }
|
||||
if (!/cycle\.completed/u.test(source)) continue;
|
||||
for (const line of source.split("\n")) {
|
||||
if (!/cycle\.completed/u.test(line)) continue;
|
||||
let event;
|
||||
try {
|
||||
const start = line.indexOf("{");
|
||||
event = JSON.parse(start >= 0 ? line.slice(start) : line);
|
||||
}
|
||||
catch { event = null; }
|
||||
const entry = event?.failure ?? event?.entry;
|
||||
if (!entry || typeof entry !== "object" || !entry.task || !entry.phase || !entry.reason) continue;
|
||||
const key = keyFor(entry);
|
||||
if (existing.has(key)) continue;
|
||||
ledger.entries.push({ ...entry, key, recordedAt: event.recordedAt ?? new Date().toISOString(), hydratedFrom: relative });
|
||||
existing.add(key);
|
||||
dirty = true;
|
||||
}
|
||||
hydrated.add(relative);
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
const record = (entry) => {
|
||||
const key = keyFor(entry);
|
||||
if (existing.has(key)) return false;
|
||||
ledger.entries.push({ ...entry, key, recordedAt: new Date().toISOString() });
|
||||
ledger.entries = ledger.entries.slice(-200);
|
||||
existing.add(key);
|
||||
dirty = true;
|
||||
return true;
|
||||
};
|
||||
const flush = () => {
|
||||
if (!dirty) return false;
|
||||
const file = ledgerPath(root);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const temporary = `${file}.tmp-${process.pid}`;
|
||||
fs.writeFileSync(temporary, `${JSON.stringify({ ...ledger, hydratedLogs: [...hydrated] }, null, 2)}\n`);
|
||||
fs.renameSync(temporary, file);
|
||||
dirty = false;
|
||||
return true;
|
||||
};
|
||||
return { record, flush, ledger };
|
||||
}
|
||||
|
||||
export function recordFailure(root, entry) {
|
||||
const session = createFailureLedgerSession(root);
|
||||
const recorded = session.record(entry);
|
||||
session.flush();
|
||||
return recorded;
|
||||
}
|
||||
|
||||
/* Legacy callers should migrate to createFailureLedgerSession so a cycle only writes once. */
|
||||
export function _legacyRecordFailure(root, entry) {
|
||||
const file = ledgerPath(root);
|
||||
let ledger = { schemaVersion: 1, entries: [] };
|
||||
try { ledger = JSON.parse(fs.readFileSync(file, "utf8")); }
|
||||
|
||||
48
web/tests/unit/wbv2-fast-paths.test.mjs
Normal file
48
web/tests/unit/wbv2-fast-paths.test.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
adaptiveBatchSize,
|
||||
batchContract,
|
||||
commandBudget,
|
||||
completedBatch,
|
||||
} from "../../../tools/web/wbv2-batch-contract.mjs";
|
||||
import { createFailureLedgerSession } from "../../../tools/web/wbv2-failure-ledger.mjs";
|
||||
|
||||
test("batch contract is stable and completion is idempotent", () => {
|
||||
const module = { moduleId: "FCURVE", coverageBindings: ["rna:a", "rna:b"] };
|
||||
const contract = batchContract("WBV2-P1-001", module);
|
||||
assert.equal(contract.selectedCount, 2);
|
||||
assert.match(contract.batchId, /^WBV2-P1-001:FCURVE:[a-f0-9]{16}$/u);
|
||||
assert.equal(completedBatch({ status: "done", batchId: contract.batchId, selectedRecordIds: contract.selectedRecordIds }, "WBV2-P1-001", module), true);
|
||||
});
|
||||
|
||||
test("adaptive batches follow the three-cycle fast and slow paths", () => {
|
||||
assert.equal(adaptiveBatchSize({ currentSize: 24, maxSize: 32, history: [{ durationMs: 120_000, commands: 10, focusedGateFailure: false }] }), 28);
|
||||
assert.equal(adaptiveBatchSize({ currentSize: 24, maxSize: 32, history: [{ durationMs: 901_000, commands: 10 }] }), 12);
|
||||
assert.equal(adaptiveBatchSize({ currentSize: 24, maxSize: 32, remaining: 5, history: [] }), 5);
|
||||
});
|
||||
|
||||
test("command budget separates exploration from execution", () => {
|
||||
assert.equal(commandBudget(["cat file", "npm run build"]).withinBudget, true);
|
||||
assert.equal(commandBudget(Array.from({ length: 49 }, () => "npm run focused")).withinBudget, false);
|
||||
assert.equal(commandBudget(Array.from({ length: 17 }, () => "rg pattern")).withinBudget, false);
|
||||
});
|
||||
|
||||
test("failure ledger hydrates completed cycles and flushes once", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "wbv2-fast-path-ledger-"));
|
||||
const logPath = "tests/golden/WBV2/cycle.log";
|
||||
fs.mkdirSync(path.dirname(path.join(root, logPath)), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, logPath), 'prefix {"event":"cycle.completed","failure":{"task":"T","phase":"gate","reason":"failed"}}\n');
|
||||
const session = createFailureLedgerSession(root, { logPaths: [logPath] });
|
||||
assert.equal(session.ledger.entries.length, 1);
|
||||
session.record({ task: "T2", phase: "gate", reason: "failed" });
|
||||
assert.equal(fs.existsSync(path.join(root, "tests/golden/WBV2/failure-ledger.json")), false);
|
||||
session.flush();
|
||||
const written = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/WBV2/failure-ledger.json"), "utf8"));
|
||||
assert.equal(written.entries.length, 2);
|
||||
assert.deepEqual(written.hydratedLogs, [logPath]);
|
||||
});
|
||||
Reference in New Issue
Block a user