From 831ae0e598ca6e5e21f7721da5e7f0ed9f81f640 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 4 Oct 2026 00:48:22 +0200 Subject: [PATCH] CUST-WP-0075: reconcile owner waits and decision routing Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a10391-6355-7b41-b0fa-3e14e874f091 --- scripts/consistency_check.py | 73 +++++++++++- tests/test_consistency_check.py | 57 +++++++++- ...-WP-0094-routed-gates-and-owner-returns.md | 106 ++++++++++++++++++ 3 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 workplans/STATE-WP-0094-routed-gates-and-owner-returns.md diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py index c7e18d8..eb581df 100644 --- a/scripts/consistency_check.py +++ b/scripts/consistency_check.py @@ -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:, 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:, 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 ], diff --git a/tests/test_consistency_check.py b/tests/test_consistency_check.py index 7e8a844..9540b26 100644 --- a/tests/test_consistency_check.py +++ b/tests/test_consistency_check.py @@ -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 diff --git a/workplans/STATE-WP-0094-routed-gates-and-owner-returns.md b/workplans/STATE-WP-0094-routed-gates-and-owner-returns.md new file mode 100644 index 0000000..cdbba7b --- /dev/null +++ b/workplans/STATE-WP-0094-routed-gates-and-owner-returns.md @@ -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.