CUST-WP-0075: reconcile owner waits and decision routing
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a10391-6355-7b41-b0fa-3e14e874f091
This commit is contained in:
parent
d61ba327fa
commit
831ae0e598
3 changed files with 233 additions and 3 deletions
|
|
@ -42,7 +42,7 @@ Checks:
|
|||
C-36 work-record-flavor-unknown WARN No flavor is set but not in the closed list
|
||||
C-37 residual-provenance-missing WARN No flavor: residual without origin/origin_ref
|
||||
C-38 task-flavor-drift WARN Yes task flavor differs between file and DB (file wins)
|
||||
C-39 task-wait-unqualified WARN No wait task has none of task-block depends_on, needs_human: true, blocked_on: message-from:<agent>, wait_until: YYYY-MM-DD (CUST-WP-0074 rules 1/6)
|
||||
C-39 task-wait-unqualified WARN No unrouted human wait (open non-residual) or wait task has none of task-block depends_on, needs_human: true, blocked_on: message-from:<agent>, wait_until: YYYY-MM-DD (CUST-WP-0074 rules 1/6)
|
||||
C-40 task-wait-blocker-satisfied WARN No wait task whose depends_on targets are all terminal, whose decision_id is resolved, or whose wait_until has passed (CUST-WP-0074 rules 3/4)
|
||||
C-41 task-wait-qualifier-drift WARN Yes needs_human/blocking_reason/decision_id/blocked_on/wait_until differ between file and DB (file wins)
|
||||
C-42 workplan-blocked-no-wait WARN No blocked workplan has no task in status wait (CUST-WP-0074-T06)
|
||||
|
|
@ -2082,6 +2082,21 @@ def check_repo(
|
|||
file_value=t_status,
|
||||
fixable=False,
|
||||
)
|
||||
# CUST-WP-0075 P1: human gates belong in the decision queue.
|
||||
if (
|
||||
wait_fields["needs_human"] and not wait_fields["decision_id"]
|
||||
and not is_residual_flavor(meta.get("flavor"))
|
||||
):
|
||||
report.add(
|
||||
severity="WARN", check_id="C-39",
|
||||
message=(
|
||||
f"Human wait task '{t_id}' in workplan '{meta.get('id', fname)}' "
|
||||
"has needs_human: true without decision_id — unrouted human gate"
|
||||
),
|
||||
file_path=f"{fname}#{t_id}",
|
||||
file_value="needs_human: true",
|
||||
fixable=False,
|
||||
)
|
||||
# C-40: rule 3 for a wait window — the date has passed (UTC today).
|
||||
if wait_until and _date.fromisoformat(wait_until) < datetime.now(timezone.utc).date():
|
||||
report.add(
|
||||
|
|
@ -2814,6 +2829,61 @@ def _check_blocked_unblock_sweep(
|
|||
)
|
||||
|
||||
|
||||
# Task-level returns can clear a wait even after somebody read the reply.
|
||||
# Preserve the legacy workplan check above; task findings carry stable IDs
|
||||
# for an owner runner to deduplicate without changing task status here.
|
||||
messages_by_agent: dict[str, list[dict]] = {}
|
||||
for wp_file, meta, body in workplan_infos:
|
||||
if (
|
||||
normalise_workstream_status(str(meta.get("status", ""))) in CLOSED_WORKSTREAM_STATUSES
|
||||
or is_residual_flavor(meta.get("flavor"))
|
||||
):
|
||||
continue
|
||||
for task in get_tasks_from_workplan(meta, body):
|
||||
if normalise_task_status(task.get("status", "todo")) != "wait":
|
||||
continue
|
||||
counterpart = parse_blocked_on(str(task.get("blocked_on") or ""))
|
||||
if not counterpart:
|
||||
continue
|
||||
if counterpart not in messages_by_agent:
|
||||
rows = _api_get(api_base, "/messages", {
|
||||
"to_agent": repo_slug, "from_agent": counterpart,
|
||||
"unread_only": False, "limit": 200,
|
||||
}) or []
|
||||
messages_by_agent[counterpart] = rows if isinstance(rows, list) else []
|
||||
# Dated notes are the available acknowledgement watermark. Date-only
|
||||
# notes cover the whole UTC day, avoiding same-day re-notification.
|
||||
note_dates = []
|
||||
for value in re.findall(r"(?m)^\s*(?:[-*]\s+)?(?:\*\*)?(\d{4}-\d{2}-\d{2})\b", str(task.get("description") or "")):
|
||||
parsed = _parse_message_timestamp(value)
|
||||
if parsed:
|
||||
note_dates.append(parsed + timedelta(days=1))
|
||||
explicit_updated = _parse_message_timestamp(task.get("updated"))
|
||||
cutoff = max(note_dates + ([explicit_updated] if explicit_updated else []), default=None)
|
||||
hits = []
|
||||
for message in messages_by_agent[counterpart]:
|
||||
if message.get("from_agent") != counterpart or message.get("archived_at"):
|
||||
continue
|
||||
created = _parse_message_timestamp(message.get("created_at"))
|
||||
if created and (created >= cutoff if cutoff else not message.get("read_at")):
|
||||
hits.append(message)
|
||||
if not hits:
|
||||
continue
|
||||
hits.sort(key=lambda message: str(message.get("created_at", "")))
|
||||
task_id = str(task.get("id", "")).strip()
|
||||
message_ids = [str(message["id"]) for message in hits if message.get("id")]
|
||||
report.add(
|
||||
severity="WARN", check_id="C-25",
|
||||
message=(f"Owner-return task '{task_id}' waiting on {counterpart} has "
|
||||
f"{len(hits)} reply/replies after its last dated note "
|
||||
f"(or unread when undated) — owner action needed: {', '.join(message_ids)}"),
|
||||
file_path=f"{workplan_display_path(repo_dir, wp_file)}#{task_id}",
|
||||
file_value=str(task.get("blocked_on")),
|
||||
fixable=False,
|
||||
_fix_context={"task_record_id": task_id, "message_ids": message_ids},
|
||||
)
|
||||
|
||||
|
||||
def _check_inbox_hygiene(api_base: str, repo_slug: str, report: ConsistencyReport) -> None:
|
||||
hygiene = collect_inbox_hygiene(api_base, repo_slug)
|
||||
if hygiene["stale_unread_count"]:
|
||||
|
|
@ -4254,6 +4324,7 @@ def report_to_dict(report: ConsistencyReport) -> dict:
|
|||
"file_value": i.file_value,
|
||||
"db_value": i.db_value,
|
||||
"fixable": i.fixable,
|
||||
**({"owner_return": i._fix_context} if i.check_id == "C-25" and i._fix_context.get("task_record_id") else {}),
|
||||
}
|
||||
for i in report.issues
|
||||
],
|
||||
|
|
|
|||
|
|
@ -2199,7 +2199,7 @@ class TestTaskWaitQualifiers:
|
|||
repo = self._make_repo(
|
||||
tmp_path,
|
||||
"id: STATE-WP-0001-T01\nstatus: wait\npriority: high\n"
|
||||
"state_hub_task_id: \"task-1\"\nneeds_human: true\n"
|
||||
"state_hub_task_id: \"task-1\"\nneeds_human: true\ndecision_id: decision-1\n"
|
||||
"blocking_reason: \"operator must provision creds\"",
|
||||
)
|
||||
self._install(monkeypatch, self._fake_get(repo))
|
||||
|
|
@ -2314,7 +2314,7 @@ class TestTaskWaitQualifiers:
|
|||
repo = self._make_repo(
|
||||
tmp_path,
|
||||
"id: STATE-WP-0001-T01\nstatus: wait\npriority: high\n"
|
||||
"state_hub_task_id: \"task-1\"\nneeds_human: true\n"
|
||||
"state_hub_task_id: \"task-1\"\nneeds_human: true\ndecision_id: decision-1\n"
|
||||
"decision_id: CCR-2026-0004\n"
|
||||
"blocking_reason: \"operator must provision creds\"",
|
||||
extra_files={"docs/decisions.md": decision_doc},
|
||||
|
|
@ -2532,3 +2532,56 @@ class TestTaskWaitQualifiers:
|
|||
assert ("/tasks/task-1", wait_fields) in patches
|
||||
assert any("C-20 fixed" in fix for fix in report.fixes_applied)
|
||||
assert any("C-41 fixed" in fix for fix in report.fixes_applied)
|
||||
|
||||
|
||||
class TestRoutedHumanGates:
|
||||
WORKPLAN_HEAD = TestTaskWaitQualifiers.WORKPLAN_HEAD
|
||||
_make_repo = TestTaskWaitQualifiers._make_repo
|
||||
_fake_get = TestTaskWaitQualifiers._fake_get
|
||||
_install = TestTaskWaitQualifiers._install
|
||||
_issues = staticmethod(TestTaskWaitQualifiers._issues)
|
||||
@pytest.mark.parametrize("status,flavor,decision,expected", [
|
||||
("blocked", "implementation", "", 1),
|
||||
("active", "implementation", "decision-1", 0),
|
||||
("blocked", "residual", "", 0),
|
||||
("finished", "implementation", "", 0),
|
||||
("archived", "implementation", "", 0),
|
||||
])
|
||||
def test_human_gate_routing(self, tmp_path, monkeypatch, status, flavor, decision, expected):
|
||||
repo = self._make_repo(tmp_path, "id: STATE-WP-0001-T01\nstatus: wait\nneeds_human: true\n"
|
||||
+ (f"decision_id: {decision}\n" if decision else ""))
|
||||
path = repo / "workplans/STATE-WP-0001-waiting.md"
|
||||
path.write_text(path.read_text().replace("status: blocked", f"status: {status}\nflavor: {flavor}", 1))
|
||||
self._install(monkeypatch, self._fake_get(repo))
|
||||
issues = self._issues(check_repo("http://unused", "demo-repo"), "C-39")
|
||||
assert len(issues) == expected
|
||||
if issues:
|
||||
assert "without decision_id" in issues[0].message
|
||||
assert not issues[0].fixable
|
||||
|
||||
|
||||
@pytest.mark.parametrize("read_at,date,flavor,status,description,expected", [
|
||||
("2026-10-03T12:00:00Z", "2026-10-03T11:00:00Z", "implementation", "blocked", "2026-10-01: still waiting.", 1),
|
||||
(None, "2026-10-01T11:00:00Z", "implementation", "active", "2026-10-02: reply reviewed; still waiting.", 0),
|
||||
(None, "2026-10-03T11:00:00Z", "residual", "blocked", "2026-10-01: waiting.", 0),
|
||||
(None, "2026-10-03T11:00:00Z", "implementation", "finished", "2026-10-01: waiting.", 0),
|
||||
(None, "2026-10-03T11:00:00Z", "implementation", "ready", "Undated wait.", 1),
|
||||
("2026-10-03T12:00:00Z", "2026-10-03T11:00:00Z", "implementation", "ready", "Undated wait.", 0),
|
||||
])
|
||||
def test_task_owner_return_sweep(tmp_path, monkeypatch, read_at, date, flavor, status, description, expected):
|
||||
from consistency_check import _check_blocked_unblock_sweep
|
||||
|
||||
queries = []
|
||||
def fake_get(_api_base, _path, params):
|
||||
queries.append(params)
|
||||
return [{"id": "reply-1", "from_agent": "provider", "created_at": date, "read_at": read_at},
|
||||
{"id": "unrelated", "from_agent": "other", "created_at": date}]
|
||||
monkeypatch.setattr("consistency_check._api_get", fake_get)
|
||||
body = f"```task\nid: DEMO-WP-0001-T01\nstatus: wait\nblocked_on: message-from:provider\n```\n\n{description}"
|
||||
report = ConsistencyReport(repo_slug="demo", repo_path=str(tmp_path))
|
||||
_check_blocked_unblock_sweep("http://unused", "demo", tmp_path,
|
||||
[(tmp_path / "plan.md", {"status": status, "flavor": flavor}, body)], report)
|
||||
assert len(report.issues) == expected
|
||||
if expected:
|
||||
assert report.issues[0]._fix_context == {"task_record_id": "DEMO-WP-0001-T01", "message_ids": ["reply-1"]}
|
||||
assert queries[0]["unread_only"] is False
|
||||
|
|
|
|||
106
workplans/STATE-WP-0094-routed-gates-and-owner-returns.md
Normal file
106
workplans/STATE-WP-0094-routed-gates-and-owner-returns.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
---
|
||||
id: STATE-WP-0094
|
||||
type: workplan
|
||||
title: "Route human gates and expose actionable owner returns"
|
||||
domain: infotech
|
||||
repo: state-hub
|
||||
status: active
|
||||
owner: codex
|
||||
topic_slug: infotech
|
||||
created: "2026-10-03"
|
||||
updated: "2026-10-04"
|
||||
flavor: implementation
|
||||
related: [CUST-WP-0075, STATE-WP-0086, RMGR-WP-0005]
|
||||
---
|
||||
|
||||
Owner acceptance of Custodian proposal b4825539-1425-4028-a2e9-9243785a344b.
|
||||
Scope is source-level monitoring and diagnosis. No schedules, production rollout,
|
||||
human decision resolution or automatic wait-status transitions are authorized.
|
||||
|
||||
## Warn on unrouted human waits (P1)
|
||||
|
||||
```task
|
||||
id: STATE-WP-0094-T01
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Extend C-39 for needs_human waits in open non-residual workplans without
|
||||
decision_id. Routed gates, residuals and terminal workplans are excluded.
|
||||
|
||||
## Detect task-level owner replies (P5)
|
||||
|
||||
```task
|
||||
id: STATE-WP-0094-T02
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Extend C-25 to task blocked_on markers. Compare messages, including read replies,
|
||||
against the task's latest dated note; keep stable task/message IDs for deduplication.
|
||||
Undated tasks use unread replies conservatively. This detects work for owners;
|
||||
it does not awaken agents or create duplicate tasks on every checker run.
|
||||
|
||||
## Integrate deduplicated owner wakeups with the supported runner
|
||||
|
||||
```task
|
||||
id: STATE-WP-0094-T03
|
||||
status: todo
|
||||
priority: medium
|
||||
```
|
||||
|
||||
Select the current owner runner, consume C-25 task/message identities once,
|
||||
record receipts and provide an opt-in activation plan. Reuse the Custodian
|
||||
dependency monitor and established runner; do not introduce another schedule.
|
||||
Production activation remains a separately reviewed founder action.
|
||||
|
||||
## Diagnose qualifier projection parity (P6a)
|
||||
|
||||
```task
|
||||
id: STATE-WP-0094-T04
|
||||
status: todo
|
||||
priority: high
|
||||
```
|
||||
|
||||
Investigate report 70d08f35-a008-4b2c-878d-d9f32db892ce with the current editable
|
||||
CLI and direct checker from alternate CWDs. Separate missing canonical record_id
|
||||
from real UUID-addressed qualifier drift. Retain the historical report even if
|
||||
current source does not reproduce it; correct only an evidenced defect.
|
||||
|
||||
## Assess nightly graph maintenance against the Forge boundary (P6)
|
||||
|
||||
```task
|
||||
id: STATE-WP-0094-T05
|
||||
status: todo
|
||||
priority: medium
|
||||
```
|
||||
|
||||
Decline enabling the proposed legacy --all --remote schedule: production values
|
||||
explicitly disable it pending RMGR-WP-0005-T11, and ADR-002 prohibits central
|
||||
reads/writes of workstation checkouts. Confirm current Forge reconciliation
|
||||
coverage, task-level edge support and supported graph freshness schedule before
|
||||
preparing any production change. No production action in this workplan session.
|
||||
|
||||
|
||||
2026-10-04 owner assessment: P1 is implemented in C-39; P5 detection is
|
||||
implemented in C-25, with task/message IDs exposed in JSON. Owner wakeup execution
|
||||
remains T03 and is not represented as delivered by detection alone. Date-only
|
||||
notes acknowledge their full UTC day; undated waits retain unread-only behavior.
|
||||
The inbox query is bounded to 200 replies per awaited agent; a paginated runner
|
||||
must retain stable deduplication and evidence before activation.
|
||||
|
||||
P6a reproduction retained: primary snapshot for ops-warden task
|
||||
33587361-2b8a-5540-9c4f-d7b4f8ef25e0 (WARDEN-WP-0041-T03) has both blocked_on and
|
||||
blocking_reason null while its file carries message-from:railiance-platform and
|
||||
an explicit reason. A read-only current checker run narrowed to that owner plan
|
||||
emits C-41 for both fields. record_id is also null but does not hide the task from
|
||||
UUID-addressed C-41. Installed statehub resolves the same editable checkout and
|
||||
Python interpreter; its API_BASE is the direct checker's default localhost:8000.
|
||||
The earlier CLI PASS therefore is not accepted as convergence evidence. Its
|
||||
causality remains T04; do not call the gap fixed on this evidence.
|
||||
|
||||
P6 disposition: decline the requested legacy nightly remote checkout sweep in
|
||||
this form. Production Helm source explicitly disables it pending RMGR-WP-0005-T11;
|
||||
the supported alternative is immutable Forge reconciliation (STATE-WP-0086)
|
||||
and a separately reviewed freshness/wakeup runner under T03/T05. No scheduler,
|
||||
deployment, credential or human decision was changed.
|
||||
Loading…
Add table
Add a link
Reference in a new issue