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-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-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-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-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-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)
|
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,
|
file_value=t_status,
|
||||||
fixable=False,
|
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).
|
# 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():
|
if wait_until and _date.fromisoformat(wait_until) < datetime.now(timezone.utc).date():
|
||||||
report.add(
|
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:
|
def _check_inbox_hygiene(api_base: str, repo_slug: str, report: ConsistencyReport) -> None:
|
||||||
hygiene = collect_inbox_hygiene(api_base, repo_slug)
|
hygiene = collect_inbox_hygiene(api_base, repo_slug)
|
||||||
if hygiene["stale_unread_count"]:
|
if hygiene["stale_unread_count"]:
|
||||||
|
|
@ -4254,6 +4324,7 @@ def report_to_dict(report: ConsistencyReport) -> dict:
|
||||||
"file_value": i.file_value,
|
"file_value": i.file_value,
|
||||||
"db_value": i.db_value,
|
"db_value": i.db_value,
|
||||||
"fixable": i.fixable,
|
"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
|
for i in report.issues
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -2199,7 +2199,7 @@ class TestTaskWaitQualifiers:
|
||||||
repo = self._make_repo(
|
repo = self._make_repo(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
"id: STATE-WP-0001-T01\nstatus: wait\npriority: high\n"
|
"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\"",
|
"blocking_reason: \"operator must provision creds\"",
|
||||||
)
|
)
|
||||||
self._install(monkeypatch, self._fake_get(repo))
|
self._install(monkeypatch, self._fake_get(repo))
|
||||||
|
|
@ -2314,7 +2314,7 @@ class TestTaskWaitQualifiers:
|
||||||
repo = self._make_repo(
|
repo = self._make_repo(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
"id: STATE-WP-0001-T01\nstatus: wait\npriority: high\n"
|
"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"
|
"decision_id: CCR-2026-0004\n"
|
||||||
"blocking_reason: \"operator must provision creds\"",
|
"blocking_reason: \"operator must provision creds\"",
|
||||||
extra_files={"docs/decisions.md": decision_doc},
|
extra_files={"docs/decisions.md": decision_doc},
|
||||||
|
|
@ -2532,3 +2532,56 @@ class TestTaskWaitQualifiers:
|
||||||
assert ("/tasks/task-1", wait_fields) in patches
|
assert ("/tasks/task-1", wait_fields) in patches
|
||||||
assert any("C-20 fixed" in fix for fix in report.fixes_applied)
|
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)
|
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