From 6da4dab77e81586226d169adaaff5553e66f7e2d Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:54:22 +0800 Subject: [PATCH 1/2] fix(dashboard): accept native Todos without source indexes Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- apps/presentation/dashboard/src/data/status.ts | 11 ++++++++--- .../dashboard/src/views/dashboard-page.tsx | 3 --- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/presentation/dashboard/src/data/status.ts b/apps/presentation/dashboard/src/data/status.ts index 613a98ced2..724dca9573 100644 --- a/apps/presentation/dashboard/src/data/status.ts +++ b/apps/presentation/dashboard/src/data/status.ts @@ -63,7 +63,9 @@ export const reviewMaterialSchema = z.object({ }); export const todoItemSchema = z.object({ - index: z.number(), + // Legacy Markdown Todos have a source index. Native Todos are addressed by + // todo_id and intentionally have no synthetic index. + index: z.number().optional().nullable(), done: z.boolean(), text: z.string(), schema_version: z.string().optional().nullable(), @@ -96,7 +98,10 @@ export const todoItemSchema = z.object({ revised_at: z.string(), }).passthrough()).optional().default([]), review_materials: z.array(reviewMaterialSchema).optional().default([]), -}).passthrough(); +}).passthrough().refine( + (todo) => todo.index != null || Boolean(todo.todo_id?.trim()), + { path: ["todo_id"], message: "Todo without a source index requires todo_id" }, +); export const todoGroupSchema = z.object({ source_section: z.string().optional().nullable(), @@ -108,7 +113,7 @@ export const todoGroupSchema = z.object({ deferred_items: z.array(todoItemSchema).optional(), }); -export const todoIndexItemSchema = todoItemSchema.extend({ +export const todoIndexItemSchema = todoItemSchema.safeExtend({ goal_id: z.string(), source: z.string().optional().nullable(), event_count: z.number().optional().default(0), diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 9fb41c96e4..e442c2e25c 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -219,7 +219,6 @@ type PersonalAgentTodoItem = { claimedBy?: string | null; done: boolean; evidence?: string | null; - index: number; priority?: string | null; status?: string | null; taskClass?: string | null; @@ -725,7 +724,6 @@ function personalAgentTodoFromItem(todo: TodoItem, row: GoalDirectoryRow): Perso // Legacy summaries mark deferred entries checked; they are not completed work. done: todo.status === "deferred" ? false : todo.done, evidence: todo.evidence ? compactShareText(todo.evidence, 96) : null, - index: todo.index, priority: todo.priority ?? null, status: todo.status ?? null, taskClass: todo.task_class ?? null, @@ -787,7 +785,6 @@ function personalAgentTodoFromProjection( return { claimedBy: todo.claimed_by ?? null, done: todo.status === "done" || todo.status === "completed", - index: -1, priority: todo.priority ?? null, status: todo.status ?? null, taskClass: todo.task_class ?? null, From 1491bcb290b388f88db2ce94f3dd0a0fc38189e2 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:54:45 +0800 Subject: [PATCH 2/2] test(dashboard): keep mixed Todo Goal status loadable Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../smoke/status-projection-contract-smoke.ts | 34 +++++++++++++++++++ ...pace-progressive-loading-browser-smoke.mjs | 10 ++++++ .../references/repair-patterns.md | 1 + 3 files changed, 45 insertions(+) diff --git a/apps/presentation/dashboard/smoke/status-projection-contract-smoke.ts b/apps/presentation/dashboard/smoke/status-projection-contract-smoke.ts index 7bcf0233cf..137013ac63 100644 --- a/apps/presentation/dashboard/smoke/status-projection-contract-smoke.ts +++ b/apps/presentation/dashboard/smoke/status-projection-contract-smoke.ts @@ -2,6 +2,8 @@ import { z } from "zod"; import { parseStatusPayload, + todoItemSchema, + todoIndexItemSchema, periodicReportIndexItemSchema, periodicReportIndexResponseSchema, } from "../src/data/status"; @@ -76,6 +78,38 @@ function goal(id: string, activation: "active" | "stopped") { }; } +// Native Todo identity is the stable todo_id; a Markdown source index is only +// a legacy display coordinate. Both forms must survive a full status parse. +const nativeTodo = { done: false, text: "Review public evidence", todo_id: "todo_native" }; +const nativeStatus = basePayload({ + attention_queue: { + available: true, item_count: 1, needs_user_or_controller: 0, + needs_controller: 0, needs_codex: 1, watching_external_evidence: 0, + items: [{ goal_id: "native", status: "running", waiting_on: "codex", + severity: "normal", recommended_action: "continue", + agent_todos: { items: [nativeTodo] }, + project_asset: { owner: "agent", gate: "none", next_action: "review", + stop_condition: "accepted", agent_todos: { + items: [{ ...nativeTodo, index: null }], + recent_completed_advancement_items: [{ ...nativeTodo, todo_id: "todo_done", done: true }], + } }, + }], + }, + todo_index: { items: [{ ...nativeTodo, index: null, goal_id: "native" }] }, +}); +equal(nativeStatus.attention_queue.items[0].agent_todos?.items[0].index, undefined, + "native Todo without a source index remains addressable"); +equal(nativeStatus.todo_index?.items[0].index, null, + "Todo index readback preserves the explicitly absent source coordinate"); +assert(todoItemSchema.safeParse({ index: 3, done: false, text: "Legacy Todo" }).success, + "legacy source-index Todo remains valid without a stable id"); +assert(!todoItemSchema.safeParse({ done: false, text: "Anonymous Todo" }).success, + "missing both source index and stable id is still invalid"); +assert(!todoIndexItemSchema.safeParse({ goal_id: "native", index: null, done: false, text: "Anonymous Todo" }).success, + "Todo index must retain the same identity guard"); +assert(!todoItemSchema.safeParse({ ...nativeTodo, index: "3" }).success, + "non-numeric source coordinates remain invalid"); + const activePayload = basePayload({ goal_projection: { schema_version: "loopx_goal_projection_scope_v0", diff --git a/examples/workspace-progressive-loading-browser-smoke.mjs b/examples/workspace-progressive-loading-browser-smoke.mjs index 64c435b928..de18d94fed 100644 --- a/examples/workspace-progressive-loading-browser-smoke.mjs +++ b/examples/workspace-progressive-loading-browser-smoke.mjs @@ -19,6 +19,16 @@ function snapshot(id) { const payload = structuredClone(require(resolve(root, "examples/status.example.json"))); payload.run_history.goals = [{ ...payload.run_history.goals[0], id, display_name: `${id} project`, activation_state: id === "archived" ? "stopped" : "active", registry_member: true }]; for (const item of payload.attention_queue.items) item.goal_id = id; + if (id === "ready") { + const native = { done: false, text: "Review public evidence", todo_id: "todo_native_ready" }; + payload.attention_queue.items[0].agent_todos.items.unshift(native); + payload.attention_queue.items[0].project_asset = { + owner: "agent", gate: "none", next_action: "review", stop_condition: "accepted", + agent_todos: { items: [{ ...native, index: null }], + recent_completed_advancement_items: [{ ...native, todo_id: "todo_native_done", done: true }] }, + }; + payload.todo_index.items.unshift({ ...native, index: null, goal_id: id }); + } payload.workspace_registry_revision = directory.registry_revision; return payload; } diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index 77f8339376..d480a22027 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -5,6 +5,7 @@ teaches a reusable control-plane lesson. | Pattern | Symptoms | Evidence To Read | Likely Root | Durable Repair | | --- | --- | --- | --- | --- | +| `native_todo_status_index_schema_gap` | A Goal shows “status load failed / invalid response” after native Todos appear, while the scoped status endpoint returns valid JSON. | Exact scoped status response, Zod issue paths, Todo `todo_id` and `index` fields, native presentation contract, packaged Goal load. | The dashboard still requires every Todo to have a numeric source index, but native Todos deliberately use stable `todo_id` with absent or null index. One row rejects the entire Goal snapshot. | Accept nullable/absent index only when a nonempty stable Todo ID exists; keep numeric legacy indexes and reject anonymous or malformed rows. Render and act by Todo ID, then prove a mixed native/legacy scoped snapshot loads in the packaged UI. | | `capability_catalog_editor_kind_drift` | Machine or Goal settings report an empty capability list even though the configuration API returns registered capabilities. | Live API catalog IDs and editor kinds, the dashboard's accepted field-kind schema, and the page's load-error state. | One new descriptor emits an unsupported field kind; strict validation rejects the shared catalog and the machine page presents the failed load as an empty registry. | Keep the published editor vocabulary aligned with the browser contract, check every built-in descriptor together, and show a retryable error when catalog loading or validation fails. Only a successfully loaded empty catalog may show the empty state. | | `acceptance_scope_capture` | A bounded validation experiment leaves unrelated existing/new work unbound; a recorded blocker quiets replan without repairing admission. | Canonical contract scope/bindings, exact held generation, authorized configuration source, ordinary task validators and post-correction claim/lease readback. | Omitted scope silently imposed Goal-wide acceptance; repeated per-task binding masked the missing scope contract. | Require explicit scope on new owner configuration, preserve legacy persisted semantics/replay, and enforce one typed scope across admission, completion and verification freshness. Expose scope on existing read surfaces. Diagnose scope before proposing rebinding; a blocker ACK is neither a repair nor a handoff. Apply authorized corrections through CAS and validate independent work resumes while selected holds and ordinary validation remain. | | `acceptance_hold_recovery_selection_split` | Newly created advancement work is acceptance-unbound, repeated vision replans never expose its hold, or a replan packet also selects an unrelated due monitor. | Canonical acceptance tasks, scoped source Todos, bounded trigger checkpoints, effective action and original Turn receipt. | Recovery covered stale associations only; generic vision gaps displaced hold identities; candidate inventory leaked into the selected execution target. | Route missing and stale associations through the existing bounded replan lane, retain exact hold checkpoints before generic gaps, and separate replan from candidate selection. Keep owner association and completion validation enforced. A new unbound repair Todo is not runnable recovery; only a qualified successor or concrete blocker settles the exact hold. Validate real File/SQLite CLI paths and receipt reentry without mutating an active Goal. |