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.
|
a missing scheduler and stops on a terminal or blocked checkpoint.
|
||||||
- `show_blender_web_scheduler.py`: delegates live RUNNING/IDLE/BLOCKED state to
|
- `show_blender_web_scheduler.py`: delegates live RUNNING/IDLE/BLOCKED state to
|
||||||
`tools/web/print-wbv2-live-status.mjs`.
|
`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
|
- `start_unattended.sh`: launches the scheduler and watchdog with `nohup` and
|
||||||
`setsid` for process-level continuity.
|
`setsid` for process-level continuity.
|
||||||
- `check_continuation_state.py` and `validate_web_context.py`: read-only WBV2
|
- `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
|
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
|
`--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`.
|
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",
|
"production": "tests/golden/{task}/line-style-reader-production.json",
|
||||||
"types": [],
|
"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",
|
"moduleId": "MODIFIER_SOURCE_DEPENDENCY_REUSE",
|
||||||
"title": "reuse verified modifier receipts for shared source utilities",
|
"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()
|
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:
|
def run_node(*args: str) -> None:
|
||||||
result = subprocess.run(["node", *args], cwd=ROOT, text=True, capture_output=True, check=False)
|
result = subprocess.run(["node", *args], cwd=ROOT, text=True, capture_output=True, check=False)
|
||||||
output = result.stdout or result.stderr
|
output = result.stdout or result.stderr
|
||||||
@@ -298,7 +315,7 @@ def snapshot(text: str, task: str, parent: str, next_task: str) -> str:
|
|||||||
return text
|
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_paths = batch.get("fixtures") or ([batch["fixture"]] if batch.get("fixture") else [])
|
||||||
fixture_text = ", ".join(f"`{fixture}`" for fixture in fixture_paths)
|
fixture_text = ", ".join(f"`{fixture}`" for fixture in fixture_paths)
|
||||||
if not fixture_text:
|
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']}
|
- gap: capability:{batch['moduleId']}
|
||||||
- ownerFamily: {batch['owner']}
|
- ownerFamily: {batch['owner']}
|
||||||
- targetImplementationClass: QUEUE_ONLY
|
- 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}`
|
- Implementation entrypoint: `{entrypoint}`
|
||||||
|
|
||||||
## 目标
|
## 目标
|
||||||
@@ -345,6 +365,9 @@ def make_card(task: str, parent: str, status: str, batch: dict, entrypoint: str)
|
|||||||
- gap: capability:{batch['moduleId']}
|
- gap: capability:{batch['moduleId']}
|
||||||
- ownerFamily: {batch['owner']}
|
- ownerFamily: {batch['owner']}
|
||||||
- targetImplementationClass: LOCAL_EXACT
|
- 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}`
|
- Implementation entrypoint: `{entrypoint}`
|
||||||
|
|
||||||
## 目标
|
## 目标
|
||||||
@@ -388,6 +411,9 @@ def make_card(task: str, parent: str, status: str, batch: dict, entrypoint: str)
|
|||||||
- gap: capability:{batch['moduleId']}
|
- gap: capability:{batch['moduleId']}
|
||||||
- ownerFamily: {batch['owner']}
|
- ownerFamily: {batch['owner']}
|
||||||
- targetImplementationClass: LOCAL_EXACT
|
- 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}`
|
- Implementation entrypoint: `{entrypoint}`
|
||||||
|
|
||||||
## 目标
|
## 目标
|
||||||
@@ -435,7 +461,33 @@ def select_batch(inventory: dict) -> dict:
|
|||||||
if all(selected):
|
if all(selected):
|
||||||
unresolved = [unit for unit in selected if unit.get("coverageStatus") != "VERIFIED"]
|
unresolved = [unit for unit in selected if unit.get("coverageStatus") != "VERIFIED"]
|
||||||
if unresolved:
|
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")
|
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"
|
root_task, module_task = f"WBV2-P{phase}-000", f"WBV2-P{phase}-001"
|
||||||
inventory = read_json(CONTROL_ROOT / "dynamic-inventory.json")
|
inventory = read_json(CONTROL_ROOT / "dynamic-inventory.json")
|
||||||
batch = select_batch(inventory)
|
batch = select_batch(inventory)
|
||||||
|
contract = batch_contract(module_task, batch)
|
||||||
|
batch["batchId"] = contract["batchId"]
|
||||||
if not args.write:
|
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
|
return 0
|
||||||
|
|
||||||
current_status = ROOT / "docs/status" / f"{current}.md"
|
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}",
|
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}
|
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_out = CONTROL_ROOT.parent / root_task
|
||||||
root_plan_path = root_out / "module-execution-plan.json"
|
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)
|
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"
|
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(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"))
|
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"
|
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_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))
|
run_node("tools/web/generate-v2-task-index.mjs", "--plan-path", rel(root_plan_path))
|
||||||
queue_text = QUEUE.read_text(encoding="utf-8")
|
queue_text = QUEUE.read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ def main() -> int:
|
|||||||
"schedulerWrapperStatus": read_json(CONTROL_ROOT / "my-tools-scheduler.json"),
|
"schedulerWrapperStatus": read_json(CONTROL_ROOT / "my-tools-scheduler.json"),
|
||||||
}
|
}
|
||||||
status["progress"] = project_progress()
|
status["progress"] = project_progress()
|
||||||
|
status["fastPath"] = fast_path_status(status)
|
||||||
if args.json:
|
if args.json:
|
||||||
print(json.dumps(status, ensure_ascii=False, separators=(",", ":")))
|
print(json.dumps(status, ensure_ascii=False, separators=(",", ":")))
|
||||||
if not args.watch:
|
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" 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" latest handoff: {status.get('latestHandoff') or 'none'}")
|
||||||
print(f" local HEAD: {status.get('localHead') or 'unknown'}")
|
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(line)
|
||||||
print(f" message: {status.get('message', '')}")
|
print(f" message: {status.get('message', '')}")
|
||||||
if not args.watch:
|
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 = progress.get("estimatedRemainingHours")
|
||||||
estimate_text = f"{estimate} h" if estimate is not None else "n/a"
|
estimate_text = f"{estimate} h" if estimate is not None else "n/a"
|
||||||
total_estimate = progress.get("estimatedTotalHours")
|
total_estimate = progress.get("estimatedTotalHours")
|
||||||
total_text = f"{total_estimate} h" if total_estimate is not None else "n/a"
|
total_text = f"{total_estimate} h" if total_estimate is not None else "n/a"
|
||||||
plan = progress.get("taskPlan", {})
|
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 [
|
return [
|
||||||
"",
|
"",
|
||||||
"Project migration progress",
|
"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" 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: remaining={estimate_text}, total={total_text}",
|
||||||
f" estimate basis: {progress.get('estimateBasis', 'n/a')}",
|
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": {
|
"moduleReceipt": {
|
||||||
"path": "tests/golden/WBV2-P1161-001/module-receipt.json",
|
"path": "tests/golden/WBV2-P1161-001/module-receipt.json",
|
||||||
"sha256": "48d1c06e1f03206fa68bec9db86ecd67fbfd858776b48a82b9505dc6ccd03459"
|
"sha256": "6b1131b452c509f600536baa2e95f0d9ad30a1fb5951ae9e36b3f540c36326bf"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"path": "docs/status/WBV2-P1161-001.md",
|
"path": "docs/status/WBV2-P1161-001.md",
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
},
|
},
|
||||||
"taskContext": {
|
"taskContext": {
|
||||||
"path": "tests/golden/WBV2-P1161-001/task-context.json",
|
"path": "tests/golden/WBV2-P1161-001/task-context.json",
|
||||||
"sha256": "cb7a0b446bfc6c27a2040f42107162c99290927f3115eab029c2a456442b8ad3"
|
"sha256": "f98e1ee00beed21da1ef51d60caee5629e52c11a4f2d9bda78bb7b0cf52d7d41"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"assertions": {
|
"assertions": {
|
||||||
@@ -97,7 +97,33 @@
|
|||||||
"historicalInventoryTotal": 6900,
|
"historicalInventoryTotal": 6900,
|
||||||
"historicalEffectiveCompleted": 290,
|
"historicalEffectiveCompleted": 290,
|
||||||
"historicalEffectivePending": 6609,
|
"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
|
"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"
|
"node tools/web/check-task-context.mjs --task WBV2-P1161-001 --write"
|
||||||
],
|
],
|
||||||
"inputPaths": [],
|
"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": {
|
"inputSelection": {
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"limits": {
|
"limits": {
|
||||||
@@ -54,8 +84,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "tests/golden/WBV2-P1161-001/module-receipt.json",
|
"path": "tests/golden/WBV2-P1161-001/module-receipt.json",
|
||||||
"bytes": null,
|
"bytes": 5223,
|
||||||
"reason": "MISSING_PATH"
|
"reason": "CONTEXT_REMAINING_SPACE"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "tests/golden/WBV2-P1161-001/modifier-reader-batch-production.json",
|
"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");
|
if (!task) throw new Error("unable to resolve current WBV2 task");
|
||||||
const parentManifest = queue.match(/\| parent manifest \| `([^`]+)` \|/u)?.[1];
|
const parentManifest = queue.match(/\| parent manifest \| `([^`]+)` \|/u)?.[1];
|
||||||
if (!parentManifest) throw new Error("queue parent manifest is missing");
|
if (!parentManifest) throw new Error("queue parent manifest is missing");
|
||||||
const inputPaths = [
|
const baseInputPaths = [
|
||||||
"docs/EXECUTION_QUEUE.md",
|
"docs/EXECUTION_QUEUE.md",
|
||||||
`docs/tasks/${task}.md`,
|
`docs/tasks/${task}.md`,
|
||||||
parentManifest,
|
parentManifest,
|
||||||
`tests/golden/${task}/task-context.json`,
|
`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 statInput = (file) => {
|
||||||
const inputHash = inputHashFor();
|
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;
|
let cached = null;
|
||||||
try { cached = JSON.parse(fs.readFileSync(cachePath, "utf8")); }
|
try { cached = JSON.parse(fs.readFileSync(cachePath, "utf8")); }
|
||||||
catch { /* First use or an interrupted write. */ }
|
catch { /* First use or an interrupted write. */ }
|
||||||
if (!process.argv.includes("--force") && cached?.status === "PASS" && cached.task === task && cached.inputHash === inputHash) {
|
const inputPaths = [...new Set([
|
||||||
process.stdout.write(`wbv2-context-cache-hit task=${task} inputHash=${inputHash}\n`);
|
...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);
|
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 });
|
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));
|
process.exit(Math.max(result.status ?? 1, check.status ?? 1));
|
||||||
}
|
}
|
||||||
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
||||||
const refreshedInputHash = inputHashFor();
|
let contextMetadata = {};
|
||||||
const payload = { schemaVersion: 1, status: "PASS", task, inputHash: refreshedInputHash, refreshedAt: new Date().toISOString() };
|
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}`;
|
const temporary = `${cachePath}.tmp-${process.pid}`;
|
||||||
fs.writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`);
|
fs.writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`);
|
||||||
fs.renameSync(temporary, cachePath);
|
fs.renameSync(temporary, cachePath);
|
||||||
|
|||||||
@@ -64,6 +64,25 @@ export function validateContextBundle(bundle, { current = true } = {}) {
|
|||||||
if (context.commands.length > GOVERNANCE_LIMITS.maxTaskCommands) {
|
if (context.commands.length > GOVERNANCE_LIMITS.maxTaskCommands) {
|
||||||
add(violations, "TASK_COMMANDS_TOO_WIDE", `count=${context.commands.length}`);
|
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;
|
const selection = context.inputSelection;
|
||||||
if (!selection || selection.schemaVersion !== 1 || !Array.isArray(selection.selected) || !Array.isArray(selection.excluded)) {
|
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 root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
const abs = (file) => path.join(root, file);
|
const abs = (file) => path.join(root, file);
|
||||||
const controlRoot = "tests/golden/WBV2";
|
const controlRoot = "tests/golden/WBV2";
|
||||||
|
const schedulerLockPath = `${controlRoot}/continuous-scheduler.lock`;
|
||||||
|
const schedulerCheckpointPath = `${controlRoot}/continuous-scheduler.json`;
|
||||||
const guardianLockPath = `${controlRoot}/continuous-guardian.lock`;
|
const guardianLockPath = `${controlRoot}/continuous-guardian.lock`;
|
||||||
const supervisorLockPath = `${controlRoot}/continuous-supervisor.lock`;
|
const supervisorLockPath = `${controlRoot}/continuous-supervisor.lock`;
|
||||||
const guardianCheckpointPath = `${controlRoot}/continuous-guardian.json`;
|
const guardianCheckpointPath = `${controlRoot}/continuous-guardian.json`;
|
||||||
@@ -80,15 +82,17 @@ const pushVerification = (task) => {
|
|||||||
};
|
};
|
||||||
const collect = () => {
|
const collect = () => {
|
||||||
const queue = currentPointer();
|
const queue = currentPointer();
|
||||||
|
const scheduler = readJson(schedulerCheckpointPath);
|
||||||
const guardian = readJson(guardianCheckpointPath);
|
const guardian = readJson(guardianCheckpointPath);
|
||||||
const supervisor = readJson(supervisorCheckpointPath);
|
const supervisor = readJson(supervisorCheckpointPath);
|
||||||
|
const schedulerLock = lockInfo(schedulerLockPath);
|
||||||
const guardianLock = lockInfo(guardianLockPath);
|
const guardianLock = lockInfo(guardianLockPath);
|
||||||
const supervisorLock = lockInfo(supervisorLockPath);
|
const supervisorLock = lockInfo(supervisorLockPath);
|
||||||
const handoff = latestHandoff();
|
const handoff = latestHandoff();
|
||||||
const active = guardianLock.alive && supervisorLock.alive;
|
const active = schedulerLock.alive || (guardianLock.alive && supervisorLock.alive);
|
||||||
const blocked = guardian?.state === "blocked" || supervisor?.state === "blocked";
|
const blocked = scheduler?.state === "blocked" || guardian?.state === "blocked" || supervisor?.state === "blocked";
|
||||||
const state = blocked ? "BLOCKED" : active ? "RUNNING" : "IDLE";
|
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();
|
const observed = new Date().toISOString();
|
||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
@@ -97,14 +101,15 @@ const collect = () => {
|
|||||||
phase,
|
phase,
|
||||||
message: state === "RUNNING"
|
message: state === "RUNNING"
|
||||||
? `RUNNING: ${queue.task ?? "unknown task"} is owned by the guardian/supervisor process; wait for its handoff.`
|
? `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,
|
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 },
|
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 },
|
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,
|
latestHandoff: handoff,
|
||||||
latestPushVerification: pushVerification(handoff?.before ?? supervisor?.before?.task),
|
latestPushVerification: pushVerification(handoff?.before ?? supervisor?.before?.task),
|
||||||
localHead: gitHead(),
|
localHead: gitHead(),
|
||||||
checkpoints: { guardian: guardianCheckpointPath, supervisor: supervisorCheckpointPath },
|
checkpoints: { scheduler: schedulerCheckpointPath, guardian: guardianCheckpointPath, supervisor: supervisorCheckpointPath },
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const format = (status) => [
|
const format = (status) => [
|
||||||
@@ -112,6 +117,7 @@ const format = (status) => [
|
|||||||
`阶段: ${status.phase}`,
|
`阶段: ${status.phase}`,
|
||||||
`当前任务: ${status.queue.task ?? "unknown"}`,
|
`当前任务: ${status.queue.task ?? "unknown"}`,
|
||||||
`parent manifest: ${status.queue.parentManifest ?? "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"}`,
|
`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"}`,
|
`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"}`,
|
`最近交接: ${status.latestHandoff ? `${status.latestHandoff.before} -> ${status.latestHandoff.after} (#${status.latestHandoff.iteration})` : "none"}`,
|
||||||
|
|||||||
@@ -58,20 +58,6 @@ function taskCommand(task) {
|
|||||||
return match[1];
|
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;
|
let iterations = 0;
|
||||||
const summary = [];
|
const summary = [];
|
||||||
while (maxIterations === 0 || iterations < maxIterations) {
|
while (maxIterations === 0 || iterations < maxIterations) {
|
||||||
@@ -91,10 +77,10 @@ while (maxIterations === 0 || iterations < maxIterations) {
|
|||||||
const after = currentTask();
|
const after = currentTask();
|
||||||
const terminal = after.task === before.task && terminalTask(before.task);
|
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}`);
|
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)
|
// Implementation cycles keep the post-handoff gate incremental. Full API,
|
||||||
? "printf 'typecheck reused from hash-bound module receipt\\n'"
|
// format, Chromium, performance and release gates belong to the explicit
|
||||||
: "npm --prefix web run typecheck";
|
// final audit and must not be repeated for every batch.
|
||||||
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`);
|
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`);
|
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;
|
iterations += 1;
|
||||||
summary.push({ task: before.task, command: focused, nextTask: terminal ? null : after.task, terminal, log: logPath });
|
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 path from "node:path";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { fileURLToPath } from "node:url";
|
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 root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
const abs = (file) => path.join(root, file);
|
const abs = (file) => path.join(root, file);
|
||||||
@@ -11,6 +11,7 @@ const logPath = `${controlRoot}/continuous-guardian.log`;
|
|||||||
const checkpointPath = `${controlRoot}/continuous-guardian.json`;
|
const checkpointPath = `${controlRoot}/continuous-guardian.json`;
|
||||||
const supervisorCheckpointPath = `${controlRoot}/continuous-supervisor.json`;
|
const supervisorCheckpointPath = `${controlRoot}/continuous-supervisor.json`;
|
||||||
const lockPath = `${controlRoot}/continuous-guardian.lock`;
|
const lockPath = `${controlRoot}/continuous-guardian.lock`;
|
||||||
|
const failureLedger = createFailureLedgerSession(root, { logPaths: [`${controlRoot}/continuous-guardian.log`] });
|
||||||
|
|
||||||
const argv = process.argv.slice(2);
|
const argv = process.argv.slice(2);
|
||||||
const numericArg = (name, fallback) => {
|
const numericArg = (name, fallback) => {
|
||||||
@@ -173,7 +174,7 @@ try {
|
|||||||
}
|
}
|
||||||
const decision = safeRestart(checkpoint, after);
|
const decision = safeRestart(checkpoint, after);
|
||||||
if (!decision.ok) {
|
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 });
|
writeCheckpoint({ state: "blocked", restartCount, before, after, reason: decision.reason, supervisorExit: result.code });
|
||||||
appendLog(`blocked reason=${decision.reason}; no automatic replay`);
|
appendLog(`blocked reason=${decision.reason}; no automatic replay`);
|
||||||
guardianExit = 2;
|
guardianExit = 2;
|
||||||
@@ -181,7 +182,7 @@ try {
|
|||||||
}
|
}
|
||||||
restartCount += 1;
|
restartCount += 1;
|
||||||
if (maxRestarts > 0 && restartCount > maxRestarts) {
|
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 });
|
writeCheckpoint({ state: "blocked", restartCount, before, after, reason: "max_safe_restarts_exceeded", supervisorExit: result.code });
|
||||||
appendLog("blocked reason=max_safe_restarts_exceeded");
|
appendLog("blocked reason=max_safe_restarts_exceeded");
|
||||||
guardianExit = 3;
|
guardianExit = 3;
|
||||||
@@ -194,12 +195,13 @@ try {
|
|||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
guardianExit = 4;
|
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. */ }
|
catch { /* Preserve the original guardian failure. */ }
|
||||||
writeCheckpoint({ state: "blocked", restartCount, reason: "guardian_error", error: error?.message ?? String(error) });
|
writeCheckpoint({ state: "blocked", restartCount, reason: "guardian_error", error: error?.message ?? String(error) });
|
||||||
appendLog(`blocked reason=guardian_error error=${error?.message ?? String(error)}`);
|
appendLog(`blocked reason=guardian_error error=${error?.message ?? String(error)}`);
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
failureLedger.flush();
|
||||||
signalChild("SIGTERM");
|
signalChild("SIGTERM");
|
||||||
releaseLock();
|
releaseLock();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { execFileSync, spawnSync } from "node:child_process";
|
import { execFileSync, spawnSync } from "node:child_process";
|
||||||
import { fileURLToPath } from "node:url";
|
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 root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
const abs = (file) => path.join(root, file);
|
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 json = (file) => JSON.parse(read(file));
|
||||||
const sha = (file) => crypto.createHash("sha256").update(fs.readFileSync(abs(file))).digest("hex");
|
const sha = (file) => crypto.createHash("sha256").update(fs.readFileSync(abs(file))).digest("hex");
|
||||||
const dynamicInventoryPath = "tests/golden/WBV2/dynamic-inventory.json";
|
const dynamicInventoryPath = "tests/golden/WBV2/dynamic-inventory.json";
|
||||||
|
const out = `tests/golden/${process.argv[process.argv.indexOf("--task") + 1]}`;
|
||||||
const writeJson = (file, value) => {
|
const writeJson = (file, value) => {
|
||||||
fs.mkdirSync(path.dirname(abs(file)), { recursive: true });
|
fs.mkdirSync(path.dirname(abs(file)), { recursive: true });
|
||||||
fs.writeFileSync(abs(file), `${JSON.stringify(value, null, 2)}\n`);
|
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 parentPath = `tests/golden/${parentId}/manifest.json`;
|
||||||
const parent = json(parentPath);
|
const parent = json(parentPath);
|
||||||
if (parent.status !== "done" || parent.nextTask !== task) throw new Error(`module parent is not a completed handoff: ${parentId}`);
|
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`);
|
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}`);
|
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) =>
|
const sourceAuditPaths = (record.module.sourcePaths ?? []).filter((source) =>
|
||||||
/^(blender-5\.2\.0\/source\/blender\/|web\/protocol\/|web\/app\/src\/(?!vendor\/))/u.test(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),
|
reportSha256: sha(receipt.report),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const out = `tests/golden/${task}`;
|
|
||||||
const reportPath = `${out}/module-receipt.json`;
|
const reportPath = `${out}/module-receipt.json`;
|
||||||
const statusPath = `docs/status/${task}.md`;
|
const statusPath = `docs/status/${task}.md`;
|
||||||
const logPath = `${out}/module-run.log`;
|
const logPath = `${out}/module-run.log`;
|
||||||
@@ -130,7 +151,11 @@ const report = {
|
|||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
task,
|
task,
|
||||||
operation: "MODULE_FUNCTION_EXECUTION",
|
operation: "MODULE_FUNCTION_EXECUTION",
|
||||||
|
phase: failed || !sourceUnchanged ? "BLOCKED" : "CONTINUE",
|
||||||
moduleId: record.module.moduleId,
|
moduleId: record.module.moduleId,
|
||||||
|
batchId: contract.batchId,
|
||||||
|
selectedRecordIds: contract.selectedRecordIds,
|
||||||
|
commandBudget: commandContract,
|
||||||
status: failed || !sourceUnchanged ? "BLOCKED" : "PASS",
|
status: failed || !sourceUnchanged ? "BLOCKED" : "PASS",
|
||||||
source: { paths: record.module.sourcePaths, auditedPaths: sourceAuditPaths, before: sourceBefore, after: sourceAfter, unchanged: sourceUnchanged },
|
source: { paths: record.module.sourcePaths, auditedPaths: sourceAuditPaths, before: sourceBefore, after: sourceAfter, unchanged: sourceUnchanged },
|
||||||
reusedReceipts,
|
reusedReceipts,
|
||||||
@@ -159,7 +184,7 @@ if (failed || !sourceUnchanged) {
|
|||||||
const manifestPath = `${out}/manifest.json`;
|
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 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`) } };
|
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) {
|
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/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" });
|
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 path from "node:path";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { fileURLToPath } from "node:url";
|
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 root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
const controlRoot = "tests/golden/WBV2";
|
const controlRoot = "tests/golden/WBV2";
|
||||||
@@ -11,6 +11,10 @@ const checkpointPath = `${controlRoot}/continuous-scheduler.json`;
|
|||||||
const lockPath = `${controlRoot}/continuous-scheduler.lock`;
|
const lockPath = `${controlRoot}/continuous-scheduler.lock`;
|
||||||
const logPath = `${controlRoot}/continuous-scheduler.log`;
|
const logPath = `${controlRoot}/continuous-scheduler.log`;
|
||||||
const abs = (file) => path.join(root, file);
|
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 argValue = (name, fallback) => {
|
||||||
const index = process.argv.indexOf(name);
|
const index = process.argv.indexOf(name);
|
||||||
@@ -60,6 +64,7 @@ function writeCheckpoint(payload) {
|
|||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
...payload,
|
...payload,
|
||||||
|
latestBatch: payload.latestBatch ?? latestBatch,
|
||||||
};
|
};
|
||||||
const stable = (value) => { const { pid, updatedAt, ...rest } = value ?? {}; return rest; };
|
const stable = (value) => { const { pid, updatedAt, ...rest } = value ?? {}; return rest; };
|
||||||
try {
|
try {
|
||||||
@@ -72,6 +77,43 @@ function writeCheckpoint(payload) {
|
|||||||
fs.renameSync(abs(temporaryPath), abs(checkpointPath));
|
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) {
|
function appendLog(message) {
|
||||||
fs.appendFileSync(abs(logPath), `[${new Date().toISOString()}] ${message}\n`);
|
fs.appendFileSync(abs(logPath), `[${new Date().toISOString()}] ${message}\n`);
|
||||||
}
|
}
|
||||||
@@ -91,10 +133,10 @@ function refreshContinuation(task) {
|
|||||||
if (!fs.existsSync(continuationPath)) return false;
|
if (!fs.existsSync(continuationPath)) return false;
|
||||||
const pointer = currentTask();
|
const pointer = currentTask();
|
||||||
const index = JSON.parse(read("tests/golden/WBV2/task-index.json"));
|
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 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 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");
|
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 {
|
try {
|
||||||
@@ -246,7 +288,9 @@ let iteration = 0;
|
|||||||
let completed = false;
|
let completed = false;
|
||||||
try {
|
try {
|
||||||
appendLog(`started pid=${process.pid} maxIterations=${maxIterations} commit=${commitEnabled} push=${pushEnabled}`);
|
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) {
|
while (maxIterations === 0 || iteration < maxIterations) {
|
||||||
if (stopRequested) {
|
if (stopRequested) {
|
||||||
writeCheckpoint({ state: "stopped", iteration, current: currentTask(), reason: stopSignal });
|
writeCheckpoint({ state: "stopped", iteration, current: currentTask(), reason: stopSignal });
|
||||||
@@ -254,6 +298,7 @@ try {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const before = currentTask();
|
const before = currentTask();
|
||||||
|
latestBatch = batchInfo(before);
|
||||||
if (!before) {
|
if (!before) {
|
||||||
writeCheckpoint({ state: "complete", iteration, current: null, reason: "queue_closed" });
|
writeCheckpoint({ state: "complete", iteration, current: null, reason: "queue_closed" });
|
||||||
appendLog(`complete queue_closed iteration=${iteration}`);
|
appendLog(`complete queue_closed iteration=${iteration}`);
|
||||||
@@ -266,14 +311,24 @@ try {
|
|||||||
try {
|
try {
|
||||||
const index = JSON.parse(read("tests/golden/WBV2/task-index.json"));
|
const index = JSON.parse(read("tests/golden/WBV2/task-index.json"));
|
||||||
if (index.activeTask === null && index.entries?.[before.task]) {
|
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}`);
|
appendLog(`complete v2_plan_terminal task=${before.task} iteration=${iteration}`);
|
||||||
completed = true;
|
completed = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error) {
|
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) });
|
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)}`);
|
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`);
|
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 });
|
writeCheckpoint({ state: "preflight", iteration, before, readiness });
|
||||||
appendLog(`preflight task=${before.task} class=${readiness.taskClass ?? "UNKNOWN"} ok=${readiness.ok} reason=${readiness.reason ?? "ready"}`);
|
appendLog(`preflight task=${before.task} class=${readiness.taskClass ?? "UNKNOWN"} ok=${readiness.ok} reason=${readiness.reason ?? "ready"}`);
|
||||||
if (!readiness.ok) {
|
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 });
|
writeCheckpoint({ state: "blocked", iteration, before, readiness });
|
||||||
appendLog(`blocked task=${before.task} reason=${readiness.reason}`);
|
appendLog(`blocked task=${before.task} reason=${readiness.reason}`);
|
||||||
process.stderr.write(`scheduler-blocked task=${before.task} reason=${readiness.reason}\n`);
|
process.stderr.write(`scheduler-blocked task=${before.task} reason=${readiness.reason}\n`);
|
||||||
@@ -306,7 +361,7 @@ try {
|
|||||||
refreshTaskContext(before.task);
|
refreshTaskContext(before.task);
|
||||||
}
|
}
|
||||||
catch (error) {
|
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) });
|
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`);
|
process.stderr.write(`scheduler-blocked task=${before.task} reason=context_rotation_failed\n`);
|
||||||
exitCode = 2;
|
exitCode = 2;
|
||||||
@@ -315,6 +370,7 @@ try {
|
|||||||
writeCheckpoint({ state: "running", iteration: iteration + 1, before, readiness });
|
writeCheckpoint({ state: "running", iteration: iteration + 1, before, readiness });
|
||||||
const child = runOne(before);
|
const child = runOne(before);
|
||||||
const after = currentTask();
|
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}`);
|
appendLog(`child task=${before.task} exit=${child.code} signal=${child.signal ?? "none"} after=${after?.task ?? "NONE"} log=${child.outputPath}`);
|
||||||
if (stopRequested) {
|
if (stopRequested) {
|
||||||
writeCheckpoint({ state: "stopped", iteration, before, after, reason: stopSignal, childExit: child.code });
|
writeCheckpoint({ state: "stopped", iteration, before, after, reason: stopSignal, childExit: child.code });
|
||||||
@@ -323,7 +379,7 @@ try {
|
|||||||
}
|
}
|
||||||
if (child.code !== 0) {
|
if (child.code !== 0) {
|
||||||
const reason = after?.task !== before.task ? "child_failed_after_queue_advance" : "child_failed_same_task";
|
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 });
|
writeCheckpoint({ state: "blocked", iteration, before, after, reason, childExit: child.code, childLog: child.outputPath });
|
||||||
appendLog(`blocked task=${before.task} reason=${reason}`);
|
appendLog(`blocked task=${before.task} reason=${reason}`);
|
||||||
process.stderr.write(`scheduler-blocked task=${before.task} reason=${reason} childExit=${child.code}\n`);
|
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();
|
if (refreshContinuation(after?.task ?? before.task) && commitEnabled) commitContinuation();
|
||||||
}
|
}
|
||||||
catch (error) {
|
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) });
|
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`);
|
process.stderr.write(`scheduler-blocked task=${before.task} reason=continuation_rotation_failed\n`);
|
||||||
exitCode = 5;
|
exitCode = 5;
|
||||||
@@ -348,7 +404,7 @@ try {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (!after || after.task === before.task) {
|
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 });
|
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`);
|
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`);
|
process.stderr.write(`scheduler-blocked task=${before.task} reason=child_exited_without_queue_advance\n`);
|
||||||
@@ -372,12 +428,13 @@ try {
|
|||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
exitCode = 5;
|
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. */ }
|
catch { /* Preserve the original scheduler failure. */ }
|
||||||
writeCheckpoint({ state: "blocked", iteration, reason: "scheduler_error", error: error?.message ?? String(error) });
|
writeCheckpoint({ state: "blocked", iteration, reason: "scheduler_error", error: error?.message ?? String(error) });
|
||||||
appendLog(`blocked reason=scheduler_error error=${error?.message ?? String(error)}`);
|
appendLog(`blocked reason=scheduler_error error=${error?.message ?? String(error)}`);
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
failureLedger.flush();
|
||||||
releaseLock();
|
releaseLock();
|
||||||
}
|
}
|
||||||
process.exitCode = exitCode;
|
process.exitCode = exitCode;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { fileURLToPath } from "node:url";
|
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 root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
const abs = (file) => path.join(root, file);
|
const abs = (file) => path.join(root, file);
|
||||||
@@ -10,6 +10,7 @@ const supervisorRoot = "tests/golden/WBV2";
|
|||||||
const supervisorLog = `${supervisorRoot}/continuous-supervisor.log`;
|
const supervisorLog = `${supervisorRoot}/continuous-supervisor.log`;
|
||||||
const checkpointPath = `${supervisorRoot}/continuous-supervisor.json`;
|
const checkpointPath = `${supervisorRoot}/continuous-supervisor.json`;
|
||||||
const lockPath = `${supervisorRoot}/continuous-supervisor.lock`;
|
const lockPath = `${supervisorRoot}/continuous-supervisor.lock`;
|
||||||
|
const failureLedger = createFailureLedgerSession(root, { logPaths: [`${supervisorRoot}/continuous-supervisor.log`] });
|
||||||
|
|
||||||
const valueArg = (name, fallback) => {
|
const valueArg = (name, fallback) => {
|
||||||
const index = process.argv.indexOf(name);
|
const index = process.argv.indexOf(name);
|
||||||
@@ -158,7 +159,7 @@ try {
|
|||||||
}
|
}
|
||||||
if (child.code !== 0) {
|
if (child.code !== 0) {
|
||||||
const reason = after.task === before.task ? "child_failed_same_task" : "child_failed_after_queue_advance";
|
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 });
|
writeCheckpoint({ state: "blocked", iteration: iteration + 1, before, after, reason, childExit: child.code });
|
||||||
exitCode = 3;
|
exitCode = 3;
|
||||||
break;
|
break;
|
||||||
@@ -170,7 +171,7 @@ try {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (after.task === before.task) {
|
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 });
|
writeCheckpoint({ state: "blocked", iteration: iteration + 1, before, after, reason: "child_exited_without_queue_advance", childExit: child.code });
|
||||||
exitCode = 4;
|
exitCode = 4;
|
||||||
break;
|
break;
|
||||||
@@ -193,6 +194,7 @@ try {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
failureLedger.flush();
|
||||||
releaseLock();
|
releaseLock();
|
||||||
}
|
}
|
||||||
process.exitCode = exitCode;
|
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 relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
|
||||||
const tokenEstimate = (value) => Math.ceil(Buffer.byteLength(value, "utf8") / 4);
|
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 queuePath = path.join(root, "docs/EXECUTION_QUEUE.md");
|
||||||
const planPath = path.join(root, "tests/golden/M15-03A/next-task-plan.json");
|
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");
|
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 ownOutputDir = path.join(root, "tests/golden", task);
|
||||||
const generatedPaths = [relative(ownOutputDir), relative(ownManifestPath), relative(ownStatusPath)];
|
const generatedPaths = [relative(ownOutputDir), relative(ownManifestPath), relative(ownStatusPath)];
|
||||||
const selectedInputs = selectInputPaths(candidatePaths, { sourceBytes, sourceTokens, reservedPaths, generatedPaths });
|
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 nextTask = ownManifest?.nextTask ?? nextFromPlan(task) ?? null;
|
||||||
const context = {
|
const context = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
@@ -381,6 +401,7 @@ export function buildTaskContext(requestedTask) {
|
|||||||
},
|
},
|
||||||
commands,
|
commands,
|
||||||
inputPaths: selectedInputs.inputPaths,
|
inputPaths: selectedInputs.inputPaths,
|
||||||
|
inputMetadata: Object.fromEntries(metadataPaths.map((file) => [relative(file), inputMetadata(file)])),
|
||||||
inputSelection: selectedInputs.inputSelection,
|
inputSelection: selectedInputs.inputSelection,
|
||||||
exitCriteria: entry?.exitCriteria ?? ["focused command exits 0", "report and manifest are hash-bound", "failure path does not publish a late result"],
|
exitCriteria: entry?.exitCriteria ?? ["focused command exits 0", "report and manifest are hash-bound", "failure path does not publish a late result"],
|
||||||
nextTask,
|
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 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 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) {
|
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);
|
const file = ledgerPath(root);
|
||||||
let ledger = { schemaVersion: 1, entries: [] };
|
let ledger = { schemaVersion: 1, entries: [] };
|
||||||
try { ledger = JSON.parse(fs.readFileSync(file, "utf8")); }
|
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