CUST-WP-0055 T05: workplan-first progress scope in sinks and resolvers

Prefer workplan_id in State Hub progress writes while dual-writing
workstream_id for wire-compat. Extend schedule_health, evidence/report
sinks, phase5 checks, k8s triage prose, and SCOPE terminology.
This commit is contained in:
tegwick 2026-07-08 20:00:35 +02:00
parent 7947961ed4
commit 69fe69041f
12 changed files with 135 additions and 30 deletions

View file

@ -10,7 +10,7 @@ Supported queries:
- workplan_index: GET {STATE_HUB_URL}/workstreams/workplan-index
- hub_inbox: GET {STATE_HUB_URL}/messages/?to_agent=hub&unread_only=true
- pending_decisions: GET {STATE_HUB_URL}/decisions/?status=open (topic_id/
workstream_id/decision_type passed through as given)
workplan_id or legacy workstream_id/decision_type passed through)
- coding_retro: latest /progress/ item with event_type=coding_retro
- daily_triage_digest: curated scalar JSON digest for daily WSJF triage
- recently_on_scope_hourly: POST {STATE_HUB_URL}/recently-on-scope/hourly
@ -199,7 +199,9 @@ def _phase5_stabilization_check(params: dict[str, Any]) -> dict[str, Any]:
}
baseline = params.get("baseline") or {}
expected_workstreams = int(baseline.get("workstreams", 640))
expected_workplan_total = int(
baseline.get("workplans", baseline.get("workstreams", 640))
)
expected_tasks = int(baseline.get("tasks", 4002))
expected_topics = int(baseline.get("topics", 14))
sweep_limit = _bounded_int(params.get("sweep_limit", 6), default=6, minimum=1, maximum=24)
@ -215,11 +217,12 @@ def _phase5_stabilization_check(params: dict[str, Any]) -> dict[str, Any]:
summary = _fetch_json("/state/summary")
totals = (summary or {}).get("totals") or {}
ws_total = int((totals.get("workstreams") or {}).get("total", -1))
workplan_totals = totals.get("workplans") or totals.get("workstreams") or {}
ws_total = int(workplan_totals.get("total", -1))
task_total = int((totals.get("tasks") or {}).get("total", -1))
topic_total = int((totals.get("topics") or {}).get("total", -1))
totals_pass = (
ws_total == expected_workstreams
ws_total == expected_workplan_total
and task_total == expected_tasks
and topic_total == expected_topics
)
@ -259,11 +262,13 @@ def _phase5_stabilization_check(params: dict[str, Any]) -> dict[str, Any]:
"health": {"pass": health_pass, "status": health.get("status") if isinstance(health, dict) else None},
"totals": {
"pass": totals_pass,
"workplans": ws_total,
"workstreams": ws_total,
"tasks": task_total,
"topics": topic_total,
"expected": {
"workstreams": expected_workstreams,
"workplans": expected_workplan_total,
"workstreams": expected_workplan_total,
"tasks": expected_tasks,
"topics": expected_topics,
},

View file

@ -10,7 +10,7 @@ from typing import Any
import httpx
from activity_core.context_resolvers.ops_inventory import _sanitize_url
from activity_core.state_hub_write import idempotency_headers
from activity_core.state_hub_write import apply_progress_scope_fields, idempotency_headers
_DEFAULT_STATE_HUB_URL = "http://127.0.0.1:8000"
_INTER_HUB_SINK_TYPES = {
@ -149,9 +149,7 @@ def _post_state_hub_progress(
"probe": compact,
},
}
for key in ("topic_id", "workstream_id", "task_id", "decision_id"):
if sink.get(key):
body[key] = sink[key]
apply_progress_scope_fields(body, sink)
resp = httpx.post(
f"{base_url}/progress/",
@ -529,7 +527,8 @@ def _phase5_summary_text(result: dict[str, Any]) -> str:
mode = "closeout" if result.get("closeout") else "daily"
return (
f"Phase 5 stabilization {mode}: {status}; "
f"totals {totals.get('workstreams', '?')}/{totals.get('tasks', '?')}/"
f"workplan totals {totals.get('workplans', totals.get('workstreams', '?'))}/"
f"{totals.get('tasks', '?')}/"
f"{totals.get('topics', '?')}; sweeps sampled={sweeps.get('sampled', 0)}; "
f"daily_triage age_h={triage.get('age_hours', '?')}"
)

View file

@ -16,7 +16,7 @@ from activity_core.runtime_paths import (
custodian_repo_root,
resolve_runtime_path,
)
from activity_core.state_hub_write import idempotency_headers
from activity_core.state_hub_write import apply_progress_scope_fields, idempotency_headers
_DEFAULT_STATE_HUB_URL = "http://127.0.0.1:8000"
@ -146,9 +146,7 @@ def _post_state_hub_progress(
body["detail"]["working_memory_status"] = report_entry.get(
"working_memory_status"
)
for key in ("topic_id", "workstream_id", "task_id", "decision_id"):
if sink.get(key):
body[key] = sink[key]
apply_progress_scope_fields(body, sink)
resp = httpx.post(
f"{base_url}/progress/",

View file

@ -139,6 +139,7 @@ def post_missed_fire_alert(
state_hub_url: str | None = None,
author: str = "activity-core",
topic_id: str | None = None,
workplan_id: str | None = None,
workstream_id: str | None = None,
timeout_seconds: float = 10.0,
) -> dict[str, Any]:
@ -174,8 +175,10 @@ def post_missed_fire_alert(
}
if topic_id:
body["topic_id"] = topic_id
if workstream_id:
body["workstream_id"] = workstream_id
scope_id = workplan_id or workstream_id
if scope_id:
body["workplan_id"] = scope_id
body["workstream_id"] = scope_id
# Dedup repeated alerts for the same missed window (same schedule + last fire).
last_fired = health.last_fired_at.isoformat() if health.last_fired_at else "none"

View file

@ -15,9 +15,26 @@ ignored by State Hub versions that do not yet honour it.
from __future__ import annotations
from typing import Any
IDEMPOTENCY_HEADER = "Idempotency-Key"
def apply_progress_scope_fields(body: dict[str, Any], source: dict[str, Any]) -> None:
"""Copy topic/workplan/task/decision scope from a sink config into a progress body.
Prefers ``workplan_id``; dual-writes ``workstream_id`` for wire-compat until
legacy-meter retires the alias.
"""
workplan_id = source.get("workplan_id") or source.get("workstream_id")
if workplan_id:
body["workplan_id"] = workplan_id
body["workstream_id"] = workplan_id
for key in ("topic_id", "task_id", "decision_id"):
if source.get(key):
body[key] = source[key]
def idempotency_key(*parts: str | None) -> str:
"""Build a stable, header-safe idempotency key from identity parts.