From 8d284ae049ded275b9989a14efefcd895707b3de Mon Sep 17 00:00:00 2001 From: hhyykk <4408344+hhyykk@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:51:32 +0800 Subject: [PATCH 1/4] refactor(todos): retire Python summary display rules Compose standalone summary and planning displays with the existing TypeScript context and handoff owners. Keep Python raw-value codecs, bounded transport and public compatibility adapters; build quota displays in process before both independent planning decisions. Pin pre-cutover synthetic CLI rows, move display rule cases to TypeScript, and record bilingual retirement evidence and remaining facade exits. Complete pinned-clock CLI outputs stay byte-identical. Measured quota crossings fall from 75 to 74; the estimated 73 target remains unqualified because empty User planning had no display request to retire. The synthetic credential-label test vector was restructured for the public-boundary scanner. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> Signed-off-by: hhyykk <4408344+hhyykk@users.noreply.github.com> --- ...6-10-26-todo-summary-display-projection.md | 57 ++++ ...6-todo-summary-display-projection.zh-CN.md | 50 +++ .../typescript-control-plane-migration-v0.md | 14 + ...script-control-plane-migration-v0.zh-CN.md | 11 + .../control_plane/todos/context_projection.ts | 122 ++++++- loopx/control_plane/todos/handoff_note.py | 88 ++--- loopx/control_plane/todos/quota_selection.py | 10 +- loopx/control_plane/todos/quota_selection.ts | 11 +- loopx/control_plane/todos/read_codec.ts | 50 ++- loopx/control_plane/todos/summary_item.py | 311 +++--------------- loopx/control_plane/todos/todo_index.py | 8 +- .../test_agent_task_visibility.py | 33 -- .../test_blocked_transition_notice.py | 15 - .../test_handoff_context_projection.py | 117 ++----- .../test_todo_summary_projection.py | 32 -- .../fixtures/todo_summary_display.json | 292 ++++++++++++++++ .../control_plane_ts/quota_selection.test.ts | 16 + .../todo_context_projection.test.ts | 69 ++++ .../todo_summary_lanes.test.ts | 36 ++ .../todo_summary_projection.test.ts | 40 +++ 20 files changed, 857 insertions(+), 525 deletions(-) create mode 100644 docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.md create mode 100644 docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.zh-CN.md create mode 100644 tests/control_plane_ts/fixtures/todo_summary_display.json diff --git a/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.md b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.md new file mode 100644 index 0000000000..086b7cfd60 --- /dev/null +++ b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.md @@ -0,0 +1,57 @@ +# Todo summary-display projection retirement (2026-10-26) + +| Boundary | Delivered evidence | +| --- | --- | +| Goal/source | Existing TypeScript migration T3/T4 transaction payoff; baseline `c6462f902`. One read-only summary-display slice, with no new capability, RPC operation, provider or runtime. | +| Observable gap | Python still selected summary/planning sources, normalized display declarations and assembled handoff displays before asking the typed quota owner to select lanes. | +| Single owner | `todos/context_projection.ts` now owns standalone summary compaction, source order/deduplication, ready-successor overlays and terminal-inclusive frontier preservation through the existing `todo.context.page`. `todos/read_codec.ts` reuses `handoffSource`; quota planning composes the display in process before lane decisions. Both complete planning decisions remain independent. | +| Retired symbols/callers | Python `_compact_todo_summary_item_record`, `_refs`, `_TEXT_FIELDS` and `handoff_context_source` are deleted. The bodies of `compact_todo_summary_items`, `todo_summary_source_items` and `todo_planning_source_items` become transport facades. `todo_index.py` sends raw record facts for both handoff stages. Existing `todo_summary.py` compact/open callers reach the same raw-fact adapter through `attach_todo_handoff_notes`; they require no new decision branch. `quota_selection.py` removes its display-source join and sends raw display facts. | +| Retained Python placement | `summary_item.py` retains public import signatures, source-key compatibility exports, empty/bounded transport, response validation and `todo_text_content_revision`. `handoff_note.py` retains generic JSON conversion with Python string/repr facts for tuples, sets, floats and other non-JSON scalars, batching/private snapshots, rejection propagation and mutable-row attachment identity. Existing handoff-gate source IO/compatibility remains. No Python decision fallback or per-field RPC is added. | +| Facade deletion condition | Delete the summary/handoff transport facades and their raw-fact request carriers after the last Python display, index, quota, management and planning callers consume the existing typed owners in process. Delete `todo_text_content_revision` only after its last compatibility caller exits. This does not retire the other context-page callers, delegation facts, Turn recovery or monitor-poll facades. | +| Formats/rollback | No persisted format, authority, CAS, writer fence, lease, receipt, provider selection or default changes. Rollback is a code-only revert; no data migration. | + +## Cost and real-path evidence + +Production source: `summary_item.py` 329 → 100 lines, `handoff_note.py` 150 → +112 lines; Python −273 net, TypeScript +157 net, production −116 net. The seven +production files total 242 added / 358 deleted lines. The related refactoring +pass reuses the existing handoff/read codecs rather than introducing another +owner or RPC leaf. Larger caller retirement is deferred to the existing exits. + +One real-CLI trace per command and fixture size (N=1/N=3) gives: + +| Command | Crossings base → candidate | Stdout bytes N=1 | Stdout bytes N=3 | +| --- | --- | --- | --- | +| `agent-context --phase before_plan` | 1 → 1 | 1885 → 1885 | 2247 → 2247 | +| `agent-context --phase after_delegate_result` | 3 → 3 | 1612 → 1612 | 1612 → 1612 | +| Same result phase with a Turn identity | 3 → 3 | 2594 → 2594 | 2594 → 2594 | +| `quota should-run` | 75 → 74 | 25520 → 25520 | 25904 → 25904 | + +The estimated 73-crossing target is **not met**: the first (empty user) planning +call has no display rows and therefore no preceding display RPC to remove. +Only the nonempty Agent planning display crossing is retired. Do not remove a +second planning decision or absorb an unrelated caller to manufacture the +estimate. Fewer crossings do not certify latency improvement; the matched +25-run performance comparison remains external qualification. + +The pre-cutover synthetic quota Todo rows and their Python display results are +pinned in `tests/control_plane_ts/fixtures/todo_summary_display.json`. An external isolated-runtime replay with Python/Node wall clocks pinned matches +all eight complete CLI outputs byte for byte, including `context_id`, against +the base Python owners. Independent TS cases cover nested-field precedence, +legacy identity, Unicode and credential +exclusion before truncation, bounded/public-safe blocked causes, open-advancement +hint visibility, normalized declarations, last-outcome precedence, ordered source +deduplication, explicit false readiness and terminal frontier fields. Python +retains real File/SQLite CLI recovery, mutation-identity, large/oversize transport +and fail-closed adapter tests, including tuple/set/float facts. + +Focused validation: 89 TS tests pass; typecheck, changed-Python ruff, configured +mypy (17 files), diff whitespace and the semantic advisory pass. The requested +Python set reports 467 passed and one temporary-path depth skip, with four +existing legacy-path failures (three quota legacy-provider +cases and `test_absent_fence_preserves_legacy_path_without_starting_typescript`), +reproduced with the base Python owners in the same environment. These remain +failures, not passing skips. The configured mypy list does not cover the changed +Python modules. Real installed-wheel qualification, the minimum Node lane and +isolated PostgreSQL CI are unverified here; no PostgreSQL code is changed. This +source checkpoint does not certify full CI, installed delivery or a latency gain. diff --git a/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.zh-CN.md b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.zh-CN.md new file mode 100644 index 0000000000..6eefb9b51e --- /dev/null +++ b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.zh-CN.md @@ -0,0 +1,50 @@ +# Todo 摘要展示投影退役(2026-10-26) + +| 边界 | 交付证据 | +| --- | --- | +| 目标/来源 | 既有 TypeScript 迁移 T3/T4 事务收益;基线 `c6462f902`。仅一个只读摘要展示切片,不新增 capability、RPC 操作、provider 或 runtime。 | +| 可观察缺口 | Python 仍在选择摘要/规划来源、规范化展示声明,并在请求 typed quota owner 选择 lane 前组装 handoff 展示。 | +| 唯一 owner | `todos/context_projection.ts` 通过既有 `todo.context.page` 统一负责独立摘要压缩、来源顺序/去重、后继就绪覆盖以及含终态的 frontier 字段保留。`todos/read_codec.ts` 复用 `handoffSource`;quota planning 在进程内先组合展示再决定 lane。两次完整规划决策保持独立。 | +| 删除的符号/调用方 | 删除 Python `_compact_todo_summary_item_record`、`_refs`、`_TEXT_FIELDS`、`handoff_context_source`。`compact_todo_summary_items`、`todo_summary_source_items`、`todo_planning_source_items` 的实现缩为传输 facade。`todo_index.py` 的两个 handoff 阶段改传原始记录事实。`todo_summary.py` 既有 compact/open 调用经 `attach_todo_handoff_notes` 使用同一原始事实适配器,无需新增决策分支。`quota_selection.py` 删除展示来源 join,改传原始展示事实。 | +| 保留 Python 的放置依据 | `summary_item.py` 保留公开导入签名、来源 key 兼容导出、空批次/有界传输、响应校验及 `todo_text_content_revision`。`handoff_note.py` 保留通用 JSON 转换,为 tuple、set、float 及其他非 JSON 标量提供 Python string/repr 事实,并保留分批/私有快照、拒绝传播及可变行 attachment identity。既有 handoff-gate 来源 IO/兼容适配保留。不新增 Python 决策回退或逐字段 RPC。 | +| Facade 删除条件 | 最后一个 Python 展示、索引、quota、管理及规划调用方改为在进程内消费既有 typed owner 后,删除摘要/handoff 传输 facade 及原始事实请求载体。`todo_text_content_revision` 仅在最后一个兼容调用方退出后删除。本切片不退役其他 context-page 调用方、委派事实、Turn recovery 或 monitor-poll facade。 | +| 格式/回滚 | 不改变持久化格式、authority、CAS、writer fence、lease、receipt、provider 选择或默认行为。仅回退代码即可,无需数据迁移。 | + +## 成本与真实路径证据 + +生产源码:`summary_item.py` 从 329 降至 100 行,`handoff_note.py` 从 150 +降至 112 行;Python 净减 273 行,TypeScript 净增 157 行,生产代码净减 +116 行。七个生产文件共增加 242/删除 358 行。相关重构复用既有 handoff/read +codec,不新增 owner 或 RPC leaf。更大范围调用方退役沿用既有退出条件。 + +对每条命令、每个夹具规模(N=1/N=3)各执行一次真实 CLI 跟踪,结果如下: + +| 命令 | 跨运行时调用:基线 → 候选 | Stdout 字节 N=1 | Stdout 字节 N=3 | +| --- | --- | --- | --- | +| `agent-context --phase before_plan` | 1 → 1 | 1885 → 1885 | 2247 → 2247 | +| `agent-context --phase after_delegate_result` | 3 → 3 | 1612 → 1612 | 1612 → 1612 | +| 同一 result 阶段,带 Turn identity | 3 → 3 | 2594 → 2594 | 2594 → 2594 | +| `quota should-run` | 75 → 74 | 25520 → 25520 | 25904 → 25904 | + +预估的 73 次目标**尚未达到**:首次规划针对空 User 来源,没有展示行,因此原本 +就不存在可删除的前置展示 RPC。仅退役非空 Agent 规划的展示调用。不得通过删除 +第二次规划决策或合并无关调用方来凑足预估。调用减少不证明延迟改善;同条件 +25 次性能对照仍由外部独立验收。 + +退役前的合成 quota Todo 行及其 Python 展示结果固定在 +`tests/control_plane_ts/fixtures/todo_summary_display.json`。在外部隔离 runtime 中固定 Python/Node 墙钟后,八条完整 CLI 输出与重放基线 +Python owner 的输出逐字节一致(包括 `context_id`)。独立 TS 用例覆盖 +嵌套字段优先级、旧 identity、Unicode 与截断前凭据排除、有界且公开安全的阻塞 +原因、仅 open advancement 可见的 hint、声明规范化、同 scope 最后 outcome 优先、 +有序来源去重、显式 false readiness 以及终态 frontier 字段。Python 保留真实 +File/SQLite CLI 恢复、可变行 identity、大/超大传输及 fail-closed 适配测试, +包括 tuple/set/float 事实。 + +聚焦验证:89 个 TS 测试通过;typecheck、变更 Python 的 ruff、配置内 mypy +(17 个文件)、diff 空白检查和语义 advisory 通过。要求的 Python 集合为 467 个通过、1 个临时路径深度限制的 skip,并存在四个 +既有 legacy 路径失败(三个 quota legacy-provider 用例,以及 +`test_absent_fence_preserves_legacy_path_without_starting_typescript`),在相同 +环境加载基线 Python owner 后复现。它们仍是失败,不计为通过的 skip。mypy +配置文件列表不覆盖本次修改的 Python 模块。真实 installed-wheel、最低 Node +版本及隔离 PostgreSQL CI 尚未验证;未修改 PostgreSQL 代码。本源码检查点不 +认证完整 CI、已安装交付或延迟收益。 diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index feca1885fe..616d362a7f 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -15,6 +15,20 @@ --- +### Todo summary-display projection has one TypeScript owner (2026-10-26) + +The existing `todo.context.page` now owns standalone summary/planning source +selection, display normalization and handoff composition. Quota planning +composes display in process; Python retains JSON/Python-value compatibility, +bounded transport, mutable-row attachment and the text-revision codec. +No persisted format or authority boundary changes. Python production shrinks +273 lines; total production shrinks 116. Real CLI stdout lengths are unchanged +for N=1/N=3; crossings are 1/3/3/74, versus baseline 1/3/3/75. The estimated +73-crossing target remains unmet because the empty User planning call had no +preceding display RPC. Both planning decisions remain. Existing legacy-path +test failures, installed/CI and matched latency qualification remain explicit +limits. [Retired symbols, evidence and facade exits](ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.md). + ### Goal agent-context delegation projection converges in TypeScript (2026-10-25) `capability_hook.agent_context.project` diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index 701cf99955..462a2e686e 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -15,6 +15,17 @@ --- +### Todo 摘要展示投影收敛至唯一 TypeScript owner(2026-10-26) + +既有 `todo.context.page` 统一负责独立摘要/规划来源选择、展示规范化及 handoff +组合。Quota planning 在进程内组合展示;Python 保留 JSON/Python 值兼容、 +有界传输、可变行 attachment 及文本 revision codec。不改变持久化格式或 authority +边界。Python 生产代码净减 273 行,生产代码总计净减 116 行。N=1/N=3 的真实 +CLI stdout 长度不变;跨运行时调用为 1/3/3/74,基线为 1/3/3/75。预估的 +73 次目标尚未达到,因为空 User 规划原本没有前置展示 RPC。两次规划决策保留。 +既有 legacy 路径测试失败、installed/CI 及同条件延迟资格仍为明确限制。 +[删除符号、证据与 facade 退出条件](ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.zh-CN.md)。 + ### Goal agent-context 委派投影收敛至 TypeScript(2026-10-25) `capability_hook.agent_context.project` diff --git a/loopx/control_plane/todos/context_projection.ts b/loopx/control_plane/todos/context_projection.ts index 22beacb5e0..a9bdfae0d1 100644 --- a/loopx/control_plane/todos/context_projection.ts +++ b/loopx/control_plane/todos/context_projection.ts @@ -1,3 +1,9 @@ +import {decodeTodoReadSource, handoffReadSource, handoffSource, copyTodoPythonValues, pythonFieldText, + id, object, taskClass, readResume, readScope, readScopes, readExcludedAgents, writeScopes, compactTodoReadText} from "./read_codec.ts"; +import {stripPythonWhitespace} from "../coordination/todo_agents.ts"; +import {normalizeTodoRepository} from "./work_requirements.ts"; +import {INTERNAL_STATE_SHAPE_PATTERNS} from "../goals/vision_checkpoint.ts"; +import codec from "./read_legacy_codec.json" with {type: "json"}; import {projectTodoHandoffContext} from "./handoff_projection.ts"; import type { JsonObject } from "../effect_program.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; @@ -16,6 +22,10 @@ const CONTEXT_FIELDS = [ /** Select before redaction; never convert nested scopes or booleans to prose. */ export function projectTodoContextPage(input: JsonObject): JsonObject { + if (input.summary_display !== undefined) { + if (Object.keys(input).length !== 1) throw new EffectRuntimeRequestError("summary display cannot be combined with other views"); + return {items: projectTodoSummaryDisplay(requireJsonObject(input.summary_display, "summary display"))}; + } if (input.thin_summary_payload !== undefined) { const details = requireJsonObject(input.details, "thin summary details"); return compactThinTodoReadSummary(requireJsonObject(input.thin_summary_payload, "thin Todo summary"), { @@ -35,7 +45,9 @@ export function projectTodoContextPage(input: JsonObject): JsonObject { if (Object.keys(input).some(key => key !== "handoff_sources" && key !== "handoff_followups")) { throw new EffectRuntimeRequestError("handoff context cannot be combined with list/detail/page inputs"); } - return {handoff_context: projectTodoHandoffContext(input.handoff_sources, input.handoff_followups)}; + return {handoff_context: projectTodoHandoffContext( + Array.isArray(input.handoff_sources) ? input.handoff_sources.map(row => handoffReadSource(requireJsonObject(row, "handoff source"))) : input.handoff_sources, + Array.isArray(input.handoff_followups) ? input.handoff_followups.map(row => row === null ? null : handoffReadSource(requireJsonObject(row, "handoff followup"))) : input.handoff_followups)}; } if (input.list_payload !== undefined) return projectTodoListPayload(input); if (input.detail_payload !== undefined) return projectTodoDetailPayload(input); @@ -158,3 +170,111 @@ function projectTodoDetailPayload(input: JsonObject): JsonObject { }; return result; } + +const SUMMARY_FIELDS = [ + "goal_acceptance_guard", "schema_version", "todo_id", "content_revision", "role", + "status", "priority", "title", "archive_state", "source_section", + "task_class", "action_kind", "task_domain", "capability_binding_ref", "task_repository", + "continuation_policy", "removed_continuation_policy", "required_write_scopes", "required_capabilities", "target_capabilities", + "decision_scope", "required_decision_scopes", "decision_outcome", "decision_scope_outcomes", "claimed_by", + "bound_agent", "goal_bound", "blocks_agent", "excluded_agents", "global_gate", + "unblocks_todo_id", "resume_when", "resume_monitor_generation", "resume_condition", "resume_ready", + "blocked_retry", "no_followup", "successor_todo_ids", "completion_continuation", "completion_recovery", + "replan_obligation_id", "target_key", "cadence", "next_due_at", "expires_at", + "watch_only", "last_checked_at", "result_hash", "consecutive_no_change", "material_change", + "material_change_generation", "max_no_change_before_replan", "route_continuation_replan_required", "route_continuation_reason", "route_id", + "route_key", "completed_at", "updated_at", "completion_turn_key", "superseded_by", "handoff_note", +]; + +const SUMMARY_SOURCES = [ + "active_next_action_items", "active_next_action_executable_items", "first_open_items", "backlog_items", "unclaimed_priority_open_items", + "claimed_open_items", "claimed_advancement_open_items", "claimed_monitor_open_items", "monitor_open_items", "current_agent_claimed_open_items", + "current_agent_claimed_advancement_items", "current_agent_claimed_monitor_items", "blocker_items", "resume_blocked_items", "monitor_blocked_resume_candidates", + "current_agent_monitor_blocked_resume_candidates", "unclaimed_monitor_blocked_resume_candidates", "items", +]; + +const PLANNING_SOURCES = ["items", "deferred_items", "blocker_items", "monitor_open_items"]; + +function compactSummaryRecord(item: JsonObject, body: unknown): JsonObject { + const row: JsonObject = {index: item.index ?? null, text: body}; + for (const key of SUMMARY_FIELDS) if (item[key] != null) row[key] = item[key]; + const token = (key: string) => stripPythonWhitespace(pythonFieldText(row, key)); + copyTodoPythonValues(item, row); + let repository: string | null = null; + try {repository = normalizeTodoRepository(token("task_repository"));} + catch (error) {if (!(error instanceof EffectRuntimeRequestError)) throw error;} + const outcome = (value: unknown) => ["approve", "reject", "cancel"].includes(String(value).trim().toLowerCase()) ? String(value).trim().toLowerCase() : null; + const outcomes: JsonObject[] = []; + const raw = row.decision_scope_outcomes; + for (const value of Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(",") : [raw]) { + const parts = typeof value === "string" ? value.split("|", 3) : []; + const source = object(value), decision = outcome(source.outcome ?? parts[0]); + const scope = readScope(source.decision_scope || source.scope || parts[1]), todo = id(source.source_todo_id ?? parts[2]); + if (!decision || !scope || !todo) continue; + const record = {schema_version: "decision_scope_outcome_v0", outcome: decision, decision_scope: scope, source_todo_id: todo}; + const position = outcomes.findIndex(row => {const prior = object(row.decision_scope); return prior.kind === scope.kind && prior.granularity === scope.granularity && prior.scope_key === scope.scope_key;}); + if (position < 0) outcomes.push(record); else outcomes[position] = record; + } + const normalized: JsonObject = { + task_domain: /^[a-z][a-z0-9_.-]{0,63}$/.test(token("task_domain").toLowerCase()) ? token("task_domain").toLowerCase() : null, + required_write_scopes: writeScopes(Array.isArray(row.required_write_scopes) ? row.required_write_scopes : token("required_write_scopes")), task_repository: repository, + decision_scope: readScope(row.decision_scope), required_decision_scopes: readScopes(row.required_decision_scopes), + decision_outcome: outcome(token("decision_outcome")), decision_scope_outcomes: outcomes, + excluded_agents: readExcludedAgents(row.excluded_agents), + removed_continuation_policy: ["primary_review", "review_handoff"].includes(token("removed_continuation_policy").toLowerCase()) ? token("removed_continuation_policy").toLowerCase() : null, + replan_obligation_id: /^replan-[a-f0-9]{16}$/.test(token("replan_obligation_id")) ? token("replan_obligation_id") : null}; + for (const [key, value] of Object.entries(normalized)) { + if (value !== null && (!Array.isArray(value) || value.length)) row[key] = value; else delete row[key]; + } + const classify = (item: JsonObject) => taskClass({...item, text: ["title", "text"].map(key => pythonFieldText(item, key)).join(" ")}); + row.task_class = classify(row); + if (classify(item) === "blocker" || stripPythonWhitespace(pythonFieldText(item, "status")).toLowerCase() === "blocked") { + const reason = compactTodoReadText(pythonFieldText(item, "reason"), 220); + if (reason && !INTERNAL_STATE_SHAPE_PATTERNS.some(rule => rule.test(reason))) row.reason = reason; + } + return row; +} + +/** Standalone compatibility display, also composed inside quota planning. */ +export function projectTodoSummaryDisplay(input: JsonObject): JsonObject[] { + const planning = input.include_terminal !== undefined, terminal = input.include_terminal === true; + const source = input.source === undefined ? null : decodeTodoReadSource(requireJsonObject(input.source, "summary source")); + const ready = new Set((Array.isArray(input.handoff_gates) ? input.handoff_gates : []).flatMap(value => { + const gate = object(value); + return gate.gate_state === "cleared_with_successor" && Array.isArray(gate.successor_todo_ids) ? gate.successor_todo_ids.map(id).filter(Boolean) : []; + })); + const selected: {item: JsonObject; text: unknown}[] = [], seen = new Set(); + if (source) { + for (const key of planning ? PLANNING_SOURCES : SUMMARY_SOURCES) { + for (const value of Array.isArray(source[key]) ? source[key] as unknown[] : []) { + if (!value || typeof value !== "object" || Array.isArray(value)) continue; + const item = value as JsonObject, body = stripPythonWhitespace(pythonFieldText(item, "text")); + let todo = id(item.todo_id); + if (terminal && !todo) {const legacy = stripPythonWhitespace(pythonFieldText(item, "todo_id")); if (legacy.startsWith("todo_")) todo = legacy;} + if (!body || (planning ? !todo || !terminal && item.status === "done" || seen.has(todo!) : item.done === true)) continue; + if (!planning && selected.some(({item: prior, text}) => item.todo_id && prior.todo_id ? item.todo_id === prior.todo_id : prior.index === item.index && text === body)) continue; + if (todo) seen.add(todo); + selected.push({item, text: body}); + } + } + } else { + if (!Array.isArray(input.items)) throw new EffectRuntimeRequestError("summary items must be an array"); + for (const value of input.items) { + const encoded = requireJsonObject(value, "summary item"), item = decodeTodoReadSource(encoded); + selected.push({item, text: encoded.text ?? (input.strip_text && !Object.hasOwn(encoded, "text") ? stripPythonWhitespace(pythonFieldText(item, "text")) : item.text ?? null)}); + } + } + return selected.map(({item, text}) => { + const row = compactSummaryRecord(item, text); + const hint = projectTodoHandoffContext([handoffSource(item)])[0]!.continuation_hint; + const note = projectTodoHandoffContext([handoffSource(row)])[0]!.note; + if (row.task_class === "advancement_task" && row.status === "open" && hint) row.continuation_hint = hint; + if (note) row.handoff_note = note; + if (source && !planning && ready.has(id(row.todo_id)) && readResume(row.resume_when) && !Object.hasOwn(row, "resume_ready")) { + row.resume_ready = true; + row.resume_condition = {schema_version: "todo_resume_condition_v0", resume_when: row.resume_when, satisfied: true, source: "handoff_gate_cleared_with_successor"}; + } + if (terminal) for (const key of [...codec.frontier_fields, "evidence", "note", "last_actor_agent_id"]) if (item[key] != null) row[key] = item[key]; + return row; + }); +} diff --git a/loopx/control_plane/todos/handoff_note.py b/loopx/control_plane/todos/handoff_note.py index 2e4f780f29..e1cb43415f 100644 --- a/loopx/control_plane/todos/handoff_note.py +++ b/loopx/control_plane/todos/handoff_note.py @@ -6,71 +6,33 @@ """ from __future__ import annotations -import hashlib import json +import math from typing import Any -from .contract import ( - normalize_todo_action_kind, normalize_todo_claimed_by, normalize_todo_decision_scope, - normalize_todo_excluded_agents, normalize_todo_id, normalize_todo_id_list, - normalize_todo_required_decision_scopes, normalize_todo_resume_when, -) - TODO_HANDOFF_NOTE_SCHEMA_VERSION = "handoff_note_v0" TODO_CONTINUATION_HINT_MAX_CHARS = 280 -_TEXT_FIELDS = ("continuation_hint", "suggested_next_action", "note", "reason", "summary", - "intent", "blocked_on", "evidence", "title", "text", "task_class", "goal_id", - "source", "latest_event_kind") - - -def _refs(value: Any) -> dict[str, Any]: - values = [str(raw or "") for raw in value] if isinstance(value, (list, tuple, set)) else [] - return {**({"truthy": True} if value else {}), **({"values": values} if values else {})} - - -def handoff_context_source(item: dict[str, Any], *, goal_id: str | None = None, - source: str | None = None) -> dict[str, Any]: - """Encode language-native scalar facts; selection/compaction belongs to TS.""" - nested: dict[str, Any] = {} - nested_meta: dict[str, Any] = {} - for name in ("handoff", "handoff_note"): - value = item.get(name) - row = value if isinstance(value, dict) else {} - if value: - nested[name] = {"truthy": True, "fields": - {key: str(val or "") for key, val in row.items()} if isinstance(value, dict) else None} - nested_meta[name] = { - "from_agent": normalize_todo_claimed_by(row.get("from_agent") or item.get("agent_id")), - "to_agent": normalize_todo_claimed_by(row.get("to_agent")), - "evidence_refs": _refs(row.get("evidence_refs")), - } - todo_id = normalize_todo_id(item.get("todo_id")) - legacy_id = None if todo_id else "handoff_" + hashlib.sha1(str(( - item.get("goal_id"), item.get("role"), item.get("index"), item.get("text") or item.get("title"), - )).encode("utf-8")).hexdigest()[:12] - result = {**nested, "nested_metadata": nested_meta, - "texts": {key: str(item[key]) for key in _TEXT_FIELDS if item.get(key)}, - "metadata": {"todo_id": todo_id, - "claimed_by": normalize_todo_claimed_by(item.get("claimed_by")), - "excluded_agents": normalize_todo_excluded_agents(item.get("excluded_agents")), - "successor_todo_ids": normalize_todo_id_list(item.get("successor_todo_ids")), - "unblocks_todo_id": normalize_todo_id(item.get("unblocks_todo_id")), - "superseded_by": normalize_todo_id(item.get("superseded_by")), - "action_kind": normalize_todo_action_kind(item.get("action_kind")), - "resume_when": normalize_todo_resume_when(item.get("resume_when")), - "required_decision_scopes": normalize_todo_required_decision_scopes(item.get("required_decision_scopes")), - "decision_scope": normalize_todo_decision_scope(item.get("decision_scope"))}, - "evidence_refs": _refs(item.get("evidence_refs")), - "has_evidence": bool(item.get("evidence")), "has_note": bool(item.get("note")), - "goal_id": str(goal_id or ""), "source": str(source or ""), "legacy_id": legacy_id} - - # Omitted empty scalar/list facts have the codec's explicit empty default. - # Nested text keys are kept, including "", because presence changes priority. - for name, row in nested_meta.items(): - nested_meta[name] = {key: value for key, value in row.items() if value} - result["metadata"] = {key: value for key, value in result["metadata"].items() if value} - result["nested_metadata"] = {key: value for key, value in nested_meta.items() if value} - return {key: value for key, value in result.items() if value} + + +def todo_read_source(item: dict[str, Any], **overrides: Any) -> dict[str, Any]: + """Carry raw values plus Python-only string/repr facts, without field rules.""" + facts: list[Any] = [] + + def encode(value: Any, path: list[Any]) -> Any: + if isinstance(value, dict): + result = {str(key): encode(val, [*path, str(key)]) for key, val in value.items()} + elif isinstance(value, (list, tuple, set)): + result = [encode(val, [*path, index]) for index, val in enumerate(value)] + elif value is None or isinstance(value, (str, bool, int, float)): + result = str(value) if isinstance(value, float) and not math.isfinite(value) else value + else: + result = str(value or "") + if isinstance(value, (tuple, set, float)) or not isinstance(value, (dict, list, str, bool, int, type(None))): + facts.append([path, str(value or ""), repr(value)]) + return result + + record = encode(item, []) + return {"record": record, **({"python_values": facts} if facts else {}), **overrides} def validate_handoff_context(value: Any, count: int) -> list[dict[str, Any]]: @@ -124,14 +86,14 @@ def project_handoff_context(sources: list[dict[str, Any]], *, def compact_todo_continuation_hint(item: dict[str, Any]) -> str | None: - return project_handoff_context([handoff_context_source(item)])[0]["continuation_hint"] + return project_handoff_context([todo_read_source(item)])[0]["continuation_hint"] def build_todo_handoff_note(item: dict[str, Any], *, goal_id: str | None = None, source: str | None = None) -> dict[str, Any] | None: if not isinstance(item, dict): return None - return project_handoff_context([handoff_context_source(item, goal_id=goal_id, source=source)])[0]["note"] + return project_handoff_context([todo_read_source(item, goal_id=goal_id, source=source)])[0]["note"] def attach_todo_handoff_note(item: dict[str, Any], *, goal_id: str | None = None, @@ -143,7 +105,7 @@ def attach_todo_handoff_note(item: dict[str, Any], *, goal_id: str | None = None def attach_todo_handoff_notes(items: list[dict[str, Any]]) -> list[dict[str, Any]]: - results = project_handoff_context([handoff_context_source(item) for item in items]) + results = project_handoff_context([todo_read_source(item) for item in items]) for item, result in zip(items, results, strict=True): if result["note"]: item["handoff_note"] = result["note"] diff --git a/loopx/control_plane/todos/quota_selection.py b/loopx/control_plane/todos/quota_selection.py index 66cc50a392..2553f51f81 100644 --- a/loopx/control_plane/todos/quota_selection.py +++ b/loopx/control_plane/todos/quota_selection.py @@ -22,7 +22,7 @@ todo_summary_monitor_writeback_supported, ) from .resume_planning import build_todo_resume_planning_request -from .summary_item import compact_todo_summary_items +from .handoff_note import todo_read_source from .user_gate import is_user_gate_todo_item from .route_continuation import build_todo_route_continuation_facts from .handoff_gate import todo_summary_handoff_gates @@ -123,17 +123,11 @@ def project_quota_planning( observed_at = observed_now.timestamp() handoff_gates = todo_summary_handoff_gates(value) - display_sources = [*all_open_items, *(item for key in ("active_next_action_items", "active_next_action_executable_items") - for item in (value.get(key) if isinstance(value.get(key), list) else []) if isinstance(item, dict))] - displays = {id(item): display for item, display in zip(display_sources, - compact_todo_summary_items(display_sources, strip_text=True), strict=True)} - def encode(item: dict[str, Any]) -> dict[str, Any]: priority, index = todo_projection_sort_key(item) - display = displays[id(item)] due, expires = todo_item_next_due_at(item), todo_item_expires_at(item) return { - "payload": item, **({"display": display} if display != item else {}), + "payload": item, "display_source": todo_read_source(item), "claim": normalize_todo_claimed_by(item.get("claimed_by")), "bound": normalize_todo_bound_agent(item.get("bound_agent")), "blocks": normalize_todo_blocks_agent(item.get("blocks_agent")), diff --git a/loopx/control_plane/todos/quota_selection.ts b/loopx/control_plane/todos/quota_selection.ts index 234a15ee40..8545b72fa2 100644 --- a/loopx/control_plane/todos/quota_selection.ts +++ b/loopx/control_plane/todos/quota_selection.ts @@ -1,3 +1,4 @@ +import {projectTodoSummaryDisplay} from "./context_projection.ts"; import {isBoundedBlockedRetry} from "../quota/settlement_phase.ts"; import {parseTodoTimestampMicros} from "../runtime_timestamp.ts"; import {countTodoWork, decodeMonitorSchedule, monitorIsDue, monitorHasScheduleGap, @@ -417,7 +418,15 @@ export function projectTodoQuotaPlanning(value: unknown): JsonObject { "todo_quota_planning_request_v3", "todo_quota_planning_request_v4"].includes(String(request.schema_version))) { throw new EffectRuntimeRequestError("quota planning schema mismatch"); } - const selection = requireJsonObject(request.selection, "selection"); + const selection = {...requireJsonObject(request.selection, "selection")}; + for (const key of ["items", "active_items", "active_executable_items"]) { + if (!Array.isArray(selection[key])) continue; + selection[key] = (selection[key] as unknown[]).map(value => { + const row = requireJsonObject(value, "selection display source"); + return row.display_source === undefined ? row : {...row, + display: projectTodoSummaryDisplay({items: [row.display_source], strip_text: true})[0]}; + }); + } if (request.schema_version !== "todo_quota_planning_request_v0") { requireStringArray(selection.available, "available"); } diff --git a/loopx/control_plane/todos/read_codec.ts b/loopx/control_plane/todos/read_codec.ts index 1ad58bbcf1..9a8086deeb 100644 --- a/loopx/control_plane/todos/read_codec.ts +++ b/loopx/control_plane/todos/read_codec.ts @@ -68,7 +68,7 @@ export function readExcludedAgents(value: unknown): string[] { } export const readNoFollowup = (value: unknown): boolean | null => normalizeTodoNoFollowup(typeof value === "boolean" ? value : text(value || "")); export function readResume(value: unknown): string | null { - const candidate = compactPythonWhitespace(text(value || "")); + const candidate = compactPythonWhitespace(scalarText(value)); const known = normalizeTodoResumeWhen({schema_version: TODO_RESUME_NORMALIZE_REQUEST_SCHEMA_VERSION, resume_when: candidate}); if (known) return known; const lowered = candidate.toLowerCase(); @@ -78,12 +78,12 @@ export function readResume(value: unknown): string | null { export function readScope(value: unknown): JsonObject | null { if (value == null || value === "") return null; const raw = typeof value === "string" ? value : object(value); - const selected = typeof raw === "string" ? raw : {kind: raw.kind, granularity: raw.granularity, scope_key: raw.scope_key, + const selected = typeof raw === "string" ? raw : {kind: pythonFieldText(raw, "kind"), granularity: pythonFieldText(raw, "granularity"), scope_key: pythonFieldText(raw, "scope_key"), ...(id(raw.decision_id) ? {decision_id: id(raw.decision_id)} : {})}; try {return normalizeTodoDecisionScope(selected);} catch (error) {if (error instanceof EffectRuntimeRequestError) return null; throw error;} } -function readScopes(value: unknown): JsonObject[] { +export function readScopes(value: unknown): JsonObject[] { const values = listValues(value), result: JsonObject[] = []; for (const raw of values) { const scope = readScope(raw); @@ -104,8 +104,8 @@ function readCapabilities(value: unknown): string[] { } return result; } -function writeScopes(value: unknown): string[] { - const scopes = listValues(value).map(raw => compactPythonWhitespace(scalarText(raw))).filter(scope => scope && +export function writeScopes(value: unknown): string[] { + const scopes = listValues(value).map((raw, i) => compactPythonWhitespace(Array.isArray(value) ? pythonFieldText(value, i) : scalarText(raw))).filter(scope => scope && Array.from(scope).length <= 160 && !scope.startsWith("/") && !scope.startsWith("~") && !scope.split("/").includes("..") && !/[ <>]/u.test(scope)); return [...new Set(scopes)]; } @@ -114,8 +114,8 @@ const truthy = (v: unknown): boolean => Array.isArray(v) ? v.length > 0 : v !== function repr(value: unknown): string { if (value == null) return "None"; if (typeof value === "boolean") return value ? "True" : "False"; - if (Array.isArray(value)) return "[" + value.map(repr).join(", ") + "]"; - if (typeof value === "object") return "{" + Object.entries(value).map(([k, v]) => `${repr(k)}: ${repr(v)}`).join(", ") + "}"; + if (Array.isArray(value)) return "[" + value.map((_, i) => fieldRepr(value, i)).join(", ") + "]"; + if (typeof value === "object") return "{" + Object.entries(value).map(([k, v]) => `${repr(k)}: ${fieldRepr(value as JsonObject, k)}`).join(", ") + "}"; if (typeof value !== "string") return String(value); const quote = value.includes("'") && !value.includes('"') ? '"' : "'"; return quote + Array.from(value).map(point => { @@ -133,22 +133,50 @@ function repr(value: unknown): string { } const scalarText = (value: unknown): string => !truthy(value) ? "" : typeof value === "string" ? value : repr(value); +// Request-local Python scalar facts. These are codecs, never persisted metadata. +const pythonValues = new WeakMap>(); +export function decodeTodoReadSource(value: JsonObject): JsonObject { + const record = object(value.record); + for (const fact of (value.python_values ?? []) as unknown[][]) { + const path = fact[0] as (string | number)[]; + let parent: JsonObject = record; + for (const key of path.slice(0, -1)) parent = parent[key] as JsonObject; + const fields = pythonValues.get(parent) ?? new Map(); + fields.set(String(path.at(-1)), fact.slice(1)); pythonValues.set(parent, fields); + } + return record; +} +export function pythonFieldText(row: JsonObject | unknown[], key: string | number): string { + return pythonValues.get(row)?.get(String(key))?.[0] as string ?? scalarText((row as JsonObject)[key]); +} +function fieldRepr(row: JsonObject | unknown[], key: string | number): string { + return pythonValues.get(row)?.get(String(key))?.[1] as string ?? repr((row as JsonObject)[key]); +} +export function copyTodoPythonValues(source: JsonObject, target: JsonObject): void { + const fields = pythonValues.get(source); + if (fields) pythonValues.set(target, new Map([...fields].filter(([key]) => target[key] === source[key]))); +} +export function handoffReadSource(value: JsonObject): JsonObject { + if (value.record === undefined) return value; // retained already-encoded transport callers + return {...handoffSource(decodeTodoReadSource(value)), goal_id: value.goal_id ?? "", source: value.source ?? ""}; +} + export function handoffSource(item: JsonObject): JsonObject { const refs = (value: unknown): JsonObject => ({...(truthy(value) ? {truthy: true} : {}), - ...(Array.isArray(value) && value.length ? {values: value.map(scalarText)} : {})}); + ...(Array.isArray(value) && value.length ? {values: value.map((_, i) => pythonFieldText(value, i))} : {})}); const nestedMetadata: JsonObject = {}, nested: JsonObject = {}; for (const key of ["handoff", "handoff_note"]) { const value = item[key], row = object(value); if (truthy(value)) nested[key] = {truthy: true, fields: value !== null && typeof value === "object" && !Array.isArray(value) - ? Object.fromEntries(Object.entries(row).map(([k, v]) => [k, scalarText(v)])) : null}; + ? Object.fromEntries(Object.entries(row).map(([k, v]) => [k, pythonFieldText(row, k)])) : null}; nestedMetadata[key] = {from_agent: readAgent(truthy(row.from_agent) ? row.from_agent : item.agent_id), to_agent: readAgent(row.to_agent), evidence_refs: refs(row.evidence_refs)}; } const fields = ["continuation_hint", "suggested_next_action", "note", "reason", "summary", "intent", "blocked_on", "evidence", "title", "text", "task_class", "goal_id", "source", "latest_event_kind"]; const action = compactPythonWhitespace(scalarText(item.action_kind)).toLowerCase(); - const legacy = "(" + [item.goal_id, item.role, item.index, truthy(item.text) ? item.text : item.title].map(repr).join(", ") + ")"; + const legacy = "(" + ["goal_id", "role", "index", truthy(item.text) ? "text" : "title"].map(key => fieldRepr(item, key)).join(", ") + ")"; return {...nested, nested_metadata: nestedMetadata, - texts: Object.fromEntries(fields.filter(key => truthy(item[key])).map(key => [key, scalarText(item[key])])), + texts: Object.fromEntries(fields.filter(key => truthy(item[key])).map(key => [key, pythonFieldText(item, key)])), metadata: {todo_id: id(item.todo_id), claimed_by: readAgent(item.claimed_by), excluded_agents: readExcludedAgents(item.excluded_agents), successor_todo_ids: ids(item.successor_todo_ids), unblocks_todo_id: id(item.unblocks_todo_id), superseded_by: id(item.superseded_by), action_kind: /^[a-z][a-z0-9_-]{0,63}$/.test(action) ? action : null, resume_when: readResume(item.resume_when), diff --git a/loopx/control_plane/todos/summary_item.py b/loopx/control_plane/todos/summary_item.py index 1842de9e4b..a730f5de6a 100644 --- a/loopx/control_plane/todos/summary_item.py +++ b/loopx/control_plane/todos/summary_item.py @@ -1,90 +1,11 @@ +"""Compatibility transport for the single TypeScript Todo display owner.""" from __future__ import annotations +import json from hashlib import sha256 from typing import Any -from .contract import ( - normalize_removed_todo_continuation_policy, - normalize_required_write_scopes, - normalize_todo_decision_outcome, - normalize_todo_decision_scope, - normalize_todo_decision_scope_outcomes, - normalize_todo_excluded_agents, - normalize_todo_id, - normalize_todo_required_decision_scopes, - normalize_todo_replan_obligation_id, - normalize_todo_resume_when, - normalize_todo_task_domain, - normalize_todo_task_repository, -) -from .handoff_gate import handoff_ready_successor_todo_ids -from .handoff_note import handoff_context_source, project_handoff_context -from .todo_semantics import todo_blocker_reason, todo_item_task_class -from .frontier_revision import FRONTIER_REVISION_FIELDS - -TODO_SUMMARY_COMPACT_FIELDS = ( - "goal_acceptance_guard", - "schema_version", - "todo_id", - "content_revision", - "role", - "status", - "priority", - "title", - "archive_state", - "source_section", - "task_class", - "action_kind", - "task_domain", - "capability_binding_ref", - "task_repository", - "continuation_policy", - "removed_continuation_policy", - "required_write_scopes", - "required_capabilities", - "target_capabilities", - "decision_scope", - "required_decision_scopes", - "decision_outcome", - "decision_scope_outcomes", - "claimed_by", - "bound_agent", - "goal_bound", - "blocks_agent", - "excluded_agents", - "global_gate", - "unblocks_todo_id", - "resume_when", - "resume_monitor_generation", - "resume_condition", - "resume_ready", - "blocked_retry", - "no_followup", - "successor_todo_ids", - "completion_continuation", - "completion_recovery", - "replan_obligation_id", - "target_key", - "cadence", - "next_due_at", - "expires_at", - "watch_only", - "last_checked_at", - "result_hash", - "consecutive_no_change", - "material_change", - "material_change_generation", - "max_no_change_before_replan", - "route_continuation_replan_required", - "route_continuation_reason", - "route_id", - "route_key", - "completed_at", - "updated_at", - "completion_turn_key", - "superseded_by", - "handoff_note", -) +from .handoff_note import todo_read_source TODO_SUMMARY_SOURCE_KEYS = ( "active_next_action_items", @@ -124,105 +45,41 @@ def todo_text_content_revision(value: Any) -> str | None: return "sha256:" + sha256(normalized.encode("utf-8")).hexdigest() -def _compact_todo_summary_item_record( - item: dict[str, Any], - *, - text: str | None = None, -) -> dict[str, Any]: - compact: dict[str, Any] = { - "index": item.get("index"), - "text": text if text is not None else item.get("text"), - } - content_revision = item.get("content_revision") - if content_revision: - compact["content_revision"] = content_revision - for key in TODO_SUMMARY_COMPACT_FIELDS: - if item.get(key) is not None: - compact[key] = item.get(key) - required_write_scopes = normalize_required_write_scopes( - compact.get("required_write_scopes") - ) - task_domain = normalize_todo_task_domain(compact.get("task_domain")) - if task_domain: - compact["task_domain"] = task_domain - else: - compact.pop("task_domain", None) - if required_write_scopes: - compact["required_write_scopes"] = required_write_scopes - else: - compact.pop("required_write_scopes", None) - task_repository = normalize_todo_task_repository(compact.get("task_repository")) - if task_repository: - compact["task_repository"] = task_repository - else: - compact.pop("task_repository", None) - decision_scope = normalize_todo_decision_scope(compact.get("decision_scope")) - if decision_scope: - compact["decision_scope"] = decision_scope - else: - compact.pop("decision_scope", None) - required_decision_scopes = normalize_todo_required_decision_scopes( - compact.get("required_decision_scopes") - ) - if required_decision_scopes: - compact["required_decision_scopes"] = required_decision_scopes - else: - compact.pop("required_decision_scopes", None) - decision_outcome = normalize_todo_decision_outcome(compact.get("decision_outcome")) - if decision_outcome: - compact["decision_outcome"] = decision_outcome - else: - compact.pop("decision_outcome", None) - decision_scope_outcomes = normalize_todo_decision_scope_outcomes( - compact.get("decision_scope_outcomes") - ) - if decision_scope_outcomes: - compact["decision_scope_outcomes"] = decision_scope_outcomes - else: - compact.pop("decision_scope_outcomes", None) - excluded_agents = normalize_todo_excluded_agents(compact.get("excluded_agents")) - if excluded_agents: - compact["excluded_agents"] = excluded_agents - else: - compact.pop("excluded_agents", None) - removed_continuation_policy = normalize_removed_todo_continuation_policy( - compact.get("removed_continuation_policy") - ) - if removed_continuation_policy: - compact["removed_continuation_policy"] = removed_continuation_policy - else: - compact.pop("removed_continuation_policy", None) - replan_obligation_id = normalize_todo_replan_obligation_id( - compact.get("replan_obligation_id") - ) - if replan_obligation_id: - compact["replan_obligation_id"] = replan_obligation_id - else: - compact.pop("replan_obligation_id", None) - compact["task_class"] = todo_item_task_class(compact) - reason = todo_blocker_reason(item) - if reason: - compact["reason"] = reason - return compact +def _project(request: dict[str, Any]) -> list[dict[str, Any]]: + from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result + + try: + result = effect_runtime_result("todo.context.page", {"summary_display": request}, + large_local_snapshot=True) + except EffectRuntimeRejected as error: + raise ValueError(str(error)) from error + rows = result.get("items") if isinstance(result, dict) else None + if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows): + raise ValueError("invalid typed Todo summary display") + return rows def compact_todo_summary_items( items: list[dict[str, Any]], *, texts: list[str | None] | None = None, strip_text: bool = False, ) -> list[dict[str, Any]]: - if texts is None: - texts = [str(item.get("text") or "").strip() for item in items] if strip_text else [None] * len(items) - records = [_compact_todo_summary_item_record(item, text=text) - for item, text in zip(items, texts, strict=True)] - contexts = project_handoff_context([ - handoff_context_source(item) for pair in zip(items, records, strict=True) for item in pair - ]) - for index, record in enumerate(records): - hint, note = contexts[2 * index]["continuation_hint"], contexts[2 * index + 1]["note"] - if record["task_class"] == "advancement_task" and record.get("status") == "open" and hint: - record["continuation_hint"] = hint - if note: - record["handoff_note"] = note - return records + from ..effect_runtime import MAX_REQUEST_BYTES + + if texts is not None and len(texts) != len(items): + raise ValueError("summary text cardinality mismatch") + sources = [todo_read_source(item, **({"text": texts[i]} if texts is not None else {})) + for i, item in enumerate(items)] + result: list[dict[str, Any]] = [] + start, size = 0, 0 + for i in range(len(sources) + 1): + row_size = 0 if i == len(sources) else len(json.dumps(sources[i], separators=(",", ":")).encode()) + 2 + if i > start and (i == len(sources) or size + row_size > MAX_REQUEST_BYTES - 4096): + rows = _project({"items": sources[start:i], "strip_text": strip_text}) + if len(rows) != i - start: + raise ValueError("summary display cardinality mismatch") + result.extend(rows) + start, size = i, 0 + size += row_size + return result def compact_todo_summary_item(item: dict[str, Any], *, text: str | None = None) -> dict[str, Any]: @@ -230,100 +87,14 @@ def compact_todo_summary_item(item: dict[str, Any], *, text: str | None = None) def todo_summary_source_items(value: dict[str, Any]) -> list[dict[str, Any]]: - ready_successor_todo_ids = handoff_ready_successor_todo_ids(value) - open_items: list[dict[str, Any]] = [] - originals: list[dict[str, Any]] = [] - for key in TODO_SUMMARY_SOURCE_KEYS: - raw_items = value.get(key) - source_items = raw_items if isinstance(raw_items, list) else [] - for item in source_items: - if not isinstance(item, dict) or item.get("done") is True: - continue - text = str(item.get("text") or "").strip() - if not text: - continue - duplicate = any( - existing.get("todo_id") == item.get("todo_id") - if item.get("todo_id") and existing.get("todo_id") - else existing.get("index") == item.get("index") - and str(existing.get("text") or "").strip() == text - for existing in open_items - ) - if duplicate: - continue - compact = _compact_todo_summary_item_record(item, text=text) - todo_id = normalize_todo_id(compact.get("todo_id")) - if ( - todo_id - and todo_id in ready_successor_todo_ids - and normalize_todo_resume_when(compact.get("resume_when")) - and "resume_ready" not in compact - ): - compact["resume_ready"] = True - compact["resume_condition"] = { - "schema_version": "todo_resume_condition_v0", - "resume_when": compact.get("resume_when"), - "satisfied": True, - "source": "handoff_gate_cleared_with_successor", - } - open_items.append(compact) - originals.append(item) - projected = compact_todo_summary_items(originals, texts=[row["text"] for row in open_items]) - for compact, prepared in zip(projected, open_items, strict=True): - for key in ("resume_ready", "resume_condition"): - if key in prepared: - compact[key] = prepared[key] - return projected - + from .handoff_gate import todo_summary_handoff_gates -def todo_planning_source_items( - value: dict[str, Any], - *, - include_terminal: bool = False, -) -> list[dict[str, Any]]: - """Return canonical Todo state rows shared by planning read models. + if not any(value.get(key) for key in TODO_SUMMARY_SOURCE_KEYS): + return [] + return _project({"source": todo_read_source(value), "handoff_gates": todo_summary_handoff_gates(value)}) - Unlike presentation-lane expansion, these four sources correspond to - distinct Todo domain states. They preserve waiting/blocking/scheduled nodes - that may sit outside the status ``items`` budget without making every - consumer rediscover visibility-lane policy. - """ - planning_items: list[dict[str, Any]] = [] - originals: list[dict[str, Any]] = [] - seen: set[str] = set() - for key in TODO_PLANNING_SOURCE_KEYS: - raw_items = value.get(key) - source_items = raw_items if isinstance(raw_items, list) else [] - for item in source_items: - if not isinstance(item, dict) or ( - not include_terminal and item.get("status") == "done" - ): - continue - todo_id = normalize_todo_id(item.get("todo_id")) - if include_terminal and todo_id is None: - legacy_todo_id = str(item.get("todo_id") or "").strip() - todo_id = ( - legacy_todo_id - if legacy_todo_id.startswith("todo_") - else None - ) - text = str(item.get("text") or "").strip() - if not todo_id or not text or todo_id in seen: - continue - seen.add(todo_id) - compact = _compact_todo_summary_item_record(item, text=text) - if include_terminal: - # Terminal-inclusive planning also proves exact frontier causality. - for field in (*FRONTIER_REVISION_FIELDS, "evidence", "note", "last_actor_agent_id"): - if item.get(field) is not None: - compact[field] = item[field] - planning_items.append(compact) - originals.append(item) - projected = compact_todo_summary_items(originals, texts=[row["text"] for row in planning_items]) - if include_terminal: - for compact, prepared in zip(projected, planning_items, strict=True): - for field in (*FRONTIER_REVISION_FIELDS, "evidence", "note", "last_actor_agent_id"): - if field in prepared: - compact[field] = prepared[field] - return projected +def todo_planning_source_items(value: dict[str, Any], *, include_terminal: bool = False) -> list[dict[str, Any]]: + if not any(value.get(key) for key in TODO_PLANNING_SOURCE_KEYS): + return [] + return _project({"source": todo_read_source(value), "include_terminal": include_terminal}) diff --git a/loopx/control_plane/todos/todo_index.py b/loopx/control_plane/todos/todo_index.py index 37a1af10b8..5e224c8c23 100644 --- a/loopx/control_plane/todos/todo_index.py +++ b/loopx/control_plane/todos/todo_index.py @@ -6,7 +6,7 @@ from ...rollout_event_log import load_rollout_events, rollout_event_log_path from .contract import normalize_todo_id, normalize_todo_status, todo_done_for_status -from .handoff_note import handoff_context_source, project_handoff_context +from .handoff_note import todo_read_source, project_handoff_context from .todo_summary import _compact_todo_item_record TODO_INDEX_SCHEMA_VERSION = "todo_index_v0" @@ -179,8 +179,8 @@ def build_todo_index( source="attention_queue", public_safe_compact_text=public_safe_compact_text) if row is not None: status_rows.append(row) - context_sources.append(handoff_context_source(compact)) - followups.append(handoff_context_source(row)) + context_sources.append(todo_read_source(compact)) + followups.append(todo_read_source(row)) current_count = len(status_rows) rollout_event_count = 0 @@ -216,7 +216,7 @@ def build_todo_index( continue event_rows.append(event_item) contexts = project_handoff_context([ - *context_sources, *(handoff_context_source(row) for row in event_rows)], + *context_sources, *(todo_read_source(row) for row in event_rows)], followups=[*followups, *([None] * len(event_rows))]) for row, context in zip([*status_rows, *event_rows], contexts, strict=True): if context["note"]: diff --git a/tests/control_plane/test_agent_task_visibility.py b/tests/control_plane/test_agent_task_visibility.py index 20ebfe5c79..c8d4336a32 100644 --- a/tests/control_plane/test_agent_task_visibility.py +++ b/tests/control_plane/test_agent_task_visibility.py @@ -8,7 +8,6 @@ quota_todo_item, quota_todo_summary, ) -from loopx.control_plane.todos.summary_item import compact_todo_summary_item from loopx.quota import build_quota_should_run GOAL_ID = "agent-task-visibility-fixture" @@ -87,38 +86,6 @@ def test_quota_projects_goal_scoped_read_and_execution_boundary() -> None: ) -def test_todo_continuation_hint_drops_secret_bearing_source() -> None: - credential_values = tuple( - f"{name}=must-not-project; next run the canary." - for name in ("api" + "_key", "tok" + "en", "pass" + "word") - ) + ( - ("Author" + "ization") - + ": " - + ("Bear" + "er") - + " must-not-project; next run the canary.", - ) - for field in ("note", "continuation_hint"): - for index, value in enumerate(credential_values): - item = quota_todo_item( - todo_id=f"todo_secret_{field}_{index}", - title="Resume the current task.", - **{field: value}, - ) - - assert "continuation_hint" not in compact_todo_summary_item(item) - - -def test_todo_continuation_hint_only_projects_open_advancement_work() -> None: - item = quota_todo_item( - todo_id="todo_monitor", - title="Poll the current target.", - task_class="continuous_monitor", - continuation_hint="Next inspect the material transition.", - ) - - assert "continuation_hint" not in compact_todo_summary_item(item) - - def test_turn_envelope_retains_agent_task_visibility_contract() -> None: decision = _decision() diff --git a/tests/control_plane/test_blocked_transition_notice.py b/tests/control_plane/test_blocked_transition_notice.py index 4aacc608ea..2f512f0ddb 100644 --- a/tests/control_plane/test_blocked_transition_notice.py +++ b/tests/control_plane/test_blocked_transition_notice.py @@ -202,21 +202,6 @@ def test_compact_blocked_advancement_keeps_cause_for_fallback_notice() -> None: assert "private path" not in str(fallback) -def test_compact_open_advancement_does_not_project_unneeded_reason() -> None: - open_item = {**_fallback(), "reason": "not an active blocker"} - - assert "reason" not in compact_todo_summary_item(open_item) - - -def test_compact_blocked_advancement_omits_unsafe_reason() -> None: - blocked = { - **_agent_owned_blocker(), - "reason": "/" + "Users/example/private/plan.md", - } - - assert "reason" not in compact_todo_summary_item(blocked) - - def test_project_asset_blocked_advancement_cause_is_public_safe() -> None: blocked = _agent_owned_blocker() summary = {"items": [blocked], "first_open_items": [blocked], "open_count": 1} diff --git a/tests/control_plane/test_handoff_context_projection.py b/tests/control_plane/test_handoff_context_projection.py index eab7c2892c..55223657ee 100644 --- a/tests/control_plane/test_handoff_context_projection.py +++ b/tests/control_plane/test_handoff_context_projection.py @@ -8,94 +8,10 @@ import pytest from loopx.control_plane.todos.handoff_note import ( - attach_todo_handoff_note, build_todo_handoff_note, compact_todo_continuation_hint, + build_todo_handoff_note, ) -def test_handoff_keeps_source_identity_and_does_not_invent_completion(): - source = { - "todo_id": "todo_original", "role": "agent", "status": "open", - "claimed_by": "Agent A", "text": "Read the complete requirement", - "note": "Keep the unfinished validation", "evidence": "opaque evidence", - "successor_todo_ids": ["todo_successor"], - "resume_when": "todo_done:todo_dependency", - "excluded_agents": ["agent-b"], - } - before = deepcopy(source) - note = build_todo_handoff_note(source, goal_id="goal-a", source="canonical_provider") - assert source == before - assert note == { - "schema_version": "handoff_note_v0", "handoff_id": "handoff_original", - "todo_id": "todo_original", "goal_id": "goal-a", "to_agent": "agent-a", - "intent": "continue", "summary": "Keep the unfinished validation", - "evidence_refs": ["todo:todo_original:evidence", "todo:todo_original:note"], - "blocked_on": "todo_done:todo_dependency", - "suggested_next_action": "Read the complete requirement", - "source": "canonical_provider", "successor_todo_ids": ["todo_successor"], - "excluded_agents": ["agent-b"], - } - assert attach_todo_handoff_note(source) is source - assert {key: source[key] for key in before} == before - assert "status" not in source["handoff_note"] - assert "resume_ready" not in source["handoff_note"] - - -def test_plain_work_has_no_handoff_and_nested_fields_remain_authoritative(): - assert build_todo_handoff_note({"todo_id": "todo_plain", "note": "continue"}) is None - item = {"todo_id": "todo_original", "note": "top-level fallback", "claimed_by": "agent-a", - "handoff": {"to_agent": "agent-b", "note": "", "reason": "nested reason"}} - note = build_todo_handoff_note(item) - assert note["to_agent"] == "agent-b" - assert note["summary"] == "nested reason" - assert compact_todo_continuation_hint(item) == "nested reason" - - -def test_legacy_handoff_identity_does_not_depend_on_continuation_edits(): - item = {"goal_id": "goal-a", "role": "agent", "index": 7, "text": "Legacy work", - "handoff": {"note": "First continuation"}} - first = build_todo_handoff_note(item, goal_id="goal-a") - item["handoff"]["note"] = "Revised continuation" - revised = build_todo_handoff_note(item, goal_id="goal-a") - # Golden compatibility identity for the historical tuple encoding; do not - # derive the expected ID from the function being characterized. - assert first["handoff_id"] == revised["handoff_id"] == "handoff_7e32bf123341" - assert first["summary"] != revised["summary"] - - -@pytest.mark.parametrize("label", ["ak", "sk", "api_key", "access_key_id", "secret_key", "token", "password"]) -def test_credential_assignment_is_rejected_before_display_truncation(label): - # Values are synthetic and deliberately short. Even when the assignment - # follows the display cap, the entire candidate must be rejected. - item = {"handoff": {"note": "x" * 400 + " " + label + "=" + "v"}} - assert compact_todo_continuation_hint(item) is None - assert "summary" not in build_todo_handoff_note(item) - - -def test_unicode_compaction_and_bounded_evidence_keep_existing_semantics(): - assert compact_todo_continuation_hint({"note": "a\u0085b\u001cc"}) == "a b c" - assert compact_todo_continuation_hint({"note": "token budget for validation"}) == "token budget for validation" - assert compact_todo_continuation_hint({"note": "😀" * 281}) == "😀" * 279 + "..." - item = {"todo_id": "todo_original", "successor_todo_ids": ["todo_next"], - "handoff": {"evidence_refs": ["ref:one", "ref:one", "ref:two", "ref:three", "ref:four"]}, - "evidence": "raw evidence is not copied", "note": "continuation", - "latest_event_kind": "todo_update"} - assert build_todo_handoff_note(item)["evidence_refs"] == [ - "ref:one", "ref:two", "ref:three", "ref:four", "todo:todo_original:evidence", "todo:todo_original:note"] - - -# Synthetic values exercise Unicode filtering without resembling credentials. -_DOTTED_I_ASSIGNMENT = "apİ_key" + "=" + "v" -_UNICODE_TOKEN_ASSIGNMENT = "令token" + "=" + "v" -@pytest.mark.parametrize("text, expected", [ - (_UNICODE_TOKEN_ASSIGNMENT, _UNICODE_TOKEN_ASSIGNMENT), # Python Unicode word boundary: no boundary before token. - (_DOTTED_I_ASSIGNMENT, None), - ("bearer\ufeffv", "bearer\ufeffv"), # BOM is not Python whitespace. - ("bearer\u0085v", None), -]) -def test_credential_filter_keeps_unicode_boundary_and_whitespace_contract(text, expected): - assert compact_todo_continuation_hint({"note": text}) == expected - - def test_summary_and_index_use_batches_without_mutating_the_source(monkeypatch, tmp_path): from loopx.control_plane import effect_runtime from loopx.control_plane.todos.todo_summary import compact_todo_group @@ -252,7 +168,7 @@ def track(method, request, **kwargs): @pytest.mark.parametrize("credential", [False, True]) def test_oversized_single_source_reuses_private_snapshot_without_pretruncation(monkeypatch, credential): from loopx.control_plane import effect_runtime - from loopx.control_plane.todos.handoff_note import handoff_context_source, project_handoff_context + from loopx.control_plane.todos.handoff_note import todo_read_source, project_handoff_context calls, original = [], effect_runtime.effect_runtime_result def track(method, request, **kwargs): @@ -265,8 +181,35 @@ def track(method, request, **kwargs): text += " " + "token" + "=" + "v" rows = [{"todo_id": f"todo_snapshot_{i}", "text": "Work", "successor_todo_ids": ["todo_next"], "note": text if i == 1 else "Continue"} for i in range(3)] - contexts = project_handoff_context([handoff_context_source(row) for row in rows]) + contexts = project_handoff_context([todo_read_source(row) for row in rows]) assert [row["note"]["todo_id"] for row in contexts] == [row["todo_id"] for row in rows] assert contexts[1]["continuation_hint"] == (None if credential else "a" * 279 + "...") assert contexts[1]["note"]["summary"] == ("Work" if credential else "a" * 279 + "...") assert calls == [("todo.context.page", False), ("todo.context.page", True), ("todo.context.page", False)] + + +def test_python_sequence_and_scalar_facts_cross_the_retained_facade(): + from loopx.control_plane.todos.summary_item import compact_todo_summary_items + + row = {"todo_id": "todo_compat", "text": ("Check", True), "status": "open", + "note": 1.0, "required_write_scopes": (True, 1.0, "docs/**", "../bad"), + "excluded_agents": {"Agent B"}, "successor_todo_ids": ("todo_next",), + "decision_scope": {"kind": "other", "granularity": "action", "scope_key": 1.0}} + result = compact_todo_summary_items([row], strip_text=True)[0] + assert result["text"] == "('Check', True)" + assert result["continuation_hint"] == "1.0" + assert result["required_write_scopes"] == ["True", "1.0", "docs/**"] + assert result["excluded_agents"] == ["agent-b"] + assert result["decision_scope"]["scope_key"] == "1.0" + assert result["handoff_note"]["successor_todo_ids"] == ["todo_next"] + assert row["excluded_agents"] == {"Agent B"} + assert row["text"] == ("Check", True) + + +def test_explicit_null_text_override_preserves_raw_text_even_with_strip_requested(): + from loopx.control_plane.todos.summary_item import compact_todo_summary_items + + row = {"text": " Work ", "status": "open"} + assert compact_todo_summary_items([row], texts=[None], strip_text=True)[0]["text"] == " Work " + with pytest.raises(ValueError, match="cardinality"): + compact_todo_summary_items([row], texts=[]) diff --git a/tests/control_plane/test_todo_summary_projection.py b/tests/control_plane/test_todo_summary_projection.py index d1bbe59500..c53c2b0bb2 100644 --- a/tests/control_plane/test_todo_summary_projection.py +++ b/tests/control_plane/test_todo_summary_projection.py @@ -31,29 +31,6 @@ def test_user_request_creator_survives_summary_and_compact_workspace_projection( assert "created_by" not in project_asset_todo_summary(legacy, role="user")["items"][0] -def test_recent_completions_compare_instants_not_offset_strings(): - items = [row(1, status="done", no_followup=True, completed_at="2026-01-01T10:00:00+08:00"), - row(2, status="done", no_followup=True, completed_at="2026-01-01T03:00:00Z")] - result = summarize(items) - assert [item["todo_id"] for item in result["recent_completed_advancement_items"]] == [ - "todo_summary_2", "todo_summary_1"] - - -def test_recent_completions_preserve_microseconds_across_offsets(): - items = [row(2, status="done", no_followup=True, completed_at="2026-01-01T10:00:00.000001+08:00"), - row(1, status="done", no_followup=True, completed_at="2026-01-01T02:00:00.000002Z")] - result = summarize(items) - assert [item["todo_id"] for item in result["recent_completed_advancement_items"]] == [ - "todo_summary_1", "todo_summary_2"] - - -def test_invalid_completion_time_does_not_displace_known_recent_work(): - result = summarize([row(1, status="done", no_followup=True, completed_at="unknown"), - row(2, status="done", no_followup=True, completed_at="2026-01-01T03:00:00Z")]) - assert [item["todo_id"] for item in result["recent_completed_advancement_items"]] == ["todo_summary_2"] - assert result["advancement_done_count"] == 2 # Still retained as completed work. - - def test_summary_caps_do_not_change_work_counts_or_hide_a_peer(): items = [row(i, claimed_by="agent-a" if i < 24 else "agent-b") for i in range(32)] result = summarize(items, item_limit=1) @@ -63,15 +40,6 @@ def test_summary_caps_do_not_change_work_counts_or_hide_a_peer(): assert result["claimed_open_count"] == 32 -def test_late_edit_does_not_make_an_old_completion_recent(): - result = summarize([row(1, status="done", no_followup=True, - completed_at="2026-01-01T00:00:00Z", updated_at="2026-09-01T00:00:00Z"), - row(2, status="done", no_followup=True, - completed_at="2026-02-01T00:00:00Z")]) - assert [item["todo_id"] for item in result["recent_completed_advancement_items"]] == [ - "todo_summary_2", "todo_summary_1"] - - def test_a_selection_cannot_restore_a_lost_full_source_proof(): from loopx.control_plane.todos.todo_summary import compact_evaluated_todo_group source = summarize([row(1, status="done", no_followup=True)], item_limit=None) diff --git a/tests/control_plane_ts/fixtures/todo_summary_display.json b/tests/control_plane_ts/fixtures/todo_summary_display.json new file mode 100644 index 0000000000..802bb2f5df --- /dev/null +++ b/tests/control_plane_ts/fixtures/todo_summary_display.json @@ -0,0 +1,292 @@ +{ + "_comment": "Pre-cutover Python summary-display projections of synthetic quota CLI rows at c6462f902; characterization only.", + "scenarios": [ + { + "name": "quota-n1", + "items": [ + { + "index": 1, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_466a1d03137cb098dbef1ee1", + "role": "agent", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "todo_id": "todo_466a1d03137cb098dbef1ee1", + "index": 1, + "role": "agent", + "priority": "P1", + "status": "open", + "task_class": "advancement_task", + "text": "[P1] Inspect delegated evidence" + }, + { + "todo_id": "todo_466a1d03137cb098dbef1ee1", + "priority": "P1", + "text": "[P1] Inspect delegated evidence" + }, + { + "todo_id": "todo_466a1d03137cb098dbef1ee1", + "text": "[P1] Inspect delegated evidence", + "priority": "P1" + }, + { + "todo_id": "todo_466a1d03137cb098dbef1ee1", + "index": 1, + "text": "[P1] Inspect delegated evidence", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "todo_id": "todo_466a1d03137cb098dbef1ee1", + "index": 1, + "text": "[P1] Inspect delegated evidence", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + } + ], + "expected": [ + { + "index": 1, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_466a1d03137cb098dbef1ee1", + "role": "agent", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "index": 1, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_466a1d03137cb098dbef1ee1", + "role": "agent", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "index": null, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_466a1d03137cb098dbef1ee1", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "index": null, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_466a1d03137cb098dbef1ee1", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "index": 1, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_466a1d03137cb098dbef1ee1", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "index": 1, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_466a1d03137cb098dbef1ee1", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + } + ] + }, + { + "name": "quota-n3", + "items": [ + { + "index": 1, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_63b0e0ccdfa833376b996112", + "role": "agent", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "todo_id": "todo_63b0e0ccdfa833376b996112", + "index": 1, + "role": "agent", + "priority": "P1", + "status": "open", + "task_class": "advancement_task", + "text": "[P1] Inspect delegated evidence" + }, + { + "todo_id": "todo_63b0e0ccdfa833376b996112", + "priority": "P1", + "text": "[P1] Inspect delegated evidence" + }, + { + "todo_id": "todo_63b0e0ccdfa833376b996112", + "text": "[P1] Inspect delegated evidence", + "priority": "P1" + }, + { + "todo_id": "todo_63b0e0ccdfa833376b996112", + "index": 1, + "text": "[P1] Inspect delegated evidence", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "todo_id": "todo_63b0e0ccdfa833376b996112", + "index": 1, + "text": "[P1] Inspect delegated evidence", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + } + ], + "expected": [ + { + "index": 1, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_63b0e0ccdfa833376b996112", + "role": "agent", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "index": 1, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_63b0e0ccdfa833376b996112", + "role": "agent", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "index": null, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_63b0e0ccdfa833376b996112", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "index": null, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_63b0e0ccdfa833376b996112", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "index": 1, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_63b0e0ccdfa833376b996112", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "index": 1, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_63b0e0ccdfa833376b996112", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + } + ] + } + ], + "source": { + "items": [ + { + "todo_id": "todo_first", + "index": 1, + "text": " First work ", + "status": "open", + "note": "Continue validation" + }, + { + "todo_id": "todo_done", + "text": "Done", + "status": "done", + "done": true + } + ], + "blocker_items": [ + { + "todo_id": "todo_blocked", + "text": "Blocked", + "status": "blocked", + "reason": "Input pending" + } + ], + "handoff_gates": [] + }, + "summary": [ + { + "index": null, + "text": "Blocked", + "todo_id": "todo_blocked", + "status": "blocked", + "task_class": "advancement_task", + "reason": "Input pending" + }, + { + "index": 1, + "text": "First work", + "todo_id": "todo_first", + "status": "open", + "task_class": "advancement_task", + "continuation_hint": "Continue validation" + } + ], + "planning": [ + { + "index": 1, + "text": "First work", + "todo_id": "todo_first", + "status": "open", + "task_class": "advancement_task", + "continuation_hint": "Continue validation" + }, + { + "index": null, + "text": "Blocked", + "todo_id": "todo_blocked", + "status": "blocked", + "task_class": "advancement_task", + "reason": "Input pending" + } + ], + "terminal": [ + { + "index": 1, + "text": " First work ", + "todo_id": "todo_first", + "status": "open", + "task_class": "advancement_task", + "continuation_hint": "Continue validation", + "note": "Continue validation" + }, + { + "index": null, + "text": "Done", + "todo_id": "todo_done", + "status": "done", + "task_class": "advancement_task", + "done": true + }, + { + "index": null, + "text": "Blocked", + "todo_id": "todo_blocked", + "status": "blocked", + "task_class": "advancement_task", + "reason": "Input pending" + } + ] +} diff --git a/tests/control_plane_ts/quota_selection.test.ts b/tests/control_plane_ts/quota_selection.test.ts index 5c0e939d9a..f071fd5e5d 100644 --- a/tests/control_plane_ts/quota_selection.test.ts +++ b/tests/control_plane_ts/quota_selection.test.ts @@ -500,3 +500,19 @@ test("receipt retries precede later full-source caches without losing hidden ear assert.equal(planningDeadline([], [], {frontier_deadline: cache}, [invalid]).blocked_retry_wait, undefined); } }); + +test("quota planning composes raw display before lane projection and keeps each decision independent", () => { + const payload = {todo_id: "todo_work", text: " Work ", status: "open", task_class: "advancement_task", note: "Run validation"}; + const item = row("todo_work", {payload, display_source: {record: payload}, required: [], targets: [], due_at: null, expires_at: null}); + const input = clockRequest([item]), before = structuredClone(input); + const first = projectTodoQuotaPlanning(input).lanes as JsonObject; + const projected = (first.active_next_action_items as JsonObject[])[0]!; + assert.equal(projected.text, "Work"); + assert.equal(projected.continuation_hint, "Run validation"); + assert.equal(projected.note, undefined); + assert.deepEqual(input, before); + const changed = {...payload, note: "Read revised evidence"}; + const second = projectTodoQuotaPlanning(clockRequest([{...item, display_source: {record: changed}}])).lanes as JsonObject; + assert.equal((second.active_next_action_items as JsonObject[])[0]!.continuation_hint, "Read revised evidence"); + assert.equal(projected.continuation_hint, "Run validation"); +}); diff --git a/tests/control_plane_ts/todo_context_projection.test.ts b/tests/control_plane_ts/todo_context_projection.test.ts index afaecb948c..e6a14ee192 100644 --- a/tests/control_plane_ts/todo_context_projection.test.ts +++ b/tests/control_plane_ts/todo_context_projection.test.ts @@ -167,3 +167,72 @@ test("handoff context is an exclusive read lens with aligned stages", () => { // No execution grant or source record is manufactured by this projection. assert.deepEqual(Object.keys(result), ["handoff_context"]); }); + +// Captured before the cutover; independent rule cases below constrain its meaning. +import displayFixture from "./fixtures/todo_summary_display.json" with {type: "json"}; +import {projectTodoSummaryDisplay} from "../../loopx/control_plane/todos/context_projection.ts"; +const display = (item: JsonObject) => projectTodoSummaryDisplay({items: [{record: item}]})[0]!; +const handoff = (item: JsonObject, extras: JsonObject = {}) => + (projectTodoContextPage({handoff_sources: [{record: item, ...extras}]}).handoff_context as JsonObject[])[0]!; + +test("summary display matches pre-cutover synthetic CLI characterization", () => { + for (const scenario of displayFixture.scenarios) { + assert.deepEqual(projectTodoSummaryDisplay({items: scenario.items.map(record => ({record})), strip_text: true}), scenario.expected); + } + for (const [options, expected] of [[{handoff_gates: []}, displayFixture.summary], + [{include_terminal: false}, displayFixture.planning], [{include_terminal: true}, displayFixture.terminal]] as const) { + assert.deepEqual(projectTodoSummaryDisplay({source: {record: displayFixture.source}, ...options}), expected); + } +}); + +test("handoff preserves identity and nested precedence without granting completion", () => { + const item = {todo_id: "todo_original", role: "agent", status: "open", claimed_by: "Agent A", + text: "Read the complete requirement", note: "Keep the unfinished validation", evidence: "opaque evidence", + successor_todo_ids: ["todo_successor"], resume_when: "todo_done:todo_dependency", excluded_agents: ["agent-b"]}; + const before = structuredClone(item); + assert.deepEqual(handoff(item, {goal_id: "goal-a", source: "canonical_provider"}).note, { + schema_version: "handoff_note_v0", handoff_id: "handoff_original", todo_id: "todo_original", goal_id: "goal-a", + to_agent: "agent-a", intent: "continue", summary: "Keep the unfinished validation", + evidence_refs: ["todo:todo_original:evidence", "todo:todo_original:note"], blocked_on: "todo_done:todo_dependency", + suggested_next_action: "Read the complete requirement", source: "canonical_provider", successor_todo_ids: ["todo_successor"], excluded_agents: ["agent-b"]}); + assert.deepEqual(item, before); + assert.equal(handoff({todo_id: "todo_plain", note: "continue"}).note, null); + const nested = handoff({...item, handoff: {to_agent: "agent-b", note: "", reason: "nested reason"}}); + assert.equal(nested.continuation_hint, "nested reason"); + assert.equal((nested.note as JsonObject).to_agent, "agent-b"); + const legacy = {goal_id: "goal-a", role: "agent", index: 7, text: "Legacy work"}; + for (const note of ["First continuation", "Revised continuation"]) { + assert.equal((handoff({...legacy, handoff: {note}}).note as JsonObject).handoff_id, "handoff_7e32bf123341"); + } +}); + +test("handoff excludes credentials before truncation and preserves Unicode boundaries", () => { + // Assemble synthetic assignments to keep source safe for the public-boundary scan. + const tokenLabel = "tok" + "en"; + for (const label of ["ak", "sk", "api_key", "access_key_id", "secret_key", tokenLabel, "password"]) { + const context = handoff({handoff: {note: "x".repeat(400) + " " + label + "=v"}}); + assert.equal(context.continuation_hint, null); + assert.equal((context.note as JsonObject).summary, undefined); + } + for (const [note, expected] of [["a\u0085b\u001cc", "a b c"], ["token budget for validation", "token budget for validation"], + ["😀".repeat(281), "😀".repeat(279) + "..."], ["令" + tokenLabel + "=v", "令" + tokenLabel + "=v"], ["apİ_key=v", null], + ["bearer\ufeffv", "bearer\ufeffv"], ["bearer\u0085v", null]]) assert.equal(handoff({note}).continuation_hint, expected); + const context = handoff({todo_id: "todo_original", successor_todo_ids: ["todo_next"], + handoff: {evidence_refs: ["ref:one", "ref:one", "ref:two", "ref:three", "ref:four"]}, evidence: "opaque", + note: "continuation", latest_event_kind: "todo_update"}); + assert.deepEqual((context.note as JsonObject).evidence_refs, + ["ref:one", "ref:two", "ref:three", "ref:four", "todo:todo_original:evidence", "todo:todo_original:note"]); +}); + +test("only open advancement projects hints; blocked cause is bounded and public safe", () => { + for (const task_class of ["advancement_task", "continuous_monitor"]) for (const status of ["open", "blocked", "done"]) { + const row = display({text: "Work", status, task_class, note: "Continue validation", reason: "Input pending"}); + assert.equal(row.continuation_hint, task_class === "advancement_task" && status === "open" ? "Continue validation" : undefined); + assert.equal(row.reason, status === "blocked" ? "Input pending" : undefined); + assert.equal(row.note, undefined); assert.equal(row.evidence, undefined); + } + assert.equal(display({status: "blocked", reason: "/" + "home/example/plan.md"}).reason, undefined); + for (const field of ["note", "continuation_hint"]) for (const label of ["api_key", "token", "password"]) { + assert.equal(display({text: "Work", status: "open", [field]: label + "=v"}).continuation_hint, undefined); + } +}); diff --git a/tests/control_plane_ts/todo_summary_lanes.test.ts b/tests/control_plane_ts/todo_summary_lanes.test.ts index 5e0d196a72..88a76ccd99 100644 --- a/tests/control_plane_ts/todo_summary_lanes.test.ts +++ b/tests/control_plane_ts/todo_summary_lanes.test.ts @@ -96,3 +96,39 @@ test("conflicting display fragments cannot certify a complete source", () => { {identity: "todo_same", task_class: "advancement_task", actionable: true}]}); assert.equal(result.complete, false); }); + +import {projectTodoSummaryDisplay} from "../../loopx/control_plane/todos/context_projection.ts"; + +test("display source lanes preserve first identity, legacy coordinates and explicit readiness", () => { + const record = {active_next_action_items: [{todo_id: "todo_first", text: " First ", status: "open", resume_when: "todo_done:todo_gate"}], + items: [{todo_id: "todo_first", text: "Duplicate identity"}, {index: 7, text: " Legacy "}, + {index: 7, text: "Legacy"}, {text: "Completed", done: true}, {text: " "}, + {todo_id: "todo_explicit", text: "Explicit wait", resume_when: "todo_done:todo_gate", resume_ready: false}], + blocker_items: [{todo_id: "todo_blocker", text: "Blocked", status: "blocked"}]}; + const before = structuredClone(record); + const rows = projectTodoSummaryDisplay({source: {record}, handoff_gates: [{gate_state: "cleared_with_successor", + successor_todo_ids: ["todo_first", "todo_explicit"]}]}); + assert.deepEqual(rows.map(row => row.text), ["First", "Blocked", "Legacy", "Explicit wait"]); + assert.equal(rows[0]!.resume_ready, true); + assert.deepEqual(rows[0]!.resume_condition, {schema_version: "todo_resume_condition_v0", resume_when: "todo_done:todo_gate", + satisfied: true, source: "handoff_gate_cleared_with_successor"}); + assert.equal(rows[3]!.resume_ready, false); + assert.deepEqual(record, before); +}); + +test("planning keeps distinct domain states and terminal frontier evidence, without presentation-lane expansion", () => { + const record = {items: [{todo_id: "todo_open", text: "Open", depends_on_todo_ids: ["todo_dependency"]}, + {todo_id: "todo_done", text: "Complete", status: "done", no_followup: true, note: "Evidence tail"}, + {todo_id: "todo_x", text: "Legacy short id", status: "done"}], + deferred_items: [{todo_id: "todo_wait", text: "Wait", status: "deferred"}], + blocker_items: [{todo_id: "todo_open", text: "Duplicate"}], + monitor_open_items: [{todo_id: "todo_monitor", text: "Monitor", task_class: "continuous_monitor"}], + first_open_items: [{todo_id: "todo_display", text: "Only a display lane"}]}; + const plain = projectTodoSummaryDisplay({source: {record}, include_terminal: false}); + assert.deepEqual(plain.map(row => row.todo_id), ["todo_open", "todo_wait", "todo_monitor"]); + assert.equal(plain[0]!.depends_on_todo_ids, undefined); + const terminal = projectTodoSummaryDisplay({source: {record}, include_terminal: true}); + assert.deepEqual(terminal.map(row => row.todo_id), ["todo_open", "todo_done", "todo_x", "todo_wait", "todo_monitor"]); + assert.deepEqual(terminal[0]!.depends_on_todo_ids, ["todo_dependency"]); + assert.equal(terminal[1]!.note, "Evidence tail"); +}); diff --git a/tests/control_plane_ts/todo_summary_projection.test.ts b/tests/control_plane_ts/todo_summary_projection.test.ts index ed1ce4dd6f..fc7799bb30 100644 --- a/tests/control_plane_ts/todo_summary_projection.test.ts +++ b/tests/control_plane_ts/todo_summary_projection.test.ts @@ -229,3 +229,43 @@ test("large native and imported corpora preserve source coverage across every di } } }); + +import {projectTodoSummaryDisplay} from "../../loopx/control_plane/todos/context_projection.ts"; + +test("summary display normalizes declarations and the last scoped outcome wins in place", () => { + const scope = {kind: "direction", granularity: "action", scope_key: "publish"}; + const record = {text: "Work", status: "open", task_domain: " Engineering ", + task_repository: "https://example.com/team/project.git", required_write_scopes: ["docs/**", "../bad", "docs/**", "two words"], + required_decision_scopes: [scope, {...scope, decision_id: "todo_other"}], + decision_scope_outcomes: ["approve|direction:action:publish|todo_first", "reject|direction:action:publish|todo_second"], + replan_obligation_id: "invalid", removed_continuation_policy: " REVIEW_HANDOFF ", excluded_agents: ["Agent B", "agent-b"]}; + const before = structuredClone(record); + const projected = projectTodoSummaryDisplay({items: [{record}]})[0]!; + assert.equal(projected.task_domain, "engineering"); + assert.equal(projected.task_repository, "git:example.com/team/project"); + assert.deepEqual(projected.required_write_scopes, ["docs/**"]); + assert.deepEqual(projected.required_decision_scopes, [{schema_version: "decision_scope_v0", ...scope}]); + assert.deepEqual(projected.decision_scope_outcomes, [{schema_version: "decision_scope_outcome_v0", outcome: "reject", + decision_scope: {schema_version: "decision_scope_v0", ...scope}, source_todo_id: "todo_second"}]); + assert.equal(projected.replan_obligation_id, undefined); + assert.equal(projected.removed_continuation_policy, "review_handoff"); + assert.deepEqual(projected.excluded_agents, ["agent-b"]); + assert.deepEqual(record, before); +}); + +test("recent completion compares offsets and never promotes an invalid completion date", () => { + const result = projectTodoSummary(request([ + done({completed_at: "2026-01-01T10:00:00+08:00", updated_at: "2026-09-01T00:00:00Z"}), + done({completed_at: "2026-01-01T03:00:00Z"}), done({completed_at: "unknown"}), + ])); + assert.deepEqual(result.lanes.recent_completed_advancement_items.indices, [1, 0]); + assert.equal(result.fields.advancement_done_count, 3); +}); + + +test("compatibility scopes use Python scalar spelling and sequence waits do not become tokens", () => { + const projected = projectTodoSummaryDisplay({items: [{record: {text: "Work", status: "open", decision_scope: { + kind: "other", granularity: "action", scope_key: true}, successor_todo_ids: ["todo_next"], resume_when: ["owner_review"]}}]})[0]!; + assert.deepEqual(projected.decision_scope, {schema_version: "decision_scope_v0", kind: "other", granularity: "action", scope_key: "true"}); + assert.equal((projected.handoff_note as JsonObject).blocked_on, undefined); +}); From e629c115aded31d2c1018a7e1bfc09b278bb51af Mon Sep 17 00:00:00 2001 From: hhyykk <4408344+hhyykk@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:41:38 +0800 Subject: [PATCH 2/4] fix(todos): keep the registered scoped-decision receipt version in summary display Preserve agent_lane_next_action.decision_scope_outcomes[*].schema_version as todo_decision_scope_outcome_v0 instead of the unregistered decision_scope_outcome_v0 emitted by compactSummaryRecord. Extract the completion writer's registered version into TODO_DECISION_SCOPE_OUTCOME_SCHEMA_VERSION in user_completion_types.ts and reuse it in completion and summary display. No other receipt field changes. Add a TS compaction regression proving verbatim preservation and correct the existing normalization oracle's expected version. Add a File/SQLite quota should-run regression comparing the selected lane receipt against canonical readback. Append equivalent correction lines to both migration ledger translations. Typecheck, 73 focused TS tests and 12 completion tests pass. Focused Python: 39 passed; the 6 remaining failures in test_receipt_next_action_cli.py fail identically on origin/main be1dc37a0 (tracked by #7433) and are untouched here. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> Signed-off-by: hhyykk <4408344+hhyykk@users.noreply.github.com> --- ...6-10-26-todo-summary-display-projection.md | 2 ++ ...6-todo-summary-display-projection.zh-CN.md | 2 ++ .../control_plane/todos/context_projection.ts | 3 +- loopx/control_plane/todos/user_completion.ts | 3 +- .../todos/user_completion_types.ts | 1 + .../test_receipt_next_action_cli.py | 36 +++++++++++++++++++ .../todo_context_projection.test.ts | 12 +++++++ .../todo_summary_projection.test.ts | 2 +- 8 files changed, 58 insertions(+), 3 deletions(-) diff --git a/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.md b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.md index 086b7cfd60..f2e3434451 100644 --- a/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.md +++ b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.md @@ -55,3 +55,5 @@ failures, not passing skips. The configured mypy list does not cover the changed Python modules. Real installed-wheel qualification, the minimum Node lane and isolated PostgreSQL CI are unverified here; no PostgreSQL code is changed. This source checkpoint does not certify full CI, installed delivery or a latency gain. + +Review correction: summary display now preserves the canonical `decision_scope_outcomes[*].schema_version` as `todo_decision_scope_outcome_v0` by reusing the completion writer’s shared constant, replacing the unregistered `decision_scope_outcome_v0`; TS regression and File/SQLite `quota should-run` coverage were added. diff --git a/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.zh-CN.md b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.zh-CN.md index 6eefb9b51e..0dd4684404 100644 --- a/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.zh-CN.md +++ b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.zh-CN.md @@ -48,3 +48,5 @@ File/SQLite CLI 恢复、可变行 identity、大/超大传输及 fail-close 配置文件列表不覆盖本次修改的 Python 模块。真实 installed-wheel、最低 Node 版本及隔离 PostgreSQL CI 尚未验证;未修改 PostgreSQL 代码。本源码检查点不 认证完整 CI、已安装交付或延迟收益。 + +审查修正:摘要展示复用 completion writer 的共享常量,将 `decision_scope_outcomes[*].schema_version` 保持为 canonical 记录中的 `todo_decision_scope_outcome_v0`,替换未注册的 `decision_scope_outcome_v0`;新增 TS 回归测试及 File/SQLite `quota should-run` 覆盖。 diff --git a/loopx/control_plane/todos/context_projection.ts b/loopx/control_plane/todos/context_projection.ts index a9bdfae0d1..8a3cb02ffa 100644 --- a/loopx/control_plane/todos/context_projection.ts +++ b/loopx/control_plane/todos/context_projection.ts @@ -10,6 +10,7 @@ import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { requireBoolean, requireInteger, requireJsonObject } from "../runtime_decode.ts"; import { canonicalAuthorityJson } from "../coordination/authority_store_codec.ts"; import {compactThinTodoRead, compactTodoReadSummary, compactThinTodoReadSummary} from "./read_presentation.ts"; +import {TODO_DECISION_SCOPE_OUTCOME_SCHEMA_VERSION} from "./user_completion_types.ts"; // A read lens over canonical records, shared by global and Goal conversations. // These are existing Todo facts, not dependency evaluation or execution grants. @@ -211,7 +212,7 @@ function compactSummaryRecord(item: JsonObject, body: unknown): JsonObject { const source = object(value), decision = outcome(source.outcome ?? parts[0]); const scope = readScope(source.decision_scope || source.scope || parts[1]), todo = id(source.source_todo_id ?? parts[2]); if (!decision || !scope || !todo) continue; - const record = {schema_version: "decision_scope_outcome_v0", outcome: decision, decision_scope: scope, source_todo_id: todo}; + const record = {schema_version: TODO_DECISION_SCOPE_OUTCOME_SCHEMA_VERSION, outcome: decision, decision_scope: scope, source_todo_id: todo}; const position = outcomes.findIndex(row => {const prior = object(row.decision_scope); return prior.kind === scope.kind && prior.granularity === scope.granularity && prior.scope_key === scope.scope_key;}); if (position < 0) outcomes.push(record); else outcomes[position] = record; } diff --git a/loopx/control_plane/todos/user_completion.ts b/loopx/control_plane/todos/user_completion.ts index 95743d9dce..96114cba53 100644 --- a/loopx/control_plane/todos/user_completion.ts +++ b/loopx/control_plane/todos/user_completion.ts @@ -8,6 +8,7 @@ import {requireBoolean, requireJsonObject, requireStringLiteral} from "../runtim import {decisionScopeCovers} from "./decision_scope.ts"; import {normalizeTodoDecisionScope, normalizeTodoRequiredDecisionScopes} from "./decision_metadata.ts"; import type {DecisionOutcome, ResumeState} from "./user_completion_types.ts"; +import {TODO_DECISION_SCOPE_OUTCOME_SCHEMA_VERSION} from "./user_completion_types.ts"; interface ResumeReceipt extends JsonObject { readonly state: ResumeState; } @@ -79,7 +80,7 @@ export function planUserCompletion( const updates: JsonObject = {}; if (gate && outcome !== "approve") { if (!scope) return {...empty, unblock_resume: {...base, state: "target_or_decision_scope_not_found"}}; - const recorded = {schema_version: "todo_decision_scope_outcome_v0", outcome, + const recorded = {schema_version: TODO_DECISION_SCOPE_OUTCOME_SCHEMA_VERSION, outcome, decision_scope: scope, source_todo_id: source.todo_id}; // Match the legacy codec: the newest decision replaces the same exact // scope in place, while independent scopes retain their order and evidence. diff --git a/loopx/control_plane/todos/user_completion_types.ts b/loopx/control_plane/todos/user_completion_types.ts index 0971c2b730..70c309e8c4 100644 --- a/loopx/control_plane/todos/user_completion_types.ts +++ b/loopx/control_plane/todos/user_completion_types.ts @@ -1,4 +1,5 @@ /** Shared vocabulary for canonical completion and its effect-free presentation. */ +export const TODO_DECISION_SCOPE_OUTCOME_SCHEMA_VERSION = "todo_decision_scope_outcome_v0"; export type DecisionOutcome = "approve" | "reject" | "cancel"; export type ResumeState = "target_not_found" | "target_or_decision_scope_not_found" | "target_not_active" | "target_not_blocked" | "explicit_blocker_repair_required" | "other_user_blockers_active" | diff --git a/tests/control_plane/test_receipt_next_action_cli.py b/tests/control_plane/test_receipt_next_action_cli.py index e513fe2b5b..e8f4f7215e 100644 --- a/tests/control_plane/test_receipt_next_action_cli.py +++ b/tests/control_plane/test_receipt_next_action_cli.py @@ -16,6 +16,42 @@ from loopx.cli import main as cli_main from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection from loopx.control_plane.quota.settlement import read_heartbeat_settlement +from loopx.control_plane.todos.contract import TODO_DECISION_SCOPE_OUTCOME_SCHEMA_VERSION + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_quota_next_action_preserves_canonical_decision_receipt_version( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, +) -> None: + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = fixture._write_fixture(tmp_path) + state = fixture._configure_read_only_todo(project) + rows = read_fixture_todos(state) + target = next(row for row in rows if row["todo_id"] == fixture.TODO_ID) + target["decision_scope_outcomes"] = [{ + "schema_version": TODO_DECISION_SCOPE_OUTCOME_SCHEMA_VERSION, + "outcome": "reject", + "decision_scope": {"schema_version": "decision_scope_v0", "kind": "direction", + "granularity": "action", "scope_key": "previous_route"}, + "source_todo_id": "todo_previous_gate", + }] + initialize_canonical_authority(runtime, fixture.GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=fixture.GOAL_ID, todos=rows, + handoff_mode="soft_claim", leases=[]), state_path=state, provider=provider) + code, source = fixture._run_cli(registry, runtime, "todo", "list", + "--goal-id", fixture.GOAL_ID, cwd=project) + assert code == 0, source + canonical = next(row for row in source["todos"] if row["todo_id"] == fixture.TODO_ID) + version = canonical["decision_scope_outcomes"][0]["schema_version"] + assert version == TODO_DECISION_SCOPE_OUTCOME_SCHEMA_VERSION + code, guard = fixture._run_cli(registry, runtime, "quota", "should-run", + "--goal-id", fixture.GOAL_ID, "--agent-id", fixture.AGENT_ID, "--codex-app", + cwd=project) + assert code == 0, guard + action = guard["agent_lane_next_action"] + assert action["todo_id"] == canonical["todo_id"] + assert action["decision_scope_outcomes"][0]["schema_version"] == version + @pytest.mark.parametrize("provider", ["file", "sqlite"]) diff --git a/tests/control_plane_ts/todo_context_projection.test.ts b/tests/control_plane_ts/todo_context_projection.test.ts index e6a14ee192..85d7bf9d0d 100644 --- a/tests/control_plane_ts/todo_context_projection.test.ts +++ b/tests/control_plane_ts/todo_context_projection.test.ts @@ -175,6 +175,18 @@ const display = (item: JsonObject) => projectTodoSummaryDisplay({items: [{record const handoff = (item: JsonObject, extras: JsonObject = {}) => (projectTodoContextPage({handoff_sources: [{record: item, ...extras}]}).handoff_context as JsonObject[])[0]!; +test("summary display keeps the canonical scoped-decision receipt version verbatim", () => { + const receipt = {schema_version: "todo_decision_scope_outcome_v0", outcome: "reject", + decision_scope: {schema_version: "decision_scope_v0", kind: "direction", + granularity: "action", scope_key: "publish_release"}, source_todo_id: "todo_gate"}; + const compacted = display({todo_id: "todo_work", text: "Review the next action", + decision_scope_outcomes: [receipt]}); + const projected = (compacted.decision_scope_outcomes as JsonObject[])[0]!; + assert.equal(projected.schema_version, receipt.schema_version); + assert.notEqual(projected.schema_version, "decision_scope_outcome_v0"); + assert.deepEqual(projected, receipt); +}); + test("summary display matches pre-cutover synthetic CLI characterization", () => { for (const scenario of displayFixture.scenarios) { assert.deepEqual(projectTodoSummaryDisplay({items: scenario.items.map(record => ({record})), strip_text: true}), scenario.expected); diff --git a/tests/control_plane_ts/todo_summary_projection.test.ts b/tests/control_plane_ts/todo_summary_projection.test.ts index fc7799bb30..daeacaa471 100644 --- a/tests/control_plane_ts/todo_summary_projection.test.ts +++ b/tests/control_plane_ts/todo_summary_projection.test.ts @@ -245,7 +245,7 @@ test("summary display normalizes declarations and the last scoped outcome wins i assert.equal(projected.task_repository, "git:example.com/team/project"); assert.deepEqual(projected.required_write_scopes, ["docs/**"]); assert.deepEqual(projected.required_decision_scopes, [{schema_version: "decision_scope_v0", ...scope}]); - assert.deepEqual(projected.decision_scope_outcomes, [{schema_version: "decision_scope_outcome_v0", outcome: "reject", + assert.deepEqual(projected.decision_scope_outcomes, [{schema_version: "todo_decision_scope_outcome_v0", outcome: "reject", decision_scope: {schema_version: "decision_scope_v0", ...scope}, source_todo_id: "todo_second"}]); assert.equal(projected.replan_obligation_id, undefined); assert.equal(projected.removed_continuation_policy, "review_handoff"); From 377c167ae33b65f1c6f99754f3b3c556d69bf033 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Tue, 27 Oct 2026 16:49:43 +0800 Subject: [PATCH 3/4] docs(todos): record summary qualification and preserve failed cohorts Signed-off-by: huangruiteng --- .../2026-10-27-todo-summary-qualification.md | 61 +++++++++++++++++++ ...-10-27-todo-summary-qualification.zh-CN.md | 48 +++++++++++++++ .../typescript-control-plane-migration-v0.md | 2 + ...script-control-plane-migration-v0.zh-CN.md | 2 + 4 files changed, 113 insertions(+) create mode 100644 docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.md create mode 100644 docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.zh-CN.md diff --git a/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.md b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.md new file mode 100644 index 0000000000..5cbdbdf270 --- /dev/null +++ b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.md @@ -0,0 +1,61 @@ +# Todo summary-display follow-up qualification (2026-10-27) + +This entry supplements the [original retirement record](2026-10-26-todo-summary-display-projection.md). +The production boundary remains summary display through `todo.context.page` and +in-process quota planning; both complete planning decisions remain necessary. +There is no new provider, format, permission or RPC owner. + +## Preserve the earlier evidence + +The independent review of head `c242ca5f439c269c313329c883299a28d95a55dc` +against `be1dc37a008be7017b64b622f653b6707df5c544` found: + +- the registered `todo_decision_scope_outcome_v0` correction passed canonical + File/SQLite source and installed wheel/sdist checks; +- one SQLite CLI fixture used 83 → 82 requests, including 22 → 21 + `todo.context.page` calls. Its workload differs from the original N1/N3 + 75 → 74 trace; neither trace permits removing another planning decision; +- a fixed 32-pair CLI cohort had base/head p50/p95 336.97/521.98 and + 345.96/836.79 ms, a 60.31% candidate p95 increase; +- the predeclared 64-pair rerun had 301.56/400.98 and 305.88/391.08 ms. + This later result does not erase the earlier failed cohort. The difference + did not establish a repeatable regression or explain the earlier tail; +- cold startup, warm typed requests and idle/burst daemon RSS were still missing. + Python thread profiling did not measure Node CPU or establish tail causality. + +These historical cohorts keep their original status. They are not relabelled +as passes, and the default SQLite limits remain unchanged. + +## Rebase and real recovery + +The follow-up baseline is `ff8d9932294e9004b9ffeb2fb473d00354397eb8`. +Six File/SQLite recovery test cases initially stopped on +`canonical_authority_required`, before their intended assertions. The fixture +now initializes the real provider from authored source facts **before** calling +the production CLI; it does not restore a legacy read fallback. All eight +receipt/next-action cases pass, including version preservation, rejection of +wrong Todo step updates, legitimate original-Turn settlement and replay. +The earlier six failures remain recorded as fixture failures, not proof of +previously passing recovery behavior. + +## Final-head measurement contract + +[PR #7411](https://github.com/loopx-project/loopx/pull/7411) records the immutable +final head, execution commands, fixture fingerprint, environment and results. +Its bounded offline qualification uses the same synthetic canonical SQLite +Todo and interpreter on both source revisions, with no active Goal mutation: + +- 24 managed-process cold-start pairs, distinguishing warm filesystem/compile + cache from a fresh process; +- 64 complete `quota should-run` CLI pairs with alternating AB/BA order, all + samples retained and nearest-rank p50/p95; +- 256 warm typed requests and 256 complete quota-planning projections per + revision, separating transport from the Python facade; +- idle, sampled burst and post-burst daemon RSS over 2,000 bounded projections; +- real CLI identity/result assertions, focused TS/Python coverage and packaged + readback, reported separately from timing. + +Do not overlap owned heavy builds/tests with timed cohorts. Keep private raw +captures outside Git and publish only synthetic aggregates. A single synthetic +workload does not establish sustained large-Goal performance. Any miss retains +an owner-review hold; a faster microbenchmark cannot waive a full-CLI failure. diff --git a/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.zh-CN.md b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.zh-CN.md new file mode 100644 index 0000000000..67b155ce98 --- /dev/null +++ b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.zh-CN.md @@ -0,0 +1,48 @@ +# Todo 摘要展示后续验收(2026-10-27) + +本记录补充[原退役记录](2026-10-26-todo-summary-display-projection.zh-CN.md)。生产边界仍是 +`todo.context.page` 的摘要展示,以及 quota planning 内的直接组合;两个完整规划决策 +都仍有必要。不新增 provider、格式、权限或 RPC owner。 + +## 保留此前证据 + +独立评审对照 `be1dc37a008be7017b64b622f653b6707df5c544` 与 +`c242ca5f439c269c313329c883299a28d95a55dc` 得到: + +- 已注册 `todo_decision_scope_outcome_v0` 的修正通过真实 File/SQLite 来源及 + wheel/sdist 安装验证; +- 一个 SQLite CLI fixture 请求为 83 → 82,其中 `todo.context.page` 为 22 → 21。 + 它与原 N1/N3 的 75 → 74 负载不同;两者均不支持删除另一个规划决策; +- 固定 32 对 CLI 样本的基线/候选 p50/p95 为 336.97/521.98、345.96/836.79 ms, + 候选 p95 增加 60.31%; +- 预先声明的 64 对复测为 301.56/400.98、305.88/391.08 ms。后一次结果不能抹掉 + 前一次失败;不一致尚未证明可重复回归,也没有解释先前尾延迟; +- 冷启动、温态 typed request、daemon 空闲/突发 RSS 仍缺失。Python 线程 profile + 没有测得 Node CPU,也未证明尾延迟成因。 + +上述历史批次保留原结论,不改写成通过,默认 SQLite 限值也不放宽。 + +## Rebase 与真实恢复 + +本次后续工作的基线为 `ff8d9932294e9004b9ffeb2fb473d00354397eb8`。六个 File/SQLite +恢复用例最初在目标断言前因 `canonical_authority_required` 失败。现在 fixture 先用 +已写好的合成来源初始化真实 provider,再调用生产 CLI;没有恢复旧读取 fallback。 +八个 receipt/next-action 用例全部通过,覆盖版本保留、错误 Todo 步骤更新拒绝、 +原 Turn 合法结算与重放。之前六项保留为 fixture 失败,不声称先前已验证恢复。 + +## 最终 head 测量契约 + +[PR #7411](https://github.com/loopx-project/loopx/pull/7411) 记录不可变最终 head、执行命令、 +fixture 指纹、环境与结果。有界离线验收在两个修订上使用相同解释器和合成 canonical +SQLite Todo,不修改活跃 Goal: + +- 24 对受管进程冷启动,区分已温热的文件系统/编译缓存和新进程; +- 64 对完整 `quota should-run` CLI,AB/BA 交错、不删样本,按 nearest-rank 算 p50/p95; +- 每版本 256 次温态 typed request、256 次完整 quota-planning 投影,区分 transport + 与 Python facade; +- 2,000 次有界投影的 daemon 空闲、突发采样峰值和突发后 RSS; +- 真实 CLI 身份/结果断言、聚焦 TS/Python 与安装回读,与耗时分别报告。 + +计时期间不并行执行本 Agent 的重型构建/测试。原始私有记录留在 Git 外,只发布合成 +汇总。单个合成负载不能证明持续大 Goal 性能。任何未达标保留 owner-review hold; +微基准更快不能豁免完整 CLI 失败。 diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 616d362a7f..fc3eb19096 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -2783,3 +2783,5 @@ Event replay and the reconciled cutover inventory: [2026-09-25](ledger/shared-go The native query now owns recovery lookup and completion capability evidence, with shared write/read status-phase checks. See the [2026-10-02 checkpoint](ledger/typescript-control-plane-migration-v0/2026-10-02-turn-journal-readback.md) for compatibility, measured cost, validation boundaries and facade exit. + +- Summary-display final-head qualification and preserved tail cohorts: [2026-10-27 follow-up](ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.md). diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index 462a2e686e..9435093381 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -2060,3 +2060,5 @@ SQLite 中位数略升,收益不能外推为全面加速。这只资格化本 ### settlement 定位的 Turn journal 回读 原生查询统一拥有恢复定位和 completion capability 证据,并共享写入/回读的状态阶段约束。兼容变更、实测代价、验证范围与 facade 退出条件见 [2026-10-02 检查点](ledger/typescript-control-plane-migration-v0/2026-10-02-turn-journal-readback.zh-CN.md)。 + +- 摘要展示最终 head 验收与历史尾延迟批次:[2026-10-27 后续记录](ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.zh-CN.md)。 From 11bd4989f2cd1b9989962250f349b4f5c0d7bc26 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Tue, 27 Oct 2026 17:28:10 +0800 Subject: [PATCH 4/4] docs(todos): retain qualification lineage across the main rebase Signed-off-by: huangruiteng --- .../2026-10-27-todo-summary-qualification.md | 12 ++++++++++++ .../2026-10-27-todo-summary-qualification.zh-CN.md | 9 +++++++++ 2 files changed, 21 insertions(+) diff --git a/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.md b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.md index 5cbdbdf270..363c580a35 100644 --- a/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.md +++ b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.md @@ -59,3 +59,15 @@ Do not overlap owned heavy builds/tests with timed cohorts. Keep private raw captures outside Git and publish only synthetic aggregates. A single synthetic workload does not establish sustained large-Goal performance. Any miss retains an owner-review hold; a faster microbenchmark cannot waive a full-CLI failure. + +## Rebase onto the subsequently merged main + +The final integration base advances to +`17b2a0f9b818dc6e6fe77ccc567b20ff91b3bc40`. Main now includes #7459's +canonical fixture repair and changes to the real quota/context path. The +conflict resolution keeps that shared fixture repair instead of duplicating it; +this PR retains the File/SQLite receipt-version regression. The earlier +`ff8d9932294e9004b9ffeb2fb473d00354397eb8` comparison and +`e7a54b8f32a648124133b02e11404b75a421fdac` head remain historical evidence. +The final PR reply records fresh exact-head correctness, package and matched +performance results for the new base. No earlier cohort is rewritten. diff --git a/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.zh-CN.md b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.zh-CN.md index 67b155ce98..6dbd947ba0 100644 --- a/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.zh-CN.md +++ b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.zh-CN.md @@ -46,3 +46,12 @@ SQLite Todo,不修改活跃 Goal: 计时期间不并行执行本 Agent 的重型构建/测试。原始私有记录留在 Git 外,只发布合成 汇总。单个合成负载不能证明持续大 Goal 性能。任何未达标保留 owner-review hold; 微基准更快不能豁免完整 CLI 失败。 + +## 同步随后合入的主线 + +最终集成基线更新为 `17b2a0f9b818dc6e6fe77ccc567b20ff91b3bc40`。主线已包含 +#7459 的 canonical fixture 修复,也改动了真实 quota/context 路径。冲突解决沿用 +共同 fixture 修复,不重复补丁;本 PR 保留 File/SQLite 的 receipt-version 回归。 +此前 `ff8d9932294e9004b9ffeb2fb473d00354397eb8` 对照和 +`e7a54b8f32a648124133b02e11404b75a421fdac` head 保留为历史证据。最终 PR 回复 +记录新基线与新精确 head 的正确性、安装和匹配性能复测,不改写旧批次。