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:
parent
7947961ed4
commit
69fe69041f
12 changed files with 135 additions and 30 deletions
6
SCOPE.md
6
SCOPE.md
|
|
@ -55,7 +55,7 @@ The two evaluation modes:
|
||||||
scheduled datetime; event-type subscription via NATS; manual one-shot API
|
scheduled datetime; event-type subscription via NATS; manual one-shot API
|
||||||
trigger; one-shot schedule smoke tests for recurring definitions.
|
trigger; one-shot schedule smoke tests for recurring definitions.
|
||||||
- **Context resolution adapters**: repo-scoping (repository capability queries),
|
- **Context resolution adapters**: repo-scoping (repository capability queries),
|
||||||
State Hub (domain/workstream state, SBOM status, daily triage digest, coding
|
State Hub (domain/workplan state, SBOM status, daily triage digest, coding
|
||||||
retro read model), and ops inventory (bounded HTTP/HTTPS probes of a
|
retro read model), and ops inventory (bounded HTTP/HTTPS probes of a
|
||||||
non-secret service inventory). The adapter registry is extensible for other
|
non-secret service inventory). The adapter registry is extensible for other
|
||||||
sources.
|
sources.
|
||||||
|
|
@ -218,7 +218,7 @@ workplan explicitly rehomes execution responsibility.
|
||||||
One boundary nuance is now explicit: activity-core may post State Hub progress
|
One boundary nuance is now explicit: activity-core may post State Hub progress
|
||||||
events as a configured report or evidence sink. That is acceptable because it
|
events as a configured report or evidence sink. That is acceptable because it
|
||||||
records the result of an activity-core activation; it is not ownership of State
|
records the result of an activity-core activation; it is not ownership of State
|
||||||
Hub state, task lifecycle, or workstream planning.
|
Hub state, task lifecycle, or workplan planning.
|
||||||
|
|
||||||
The main drift risk is convenience creep: adding direct task tracking,
|
The main drift risk is convenience creep: adding direct task tracking,
|
||||||
project-phase state, or bespoke operational scripts because the Temporal
|
project-phase state, or bespoke operational scripts because the Temporal
|
||||||
|
|
@ -277,7 +277,7 @@ new one-off control paths.
|
||||||
- **Coordinates with**: the state hub delegates maintenance automations to
|
- **Coordinates with**: the state hub delegates maintenance automations to
|
||||||
activity-core by publishing lifecycle events or by being resolved as context.
|
activity-core by publishing lifecycle events or by being resolved as context.
|
||||||
activity-core may post progress events as report/evidence outputs, but it
|
activity-core may post progress events as report/evidence outputs, but it
|
||||||
does not own State Hub task/workstream state.
|
does not own State Hub task/workplan state.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,13 +61,13 @@ data:
|
||||||
3. `GET http://127.0.0.1:8000/messages/?to_agent=hub&unread_only=true`
|
3. `GET http://127.0.0.1:8000/messages/?to_agent=hub&unread_only=true`
|
||||||
4. `GET http://127.0.0.1:8000/state/next_steps`
|
4. `GET http://127.0.0.1:8000/state/next_steps`
|
||||||
5. `GET http://127.0.0.1:8000/workstreams/workplan-index`
|
5. `GET http://127.0.0.1:8000/workstreams/workplan-index`
|
||||||
6. Local workplan files for candidate workstreams that need closer inspection
|
6. Local workplan files for candidate workplans that need closer inspection
|
||||||
7. Read-only `git status --short` for repos named in top recommendations
|
7. Read-only `git status --short` for repos named in top recommendations
|
||||||
|
|
||||||
Optional enrichment:
|
Optional enrichment:
|
||||||
|
|
||||||
- `GET /tasks/?workplan_id=<id>` for a top-ranked workplan (legacy alias: workstream_id)
|
- `GET /tasks/?workplan_id=<id>` for a top-ranked workplan (legacy alias: workstream_id)
|
||||||
- `GET /progress/?workstream_id=<id>&limit=5` for staleness confidence
|
- `GET /progress/?workplan_id=<id>&limit=5` for staleness confidence (legacy alias: workstream_id)
|
||||||
- State Hub domain summaries through MCP when available
|
- State Hub domain summaries through MCP when available
|
||||||
|
|
||||||
## Candidate Set
|
## Candidate Set
|
||||||
|
|
@ -93,9 +93,9 @@ data:
|
||||||
| Active with no recent progress | no progress in 14 days, or 7 days for high-priority items | `revisit` |
|
| Active with no recent progress | no progress in 14 days, or 7 days for high-priority items | `revisit` |
|
||||||
| Large unstarted plan | `tasks_total >= 6`, `tasks_done == 0`, `tasks_in_progress == 0` | `split` or `revisit` |
|
| Large unstarted plan | `tasks_total >= 6`, `tasks_done == 0`, `tasks_in_progress == 0` | `split` or `revisit` |
|
||||||
| Near complete | `tasks_done / tasks_total >= 0.75` and `tasks_todo <= 3` | `close-out` or `work-next` |
|
| Near complete | `tasks_done / tasks_total >= 0.75` and `tasks_todo <= 3` | `close-out` or `work-next` |
|
||||||
| Zero parsed tasks | active or blocked workstream with `tasks_total == 0` | `needs-consistency-sync` |
|
| Zero parsed tasks | active or blocked workplan with `tasks_total == 0` | `needs-consistency-sync` |
|
||||||
| Blocked but dependency appears closed | blocked workstream with cleared dependency or empty blocked reason | `revisit` |
|
| Blocked but dependency appears closed | blocked workplan with cleared dependency or empty blocked reason | `revisit` |
|
||||||
| Open decision blocks execution | blocking decision connected to workstream/task | `needs-human` |
|
| Open decision blocks execution | blocking decision connected to workplan/task | `needs-human` |
|
||||||
| File-backed mismatch | missing index entry, stale `updated`, `needs_review`, or status disagreement | `needs-consistency-sync` |
|
| File-backed mismatch | missing index entry, stale `updated`, `needs_review`, or status disagreement | `needs-consistency-sync` |
|
||||||
| Too broad for one session | many unrelated task clusters in one workplan | `split` |
|
| Too broad for one session | many unrelated task clusters in one workplan | `split` |
|
||||||
| Safety-sensitive | security, identity, secrets, legal, external reputation, money | `needs-human` |
|
| Safety-sensitive | security, identity, secrets, legal, external reputation, money | `needs-human` |
|
||||||
|
|
@ -120,7 +120,7 @@ data:
|
||||||
- dependency-chain foundations: Railiance, identity, secrets, backup, HA
|
- dependency-chain foundations: Railiance, identity, secrets, backup, HA
|
||||||
- explicit `planning_priority: high` or low `planning_order`
|
- explicit `planning_priority: high` or low `planning_order`
|
||||||
- active repo/domain goals
|
- active repo/domain goals
|
||||||
- closing a workstream that is already consuming coordination attention
|
- closing a workplan that is already consuming coordination attention
|
||||||
|
|
||||||
### Time Criticality
|
### Time Criticality
|
||||||
|
|
||||||
|
|
@ -144,7 +144,7 @@ data:
|
||||||
|
|
||||||
Score higher when the work unlocks:
|
Score higher when the work unlocks:
|
||||||
|
|
||||||
- multiple downstream workstreams
|
- multiple downstream workplans
|
||||||
- agent autonomy or fewer repeated setup steps
|
- agent autonomy or fewer repeated setup steps
|
||||||
- reliable cross-repo operation
|
- reliable cross-repo operation
|
||||||
- future daily triage quality
|
- future daily triage quality
|
||||||
|
|
@ -201,7 +201,7 @@ data:
|
||||||
## Snapshot
|
## Snapshot
|
||||||
|
|
||||||
- Generated at: <timestamp>
|
- Generated at: <timestamp>
|
||||||
- Workstreams: <active> active, <blocked> blocked, <finished> finished
|
- Workplans: <active> active, <blocked> blocked, <finished> finished
|
||||||
- Tasks: <todo> todo, <in_progress> in progress, <blocked> blocked
|
- Tasks: <todo> todo, <in_progress> in progress, <blocked> blocked
|
||||||
- Decisions: <open> open, <escalated> escalated
|
- Decisions: <open> open, <escalated> escalated
|
||||||
- Inbox: <count> unread hub messages
|
- Inbox: <count> unread hub messages
|
||||||
|
|
@ -211,7 +211,7 @@ data:
|
||||||
|
|
||||||
| Rank | Action | Candidate | WSJF | Confidence | Why now |
|
| Rank | Action | Candidate | WSJF | Confidence | Why now |
|
||||||
|------|--------|-----------|------|------------|---------|
|
|------|--------|-----------|------|------------|---------|
|
||||||
| 1 | work-next | <workstream/task> | 0.0 | high | <short reason> |
|
| 1 | work-next | <workplan/task> | 0.0 | high | <short reason> |
|
||||||
|
|
||||||
## Loose Ends
|
## Loose Ends
|
||||||
|
|
||||||
|
|
@ -240,7 +240,7 @@ data:
|
||||||
When the State Hub API is reachable, append one progress event for the review:
|
When the State Hub API is reachable, append one progress event for the review:
|
||||||
|
|
||||||
- `topic_id`: Custodian topic id when known
|
- `topic_id`: Custodian topic id when known
|
||||||
- `workstream_id`: `99993845-be6a-401d-be98-f8107014abed`
|
- `workplan_id`: `99993845-be6a-401d-be98-f8107014abed` (legacy alias: workstream_id)
|
||||||
- `event_type`: `daily_triage`
|
- `event_type`: `daily_triage`
|
||||||
- `summary`: one sentence summarizing the top recommendation and loose-end count
|
- `summary`: one sentence summarizing the top recommendation and loose-end count
|
||||||
- `detail`: include top three recommendations, input health, and any human gates
|
- `detail`: include top three recommendations, input health, and any human gates
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ Supported queries:
|
||||||
- workplan_index: GET {STATE_HUB_URL}/workstreams/workplan-index
|
- workplan_index: GET {STATE_HUB_URL}/workstreams/workplan-index
|
||||||
- hub_inbox: GET {STATE_HUB_URL}/messages/?to_agent=hub&unread_only=true
|
- hub_inbox: GET {STATE_HUB_URL}/messages/?to_agent=hub&unread_only=true
|
||||||
- pending_decisions: GET {STATE_HUB_URL}/decisions/?status=open (topic_id/
|
- 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
|
- coding_retro: latest /progress/ item with event_type=coding_retro
|
||||||
- daily_triage_digest: curated scalar JSON digest for daily WSJF triage
|
- daily_triage_digest: curated scalar JSON digest for daily WSJF triage
|
||||||
- recently_on_scope_hourly: POST {STATE_HUB_URL}/recently-on-scope/hourly
|
- 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 {}
|
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_tasks = int(baseline.get("tasks", 4002))
|
||||||
expected_topics = int(baseline.get("topics", 14))
|
expected_topics = int(baseline.get("topics", 14))
|
||||||
sweep_limit = _bounded_int(params.get("sweep_limit", 6), default=6, minimum=1, maximum=24)
|
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")
|
summary = _fetch_json("/state/summary")
|
||||||
totals = (summary or {}).get("totals") or {}
|
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))
|
task_total = int((totals.get("tasks") or {}).get("total", -1))
|
||||||
topic_total = int((totals.get("topics") or {}).get("total", -1))
|
topic_total = int((totals.get("topics") or {}).get("total", -1))
|
||||||
totals_pass = (
|
totals_pass = (
|
||||||
ws_total == expected_workstreams
|
ws_total == expected_workplan_total
|
||||||
and task_total == expected_tasks
|
and task_total == expected_tasks
|
||||||
and topic_total == expected_topics
|
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},
|
"health": {"pass": health_pass, "status": health.get("status") if isinstance(health, dict) else None},
|
||||||
"totals": {
|
"totals": {
|
||||||
"pass": totals_pass,
|
"pass": totals_pass,
|
||||||
|
"workplans": ws_total,
|
||||||
"workstreams": ws_total,
|
"workstreams": ws_total,
|
||||||
"tasks": task_total,
|
"tasks": task_total,
|
||||||
"topics": topic_total,
|
"topics": topic_total,
|
||||||
"expected": {
|
"expected": {
|
||||||
"workstreams": expected_workstreams,
|
"workplans": expected_workplan_total,
|
||||||
|
"workstreams": expected_workplan_total,
|
||||||
"tasks": expected_tasks,
|
"tasks": expected_tasks,
|
||||||
"topics": expected_topics,
|
"topics": expected_topics,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from typing import Any
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from activity_core.context_resolvers.ops_inventory import _sanitize_url
|
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"
|
_DEFAULT_STATE_HUB_URL = "http://127.0.0.1:8000"
|
||||||
_INTER_HUB_SINK_TYPES = {
|
_INTER_HUB_SINK_TYPES = {
|
||||||
|
|
@ -149,9 +149,7 @@ def _post_state_hub_progress(
|
||||||
"probe": compact,
|
"probe": compact,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for key in ("topic_id", "workstream_id", "task_id", "decision_id"):
|
apply_progress_scope_fields(body, sink)
|
||||||
if sink.get(key):
|
|
||||||
body[key] = sink[key]
|
|
||||||
|
|
||||||
resp = httpx.post(
|
resp = httpx.post(
|
||||||
f"{base_url}/progress/",
|
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"
|
mode = "closeout" if result.get("closeout") else "daily"
|
||||||
return (
|
return (
|
||||||
f"Phase 5 stabilization {mode}: {status}; "
|
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"{totals.get('topics', '?')}; sweeps sampled={sweeps.get('sampled', 0)}; "
|
||||||
f"daily_triage age_h={triage.get('age_hours', '?')}"
|
f"daily_triage age_h={triage.get('age_hours', '?')}"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ from activity_core.runtime_paths import (
|
||||||
custodian_repo_root,
|
custodian_repo_root,
|
||||||
resolve_runtime_path,
|
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"
|
_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(
|
body["detail"]["working_memory_status"] = report_entry.get(
|
||||||
"working_memory_status"
|
"working_memory_status"
|
||||||
)
|
)
|
||||||
for key in ("topic_id", "workstream_id", "task_id", "decision_id"):
|
apply_progress_scope_fields(body, sink)
|
||||||
if sink.get(key):
|
|
||||||
body[key] = sink[key]
|
|
||||||
|
|
||||||
resp = httpx.post(
|
resp = httpx.post(
|
||||||
f"{base_url}/progress/",
|
f"{base_url}/progress/",
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,7 @@ def post_missed_fire_alert(
|
||||||
state_hub_url: str | None = None,
|
state_hub_url: str | None = None,
|
||||||
author: str = "activity-core",
|
author: str = "activity-core",
|
||||||
topic_id: str | None = None,
|
topic_id: str | None = None,
|
||||||
|
workplan_id: str | None = None,
|
||||||
workstream_id: str | None = None,
|
workstream_id: str | None = None,
|
||||||
timeout_seconds: float = 10.0,
|
timeout_seconds: float = 10.0,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
|
@ -174,8 +175,10 @@ def post_missed_fire_alert(
|
||||||
}
|
}
|
||||||
if topic_id:
|
if topic_id:
|
||||||
body["topic_id"] = topic_id
|
body["topic_id"] = topic_id
|
||||||
if workstream_id:
|
scope_id = workplan_id or workstream_id
|
||||||
body["workstream_id"] = 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).
|
# 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"
|
last_fired = health.last_fired_at.isoformat() if health.last_fired_at else "none"
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,26 @@ ignored by State Hub versions that do not yet honour it.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
IDEMPOTENCY_HEADER = "Idempotency-Key"
|
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:
|
def idempotency_key(*parts: str | None) -> str:
|
||||||
"""Build a stable, header-safe idempotency key from identity parts.
|
"""Build a stable, header-safe idempotency key from identity parts.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@ def test_state_hub_progress_sink_posts_compact_probe_summary(monkeypatch) -> Non
|
||||||
]
|
]
|
||||||
body = posts[0]["json"]
|
body = posts[0]["json"]
|
||||||
assert body["summary"] == "Ops inventory probe: 1 ok, 0 degraded, 0 down, 1 skipped"
|
assert body["summary"] == "Ops inventory probe: 1 ok, 0 degraded, 0 down, 1 skipped"
|
||||||
|
assert body["workplan_id"] == "workstream-1"
|
||||||
assert body["workstream_id"] == "workstream-1"
|
assert body["workstream_id"] == "workstream-1"
|
||||||
assert body["task_id"] == "task-1"
|
assert body["task_id"] == "task-1"
|
||||||
assert body["detail"]["activity_core_run_id"] == _run_id()
|
assert body["detail"]["activity_core_run_id"] == _run_id()
|
||||||
|
|
@ -136,6 +137,35 @@ def test_state_hub_progress_sink_posts_compact_probe_summary(monkeypatch) -> Non
|
||||||
assert "token=secret" not in serialized
|
assert "token=secret" not in serialized
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_hub_progress_prefers_workplan_id(monkeypatch) -> None:
|
||||||
|
posts: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
||||||
|
return DummyResponse([])
|
||||||
|
|
||||||
|
def fake_post(url: str, **kwargs: Any) -> DummyResponse:
|
||||||
|
posts.append({"url": url, **kwargs})
|
||||||
|
return DummyResponse({"id": "progress-2"})
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "get", fake_get)
|
||||||
|
monkeypatch.setattr(httpx, "post", fake_post)
|
||||||
|
|
||||||
|
persist_ops_inventory_evidence(
|
||||||
|
_payload([
|
||||||
|
{
|
||||||
|
"type": "state-hub-progress",
|
||||||
|
"state_hub_url": "http://state-hub.test",
|
||||||
|
"event_type": "ops_inventory_probe",
|
||||||
|
"workplan_id": "workplan-1",
|
||||||
|
}
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
|
body = posts[0]["json"]
|
||||||
|
assert body["workplan_id"] == "workplan-1"
|
||||||
|
assert body["workstream_id"] == "workplan-1"
|
||||||
|
|
||||||
|
|
||||||
def test_core_hub_stabilization_sink_posts_progress(monkeypatch) -> None:
|
def test_core_hub_stabilization_sink_posts_progress(monkeypatch) -> None:
|
||||||
posts: list[dict[str, Any]] = []
|
posts: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@ def test_state_hub_progress_sink_posts(monkeypatch) -> None:
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
assert posts[0]["url"] == "http://state-hub.test/progress/"
|
assert posts[0]["url"] == "http://state-hub.test/progress/"
|
||||||
|
assert posts[0]["json"]["workplan_id"] == "workstream-1"
|
||||||
assert posts[0]["json"]["workstream_id"] == "workstream-1"
|
assert posts[0]["json"]["workstream_id"] == "workstream-1"
|
||||||
assert posts[0]["json"]["detail"]["activity_core_run_id"] == payload_run_id()
|
assert posts[0]["json"]["detail"]["activity_core_run_id"] == payload_run_id()
|
||||||
assert posts[0]["json"]["detail"]["output_validated"] is True
|
assert posts[0]["json"]["detail"]["output_validated"] is True
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from activity_core.schedule_health import evaluate_schedule_health
|
import httpx
|
||||||
|
|
||||||
|
from activity_core.schedule_health import evaluate_schedule_health, post_missed_fire_alert
|
||||||
|
|
||||||
NOW = datetime(2026, 6, 23, 12, 0, tzinfo=timezone.utc)
|
NOW = datetime(2026, 6, 23, 12, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
@ -70,6 +72,35 @@ def test_no_fire_recorded_for_due_schedule_is_unhealthy() -> None:
|
||||||
assert "no recorded fire" in health.reasons[0]
|
assert "no recorded fire" in health.reasons[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_missed_fire_alert_dual_writes_workplan_scope(monkeypatch) -> None:
|
||||||
|
posts: list[dict] = []
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
def raise_for_status(self) -> None: ...
|
||||||
|
def json(self) -> dict[str, str]:
|
||||||
|
return {"id": "progress-1"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "post", lambda url, **kwargs: posts.append(kwargs) or _Resp())
|
||||||
|
|
||||||
|
health = evaluate_schedule_health(
|
||||||
|
activity_id="daily",
|
||||||
|
missed_catchup_window=1,
|
||||||
|
last_fired_at=NOW - timedelta(days=2),
|
||||||
|
now=NOW,
|
||||||
|
expected_interval=timedelta(days=1),
|
||||||
|
)
|
||||||
|
result = post_missed_fire_alert(
|
||||||
|
health,
|
||||||
|
state_hub_url="http://state-hub.test",
|
||||||
|
workplan_id="wp-123",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "posted"
|
||||||
|
body = posts[0]["json"]
|
||||||
|
assert body["workplan_id"] == "wp-123"
|
||||||
|
assert body["workstream_id"] == "wp-123"
|
||||||
|
|
||||||
|
|
||||||
def test_no_interval_and_no_fire_is_not_flagged() -> None:
|
def test_no_interval_and_no_fire_is_not_flagged() -> None:
|
||||||
# Without an expected interval we cannot assert a miss from absence alone.
|
# Without an expected interval we cannot assert a miss from absence alone.
|
||||||
health = evaluate_schedule_health(
|
health = evaluate_schedule_health(
|
||||||
|
|
|
||||||
|
|
@ -751,3 +751,5 @@ def test_phase5_stabilization_check_passes(monkeypatch) -> None:
|
||||||
assert result["overall_pass"] is True
|
assert result["overall_pass"] is True
|
||||||
assert result["skipped"] is False
|
assert result["skipped"] is False
|
||||||
assert result["checks"]["totals"]["pass"] is True
|
assert result["checks"]["totals"]["pass"] is True
|
||||||
|
assert result["checks"]["totals"]["workplans"] == 640
|
||||||
|
assert result["checks"]["totals"]["workstreams"] == 640
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import pytest
|
||||||
from activity_core import report_sinks
|
from activity_core import report_sinks
|
||||||
from activity_core.state_hub_write import (
|
from activity_core.state_hub_write import (
|
||||||
IDEMPOTENCY_HEADER,
|
IDEMPOTENCY_HEADER,
|
||||||
|
apply_progress_scope_fields,
|
||||||
idempotency_headers,
|
idempotency_headers,
|
||||||
idempotency_key,
|
idempotency_key,
|
||||||
)
|
)
|
||||||
|
|
@ -33,6 +34,24 @@ def test_headers_carry_the_key() -> None:
|
||||||
assert headers == {IDEMPOTENCY_HEADER: "run1:i:e"}
|
assert headers == {IDEMPOTENCY_HEADER: "run1:i:e"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_progress_scope_prefers_workplan_id() -> None:
|
||||||
|
body: dict[str, str] = {}
|
||||||
|
apply_progress_scope_fields(
|
||||||
|
body,
|
||||||
|
{"workplan_id": "wp-1", "workstream_id": "ws-legacy", "topic_id": "topic-1"},
|
||||||
|
)
|
||||||
|
assert body["workplan_id"] == "wp-1"
|
||||||
|
assert body["workstream_id"] == "wp-1"
|
||||||
|
assert body["topic_id"] == "topic-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_progress_scope_dual_writes_legacy_workstream_id() -> None:
|
||||||
|
body: dict[str, str] = {}
|
||||||
|
apply_progress_scope_fields(body, {"workstream_id": "ws-1"})
|
||||||
|
assert body["workplan_id"] == "ws-1"
|
||||||
|
assert body["workstream_id"] == "ws-1"
|
||||||
|
|
||||||
|
|
||||||
def test_distinct_identities_get_distinct_keys() -> None:
|
def test_distinct_identities_get_distinct_keys() -> None:
|
||||||
assert idempotency_key("r", "i", "daily_triage") != idempotency_key(
|
assert idempotency_key("r", "i", "daily_triage") != idempotency_key(
|
||||||
"r", "i", "schedule_miss"
|
"r", "i", "schedule_miss"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue