Skip to content

Commit a9eba0f

Browse files
committed
feat: persist unblock task planning artifacts
1 parent 02ccf0f commit a9eba0f

14 files changed

Lines changed: 237 additions & 10 deletions

apps/orchestrator/src/cortexpilot_orch/api/main_pm_intake_helpers.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@
1818
from cortexpilot_orch.config import load_config
1919
from cortexpilot_orch.contract.compiler import build_role_binding_summary, sync_role_contract
2020
from cortexpilot_orch.observability.logger import log_event
21-
from cortexpilot_orch.planning.intake import IntakeService, _build_wave_plan, _build_worker_prompt_contracts
21+
from cortexpilot_orch.planning.intake import (
22+
IntakeService,
23+
_build_unblock_tasks_from_worker_contracts,
24+
_build_wave_plan,
25+
_build_worker_prompt_contracts,
26+
)
2227
from cortexpilot_orch.store.intake_store import IntakeStore
2328
from cortexpilot_orch.store.run_store import RunStore
2429

@@ -169,9 +174,11 @@ def _persist_planning_artifacts_for_run(
169174

170175
run_store = RunStore(runs_root=runs_root)
171176
run_dir = run_store.run_dir(run_id)
177+
worker_prompt_contracts = _build_worker_prompt_contracts(plan_bundle, intake_payload)
172178
artifacts_to_write: list[tuple[str, Any]] = [
173179
("planning_wave_plan.json", _build_wave_plan(plan_bundle)),
174-
("planning_worker_prompt_contracts.json", _build_worker_prompt_contracts(plan_bundle, intake_payload)),
180+
("planning_worker_prompt_contracts.json", worker_prompt_contracts),
181+
("planning_unblock_tasks.json", _build_unblock_tasks_from_worker_contracts(worker_prompt_contracts)),
175182
]
176183
written: list[str] = []
177184
artifact_refs: list[dict[str, Any]] = []

apps/orchestrator/src/cortexpilot_orch/planning/intake.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,56 @@ def _build_worker_prompt_contracts(plan_bundle: dict[str, Any], payload: dict[st
585585
return contracts
586586

587587

588+
def _build_unblock_tasks_from_worker_contracts(worker_contracts: list[dict[str, Any]]) -> list[dict[str, Any]]:
589+
tasks: list[dict[str, Any]] = []
590+
for contract in worker_contracts:
591+
if not isinstance(contract, dict):
592+
continue
593+
continuation_policy = contract.get("continuation_policy") if isinstance(contract.get("continuation_policy"), dict) else {}
594+
on_blocked = str(continuation_policy.get("on_blocked") or "").strip()
595+
if on_blocked != "spawn_independent_temporary_unblock_task":
596+
continue
597+
assigned_agent = contract.get("assigned_agent") if isinstance(contract.get("assigned_agent"), dict) else {}
598+
blocked_when = contract.get("blocked_when") if isinstance(contract.get("blocked_when"), list) else []
599+
reason = next(
600+
(
601+
str(item).strip()
602+
for item in blocked_when
603+
if str(item).strip() and (
604+
"external blocker" in str(item).lower()
605+
or "unblock task" in str(item).lower()
606+
)
607+
),
608+
"an external blocker requires an L0-managed unblock task",
609+
)
610+
prompt_contract_id = str(contract.get("prompt_contract_id") or "").strip() or "worker-preview-1"
611+
tasks.append(
612+
{
613+
"version": "v1",
614+
"unblock_task_id": f"unblock-{prompt_contract_id}",
615+
"source_prompt_contract_id": prompt_contract_id,
616+
"objective": f"Unblock the scoped worker assignment for {contract.get('objective') or 'the current wave'}",
617+
"scope_hint": str(contract.get("scope") or "").strip() or "No scope summary provided.",
618+
"assigned_agent": {
619+
"role": str(assigned_agent.get("role") or "WORKER").strip() or "WORKER",
620+
"agent_id": str(assigned_agent.get("agent_id") or "agent-1").strip() or "agent-1",
621+
},
622+
"owner": "L0",
623+
"mode": "independent_temporary_task",
624+
"status": "proposed",
625+
"trigger": on_blocked,
626+
"reason": reason,
627+
"verification_requirements": [
628+
str(item).strip()
629+
for item in contract.get("verification_requirements", [])
630+
if str(item).strip()
631+
]
632+
or ["repo_hygiene"],
633+
}
634+
)
635+
return tasks
636+
637+
588638
def _apply_intake_contract_overrides(
589639
contract: dict[str, Any],
590640
intake_payload: dict[str, Any],
@@ -1127,6 +1177,10 @@ def preview(self, payload: dict[str, Any]) -> dict[str, Any]:
11271177
assigned_role = str(assigned_agent.get("role") or "WORKER").strip() or "WORKER"
11281178
predicted_reports = _predicted_reports_for_task_template(task_template)
11291179
predicted_artifacts = _predicted_artifacts_for_payload(normalized_payload)
1180+
worker_prompt_contracts = _build_worker_prompt_contracts(plan_bundle, normalized_payload)
1181+
unblock_tasks = _build_unblock_tasks_from_worker_contracts(worker_prompt_contracts)
1182+
if unblock_tasks and "planning_unblock_tasks.json" not in predicted_artifacts:
1183+
predicted_artifacts.append("planning_unblock_tasks.json")
11301184
warnings: list[str] = []
11311185
if requires_human_approval:
11321186
warnings.append("Current policies suggest the run may require manual approval before execution can continue.")
@@ -1171,13 +1225,16 @@ def preview(self, payload: dict[str, Any]) -> dict[str, Any]:
11711225
"plan_bundle": plan_bundle,
11721226
"task_chain": task_chain,
11731227
"wave_plan": _build_wave_plan(plan_bundle),
1174-
"worker_prompt_contracts": _build_worker_prompt_contracts(plan_bundle, normalized_payload),
1228+
"worker_prompt_contracts": worker_prompt_contracts,
1229+
"unblock_tasks": unblock_tasks,
11751230
"role_contract_summary": contract_preview.get("role_contract") if isinstance(contract_preview.get("role_contract"), dict) else {},
11761231
"contract_preview": contract_preview,
11771232
}
11781233
self._validator.validate_report(response["wave_plan"], "wave_plan.v1.json")
1179-
for contract in response["worker_prompt_contracts"]:
1234+
for contract in worker_prompt_contracts:
11801235
self._validator.validate_report(contract, "worker_prompt_contract.v1.json")
1236+
for unblock_task in unblock_tasks:
1237+
self._validator.validate_report(unblock_task, "unblock_task.v1.json")
11811238
self._validator.validate_report(response, "execution_plan_report.v1.json")
11821239
return response
11831240

apps/orchestrator/tests/test_intake_preview_helpers.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,9 @@ def test_intake_preview_builds_execution_plan_report(monkeypatch) -> None:
9696
assert report["wave_plan"]["worker_count"] == 1
9797
assert report["worker_prompt_contracts"][0]["prompt_contract_id"] == "plan-preview-1"
9898
assert report["worker_prompt_contracts"][0]["continuation_policy"]["on_blocked"] == "spawn_independent_temporary_unblock_task"
99+
assert report["unblock_tasks"][0]["source_prompt_contract_id"] == "plan-preview-1"
100+
assert report["unblock_tasks"][0]["trigger"] == "spawn_independent_temporary_unblock_task"
101+
assert "planning_unblock_tasks.json" in report["predicted_artifacts"]
99102

100103

101104
def test_intake_preview_marks_manual_approval_when_env_requires_it(monkeypatch) -> None:
@@ -178,3 +181,4 @@ def test_intake_preview_marks_manual_approval_when_env_requires_it(monkeypatch)
178181
assert report["warnings"]
179182
assert report["wave_plan"]["completion_policy_ref"].endswith("#/wave_completion_policy")
180183
assert report["worker_prompt_contracts"][0]["verification_requirements"] == ["repo_hygiene"]
184+
assert report["unblock_tasks"][0]["owner"] == "L0"

apps/orchestrator/tests/test_main_pm_intake_helpers_branches.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -564,16 +564,26 @@ def execute_task(contract_path: Path, mock_mode: bool = False) -> str:
564564
worker_contracts = json.loads(
565565
(runs_root / run_id / "artifacts" / "planning_worker_prompt_contracts.json").read_text(encoding="utf-8")
566566
)
567+
unblock_tasks = json.loads(
568+
(runs_root / run_id / "artifacts" / "planning_unblock_tasks.json").read_text(encoding="utf-8")
569+
)
567570
manifest = json.loads((runs_root / run_id / "manifest.json").read_text(encoding="utf-8"))
568571

569-
assert result["planning_artifacts"] == ["planning_wave_plan.json", "planning_worker_prompt_contracts.json"]
572+
assert result["planning_artifacts"] == [
573+
"planning_wave_plan.json",
574+
"planning_worker_prompt_contracts.json",
575+
"planning_unblock_tasks.json",
576+
]
570577
assert wave_plan["wave_id"] == "bundle-1"
571578
assert wave_plan["objective"] == "Ship one planning artifact bridge"
572579
assert worker_contracts[0]["prompt_contract_id"] == "worker-1"
573580
assert worker_contracts[0]["continuation_policy"]["on_blocked"] == "spawn_independent_temporary_unblock_task"
581+
assert unblock_tasks[0]["source_prompt_contract_id"] == "worker-1"
582+
assert unblock_tasks[0]["owner"] == "L0"
574583
artifact_names = [item["name"] for item in manifest["artifacts"]]
575584
assert "planning_wave_plan" in artifact_names
576585
assert "planning_worker_prompt_contracts" in artifact_names
586+
assert "planning_unblock_tasks" in artifact_names
577587
assert intake_events[-1] == ("persist", {"event": "INTAKE_RUN", "run_id": run_id})
578588

579589

apps/orchestrator/tests/test_schema_validation.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,22 @@ def test_new_operator_report_and_task_pack_schemas_pass() -> None:
276276
}
277277
assert validator.validate_report(worker_prompt_contract, "worker_prompt_contract.v1.json")["prompt_contract_id"] == "worker-prompt-1"
278278

279+
unblock_task = {
280+
"version": "v1",
281+
"unblock_task_id": "unblock-worker-prompt-1",
282+
"source_prompt_contract_id": "worker-prompt-1",
283+
"objective": "Unblock the scoped worker assignment for Ship the preview artifact.",
284+
"scope_hint": "Implement the preview artifact inside apps/orchestrator/src.",
285+
"assigned_agent": {"role": "WORKER", "agent_id": "agent-1"},
286+
"owner": "L0",
287+
"mode": "independent_temporary_task",
288+
"status": "proposed",
289+
"trigger": "spawn_independent_temporary_unblock_task",
290+
"reason": "an external blocker requires an L0-managed unblock task",
291+
"verification_requirements": ["repo_hygiene"],
292+
}
293+
assert validator.validate_report(unblock_task, "unblock_task.v1.json")["unblock_task_id"] == "unblock-worker-prompt-1"
294+
279295
context_pack = {
280296
"version": "v1",
281297
"pack_id": "ctx-pack-1",

docs/architecture/runtime-topology.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ flowchart LR
5858
alongside `plan_bundle`, `task_chain`, and `contract_preview`, so operator
5959
planning surfaces can speak in canon planner language without changing
6060
execution authority.
61+
- The same planning preview may now derive `unblock_tasks`, and run bundles may
62+
persist `planning_unblock_tasks.json` when worker continuation policy says
63+
blocked work should spawn an independent temporary unblock task.
6164
- Queue truth currently lives in `.runtime-cache/cortexpilot/queue.jsonl`; API
6265
and workflow surfaces read that queue state and derive `eligible` /
6366
`sla_state` instead of storing a second scheduler database.

docs/specs/00_SPEC.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,17 @@
342342
- `harness_request` represents a proposed capability change; applying that
343343
change still depends on policy and approval boundaries.
344344

345+
### 6.6 Unblock Task Contract
346+
347+
- `schemas/unblock_task.v1.json` defines the first-class object shape for an
348+
L0-managed independent temporary unblock assignment.
349+
- `unblock_task` is derived from worker continuation policy when
350+
`on_blocked = spawn_independent_temporary_unblock_task`.
351+
- Intake preview may surface `unblock_tasks`, and run-local planning artifacts
352+
may persist `planning_unblock_tasks.json` as an advisory planning artifact.
353+
- `unblock_task` does not replace `task_contract` as execution authority; it is
354+
a read-only control-plane object for unblock coordination.
355+
345356
---
346357

347358
## 7. State Machine

packages/frontend-shared/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ export type ExecutionPlanReport = {
229229
task_chain?: JsonValue;
230230
wave_plan?: JsonValue;
231231
worker_prompt_contracts?: JsonValue[];
232+
unblock_tasks?: JsonValue[];
232233
contract_preview: RunContract;
233234
};
234235

schemas/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
## 2026-04-12
44
- Added `control_plane_runtime_policy.v1.json` to formalize L0 command-tower runtime rules.
55
- Added `wave_plan.v1.json` and `worker_prompt_contract.v1.json` for planner preview artifacts.
6+
- Added `unblock_task.v1.json` to formalize L0-managed independent temporary unblock assignments.
67
- Added `context_pack.v1.json` and `harness_request.v1.json` to reserve first-class schema homes for explicit handoff and harness-evolution contracts.
7-
- Extended `execution_plan_report.v1.json` with `wave_plan` and `worker_prompt_contracts`.
8+
- Extended `execution_plan_report.v1.json` with `wave_plan`, `worker_prompt_contracts`, and `unblock_tasks`.
89

910
## 2026-02-04
1011
- Added `schema_registry.json` with SHA256 and size metadata for all v1 schemas.

schemas/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Machine-readable schemas for contracts, events, and policy validation.
99
- `control_plane_runtime_policy.v1.json` — machine-readable command-tower runtime constitution for L0/L1/L2, wake policy, completion governance, and harness boundaries.
1010
- `wave_plan.v1.json` — wave-level orchestration preview artifact derived from intake planning.
1111
- `worker_prompt_contract.v1.json` — worker-scoped planner artifact for scope, reading list, continuation, and verification rules.
12+
- `unblock_task.v1.json` — L0-managed independent temporary unblock assignment derived from worker continuation policy.
1213
- `context_pack.v1.json` — explicit fallback handoff contract for context-pressure and role-switch situations.
1314
- `harness_request.v1.json` — capability-evolution request contract for session-local/project-local/global harness changes.
1415
- `approval_pack.v1.json` / `incident_pack.v1.json` / `run_compare_report.v1.json` — derived operator-readable decision packs for approval, failure triage, and replay compare surfaces.

0 commit comments

Comments
 (0)