diff --git a/apps/presentation/dashboard/src/features/personal-workspace/completed-task-lane.tsx b/apps/presentation/dashboard/src/features/personal-workspace/completed-task-lane.tsx
index 2c1643b455..5cc434414a 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/completed-task-lane.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/completed-task-lane.tsx
@@ -1,12 +1,19 @@
import { useEffect, useEffectEvent, useId, useRef, useState } from "react";
import { z } from "zod";
-import {compactWorkspaceText} from "./personal-workspace-model";
+import {compactWorkspaceText, workspaceAgentTodoFromItem} from "./personal-workspace-model";
+import { todoItemSchema } from "../../data/status";
import type { WorkspaceAgentTodo, WorkspaceDrawerSelection, WorkspaceGoal } from "./personal-workspace-model";
import { useWorkspaceI18n } from "./i18n";
const pageSchema = z.object({
ok: z.literal(true), total: z.number().int().nonnegative(), next_cursor: z.string().nullable(),
- items: z.array(z.object({ todo_id: z.string(), text: z.string(), claimed_by: z.string().nullable(), evidence: z.string().nullable(), priority: z.string().nullable(), task_class: z.string().nullable() })).max(40),
+ items: z.array(z.object(todoItemSchema.shape).pick({
+ todo_id: true, text: true, done: true, status: true, claimed_by: true,
+ evidence: true, note: true, priority: true, task_class: true, task_domain: true,
+ completed_at: true, resume_when: true, resume_ready: true, resume_condition: true,
+ completion_validation_sha256: true, completion_validation_revision: true,
+ completion_validation_revision_history: true,
+ }).extend({ todo_id: z.string().min(1), done: z.literal(true), status: z.literal("done") })).max(40),
});
/** Fixed-height previews keep layout/DOM cost bounded; the drawer retains full text. */
@@ -69,7 +76,7 @@ export function CompletedTaskLane({ goal, agentId, seed, enabled, refreshRevisio
if (!response.ok) throw new Error("history unavailable");
const page = pageSchema.parse(await response.json());
if (controller.signal.aborted) return;
- const next = page.items.map((todo): WorkspaceAgentTodo => ({ todoId: todo.todo_id, text: todo.text, claimedBy: todo.claimed_by, evidence: todo.evidence, priority: todo.priority, taskClass: todo.task_class, done: true, status: "done" }));
+ const next = page.items.map((todo) => workspaceAgentTodoFromItem(todo, todo.todo_id));
setRows((current) => cursor === undefined ? next : [...current, ...next.filter((todo) => !current.some((existing) => existing.todoId === todo.todoId))]);
setTotal(page.total);
setCursor(page.next_cursor);
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 f6917d0f3c..6415d75441 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx
@@ -43,6 +43,7 @@ import { localizedGoalState, localizedSessionStatus, useWorkspaceI18n } from "./
import { formatCostUsd, formatDurationMs, formatTokenCount, formatUsageValue } from "./personal-workspace-model";
import { TeamPlanResult } from "./team-plan-result";
import { parseTodoResumeCondition } from "./todo-resume-condition";
+import { MarkdownText } from "./markdown";
import { formatMonitorDate } from "./monitor-readback";
function subagentModelRequest(include: boolean, model: string, effort: string) {
@@ -643,12 +644,13 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
{t("common.owner")}{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("drawer.priority")}{selection.item.priority ?? t("drawer.notSet")}
- {t("drawer.dependencies")}{selection.item.dependencies?.join(" · ") || t("common.none")}
+ {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.resumeReady ? t("drawer.resumeReady") : t("drawer.resumePending")} : null}
+ {selection.item.resumeWhen ? {t("drawer.resumeState")}{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.validationDigest ? <>
- {t("drawer.validationRevision")}{selection.item.validationRevision ?? 0}
+ {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}
@@ -708,6 +710,10 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
: null}
+ {selection.item.evidence ?
+ {t("drawer.evidence")}
+
+ : null}
{selection.item.done ? {t("drawer.taskCompletedTitle")}{t("drawer.taskCompletedNote")}
: null}
>
) : null}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx
index 7c15be70cd..bb592e78a2 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx
@@ -245,6 +245,7 @@ const en = {
"drawer.nextTransition": "Next transition",
"drawer.validationRevision": "Validation revision",
"drawer.validationDigest": "Validation declaration digest",
+ "drawer.completedAt": "Completed at",
"drawer.validationRevisionActor": "Revised by",
"drawer.noExecutionHistory": "No execution history yet.",
"drawer.noRun": "Execution Session has not started",
@@ -1522,6 +1523,7 @@ const zhCN: Record = {
"drawer.nextTransition": "下一转换",
"drawer.validationRevision": "验证声明修订",
"drawer.validationDigest": "验证声明摘要",
+ "drawer.completedAt": "完成时间",
"drawer.validationRevisionActor": "修订者",
"drawer.noExecutionHistory": "暂无执行记录。",
"drawer.noRun": "尚未启动执行 Session",
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts
index 8d0e0057e5..def13b066e 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts
+++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts
@@ -6,6 +6,7 @@ import type { ActionReviewPlan } from "../../../../../../loopx/control_plane/pre
import type { GoalAcceptanceObservation } from "../../data/goal-acceptance-observation";
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";
export type WorkspaceGoalState =
| "需修复"
@@ -52,6 +53,36 @@ export type WorkspaceAgentTodo = {
validationRevisionActor?: string | null;
};
+/** Both active status and retained history carry the same inspector facts. */
+export function workspaceAgentTodoFromItem(todo: Pick, fallbackId: string): WorkspaceAgentTodo {
+ const receipt = todo.resume_condition?.resume_receipt;
+ const receiptId = receipt && typeof receipt === "object" && !Array.isArray(receipt)
+ ? (receipt as Record).receipt_id : null;
+ return {
+ todoId: todo.todo_id?.trim() || fallbackId,
+ text: todo.text,
+ done: todo.status === "deferred" ? false : todo.done,
+ status: todo.status ?? null,
+ claimedBy: todo.claimed_by ?? null,
+ evidence: todo.evidence || todo.note || null,
+ priority: todo.priority ?? null,
+ taskClass: todo.task_class ?? null,
+ taskDomain: todo.task_domain ?? null,
+ completedAt: todo.completed_at ?? null,
+ resumeWhen: todo.resume_when ?? null,
+ resumeReady: todo.resume_ready ?? null,
+ resumeReceiptId: typeof receiptId === "string" && receiptId.trim() ? receiptId.trim() : null,
+ validationDigest: todo.completion_validation_sha256 ?? null,
+ validationRevision: todo.completion_validation_revision ?? null,
+ validationRevisionActor: todo.completion_validation_revision_history.at(-1)?.actor_agent_id ?? null,
+ };
+}
+
export type WorkspaceTodo = WorkspaceAgentTodo & {
goalId: string;
goalTitle: string;
diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx
index 962b0bb3d6..f39a930c7a 100644
--- a/apps/presentation/dashboard/src/views/dashboard-page.tsx
+++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx
@@ -3,7 +3,7 @@ import { withTurnActivity, type TurnStep } from "../data/turn-steps";
import { conversationReturnSessions, conversationPendingReturnSessions, reconcileConversationHistory, reconcileConversationReturns } from "../data/conversation-returns";
import { readConversationReturns } from "../data/conversation-return-observation";
import { currentChannelSession, useConversationHistory } from "../data/use-conversation-history";
-import {compactWorkspaceText as compactShareText} from "../features/personal-workspace/personal-workspace-model";
+import {compactWorkspaceText as compactShareText, workspaceAgentTodoFromItem} from "../features/personal-workspace/personal-workspace-model";
import type { GoalAcceptanceObservation } from "../data/goal-acceptance-observation";
import { attentionDetails, attentionDetailsFromSnapshot, sourceAttention } from "../features/personal-workspace/attention-details";
import type { AttentionDetails } from "../features/personal-workspace/attention-details";
@@ -238,8 +238,6 @@ type TodoExplorerItem = {
todo: TodoItem;
};
-type PersonalAgentTodoItem = WorkspaceAgentTodo;
-
function inferLifecyclePhase(status?: string | null, run?: RunRecord) {
if (run?.controller_readiness?.decision_advisor_ready || run?.controller_readiness?.write_controller_ready) {
return "controller_ready";
@@ -457,7 +455,7 @@ type PersonalGoalItem = {
activationState: "active" | "stopped";
agentId: string;
agentSentence: string;
- agentTodos: PersonalAgentTodoItem[];
+ agentTodos: WorkspaceAgentTodo[];
doneTodoCount: number;
goalId: string;
latestActivity?: string;
@@ -702,34 +700,14 @@ function personalTodoText(todo: TodoItem) {
return compactShareText(todo.title ?? todo.text, 112);
}
-function personalTodoResumeReceiptId(todo: TodoItem) {
- const receipt = todo.resume_condition?.resume_receipt;
- if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) return null;
- const receiptId = (receipt as Record).receipt_id;
- return typeof receiptId === "string" && receiptId.trim() ? receiptId.trim() : null;
-}
-
-function personalAgentTodoFromItem(todo: TodoItem, row: GoalDirectoryRow): PersonalAgentTodoItem {
- const latestValidationRevision = todo.completion_validation_revision_history.at(-1);
+function personalAgentTodoFromItem(todo: TodoItem, row: GoalDirectoryRow): WorkspaceAgentTodo {
return {
...monitorTodoReadback(todo),
- completedAt: todo.completed_at ?? null,
- resumeWhen: todo.resume_when ?? null,
- resumeReady: todo.resume_ready ?? null,
- resumeReceiptId: personalTodoResumeReceiptId(todo),
- claimedBy: todo.claimed_by ?? null,
- // Legacy summaries mark deferred entries checked; they are not completed work.
- done: todo.status === "deferred" ? false : todo.done,
+ ...workspaceAgentTodoFromItem(todo, `${row.goal.id}:agent:${todo.index}`),
+ // Preserve the active queue's existing preview budget. History and its
+ // inspector use full text/evidence from the retained read.
evidence: todo.evidence ? compactShareText(todo.evidence, 96) : null,
- priority: todo.priority ?? null,
- status: todo.status ?? null,
- taskClass: todo.task_class ?? null,
- taskDomain: todo.task_domain ?? null,
text: personalTodoText(todo),
- todoId: todo.todo_id?.trim() || `${row.goal.id}:agent:${todo.index}`,
- validationDigest: todo.completion_validation_sha256 ?? null,
- validationRevision: todo.completion_validation_revision ?? null,
- validationRevisionActor: latestValidationRevision?.actor_agent_id ?? null,
};
}
@@ -746,16 +724,16 @@ function personalAgentTodoItems(row: GoalDirectoryRow): TodoItem[] {
return [...merged.values()];
}
-function personalAgentTodos(row: GoalDirectoryRow): PersonalAgentTodoItem[] {
+function personalAgentTodos(row: GoalDirectoryRow): WorkspaceAgentTodo[] {
return personalAgentTodoItems(row).map((todo) => personalAgentTodoFromItem(todo, row));
}
function personalSubagentDomainCandidates(
payload: StatusPayload,
row: GoalDirectoryRow,
- fallbackTodos: PersonalAgentTodoItem[],
+ fallbackTodos: WorkspaceAgentTodo[],
) {
- const candidateTodos = new Map();
+ const candidateTodos = new Map();
for (const todo of payload.todo_index?.items ?? []) {
if (todo.goal_id !== row.goal.id || todo.role !== "agent") continue;
const projected = personalAgentTodoFromItem(todo, row);
@@ -778,7 +756,7 @@ function personalSubagentDomainCandidates(
function personalAgentTodoFromProjection(
todo: NonNullable,
row: GoalDirectoryRow,
-): PersonalAgentTodoItem {
+): WorkspaceAgentTodo {
return {
claimedBy: todo.claimed_by ?? null,
done: todo.status === "done" || todo.status === "completed",
@@ -791,10 +769,10 @@ function personalAgentTodoFromProjection(
}
function mergePersonalAgentTodos(
- projectedTodos: PersonalAgentTodoItem[],
+ projectedTodos: WorkspaceAgentTodo[],
agentRows: AgentManagementRow[],
row: GoalDirectoryRow,
-): PersonalAgentTodoItem[] {
+): WorkspaceAgentTodo[] {
const merged = new Map(projectedTodos.map((todo) => [todo.todoId, todo]));
for (const agent of agentRows) {
const current = agent.currentTodo;
@@ -813,7 +791,7 @@ function mergePersonalAgentTodos(
function personalAgentTodoFacts(row: GoalDirectoryRow): {
doneTodoCount: number;
nextTodoText: string | null;
- recentCompleted: PersonalAgentTodoItem[];
+ recentCompleted: WorkspaceAgentTodo[];
} {
const assetTodos = row.queueItem?.project_asset?.agent_todos;
const queueTodos = row.queueItem?.agent_todos;
@@ -840,7 +818,7 @@ function personalAgentTodoFacts(row: GoalDirectoryRow): {
return { doneTodoCount, nextTodoText, recentCompleted };
}
-function personalVisiblePlanTodos(todos: PersonalAgentTodoItem[], limit = 4) {
+function personalVisiblePlanTodos(todos: WorkspaceAgentTodo[], limit = 4) {
if (todos.length <= limit) {
return todos;
}
diff --git a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-28-retirement-cadence.md b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-28-retirement-cadence.md
index 27cacf75e9..df8baf167d 100644
--- a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-28-retirement-cadence.md
+++ b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-28-retirement-cadence.md
@@ -504,8 +504,29 @@ The representative request falls from over 2 MiB to about 0.96 MB without raisin
budgets. Real File/SQLite CLI tests cover exact counts, distant inferred edges,
metadata preservation and unchanged provider state. This is a bounded capacity
repair, not unlimited graph capacity, stable latency evidence, D2 qualification
-or permission to change the default provider. Full-Goal summary/list/detail
-adoption and sustained observation remain separate work.
+or permission to change the default provider.
+
+### Retained completion consumption
+
+The completed-history consumer
+now explicitly reads retained active and archived records through the existing
+typed summary/filter owner. Previously, archiving 84 of 85 completions made
+the workspace history return only one row. Its bounded HTTP pages now include
+all 85, preserve full task text/evidence and Agent filtering, and expose only
+display fields. Explicit history reads opt out of the existing 500-character
+summary limit; the default active Todo list and scheduling input still exclude
+archives and retain their display limit. Real File/SQLite and legacy Markdown
+reads preserve provider state. The packaged workspace keeps its existing
+pagination and opens retained evidence in the read-only Todo inspector using
+the shared Markdown renderer, without another navigation step or edit action.
+History also preserves completion time, resume facts and validation-declaration
+revision/actor facts. Active and history consumers reuse the same TS inspector
+mapping and the existing Todo schema; the active queue's preview budgets remain
+unchanged. Unprovided dependencies are not presented as an empty dependency
+list, and absent resume readiness or validation revisions are not invented.
+This closes that history-enumeration/detail gap, not whole-Goal summary/list/detail
+adoption, sustained observation, D2 or default-provider admission. No persisted
+format, writer or PostgreSQL consumer is changed.
### Packaged-source fingerprint cost
diff --git a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-28-retirement-cadence.zh-CN.md b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-28-retirement-cadence.zh-CN.md
index 345a77f883..8ec905adae 100644
--- a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-28-retirement-cadence.zh-CN.md
+++ b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-28-retirement-cadence.zh-CN.md
@@ -376,7 +376,23 @@ Todo/租约集合中位数为 File 输入 32.4→27.5 ms、SQLite 输入 32.9
响应不变。代表性复核请求从超过 2 MiB 降到约 0.96 MB,没有提高预算。
真实 File/SQLite CLI 验证精确计数、跨远端记录的推断后继、metadata 保留与
provider 状态不变。这只修复有界容量,不证明任意规模、稳定延迟、D2 验收,
-也不授予默认 provider 切换;整 Goal summary/list/detail 消费和持续观察仍待推进。
+也不授予默认 provider 切换。
+
+### 保留完成记录的消费
+
+完成历史消费者现在显式读取活跃与归档的保留记录,
+复用既有类型化 summary/filter owner。此前 85 条完成记录归档 84 条后,工作区
+历史只返回 1 条;有界 HTTP 分页现在包含全部 85 条,保留完整任务文本、证据和
+Agent 筛选,仅暴露展示字段。显式历史读取不再套用既有的 500 字符摘要上限;
+默认活跃 Todo 列表与调度输入仍排除归档,并保留原展示上限。真实 File/SQLite
+和旧 Markdown 读取保持 provider 状态不变。打包工作区沿用已有分页,在只读
+Todo 详情中用共享 Markdown 组件呈现保留证据,不增加导航步骤或编辑动作。
+历史同时保留完成时间、恢复事实及验收声明的版本/修订者。活跃列表和历史
+复用同一 TS 详情映射与既有 Todo schema,活跃队列的预览预算保持不变。
+未提供的依赖列表不再展示为“无依赖”,缺失的恢复就绪性或验收版本也不虚构。
+这关闭历史枚举与详情缺口,不代表整 Goal summary/list/detail
+消费、持续观察、D2 或默认 provider 准入完成;不改变持久格式、writer 或
+PostgreSQL 消费者。
### 打包源码指纹成本
diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs
index 4af46458ae..829db0433f 100644
--- a/examples/personal-workspace-browser/fixture.mjs
+++ b/examples/personal-workspace-browser/fixture.mjs
@@ -901,7 +901,13 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true
const offset = Number(url.searchParams.get("cursor") || 0);
const items = Array.from({ length: Math.min(40, total - offset) }, (_, position) => {
const index = offset + position;
- return { todo_id: `todo_history_${index}`, text: index < 3 ? `Completed ${String.fromCharCode(65 + index)}` : `Completed historical Task ${index + 1}`, claimed_by: "example-agent", evidence: null, priority: null, task_class: "advancement_task" };
+ return { todo_id: `todo_history_${index}`, text: index < 3 ? `Completed ${String.fromCharCode(65 + index)}` : `Completed historical Task ${index + 1}`, claimed_by: "example-agent", evidence: index === 0 ? "Verified retained **completion evidence**." : null, priority: null, task_class: "advancement_task", done: true, status: "done",
+ completed_at: "2026-08-01T00:00:00Z", resume_when: index === 0 ? "todo_done:todo_history_1" : null, resume_ready: index === 0 ? true : null,
+ resume_condition: index === 0 ? { resume_receipt: { receipt_id: "receipt-completed-history" } } : null,
+ completion_validation_sha256: index === 0 ? "a".repeat(64) : null,
+ completion_validation_revision: index === 0 ? 3 : null,
+ completion_validation_revision_history: index === 0 ? [{ revision: 3, previous_declaration_sha256: "b".repeat(64), declaration_sha256: "a".repeat(64), actor_agent_id: "example-reviewer", revised_at: "2026-08-01T00:00:00Z" }] : [],
+ };
});
await route.fulfill({ json: { ok: true, total, items, next_cursor: offset + 40 < total ? String(offset + 40) : null } });
return;
diff --git a/examples/personal-workspace-browser/typed-actions.mjs b/examples/personal-workspace-browser/typed-actions.mjs
index fa5cbc05cf..f206f632df 100644
--- a/examples/personal-workspace-browser/typed-actions.mjs
+++ b/examples/personal-workspace-browser/typed-actions.mjs
@@ -946,6 +946,17 @@ export const typedActionsScenario = {
await page.screenshot({ path: resolve(outputDir, 'completed-history-4087.png'), fullPage: false, animations: 'disabled' });
await historyScroll.evaluate(element => { element.scrollTop = 0; });
await completedColumn.getByText('Completed A', { exact: true }).waitFor();
+ await completedColumn.getByText('Completed A', { exact: true }).click();
+ const completedDetail = page.getByRole('dialog', { name: 'Todo 详情' });
+ await completedDetail.getByRole('region', { name: '证据', exact: true }).getByText('Verified retained completion evidence.', { exact: true }).waitFor();
+ await completedDetail.getByText('todo_done:todo_history_1', { exact: true }).waitFor();
+ await completedDetail.getByText('receipt-completed-history', { exact: true }).waitFor();
+ await completedDetail.getByText('example-reviewer', { exact: true }).waitFor();
+ await completedDetail.getByText('a'.repeat(64), { exact: true }).waitFor();
+ await completedDetail.getByText('2026-08-01T00:00:00Z', { exact: true }).waitFor();
+ if (await completedDetail.getByText('依赖', { exact: true }).count()) throw new Error('An absent dependency projection was reported as no dependencies');
+ if (await completedDetail.getByRole('button', { name: '完成任务', exact: true }).count()) throw new Error('Completed history exposed a write action');
+ await page.keyboard.press('Escape');
// Both presentations retain one snapshot, including archived history and evidence.
let historyRequests = 0;
page.on('request', request => { if (request.url().includes('/api/chat/completed-todos?')) historyRequests += 1; });
diff --git a/examples/workspace-progressive-loading-browser-smoke.mjs b/examples/workspace-progressive-loading-browser-smoke.mjs
index f814ed3310..8c6bcead61 100644
--- a/examples/workspace-progressive-loading-browser-smoke.mjs
+++ b/examples/workspace-progressive-loading-browser-smoke.mjs
@@ -138,7 +138,7 @@ try {
historyRequests++;
return route.fulfill({ json: { ok: true, total: 1, next_cursor: null, items: [{
todo_id: "todo_native_done", text: historyText, claimed_by: null,
- evidence: "Verified public evidence", priority: "P2", task_class: "advancement_task",
+ evidence: "Verified public evidence", priority: "P2", task_class: "advancement_task", done: true, status: "done",
}] } });
});
await accessPage.goto(`${origin}/chat/`);
diff --git a/loopx/chat_completed_todos.py b/loopx/chat_completed_todos.py
index 99df5e29bf..59b92eda61 100644
--- a/loopx/chat_completed_todos.py
+++ b/loopx/chat_completed_todos.py
@@ -192,7 +192,7 @@ def _completed_todos(self) -> None:
def load():
payload = list_goal_todos(
registry_path=self.server.registry_path, goal_id=goal_id,
- role="agent", status="done",
+ role="agent", status="done", read_scope="completed_history",
runtime_root_arg=self.server.runtime_root_override,
)
ordered = sorted(enumerate(payload["todos"]), key=lambda pair: (str(pair[1].get("completed_at") or ""), pair[0]), reverse=True)
@@ -204,6 +204,16 @@ def load():
"evidence": item.get("evidence") or item.get("note") or None,
"priority": item.get("priority"),
"task_class": item.get("task_class"),
+ "done": item["done"],
+ "status": item["status"],
+ "task_domain": item.get("task_domain"),
+ "completed_at": item.get("completed_at"),
+ "resume_when": item.get("resume_when"),
+ "resume_ready": item.get("resume_ready"),
+ "resume_condition": item.get("resume_condition"),
+ "completion_validation_sha256": item.get("completion_validation_sha256"),
+ "completion_validation_revision": item.get("completion_validation_revision"),
+ "completion_validation_revision_history": item.get("completion_validation_revision_history") or [],
}
for _, item in ordered
if item.get("todo_id") and item.get("task_class") != "continuous_monitor"
diff --git a/loopx/control_plane/todos/goal_todo_projection.py b/loopx/control_plane/todos/goal_todo_projection.py
index 8cf94de6fa..f498f364c0 100644
--- a/loopx/control_plane/todos/goal_todo_projection.py
+++ b/loopx/control_plane/todos/goal_todo_projection.py
@@ -14,7 +14,7 @@
from ..goals.legacy_event_source import require_no_legacy_todo_events
from .active_state_editing import TODO_SECTION_HEADINGS
-from .active_state_todo_parser import parse_active_state_todos
+from .active_state_todo_parser import parse_active_state_todos, parse_todo_source
from .list_projection import compact_explicit_limit_todo_summary
from .succession_warning import public_todo_summary
from .contract import (
@@ -106,6 +106,7 @@ def goal_todo_summaries(
todo_id: str | None,
agent_id: str | None,
limit: int | None,
+ include_retained: bool = False,
) -> GoalTodoSummaries:
"""Project todo summaries from active-state text plus its event projection.
@@ -115,13 +116,19 @@ def goal_todo_summaries(
"""
require_no_legacy_todo_events(goal or {}, state_path=state_path)
- markdown_fields = parse_active_state_todos(
- state_text,
- goal=goal,
- state_path=state_path,
- item_limit=None,
- rollout_events=rollout_events,
- )
+ if include_retained:
+ active, archived, _sections = parse_todo_source(state_text, goal=goal, state_path=state_path)
+ markdown_fields = retained_todo_summary_fields(
+ [*active["user"], *active["agent"], *archived], rollout_events=rollout_events,
+ )
+ else:
+ markdown_fields = parse_active_state_todos(
+ state_text,
+ goal=goal,
+ state_path=state_path,
+ item_limit=None,
+ rollout_events=rollout_events,
+ )
return todo_summaries_from_fields(
fields=markdown_fields,
source="markdown_active_state",
@@ -182,6 +189,31 @@ def todo_summaries_from_fields(
)
+def retained_todo_summary_fields(
+ items: list[dict[str, Any]],
+ *,
+ rollout_events: list[dict[str, Any]],
+) -> dict[str, Any]:
+ """Feed retained source rows through the existing typed summary owner.
+
+ History is an explicit cold read; active scheduler/attention summaries
+ continue to exclude archives. Preserve full text, evidence and source ids.
+ """
+ return {
+ f"{role}_todos": compact_todo_group(
+ [item for item in items if item.get("role") == role],
+ source_section=TODO_SECTION_HEADINGS[role],
+ role=role,
+ include_empty_source=True,
+ resume_source_items=items,
+ rollout_events=rollout_events,
+ item_limit=None,
+ text_limit=None,
+ )
+ for role in ("user", "agent")
+ }
+
+
def exact_archived_todo_summaries(
*,
archived_items: list[dict[str, Any]],
diff --git a/loopx/control_plane/todos/todo_summary.py b/loopx/control_plane/todos/todo_summary.py
index fd1ad81cd0..eab1d1c0a6 100644
--- a/loopx/control_plane/todos/todo_summary.py
+++ b/loopx/control_plane/todos/todo_summary.py
@@ -789,6 +789,7 @@ def _structured_todo_group_items(
*,
source_section: str | None,
role: str | None,
+ text_limit: int | None,
) -> list[dict[str, Any]]:
return [
structured_todo_item(
@@ -796,6 +797,7 @@ def _structured_todo_group_items(
role=role,
source_section=source_section,
archive_state=todo_archive_state(item),
+ text_limit=text_limit,
)
if isinstance(item, dict)
else item
@@ -963,6 +965,7 @@ def compact_todo_group(
rollout_events: list[dict[str, Any]] | None = None,
available_capabilities: Any = None,
item_limit: int | None = MAX_STATUS_TODOS_PER_ROLE,
+ text_limit: int | None = 500,
include_task_orchestration_authority: bool = False,
vision_runs: list[dict[str, Any]] | None = None,
evaluated_at: str | None = None,
@@ -973,6 +976,7 @@ def compact_todo_group(
items,
source_section=source_section,
role=role,
+ text_limit=text_limit,
)
_apply_resume_conditions(
items,
diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json
index 75f293d6d3..b59904b897 100644
--- a/loopx/semantics/project_registry_io_manifest_v1.json
+++ b/loopx/semantics/project_registry_io_manifest_v1.json
@@ -2175,7 +2175,7 @@
},
{
"site": "loopx/todos.py::.list_goal_todos::codec_read:load_registry#1",
- "line": 192,
+ "line": 198,
"column": 16,
"kind": "codec_read",
"api": "load_registry",
diff --git a/loopx/todos.py b/loopx/todos.py
index b015a2015e..97274ea8ec 100644
--- a/loopx/todos.py
+++ b/loopx/todos.py
@@ -4,7 +4,7 @@
from contextlib import ExitStack
from json import dumps as json_dumps
from pathlib import Path
-from typing import Any
+from typing import Any, Literal
from .agent_registry import registered_agent_ids_from_registry, require_registered_agent_id
from .history import load_registry
@@ -84,6 +84,7 @@
)
from .control_plane.todos.goal_todo_projection import (
exact_archived_todo_summaries,
+ retained_todo_summary_fields,
goal_todo_summaries,
todo_summaries_from_fields,
)
@@ -180,7 +181,12 @@ def list_goal_todos(
runtime_root_arg: str | None = None,
limit: int | None = None,
thin: bool = False,
+ read_scope: Literal["active", "completed_history"] = "active",
) -> dict[str, Any]:
+ if read_scope not in {"active", "completed_history"}:
+ raise ValueError("Todo read_scope must be active or completed_history")
+ if read_scope == "completed_history" and (role != "agent" or status != "done"):
+ raise ValueError("Completed history requires role=agent and status=done")
normalized_todo_id = normalize_todo_id(todo_id) if todo_id else None
if todo_id and not normalized_todo_id:
raise ValueError("todo_id must use the public token shape todo_")
@@ -216,12 +222,14 @@ def list_goal_todos(
)
if canonical_read is not None:
projected = todo_summaries_from_fields(
- fields=canonical_todo_summary_fields(
+ fields=(retained_todo_summary_fields(
+ canonical_todo_items(canonical_read["todos"]), rollout_events=rollout_events,
+ ) if read_scope == "completed_history" else canonical_todo_summary_fields(
canonical_read["todos"],
rollout_events=rollout_events,
goal_acceptance_contract=canonical_read.get("goal_acceptance_contract"),
goal_acceptance_work_guards=canonical_read.get("goal_acceptance_work_guards"),
- ),
+ )),
source="file_authority",
rollout_events=rollout_events,
roles=roles,
@@ -244,8 +252,9 @@ def list_goal_todos(
todo_id=normalized_todo_id,
agent_id=normalized_agent_id,
limit=limit,
+ include_retained=read_scope == "completed_history",
)
- if normalized_todo_id and not projected.todos:
+ if read_scope == "active" and normalized_todo_id and not projected.todos:
if canonical_read is not None:
archived_items = [
item
@@ -278,7 +287,7 @@ def list_goal_todos(
matched_todo_count = len(todos)
agent_lane_hot_path = bool(
- normalized_agent_id and limit is None
+ read_scope == "active" and normalized_agent_id and limit is None
and role is None
and status is None
and normalized_todo_id is None
diff --git a/tests/test_chat_completed_todos.py b/tests/test_chat_completed_todos.py
index 6a1471afec..c3de1cc5dd 100644
--- a/tests/test_chat_completed_todos.py
+++ b/tests/test_chat_completed_todos.py
@@ -97,13 +97,27 @@ def read(**kwargs):
@pytest.mark.parametrize("count", [85, 4087])
-def test_http_history_reads_real_markdown_without_writes(tmp_path, count):
+@pytest.mark.parametrize("archived", [False, True])
+def test_http_history_reads_real_markdown_without_writes(tmp_path, count, archived):
state = tmp_path / "active.md"
- text = (f"Inspect {tmp_path}/results.txt " + "complete task description " * 25)[:420]
+ text = f"Inspect {tmp_path}/results.txt " + " ".join(["complete task description"] * 25)
evidence = f"Verified output at {tmp_path}/results.txt"
state.write_text("# Synthetic Goal\n\n## Agent Todo\n" + "\n".join(
f"- [x] {text}{index}\n " for index in range(count)
) + "\n\n## User Todo\n", encoding="utf-8")
+ if archived:
+ from loopx.control_plane.todos.completed_archive import archive_completed_todo_lines
+ if count == 85:
+ result = archive_completed_todo_lines(state.read_text().splitlines(), max_active_done=1)
+ assert result["moved_count"] == count - 1
+ state.write_text("\n".join(result["lines"]) + "\n", encoding="utf-8")
+ else:
+ # A pre-existing large archive tests history reads independently of
+ # the archive writer's per-request batch budget.
+ lines = state.read_text().splitlines()
+ first = next(index for index, line in enumerate(lines) if line.startswith("- [x]"))
+ lines.insert(first + 2, "\n## Completed Work Archive\n")
+ state.write_text("\n".join(lines).replace("status=done ", "role=agent status=done "))
registry = tmp_path / "registry.json"
registry.write_text(json.dumps({"runtime_root": str(tmp_path / "runtime"), "goals": [{"id": "history-goal", "repo": str(tmp_path), "state_file": "active.md"}]}))
before = state.read_bytes()
@@ -121,13 +135,132 @@ def test_http_history_reads_real_markdown_without_writes(tmp_path, count):
assert len(page["items"]) == 40
assert page["next_cursor"]
assert page["items"][0]["text"].startswith(text)
- assert len(page["items"][0]["text"]) > 400
+ assert len(page["items"][0]["text"]) > 600
assert page["items"][0]["evidence"] == evidence
with pytest.raises(HTTPError) as denied:
urlopen(Request(url, headers={"Origin": "https://unrelated.example"}))
assert denied.value.code == 403
+ ids = [row["todo_id"] for row in page["items"]]
+ assert all(row["text"] == text + row["todo_id"].removeprefix("todo_history_") for row in page["items"])
+ from urllib.parse import quote
+ while page["next_cursor"]:
+ with urlopen(url + "&cursor=" + quote(page["next_cursor"])) as response:
+ page = json.load(response)
+ ids.extend(row["todo_id"] for row in page["items"])
+ assert all(row["text"] == text + row["todo_id"].removeprefix("todo_history_") for row in page["items"])
+ assert len(ids) == len(set(ids)) == count
+ from loopx.todos import list_goal_todos
+ active = list_goal_todos(registry_path=registry, goal_id="history-goal", role="agent", status="done")
+ assert active["todo_count"] == (1 if archived else count)
assert state.read_bytes() == before
finally:
server.shutdown()
server.server_close()
worker.join()
+
+
+@pytest.mark.parametrize("provider", ["file", "sqlite"])
+def test_canonical_history_includes_archives_without_widening_active_lists(tmp_path, monkeypatch, provider):
+ from pathlib import Path
+ monkeypatch.syspath_prepend(str(Path(__file__).parent / "control_plane"))
+ 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.coordination.local_authority import read_canonical_todos_if_promoted
+ from loopx.todos import list_goal_todos
+
+ isolate_sqlite_runtime(tmp_path, monkeypatch)
+ state = tmp_path / "state.md"
+ state.write_text("# Synthetic Goal\n\n## Agent Todo\n\n## Completed Work Archive\n")
+ runtime = tmp_path / "runtime"
+ records = [
+ {"schema_version": "todo_item_v0", "todo_id": f"todo_history_{index}",
+ "index": index + 1, "role": "agent", "status": "done", "done": True,
+ "text": f"Completed task {index}: " + " ".join(["Full retained task description"] * 20),
+ "archive_state": "archive" if index < 84 else "active",
+ "source_section": "Completed Work Archive" if index < 84 else "Agent Todo",
+ "task_class": "advancement_task", "claimed_by": "agent-a" if index % 2 else "agent-b",
+ "priority": "P2", "required_capabilities": ["local-fixture-capability"],
+ "note": "Retain full evidence " + "verified " * 50,
+ "completed_at": f"2026-01-{index % 28 + 1:02d}T00:00:00Z"}
+ for index in range(85)
+ ]
+ records[0].update({
+ "task_domain": "quality",
+ "resume_when": "todo_done:todo_history_84",
+ "completion_validation_sha256": "a" * 64,
+ "completion_validation_revision": 3,
+ "completion_validation_revision_history": [{
+ "revision": 3, "previous_declaration_sha256": "b" * 64,
+ "declaration_sha256": "a" * 64, "actor_agent_id": "agent-a",
+ "revised_at": "2026-01-01T00:00:00Z",
+ }],
+ })
+ records.extend([
+ {**records[-1], "todo_id": "todo_monitor", "task_class": "continuous_monitor"},
+ {**records[-1], "todo_id": "todo_open", "status": "open", "done": False},
+ {**records[-1], "todo_id": "todo_deferred", "status": "deferred"},
+ {**records[-1], "todo_id": "todo_user", "role": "user", "task_class": "user_action"},
+ ])
+ projection = build_todo_runtime_shadow_projection(goal_id="history-goal", todos=records, handoff_mode="soft_claim")
+ initialize_canonical_authority(runtime, "history-goal", projection, state_path=state, provider=provider)
+ # A canonical history reader must not fall back to a stale display file.
+ state.unlink()
+ registry = tmp_path / "registry.json"
+ registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [
+ {"id": "history-goal", "repo": str(tmp_path), "state_file": "state.md"}]}))
+ before = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="history-goal")
+ active = list_goal_todos(registry_path=registry, goal_id="history-goal", role="agent", status="done")
+ assert {row["todo_id"] for row in active["todos"]} == {"todo_history_84", "todo_monitor"}
+ assert all(len(row["text"]) <= 500 and row["text"].endswith("…") for row in active["todos"])
+ exact = list_goal_todos(registry_path=registry, goal_id="history-goal", todo_id="todo_history_0")
+ assert exact["todos"][0]["archive_state"] == "archive"
+ with pytest.raises(ValueError, match="requires role=agent and status=done"):
+ list_goal_todos(registry_path=registry, goal_id="history-goal", read_scope="completed_history")
+ with pytest.raises(ValueError, match="read_scope must be"):
+ list_goal_todos(registry_path=registry, goal_id="history-goal", read_scope="unknown")
+ server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler)
+ server.registry_path, server.runtime_root_override, server.verbose = registry, runtime, False
+ worker = threading.Thread(target=server.serve_forever, daemon=True)
+ worker.start()
+ try:
+ from urllib.parse import quote
+ base = f"http://127.0.0.1:{server.server_port}/api/chat/completed-todos?goal_id=history-goal"
+ for suffix, expected in [("", 85), ("&agent_id=agent-a", 42)]:
+ cursor, rows = "", []
+ while True:
+ with urlopen(base + suffix + "&cursor=" + quote(cursor)) as response:
+ page = json.load(response)
+ assert page["total"] == expected
+ assert len(page["items"]) <= 40
+ rows.extend(page["items"])
+ cursor = page["next_cursor"]
+ if not cursor:
+ break
+ assert len(rows) == len({row["todo_id"] for row in rows}) == expected
+ assert all(row["evidence"] == records[0]["note"] for row in rows)
+ assert all(row["priority"] == "P2" for row in rows)
+ source_by_id = {record["todo_id"]: record for record in records}
+ assert all(row["text"] == source_by_id[row["todo_id"]]["text"] for row in rows)
+ assert all(row["completed_at"] == source_by_id[row["todo_id"]]["completed_at"] for row in rows)
+ assert all(row["done"] is True and row["status"] == "done" for row in rows)
+ # Existing inspector facts travel with their source, while private
+ # execution declarations and capability metadata remain excluded.
+ assert all("required_capabilities" not in row and "completion_validation" not in row for row in rows)
+ if not suffix:
+ detail = next(row for row in rows if row["todo_id"] == "todo_history_0")
+ assert detail["task_domain"] == "quality"
+ assert detail["resume_when"] == "todo_done:todo_history_84"
+ assert detail["resume_ready"] is True
+ assert detail["completion_validation_sha256"] == "a" * 64
+ assert detail["completion_validation_revision"] == 3
+ assert detail["completion_validation_revision_history"] == records[0]["completion_validation_revision_history"]
+ if suffix:
+ assert all(row["claimed_by"] == "agent-a" for row in rows)
+ after = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="history-goal")
+ assert after["provider_revision"] == before["provider_revision"]
+ assert after["todos"] == before["todos"]
+ assert not state.exists()
+ finally:
+ server.shutdown()
+ server.server_close()
+ worker.join()