Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -642,7 +642,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
<section className="personal-task-inspector-summary">
<div className="personal-task-inspector-status">
<span className={selection.item.done ? "is-done" : selection.item.status === "blocked" ? "is-blocked" : "is-open"}>
<i />{selection.item.done ? t("drawer.taskStatusCompleted") : selection.item.status === "deferred" ? t("drawer.taskStatusDeferred") : selection.item.status === "blocked" ? t("drawer.taskStatusBlocked") : t("drawer.taskStatusOpen")}
<i />{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")}
</span>
{selection.item.priority ? <span>{selection.item.priority}</span> : null}
<span>{selection.item.taskClass === "advancement_task" ? t("drawer.taskAdvancement") : selection.item.taskClass ?? t("drawer.taskOrdinary")}</span>
Expand All @@ -658,19 +658,24 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
<dl>
<div><dt>Goal</dt><dd>{selection.item.goalTitle}</dd></div>
<div><dt>{t("common.owner")}</dt><dd>{selection.item.claimedBy ? selection.item.ownerLabel ?? selection.item.claimedBy : t("drawer.notAssigned")}</dd></div>
<div><dt>{t("common.status")}</dt><dd>{selection.item.done ? t("drawer.taskStatusCompleted") : selection.item.status === "deferred" ? t("drawer.taskStatusDeferred") : selection.item.status === "blocked" ? t("drawer.taskStatusBlocked") : t("drawer.taskStatusOpen")}</dd></div>
<div><dt>{t("common.status")}</dt><dd>{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")}</dd></div>
<div><dt>{t("drawer.priority")}</dt><dd>{selection.item.priority ?? t("drawer.notSet")}</dd></div>
{selection.item.completedAt ? <div><dt>{t("drawer.completedAt")}</dt><dd><time dateTime={selection.item.completedAt}>{selection.item.completedAt}</time></dd></div> : null}
{selection.item.dependencies ? <div><dt>{t("drawer.dependencies")}</dt><dd>{selection.item.dependencies.join(" · ") || t("common.none")}</dd></div> : null}
{selection.item.status === "deferred" || selection.item.resumeWhen ? <div><dt>{t("drawer.resumeWhen")}</dt><dd>{selection.item.resumeWhen || t("drawer.notSet")}</dd></div> : null}
{selection.item.resumeWhen ? <div><dt>{t("drawer.resumeState")}</dt><dd>{selection.item.resumeInvalidReason === "dependency_cycle" ? t("drawer.resumeCycle") : selection.item.resumeReady == null ? t("drawer.notSet") : selection.item.resumeReady ? t("drawer.resumeReady") : t("drawer.resumePending")}</dd></div> : null}
{selection.item.resumeReceiptId ? <div><dt>{t("drawer.resumeReceipt")}</dt><dd>{selection.item.resumeReceiptId}</dd></div> : null}
{selection.item.retryReadback ? <>
<div><dt>{t("drawer.retryObserved")}</dt><dd><time dateTime={selection.item.retryReadback.observedAt}>{formatMonitorDate(selection.item.retryReadback.observedAt, locale)}</time></dd></div>
<div><dt>{t("drawer.retryDue")}</dt><dd><time dateTime={selection.item.retryReadback.dueAt}>{formatMonitorDate(selection.item.retryReadback.dueAt, locale)}</time><p>{t("drawer.retryBoundary")}</p></dd></div>
<div><dt>{t("drawer.retryTurn")}</dt><dd><code>{selection.item.retryReadback.turnId}</code></dd></div>
</> : null}
{selection.item.validationDigest ? <>
<div><dt>{t("drawer.validationRevision")}</dt><dd>{selection.item.validationRevision ?? t("drawer.notSet")}</dd></div>
<div><dt>{t("drawer.validationDigest")}</dt><dd><code>{selection.item.validationDigest}</code></dd></div>
{selection.item.validationRevisionActor ? <div><dt>{t("drawer.validationRevisionActor")}</dt><dd>{selection.item.validationRevisionActor}</dd></div> : null}
</> : null}
<div><dt>{t("drawer.nextTransition")}</dt><dd>{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"))}</dd></div>
<div><dt>{t("drawer.nextTransition")}</dt><dd>{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"))}</dd></div>
</dl>
</section>
{!readOnly && !selection.item.done ? <div className="personal-task-inspector-actions" aria-label={t("drawer.taskActions")}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -1892,6 +1896,10 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"drawer.taskNextCycle": "与任务负责人核查并修复依赖关系",
"drawer.resumeReady": "可恢复",
"drawer.resumeReceipt": "恢复回执",
"drawer.retryObserved": "重试等待记录于",
"drawer.retryDue": "可重新核验资格的时间",
"drawer.retryTurn": "原 Turn",
"drawer.retryBoundary": "等待只属于原尝试。到时需重新核验资格,不授权执行,也不暂停其它工作。",
"drawer.taskNextDeferred": "等待恢复条件满足后重新评估",
"drawer.taskNextResumeReady": "恢复条件已满足,等待生命周期重新规划",
"drawer.taskStatusCompleted": "已完成",
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TodoItem, "todo_id" | "claimed_by" | "resume_condition">): WorkspaceAgentTodo["retryReadback"] {
const retry = todo.resume_condition?.blocked_retry;
if (!retry || typeof retry !== "object" || Array.isArray(retry)) return null;
const row = retry as Record<string, unknown>;
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<WorkspaceAgentTodo,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { AttentionDetails } from "./attention-details";
import type { WorkspaceLoadError, WorkspaceReadScope } from "../../data/workspace-progressive-status";
import type { TodoItem } from "../../data/status";
import { goalWorkKind, type GoalHostThreadActivity, type WorkspaceGoalExecution } from "./goal-activity";
import { receiptRetryReadback } from "./monitor-readback";
import type { DigestAttentionSummary } from "../../../../../../loopx/control_plane/presentation/goal_workspace_digest.js";
export type WorkspaceGoalState =
| "需修复"
Expand Down Expand Up @@ -39,6 +40,8 @@ export type WorkspaceAgentTodo = {
resumeReady?: boolean | null;
resumeInvalidReason?: string | null;
resumeReceiptId?: string | null;
/** Read-only provenance from the Run that owns this temporary retry. */
retryReadback?: { observedAt: string; dueAt: string; turnId: string } | null;
blockedReason?: string | null;
claimedBy?: string | null;
dependencies?: string[];
Expand Down Expand Up @@ -93,6 +96,7 @@ export function workspaceAgentTodoFromItem(todo: Pick<TodoItem,
resumeInvalidReason: typeof todo.resume_condition?.invalid_state === "string"
? todo.resume_condition.invalid_state : null,
resumeReceiptId: typeof receiptId === "string" && receiptId.trim() ? receiptId.trim() : null,
retryReadback: receiptRetryReadback(todo),
validationDigest: todo.completion_validation_sha256 ?? null,
validationRevision: todo.completion_validation_revision ?? null,
validationRevisionActor: todo.completion_validation_revision_history.at(-1)?.actor_agent_id ?? null,
Expand Down
17 changes: 17 additions & 0 deletions docs/reference/protocols/quota-blocked-causal-closeout-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,16 @@ prerequisite: packaged Dashboard and live Lark rendering/adoption are separate
acceptance stages. No new timer, authority, setting, canonical Todo field,
financial clock, or automatically executing display action is introduced.

Status CLI and HTTP now consume the same per-agent retry projection before
building their Todo index. The task inspector displays the recorded time,
original retry-check deadline and original Turn. Its `resume_condition`
contains the read-only `blocked_retry` provenance so existing structured readers
can retain it. At the deadline, or after a superseding work Run, the projection
disappears on the next status read; no Todo or receipt is rewritten. Canonical
waits, foreign claims and unrelated work retain their existing behavior.
This adds no execution button, permission, timer or debit. Lark transports can
read the same status data; live Lark delivery remains separately qualified.

原生 blocked/outcome_gap Run 可以拥有五分钟 retry,而 canonical Todo 仍为
OPEN、没有 resume_when。临时选择投影只作用于同一 Goal/Agent/Todo,到原到期
时点即消失;更新的工作 Run 会替代旧等待。blocked_retry 保留原 Turn、来源和
Expand All @@ -278,3 +288,10 @@ retry 暂时移除唯一可执行工作时,独立 user_action 仍是提醒,
既有角色间隔下限;真实用户门禁继续阻塞执行。唤醒只意味着重新读取 quota、
claim/lease 与当前门禁,不授权执行,也不承诺精确时刻运行。到期不需修改原
Todo 或回执。CLI/heartbeat 同源;打包 App、真实 Lark 展示与采用仍须分别验收。

status CLI/HTTP 现在于构建 Todo 索引前消费同一角色 retry 投影。既有任务详情
显示原记录时间、重试资格核验时点与原 Turn;`resume_condition.blocked_retry`
保留只读来源,结构化读者可继续传递。到期或被新工作 Run 替代后,下次 status
读取即移除投影,不改原 Todo/回执。canonical 等待、其他 claimant 及独立工作
保持原行为;不增加执行按钮、权限、计时器或扣额。Lark 可复用同源 status 数据,
真实送达仍需单独验收。
34 changes: 34 additions & 0 deletions loopx/control_plane/quota/blocked_retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ def overlay_summary(summary: Any) -> tuple[Any, bool]:
"kind": "resume_at",
"resume_when": resume,
"satisfied": False,
"blocked_retry": retry,
},
}
)
Expand Down Expand Up @@ -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"]
2 changes: 2 additions & 0 deletions loopx/control_plane/status/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion loopx/semantics/project_registry_io_manifest_v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -1759,7 +1759,7 @@
},
{
"site": "loopx/control_plane/status/collection.py::<module>.collect_status::codec_read:load_registry#1",
"line": 99,
"line": 100,
"column": 16,
"kind": "codec_read",
"api": "load_registry",
Expand Down
Loading
Loading