From 04c796d951e0912e5c6e76fc2d9ba47c76e15866 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:13:47 +0800 Subject: [PATCH] fix(status): show receipt-owned retries in task details Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../personal-workspace/context-drawer.tsx | 11 +- .../src/features/personal-workspace/i18n.tsx | 8 + .../monitor-readback.test.mjs | 14 +- .../personal-workspace/monitor-readback.ts | 12 ++ .../personal-workspace-model.ts | 4 + .../quota-blocked-causal-closeout-v0.md | 17 ++ loopx/control_plane/quota/blocked_retry.py | 34 ++++ loopx/control_plane/status/collection.py | 2 + .../project_registry_io_manifest_v1.json | 2 +- .../test_status_receipt_retry_projection.py | 149 ++++++++++++++++++ 10 files changed, 248 insertions(+), 5 deletions(-) create mode 100644 tests/control_plane/test_status_receipt_retry_projection.py diff --git a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx index 1732c65502..d25ae1be9c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -642,7 +642,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
- {selection.item.done ? t("drawer.taskStatusCompleted") : selection.item.status === "deferred" ? t("drawer.taskStatusDeferred") : selection.item.status === "blocked" ? t("drawer.taskStatusBlocked") : t("drawer.taskStatusOpen")} + {selection.item.done ? t("drawer.taskStatusCompleted") : selection.item.retryReadback ? t("drawer.resumePending") : selection.item.status === "deferred" ? t("drawer.taskStatusDeferred") : selection.item.status === "blocked" ? t("drawer.taskStatusBlocked") : t("drawer.taskStatusOpen")} {selection.item.priority ? {selection.item.priority} : null} {selection.item.taskClass === "advancement_task" ? t("drawer.taskAdvancement") : selection.item.taskClass ?? t("drawer.taskOrdinary")} @@ -658,19 +658,24 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
Goal
{selection.item.goalTitle}
{t("common.owner")}
{selection.item.claimedBy ? selection.item.ownerLabel ?? selection.item.claimedBy : t("drawer.notAssigned")}
-
{t("common.status")}
{selection.item.done ? t("drawer.taskStatusCompleted") : selection.item.status === "deferred" ? t("drawer.taskStatusDeferred") : selection.item.status === "blocked" ? t("drawer.taskStatusBlocked") : t("drawer.taskStatusOpen")}
+
{t("common.status")}
{selection.item.done ? t("drawer.taskStatusCompleted") : selection.item.retryReadback ? t("drawer.resumePending") : selection.item.status === "deferred" ? t("drawer.taskStatusDeferred") : selection.item.status === "blocked" ? t("drawer.taskStatusBlocked") : t("drawer.taskStatusOpen")}
{t("drawer.priority")}
{selection.item.priority ?? t("drawer.notSet")}
{selection.item.completedAt ?
{t("drawer.completedAt")}
: null} {selection.item.dependencies ?
{t("drawer.dependencies")}
{selection.item.dependencies.join(" · ") || t("common.none")}
: null} {selection.item.status === "deferred" || selection.item.resumeWhen ?
{t("drawer.resumeWhen")}
{selection.item.resumeWhen || t("drawer.notSet")}
: null} {selection.item.resumeWhen ?
{t("drawer.resumeState")}
{selection.item.resumeInvalidReason === "dependency_cycle" ? t("drawer.resumeCycle") : selection.item.resumeReady == null ? t("drawer.notSet") : selection.item.resumeReady ? t("drawer.resumeReady") : t("drawer.resumePending")}
: null} {selection.item.resumeReceiptId ?
{t("drawer.resumeReceipt")}
{selection.item.resumeReceiptId}
: null} + {selection.item.retryReadback ? <> +
{t("drawer.retryObserved")}
+
{t("drawer.retryDue")}

{t("drawer.retryBoundary")}

+
{t("drawer.retryTurn")}
{selection.item.retryReadback.turnId}
+ : null} {selection.item.validationDigest ? <>
{t("drawer.validationRevision")}
{selection.item.validationRevision ?? t("drawer.notSet")}
{t("drawer.validationDigest")}
{selection.item.validationDigest}
{selection.item.validationRevisionActor ?
{t("drawer.validationRevisionActor")}
{selection.item.validationRevisionActor}
: null} : null} -
{t("drawer.nextTransition")}
{selection.item.nextTransition ?? (selection.item.done ? t("drawer.taskNextCompleted") : selection.item.resumeInvalidReason === "dependency_cycle" ? t("drawer.taskNextCycle") : selection.item.resumeReady ? t("drawer.taskNextResumeReady") : selection.item.status === "deferred" ? t("drawer.taskNextDeferred") : selection.item.status === "blocked" ? t("drawer.taskNextBlocked") : t("drawer.taskNextOpen"))}
+
{t("drawer.nextTransition")}
{selection.item.nextTransition ?? (selection.item.done ? t("drawer.taskNextCompleted") : selection.item.resumeInvalidReason === "dependency_cycle" ? t("drawer.taskNextCycle") : selection.item.retryReadback ? t("drawer.taskNextDeferred") : selection.item.resumeReady ? t("drawer.taskNextResumeReady") : selection.item.status === "deferred" ? t("drawer.taskNextDeferred") : selection.item.status === "blocked" ? t("drawer.taskNextBlocked") : t("drawer.taskNextOpen"))}
{!readOnly && !selection.item.done ?
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 79b5702a50..4a014096f4 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -480,6 +480,10 @@ const en = { "drawer.taskNextCycle": "Review dependencies with the task owners", "drawer.resumeReady": "Ready to resume", "drawer.resumeReceipt": "Resume receipt", + "drawer.retryObserved": "Retry recorded", + "drawer.retryDue": "Eligible for a retry check", + "drawer.retryTurn": "Original Turn", + "drawer.retryBoundary": "This wait belongs to the original attempt. Eligibility triggers a fresh check; it does not authorize execution or pause other work.", "drawer.taskNextDeferred": "Wait for the resume condition, then reassess", "drawer.taskNextResumeReady": "Resume condition met; awaiting lifecycle replan", "drawer.taskStatusCompleted": "Completed", @@ -1892,6 +1896,10 @@ const zhCN: Record = { "drawer.taskNextCycle": "与任务负责人核查并修复依赖关系", "drawer.resumeReady": "可恢复", "drawer.resumeReceipt": "恢复回执", + "drawer.retryObserved": "重试等待记录于", + "drawer.retryDue": "可重新核验资格的时间", + "drawer.retryTurn": "原 Turn", + "drawer.retryBoundary": "等待只属于原尝试。到时需重新核验资格,不授权执行,也不暂停其它工作。", "drawer.taskNextDeferred": "等待恢复条件满足后重新评估", "drawer.taskNextResumeReady": "恢复条件已满足,等待生命周期重新规划", "drawer.taskStatusCompleted": "已完成", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/monitor-readback.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/monitor-readback.test.mjs index b5ca60e415..0522ad47a3 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/monitor-readback.test.mjs +++ b/apps/presentation/dashboard/src/features/personal-workspace/monitor-readback.test.mjs @@ -1,6 +1,18 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { formatMonitorDate, monitorScheduleReadback, monitorTodoReadback } from "./monitor-readback.ts"; +import { formatMonitorDate, monitorScheduleReadback, monitorTodoReadback, receiptRetryReadback } from "./monitor-readback.ts"; + +test("receipt wait displays its original clocks and Turn without creating a monitor", () => { + const retry = { source: "turn_settlement", todo_id: "todo_retry", agent_id: "agent-a", + observed_at: "2030-10-01T10:58:16Z", due_at: "2030-10-01T11:03:16Z", turn_instance_id: "original-turn" }; + const todo = { todo_id: "todo_retry", claimed_by: "agent-a", resume_condition: { blocked_retry: retry } }; + assert.deepEqual(receiptRetryReadback(todo), { observedAt: retry.observed_at, dueAt: retry.due_at, turnId: retry.turn_instance_id }); + for (const bad of [null, [], {}, { ...retry, todo_id: "todo_foreign" }, + { ...retry, agent_id: "agent-b" }, { ...retry, source: "todo" }, { ...retry, turn_instance_id: "" }]) { + assert.equal(receiptRetryReadback({ ...todo, resume_condition: { blocked_retry: bad } }), null); + } + assert.equal(receiptRetryReadback({ ...todo, resume_condition: {} }), null); +}); test("canonical monitor facts survive Todo and schedule projections", () => { const source = { todo_id: "monitor-test", done: false, text: "Synthetic monitor", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/monitor-readback.ts b/apps/presentation/dashboard/src/features/personal-workspace/monitor-readback.ts index a2d1fe7d66..08f4302639 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/monitor-readback.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/monitor-readback.ts @@ -1,6 +1,18 @@ import type { TodoItem } from "../../data/status"; import type { WorkspaceAgentTodo, WorkspaceSchedule } from "./personal-workspace-model"; +/** Display the producer's receipt provenance; never evaluate eligibility here. */ +export function receiptRetryReadback(todo: Pick): WorkspaceAgentTodo["retryReadback"] { + const retry = todo.resume_condition?.blocked_retry; + if (!retry || typeof retry !== "object" || Array.isArray(retry)) return null; + const row = retry as Record; + if (row.source !== "turn_settlement" || row.todo_id !== todo.todo_id + || (todo.claimed_by && row.agent_id !== todo.claimed_by) + || typeof row.observed_at !== "string" || typeof row.due_at !== "string" + || typeof row.turn_instance_id !== "string" || !row.turn_instance_id.trim()) return null; + return { observedAt: row.observed_at, dueAt: row.due_at, turnId: row.turn_instance_id }; +} + // Readback only: dates and ownership come from the Todo owner. Never calculate // a next check, expiry, or domain-specific deadline in the presentation layer. export function monitorTodoReadback(todo: TodoItem): Pick tuple[Any, bool]: "kind": "resume_at", "resume_when": resume, "satisfied": False, + "blocked_retry": retry, }, } ) @@ -231,3 +232,36 @@ def overlay_summary(summary: Any) -> tuple[Any, bool]: if changed else status_payload ) + + +def overlay_status_turn_retries( + history: dict[str, Any], queue: dict[str, Any], *, observed_at: str +) -> dict[str, Any]: + """Transport the existing per-agent retry projection into status readers. + + History is the uncompressed control-plane snapshot, not the display tail. + Qualification, supersession and clocks stay with the existing retry owner. + No canonical Todo, receipt, scheduler or quota mutation is performed here. + """ + payload = {"run_history": history, "attention_queue": queue} + for goal in history.get("goals") or []: + if not isinstance(goal, dict) or not goal.get("id"): + continue + semantic = goal.get("semantic_history") + runs = ( + semantic.get("active_blocked_retry_runs") + if isinstance(semantic, dict) + and isinstance(semantic.get("active_blocked_retry_runs"), list) + else goal.get("latest_runs") or [] + ) + agents = dict.fromkeys( + run["agent_id"] for run in runs + if isinstance(run, dict) and isinstance(run.get("agent_id"), str) + and run["agent_id"] + ) + for agent_id in agents: + payload = overlay_active_turn_retries( + payload, goal_id=goal["id"], agent_id=agent_id, + observed_at=observed_at, + ) + return payload["attention_queue"] diff --git a/loopx/control_plane/status/collection.py b/loopx/control_plane/status/collection.py index da2bfe5240..9b523c8cb3 100644 --- a/loopx/control_plane/status/collection.py +++ b/loopx/control_plane/status/collection.py @@ -22,6 +22,7 @@ collect_runtime_projection_route_diagnostics, ) from ..projection_envelope_facts import seal_projection_envelope, source_fact +from ..quota.blocked_retry import overlay_status_turn_retries from ..runtime.time import now_utc_iso, parse_timestamp, utc_isoformat from ..todos.todo_index import MAX_TODO_INDEX_ROLLOUT_EVENTS_PER_GOAL from ...registry import registry_goals @@ -159,6 +160,7 @@ def collect_status( # No later projection consumes canonical rows; release retained archive # data before assembling the rest of the display. del todo_snapshot + queue = overlay_status_turn_retries(history, queue, observed_at=now_utc_iso()) runtime_summaries = context.build_runtime_summaries( history=history, queue=queue, diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 3092b66c2f..594d26a981 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -1719,7 +1719,7 @@ }, { "site": "loopx/control_plane/status/collection.py::.collect_status::codec_read:load_registry#1", - "line": 99, + "line": 100, "column": 16, "kind": "codec_read", "api": "load_registry", diff --git a/tests/control_plane/test_status_receipt_retry_projection.py b/tests/control_plane/test_status_receipt_retry_projection.py new file mode 100644 index 0000000000..576669a6b0 --- /dev/null +++ b/tests/control_plane/test_status_receipt_retry_projection.py @@ -0,0 +1,149 @@ +"""Status transports receipt-owned waits without creating Todo authority.""" +from __future__ import annotations + +from copy import deepcopy +from datetime import UTC, datetime, timedelta +import json +from pathlib import Path +import subprocess +import sys +from threading import Thread +from urllib.request import urlopen + +import pytest +from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime + +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.control_plane.effect_runtime import restart_effect_runtime +from loopx.control_plane.quota.blocked_retry import overlay_status_turn_retries +from loopx.status import active_state_todo_fields +from loopx.status_server import ( + DEFAULT_CONFIGURE_GOAL_APPLY_PATH, + DEFAULT_CONFIGURE_GOAL_DRY_RUN_PATH, + DEFAULT_REWARD_APPEND_PATH, + DEFAULT_REWARD_DRY_RUN_PATH, + StatusHTTPServer, + StatusRequestHandler, +) + + +def fixture(observed: str = "2030-01-01T00:00:00Z", due: str = "2030-01-01T00:05:00Z"): + todo = {"schema_version": "todo_item_v0", "todo_id": "todo_work", "index": 1, + "role": "agent", "status": "open", "done": False, "archive_state": "active", + "source_section": "Agent Todo", "task_class": "advancement_task", + "claimed_by": "agent-a", "text": "Validate the independent result."} + retry = {"schema_version": "quota_blocked_retry_v0", "source": "turn_settlement", + "todo_id": todo["todo_id"], "observed_at": observed, "due_at": due, + "resume_when": f"resume_at:{due}"} + run = {"goal_id": "goal-a", "agent_id": "agent-a", "todo_id": todo["todo_id"], + "turn_instance_id": "original-attempt", "classification": "fixture_blocked", + "generated_at": observed, "delivery_outcome": "outcome_gap", + "progress_observation": {"result_class": "blocked"}, "blocked_retry": retry} + history = {"goals": [{"id": "goal-a", "latest_runs": [run]}]} + queue = {"items": [{"goal_id": "goal-a", "agent_todos": {"items": [todo]}, + "project_asset": {"agent_todos": {"items": [todo]}}}]} + return todo, run, history, queue + + +@pytest.mark.parametrize("observed_at", ["2030-01-01T00:02:00Z", "2030-01-01T00:05:00Z", "2030-01-01T00:05:01Z"]) +def test_status_retains_original_clock_and_disappears_at_due(observed_at): + _, run, history, queue = fixture() + before = deepcopy((history, queue)) + projected = overlay_status_turn_retries(history, queue, observed_at=observed_at) + row = projected["items"][0]["agent_todos"]["items"][0] + if observed_at < run["blocked_retry"]["due_at"]: + assert row["resume_ready"] is False + assert row["blocked_retry"]["turn_instance_id"] == "original-attempt" + assert row["blocked_retry"]["observed_at"] == run["blocked_retry"]["observed_at"] + assert row["blocked_retry"]["due_at"] == run["blocked_retry"]["due_at"] + assert row["resume_condition"]["blocked_retry"] == row["blocked_retry"] + assert projected["items"][0]["project_asset"]["agent_todos"]["items"][0] == row + else: + assert "blocked_retry" not in row + assert "resume_when" not in row + assert (history, queue) == before + + +@pytest.mark.parametrize("change", ["agent", "goal", "newer_run", "canonical_wait", "done"]) +def test_status_does_not_borrow_foreign_or_superseded_retries(change): + todo, run, history, queue = fixture() + if change == "agent": + todo["claimed_by"] = "agent-b" + elif change == "goal": + queue["items"][0]["goal_id"] = "goal-b" + elif change == "newer_run": + history["goals"][0]["latest_runs"].insert(0, {**run, "delivery_outcome": "outcome_progress"}) + elif change == "canonical_wait": + todo["resume_when"] = "todo_done:todo_dependency" + else: + todo["status"] = "done" + projected = overlay_status_turn_retries(history, queue, observed_at="2030-01-01T00:02:00Z") + assert projected is queue + + +def test_status_uses_retained_semantic_runs_before_display_compaction(): + _, run, history, queue = fixture() + history["goals"][0]["latest_runs"] = [] + history["goals"][0]["semantic_history"] = {"active_blocked_retry_runs": [run]} + projected = overlay_status_turn_retries(history, queue, observed_at="2030-01-01T00:02:00Z") + assert projected["items"][0]["agent_todos"]["items"][0]["blocked_retry"]["turn_instance_id"] == "original-attempt" + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("channel", ["cli", "http"]) +def test_real_provider_status_projects_wait_without_writes(tmp_path: Path, monkeypatch, provider, channel): + isolate_sqlite_runtime(tmp_path, monkeypatch) + now = datetime.now(UTC) + observed, due = now.isoformat(), (now + timedelta(minutes=5)).isoformat() + todo, run, _, _ = fixture(observed, due) + state = tmp_path / "ACTIVE_GOAL_STATE.md" + state.write_text("# Goal\n\n## Agent Todo\n\n") + runtime = tmp_path / "runtime" + goal = {"id": "goal-a", "repo": str(tmp_path), "state_file": str(state), + "domain": "software", "adapter": {"kind": "read_only_project_map_v0"}} + projection = build_todo_runtime_shadow_projection(goal_id=goal["id"], todos=[todo]) + initialize_canonical_authority(runtime, goal["id"], projection, state_path=state, provider=provider) + before = active_state_todo_fields(goal, runtime_root=runtime) + runs = runtime / "goals" / goal["id"] / "runs" + runs.mkdir(parents=True) + body = runs / "blocked.json" + body.write_text(json.dumps(run)) + index = runs / "index.jsonl" + index.write_text(json.dumps({**run, "json_path": str(body)}) + "\n") + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"schema_version": 1, "common_runtime_root": str(runtime), "goals": [goal]})) + frozen = {p: p.read_bytes() for p in [state, body, index, registry]} + try: + if channel == "cli": + result = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(registry), + "--format", "json", "status", "--goal-id", goal["id"]], + capture_output=True, text=True, timeout=60) + payload = json.loads(result.stdout) + assert result.returncode == 0, payload + else: + server = StatusHTTPServer(("127.0.0.1", 0), StatusRequestHandler) + server.registry_path, server.runtime_root_override = registry, str(runtime) + server.scan_roots, server.limit, server.status_path = [tmp_path], 80, "/status.json" + server.control_plane_write_enabled = server.reward_write_enabled = False + server.goal_subagent_configuration_enabled = server.verbose = False + server.reward_dry_run_path, server.reward_append_path = DEFAULT_REWARD_DRY_RUN_PATH, DEFAULT_REWARD_APPEND_PATH + server.configure_goal_dry_run_path, server.configure_goal_apply_path = DEFAULT_CONFIGURE_GOAL_DRY_RUN_PATH, DEFAULT_CONFIGURE_GOAL_APPLY_PATH + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + with urlopen(f"http://127.0.0.1:{server.server_port}/status.json?goal_id=goal-a", timeout=30) as response: + payload = json.load(response) + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + assert payload["ok"] is True + row = next(row for item in payload["attention_queue"]["items"] + for row in item.get("agent_todos", {}).get("items", []) if row.get("todo_id") == todo["todo_id"]) + assert row["resume_condition"]["blocked_retry"]["due_at"] == due + assert row["blocked_retry"]["turn_instance_id"] == run["turn_instance_id"] + assert active_state_todo_fields(goal, runtime_root=runtime) == before + assert all(p.read_bytes() == content for p, content in frozen.items()) + assert not list(runtime.rglob("*spend*")) + finally: + restart_effect_runtime()