|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
|
|
|
import asyncio
|
|
|
import json
|
|
|
+import re
|
|
|
import secrets
|
|
|
from collections import Counter, defaultdict
|
|
|
from collections.abc import Mapping, Sequence
|
|
|
@@ -29,6 +30,13 @@ from script_build_host.domain.context_broker import (
|
|
|
paginate_context_cards,
|
|
|
rank_context_cards,
|
|
|
)
|
|
|
+from script_build_host.domain.errors import ScriptBuildError
|
|
|
+from script_build_host.domain.phase_protocol import (
|
|
|
+ PhaseBoundaryProtocol,
|
|
|
+ PhaseProtocolSnapshot,
|
|
|
+ PhaseTaskProtocol,
|
|
|
+ allowed_child_kinds,
|
|
|
+)
|
|
|
from script_build_host.domain.phase_two_ports import ScriptTaskContractStore
|
|
|
from script_build_host.domain.ports import (
|
|
|
InputSnapshotRepository,
|
|
|
@@ -62,12 +70,12 @@ _ACTIVE = {
|
|
|
TaskStatus.WAITING_CHILDREN,
|
|
|
TaskStatus.BLOCKED,
|
|
|
}
|
|
|
+_SEMANTIC_HANDLE = re.compile(r"^[a-z][a-z0-9_]*_[0-9a-f]{20}$")
|
|
|
|
|
|
|
|
|
-class WorkbenchError(RuntimeError):
|
|
|
+class WorkbenchError(ScriptBuildError):
|
|
|
def __init__(self, code: str, message: str) -> None:
|
|
|
- super().__init__(message)
|
|
|
- self.code = code
|
|
|
+ super().__init__(code, message)
|
|
|
self.message = message
|
|
|
|
|
|
|
|
|
@@ -145,6 +153,9 @@ class ContextBroker:
|
|
|
if known_revision == revision:
|
|
|
return NotModified(revision)
|
|
|
binding = await self._bindings.get_by_root(root_trace_id)
|
|
|
+ snapshot = await self._snapshots.get(
|
|
|
+ str(binding.input_snapshot_id), script_build_id=binding.script_build_id
|
|
|
+ )
|
|
|
counts = Counter(task.status.value for task in ledger.tasks.values())
|
|
|
included = self._active_subgraph_ids(ledger)
|
|
|
active_tasks: list[dict[str, Any]] = []
|
|
|
@@ -205,20 +216,42 @@ class ContextBroker:
|
|
|
accepted_cards,
|
|
|
" ".join((root.current_spec.objective, *(item for item in all_goals))),
|
|
|
)
|
|
|
- selected_accepted, used_tokens, omitted = fit_context_cards(ranked_accepted, 3_000)
|
|
|
+ selected_accepted, _accepted_tokens, omitted = fit_context_cards(ranked_accepted, 3_000)
|
|
|
+ input_cards = self._input_cards(snapshot)
|
|
|
+ topic_card = next(
|
|
|
+ (card for card in input_cards if card.source_type == "topic"),
|
|
|
+ input_cards[0] if input_cards else None,
|
|
|
+ )
|
|
|
+ selected_inputs = (
|
|
|
+ ()
|
|
|
+ if topic_card is None
|
|
|
+ else (
|
|
|
+ replace(
|
|
|
+ topic_card,
|
|
|
+ summary=topic_card.summary[:160],
|
|
|
+ excerpt="",
|
|
|
+ metadata={**dict(topic_card.metadata), "detail_required": True},
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ )
|
|
|
+ input_tokens = sum(estimate_context_tokens(card.to_payload()) for card in selected_inputs)
|
|
|
+ input_omitted = len(input_cards) - len(selected_inputs)
|
|
|
+ input_source_types = tuple(dict.fromkeys(card.source_type for card in input_cards))
|
|
|
bundle = ContextBundle(
|
|
|
revision=revision,
|
|
|
- cards=selected_accepted,
|
|
|
+ cards=selected_inputs,
|
|
|
required_handles=(),
|
|
|
sufficiency=assess_context_sufficiency(
|
|
|
- required_source_types=(), cards=selected_accepted, has_more=bool(omitted)
|
|
|
+ required_source_types=input_source_types,
|
|
|
+ cards=selected_inputs,
|
|
|
+ has_more=bool(input_omitted),
|
|
|
),
|
|
|
receipt=ContextReceipt(
|
|
|
revision=revision,
|
|
|
- candidate_count=len(accepted_cards),
|
|
|
- selected_count=len(selected_accepted),
|
|
|
- estimated_tokens=used_tokens,
|
|
|
- omitted_count=omitted,
|
|
|
+ candidate_count=len(input_cards),
|
|
|
+ selected_count=len(selected_inputs),
|
|
|
+ estimated_tokens=input_tokens,
|
|
|
+ omitted_count=input_omitted,
|
|
|
),
|
|
|
)
|
|
|
await self._emit_receipt(
|
|
|
@@ -226,12 +259,13 @@ class ContextBroker:
|
|
|
role="planner",
|
|
|
task_kind="root",
|
|
|
receipt=bundle.receipt,
|
|
|
- has_more=bool(omitted),
|
|
|
+ has_more=bool(input_omitted),
|
|
|
)
|
|
|
selected_active = tuple(active_tasks[:12])
|
|
|
+ phase = _phase(ledger, root, binding.active_direction_artifact_version_id)
|
|
|
state = PlannerState(
|
|
|
state_revision=revision,
|
|
|
- phase=_phase(ledger, root, binding.active_direction_artifact_version_id),
|
|
|
+ phase=phase,
|
|
|
root={
|
|
|
"task_id": root.task_id,
|
|
|
"status": root.status.value,
|
|
|
@@ -257,13 +291,93 @@ class ContextBroker:
|
|
|
active_has_more=len(active_tasks) > len(selected_active),
|
|
|
accepted_total=len(accepted_cards),
|
|
|
accepted_has_more=bool(omitted),
|
|
|
- context_bundle=_bundle_metadata(bundle),
|
|
|
+ context_bundle={
|
|
|
+ **_bundle_metadata(bundle),
|
|
|
+ "cards": [_compact_card(card) for card in bundle.cards],
|
|
|
+ },
|
|
|
+ phase_protocol=(await self._phase_protocol(ledger, phase)).to_payload(),
|
|
|
)
|
|
|
_bounded_context(
|
|
|
state.to_payload(), char_limit=24_000, token_limit=6_000, label="PlannerState"
|
|
|
)
|
|
|
return state
|
|
|
|
|
|
+ async def _phase_protocol(self, ledger: Any, phase: int) -> PhaseProtocolSnapshot:
|
|
|
+ root = ledger.tasks[ledger.root_task_id]
|
|
|
+ current = ledger.tasks.get(ledger.focused_task_id or "") or root
|
|
|
+ current_is_root = current.task_id == ledger.root_task_id
|
|
|
+ current_kind_value = (
|
|
|
+ None
|
|
|
+ if current_is_root
|
|
|
+ else task_kind_from_context_refs(current.current_spec.context_refs)
|
|
|
+ )
|
|
|
+ current_kind = ScriptTaskKind(current_kind_value) if current_kind_value else None
|
|
|
+ selectable: dict[str, list[str]] = defaultdict(list)
|
|
|
+ for child_id in current.child_task_ids:
|
|
|
+ child = ledger.tasks.get(child_id)
|
|
|
+ if child is None:
|
|
|
+ continue
|
|
|
+ decision = self._accepted_decision(ledger, child)
|
|
|
+ child_kind = task_kind_from_context_refs(child.current_spec.context_refs)
|
|
|
+ if decision is not None and child_kind:
|
|
|
+ selectable[child_kind].append(decision.decision_id)
|
|
|
+ actions: tuple[str, ...]
|
|
|
+ if current_is_root:
|
|
|
+ actions = ("plan", "submit_boundary")
|
|
|
+ elif current.status is TaskStatus.AWAITING_DECISION:
|
|
|
+ actions = ("validate", "accept", "repair", "retry", "revise", "split", "block")
|
|
|
+ elif current.status in {TaskStatus.NEEDS_REPLAN, TaskStatus.BLOCKED}:
|
|
|
+ actions = ("retry", "revise", "split", "block", "cancel")
|
|
|
+ else:
|
|
|
+ actions = ("plan", "dispatch", "inspect")
|
|
|
+ if phase == 1:
|
|
|
+ boundary_reason = "PHASE_ONE_CAPABILITY_BOUNDARY"
|
|
|
+ ready = any(
|
|
|
+ self._accepted_decision(ledger, item) is not None
|
|
|
+ and task_kind_from_context_refs(item.current_spec.context_refs)
|
|
|
+ == ScriptTaskKind.DIRECTION.value
|
|
|
+ for item in ledger.tasks.values()
|
|
|
+ )
|
|
|
+ missing = () if ready else ("accepted_direction",)
|
|
|
+ elif phase == 2:
|
|
|
+ boundary_reason = "PHASE_TWO_CANDIDATE_PORTFOLIO_READY"
|
|
|
+ ready = any(
|
|
|
+ self._accepted_decision(ledger, item) is not None
|
|
|
+ and task_kind_from_context_refs(item.current_spec.context_refs)
|
|
|
+ == ScriptTaskKind.CANDIDATE_PORTFOLIO.value
|
|
|
+ for item in ledger.tasks.values()
|
|
|
+ )
|
|
|
+ missing = () if ready else ("accepted_candidate_portfolio",)
|
|
|
+ else:
|
|
|
+ boundary_reason = "PHASE_THREE_ROOT_DELIVERY_READY"
|
|
|
+ ready = any(
|
|
|
+ self._accepted_decision(ledger, item) is not None
|
|
|
+ and task_kind_from_context_refs(item.current_spec.context_refs)
|
|
|
+ == ScriptTaskKind.ROOT_DELIVERY.value
|
|
|
+ for item in ledger.tasks.values()
|
|
|
+ )
|
|
|
+ missing = () if ready else ("accepted_root_delivery",)
|
|
|
+ return PhaseProtocolSnapshot(
|
|
|
+ phase=phase,
|
|
|
+ phase_epoch=f"script-build-phase-{phase}",
|
|
|
+ topology_version="v1",
|
|
|
+ current_task=PhaseTaskProtocol(
|
|
|
+ task_id=current.task_id,
|
|
|
+ task_kind=current_kind.value if current_kind is not None else "root",
|
|
|
+ allowed_child_kinds=tuple(
|
|
|
+ sorted(
|
|
|
+ item.value
|
|
|
+ for item in allowed_child_kinds(phase=phase, parent_kind=current_kind)
|
|
|
+ )
|
|
|
+ ),
|
|
|
+ valid_actions=actions,
|
|
|
+ selectable_decisions={
|
|
|
+ kind: tuple(sorted(values)) for kind, values in sorted(selectable.items())
|
|
|
+ },
|
|
|
+ ),
|
|
|
+ boundary=PhaseBoundaryProtocol(boundary_reason, ready, missing),
|
|
|
+ )
|
|
|
+
|
|
|
async def worker_state(self, request: RoleContextRequest) -> WorkerState:
|
|
|
ledger = await self._task_store.load(request.root_trace_id)
|
|
|
task = ledger.tasks[request.task_id]
|
|
|
@@ -857,6 +971,14 @@ class ContextBroker:
|
|
|
omitted_count=max(0, len(filtered) - page.offset - len(page.items)),
|
|
|
),
|
|
|
has_more=page.has_more,
|
|
|
+ progress_key=_digest(
|
|
|
+ {
|
|
|
+ "operation": "search",
|
|
|
+ "filter": request.filter_digest(),
|
|
|
+ "cursor": cursor or "start",
|
|
|
+ "revision": revision,
|
|
|
+ }
|
|
|
+ ),
|
|
|
)
|
|
|
_bounded_json(payload, 8_000, "Context search page")
|
|
|
_bounded_tokens(payload, request.token_budget, "Context search page")
|
|
|
@@ -875,6 +997,12 @@ class ContextBroker:
|
|
|
) -> Mapping[str, Any]:
|
|
|
"""Read one authorized immutable source through a bounded page."""
|
|
|
|
|
|
+ if not _SEMANTIC_HANDLE.fullmatch(handle):
|
|
|
+ raise WorkbenchError(
|
|
|
+ "CONTEXT_HANDLE_FORMAT_INVALID",
|
|
|
+ "use a semantic handle returned by get_current_context or search_mission_context",
|
|
|
+ )
|
|
|
+
|
|
|
ledger = await self._task_store.load(root_trace_id)
|
|
|
binding = await self._bindings.get_by_root(root_trace_id)
|
|
|
snapshot = await self._snapshots.get(
|
|
|
@@ -894,7 +1022,7 @@ class ContextBroker:
|
|
|
)
|
|
|
if handle not in authorized_handles:
|
|
|
raise WorkbenchError(
|
|
|
- "CONTEXT_HANDLE_INVALID", "context handle is stale or unauthorized"
|
|
|
+ "CONTEXT_HANDLE_UNAUTHORIZED", "context handle is outside this role and Task"
|
|
|
)
|
|
|
authorized_ref = self._authorized_artifact_refs.get(
|
|
|
(root_trace_id, task_id, attempt_id or ""), {}
|
|
|
@@ -1018,6 +1146,9 @@ class ContextBroker:
|
|
|
detail_page_key=_digest(
|
|
|
{"handle": handle, "section": section, "start": start, "revision": revision}
|
|
|
),
|
|
|
+ progress_key=_digest(
|
|
|
+ {"operation": "read", "handle": handle, "section": section, "start": start}
|
|
|
+ ),
|
|
|
)
|
|
|
_bounded_json(payload, 8_000, "Context detail page")
|
|
|
return payload
|
|
|
@@ -1316,7 +1447,7 @@ class ContextBroker:
|
|
|
):
|
|
|
if card.handle == handle:
|
|
|
return value, revision
|
|
|
- raise WorkbenchError("CONTEXT_HANDLE_INVALID", "context handle is stale or unauthorized")
|
|
|
+ raise WorkbenchError("CONTEXT_HANDLE_UNKNOWN", "context handle is stale or unknown")
|
|
|
|
|
|
async def _authorize_cards(
|
|
|
self,
|
|
|
@@ -1507,6 +1638,7 @@ class ContextBroker:
|
|
|
receipt: ContextReceipt,
|
|
|
has_more: bool,
|
|
|
detail_page_key: str | None = None,
|
|
|
+ progress_key: str | None = None,
|
|
|
) -> None:
|
|
|
append = getattr(self._receipt_store, "append_event", None)
|
|
|
if not callable(append):
|
|
|
@@ -1528,9 +1660,39 @@ class ContextBroker:
|
|
|
"omitted_count": receipt.omitted_count,
|
|
|
"detail_reads": receipt.detail_reads,
|
|
|
"detail_page_key": detail_page_key,
|
|
|
+ "progress_key": progress_key or detail_page_key,
|
|
|
},
|
|
|
)
|
|
|
|
|
|
+ async def progress_snapshot(self, root_trace_id: str) -> Mapping[str, Any]:
|
|
|
+ """Return durable, duplicate-insensitive Context Broker progress."""
|
|
|
+
|
|
|
+ get_events = getattr(self._receipt_store, "get_events", None)
|
|
|
+ if not callable(get_events):
|
|
|
+ return {}
|
|
|
+ events = await get_events(root_trace_id)
|
|
|
+ progress_keys = {
|
|
|
+ str(item.get("progress_key"))
|
|
|
+ for item in events
|
|
|
+ if item.get("event") == "context_broker_receipt" and item.get("progress_key")
|
|
|
+ }
|
|
|
+ required = {
|
|
|
+ str(key)
|
|
|
+ for item in events
|
|
|
+ if item.get("event") == "context_broker_required_context"
|
|
|
+ for key in item.get("required_source_keys") or ()
|
|
|
+ }
|
|
|
+ completed = {
|
|
|
+ str(item.get("source_key"))
|
|
|
+ for item in events
|
|
|
+ if item.get("event") == "context_broker_required_source_read"
|
|
|
+ and item.get("source_key")
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ "unique_observations": sorted(progress_keys),
|
|
|
+ "required_remaining": sorted(required - completed),
|
|
|
+ }
|
|
|
+
|
|
|
async def _record_required_context(
|
|
|
self,
|
|
|
root_trace_id: str,
|
|
|
@@ -1707,6 +1869,9 @@ class MissionWorkbench:
|
|
|
async def planner_state(self, root_trace_id: str, **kwargs: Any) -> PlannerState | NotModified:
|
|
|
return await self.broker.planner_state(root_trace_id, **kwargs)
|
|
|
|
|
|
+ async def progress_snapshot(self, root_trace_id: str) -> Mapping[str, Any]:
|
|
|
+ return await self.broker.progress_snapshot(root_trace_id)
|
|
|
+
|
|
|
async def worker_state(self, request: RoleContextRequest) -> WorkerState:
|
|
|
return await self.broker.worker_state(request)
|
|
|
|
|
|
@@ -2055,7 +2220,15 @@ def _phase(ledger: Any, root: Any, active_direction_id: int | None) -> int:
|
|
|
for item in ledger.tasks.values()
|
|
|
):
|
|
|
return 3
|
|
|
- return 2 if active_direction_id is not None else 1
|
|
|
+ if any(
|
|
|
+ task_kind_from_context_refs(item.current_spec.context_refs)
|
|
|
+ == ScriptTaskKind.CANDIDATE_PORTFOLIO.value
|
|
|
+ for item in ledger.tasks.values()
|
|
|
+ ):
|
|
|
+ return 2
|
|
|
+ if active_direction_id is not None and root.status is not TaskStatus.WAITING_CHILDREN:
|
|
|
+ return 2
|
|
|
+ return 1
|
|
|
|
|
|
|
|
|
def _scope_selector(scope_ref: str) -> dict[str, Any]:
|