diff --git a/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-11-02-status-task-graph.md b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-11-02-status-task-graph.md new file mode 100644 index 0000000000..a26e0d2ccf --- /dev/null +++ b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-11-02-status-task-graph.md @@ -0,0 +1,153 @@ +# Status task-graph composition (2026-11-02) + +## Owner boundary + +This checkpoint moves the complete optional status task-graph decision from Python +`work_items/task_graph.py` into the existing TypeScript `work_items/task_graph.ts`. +One `work_item.task_graph.status` operation owns selected work, claim display, +user-gate selection/count/cap/overflow, bounded predecessor traversal, run +selection and lineage joins, evidence, handoff, repair, deduplication, dangling +edge rejection, timestamps and completeness/limits. It calls existing topology, +Todo summary, classification and handoff owners in process. The standalone +`work_item.task_graph.topology` handler is retired; topology remains an internal +composition function used by status and its semantic tests. + +Python supplies source-shaped status facts, latest-run facts and fallback time. +It retains public-text redaction/normalization facts. Status SHA-256 node/edge +identity materialization moves into the composer with byte-identical Unicode +and `None` encoding, so deduplication compares actual historical identities +rather than unevaluated descriptors. Goal-map uses the same native identity, +state and topology-row functions; the three Python rule helpers are deleted. +Source fields are restricted to existing Todo read fields and the consumed +status lanes. Derived display text uses the qualified composition-only +compaction boundary described below. There is no Python selection, traversal, gate, evidence, +handoff, lineage, repair or limit fallback. + +`build_goal_task_map` retains its public signature and byte-identical output for +`chat_status_api.py`. It sends source/public-text facts to `projectGoalTaskMap`, +which now materializes node/edge identities, state and metadata. Native topology +fact requests retain their existing compatibility contract. Omitted opaque +metadata is not stringified; unsupported non-JSON public-text values are marked +unavailable at the codec edge and rejected only if consumed. Uncalled status +builders, attachers, source collectors, ref helpers and constants are deleted. +No new Python/TypeScript basename pair is added. + +Physical product diff: +865/−1030, net −165 lines against the archived base. +`task_graph.py` falls from 1,044 to 255 lines: one module-level recursive public +text codec and two source/transport facades, with no graph decision rules. +Tests, fixtures and documentation are counted separately. + +## Public text and remaining duplication + +Choice (a): there is no second TS redaction rule. Source strings must have +Python public-text facts; a missing fact throws. The local fallback only calls +`compactTodoReadText` on registered public fact outputs or explicitly composed +strings. Interpolated components are asserted to be registered public strings +or numeric counts. Native tests enumerate composed titles, identities, +references and reasons, and reject unqualified source text. Constructors are +claim/lease, evidence prefix and public evidence prefix-slice, handoff identity/ +title/reasons, missing-ID gate, gate overflow, latest-run title, repair defaults, +and joins of generated node identities. Fixed reason prose comes from native +graph construction; generated identities contain only ASCII identifier/hash +characters. Goal scope is already public-normalized by the status caller. +No `INTERNAL_STATE_SHAPE_PATTERNS` redaction copy remains in this module. + +`rg` found no equivalent TS owner of Python `open_todo_items`: `read_codec.ts` +provides compaction/fields, `context_projection.ts` provides planning/display +sources, and `summary_lanes.ts` selects normalized summary lanes, not the +historical source-key, dedup-before-sort, bounded-open display selection. +Python still serves status attention/monitor projections, project-asset +readbacks and Todo summary renderers. `statusOpenItems` therefore retains that +bounded duplication. Exit: migrate those callers to one native Todo open-display +selection owner, qualify ordering/handoff/limit parity, then remove the Python +selection and delegate the graph to that owner. Do not substitute normalized +summary-lane selection with different admission semantics. + +The codec caches repeated source strings and computes only field-used caps; +no source redaction facts are inferred from truncated text. Display caps and the +unsupported-text marker are local transport facts, not a new shared decision +vocabulary. Counts below include +the initial Goal public-text call; “before” means the reviewed encoder draft, +which called the public codec six times per source string (full safety plus +five display caps), not the original all-Python graph: + +| Status case | Public-text calls before → after | +| --- | --- | +| `diamond_at_cap` | 127 → 32 | +| `cycle` | 79 → 22 | +| `missing_prerequisite` | 31 → 12 | +| `open_predecessor` | 79 → 24 | +| `condition_and_lineage` | 55 → 19 | +| `predecessor_overflow` | 193 → 47 | +| `duplicate_identity` | 97 → 19 | +| `long_public_text` | 61 → 21 | +| `redaction` | 61 → 21 | +| `handoff_evidence` | 73 → 21 | +| `unicode_ids` | 43 → 15 | +| `all_empty` | 1 → 1 | +| `gate_overflow` | 145 → 24 | +| `no_selected_run_gate` | 43 → 17 | +| `repair_evidence` | 79 → 29 | +| `stale_source_time` | 43 → 18 | +| `absent_goal` | 1 → 1 | +| `invalid_goal` | 1 → 1 | + +Largest characterization request (`long_public_text`): compact JSON parameters +with the transport's ASCII escaping, 6,797 → 4,899 bytes (−27.9%); calls +61 → 21 (−65.6%). These are parameter bytes, excluding RPC framing. The maximum +call case, predecessor overflow, falls from 193 to 47. This is codec/request +cost evidence, not an end-to-end latency or effectiveness claim. + +## Crossings and parity + +Instrumentation counts every `effect_runtime_result` reached by the public +Python graph entrypoint, including Todo display/handoff helpers: + +| Operation | Before → after | +| --- | --- | +| Selected work, including topology/error evidence cases | 4 → 1 | +| User-gate overflow | 7 → 1 | +| Run/gate nodes with no selected Todo | 4 → 1 | +| Valid Goal with all-empty facts and no graph | 0 → 1 | +| Absent or invalid Goal | 0 → 0 | +| Goal task map | 1 → 1 | + +The empty valid snapshot is decided inside the native composer; only absent or +invalid Goals return before transport. A rejected native request has no Python +fallback. Large packets reuse the existing local snapshot transport. + +The frozen synthetic characterization corpus was green on unchanged +`fb16c6cb1369efce52a673030c914b38e1dac166` (21 cases) before implementation; +expected JSON bytes remain unchanged. Independent native cases test semantics +rather than using that corpus as a specification. The status Markdown renderer +has no graph section. Real `status --include-task-graph` CLI output matches an +archived base byte-for-byte on isolated File and SQLite homes in JSON and +Markdown, with a fixed observation clock: respectively 33,417/33,445 and +5,120/5,126 bytes. The actual chat `_goal_task_map` entrypoint, using the same +canonical File/SQLite sources, matches at 2,310 bytes on each provider. The three +already-pinned Goal-map cases were green on the archived base before refactoring; +the fixture expected outputs have not been edited. + +## Qualification and exit + +Current rerun: Python 355 passed, 2 failed across the six requested suites and +all tests found by `rg build_goal_task_map / chat_status_api`; native graph/handler/ +loading tests 50 passed. Ruff (two changed Python files), control-plane typecheck, +task-graph fixture, attention queue, semantic vocabulary and maintainability +smokes, six CLI/chat parity comparisons and `git diff --check` passed. Earlier +interface-budget/integrated-canary evidence remains historical, not a new rerun. The quota planning-horizon suite retains two failures +(missing `planning_horizon`, File and SQLite), reproduced with the same tests +in an untouched `git archive HEAD` baseline copy; they are not reported as passes. +No gate, budget, vocabulary anchor or baseline is relaxed. + +The Python status facade exits when status callers deliver the source/public +text facts directly to the native owner and the retained public-text codec has +an independently qualified native replacement. Remove its operation only when +those callers use an in-process owner. The same codec exit applies to +the Goal-map facade. The characterization corpus +can retire once facade removal has independent public-entrypoint coverage. + +The adjacent cleanup removes the obsolete topology RPC and callback surface; +no unrelated refactor or product journey is introduced. Existing CLI, attention +queue, chat and review-packet consumers keep their public payloads. No end-to-end latency, +total resource-cost or long-horizon effectiveness/efficiency benefit is claimed. diff --git a/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-11-02-status-task-graph.zh-CN.md b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-11-02-status-task-graph.zh-CN.md new file mode 100644 index 0000000000..97dd0fc9d4 --- /dev/null +++ b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-11-02-status-task-graph.zh-CN.md @@ -0,0 +1,125 @@ +# Status 任务图组合(2026-11-02) + +## Owner 边界 + +本检查点将可选 status 任务图的完整决策从 Python `work_items/task_graph.py` +迁入现有 TypeScript `work_items/task_graph.ts`。单个 +`work_item.task_graph.status` 操作拥有所选工作、claim 展示、用户 gate +选择/计数/上限/溢出、有界前驱遍历、run 选择与 lineage 连接、证据、handoff、 +repair、去重、悬空边拒绝、时间戳及完整性/限制。它在进程内组合现有 topology、 +Todo 摘要、分类和 handoff owner。独立的 `work_item.task_graph.topology` +handler 已退役;topology 作为内部组合函数保留,由 status 及其语义测试使用。 + +Python 提供源形状的 status 事实、latest-run 事实和后备时间。它保留公开文本 +脱敏/规范化事实。status 的 SHA-256 节点/边身份物化迁入组合器,Unicode 和 +`None` 编码逐字节一致,从而使用实际历史身份去重,而非尚未求值的 descriptor。 +Goal-map 也复用同一套原生身份、状态及 topology-row 函数;三个 Python 规则 helper 已删除。源字段限定为现有 Todo 读取字段及实际消费的 status lane。 +派生展示文本使用下文限定的组合文本压缩边界。不存在 Python 的选择、遍历、 +gate、证据、handoff、lineage、repair 或限制后备决策。 + +`build_goal_task_map` 保持公开签名及 `chat_status_api.py` 输出逐字节一致。 +它向 `projectGoalTaskMap` 提交源数据/公开文本事实,由原生实现物化节点/边身份、 +状态及元数据。现有原生 topology 事实请求保留兼容合同。被省略的 opaque 元数据 +不会提前字符串化;非 JSON 公开文本值在 codec 边界标记为不可用,仅在消费时拒绝。 +无调用方的 status builder、attacher、源收集器、ref helper 和常量已删除。 +没有新增 Python/TypeScript 同名文件对。 + +相对归档基线,产品物理行差异为 +865/−1030,净 −165 行。 +`task_graph.py` 从 1,044 行降至 255 行:一个模块级递归公开文本 codec 和两个 +源数据/传输 facade,不含任务图决策规则。测试、fixture 和文档单独计数。 + +## 公开文本与保留的重复规则 + +选择 (a):没有第二套 TS 脱敏规则。源字符串必须提供 Python 公开文本事实, +缺少事实时抛错。本地 fallback 仅对已登记的公开事实输出或显式组合字符串调用 +`compactTodoReadText`。插值组件断言为已登记公开字符串或数字计数。原生测试枚举 +组合标题、身份、引用及原因,并拒绝未限定的源字符串。构造器包括 claim/lease、 +evidence 前缀及公开证据的前缀切片、handoff 身份/标题/原因、缺失 ID 的 gate、 +gate 溢出、latest-run 标题、repair 默认文本和生成节点身份的连接。固定原因来自 +原生图构造;生成身份仅含 ASCII 标识符/hash 字符。status 调用方已对 Goal scope +做公开规范化。模块不再复制 `INTERNAL_STATE_SHAPE_PATTERNS` 脱敏规则。 + +`rg` 未找到 Python `open_todo_items` 的等价 TS owner:`read_codec.ts` 提供 +压缩/字段,`context_projection.ts` 提供规划/展示源,`summary_lanes.ts` 选择 +规范化摘要 lane,不实现历史 source-key、先去重再排序、有界开放项展示选择。 +Python 仍服务 status attention/monitor、project-asset 读回及 Todo 摘要渲染。 +因此 `statusOpenItems` 暂时保留这项有限重复。退出条件:这些调用方迁入统一的 +原生 Todo open-display 选择 owner,验收排序/handoff/上限一致性,再删除 Python +选择并让图委托同一 owner。不得用准入语义不同的摘要 lane 选择替代。 + +codec 缓存重复源字符串,只计算字段实际使用的上限;不从截断文本推断源脱敏事实。展示上限和不支持文本标记仅是本地传输事实, +不构成新的共享决策词汇。 +下表包含初始 Goal 公开文本调用;“之前”指本次评审的 encoder 初稿,每个源字符串 +调用六次(完整安全检查加五个展示上限),并非最初的全 Python 图: + +| Status 用例 | 公开文本调用次数:之前 → 之后 | +| --- | --- | +| `diamond_at_cap` | 127 → 32 | +| `cycle` | 79 → 22 | +| `missing_prerequisite` | 31 → 12 | +| `open_predecessor` | 79 → 24 | +| `condition_and_lineage` | 55 → 19 | +| `predecessor_overflow` | 193 → 47 | +| `duplicate_identity` | 97 → 19 | +| `long_public_text` | 61 → 21 | +| `redaction` | 61 → 21 | +| `handoff_evidence` | 73 → 21 | +| `unicode_ids` | 43 → 15 | +| `all_empty` | 1 → 1 | +| `gate_overflow` | 145 → 24 | +| `no_selected_run_gate` | 43 → 17 | +| `repair_evidence` | 79 → 29 | +| `stale_source_time` | 43 → 18 | +| `absent_goal` | 1 → 1 | +| `invalid_goal` | 1 → 1 | + +最大刻画请求 `long_public_text` 使用传输层 ASCII 转义的紧凑 JSON 参数计量, +6,797 → 4,899 字节(−27.9%),调用 61 → 21(−65.6%)。这些是参数字节, +不含 RPC framing。最大调用用例 predecessor overflow 从 193 降至 47。 +这是 codec/请求成本证据,不代表端到端延迟或效果收益。 + +## 跨运行时调用与一致性 + +插桩统计公开 Python 图入口触发的每次 `effect_runtime_result`,包括 Todo +展示/handoff helper: + +| 操作 | 迁移前 → 后 | +| --- | --- | +| 所选工作,包括 topology/错误证据情形 | 4 → 1 | +| 用户 gate 溢出 | 7 → 1 | +| 无所选 Todo、但有 run/gate 节点 | 4 → 1 | +| 有效 Goal、事实全空且无图 | 0 → 1 | +| 缺失或无效 Goal | 0 → 0 | +| Goal 任务地图 | 1 → 1 | + +有效空快照由原生组合器决策;只有缺失或无效 Goal 在传输前返回。原生请求拒绝后 +没有 Python fallback。大请求复用现有本地快照传输。 + +冻结的合成刻画语料在实现前已于未修改的 +`fb16c6cb1369efce52a673030c914b38e1dac166` 通过(21 个用例);预期 JSON +字节保持不变。独立原生用例验证语义,而不将刻画语料当作规范。status Markdown +渲染器没有图专属章节。真实 `status --include-task-graph` CLI 在隔离的 File 和 +SQLite home 上,以固定观察时钟对比归档基线,JSON 和 Markdown 均逐字节相同: +分别为 33,417/33,445 及 5,120/5,126 字节。实际 chat `_goal_task_map` +入口使用相同的规范 File/SQLite 源,两者均逐字节一致,为 2,310 字节。三个已冻结的 +Goal-map 用例在重构前已于归档基线通过;fixture 预期输出未修改。 + +## 验收与退出 + +本次重跑:六个指定 Python 套件及 `rg build_goal_task_map / chat_status_api` +找到的全部测试共 355 通过、2 失败;原生图/handler/loading 测试 50 通过。 +两个变更 Python 文件的 Ruff、control-plane typecheck、任务图 fixture、attention +queue、语义词汇、可维护性 smoke、六项 CLI/chat 一致性和 `git diff --check` +均通过。早先接口预算和集成 canary 证据仅保留为历史,不声称本次重跑。 +quota planning-horizon 套件仍有两项 +失败(File 和 SQLite 缺少 `planning_horizon`),使用同样测试在未修改的 +`git archive HEAD` 基线副本中复现;不将其报告为通过。未放宽任何 gate、预算、 +词汇锚点或基线。 + +当 status 调用方直接向原生 owner 提供源/公开文本事实,且保留的公开文本 codec +已有独立验收的原生替代时,Python status facade 才可退出。只有这些调用方 +改为进程内 owner 后,才移除其操作。Goal-map facade 使用相同的 codec 退出条件。facade 移除具备独立公开入口覆盖后,可退役刻画语料。 + +相邻清理移除了过时 topology RPC 和 callback 表面;没有引入无关重构或产品 +旅程。现有 CLI、attention queue、chat 及 review-packet 消费方保持其公开 +payload。不声称端到端延迟、完整资源成本或长程效果/效率收益。 diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index f30134a160..393b7dfee5 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -2843,6 +2843,7 @@ with shared write/read status-phase checks. See the [2026-10-02 checkpoint](ledg - Complete global summary and gate read transactions: [2026-11-02 checkpoint](ledger/typescript-control-plane-migration-v0/2026-11-02-global-manager-reads.md). - Complete global-risk read ownership: [2026-11-02](ledger/typescript-control-plane-migration-v0/2026-11-02-global-risks-read.md). - Complete durable-completion evidence read: [2026-11-02 owner and crossings ledger](ledger/typescript-control-plane-migration-v0/2026-11-02-completion-evidence-read.md). +- Complete status task-graph composition: [2026-11-02 checkpoint](ledger/typescript-control-plane-migration-v0/2026-11-02-status-task-graph.md). - Complete adaptive child admission decision: [2026-11-02 checkpoint](ledger/typescript-control-plane-migration-v0/2026-11-02-adaptive-child-admission.md). - Progress-review receipt qualification: [2026-11-02 checkpoint](ledger/typescript-control-plane-migration-v0/2026-11-02-progress-review-qualification.md). - Global Todo readiness complete owner cutover (both consumers): [2026-11-02 ledger](ledger/typescript-control-plane-migration-v0/2026-11-02-global-todo-readiness.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 ac9c783314..ea25ee7da4 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 @@ -2108,6 +2108,7 @@ SQLite 中位数略升,收益不能外推为全面加速。这只资格化本 - 完整全局摘要与门禁读取事务:[2026-11-02 检查点](ledger/typescript-control-plane-migration-v0/2026-11-02-global-manager-reads.zh-CN.md)。 - 完整全局风险读取所有权:[2026-11-02](ledger/typescript-control-plane-migration-v0/2026-11-02-global-risks-read.zh-CN.md)。 - 完整 durable-completion 证据读取:[2026-11-02 owner 与调用边界记录](ledger/typescript-control-plane-migration-v0/2026-11-02-completion-evidence-read.zh-CN.md)。 +- 完整 status 任务图组合:[2026-11-02 检查点](ledger/typescript-control-plane-migration-v0/2026-11-02-status-task-graph.zh-CN.md)。 - 完整自适应子 Agent 准入决策:[2026-11-02 检查点](ledger/typescript-control-plane-migration-v0/2026-11-02-adaptive-child-admission.zh-CN.md)。 - 进度评审 receipt 资格判定:[2026-11-02 检查点](ledger/typescript-control-plane-migration-v0/2026-11-02-progress-review-qualification.zh-CN.md)。 - Global Todo 就绪分类完整 owner 切换(两个消费者):[2026-11-02 记录](ledger/typescript-control-plane-migration-v0/2026-11-02-global-todo-readiness.zh-CN.md)。 diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index d9a5207d39..b2d5c428fd 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -327,7 +327,7 @@ export function createEffectRuntimeHandlers( lazyHandler(() => import("./work_items/action_portfolio.ts"), ({reconcileRetainedActionSelection}) => reconcileRetainedActionSelection), ], ["todo.context.page", lazyHandler(() => import("./todos/context_projection.ts"), ({projectTodoContextPage}) => projectTodoContextPage)], - ["work_item.task_graph.topology", lazyHandler(() => import("./work_items/task_graph.ts"), ({projectTaskGraphTopology}) => projectTaskGraphTopology)], + ["work_item.task_graph.status", lazyHandler(() => import("./work_items/task_graph.ts"), ({projectStatusTaskGraph}) => projectStatusTaskGraph)], ["work_item.task_graph.goal_topology", lazyHandler(() => import("./work_items/task_graph.ts"), ({projectGoalTaskMap}) => projectGoalTaskMap)], ["work_item.refresh_recommendation.resolve", lazyHandler(() => import("./work_items/refresh_recommendation.ts"), ({resolveRefreshRecommendation}) => resolveRefreshRecommendation)], ["work_item.refresh_recommendation.lane", lazyHandler(() => import("./work_items/refresh_recommendation.ts"), ({resolveLaneRecommendation}) => resolveLaneRecommendation)], diff --git a/loopx/control_plane/effect_runtime_snapshot_methods.json b/loopx/control_plane/effect_runtime_snapshot_methods.json index 54e13485ce..894cedb89f 100644 --- a/loopx/control_plane/effect_runtime_snapshot_methods.json +++ b/loopx/control_plane/effect_runtime_snapshot_methods.json @@ -12,6 +12,7 @@ "work_item.interaction_reads.project", "scheduler.hint.project", "work_item.task_graph.goal_topology", + "work_item.task_graph.status", "work_item.replan_semantics.project", "quota.settlement.read", "quota.spend.commit", diff --git a/loopx/control_plane/work_items/task_graph.py b/loopx/control_plane/work_items/task_graph.py index 0f1bcedb90..963c48f08d 100644 --- a/loopx/control_plane/work_items/task_graph.py +++ b/loopx/control_plane/work_items/task_graph.py @@ -1,638 +1,73 @@ from __future__ import annotations -import hashlib import json -import re from typing import Any, Callable from ..runtime.time import now_utc_iso from ..effect_runtime import MAX_REQUEST_BYTES, effect_runtime_result -from ..todos.contract import TODO_STATUS_DEFERRED -from ..todos.summary_item import todo_planning_source_items - -TASK_GRAPH_PROJECTION_SCHEMA_VERSION = "task_graph_projection_v0" -TASK_GRAPH_SOURCE_OF_TRUTH = [ - "event_ledger", - "active_goal_state", - "todos", - "gates", - "leases", - "run_history", -] -TASK_GRAPH_MAX_USER_GATE_NODES = 2 -TASK_GRAPH_MAX_PREDECESSOR_NODES = 4 -TASK_GRAPH_AUDIT_MARKERS = ("audit", "audited") -TASK_GRAPH_CONTINUATION_MARKERS = ("continuation", "continue", "continuing", "continued") - - -def _task_graph_node_id( - prefix: str, +from ..coordination.coordination_state_contract import TODO_CANONICAL_READ_RECORD_FIELDS +from ..todos.summary_item import TODO_PLANNING_SOURCE_KEYS + + +_PUBLIC_TEXT_CAPS = { + "todo_id": (120,), + "title": (160,), + "text": (160, 180), + "claimed_by": (80,), + "task_domain": (80,), + "last_actor_agent_id": (80,), + "from_agent": (80,), + "to_agent": (80,), + "status": (80,), + "evidence": (180,), + "note": (180,), + "successor_todo_ids": (120,), + "unblocks_todo_id": (240,), + "superseded_by": (240,), + "resume_when": (240,), + "run_id": (120,), + "generated_at": (80, 120), + "classification": (120, 160), + "recommended_action": (160,), + "delivery_outcome": (160,), + "delivery_batch_scale": (160,), + "schema_version": (120,), + "kind": (120,), +} + + +def _encode_public_text( value: Any, - *, - public_safe_compact_text: Callable[..., str | None], - durable_id: str | None = None, -) -> str: - raw = public_safe_compact_text(value, limit=120) or prefix - normalized = re.sub(r"[^A-Za-z0-9_]+", "_", raw).strip("_").lower() - if not normalized: - normalized = hashlib.sha256(str(value).encode("utf-8")).hexdigest()[:10] - if durable_id: - id_hash = hashlib.sha256(durable_id.encode("utf-8")).hexdigest()[:6] - else: - id_hash = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:6] - if len(normalized) > 50: - normalized = f"{normalized[:44].rstrip('_')}_{id_hash}" - return f"{prefix}_{normalized}_{id_hash}" - - -def _task_graph_ref_values( - *values: Any, - limit: int = 4, - public_safe_compact_text: Callable[..., str | None], -) -> list[str]: - refs: list[str] = [] - for value in values: - if isinstance(value, list): - for nested in _task_graph_ref_values( - *value, - limit=limit, - public_safe_compact_text=public_safe_compact_text, - ): - if nested not in refs: - refs.append(nested) - if len(refs) >= limit: - return refs - continue - text = public_safe_compact_text(value, limit=120) - if not text or text in refs: - continue - refs.append(text) - if len(refs) >= limit: - break - return refs - - -def _task_graph_refs( - key: str, - *values: Any, - public_safe_compact_text: Callable[..., str | None], -) -> dict[str, list[str]] | None: - refs = _task_graph_ref_values(*values, public_safe_compact_text=public_safe_compact_text) - if not refs: - return None - return {key: refs} - - -def _task_graph_todo_state( - todo: dict[str, Any], - *, - normalize_todo_status: Callable[[Any], str | None], - todo_done_for_status: Callable[[str], bool], - todo_status_open: str, - waiting_default: bool = False, -) -> str: - status = normalize_todo_status(todo.get("status")) or todo_status_open - # Deferred is terminal for scheduling, but the work has not been delivered. - if status == TODO_STATUS_DEFERRED: - return "waiting" - if todo.get("done") or todo_done_for_status(status): - return "done" - if status == "blocked": - return "blocked" - if status in {"waiting", "deferred"} or waiting_default: - return "waiting" - return "open" - - -def _task_graph_generated_at( - *, - goal: dict[str, Any], - goal_latest_runs: list[dict[str, Any]], + texts: dict[str, dict[str, str | None]], public_safe_compact_text: Callable[..., str | None], - latest_run: Callable[[dict[str, Any]], dict[str, Any] | None], -) -> str: - candidates: list[dict[str, Any]] = [*goal_latest_runs] - current_run = latest_run(goal) - if isinstance(current_run, dict): - candidates.append(current_run) - for run in candidates: - generated_at = public_safe_compact_text(run.get("generated_at"), limit=80) - if generated_at: - return generated_at - return now_utc_iso() - - -def _task_graph_active_state_updated_at( - item: dict[str, Any], - goal: dict[str, Any], - *, - public_safe_compact_text: Callable[..., str | None], - latest_run: Callable[[dict[str, Any]], dict[str, Any] | None], -) -> str | None: - warning = ( - item.get("stale_latest_run_warning") - if isinstance(item.get("stale_latest_run_warning"), dict) - else {} - ) - updated_at = public_safe_compact_text(warning.get("active_state_updated_at"), limit=80) - if updated_at: - return updated_at - current_run = latest_run(goal) - state = ( - current_run.get("state") - if isinstance(current_run, dict) and isinstance(current_run.get("state"), dict) - else {} - ) - frontmatter = state.get("frontmatter") if isinstance(state.get("frontmatter"), dict) else {} - return public_safe_compact_text(frontmatter.get("updated_at"), limit=80) - - -def _task_graph_latest_run_node( - *, - goal_latest_runs: list[dict[str, Any]], - selected_todo_id: str | None, - public_safe_compact_text: Callable[..., str | None], -) -> dict[str, Any] | None: - for run in goal_latest_runs: - if not isinstance(run, dict): - continue - run_ref = public_safe_compact_text( - run.get("run_id") or run.get("generated_at") or run.get("classification"), - limit=120, - ) - classification = public_safe_compact_text(run.get("classification"), limit=120) - if not (run_ref or classification): - continue - refs = _task_graph_refs( - "run_ids", - run_ref or classification, - public_safe_compact_text=public_safe_compact_text, - ) - if refs and selected_todo_id: - todo_refs = _task_graph_ref_values( - selected_todo_id, - public_safe_compact_text=public_safe_compact_text, - ) - if todo_refs: - refs["todo_ids"] = todo_refs - if not refs: - continue - title = classification or "Latest compact run-history evidence" + caps: tuple[int, ...] = (80, 120, 160, 180, 240), +) -> Any: + """Encode source text once; native composition consumes these public facts.""" + if isinstance(value, dict): return { - "node_id": _task_graph_node_id( - "node_run", - run_ref or title, - public_safe_compact_text=public_safe_compact_text, - ), - "kind": "validation", - "title": f"Latest compact run: {title}", - "state": "ready", - "refs": refs, - } - return None - - -def _task_graph_latest_run_lineage_relations( - run_node_id: str | None, - selected_node_id: str | None, - *, - goal_latest_runs: list[dict[str, Any]], - public_safe_compact_text: Callable[..., str | None], -) -> list[tuple[str, str]]: - if not run_node_id or not selected_node_id: - return [] - for run in goal_latest_runs: - if not isinstance(run, dict): - continue - values = [ - run.get("classification"), - run.get("recommended_action"), - run.get("delivery_outcome"), - run.get("delivery_batch_scale"), - ] - text = " ".join( - public_safe_compact_text(value, limit=160) or "" - for value in values - ).lower() - if not text.strip(): - continue - relations: list[tuple[str, str]] = [] - if any(marker in text for marker in TASK_GRAPH_AUDIT_MARKERS): - relations.append( - ( - "audits", - "Compact run-history evidence audits the selected work lane without replacing todo or gate state.", - ) - ) - if any(marker in text for marker in TASK_GRAPH_CONTINUATION_MARKERS): - relations.append( - ( - "continues", - "Compact run-history evidence records a continuation of the selected work lane.", - ) + key: _encode_public_text( + child, texts, public_safe_compact_text, _PUBLIC_TEXT_CAPS.get(key, ()) ) - return relations - return [] - - -def _task_graph_visible_user_gate_items( - user_todos: dict[str, Any] | None, - *, - limit: int, - open_todo_items: Callable[..., list[dict[str, Any]]], - max_status_todos_per_role: int, - todo_item_task_class: Callable[[dict[str, Any]], str], - user_gate_task_class: str, - todo_summary_open_count: Callable[[dict[str, Any] | None], int], -) -> tuple[list[dict[str, Any]], int]: - visible_items = open_todo_items( - user_todos, - limit=max_status_todos_per_role, - text_limit=180, - source_keys=("gate_open_items", "first_open_items", "items"), - ) - visible_gate_items = [ - item for item in visible_items if todo_item_task_class(item) == user_gate_task_class - ] - if visible_gate_items and len(visible_gate_items) == len(visible_items): - gate_open_count = max(len(visible_gate_items), todo_summary_open_count(user_todos)) - else: - gate_open_count = len(visible_gate_items) - return visible_gate_items[:limit], gate_open_count - - -def _task_graph_collect_todo_items( - todos_summary: dict[str, Any] | None, -) -> dict[str, dict[str, Any]]: - if not isinstance(todos_summary, dict): - return {} - by_id: dict[str, dict[str, Any]] = {} - for raw in todo_planning_source_items(todos_summary, include_terminal=True): - if not isinstance(raw, dict): - continue - tid = raw.get("todo_id") - if isinstance(tid, str) and tid: - by_id[tid] = raw - return by_id - - -def _task_graph_source_items_truncated( - todos_summary: dict[str, Any] | None, -) -> bool: - if not isinstance(todos_summary, dict): - return False - items = todo_planning_source_items(todos_summary, include_terminal=True) - try: - total_count = int(todos_summary.get("total_count") or 0) - except (TypeError, ValueError): - return False - return total_count > len(items) - - -def _task_graph_deliverable_node( - *, - todo: dict[str, Any], - public_safe_compact_text: Callable[..., str | None], - normalize_todo_status: Callable[[Any], str | None], - todo_done_for_status: Callable[[str], bool], - todo_status_open: str, - waiting_default: bool = False, -) -> dict[str, Any] | None: - todo_id = public_safe_compact_text(todo.get("todo_id"), limit=120) - title = public_safe_compact_text(todo.get("title") or todo.get("text"), limit=160) - if not todo_id or not title: - return None - node: dict[str, Any] = { - "node_id": _task_graph_node_id( - "node_todo", - todo_id, - public_safe_compact_text=public_safe_compact_text, - durable_id=todo_id, - ), - "kind": "deliverable", - "title": title, - "state": _task_graph_todo_state( - todo, - normalize_todo_status=normalize_todo_status, - todo_done_for_status=todo_done_for_status, - todo_status_open=todo_status_open, - waiting_default=waiting_default, - ), - "refs": _task_graph_refs( - "todo_ids", - todo_id, - public_safe_compact_text=public_safe_compact_text, - ), - } - owner = public_safe_compact_text(todo.get("claimed_by"), limit=80) - if owner: - node["owner_agent"] = owner - actor = public_safe_compact_text(todo.get("last_actor_agent_id"), limit=80) - if actor: - node["actor_agent"] = actor - return node - - -class _TaskGraphProjectionBuilder: - def __init__( - self, - *, - public_safe_compact_text: Callable[..., str | None], - ) -> None: - self._public_safe_compact_text = public_safe_compact_text - self.nodes: list[dict[str, Any]] = [] - self.edges: list[dict[str, Any]] = [] - self.node_ids: set[str] = set() - self.edge_ids: set[str] = set() - self.refs_by_node_id: dict[str, dict[str, list[str]]] = {} - - def add_node(self, node: dict[str, Any] | None) -> str | None: - if not isinstance(node, dict): - return None - node_id = str(node.get("node_id") or "") - if not node_id: - return None - if node_id in self.node_ids: - return node_id - refs = node.get("refs") if isinstance(node.get("refs"), dict) else None - if not refs: - return None - title = self._public_safe_compact_text(node.get("title"), limit=160) - if not title: - return None - node["title"] = title - self.node_ids.add(node_id) - self.refs_by_node_id[node_id] = refs - self.nodes.append(node) - return node_id - - def add_edge( - self, - *, - edge_id: str, - from_node_id: str | None, - to_node_id: str | None, - relation: str, - reason: str, - refs: dict[str, list[str]] | None = None, - ) -> None: - if not from_node_id or not to_node_id or from_node_id == to_node_id: - return - if ( - from_node_id not in self.node_ids - or to_node_id not in self.node_ids - or edge_id in self.edge_ids - ): - return - compact_reason = self._public_safe_compact_text(reason, limit=180) - if not compact_reason: - return - edge: dict[str, Any] = { - "edge_id": edge_id, - "from_node_id": from_node_id, - "to_node_id": to_node_id, - "relation": relation, - "reason": compact_reason, + for key, child in value.items() } - if refs: - edge["refs"] = refs - self.edge_ids.add(edge_id) - self.edges.append(edge) - - - - -def _task_graph_attach_handoff( - *, - current_todo: dict[str, Any], - current_tid: str, - current_nid: str, - successor_nid: str | None, - builder: _TaskGraphProjectionBuilder, - public_safe_compact_text: Callable[..., str | None], -) -> None: - handoff_note = current_todo.get("handoff_note") if isinstance(current_todo.get("handoff_note"), dict) else None - if not handoff_note: - return - handoff_from = public_safe_compact_text(handoff_note.get("from_agent"), limit=80) - handoff_to = public_safe_compact_text(handoff_note.get("to_agent"), limit=80) - if not handoff_from or not handoff_to or handoff_from == handoff_to: - return - handoff_status = public_safe_compact_text(handoff_note.get("status"), limit=80) - if handoff_status in ("done", "waiting", "unknown"): - handoff_state = handoff_status - elif current_todo.get("done") is True or str(current_todo.get("status") or "").strip().lower() == "done": - handoff_state = "done" - elif str(current_todo.get("status") or "").strip().lower() == "open": - handoff_state = "waiting" - else: - handoff_state = "unknown" - handoff_id = f"handoff:{current_tid}:{handoff_from}:{handoff_to}" - handoff_node_id = builder.add_node( - { - "node_id": _task_graph_node_id( - "node_handoff", - handoff_id, - public_safe_compact_text=public_safe_compact_text, - ), - "kind": "handoff", - "title": f"Handoff from {handoff_from} to {handoff_to}", - "state": handoff_state, - "refs": _task_graph_refs( - "todo_ids", - current_tid, - public_safe_compact_text=public_safe_compact_text, - ), - "from_agent": handoff_from, - "to_agent": handoff_to, - } - ) - if not handoff_node_id: - return - builder.add_edge( - edge_id=_task_graph_node_id( - "edge_hands_off_to", - f"{current_nid}:{handoff_node_id}", - public_safe_compact_text=public_safe_compact_text, - ), - from_node_id=current_nid, - to_node_id=handoff_node_id, - relation="hands_off_to", - reason=f"Agent {handoff_from} handed off to {handoff_to}.", - refs=_task_graph_refs( - "todo_ids", - current_tid, - public_safe_compact_text=public_safe_compact_text, - ), - ) - if successor_nid: - builder.add_edge( - edge_id=_task_graph_node_id( - "edge_continues_handoff", - f"{handoff_node_id}:{successor_nid}", - public_safe_compact_text=public_safe_compact_text, - ), - from_node_id=handoff_node_id, - to_node_id=successor_nid, - relation="continues", - reason=f"Successor continues after handoff from {handoff_from} to {handoff_to}.", - refs=_task_graph_refs( - "todo_ids", - current_tid, - public_safe_compact_text=public_safe_compact_text, - ), - ) - - -def _task_graph_attach_evidence( - *, - current_todo: dict[str, Any], - current_tid: str, - current_nid: str, - builder: _TaskGraphProjectionBuilder, - public_safe_compact_text: Callable[..., str | None], -) -> None: - evidence_text = public_safe_compact_text(current_todo.get("evidence"), limit=180) - note_text = public_safe_compact_text(current_todo.get("note"), limit=180) - if not evidence_text and not note_text: - return - ev_id = f"evidence:{current_tid}" - ev_title = evidence_text or note_text or f"Evidence for {current_tid}" - ev_node_id = builder.add_node( - { - "node_id": _task_graph_node_id( - "node_evidence", - ev_id, - public_safe_compact_text=public_safe_compact_text, - ), - "kind": "evidence", - "title": ev_title[:120], - "state": "done", - "refs": _task_graph_refs( - "todo_ids", - current_tid, - public_safe_compact_text=public_safe_compact_text, - ), - } - ) - if ev_node_id: - builder.add_edge( - edge_id=_task_graph_node_id( - "edge_evidences", - f"{ev_node_id}:{current_nid}", - public_safe_compact_text=public_safe_compact_text, - ), - from_node_id=ev_node_id, - to_node_id=current_nid, - relation="validates", - reason="Completion evidence supports the delivered predecessor.", - refs=_task_graph_refs( - "todo_ids", - current_tid, - public_safe_compact_text=public_safe_compact_text, - ), - ) - - -def _task_graph_topology_row( - todo_id: str, - value: dict[str, Any], - *, - done: bool, - public_safe_compact_text: Callable[..., str | None], -) -> dict[str, Any]: - row: dict[str, Any] = {"todo_id": todo_id, "done": done, "successor_todo_ids": []} - for field in ("unblocks_todo_id", "superseded_by", "resume_when"): - text = public_safe_compact_text(value.get(field), limit=240) - if text: - row[field] = text - successors = value.get("successor_todo_ids") - if isinstance(successors, list): - row["successor_todo_ids"] = [ - text for raw in successors - if (text := public_safe_compact_text(raw, limit=120)) + if isinstance(value, list): + return [ + _encode_public_text(child, texts, public_safe_compact_text, caps) for child in value ] - return row - - -def _task_graph_build_predecessor_chain( - *, - selected_todo_id: str, - selected_node_id: str, - all_todos_by_id: dict[str, dict[str, Any]], - builder: _TaskGraphProjectionBuilder, - public_safe_compact_text: Callable[..., str | None], - normalize_todo_status: Callable[[Any], str | None], - todo_done_for_status: Callable[[str], bool], - todo_status_open: str, - max_predecessor_nodes: int, - source_truncated: bool, -) -> dict[str, Any]: - # Python owns public-safe rendering; TypeScript owns relation discovery and - # bounded traversal. Never serialize evidence, notes, or private source text. - renderable_todos: dict[str, dict[str, Any]] = {} - rows = [] - for tid, value in all_todos_by_id.items(): - if not public_safe_compact_text(value.get("title") or value.get("text"), limit=160): - continue - renderable_todos[tid] = value - state = _task_graph_todo_state( - value, - normalize_todo_status=normalize_todo_status, - todo_done_for_status=todo_done_for_status, - todo_status_open=todo_status_open, waiting_default=tid != selected_todo_id, - ) - rows.append(_task_graph_topology_row( - tid, value, done=state == "done", public_safe_compact_text=public_safe_compact_text, - )) - result = effect_runtime_result("work_item.task_graph.topology", { - "schema_version": "task_graph_topology_request_v0", - "selected_todo_id": selected_todo_id, "items": rows, - "predecessor_limit": max_predecessor_nodes, - "source_truncated": source_truncated, - }) - if not isinstance(result, dict) or result.get("schema_version") != "task_graph_topology_result_v0": - raise RuntimeError("TypeScript task graph topology shape mismatch") - node_ids = {selected_todo_id: selected_node_id} - for tid in result["predecessor_todo_ids"]: - node_ids[tid] = builder.add_node(_task_graph_deliverable_node( - todo=renderable_todos[tid], public_safe_compact_text=public_safe_compact_text, - normalize_todo_status=normalize_todo_status, - todo_done_for_status=todo_done_for_status, - todo_status_open=todo_status_open, waiting_default=True, - )) - for edge in result["edges"]: - source, target = edge["from_todo_id"], edge["to_todo_id"] - builder.add_edge( - edge_id=_task_graph_node_id( - f"edge_{edge['source_relation']}", - f"{node_ids[source]}:{node_ids[target]}", - public_safe_compact_text=public_safe_compact_text, - ), - from_node_id=node_ids[source], to_node_id=node_ids[target], - relation=edge["relation"], reason=edge["reason"], - refs=_task_graph_refs("todo_ids", target, - public_safe_compact_text=public_safe_compact_text), - ) - for tid in [selected_todo_id, *result["predecessor_todo_ids"]]: - value, nid = all_todos_by_id[tid], node_ids[tid] - if _task_graph_todo_state(value, normalize_todo_status=normalize_todo_status, - todo_done_for_status=todo_done_for_status, todo_status_open=todo_status_open) == "done": - _task_graph_attach_evidence( - current_todo=value, current_tid=tid, current_nid=nid, builder=builder, - public_safe_compact_text=public_safe_compact_text, - ) - if tid != selected_todo_id: - # A shared ancestor may have several edges. Handoff presentation - # keeps its historical single attachment to the first discovered one. - successor = next(e["from_todo_id"] for e in result["edges"] - if e["to_todo_id"] == tid) - _task_graph_attach_handoff( - current_todo=value, current_tid=tid, current_nid=nid, - successor_nid=node_ids[successor], builder=builder, - public_safe_compact_text=public_safe_compact_text, - ) - return result["completeness"] + if not isinstance(value, str): + # Do not stringify opaque metadata before native node admission. + if value is None or isinstance(value, (bool, int, float)): + return value + return {"unsupported_public_text": True} + if value in texts: + for cap in caps: + if str(cap) not in texts[value]: + texts[value][str(cap)] = public_safe_compact_text(value, limit=cap) + return value + public = public_safe_compact_text(value, limit=len(value) + 1) + token = value if not value.strip() or public else f"redacted:{len(texts)}" + texts[token] = {str(cap): public_safe_compact_text(value, limit=cap) for cap in caps} + return token def build_task_graph_projection( @@ -641,330 +76,123 @@ def build_task_graph_projection( goal: dict[str, Any], goal_latest_runs: list[dict[str, Any]] | None = None, public_safe_compact_text: Callable[..., str | None], - normalize_todo_status: Callable[[Any], str | None], - todo_done_for_status: Callable[[str], bool], - todo_status_open: str, - open_todo_items: Callable[..., list[dict[str, Any]]], max_status_todos_per_role: int, - todo_item_task_class: Callable[[dict[str, Any]], str], - user_gate_task_class: str, - todo_summary_open_count: Callable[[dict[str, Any] | None], int], latest_run: Callable[[dict[str, Any]], dict[str, Any] | None], ) -> dict[str, Any] | None: - """Build a compact read-only graph from already-projected status fields.""" - + """Public-text and source codecs around the native composer.""" goal_id = public_safe_compact_text(item.get("goal_id") or goal.get("id"), limit=120) if not goal_id: return None - latest_runs = [run for run in goal_latest_runs or [] if isinstance(run, dict)] - builder = _TaskGraphProjectionBuilder(public_safe_compact_text=public_safe_compact_text) - - agent_todo_summary = ( - item.get("agent_todos") if isinstance(item.get("agent_todos"), dict) else None - ) - user_todo_summary = ( - item.get("user_todos") if isinstance(item.get("user_todos"), dict) else None - ) - agent_todos_by_id = _task_graph_collect_todo_items(agent_todo_summary) - user_todos_by_id = _task_graph_collect_todo_items(user_todo_summary) - source_truncated = ( - _task_graph_source_items_truncated(agent_todo_summary) - or _task_graph_source_items_truncated(user_todo_summary) - ) - - agent_items = open_todo_items( - agent_todo_summary, - limit=1, - text_limit=180, - ) - selected_todo = agent_items[0] if agent_items else None - selected_todo_id = public_safe_compact_text( - selected_todo.get("todo_id") if isinstance(selected_todo, dict) else None, - limit=120, - ) - selected_node_id: str | None = None - if isinstance(selected_todo, dict): - selected_node_id = builder.add_node( - _task_graph_deliverable_node( - todo=selected_todo, - public_safe_compact_text=public_safe_compact_text, - normalize_todo_status=normalize_todo_status, - todo_done_for_status=todo_done_for_status, - todo_status_open=todo_status_open, - ) - ) - claimed_by = public_safe_compact_text(selected_todo.get("claimed_by"), limit=80) - if claimed_by and selected_node_id: - lease_id = f"claim:{goal_id}:{selected_todo_id or 'selected'}:{claimed_by}" - lease_node_id = builder.add_node( + texts: dict[str, dict[str, str | None]] = {} + + summaries = {} + fields = dict.fromkeys( + ( + *TODO_CANONICAL_READ_RECORD_FIELDS, + "index", + "title", + "text", + "done", + "evidence", + "note", + "last_actor_agent_id", + "handoff", + "handoff_note", + ) + ) + for role in ("agent", "user"): + source = item.get(f"{role}_todos") + source = source if isinstance(source, dict) else {} + summaries[role] = { + key: [ { - "node_id": _task_graph_node_id( - "node_lease", - lease_id, - public_safe_compact_text=public_safe_compact_text, - ), - "kind": "lease", - "title": f"Claimed by {claimed_by}", - "state": "ready", - "refs": _task_graph_refs( - "lease_ids", - lease_id, - public_safe_compact_text=public_safe_compact_text, - ), - "owner_agent": claimed_by, + field: _encode_public_text( + row[field], + texts, + public_safe_compact_text, + _PUBLIC_TEXT_CAPS.get(field, ()), + ) + for field in fields + if field in row } + for row in values + if isinstance(row, dict) + ] + for key, values in source.items() + if key in (*TODO_PLANNING_SOURCE_KEYS, "first_open_items", "gate_open_items") + and isinstance(values, list) + } + for key in ("total_count", "open_count"): + try: + summaries[role][key] = int(source.get(key) or 0) + except (TypeError, ValueError): + summaries[role][key] = 0 + current = latest_run(goal) + current = current if isinstance(current, dict) else {} + state = current.get("state") + state = state if isinstance(state, dict) else {} + frontmatter = state.get("frontmatter") + frontmatter = frontmatter if isinstance(frontmatter, dict) else {} + warning = item.get("stale_latest_run_warning") + warning = warning if isinstance(warning, dict) else {} + replan = item.get("autonomous_replan_obligation") + replan = replan if isinstance(replan, dict) else {} + request = { + "schema_version": "status_task_graph_request_v0", + "goal_id": goal_id, + "summaries": summaries, + "max_status_todos_per_role": max_status_todos_per_role, + "latest_runs": [ + _encode_public_text( + { + key: run.get(key) + for key in ( + "run_id", + "generated_at", + "classification", + "recommended_action", + "delivery_outcome", + "delivery_batch_scale", + ) + }, + texts, + public_safe_compact_text, ) - builder.add_edge( - edge_id=_task_graph_node_id( - "edge_depends", - f"{selected_node_id}:{lease_node_id}", - public_safe_compact_text=public_safe_compact_text, - ), - from_node_id=selected_node_id, - to_node_id=lease_node_id, - relation="depends_on", - reason="Selected work depends on its active claim lease.", - refs=_task_graph_refs( - "lease_ids", - lease_id, - public_safe_compact_text=public_safe_compact_text, - ), - ) - - predecessor_metrics: dict[str, Any] = {} - if selected_todo_id and selected_node_id and isinstance(selected_todo, dict): - all_todos_by_id = {**agent_todos_by_id, **user_todos_by_id} - all_todos_by_id.setdefault(selected_todo_id, selected_todo) - predecessor_metrics = _task_graph_build_predecessor_chain( - selected_todo_id=selected_todo_id, - selected_node_id=selected_node_id, - all_todos_by_id=all_todos_by_id, - builder=builder, - public_safe_compact_text=public_safe_compact_text, - normalize_todo_status=normalize_todo_status, - todo_done_for_status=todo_done_for_status, - todo_status_open=todo_status_open, - max_predecessor_nodes=TASK_GRAPH_MAX_PREDECESSOR_NODES, - source_truncated=source_truncated, - ) - - user_items, user_gate_open_count = _task_graph_visible_user_gate_items( - user_todo_summary, - limit=TASK_GRAPH_MAX_USER_GATE_NODES, - open_todo_items=open_todo_items, - max_status_todos_per_role=max_status_todos_per_role, - todo_item_task_class=todo_item_task_class, - user_gate_task_class=user_gate_task_class, - todo_summary_open_count=todo_summary_open_count, - ) - for ordinal, todo in enumerate(user_items): - todo_id = public_safe_compact_text(todo.get("todo_id"), limit=120) - gate_id = todo_id or f"gate:{goal_id}:user:{ordinal + 1}" - gate_node_id = builder.add_node( - { - "node_id": _task_graph_node_id( - "node_gate", - gate_id, - public_safe_compact_text=public_safe_compact_text, - ), - "kind": "gate", - "title": todo.get("title") or todo.get("text") or "Open user gate", - "state": _task_graph_todo_state( - todo, - normalize_todo_status=normalize_todo_status, - todo_done_for_status=todo_done_for_status, - todo_status_open=todo_status_open, - waiting_default=True, - ), - "refs": _task_graph_refs( - "gate_ids", - gate_id, - public_safe_compact_text=public_safe_compact_text, - ), - } - ) - builder.add_edge( - edge_id=_task_graph_node_id( - "edge_blocks", - f"{gate_node_id}:{selected_node_id}:{ordinal}", - public_safe_compact_text=public_safe_compact_text, - ), - from_node_id=gate_node_id, - to_node_id=selected_node_id, - relation="blocks", - reason="Open user gate blocks the gated delivery path.", - refs=_task_graph_refs( - "gate_ids", - gate_id, - public_safe_compact_text=public_safe_compact_text, - ), - ) - user_gate_truncated_count = max(0, user_gate_open_count - len(user_items)) - if user_gate_truncated_count: - summary_node_id = builder.add_node( - { - "node_id": _task_graph_node_id( - "node_gate_summary", - f"{goal_id}:{user_gate_truncated_count}:more_user_gates", - public_safe_compact_text=public_safe_compact_text, - ), - "kind": "gate_summary", - "title": f"{user_gate_truncated_count} more open user gates not expanded", - "state": "waiting", - "refs": _task_graph_refs( - "goal_ids", - goal_id, - public_safe_compact_text=public_safe_compact_text, - ), - } - ) - builder.add_edge( - edge_id=_task_graph_node_id( - "edge_blocks", - f"{summary_node_id}:{selected_node_id}:user_gate_summary", - public_safe_compact_text=public_safe_compact_text, - ), - from_node_id=summary_node_id, - to_node_id=selected_node_id, - relation="blocks", - reason="Additional open user gates stay on the cold path instead of expanding the task graph hot path.", - refs=_task_graph_refs( - "goal_ids", - goal_id, - public_safe_compact_text=public_safe_compact_text, - ), - ) - - run_node_id = builder.add_node( - _task_graph_latest_run_node( - goal_latest_runs=latest_runs, - selected_todo_id=selected_todo_id, - public_safe_compact_text=public_safe_compact_text, - ) - ) - builder.add_edge( - edge_id=_task_graph_node_id( - "edge_validates", - f"{run_node_id}:{selected_node_id}", - public_safe_compact_text=public_safe_compact_text, + for run in goal_latest_runs or [] + if isinstance(run, dict) + ], + "current_generated_at": _encode_public_text( + current.get("generated_at"), texts, public_safe_compact_text, (80,) + ), + "warning_updated_at": _encode_public_text( + warning.get("active_state_updated_at"), texts, public_safe_compact_text, (80,) + ), + "state_updated_at": _encode_public_text( + frontmatter.get("updated_at"), texts, public_safe_compact_text, (80,) ), - from_node_id=run_node_id, - to_node_id=selected_node_id, - relation="validates", - reason="Latest compact run-history evidence validates or contextualizes the selected work.", - refs=builder.refs_by_node_id.get(run_node_id or ""), + "replan": _encode_public_text( + {key: replan.get(key) for key in ("schema_version", "kind", "recommended_action")}, + texts, + public_safe_compact_text, + ) + if replan + else None, + "fallback_time": now_utc_iso(), + "public_texts": texts, + } + use_snapshot = ( + len(json.dumps(request, separators=(",", ":")).encode()) > MAX_REQUEST_BYTES - 4096 ) - for relation, reason in _task_graph_latest_run_lineage_relations( - run_node_id, - selected_node_id, - goal_latest_runs=latest_runs, - public_safe_compact_text=public_safe_compact_text, - ): - builder.add_edge( - edge_id=_task_graph_node_id( - f"edge_{relation}", - f"{run_node_id}:{selected_node_id}", - public_safe_compact_text=public_safe_compact_text, - ), - from_node_id=run_node_id, - to_node_id=selected_node_id, - relation=relation, - reason=reason, - refs=builder.refs_by_node_id.get(run_node_id or ""), - ) - - replan = ( - item.get("autonomous_replan_obligation") - if isinstance(item.get("autonomous_replan_obligation"), dict) - else None + result = effect_runtime_result( + "work_item.task_graph.status", request, large_local_snapshot=use_snapshot ) - if replan and selected_node_id: - replan_id = public_safe_compact_text( - replan.get("schema_version") or replan.get("kind") or "autonomous_replan_obligation", - limit=120, - ) - repair_node_id = builder.add_node( - { - "node_id": _task_graph_node_id( - "node_repair", - replan_id, - public_safe_compact_text=public_safe_compact_text, - ), - "kind": "repair", - "title": replan.get("recommended_action") or replan_id, - "state": "ready", - "refs": _task_graph_refs( - "todo_ids", - selected_todo_id, - public_safe_compact_text=public_safe_compact_text, - ), - } - ) - builder.add_edge( - edge_id=_task_graph_node_id( - "edge_repairs", - f"{repair_node_id}:{selected_node_id}", - public_safe_compact_text=public_safe_compact_text, - ), - from_node_id=repair_node_id, - to_node_id=selected_node_id, - relation="repairs", - reason="Autonomous repair/replan obligation should recover the selected work lane.", - refs=_task_graph_refs( - "todo_ids", - selected_todo_id, - public_safe_compact_text=public_safe_compact_text, - ), - ) + if ( + not isinstance(result, dict) + or result.get("schema_version") != "status_task_graph_result_v0" + ): + raise RuntimeError("TypeScript status task graph shape mismatch") - if not builder.nodes: - return None - derived_from: dict[str, Any] = { - "source_of_truth": TASK_GRAPH_SOURCE_OF_TRUTH, - "status_item_goal_id": goal_id, - "run_history_window": "compact_latest_runs", - } - active_state_updated_at = _task_graph_active_state_updated_at( - item, - goal, - public_safe_compact_text=public_safe_compact_text, - latest_run=latest_run, - ) - if active_state_updated_at: - derived_from["active_state_updated_at"] = active_state_updated_at - limits: dict[str, int | bool] = { - "user_gate_node_limit": TASK_GRAPH_MAX_USER_GATE_NODES, - "user_gate_open_count": user_gate_open_count, - "user_gate_truncated_count": user_gate_truncated_count, - } - if predecessor_metrics: - limits["predecessor_node_limit"] = predecessor_metrics["predecessor_limit"] - limits["emitted_predecessor_count"] = predecessor_metrics["emitted_predecessor_count"] - limits["predecessor_truncated"] = predecessor_metrics["predecessor_truncated"] - limits["source_truncated"] = predecessor_metrics["source_truncated"] - limits["missing_predecessor_count"] = predecessor_metrics["missing_predecessor_count"] - limits["topology_complete"] = predecessor_metrics["topology_complete"] - return { - "schema_version": TASK_GRAPH_PROJECTION_SCHEMA_VERSION, - "mode": "read_only", - "goal_id": goal_id, - "generated_at": _task_graph_generated_at( - goal=goal, - goal_latest_runs=latest_runs, - public_safe_compact_text=public_safe_compact_text, - latest_run=latest_run, - ), - "derived_from": derived_from, - "truth_contract": { - "event_ledger_is_source_of_truth": True, - "projection_is_writable": False, - "write_api": False, - "recompute_rule": "Recompute from status, active state, gates, leases, and run history after each lifecycle event.", - }, - "limits": limits, - "nodes": builder.nodes, - "edges": builder.edges, - } + return result["projection"] def build_goal_task_map( @@ -981,64 +209,47 @@ def build_goal_task_map( ) -> dict[str, Any]: """Adapt shared source/identity codecs to the native Goal-map composer.""" - rows = [] - renderable: dict[str, tuple[dict[str, Any], str, str]] = {} - for value in todos: - tid = public_safe_compact_text(value.get("todo_id"), limit=120) - title = public_safe_compact_text(value.get("title") or value.get("text"), limit=160) - if not tid or not title or tid in renderable: - continue - state = _task_graph_todo_state( - value, normalize_todo_status=normalize_todo_status, - todo_done_for_status=todo_done_for_status, todo_status_open=todo_status_open, - ) - renderable[tid] = (value, title, state) - rows.append({ - **_task_graph_topology_row( - tid, value, done=state == "done", public_safe_compact_text=public_safe_compact_text, - ), - "is_user": value.get("role") == "user", - "is_monitor": value.get("task_class") == monitor_task_class, - }) + texts: dict[str, dict[str, str | None]] = {} + fields = ( + "todo_id", + "title", + "text", + "status", + "done", + "role", + "task_class", + "unblocks_todo_id", + "superseded_by", + "resume_when", + "successor_todo_ids", + "claimed_by", + "task_domain", + ) request = { - "schema_version": "goal_task_map_request_v0", "goal_id": goal_id, - "items": rows, "source_truncated": source_truncated, - "read_facts": {"observed_at": now_utc_iso(), "source_read_at": source_read_at, - "source_count": len(todos)}, + "schema_version": "goal_task_map_request_v0", + "goal_id": goal_id, + "todos": _encode_public_text( + [{key: value[key] for key in fields if key in value} for value in todos], + texts, + public_safe_compact_text, + ), + "monitor_task_class": monitor_task_class, + "public_texts": texts, + "source_truncated": source_truncated, + "read_facts": { + "observed_at": now_utc_iso(), + "source_read_at": source_read_at, + "source_count": len(todos), + }, } - # Reserve framing bytes while keeping ordinary maps on the inline transport. - use_snapshot = len(json.dumps(request, separators=(",", ":")).encode()) > MAX_REQUEST_BYTES - 4096 + use_snapshot = ( + len(json.dumps(request, separators=(",", ":")).encode()) > MAX_REQUEST_BYTES - 4096 + ) result = effect_runtime_result( - "work_item.task_graph.goal_topology", request, large_local_snapshot=use_snapshot, + "work_item.task_graph.goal_topology", + request, + large_local_snapshot=use_snapshot, ) if not isinstance(result, dict) or result.get("schema_version") != "goal_task_map_result_v0": raise RuntimeError("TypeScript goal task map shape mismatch") - projection = result["projection"] - nodes = projection["nodes"] - node_ids = [] - materialized = [] - for node, prefix, fields in zip(nodes, result["node_identity_prefixes"], result["node_metadata_fields"], strict=True): - tid = node["refs"]["todo_ids"][0] - node_ids.append(_task_graph_node_id( - prefix, tid, public_safe_compact_text=public_safe_compact_text, durable_id=tid, - )) - value, title, state = renderable[tid] - rendered = {"node_id": node_ids[-1], "kind": node["kind"], "title": title, - "state": state, "depth": node["depth"], "refs": node["refs"]} - for key, field in (("owner_agent", "claimed_by"), ("task_domain", "task_domain")): - text = public_safe_compact_text(value.get(field), limit=80) - if text and key in fields: - rendered[key] = text - materialized.append(rendered) - projection["nodes"] = materialized - # These codecs also serve the status graph; keep one owner until that caller migrates. - projection["edges"] = [{ - "edge_id": _task_graph_node_id( - f"edge_{identity['source_relation']}", - f"{node_ids[identity['from_node_index']]}:{node_ids[identity['to_node_index']]}", - public_safe_compact_text=public_safe_compact_text, - ), - "from_node_id": node_ids[identity["from_node_index"]], - "to_node_id": node_ids[identity["to_node_index"]], **edge, - } for edge, identity in zip(projection["edges"], result["edge_identities"], strict=True)] - return projection + return result["projection"] diff --git a/loopx/control_plane/work_items/task_graph.ts b/loopx/control_plane/work_items/task_graph.ts index 187909f052..ac0323cfae 100644 --- a/loopx/control_plane/work_items/task_graph.ts +++ b/loopx/control_plane/work_items/task_graph.ts @@ -1,9 +1,27 @@ +import { createHash } from "node:crypto"; +import { stripPythonWhitespace } from "../coordination/python_whitespace.ts"; +import { TODO_ITEM_SCHEMA } from "../coordination/coordination_state_contract.ts"; +import { projectTodoSummaryDisplay } from "../todos/context_projection.ts"; +import { compactTodoReadText, object, taskClass, recordFields, handoffSource, readAgent } from "../todos/read_codec.ts"; +import { projectTodoHandoffContext } from "../todos/handoff_projection.ts"; +import { readTodoPriority, todoPriorityRank } from "../todos/priority.ts"; +import { todoPresentationMetadata } from "../coordination/todo_presentation.ts"; +import { authorityUnicodeCompare } from "../coordination/authority_store_codec.ts"; import type { JsonObject } from "../effect_program.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; -import { requireBoolean, requireInteger, requireJsonObject, requireNonEmptyString, - requireStringArray } from "../runtime_decode.ts"; -import { projectRelations, todoRef, type PlanningInventoryRelation, - type PlanningRelationSource } from "./planning_relations.ts"; +import { + requireBoolean, + requireInteger, + requireJsonObject, + requireNonEmptyString, + requireStringArray, +} from "../runtime_decode.ts"; +import { + projectRelations, + todoRef, + type PlanningInventoryRelation, + type PlanningRelationSource, +} from "./planning_relations.ts"; import { PROJECTION_ENVELOPE_SEAL_REQUEST, sealProjectionEnvelope } from "../projection_envelope.ts"; export const TASK_GRAPH_TOPOLOGY_REQUEST = "task_graph_topology_request_v0"; @@ -172,7 +190,8 @@ export function projectGoalTaskGraphTopology(value: unknown): JsonObject { // only adds depth where it agrees with that order; a follow-up recorded in // the opposite direction of a dependency is not a cycle. const ordering = new Map(); - const link = (edge: GraphEdge) => ordering.set(edge.from_todo_id, [...ordering.get(edge.from_todo_id) ?? [], edge.to_todo_id]); + const link = (edge: GraphEdge) => + ordering.set(edge.from_todo_id, [...ordering.get(edge.from_todo_id) ?? [], edge.to_todo_id]); const reaches = (from: string, to: string) => { const seen = new Set([from]); const queue = [from]; @@ -221,28 +240,89 @@ const GOAL_TASK_MAP_MAX_NODES = 120; function goalMapNode(facts: JsonObject, depth: number): JsonObject { const gate = requireBoolean(facts.is_user, "goal map row.is_user"); const monitor = requireBoolean(facts.is_monitor, "goal map row.is_monitor"); + let kind = "deliverable"; + if (monitor) kind = "monitor"; + if (gate) kind = "gate"; return { - kind: gate ? "gate" : monitor ? "monitor" : "deliverable", - depth, refs: { todo_ids: [facts.todo_id] }, + kind, + depth, + refs: { todo_ids: [facts.todo_id] }, }; } -/** Source and display codecs stay with the caller; native owns map composition. */ +function materializeGoalTaskMap(request: JsonObject): JsonObject { + const { safe, composed } = publicTextCodec(request); + const byId = new Map(); + const items: JsonObject[] = []; + if (!Array.isArray(request.todos)) throw new EffectRuntimeRequestError("goal map todos must be an array"); + for (const value of request.todos) { + const todo = requireJsonObject(value, "goal map todo"); + const tid = safe(todo.todo_id, 120); + const title = safe(todo.title || todo.text, 160); + if (!tid || !title || byId.has(tid)) continue; + byId.set(tid, { ...todo, title, state: graphState(todo) }); + items.push({ + ...topologyRow(tid, todo, graphState(todo) === "done", safe), + is_user: todo.role === "user", + is_monitor: todo.task_class === request.monitor_task_class, + }); + } + const result = projectGoalTaskMap({ ...request, todos: undefined, items }); + const projection = result.projection as JsonObject; + const identities: string[] = []; + projection.nodes = (projection.nodes as JsonObject[]).map((node) => { + const tid = ((node.refs as JsonObject).todo_ids as string[])[0]; + const todo = byId.get(tid)!; + const node_id = graphIdentity(node.kind === "gate" ? "node_gate" : "node_todo", tid, safe, tid); + composed(node_id); + identities.push(node_id); + const rendered: JsonObject = { node_id, ...node, title: todo.title, state: todo.state }; + for (const [key, field] of [ + ["owner_agent", "claimed_by"], + ["task_domain", "task_domain"], + ]) { + const text = safe(todo[field], 80); + if (text && node.kind !== "gate") rendered[key] = text; + } + return rendered; + }); + projection.edges = (projection.edges as JsonObject[]).map((edge, index) => { + const identity = (result.edge_identities as JsonObject[])[index]; + const from_node_id = identities[Number(identity.from_node_index)]; + const to_node_id = identities[Number(identity.to_node_index)]; + return { + edge_id: graphIdentity( + `edge_${identity.source_relation}`, + composed(`${from_node_id}:${to_node_id}`, [from_node_id, to_node_id]), + safe, + ), + from_node_id, + to_node_id, + ...edge, + }; + }); + return { schema_version: GOAL_TASK_MAP_RESULT, projection }; +} + +/** Compose legacy topology facts or materialize a source/public-text Goal snapshot. */ export function projectGoalTaskMap(value: unknown): JsonObject { const request = requireJsonObject(value, "goal task map request"); if (request.schema_version !== GOAL_TASK_MAP_REQUEST) { throw new EffectRuntimeRequestError("Goal task map request schema mismatch"); } + if (request.todos !== undefined) return materializeGoalTaskMap(request); if (typeof request.goal_id !== "string") throw new EffectRuntimeRequestError("goal_id must be a string"); const sourceTruncated = requireBoolean(request.source_truncated, "source_truncated"); const topology = projectGoalTaskGraphTopology({ - schema_version: TASK_GRAPH_GOAL_TOPOLOGY_REQUEST, items: request.items, - node_limit: GOAL_TASK_MAP_MAX_NODES, source_truncated: sourceTruncated, + schema_version: TASK_GRAPH_GOAL_TOPOLOGY_REQUEST, + items: request.items, + node_limit: GOAL_TASK_MAP_MAX_NODES, + source_truncated: sourceTruncated, }); const rows = request.items as JsonObject[]; - const byId = new Map(rows.map(facts => [facts.todo_id as string, facts])); + const byId = new Map(rows.map((facts) => [facts.todo_id as string, facts])); const entries = topology.nodes as { todo_id: string; depth: number }[]; - const nodes = entries.map(entry => goalMapNode(byId.get(entry.todo_id)!, entry.depth)); + const nodes = entries.map((entry) => goalMapNode(byId.get(entry.todo_id)!, entry.depth)); const indices = new Map(entries.map((entry, index) => [entry.todo_id, index])); const edges = topology.edges as GraphEdge[]; const facts = requireJsonObject(request.read_facts, "goal map read facts"); @@ -250,29 +330,597 @@ export function projectGoalTaskMap(value: unknown): JsonObject { if (sourceCount < rows.length) throw new EffectRuntimeRequestError("goal map source count is incomplete"); const limits = topology.completeness as JsonObject; const omitted = [ - {reason: "unrenderable_todos", count: sourceCount - rows.length}, - {reason: "map_node_limit", count: Number(limits.omitted_node_count)}, - {reason: "unresolved_map_relations", count: Number(limits.missing_endpoint_count) + Number(limits.cycle_edge_count)}, - ].filter(value => value.count > 0); + { reason: "unrenderable_todos", count: sourceCount - rows.length }, + { reason: "map_node_limit", count: Number(limits.omitted_node_count) }, + { + reason: "unresolved_map_relations", + count: Number(limits.missing_endpoint_count) + Number(limits.cycle_edge_count), + }, + ].filter((value) => value.count > 0); const envelope = sealProjectionEnvelope({ - schema_version: PROJECTION_ENVELOPE_SEAL_REQUEST, projection: "goal_task_map", + schema_version: PROJECTION_ENVELOPE_SEAL_REQUEST, + projection: "goal_task_map", observed_at: facts.observed_at, - sources: [{source_id: "goal_todos", read_status: facts.source_read_at == null ? "not_read" : "read", - last_read_at: facts.source_read_at, item_count: sourceCount}], - coverage: {scope: "goal_task_map", expected_count: sourceTruncated ? null : sourceCount, - included_count: nodes.length, omitted, shown_count: nodes.length, available_count: sourceCount}, + sources: [ + { + source_id: "goal_todos", + read_status: facts.source_read_at == null ? "not_read" : "read", + last_read_at: facts.source_read_at, + item_count: sourceCount, + }, + ], + coverage: { + scope: "goal_task_map", + expected_count: sourceTruncated ? null : sourceCount, + included_count: nodes.length, + omitted, + shown_count: nodes.length, + available_count: sourceCount, + }, }); return { schema_version: GOAL_TASK_MAP_RESULT, projection: { - schema_version: "goal_task_map_v0", mode: "read_only", goal_id: request.goal_id, - limits: topology.completeness, nodes, + schema_version: "goal_task_map_v0", + mode: "read_only", + goal_id: request.goal_id, + limits: topology.completeness, + nodes, projection_envelope: envelope, - edges: edges.map(edge => ({ relation: edge.relation, enforcement: edge.enforcement, reason: edge.reason })), + edges: edges.map((edge) => ({ relation: edge.relation, enforcement: edge.enforcement, reason: edge.reason })), + }, + node_identity_prefixes: nodes.map((node) => (node.kind === "gate" ? "node_gate" : "node_todo")), + node_metadata_fields: nodes.map((node) => (node.kind === "gate" ? [] : ["owner_agent", "task_domain"])), + edge_identities: edges.map((edge) => ({ + from_node_index: indices.get(edge.from_todo_id)!, + to_node_index: indices.get(edge.to_todo_id)!, + source_relation: edge.source_relation, + })), + }; +} + +export const STATUS_TASK_GRAPH_REQUEST = "status_task_graph_request_v0"; +export const STATUS_TASK_GRAPH_RESULT = "status_task_graph_result_v0"; +const STATUS_GATE_LIMIT = 2; +const STATUS_PREDECESSOR_LIMIT = 4; + +type Identity = string; +type PublicText = (value: unknown, limit: number) => string | null; +const truthy = (value: unknown): boolean => + Array.isArray(value) + ? value.length > 0 + : value !== null && typeof value === "object" + ? Object.keys(value).length > 0 + : Boolean(value); +function graphIdentity(prefix: string, value: unknown, safe: PublicText, durable?: string): string { + const raw = safe(value, 120) || prefix; + const digest = (text: string) => createHash("sha256").update(text).digest("hex"); + let normalized = raw + .replace(/[^A-Za-z0-9_]+/g, "_") + .replace(/^_+|_+$/g, "") + .toLowerCase(); + if (!normalized) normalized = digest(String(value ?? "None")).slice(0, 10); + const hash = digest(durable || raw).slice(0, 6); + if (normalized.length > 50) normalized = normalized.slice(0, 44).replace(/_+$/g, "") + "_" + hash; + return `${prefix}_${normalized}_${hash}`; +} + +function graphState(todo: JsonObject, waiting = false): string { + const status = stripPythonWhitespace(String(todo.status || "")).toLowerCase(); + if (status === "deferred") return "waiting"; + if (truthy(todo.done) || status === "done") return "done"; + if (status === "blocked") return "blocked"; + return waiting ? "waiting" : "open"; +} + +function topologyRow(todo_id: string, todo: JsonObject, done: boolean, safe: PublicText): JsonObject { + const result: JsonObject = { todo_id, done, successor_todo_ids: [] }; + for (const field of ["unblocks_todo_id", "superseded_by", "resume_when"]) { + const text = safe(todo[field], 240); + if (text) result[field] = text; + } + if (Array.isArray(todo.successor_todo_ids)) { + result.successor_todo_ids = todo.successor_todo_ids + .map((value) => safe(value, 120)) + .filter((value) => value !== null); + } + return result; +} + +/** Source strings require Python facts. Only explicitly composed display strings may compact locally. */ +function publicTextCodec(request: JsonObject): { + safe: PublicText; + composed: (value: string, parts?: unknown[]) => string; +} { + const texts = requireJsonObject(request.public_texts, "public text codecs"); + const composedTexts = new Set(); + const normalizedAgents = new Set(); + const composed = (value: string, parts: unknown[] = []): string => { + for (const part of parts) { + const text = String(part); + const approved = composedTexts.has(text) || typeof part === "number"; + if (!approved) throw new EffectRuntimeRequestError("Unqualified composed public text part"); + } + composedTexts.add(value); + return value; + }; + for (const facts of Object.values(texts)) { + for (const text of Object.values(object(facts))) { + if (typeof text === "string") composed(text); + } + } + for (const [source, facts] of Object.entries(texts)) { + const normalized = readAgent(source); + const approved = object(facts)["80"]; + if (normalized && typeof approved === "string" && approved) normalizedAgents.add(normalized); + } + const safe: PublicText = (value, limit) => { + if (!truthy(value)) return null; + const text = String(value); + const facts = object(texts[text]); + if (Object.hasOwn(facts, String(limit))) { + const result = facts[String(limit)]; + if (result !== null && typeof result !== "string") { + throw new EffectRuntimeRequestError("invalid public text codec"); + } + if (result) composed(result); + return result as string | null; + } + // Reuse the handoff owner's full normalization only for approved source text. + if (normalizedAgents.has(text) && Array.from(text).length <= limit) { + composed(text); + return text; + } + if (!composedTexts.has(text)) { + throw new EffectRuntimeRequestError(`Missing public text facts for display cap ${limit}`); + } + const result = compactTodoReadText(text, limit); + if (result) composed(result); + return result; + }; + return { safe, composed }; +} + +class StatusGraph { + readonly nodes: JsonObject[] = []; + readonly edges: JsonObject[] = []; + private readonly byId = new Map(); + private readonly edgeIds = new Set(); + private readonly safe: PublicText; + private readonly composed: (value: string, parts?: unknown[]) => string; + constructor(safe: PublicText, composed: (value: string, parts?: unknown[]) => string) { + this.safe = safe; + this.composed = composed; + } + addNode(prefix: string, value: unknown, node: JsonObject, durable?: string): Identity | null { + const node_id = graphIdentity(prefix, value, this.safe, durable); + const key = node_id; + if (this.byId.has(key)) return node_id; + const title = this.safe(node.title, 160); + if (!title || !Object.keys(object(node.refs)).length) return null; + this.composed(node_id); + const record = { node_id, ...node, title }; + this.byId.set(key, record); + this.nodes.push(record); + return node_id; + } + refs(node: Identity | null): JsonObject | undefined { + return node ? object(this.byId.get(node)?.refs) : undefined; + } + edge( + prefix: string, + parts: unknown[], + from: Identity | null, + to: Identity | null, + relation: string, + reason: string, + refs?: JsonObject, + ): void { + if (!from || !to || from === to || !this.byId.has(from) || !this.byId.has(to)) return; + const edge_id = graphIdentity( + prefix, + this.composed(parts.map((part) => String(part ?? "None")).join(":"), parts), + this.safe, + ); + const key = edge_id; + const compact = this.safe(this.composed(reason), 180); + if (!compact || this.edgeIds.has(key)) return; + this.edgeIds.add(key); + this.edges.push({ + edge_id, + from_node_id: from, + to_node_id: to, + relation, + reason: compact, + ...(refs ? { refs } : {}), + }); + } +} + +function statusOpenItems(summary: JsonObject, keys: string[], limit: number): JsonObject[] { + const rows: JsonObject[] = []; + const seen = new Set(); + const collect = (): void => { + for (const key of keys) + for (const value of Array.isArray(summary[key]) ? summary[key] : []) { + const raw = object(value); + if (!Object.keys(raw).length || truthy(raw.done)) continue; + const text = compactTodoReadText(String(raw.text || ""), 180); + if (!text) continue; + const key = JSON.stringify([raw.index ?? null, text]); + if (seen.has(key)) continue; + seen.add(key); + const compact: JsonObject = { index: raw.index ?? null, done: false, text }; + for (const field of recordFields) + if (raw[field] != null && !Object.hasOwn(compact, field)) compact[field] = raw[field]; + const note = projectTodoHandoffContext([handoffSource(compact)])[0]!.note; + if (note) compact.handoff_note = note; + rows.push(compact); + if (rows.length >= limit) return; + } + }; + collect(); + const order = (todo: JsonObject): number => { + try { + const legacy = + todo.schema_version === TODO_ITEM_SCHEMA || (todo.schema_version == null && truthy(todo.source_section)); + return legacy ? (todoPresentationMetadata(todo).display_order ?? 999999) : 999999; + } catch { + return Number.isInteger(todo.index) && Number(todo.index) >= 0 ? Number(todo.index) : 999999; + } + }; + return rows.sort( + (a, b) => + todoPriorityRank(readTodoPriority(a)) - todoPriorityRank(readTodoPriority(b)) || + order(a) - order(b) || + (order(a) !== 999999 + ? 0 + : authorityUnicodeCompare( + String(a.completed_at || a.updated_at || ""), + String(b.completed_at || b.updated_at || ""), + ) || authorityUnicodeCompare(String(a.todo_id || ""), String(b.todo_id || ""))), + ); +} + +/** One status snapshot, including source selection and the in-process topology. */ +export function projectStatusTaskGraph(value: unknown): JsonObject { + const request = requireJsonObject(value, "status task graph request"); + if (request.schema_version !== STATUS_TASK_GRAPH_REQUEST) + throw new EffectRuntimeRequestError("Status task graph request schema mismatch"); + const goal = requireNonEmptyString(request.goal_id, "goal_id"); + const summaries = requireJsonObject(request.summaries, "status summaries"); + const agent = requireJsonObject(summaries.agent, "agent summary"); + const user = requireJsonObject(summaries.user, "user summary"); + const visibleLimit = requireInteger(request.max_status_todos_per_role, "max_status_todos_per_role"); + if (visibleLimit < 1) throw new EffectRuntimeRequestError("status Todo limit must be positive"); + const { safe, composed } = publicTextCodec(request); + composed(goal); + const refs = (key: string, ...values: unknown[]): JsonObject | undefined => { + const found: string[] = []; + for (const value of values.flat()) { + const text = safe(value, 120); + if (text && !found.includes(text)) found.push(text); + if (found.length === 4) break; + } + return found.length ? { [key]: found } : undefined; + }; + const builder = new StatusGraph(safe, composed); + const deliverable = (todo: JsonObject, waiting = false): Identity | null => { + const tid = safe(todo.todo_id, 120); + const title = safe(todo.title || todo.text, 160); + if (!tid || !title) return null; + const owner = safe(todo.claimed_by, 80); + const actor = safe(todo.last_actor_agent_id, 80); + return builder.addNode( + "node_todo", + tid, + { + kind: "deliverable", + title, + state: graphState(todo, waiting), + refs: refs("todo_ids", tid)!, + ...(owner ? { owner_agent: owner } : {}), + ...(actor ? { actor_agent: actor } : {}), + }, + tid, + ); + }; + const selected = statusOpenItems(agent, ["first_open_items", "items"], 1)[0]; + const selectedId = safe(selected?.todo_id, 120); + const selectedNode = selected ? deliverable(selected) : null; + if (selected && selectedNode) { + const owner = safe(selected.claimed_by, 80); + if (owner) { + const lease = composed(`claim:${goal}:${selectedId || composed("selected")}:${owner}`, [ + goal, + selectedId || composed("selected"), + owner, + ]); + const leaseRefs = refs("lease_ids", lease); + const node = builder.addNode("node_lease", lease, { + kind: "lease", + title: composed(`Claimed by ${owner}`, [owner]), + state: "ready", + refs: leaseRefs!, + owner_agent: owner, + }); + builder.edge( + "edge_depends", + [selectedNode, node], + selectedNode, + node, + "depends_on", + "Selected work depends on its active claim lease.", + leaseRefs, + ); + } + } + const planning = (source: JsonObject): JsonObject[] => + projectTodoSummaryDisplay({ source: { record: source }, include_terminal: true }); + let predecessorMetrics: JsonObject = {}; + if (selected && selectedId && selectedNode) { + const agentRows = planning(agent); + const userRows = planning(user); + const all = new Map([...agentRows, ...userRows].map((todo) => [String(todo.todo_id), todo])); + if (!all.has(selectedId)) all.set(selectedId, selected); + const renderable = [...all].filter(([, todo]) => safe(todo.title || todo.text, 160)); + const topology = projectTaskGraphTopology({ + schema_version: TASK_GRAPH_TOPOLOGY_REQUEST, + selected_todo_id: selectedId, + predecessor_limit: STATUS_PREDECESSOR_LIMIT, + source_truncated: + Number(agent.total_count || 0) > agentRows.length || Number(user.total_count || 0) > userRows.length, + items: renderable.map(([todo_id, todo]) => + topologyRow(todo_id, todo, graphState(todo, todo_id !== selectedId) === "done", safe), + ), + }); + predecessorMetrics = object(topology.completeness); + const predecessors = topology.predecessor_todo_ids as string[]; + const edges = topology.edges as GraphEdge[]; + const ids = new Map([[selectedId, selectedNode]]); + for (const tid of predecessors) ids.set(tid, deliverable(all.get(tid)!, true)); + for (const edge of edges) { + const from = ids.get(edge.from_todo_id)!; + const to = ids.get(edge.to_todo_id)!; + builder.edge( + `edge_${edge.source_relation}`, + [from, to], + from, + to, + edge.relation, + edge.reason, + refs("todo_ids", edge.to_todo_id), + ); + } + for (const tid of [selectedId, ...predecessors]) { + const todo = all.get(tid)!; + const nid = ids.get(tid)!; + if (graphState(todo) === "done") { + const evidence = safe(todo.evidence, 180) || safe(todo.note, 180); + if (evidence) { + const node = builder.addNode("node_evidence", composed(`evidence:${tid}`, [tid]), { + kind: "evidence", + title: composed(Array.from(evidence).slice(0, 120).join("")), + state: "done", + refs: refs("todo_ids", tid)!, + }); + builder.edge( + "edge_evidences", + [node, nid], + node, + nid, + "validates", + "Completion evidence supports the delivered predecessor.", + refs("todo_ids", tid), + ); + } + } + if (tid === selectedId) continue; + const note = object(todo.handoff_note); + const from = safe(note.from_agent, 80); + const to = safe(note.to_agent, 80); + if (!from || !to || from === to) continue; + const status = safe(note.status, 80); + const rawStatus = stripPythonWhitespace(String(todo.status || "")).toLowerCase(); + const state = ["done", "waiting", "unknown"].includes(status || "") + ? status! + : todo.done === true || rawStatus === "done" + ? "done" + : rawStatus === "open" + ? "waiting" + : "unknown"; + const node = builder.addNode("node_handoff", composed(`handoff:${tid}:${from}:${to}`, [tid, from, to]), { + kind: "handoff", + title: composed(`Handoff from ${from} to ${to}`, [from, to]), + state, + refs: refs("todo_ids", tid)!, + from_agent: from, + to_agent: to, + }); + builder.edge( + "edge_hands_off_to", + [nid, node], + nid, + node, + "hands_off_to", + composed(`Agent ${from} handed off to ${to}.`, [from, to]), + refs("todo_ids", tid), + ); + const successor = ids.get(edges.find((edge) => edge.to_todo_id === tid)!.from_todo_id)!; + builder.edge( + "edge_continues_handoff", + [node, successor], + node, + successor, + "continues", + composed(`Successor continues after handoff from ${from} to ${to}.`, [from, to]), + refs("todo_ids", tid), + ); + } + } + const visible = statusOpenItems(user, ["gate_open_items", "first_open_items", "items"], visibleLimit); + const gates = visible.filter( + (todo) => taskClass({ ...todo, text: [todo.title, todo.text].filter(Boolean).join(" ") }) === "user_gate", + ); + const gateCount = + gates.length && gates.length === visible.length + ? Math.max(gates.length, Number(user.open_count || 0)) + : gates.length; + const userItems = gates.slice(0, STATUS_GATE_LIMIT); + for (const [ordinal, todo] of userItems.entries()) { + const tid = safe(todo.todo_id, 120); + const gate = tid || composed(`gate:${goal}:user:${ordinal + 1}`, [goal, ordinal + 1]); + const node = builder.addNode("node_gate", gate, { + kind: "gate", + title: todo.title || todo.text || composed("Open user gate"), + state: graphState(todo, true), + refs: refs("gate_ids", gate)!, + }); + builder.edge( + "edge_blocks", + [node, selectedNode, ordinal], + node, + selectedNode, + "blocks", + "Open user gate blocks the gated delivery path.", + refs("gate_ids", gate), + ); + } + const truncated = Math.max(0, gateCount - userItems.length); + if (truncated) { + const node = builder.addNode( + "node_gate_summary", + composed(`${goal}:${truncated}:more_user_gates`, [goal, truncated]), + { + kind: "gate_summary", + title: composed(`${truncated} more open user gates not expanded`, [truncated]), + state: "waiting", + refs: refs("goal_ids", goal)!, + }, + ); + builder.edge( + "edge_blocks", + [node, selectedNode, composed("user_gate_summary")], + node, + selectedNode, + "blocks", + "Additional open user gates stay on the cold path instead of expanding the task graph hot path.", + refs("goal_ids", goal), + ); + } + if (!Array.isArray(request.latest_runs)) throw new EffectRuntimeRequestError("latest_runs must be an array"); + const runs = request.latest_runs.map((value) => requireJsonObject(value, "latest run")); + let runNode: Identity | null = null; + for (const run of runs) { + const runRef = safe(run.run_id || run.generated_at || run.classification, 120); + const classification = safe(run.classification, 120); + if (!runRef && !classification) continue; + const runRefs = refs("run_ids", runRef || classification)!; + if (selectedId) Object.assign(runRefs, refs("todo_ids", selectedId)); + const title = classification || composed("Latest compact run-history evidence"); + runNode = builder.addNode("node_run", runRef || title, { + kind: "validation", + title: composed(`Latest compact run: ${title}`, [title]), + state: "ready", + refs: runRefs, + }); + break; + } + builder.edge( + "edge_validates", + [runNode, selectedNode], + runNode, + selectedNode, + "validates", + "Latest compact run-history evidence validates or contextualizes the selected work.", + builder.refs(runNode), + ); + if (runNode && selectedNode) + for (const run of runs) { + const text = [run.classification, run.recommended_action, run.delivery_outcome, run.delivery_batch_scale] + .map((v) => safe(v, 160) || "") + .join(" ") + .toLowerCase(); + if (!text.trim()) continue; + if (["audit", "audited"].some((marker) => text.includes(marker))) + builder.edge( + "edge_audits", + [runNode, selectedNode], + runNode, + selectedNode, + "audits", + "Compact run-history evidence audits the selected work lane without replacing todo or gate state.", + builder.refs(runNode), + ); + if (["continuation", "continue", "continuing", "continued"].some((marker) => text.includes(marker))) + builder.edge( + "edge_continues", + [runNode, selectedNode], + runNode, + selectedNode, + "continues", + "Compact run-history evidence records a continuation of the selected work lane.", + builder.refs(runNode), + ); + break; + } + if (request.replan && selectedNode) { + const replan = requireJsonObject(request.replan, "replan facts"); + const id = safe(replan.schema_version || replan.kind || composed("autonomous_replan_obligation"), 120); + const node = builder.addNode("node_repair", id, { + kind: "repair", + title: replan.recommended_action || id, + state: "ready", + refs: refs("todo_ids", selectedId)!, + }); + builder.edge( + "edge_repairs", + [node, selectedNode], + node, + selectedNode, + "repairs", + "Autonomous repair/replan obligation should recover the selected work lane.", + refs("todo_ids", selectedId), + ); + } + if (!builder.nodes.length) return { schema_version: STATUS_TASK_GRAPH_RESULT, projection: null }; + const derived: JsonObject = { + source_of_truth: ["event_ledger", "active_goal_state", "todos", "gates", "leases", "run_history"], + status_item_goal_id: goal, + run_history_window: "compact_latest_runs", + }; + const updated = safe(request.warning_updated_at, 80) || safe(request.state_updated_at, 80); + if (updated) derived.active_state_updated_at = updated; + const limits: JsonObject = { + user_gate_node_limit: STATUS_GATE_LIMIT, + user_gate_open_count: gateCount, + user_gate_truncated_count: truncated, + }; + if (Object.keys(predecessorMetrics).length) { + const { predecessor_limit, ...metrics } = predecessorMetrics; + Object.assign(limits, { predecessor_node_limit: predecessor_limit }, metrics); + } + return { + schema_version: STATUS_TASK_GRAPH_RESULT, + projection: { + schema_version: "task_graph_projection_v0", + mode: "read_only", + goal_id: goal, + generated_at: + runs.map((run) => safe(run.generated_at, 80)).find(Boolean) || + safe(request.current_generated_at, 80) || + requireNonEmptyString(request.fallback_time, "fallback_time"), + derived_from: derived, + truth_contract: { + event_ledger_is_source_of_truth: true, + projection_is_writable: false, + write_api: false, + recompute_rule: + "Recompute from status, active state, gates, leases, and run history after each lifecycle event.", + }, + limits, + nodes: builder.nodes, + edges: builder.edges, }, - node_identity_prefixes: nodes.map(node => node.kind === "gate" ? "node_gate" : "node_todo"), - node_metadata_fields: nodes.map(node => node.kind === "gate" ? [] : ["owner_agent", "task_domain"]), - edge_identities: edges.map(edge => ({ from_node_index: indices.get(edge.from_todo_id)!, - to_node_index: indices.get(edge.to_todo_id)!, source_relation: edge.source_relation })), }; } diff --git a/loopx/status.py b/loopx/status.py index ed06f42c9e..cc9c56f6c0 100644 --- a/loopx/status.py +++ b/loopx/status.py @@ -229,7 +229,6 @@ compact_todo_group as compact_todo_group, compact_todo_item as compact_todo_item, first_open_todo_text, - open_todo_items, project_asset_todo_summary, sync_connected_attention_action_from_todos as _sync_connected_attention_action_from_todos_read_model, todo_lane_items as todo_lane_items, @@ -237,7 +236,6 @@ todo_item_is_due_monitor as todo_item_is_due_monitor, todo_item_missing_monitor_schedule as todo_item_missing_monitor_schedule, todo_item_next_due_at as todo_item_next_due_at, - todo_item_task_class, todo_projection_sort_key as todo_projection_sort_key, ) from .control_plane.todos.todo_index import ( @@ -254,11 +252,7 @@ state_projection_gap_warning, ) from .control_plane.todos.contract import ( - TODO_STATUS_OPEN, - TODO_TASK_CLASS_USER_GATE, - normalize_todo_status, normalize_todo_task_class as normalize_todo_task_class, - todo_done_for_status, ) from .control_plane.todos.todo_semantics import ( todo_item_is_expired_monitor as todo_item_is_expired_monitor, @@ -1059,14 +1053,7 @@ def build_task_graph_projection( goal=goal, goal_latest_runs=goal_latest_runs, public_safe_compact_text=public_safe_compact_text, - normalize_todo_status=normalize_todo_status, - todo_done_for_status=todo_done_for_status, - todo_status_open=TODO_STATUS_OPEN, - open_todo_items=open_todo_items, max_status_todos_per_role=MAX_STATUS_TODOS_PER_ROLE, - todo_item_task_class=todo_item_task_class, - user_gate_task_class=TODO_TASK_CLASS_USER_GATE, - todo_summary_open_count=todo_summary_open_count, latest_run=latest_run, ) diff --git a/tests/control_plane/fixtures/status_task_graph_characterization.json b/tests/control_plane/fixtures/status_task_graph_characterization.json new file mode 100644 index 0000000000..5748df65c2 --- /dev/null +++ b/tests/control_plane/fixtures/status_task_graph_characterization.json @@ -0,0 +1,174 @@ +{ + "status": { + "diamond_at_cap": { + "json": "{\"derived_from\":{\"run_history_window\":\"compact_latest_runs\",\"source_of_truth\":[\"event_ledger\",\"active_goal_state\",\"todos\",\"gates\",\"leases\",\"run_history\"],\"status_item_goal_id\":\"graph-fixture\"},\"edges\":[{\"edge_id\":\"edge_depends_node_todo_todo_root_f01e62_node_lease_claim_9b7ffb_9b7ffb\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Selected work depends on its active claim lease.\",\"refs\":{\"lease_ids\":[\"claim:graph-fixture:todo_root:builder\"]},\"relation\":\"depends_on\",\"to_node_id\":\"node_lease_claim_graph_fixture_todo_root_builder_858126\"},{\"edge_id\":\"edge_successor_node_todo_todo_root_f01e62_node_todo_todo_aa_be7a25_be7a25\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Work continues predecessor lineage; this is not a completion prerequisite.\",\"refs\":{\"todo_ids\":[\"todo_aaa\"]},\"relation\":\"continues\",\"to_node_id\":\"node_todo_todo_aaa_4e10e8\"},{\"edge_id\":\"edge_successor_node_todo_todo_root_f01e62_node_todo_todo_bb_1979fb_1979fb\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Work continues predecessor lineage; this is not a completion prerequisite.\",\"refs\":{\"todo_ids\":[\"todo_bbb\"]},\"relation\":\"continues\",\"to_node_id\":\"node_todo_todo_bbb_06b95c\"},{\"edge_id\":\"edge_successor_node_todo_todo_aaa_4e10e8_node_todo_todo_sha_306f76_306f76\",\"from_node_id\":\"node_todo_todo_aaa_4e10e8\",\"reason\":\"Work continues predecessor lineage; this is not a completion prerequisite.\",\"refs\":{\"todo_ids\":[\"todo_shared\"]},\"relation\":\"continues\",\"to_node_id\":\"node_todo_todo_shared_6677e1\"},{\"edge_id\":\"edge_successor_node_todo_todo_bbb_06b95c_node_todo_todo_sha_313587_313587\",\"from_node_id\":\"node_todo_todo_bbb_06b95c\",\"reason\":\"Work continues predecessor lineage; this is not a completion prerequisite.\",\"refs\":{\"todo_ids\":[\"todo_shared\"]},\"relation\":\"continues\",\"to_node_id\":\"node_todo_todo_shared_6677e1\"},{\"edge_id\":\"edge_successor_node_todo_todo_shared_6677e1_node_todo_todo_79c514_79c514\",\"from_node_id\":\"node_todo_todo_shared_6677e1\",\"reason\":\"Work continues predecessor lineage; this is not a completion prerequisite.\",\"refs\":{\"todo_ids\":[\"todo_fourth\"]},\"relation\":\"continues\",\"to_node_id\":\"node_todo_todo_fourth_5a1f50\"}],\"generated_at\":\"2026-11-02T12:00:00Z\",\"goal_id\":\"graph-fixture\",\"limits\":{\"emitted_predecessor_count\":4,\"missing_predecessor_count\":0,\"predecessor_node_limit\":4,\"predecessor_truncated\":false,\"source_truncated\":false,\"topology_complete\":true,\"user_gate_node_limit\":2,\"user_gate_open_count\":0,\"user_gate_truncated_count\":0},\"mode\":\"read_only\",\"nodes\":[{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_root_f01e62\",\"owner_agent\":\"builder\",\"refs\":{\"todo_ids\":[\"todo_root\"]},\"state\":\"open\",\"title\":\"Review todo_root\"},{\"kind\":\"lease\",\"node_id\":\"node_lease_claim_graph_fixture_todo_root_builder_858126\",\"owner_agent\":\"builder\",\"refs\":{\"lease_ids\":[\"claim:graph-fixture:todo_root:builder\"]},\"state\":\"ready\",\"title\":\"Claimed by builder\"},{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_aaa_4e10e8\",\"refs\":{\"todo_ids\":[\"todo_aaa\"]},\"state\":\"done\",\"title\":\"Review todo_aaa\"},{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_bbb_06b95c\",\"refs\":{\"todo_ids\":[\"todo_bbb\"]},\"state\":\"done\",\"title\":\"Review todo_bbb\"},{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_shared_6677e1\",\"refs\":{\"todo_ids\":[\"todo_shared\"]},\"state\":\"done\",\"title\":\"Review todo_shared\"},{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_fourth_5a1f50\",\"refs\":{\"todo_ids\":[\"todo_fourth\"]},\"state\":\"done\",\"title\":\"Review todo_fourth\"}],\"schema_version\":\"task_graph_projection_v0\",\"truth_contract\":{\"event_ledger_is_source_of_truth\":true,\"projection_is_writable\":false,\"recompute_rule\":\"Recompute from status, active state, gates, leases, and run history after each lifecycle event.\",\"write_api\":false}}", + "calls": [ + "todo.context.page", + "todo.context.page", + "todo.context.page", + "work_item.task_graph.topology" + ] + }, + "cycle": { + "json": "{\"derived_from\":{\"run_history_window\":\"compact_latest_runs\",\"source_of_truth\":[\"event_ledger\",\"active_goal_state\",\"todos\",\"gates\",\"leases\",\"run_history\"],\"status_item_goal_id\":\"graph-fixture\"},\"edges\":[{\"edge_id\":\"edge_depends_node_todo_todo_root_f01e62_node_lease_claim_9b7ffb_9b7ffb\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Selected work depends on its active claim lease.\",\"refs\":{\"lease_ids\":[\"claim:graph-fixture:todo_root:builder\"]},\"relation\":\"depends_on\",\"to_node_id\":\"node_lease_claim_graph_fixture_todo_root_builder_858126\"},{\"edge_id\":\"edge_successor_node_todo_todo_root_f01e62_node_todo_todo_pa_24e83c_24e83c\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Work continues predecessor lineage; this is not a completion prerequisite.\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"relation\":\"continues\",\"to_node_id\":\"node_todo_todo_parent_c3ca17\"},{\"edge_id\":\"edge_successor_node_todo_todo_parent_c3ca17_node_todo_todo_bf2a13_bf2a13\",\"from_node_id\":\"node_todo_todo_parent_c3ca17\",\"reason\":\"Work continues predecessor lineage; this is not a completion prerequisite.\",\"refs\":{\"todo_ids\":[\"todo_other\"]},\"relation\":\"continues\",\"to_node_id\":\"node_todo_todo_other_122893\"},{\"edge_id\":\"edge_successor_node_todo_todo_other_122893_node_todo_todo_p_258a73_258a73\",\"from_node_id\":\"node_todo_todo_other_122893\",\"reason\":\"Work continues predecessor lineage; this is not a completion prerequisite.\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"relation\":\"continues\",\"to_node_id\":\"node_todo_todo_parent_c3ca17\"}],\"generated_at\":\"2026-11-02T12:00:00Z\",\"goal_id\":\"graph-fixture\",\"limits\":{\"emitted_predecessor_count\":2,\"missing_predecessor_count\":0,\"predecessor_node_limit\":4,\"predecessor_truncated\":false,\"source_truncated\":false,\"topology_complete\":true,\"user_gate_node_limit\":2,\"user_gate_open_count\":0,\"user_gate_truncated_count\":0},\"mode\":\"read_only\",\"nodes\":[{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_root_f01e62\",\"owner_agent\":\"builder\",\"refs\":{\"todo_ids\":[\"todo_root\"]},\"state\":\"open\",\"title\":\"Review todo_root\"},{\"kind\":\"lease\",\"node_id\":\"node_lease_claim_graph_fixture_todo_root_builder_858126\",\"owner_agent\":\"builder\",\"refs\":{\"lease_ids\":[\"claim:graph-fixture:todo_root:builder\"]},\"state\":\"ready\",\"title\":\"Claimed by builder\"},{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_parent_c3ca17\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"state\":\"done\",\"title\":\"Review todo_parent\"},{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_other_122893\",\"refs\":{\"todo_ids\":[\"todo_other\"]},\"state\":\"done\",\"title\":\"Review todo_other\"}],\"schema_version\":\"task_graph_projection_v0\",\"truth_contract\":{\"event_ledger_is_source_of_truth\":true,\"projection_is_writable\":false,\"recompute_rule\":\"Recompute from status, active state, gates, leases, and run history after each lifecycle event.\",\"write_api\":false}}", + "calls": [ + "todo.context.page", + "todo.context.page", + "todo.context.page", + "work_item.task_graph.topology" + ] + }, + "missing_prerequisite": { + "json": "{\"derived_from\":{\"run_history_window\":\"compact_latest_runs\",\"source_of_truth\":[\"event_ledger\",\"active_goal_state\",\"todos\",\"gates\",\"leases\",\"run_history\"],\"status_item_goal_id\":\"graph-fixture\"},\"edges\":[{\"edge_id\":\"edge_depends_node_todo_todo_root_f01e62_node_lease_claim_9b7ffb_9b7ffb\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Selected work depends on its active claim lease.\",\"refs\":{\"lease_ids\":[\"claim:graph-fixture:todo_root:builder\"]},\"relation\":\"depends_on\",\"to_node_id\":\"node_lease_claim_graph_fixture_todo_root_builder_858126\"}],\"generated_at\":\"2026-11-02T12:00:00Z\",\"goal_id\":\"graph-fixture\",\"limits\":{\"emitted_predecessor_count\":0,\"missing_predecessor_count\":1,\"predecessor_node_limit\":4,\"predecessor_truncated\":false,\"source_truncated\":false,\"topology_complete\":false,\"user_gate_node_limit\":2,\"user_gate_open_count\":0,\"user_gate_truncated_count\":0},\"mode\":\"read_only\",\"nodes\":[{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_root_f01e62\",\"owner_agent\":\"builder\",\"refs\":{\"todo_ids\":[\"todo_root\"]},\"state\":\"open\",\"title\":\"Review todo_root\"},{\"kind\":\"lease\",\"node_id\":\"node_lease_claim_graph_fixture_todo_root_builder_858126\",\"owner_agent\":\"builder\",\"refs\":{\"lease_ids\":[\"claim:graph-fixture:todo_root:builder\"]},\"state\":\"ready\",\"title\":\"Claimed by builder\"}],\"schema_version\":\"task_graph_projection_v0\",\"truth_contract\":{\"event_ledger_is_source_of_truth\":true,\"projection_is_writable\":false,\"recompute_rule\":\"Recompute from status, active state, gates, leases, and run history after each lifecycle event.\",\"write_api\":false}}", + "calls": [ + "todo.context.page", + "todo.context.page", + "todo.context.page", + "work_item.task_graph.topology" + ] + }, + "open_predecessor": { + "json": "{\"derived_from\":{\"run_history_window\":\"compact_latest_runs\",\"source_of_truth\":[\"event_ledger\",\"active_goal_state\",\"todos\",\"gates\",\"leases\",\"run_history\"],\"status_item_goal_id\":\"graph-fixture\"},\"edges\":[{\"edge_id\":\"edge_depends_node_todo_todo_root_f01e62_node_lease_claim_9b7ffb_9b7ffb\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Selected work depends on its active claim lease.\",\"refs\":{\"lease_ids\":[\"claim:graph-fixture:todo_root:builder\"]},\"relation\":\"depends_on\",\"to_node_id\":\"node_lease_claim_graph_fixture_todo_root_builder_858126\"},{\"edge_id\":\"edge_successor_node_todo_todo_root_f01e62_node_todo_todo_pa_24e83c_24e83c\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Work continues predecessor lineage; this is not a completion prerequisite.\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"relation\":\"continues\",\"to_node_id\":\"node_todo_todo_parent_c3ca17\"}],\"generated_at\":\"2026-11-02T12:00:00Z\",\"goal_id\":\"graph-fixture\",\"limits\":{\"emitted_predecessor_count\":1,\"missing_predecessor_count\":0,\"predecessor_node_limit\":4,\"predecessor_truncated\":false,\"source_truncated\":false,\"topology_complete\":true,\"user_gate_node_limit\":2,\"user_gate_open_count\":0,\"user_gate_truncated_count\":0},\"mode\":\"read_only\",\"nodes\":[{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_root_f01e62\",\"owner_agent\":\"builder\",\"refs\":{\"todo_ids\":[\"todo_root\"]},\"state\":\"open\",\"title\":\"Review todo_root\"},{\"kind\":\"lease\",\"node_id\":\"node_lease_claim_graph_fixture_todo_root_builder_858126\",\"owner_agent\":\"builder\",\"refs\":{\"lease_ids\":[\"claim:graph-fixture:todo_root:builder\"]},\"state\":\"ready\",\"title\":\"Claimed by builder\"},{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_parent_c3ca17\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"state\":\"waiting\",\"title\":\"Review todo_parent\"}],\"schema_version\":\"task_graph_projection_v0\",\"truth_contract\":{\"event_ledger_is_source_of_truth\":true,\"projection_is_writable\":false,\"recompute_rule\":\"Recompute from status, active state, gates, leases, and run history after each lifecycle event.\",\"write_api\":false}}", + "calls": [ + "todo.context.page", + "todo.context.page", + "todo.context.page", + "work_item.task_graph.topology" + ] + }, + "condition_and_lineage": { + "json": "{\"derived_from\":{\"run_history_window\":\"compact_latest_runs\",\"source_of_truth\":[\"event_ledger\",\"active_goal_state\",\"todos\",\"gates\",\"leases\",\"run_history\"],\"status_item_goal_id\":\"graph-fixture\"},\"edges\":[{\"edge_id\":\"edge_depends_node_todo_todo_root_f01e62_node_lease_claim_9b7ffb_9b7ffb\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Selected work depends on its active claim lease.\",\"refs\":{\"lease_ids\":[\"claim:graph-fixture:todo_root:builder\"]},\"relation\":\"depends_on\",\"to_node_id\":\"node_lease_claim_graph_fixture_todo_root_builder_858126\"},{\"edge_id\":\"edge_resumes_when_node_todo_todo_root_f01e62_node_todo_todo_pa_24e83c_24e83c\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Work has a Todo completion condition; readiness is decided by the resume evaluator.\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"relation\":\"depends_on\",\"to_node_id\":\"node_todo_todo_parent_c3ca17\"},{\"edge_id\":\"edge_successor_node_todo_todo_root_f01e62_node_todo_todo_pa_24e83c_24e83c\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Work continues predecessor lineage; this is not a completion prerequisite.\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"relation\":\"continues\",\"to_node_id\":\"node_todo_todo_parent_c3ca17\"}],\"generated_at\":\"2026-11-02T12:00:00Z\",\"goal_id\":\"graph-fixture\",\"limits\":{\"emitted_predecessor_count\":1,\"missing_predecessor_count\":0,\"predecessor_node_limit\":4,\"predecessor_truncated\":false,\"source_truncated\":false,\"topology_complete\":true,\"user_gate_node_limit\":2,\"user_gate_open_count\":0,\"user_gate_truncated_count\":0},\"mode\":\"read_only\",\"nodes\":[{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_root_f01e62\",\"owner_agent\":\"builder\",\"refs\":{\"todo_ids\":[\"todo_root\"]},\"state\":\"open\",\"title\":\"Review todo_root\"},{\"kind\":\"lease\",\"node_id\":\"node_lease_claim_graph_fixture_todo_root_builder_858126\",\"owner_agent\":\"builder\",\"refs\":{\"lease_ids\":[\"claim:graph-fixture:todo_root:builder\"]},\"state\":\"ready\",\"title\":\"Claimed by builder\"},{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_parent_c3ca17\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"state\":\"done\",\"title\":\"Review todo_parent\"}],\"schema_version\":\"task_graph_projection_v0\",\"truth_contract\":{\"event_ledger_is_source_of_truth\":true,\"projection_is_writable\":false,\"recompute_rule\":\"Recompute from status, active state, gates, leases, and run history after each lifecycle event.\",\"write_api\":false}}", + "calls": [ + "todo.context.page", + "todo.context.page", + "todo.context.page", + "work_item.task_graph.topology" + ] + }, + "predecessor_overflow": { + "json": "{\"derived_from\":{\"run_history_window\":\"compact_latest_runs\",\"source_of_truth\":[\"event_ledger\",\"active_goal_state\",\"todos\",\"gates\",\"leases\",\"run_history\"],\"status_item_goal_id\":\"graph-fixture\"},\"edges\":[{\"edge_id\":\"edge_depends_node_todo_todo_root_f01e62_node_lease_claim_9b7ffb_9b7ffb\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Selected work depends on its active claim lease.\",\"refs\":{\"lease_ids\":[\"claim:graph-fixture:todo_root:builder\"]},\"relation\":\"depends_on\",\"to_node_id\":\"node_lease_claim_graph_fixture_todo_root_builder_858126\"},{\"edge_id\":\"edge_successor_node_todo_todo_root_f01e62_node_todo_todo_pa_797840_797840\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Work continues predecessor lineage; this is not a completion prerequisite.\",\"refs\":{\"todo_ids\":[\"todo_parent_0\"]},\"relation\":\"continues\",\"to_node_id\":\"node_todo_todo_parent_0_a6aa24\"},{\"edge_id\":\"edge_successor_node_todo_todo_root_f01e62_node_todo_todo_pa_900ffa_900ffa\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Work continues predecessor lineage; this is not a completion prerequisite.\",\"refs\":{\"todo_ids\":[\"todo_parent_1\"]},\"relation\":\"continues\",\"to_node_id\":\"node_todo_todo_parent_1_4a2d99\"},{\"edge_id\":\"edge_successor_node_todo_todo_root_f01e62_node_todo_todo_pa_fb879a_fb879a\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Work continues predecessor lineage; this is not a completion prerequisite.\",\"refs\":{\"todo_ids\":[\"todo_parent_2\"]},\"relation\":\"continues\",\"to_node_id\":\"node_todo_todo_parent_2_a17015\"},{\"edge_id\":\"edge_successor_node_todo_todo_root_f01e62_node_todo_todo_pa_5173ef_5173ef\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Work continues predecessor lineage; this is not a completion prerequisite.\",\"refs\":{\"todo_ids\":[\"todo_parent_3\"]},\"relation\":\"continues\",\"to_node_id\":\"node_todo_todo_parent_3_eded42\"}],\"generated_at\":\"2026-11-02T12:00:00Z\",\"goal_id\":\"graph-fixture\",\"limits\":{\"emitted_predecessor_count\":4,\"missing_predecessor_count\":0,\"predecessor_node_limit\":4,\"predecessor_truncated\":true,\"source_truncated\":false,\"topology_complete\":false,\"user_gate_node_limit\":2,\"user_gate_open_count\":0,\"user_gate_truncated_count\":0},\"mode\":\"read_only\",\"nodes\":[{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_root_f01e62\",\"owner_agent\":\"builder\",\"refs\":{\"todo_ids\":[\"todo_root\"]},\"state\":\"open\",\"title\":\"Review todo_root\"},{\"kind\":\"lease\",\"node_id\":\"node_lease_claim_graph_fixture_todo_root_builder_858126\",\"owner_agent\":\"builder\",\"refs\":{\"lease_ids\":[\"claim:graph-fixture:todo_root:builder\"]},\"state\":\"ready\",\"title\":\"Claimed by builder\"},{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_parent_0_a6aa24\",\"refs\":{\"todo_ids\":[\"todo_parent_0\"]},\"state\":\"done\",\"title\":\"Review todo_parent_0\"},{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_parent_1_4a2d99\",\"refs\":{\"todo_ids\":[\"todo_parent_1\"]},\"state\":\"done\",\"title\":\"Review todo_parent_1\"},{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_parent_2_a17015\",\"refs\":{\"todo_ids\":[\"todo_parent_2\"]},\"state\":\"done\",\"title\":\"Review todo_parent_2\"},{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_parent_3_eded42\",\"refs\":{\"todo_ids\":[\"todo_parent_3\"]},\"state\":\"done\",\"title\":\"Review todo_parent_3\"}],\"schema_version\":\"task_graph_projection_v0\",\"truth_contract\":{\"event_ledger_is_source_of_truth\":true,\"projection_is_writable\":false,\"recompute_rule\":\"Recompute from status, active state, gates, leases, and run history after each lifecycle event.\",\"write_api\":false}}", + "calls": [ + "todo.context.page", + "todo.context.page", + "todo.context.page", + "work_item.task_graph.topology" + ] + }, + "duplicate_identity": { + "json": "{\"derived_from\":{\"run_history_window\":\"compact_latest_runs\",\"source_of_truth\":[\"event_ledger\",\"active_goal_state\",\"todos\",\"gates\",\"leases\",\"run_history\"],\"status_item_goal_id\":\"graph-fixture\"},\"edges\":[{\"edge_id\":\"edge_depends_node_todo_todo_root_f01e62_node_lease_claim_9b7ffb_9b7ffb\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Selected work depends on its active claim lease.\",\"refs\":{\"lease_ids\":[\"claim:graph-fixture:todo_root:builder\"]},\"relation\":\"depends_on\",\"to_node_id\":\"node_lease_claim_graph_fixture_todo_root_builder_858126\"},{\"edge_id\":\"edge_successor_node_todo_todo_root_f01e62_node_todo_todo_sa_013e2f_013e2f\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Work continues predecessor lineage; this is not a completion prerequisite.\",\"refs\":{\"todo_ids\":[\"todo_same!\"]},\"relation\":\"continues\",\"to_node_id\":\"node_todo_todo_same_688565\"},{\"edge_id\":\"edge_successor_node_todo_todo_root_f01e62_node_todo_todo_sa_3a802e_3a802e\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Work continues predecessor lineage; this is not a completion prerequisite.\",\"refs\":{\"todo_ids\":[\"todo_same?\"]},\"relation\":\"continues\",\"to_node_id\":\"node_todo_todo_same_a3db75\"}],\"generated_at\":\"2026-11-02T12:00:00Z\",\"goal_id\":\"graph-fixture\",\"limits\":{\"emitted_predecessor_count\":2,\"missing_predecessor_count\":0,\"predecessor_node_limit\":4,\"predecessor_truncated\":false,\"source_truncated\":true,\"topology_complete\":false,\"user_gate_node_limit\":2,\"user_gate_open_count\":0,\"user_gate_truncated_count\":0},\"mode\":\"read_only\",\"nodes\":[{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_root_f01e62\",\"owner_agent\":\"builder\",\"refs\":{\"todo_ids\":[\"todo_root\"]},\"state\":\"open\",\"title\":\"Review todo_root\"},{\"kind\":\"lease\",\"node_id\":\"node_lease_claim_graph_fixture_todo_root_builder_858126\",\"owner_agent\":\"builder\",\"refs\":{\"lease_ids\":[\"claim:graph-fixture:todo_root:builder\"]},\"state\":\"ready\",\"title\":\"Claimed by builder\"},{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_same_688565\",\"refs\":{\"todo_ids\":[\"todo_same!\"]},\"state\":\"done\",\"title\":\"Review todo_parent\"},{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_same_a3db75\",\"refs\":{\"todo_ids\":[\"todo_same?\"]},\"state\":\"done\",\"title\":\"Review todo_parent\"}],\"schema_version\":\"task_graph_projection_v0\",\"truth_contract\":{\"event_ledger_is_source_of_truth\":true,\"projection_is_writable\":false,\"recompute_rule\":\"Recompute from status, active state, gates, leases, and run history after each lifecycle event.\",\"write_api\":false}}", + "calls": [ + "todo.context.page", + "todo.context.page", + "todo.context.page", + "work_item.task_graph.topology" + ] + }, + "long_public_text": { + "json": "{\"derived_from\":{\"run_history_window\":\"compact_latest_runs\",\"source_of_truth\":[\"event_ledger\",\"active_goal_state\",\"todos\",\"gates\",\"leases\",\"run_history\"],\"status_item_goal_id\":\"graph-fixture\"},\"edges\":[{\"edge_id\":\"edge_depends_node_todo_todo_root_f01e62_node_lease_claim_9b7ffb_9b7ffb\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Selected work depends on its active claim lease.\",\"refs\":{\"lease_ids\":[\"claim:graph-fixture:todo_root:builder\"]},\"relation\":\"depends_on\",\"to_node_id\":\"node_lease_claim_graph_fixture_todo_root_builder_858126\"},{\"edge_id\":\"edge_successor_node_todo_todo_root_f01e62_node_todo_todo_pa_24e83c_24e83c\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Work continues predecessor lineage; this is not a completion prerequisite.\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"relation\":\"continues\",\"to_node_id\":\"node_todo_todo_parent_c3ca17\"},{\"edge_id\":\"edge_evidences_node_evidence_evidence_todo_parent_482e52_no_649861_649861\",\"from_node_id\":\"node_evidence_evidence_todo_parent_482e52\",\"reason\":\"Completion evidence supports the delivered predecessor.\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"relation\":\"validates\",\"to_node_id\":\"node_todo_todo_parent_c3ca17\"}],\"generated_at\":\"2026-11-02T12:00:00Z\",\"goal_id\":\"graph-fixture\",\"limits\":{\"emitted_predecessor_count\":1,\"missing_predecessor_count\":0,\"predecessor_node_limit\":4,\"predecessor_truncated\":false,\"source_truncated\":false,\"topology_complete\":true,\"user_gate_node_limit\":2,\"user_gate_open_count\":0,\"user_gate_truncated_count\":0},\"mode\":\"read_only\",\"nodes\":[{\"actor_agent\":\"reviewer\",\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_root_f01e62\",\"owner_agent\":\"builder\",\"refs\":{\"todo_ids\":[\"todo_root\"]},\"state\":\"open\",\"title\":\"Public 🧪 review Public 🧪 review Public 🧪 review Public 🧪 review Public 🧪 review Public 🧪 review Public 🧪 review Public 🧪 review Public 🧪 review Public 🧪 review…\"},{\"kind\":\"lease\",\"node_id\":\"node_lease_claim_graph_fixture_todo_root_builder_858126\",\"owner_agent\":\"builder\",\"refs\":{\"lease_ids\":[\"claim:graph-fixture:todo_root:builder\"]},\"state\":\"ready\",\"title\":\"Claimed by builder\"},{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_parent_c3ca17\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"state\":\"done\",\"title\":\"Review todo_parent\"},{\"kind\":\"evidence\",\"node_id\":\"node_evidence_evidence_todo_parent_482e52\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"state\":\"done\",\"title\":\"Public completion evidence Public completion evidence Public completion evidence Public completion evidence Public compl\"}],\"schema_version\":\"task_graph_projection_v0\",\"truth_contract\":{\"event_ledger_is_source_of_truth\":true,\"projection_is_writable\":false,\"recompute_rule\":\"Recompute from status, active state, gates, leases, and run history after each lifecycle event.\",\"write_api\":false}}", + "calls": [ + "todo.context.page", + "todo.context.page", + "todo.context.page", + "work_item.task_graph.topology" + ] + }, + "redaction": { + "json": "{\"derived_from\":{\"run_history_window\":\"compact_latest_runs\",\"source_of_truth\":[\"event_ledger\",\"active_goal_state\",\"todos\",\"gates\",\"leases\",\"run_history\"],\"status_item_goal_id\":\"graph-fixture\"},\"edges\":[{\"edge_id\":\"edge_successor_node_todo_todo_root_f01e62_node_todo_todo_pa_24e83c_24e83c\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Work continues predecessor lineage; this is not a completion prerequisite.\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"relation\":\"continues\",\"to_node_id\":\"node_todo_todo_parent_c3ca17\"},{\"edge_id\":\"edge_evidences_node_evidence_evidence_todo_parent_482e52_no_649861_649861\",\"from_node_id\":\"node_evidence_evidence_todo_parent_482e52\",\"reason\":\"Completion evidence supports the delivered predecessor.\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"relation\":\"validates\",\"to_node_id\":\"node_todo_todo_parent_c3ca17\"}],\"generated_at\":\"2026-11-02T12:00:00Z\",\"goal_id\":\"graph-fixture\",\"limits\":{\"emitted_predecessor_count\":1,\"missing_predecessor_count\":0,\"predecessor_node_limit\":4,\"predecessor_truncated\":false,\"source_truncated\":false,\"topology_complete\":true,\"user_gate_node_limit\":2,\"user_gate_open_count\":0,\"user_gate_truncated_count\":0},\"mode\":\"read_only\",\"nodes\":[{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_root_f01e62\",\"refs\":{\"todo_ids\":[\"todo_root\"]},\"state\":\"open\",\"title\":\"Review todo_root\"},{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_parent_c3ca17\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"state\":\"done\",\"title\":\"Review todo_parent\"},{\"kind\":\"evidence\",\"node_id\":\"node_evidence_evidence_todo_parent_482e52\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"state\":\"done\",\"title\":\"Public note survives\"}],\"schema_version\":\"task_graph_projection_v0\",\"truth_contract\":{\"event_ledger_is_source_of_truth\":true,\"projection_is_writable\":false,\"recompute_rule\":\"Recompute from status, active state, gates, leases, and run history after each lifecycle event.\",\"write_api\":false}}", + "calls": [ + "todo.context.page", + "todo.context.page", + "todo.context.page", + "work_item.task_graph.topology" + ] + }, + "handoff_evidence": { + "json": "{\"derived_from\":{\"run_history_window\":\"compact_latest_runs\",\"source_of_truth\":[\"event_ledger\",\"active_goal_state\",\"todos\",\"gates\",\"leases\",\"run_history\"],\"status_item_goal_id\":\"graph-fixture\"},\"edges\":[{\"edge_id\":\"edge_depends_node_todo_todo_root_f01e62_node_lease_claim_9b7ffb_9b7ffb\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Selected work depends on its active claim lease.\",\"refs\":{\"lease_ids\":[\"claim:graph-fixture:todo_root:builder\"]},\"relation\":\"depends_on\",\"to_node_id\":\"node_lease_claim_graph_fixture_todo_root_builder_858126\"},{\"edge_id\":\"edge_successor_node_todo_todo_root_f01e62_node_todo_todo_pa_24e83c_24e83c\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Work continues predecessor lineage; this is not a completion prerequisite.\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"relation\":\"continues\",\"to_node_id\":\"node_todo_todo_parent_c3ca17\"},{\"edge_id\":\"edge_evidences_node_evidence_evidence_todo_parent_482e52_no_649861_649861\",\"from_node_id\":\"node_evidence_evidence_todo_parent_482e52\",\"reason\":\"Completion evidence supports the delivered predecessor.\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"relation\":\"validates\",\"to_node_id\":\"node_todo_todo_parent_c3ca17\"},{\"edge_id\":\"edge_hands_off_to_node_todo_todo_parent_c3ca17_node_handoff_ha_a9be22_a9be22\",\"from_node_id\":\"node_todo_todo_parent_c3ca17\",\"reason\":\"Agent builder handed off to reviewer.\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"relation\":\"hands_off_to\",\"to_node_id\":\"node_handoff_handoff_todo_parent_builder_reviewer_062a19\"},{\"edge_id\":\"edge_continues_handoff_node_handoff_handoff_todo_parent_builder_rev_357aa5_357aa5\",\"from_node_id\":\"node_handoff_handoff_todo_parent_builder_reviewer_062a19\",\"reason\":\"Successor continues after handoff from builder to reviewer.\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"relation\":\"continues\",\"to_node_id\":\"node_todo_todo_root_f01e62\"}],\"generated_at\":\"2026-11-02T12:00:00Z\",\"goal_id\":\"graph-fixture\",\"limits\":{\"emitted_predecessor_count\":1,\"missing_predecessor_count\":0,\"predecessor_node_limit\":4,\"predecessor_truncated\":false,\"source_truncated\":false,\"topology_complete\":true,\"user_gate_node_limit\":2,\"user_gate_open_count\":0,\"user_gate_truncated_count\":0},\"mode\":\"read_only\",\"nodes\":[{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_root_f01e62\",\"owner_agent\":\"builder\",\"refs\":{\"todo_ids\":[\"todo_root\"]},\"state\":\"open\",\"title\":\"Review todo_root\"},{\"kind\":\"lease\",\"node_id\":\"node_lease_claim_graph_fixture_todo_root_builder_858126\",\"owner_agent\":\"builder\",\"refs\":{\"lease_ids\":[\"claim:graph-fixture:todo_root:builder\"]},\"state\":\"ready\",\"title\":\"Claimed by builder\"},{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_parent_c3ca17\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"state\":\"done\",\"title\":\"Review todo_parent\"},{\"kind\":\"evidence\",\"node_id\":\"node_evidence_evidence_todo_parent_482e52\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"state\":\"done\",\"title\":\"Delivered and checked\"},{\"from_agent\":\"builder\",\"kind\":\"handoff\",\"node_id\":\"node_handoff_handoff_todo_parent_builder_reviewer_062a19\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"state\":\"done\",\"title\":\"Handoff from builder to reviewer\",\"to_agent\":\"reviewer\"}],\"schema_version\":\"task_graph_projection_v0\",\"truth_contract\":{\"event_ledger_is_source_of_truth\":true,\"projection_is_writable\":false,\"recompute_rule\":\"Recompute from status, active state, gates, leases, and run history after each lifecycle event.\",\"write_api\":false}}", + "calls": [ + "todo.context.page", + "todo.context.page", + "todo.context.page", + "work_item.task_graph.topology" + ] + }, + "unicode_ids": { + "json": "{\"derived_from\":{\"run_history_window\":\"compact_latest_runs\",\"source_of_truth\":[\"event_ledger\",\"active_goal_state\",\"todos\",\"gates\",\"leases\",\"run_history\"],\"status_item_goal_id\":\"graph-fixture\"},\"edges\":[{\"edge_id\":\"edge_successor_node_todo_todo_939708_node_todo_todo_dd6898_cd56d8\",\"from_node_id\":\"node_todo_todo_939708\",\"reason\":\"Work continues predecessor lineage; this is not a completion prerequisite.\",\"refs\":{\"todo_ids\":[\"todo_前置\"]},\"relation\":\"continues\",\"to_node_id\":\"node_todo_todo_dd6898\"}],\"generated_at\":\"2026-11-02T12:00:00Z\",\"goal_id\":\"graph-fixture\",\"limits\":{\"emitted_predecessor_count\":1,\"missing_predecessor_count\":0,\"predecessor_node_limit\":4,\"predecessor_truncated\":false,\"source_truncated\":false,\"topology_complete\":true,\"user_gate_node_limit\":2,\"user_gate_open_count\":0,\"user_gate_truncated_count\":0},\"mode\":\"read_only\",\"nodes\":[{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_939708\",\"refs\":{\"todo_ids\":[\"todo_世界\"]},\"state\":\"open\",\"title\":\"Review 世界 🧪\"},{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_dd6898\",\"refs\":{\"todo_ids\":[\"todo_前置\"]},\"state\":\"done\",\"title\":\"Review todo_前置\"}],\"schema_version\":\"task_graph_projection_v0\",\"truth_contract\":{\"event_ledger_is_source_of_truth\":true,\"projection_is_writable\":false,\"recompute_rule\":\"Recompute from status, active state, gates, leases, and run history after each lifecycle event.\",\"write_api\":false}}", + "calls": [ + "todo.context.page", + "todo.context.page", + "todo.context.page", + "work_item.task_graph.topology" + ] + }, + "all_empty": { + "json": "null", + "calls": [] + }, + "gate_overflow": { + "json": "{\"derived_from\":{\"run_history_window\":\"compact_latest_runs\",\"source_of_truth\":[\"event_ledger\",\"active_goal_state\",\"todos\",\"gates\",\"leases\",\"run_history\"],\"status_item_goal_id\":\"graph-fixture\"},\"edges\":[{\"edge_id\":\"edge_depends_node_todo_todo_root_f01e62_node_lease_claim_9b7ffb_9b7ffb\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Selected work depends on its active claim lease.\",\"refs\":{\"lease_ids\":[\"claim:graph-fixture:todo_root:builder\"]},\"relation\":\"depends_on\",\"to_node_id\":\"node_lease_claim_graph_fixture_todo_root_builder_858126\"},{\"edge_id\":\"edge_blocks_node_gate_todo_gate_0_f2b661_node_todo_todo_103b6f_103b6f\",\"from_node_id\":\"node_gate_todo_gate_0_f2b661\",\"reason\":\"Open user gate blocks the gated delivery path.\",\"refs\":{\"gate_ids\":[\"todo_gate_0\"]},\"relation\":\"blocks\",\"to_node_id\":\"node_todo_todo_root_f01e62\"},{\"edge_id\":\"edge_blocks_node_gate_todo_gate_1_de61f6_node_todo_todo_23d30e_23d30e\",\"from_node_id\":\"node_gate_todo_gate_1_de61f6\",\"reason\":\"Open user gate blocks the gated delivery path.\",\"refs\":{\"gate_ids\":[\"todo_gate_1\"]},\"relation\":\"blocks\",\"to_node_id\":\"node_todo_todo_root_f01e62\"},{\"edge_id\":\"edge_blocks_node_gate_summary_graph_fixture_5_more_user_fc8eb0_fc8eb0\",\"from_node_id\":\"node_gate_summary_graph_fixture_5_more_user_gates_c13658\",\"reason\":\"Additional open user gates stay on the cold path instead of expanding the task graph hot path.\",\"refs\":{\"goal_ids\":[\"graph-fixture\"]},\"relation\":\"blocks\",\"to_node_id\":\"node_todo_todo_root_f01e62\"}],\"generated_at\":\"2026-11-02T12:00:00Z\",\"goal_id\":\"graph-fixture\",\"limits\":{\"emitted_predecessor_count\":0,\"missing_predecessor_count\":0,\"predecessor_node_limit\":4,\"predecessor_truncated\":false,\"source_truncated\":false,\"topology_complete\":true,\"user_gate_node_limit\":2,\"user_gate_open_count\":7,\"user_gate_truncated_count\":5},\"mode\":\"read_only\",\"nodes\":[{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_root_f01e62\",\"owner_agent\":\"builder\",\"refs\":{\"todo_ids\":[\"todo_root\"]},\"state\":\"open\",\"title\":\"Review todo_root\"},{\"kind\":\"lease\",\"node_id\":\"node_lease_claim_graph_fixture_todo_root_builder_858126\",\"owner_agent\":\"builder\",\"refs\":{\"lease_ids\":[\"claim:graph-fixture:todo_root:builder\"]},\"state\":\"ready\",\"title\":\"Claimed by builder\"},{\"kind\":\"gate\",\"node_id\":\"node_gate_todo_gate_0_f2b661\",\"refs\":{\"gate_ids\":[\"todo_gate_0\"]},\"state\":\"waiting\",\"title\":\"Review todo_gate\"},{\"kind\":\"gate\",\"node_id\":\"node_gate_todo_gate_1_de61f6\",\"refs\":{\"gate_ids\":[\"todo_gate_1\"]},\"state\":\"waiting\",\"title\":\"Review todo_gate\"},{\"kind\":\"gate_summary\",\"node_id\":\"node_gate_summary_graph_fixture_5_more_user_gates_c13658\",\"refs\":{\"goal_ids\":[\"graph-fixture\"]},\"state\":\"waiting\",\"title\":\"5 more open user gates not expanded\"}],\"schema_version\":\"task_graph_projection_v0\",\"truth_contract\":{\"event_ledger_is_source_of_truth\":true,\"projection_is_writable\":false,\"recompute_rule\":\"Recompute from status, active state, gates, leases, and run history after each lifecycle event.\",\"write_api\":false}}", + "calls": [ + "todo.context.page", + "todo.context.page", + "todo.context.page", + "todo.context.page", + "todo.context.page", + "work_item.task_graph.topology", + "todo.context.page" + ] + }, + "no_selected_run_gate": { + "json": "{\"derived_from\":{\"run_history_window\":\"compact_latest_runs\",\"source_of_truth\":[\"event_ledger\",\"active_goal_state\",\"todos\",\"gates\",\"leases\",\"run_history\"],\"status_item_goal_id\":\"graph-fixture\"},\"edges\":[],\"generated_at\":\"2026-11-02T12:00:00Z\",\"goal_id\":\"graph-fixture\",\"limits\":{\"user_gate_node_limit\":2,\"user_gate_open_count\":1,\"user_gate_truncated_count\":0},\"mode\":\"read_only\",\"nodes\":[{\"kind\":\"gate\",\"node_id\":\"node_gate_todo_gate_7cb5da\",\"refs\":{\"gate_ids\":[\"todo_gate\"]},\"state\":\"waiting\",\"title\":\"Review todo_gate\"},{\"kind\":\"validation\",\"node_id\":\"node_run_run_evidence_6b8c1d\",\"refs\":{\"run_ids\":[\"run_evidence\"]},\"state\":\"ready\",\"title\":\"Latest compact run: audit continuation\"}],\"schema_version\":\"task_graph_projection_v0\",\"truth_contract\":{\"event_ledger_is_source_of_truth\":true,\"projection_is_writable\":false,\"recompute_rule\":\"Recompute from status, active state, gates, leases, and run history after each lifecycle event.\",\"write_api\":false}}", + "calls": [ + "todo.context.page", + "todo.context.page", + "todo.context.page", + "todo.context.page" + ] + }, + "repair_evidence": { + "json": "{\"derived_from\":{\"run_history_window\":\"compact_latest_runs\",\"source_of_truth\":[\"event_ledger\",\"active_goal_state\",\"todos\",\"gates\",\"leases\",\"run_history\"],\"status_item_goal_id\":\"graph-fixture\"},\"edges\":[{\"edge_id\":\"edge_depends_node_todo_todo_root_f01e62_node_lease_claim_9b7ffb_9b7ffb\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Selected work depends on its active claim lease.\",\"refs\":{\"lease_ids\":[\"claim:graph-fixture:todo_root:builder\"]},\"relation\":\"depends_on\",\"to_node_id\":\"node_lease_claim_graph_fixture_todo_root_builder_858126\"},{\"edge_id\":\"edge_successor_node_todo_todo_root_f01e62_node_todo_todo_pa_24e83c_24e83c\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Work continues predecessor lineage; this is not a completion prerequisite.\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"relation\":\"continues\",\"to_node_id\":\"node_todo_todo_parent_c3ca17\"},{\"edge_id\":\"edge_validates_node_run_run_evidence_6b8c1d_node_todo_todo_208ef5_208ef5\",\"from_node_id\":\"node_run_run_evidence_6b8c1d\",\"reason\":\"Latest compact run-history evidence validates or contextualizes the selected work.\",\"refs\":{\"run_ids\":[\"run_evidence\"],\"todo_ids\":[\"todo_root\"]},\"relation\":\"validates\",\"to_node_id\":\"node_todo_todo_root_f01e62\"},{\"edge_id\":\"edge_audits_node_run_run_evidence_6b8c1d_node_todo_todo_208ef5_208ef5\",\"from_node_id\":\"node_run_run_evidence_6b8c1d\",\"reason\":\"Compact run-history evidence audits the selected work lane without replacing todo or gate state.\",\"refs\":{\"run_ids\":[\"run_evidence\"],\"todo_ids\":[\"todo_root\"]},\"relation\":\"audits\",\"to_node_id\":\"node_todo_todo_root_f01e62\"},{\"edge_id\":\"edge_continues_node_run_run_evidence_6b8c1d_node_todo_todo_208ef5_208ef5\",\"from_node_id\":\"node_run_run_evidence_6b8c1d\",\"reason\":\"Compact run-history evidence records a continuation of the selected work lane.\",\"refs\":{\"run_ids\":[\"run_evidence\"],\"todo_ids\":[\"todo_root\"]},\"relation\":\"continues\",\"to_node_id\":\"node_todo_todo_root_f01e62\"},{\"edge_id\":\"edge_repairs_node_repair_synthetic_replan_v0_6721c6_node_3cb41c_3cb41c\",\"from_node_id\":\"node_repair_synthetic_replan_v0_6721c6\",\"reason\":\"Autonomous repair/replan obligation should recover the selected work lane.\",\"refs\":{\"todo_ids\":[\"todo_root\"]},\"relation\":\"repairs\",\"to_node_id\":\"node_todo_todo_root_f01e62\"}],\"generated_at\":\"2026-11-02T12:00:00Z\",\"goal_id\":\"graph-fixture\",\"limits\":{\"emitted_predecessor_count\":1,\"missing_predecessor_count\":0,\"predecessor_node_limit\":4,\"predecessor_truncated\":false,\"source_truncated\":false,\"topology_complete\":true,\"user_gate_node_limit\":2,\"user_gate_open_count\":0,\"user_gate_truncated_count\":0},\"mode\":\"read_only\",\"nodes\":[{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_root_f01e62\",\"owner_agent\":\"builder\",\"refs\":{\"todo_ids\":[\"todo_root\"]},\"state\":\"open\",\"title\":\"Review todo_root\"},{\"kind\":\"lease\",\"node_id\":\"node_lease_claim_graph_fixture_todo_root_builder_858126\",\"owner_agent\":\"builder\",\"refs\":{\"lease_ids\":[\"claim:graph-fixture:todo_root:builder\"]},\"state\":\"ready\",\"title\":\"Claimed by builder\"},{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_parent_c3ca17\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"state\":\"done\",\"title\":\"Review todo_parent\"},{\"kind\":\"validation\",\"node_id\":\"node_run_run_evidence_6b8c1d\",\"refs\":{\"run_ids\":[\"run_evidence\"],\"todo_ids\":[\"todo_root\"]},\"state\":\"ready\",\"title\":\"Latest compact run: audit continued\"},{\"kind\":\"repair\",\"node_id\":\"node_repair_synthetic_replan_v0_6721c6\",\"refs\":{\"todo_ids\":[\"todo_root\"]},\"state\":\"ready\",\"title\":\"Repair selected work\"}],\"schema_version\":\"task_graph_projection_v0\",\"truth_contract\":{\"event_ledger_is_source_of_truth\":true,\"projection_is_writable\":false,\"recompute_rule\":\"Recompute from status, active state, gates, leases, and run history after each lifecycle event.\",\"write_api\":false}}", + "calls": [ + "todo.context.page", + "todo.context.page", + "todo.context.page", + "work_item.task_graph.topology" + ] + }, + "stale_source_time": { + "json": "{\"derived_from\":{\"active_state_updated_at\":\"2026-11-01T00:00:00Z\",\"run_history_window\":\"compact_latest_runs\",\"source_of_truth\":[\"event_ledger\",\"active_goal_state\",\"todos\",\"gates\",\"leases\",\"run_history\"],\"status_item_goal_id\":\"graph-fixture\"},\"edges\":[{\"edge_id\":\"edge_depends_node_todo_todo_root_f01e62_node_lease_claim_9b7ffb_9b7ffb\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Selected work depends on its active claim lease.\",\"refs\":{\"lease_ids\":[\"claim:graph-fixture:todo_root:builder\"]},\"relation\":\"depends_on\",\"to_node_id\":\"node_lease_claim_graph_fixture_todo_root_builder_858126\"},{\"edge_id\":\"edge_validates_node_run_2026_10_31t00_00_00z_b746af_node_to_453dab_453dab\",\"from_node_id\":\"node_run_2026_10_31t00_00_00z_b746af\",\"reason\":\"Latest compact run-history evidence validates or contextualizes the selected work.\",\"refs\":{\"run_ids\":[\"2026-10-31T00:00:00Z\"],\"todo_ids\":[\"todo_root\"]},\"relation\":\"validates\",\"to_node_id\":\"node_todo_todo_root_f01e62\"}],\"generated_at\":\"2026-10-31T00:00:00Z\",\"goal_id\":\"graph-fixture\",\"limits\":{\"emitted_predecessor_count\":0,\"missing_predecessor_count\":0,\"predecessor_node_limit\":4,\"predecessor_truncated\":false,\"source_truncated\":true,\"topology_complete\":false,\"user_gate_node_limit\":2,\"user_gate_open_count\":0,\"user_gate_truncated_count\":0},\"mode\":\"read_only\",\"nodes\":[{\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_root_f01e62\",\"owner_agent\":\"builder\",\"refs\":{\"todo_ids\":[\"todo_root\"]},\"state\":\"open\",\"title\":\"Review todo_root\"},{\"kind\":\"lease\",\"node_id\":\"node_lease_claim_graph_fixture_todo_root_builder_858126\",\"owner_agent\":\"builder\",\"refs\":{\"lease_ids\":[\"claim:graph-fixture:todo_root:builder\"]},\"state\":\"ready\",\"title\":\"Claimed by builder\"},{\"kind\":\"validation\",\"node_id\":\"node_run_2026_10_31t00_00_00z_b746af\",\"refs\":{\"run_ids\":[\"2026-10-31T00:00:00Z\"],\"todo_ids\":[\"todo_root\"]},\"state\":\"ready\",\"title\":\"Latest compact run: checked\"}],\"schema_version\":\"task_graph_projection_v0\",\"truth_contract\":{\"event_ledger_is_source_of_truth\":true,\"projection_is_writable\":false,\"recompute_rule\":\"Recompute from status, active state, gates, leases, and run history after each lifecycle event.\",\"write_api\":false}}", + "calls": [ + "todo.context.page", + "todo.context.page", + "todo.context.page", + "work_item.task_graph.topology" + ] + }, + "absent_goal": { + "json": "null", + "calls": [] + }, + "invalid_goal": { + "json": "null", + "calls": [] + } + }, + "goal_map": { + "mixed": { + "json": "{\"edges\":[],\"goal_id\":\"graph-fixture\",\"limits\":{\"cycle_edge_count\":0,\"emitted_node_count\":3,\"missing_endpoint_count\":0,\"node_limit\":120,\"omitted_node_count\":0,\"source_truncated\":false,\"topology_complete\":true},\"mode\":\"read_only\",\"nodes\":[{\"depth\":0,\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_work_76c080\",\"refs\":{\"todo_ids\":[\"todo_work\"]},\"state\":\"open\",\"title\":\"Review todo_work\"},{\"depth\":0,\"kind\":\"gate\",\"node_id\":\"node_gate_todo_gate_7cb5da\",\"refs\":{\"todo_ids\":[\"todo_gate\"]},\"state\":\"open\",\"title\":\"Review todo_gate\"},{\"depth\":0,\"kind\":\"monitor\",\"node_id\":\"node_todo_todo_monitor_82402f\",\"refs\":{\"todo_ids\":[\"todo_monitor\"]},\"state\":\"open\",\"title\":\"Review todo_monitor\"}],\"projection_envelope\":{\"age_seconds\":0,\"alert\":false,\"alert_reasons\":[],\"alert_source_ids\":[],\"complete\":true,\"coverage\":{\"available_count\":3,\"complete\":true,\"expected_count\":3,\"included_count\":3,\"omitted\":[],\"scope\":\"goal_task_map\",\"shown_count\":3,\"truncated\":false},\"fresh\":true,\"observed_at\":\"2026-11-02T12:00:00Z\",\"projection\":\"goal_task_map\",\"schema_version\":\"loopx_projection_envelope_v0\",\"served_at\":\"2026-11-02T12:00:00Z\",\"served_from_cache\":false,\"sources\":[{\"alert\":false,\"alert_reasons\":[],\"item_count\":3,\"last_read_at\":\"2026-11-02T12:00:00Z\",\"missing_count\":0,\"read_status\":\"read\",\"required\":true,\"source_id\":\"goal_todos\",\"source_updated_at\":null,\"staleness_seconds\":0,\"status\":\"fresh\",\"unreadable_count\":0,\"window_seconds\":300}],\"upstream\":[]},\"schema_version\":\"goal_task_map_v0\"}", + "calls": [ + "work_item.task_graph.goal_topology" + ] + }, + "lineage": { + "json": "{\"edges\":[{\"edge_id\":\"edge_successor_node_todo_todo_root_f01e62_node_todo_todo_pa_24e83c_24e83c\",\"enforcement\":\"lineage_only\",\"from_node_id\":\"node_todo_todo_root_f01e62\",\"reason\":\"Work continues predecessor lineage; this is not a completion prerequisite.\",\"relation\":\"continues\",\"to_node_id\":\"node_todo_todo_parent_c3ca17\"}],\"goal_id\":\"graph-fixture\",\"limits\":{\"cycle_edge_count\":0,\"emitted_node_count\":2,\"missing_endpoint_count\":0,\"node_limit\":120,\"omitted_node_count\":0,\"source_truncated\":false,\"topology_complete\":true},\"mode\":\"read_only\",\"nodes\":[{\"depth\":1,\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_root_f01e62\",\"refs\":{\"todo_ids\":[\"todo_root\"]},\"state\":\"open\",\"title\":\"Review todo_root\"},{\"depth\":0,\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_parent_c3ca17\",\"refs\":{\"todo_ids\":[\"todo_parent\"]},\"state\":\"done\",\"title\":\"Review todo_parent\"}],\"projection_envelope\":{\"age_seconds\":0,\"alert\":false,\"alert_reasons\":[],\"alert_source_ids\":[],\"complete\":true,\"coverage\":{\"available_count\":2,\"complete\":true,\"expected_count\":2,\"included_count\":2,\"omitted\":[],\"scope\":\"goal_task_map\",\"shown_count\":2,\"truncated\":false},\"fresh\":true,\"observed_at\":\"2026-11-02T12:00:00Z\",\"projection\":\"goal_task_map\",\"schema_version\":\"loopx_projection_envelope_v0\",\"served_at\":\"2026-11-02T12:00:00Z\",\"served_from_cache\":false,\"sources\":[{\"alert\":false,\"alert_reasons\":[],\"item_count\":2,\"last_read_at\":\"2026-11-02T12:00:00Z\",\"missing_count\":0,\"read_status\":\"read\",\"required\":true,\"source_id\":\"goal_todos\",\"source_updated_at\":null,\"staleness_seconds\":0,\"status\":\"fresh\",\"unreadable_count\":0,\"window_seconds\":300}],\"upstream\":[]},\"schema_version\":\"goal_task_map_v0\"}", + "calls": [ + "work_item.task_graph.goal_topology" + ] + }, + "unicode": { + "json": "{\"edges\":[],\"goal_id\":\"graph-fixture\",\"limits\":{\"cycle_edge_count\":0,\"emitted_node_count\":1,\"missing_endpoint_count\":0,\"node_limit\":120,\"omitted_node_count\":0,\"source_truncated\":false,\"topology_complete\":true},\"mode\":\"read_only\",\"nodes\":[{\"depth\":0,\"kind\":\"deliverable\",\"node_id\":\"node_todo_todo_939708\",\"refs\":{\"todo_ids\":[\"todo_世界\"]},\"state\":\"open\",\"title\":\"Review 世界\"}],\"projection_envelope\":{\"age_seconds\":0,\"alert\":false,\"alert_reasons\":[],\"alert_source_ids\":[],\"complete\":true,\"coverage\":{\"available_count\":1,\"complete\":true,\"expected_count\":1,\"included_count\":1,\"omitted\":[],\"scope\":\"goal_task_map\",\"shown_count\":1,\"truncated\":false},\"fresh\":true,\"observed_at\":\"2026-11-02T12:00:00Z\",\"projection\":\"goal_task_map\",\"schema_version\":\"loopx_projection_envelope_v0\",\"served_at\":\"2026-11-02T12:00:00Z\",\"served_from_cache\":false,\"sources\":[{\"alert\":false,\"alert_reasons\":[],\"item_count\":1,\"last_read_at\":\"2026-11-02T12:00:00Z\",\"missing_count\":0,\"read_status\":\"read\",\"required\":true,\"source_id\":\"goal_todos\",\"source_updated_at\":null,\"staleness_seconds\":0,\"status\":\"fresh\",\"unreadable_count\":0,\"window_seconds\":300}],\"upstream\":[]},\"schema_version\":\"goal_task_map_v0\"}", + "calls": [ + "work_item.task_graph.goal_topology" + ] + } + } +} diff --git a/tests/control_plane/test_status_task_graph_characterization.py b/tests/control_plane/test_status_task_graph_characterization.py new file mode 100644 index 0000000000..a31ff8fbf7 --- /dev/null +++ b/tests/control_plane/test_status_task_graph_characterization.py @@ -0,0 +1,168 @@ +"""Frozen migration evidence; remove after the Python status facade retires.""" +from __future__ import annotations + +from copy import deepcopy +import json +from pathlib import Path + +import pytest + +from loopx import status +from loopx.control_plane import effect_runtime +from loopx.control_plane.work_items import task_graph +from loopx.control_plane.runtime.public_safety import public_safe_compact_text +from loopx.control_plane.todos.contract import normalize_todo_status, todo_done_for_status + +FIXTURE = Path(__file__).with_name("fixtures") / "status_task_graph_characterization.json" +NOW = "2026-11-02T12:00:00Z" + + +def todo(tid, **fields): + return {"todo_id": tid, "text": f"Review {tid}", "status": "open", **fields} + + +def cases(): + root = todo("todo_root", claimed_by="builder") + parent = todo("todo_parent", status="done", done=True, successor_todo_ids=["todo_root"]) + gate = todo("todo_gate", task_class="user_gate") + rows = { + "diamond_at_cap": [root, todo("todo_aaa", status="done", successor_todo_ids=["todo_root"]), + todo("todo_bbb", status="done", successor_todo_ids=["todo_root"]), + todo("todo_shared", status="done", successor_todo_ids=["todo_aaa", "todo_bbb"]), + todo("todo_fourth", status="done", successor_todo_ids=["todo_shared"])], + "cycle": [root, {**parent, "successor_todo_ids": ["todo_root", "todo_other"]}, + todo("todo_other", status="done", successor_todo_ids=["todo_parent"])], + "missing_prerequisite": [{**root, "resume_when": "todo_done:todo_missing"}], + "open_predecessor": [root, {**parent, "done": False, "status": "open", "evidence": "Unfinished evidence"}, + todo("todo_ancestor", status="done", successor_todo_ids=["todo_parent"])], + "condition_and_lineage": [{**root, "resume_when": "todo_done:todo_parent"}, parent], + "predecessor_overflow": [root, *[todo(f"todo_parent_{i}", status="done", successor_todo_ids=["todo_root"]) for i in range(7)]], + "duplicate_identity": [root, {**parent, "todo_id": "todo_same!"}, {**parent, "todo_id": "todo_same?"}, root], + "long_public_text": [{**root, "text": "Public 🧪 review " * 30, "last_actor_agent_id": "reviewer"}, + {**parent, "evidence": "Public completion evidence " * 30}], + "redaction": [{**root, "claimed_by": "api_key=synthetic-value"}, + {**parent, "evidence": "api_key=synthetic-value", "note": "Public note survives"}], + "handoff_evidence": [root, {**parent, "evidence": "Delivered and checked", "handoff_note": { + "from_agent": "builder", "to_agent": "reviewer", "status": "done"}}], + "unicode_ids": [todo("todo_世界", text="Review 世界 🧪"), + todo("todo_前置", status="done", successor_todo_ids=["todo_世界"])], + "all_empty": [], + } + result = {} + for name, values in rows.items(): + result[name] = {"item": {"goal_id": "graph-fixture", "agent_todos": { + "items": values, "open_count": 1, "total_count": len(values)}}, "goal": {"id": "graph-fixture"}} + result["gate_overflow"] = {"item": {"goal_id": "graph-fixture", "agent_todos": {"items": [root]}, + "user_todos": {"items": [{**gate, "todo_id": f"todo_gate_{i}", "index": i} for i in range(5)], "open_count": 7}}, + "goal": {"id": "graph-fixture"}} + result["no_selected_run_gate"] = {"item": {"goal_id": "graph-fixture", "user_todos": {"items": [gate]}}, + "goal": {"id": "graph-fixture"}, "goal_latest_runs": [{"run_id": "run_evidence", "classification": "audit continuation", "generated_at": NOW}]} + result["repair_evidence"] = {"item": {"goal_id": "graph-fixture", "agent_todos": {"items": [root, parent]}, + "autonomous_replan_obligation": {"schema_version": "synthetic_replan_v0", "recommended_action": "Repair selected work"}}, + "goal": {"id": "graph-fixture"}, "goal_latest_runs": [{"run_id": "run_evidence", "classification": "audit continued", "generated_at": NOW}]} + result["stale_source_time"] = {"item": {"goal_id": "graph-fixture", "agent_todos": {"items": [root], "total_count": 9}, + "stale_latest_run_warning": {"active_state_updated_at": "2026-11-01T00:00:00Z"}}, + "goal": {"id": "graph-fixture"}, "goal_latest_runs": [{"generated_at": "2026-10-31T00:00:00Z", "classification": "checked"}]} + result["absent_goal"] = {"item": {}, "goal": {}} + result["invalid_goal"] = {"item": {"goal_id": "api_key=synthetic-value"}, "goal": {}} + return result + + +def encode(value): + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def measure(monkeypatch, arguments, *, goal_map=False): + calls = [] + real = effect_runtime.effect_runtime_result + + def observe(method, params, **kwargs): + calls.append(method) + return real(method, params, **kwargs) + + monkeypatch.setattr(effect_runtime, "effect_runtime_result", observe) + monkeypatch.setattr(task_graph, "effect_runtime_result", observe) + monkeypatch.setattr(task_graph, "now_utc_iso", lambda: NOW) + before = deepcopy(arguments) + if goal_map: + output = task_graph.build_goal_task_map(**arguments, public_safe_compact_text=public_safe_compact_text, + normalize_todo_status=normalize_todo_status, todo_done_for_status=todo_done_for_status, + todo_status_open="open", monitor_task_class="continuous_monitor") + else: + output = status.build_task_graph_projection(**arguments) + assert arguments == before + return {"json": encode(output), "calls": calls} + + +@pytest.mark.parametrize("name", list(cases())) +def test_status_frozen_outputs_and_crossings(monkeypatch, name): + expected = json.loads(FIXTURE.read_text())["status"][name] + actual = measure(monkeypatch, cases()[name]) + assert actual["json"] == expected["json"] + assert actual["calls"] == ([] if name in {"absent_goal", "invalid_goal"} else ["work_item.task_graph.status"]) + + +def map_cases(): + return {"mixed": [todo("todo_work"), todo("todo_gate", role="user"), todo("todo_monitor", task_class="continuous_monitor")], + "lineage": [todo("todo_root"), todo("todo_parent", status="done", successor_todo_ids=["todo_root"])], + "unicode": [todo("todo_世界", text="Review 世界") ]} + + +@pytest.mark.parametrize("name", list(map_cases())) +def test_goal_map_frozen_outputs(monkeypatch, name): + actual = measure(monkeypatch, {"goal_id": "graph-fixture", "todos": map_cases()[name], + "source_truncated": False, "source_read_at": NOW}, goal_map=True) + assert actual == json.loads(FIXTURE.read_text())["goal_map"][name] + + +def test_status_native_refusal_has_no_python_fallback(monkeypatch): + def refuse(*args, **kwargs): + raise RuntimeError("synthetic native refusal") + monkeypatch.setattr(task_graph, "effect_runtime_result", refuse) + with pytest.raises(RuntimeError, match="synthetic native refusal"): + status.build_task_graph_projection(**cases()["repair_evidence"]) + + +def test_status_boundary_carries_only_public_display_facts(monkeypatch): + calls = [] + real = task_graph.effect_runtime_result + + def observe(method, params, **kwargs): + calls.append((method, params)) + return real(method, params, **kwargs) + + monkeypatch.setattr(task_graph, "effect_runtime_result", observe) + arguments = deepcopy(cases()["redaction"]) + arguments["item"]["agent_todos"]["items"][0]["unknown"] = {"private": "must-not-cross"} + status.build_task_graph_projection(**arguments) + assert len(calls) == 1 + encoded = encode(calls[0][1]) + assert "must-not-cross" not in encoded + assert "api_key=synthetic-value" not in encoded + assert "unknown" not in encoded + + +def test_empty_display_text_does_not_steal_selection(monkeypatch): + monkeypatch.setattr(task_graph, "now_utc_iso", lambda: NOW) + output = status.build_task_graph_projection({"goal_id": "graph-fixture", "agent_todos": { + "items": [todo("todo_empty", text=" "), todo("todo_visible")]}}, goal={"id": "graph-fixture"}) + assert output["nodes"][0]["refs"] == {"todo_ids": ["todo_visible"]} + + +def test_large_status_note_uses_the_existing_snapshot_transport(monkeypatch): + arguments = deepcopy(cases()["handoff_evidence"]) + parent = arguments["item"]["agent_todos"]["items"][1] + parent.pop("evidence", None) + parent["note"] = "Public note " + "x" * 4_300_016 + calls = [] + real = task_graph.effect_runtime_result + + def observe(method, params, **kwargs): + calls.append((method, kwargs.get("large_local_snapshot", False))) + return real(method, params, **kwargs) + + monkeypatch.setattr(task_graph, "effect_runtime_result", observe) + monkeypatch.setattr(task_graph, "now_utc_iso", lambda: NOW) + output = status.build_task_graph_projection(**arguments) + assert output["nodes"] + assert calls == [("work_item.task_graph.status", True)] diff --git a/tests/control_plane/test_task_graph_topology.py b/tests/control_plane/test_task_graph_topology.py index 70767a3a9d..53e04921c0 100644 --- a/tests/control_plane/test_task_graph_topology.py +++ b/tests/control_plane/test_task_graph_topology.py @@ -99,19 +99,16 @@ def test_real_status_reader_uses_provider_snapshot_without_repair_writes(tmp_pat records = [canonical_todo_read_record(structured_todo_item(item, role=role, source_section=item.get("source_section"))) for role, rows in groups.items() for item in rows] + parent = next(record for record in records if record["todo_id"] == "todo_parent") + parent["handoff_note"] = { + "from_agent": " Builder\tTeam ", "to_agent": " Reviewer\u00a0Team ", "status": "done", + } initialize_canonical_authority(runtime, goal["id"], build_todo_runtime_shadow_projection(goal_id=goal["id"], todos=records), state_path=state, provider=provider) if display == "missing": state.unlink() before = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=goal["id"]) - fields = active_state_todo_fields(goal, runtime_root=runtime) - result = build_task_graph_projection({"goal_id": goal["id"], **fields}, goal=goal) - assert ("todo_root", "todo_monitor", "depends_on") in edges_by_todo(result) - assert ("todo_root", "todo_parent", "continues") in edges_by_todo(result) - assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=goal["id"]) == before - assert (state.read_text() if state.exists() else None) == (None if display == "missing" else source) - registry = tmp_path / "registry.json" registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [goal]})) process = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(registry), @@ -122,6 +119,25 @@ def test_real_status_reader_uses_provider_snapshot_without_repair_writes(tmp_pat assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=goal["id"]) == before assert (state.read_text() if state.exists() else None) == (None if display == "missing" else source) + for output_format in ("json", "markdown"): + process = subprocess.run([ + sys.executable, "-m", "loopx.cli", "--registry", str(registry), + "--format", output_format, "status", "--goal-id", goal["id"], "--include-task-graph", + ], capture_output=True, text=True, timeout=60) + assert process.returncode == 0, (process.stdout, process.stderr) + if output_format == "json": + assert "Handoff from builder-team to reviewer-team" in process.stdout + assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=goal["id"]) == before + assert (state.read_text() if state.exists() else None) == (None if display == "missing" else source) + + fields = active_state_todo_fields(goal, runtime_root=runtime) + result = build_task_graph_projection({"goal_id": goal["id"], **fields}, goal=goal) + assert ("todo_root", "todo_monitor", "depends_on") in edges_by_todo(result) + assert ("todo_root", "todo_parent", "continues") in edges_by_todo(result) + assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=goal["id"]) == before + assert (state.read_text() if state.exists() else None) == (None if display == "missing" else source) + + def goal_map(rows, total=None): from loopx.control_plane.runtime.public_safety import public_safe_compact_text diff --git a/tests/control_plane_ts/task_graph.test.ts b/tests/control_plane_ts/task_graph.test.ts index eded3a6c9d..f2d41d71d1 100644 --- a/tests/control_plane_ts/task_graph.test.ts +++ b/tests/control_plane_ts/task_graph.test.ts @@ -1,6 +1,7 @@ +import {compactTodoReadText} from "../../loopx/control_plane/todos/read_codec.ts"; import assert from "node:assert/strict"; import test from "node:test"; -import { GOAL_TASK_MAP_REQUEST, projectGoalTaskMap, projectGoalTaskGraphTopology, projectTaskGraphTopology, TASK_GRAPH_GOAL_TOPOLOGY_REQUEST, +import { projectStatusTaskGraph, STATUS_TASK_GRAPH_REQUEST, GOAL_TASK_MAP_REQUEST, projectGoalTaskMap, projectGoalTaskGraphTopology, projectTaskGraphTopology, TASK_GRAPH_GOAL_TOPOLOGY_REQUEST, TASK_GRAPH_TOPOLOGY_REQUEST } from "../../loopx/control_plane/work_items/task_graph.ts"; import { projectRelations, todoRef } from "../../loopx/control_plane/work_items/planning_relations.ts"; import type { JsonObject } from "../../loopx/control_plane/effect_program.ts"; @@ -311,3 +312,195 @@ test("goal map rejects malformed wire types at each consumed boundary", () => { assert.throws(() => goalMap([mapRow("todo_invalid", extra)])); } }); + +function statusGraph(extra: JsonObject = {}): JsonObject { + const publicTexts: JsonObject = {}; + const collect = (value: unknown): void => { + if (typeof value === "string") { + publicTexts[value] = Object.fromEntries([80, 120, 160, 180, 240].map(cap => + [String(cap), compactTodoReadText(value, cap)])); + } else if (value && typeof value === "object") { + for (const child of Object.values(value)) collect(child); + } + }; + collect(extra); + const overrides = extra.public_texts as JsonObject | undefined; + extra = {...extra, public_texts: Array.isArray(overrides) ? overrides : {...publicTexts, ...overrides}}; + return projectStatusTaskGraph({schema_version: STATUS_TASK_GRAPH_REQUEST, goal_id: "synthetic-goal", + summaries: {agent: {items: [], total_count: 0}, user: {items: [], total_count: 0}}, + max_status_todos_per_role: 8, latest_runs: [], public_texts: {}, + fallback_time: "2026-11-02T12:00:00Z", ...extra}); +} +const statusTodo = (todo_id: string, extra: JsonObject = {}): JsonObject => + ({todo_id, text: `Review ${todo_id}`, status: "open", ...extra}); +const statusNodes = (result: JsonObject): JsonObject[] => (result.projection as JsonObject).nodes as JsonObject[]; +const statusEdges = (result: JsonObject): JsonObject[] => (result.projection as JsonObject).edges as JsonObject[]; + +test("status empty snapshot has no graph, while gate and run evidence survive without a selected Todo", () => { + assert.deepEqual(statusGraph(), {schema_version: "status_task_graph_result_v0", projection: null}); + const result = statusGraph({summaries: {agent: {}, user: {items: [statusTodo("todo_gate", {task_class: "user_gate"})]}}, + latest_runs: [{run_id: "run_checked", classification: "checked"}]}); + assert.deepEqual(statusNodes(result).map(node => [node.kind, node.state]), [["gate", "waiting"], ["validation", "ready"]]); + assert.deepEqual(statusEdges(result), []); + assert.deepEqual((result.projection as JsonObject).limits, {user_gate_node_limit: 2, user_gate_open_count: 1, user_gate_truncated_count: 0}); +}); + +test("status owns selection before sorting and caps gates independently of predecessor expansion", () => { + const result = statusGraph({summaries: {agent: {items: [statusTodo("todo_first", {priority: "P4", claimed_by: "builder"}), + statusTodo("todo_later", {priority: "P0"})]}, user: {open_count: 9, + gate_open_items: Array.from({length: 5}, (_, i) => statusTodo(`todo_gate_${i}`, {task_class: "user_gate"}))}}}); + assert.deepEqual(statusNodes(result).map(node => node.kind), ["deliverable", "lease", "gate", "gate", "gate_summary"]); + assert.deepEqual(statusNodes(result)[0].refs, {todo_ids: ["todo_first"]}); + assert.deepEqual(statusEdges(result).map(edge => edge.relation), ["depends_on", "blocks", "blocks", "blocks"]); + assert.equal(statusNodes(result)[4].title, "7 more open user gates not expanded"); +}); + +test("status retains parallel lineage and condition edges, done evidence and predecessor handoff", () => { + const result = statusGraph({summaries: {agent: {items: [statusTodo("todo_root", {resume_when: "todo_done:todo_parent"}), + statusTodo("todo_parent", {status: "done", done: true, successor_todo_ids: ["todo_root"], evidence: "Checked result", + handoff_note: {from_agent: "builder", to_agent: "reviewer"}})]}, user: {}}}); + assert.deepEqual(statusNodes(result).map(node => [node.kind, node.state]), [ + ["deliverable", "open"], ["deliverable", "done"], ["evidence", "done"], ["handoff", "done"]]); + assert.deepEqual(statusEdges(result).map(edge => edge.relation), ["depends_on", "continues", "validates", "hands_off_to", "continues"]); + assert.deepEqual(statusNodes(result)[2].refs, {todo_ids: ["todo_parent"]}); +}); + +test("status limits expansion at open predecessors, reports missing targets and source truncation", () => { + const result = statusGraph({summaries: {agent: {total_count: 8, items: [statusTodo("todo_root", {resume_when: "todo_done:todo_missing"}), + statusTodo("todo_parent", {successor_todo_ids: ["todo_root"], evidence: "Not delivered"}), + statusTodo("todo_ancestor", {status: "done", successor_todo_ids: ["todo_parent"]})]}, user: {}}}); + assert.deepEqual(statusNodes(result).map(node => node.refs), [{todo_ids: ["todo_root"]}, {todo_ids: ["todo_parent"]}]); + assert.deepEqual((result.projection as JsonObject).limits, {user_gate_node_limit: 2, user_gate_open_count: 0, user_gate_truncated_count: 0, + predecessor_node_limit: 4, emitted_predecessor_count: 1, predecessor_truncated: false, source_truncated: true, + missing_predecessor_count: 1, topology_complete: false}); +}); + +test("status run node and lineage independently select first useful facts, repair remains display only", () => { + const result = statusGraph({summaries: {agent: {items: [statusTodo("todo_root")]}, user: {}}, + latest_runs: [{run_id: "run_identity_only"}, {classification: "audit continued", generated_at: "2026-11-01T00:00:00Z"}], + warning_updated_at: "2026-11-02T00:00:00Z", state_updated_at: "2026-10-31T00:00:00Z", + replan: {kind: "synthetic_repair", recommended_action: "Recover selected work"}}); + assert.deepEqual(statusEdges(result).map(edge => edge.relation), ["validates", "audits", "continues", "repairs"]); + const projection = result.projection as JsonObject; + assert.equal(projection.generated_at, "2026-11-01T00:00:00Z"); + assert.equal((projection.derived_from as JsonObject).active_state_updated_at, "2026-11-02T00:00:00Z"); + assert.equal((projection.truth_contract as JsonObject).write_api, false); +}); + +test("status deduplicates display identities and honors codec rejection without selecting a replacement", () => { + const gate = statusTodo("todo_gate", {task_class: "user_gate"}); + const result = statusGraph({summaries: {agent: {items: [statusTodo("todo_root"), statusTodo("todo_other")]}, + user: {items: [{...gate, index: 1}, {...gate, index: 2}]}}, + public_texts: {"Review todo_root": {"160": null}}}); + assert.deepEqual(statusNodes(result).map(node => node.kind), ["gate"]); + assert.deepEqual(statusEdges(result), []); +}); + +test("status refuses malformed consumed facts and has no native fallback", () => { + for (const extra of [{schema_version: "unknown"}, {goal_id: ""}, {summaries: []}, + {latest_runs: {}}, {max_status_todos_per_role: 0}, {public_texts: []}]) assert.throws(() => statusGraph(extra)); +}); + +test("status gate order preserves typed legacy coordinates and Python whitespace status", () => { + const result = statusGraph({summaries: {agent: {items: [statusTodo("todo_root", {status: "\ufeffdone"})]}, + user: {items: [statusTodo("todo_gate_b", {schema_version: "todo_item_v0", index: 2, task_class: "user_gate"}), + statusTodo("todo_gate_a", {schema_version: "todo_item_v0", index: 1, task_class: "user_gate"})]}}}); + assert.equal(statusNodes(result)[0].state, "open"); + assert.deepEqual(statusNodes(result).slice(1).map(node => node.refs), [{gate_ids: ["todo_gate_a"]}, {gate_ids: ["todo_gate_b"]}]); +}); + +test("retired topology RPC cannot recreate a Python-led status composition", async () => { + const {createEffectRuntimeHandlers, dispatchEffectRuntimeMethod} = await import("../../loopx/control_plane/effect_runtime_handlers.ts"); + const handlers = createEffectRuntimeHandlers({fingerprint: "status-graph-test", requestShutdown: () => undefined}); + await assert.rejects(dispatchEffectRuntimeMethod(handlers, "work_item.task_graph.topology", {}), {code: "unsupported_method"}); + const result = await dispatchEffectRuntimeMethod(handlers, "work_item.task_graph.status", { + schema_version: STATUS_TASK_GRAPH_REQUEST, goal_id: "synthetic-goal", summaries: {agent: {}, user: {}}, + max_status_todos_per_role: 8, public_texts: {}, latest_runs: [], fallback_time: "2026-11-02T12:00:00Z"}); + assert.deepEqual(result, {schema_version: "status_task_graph_result_v0", projection: null}); +}); + +test("status deduplicates actual legacy hashes, including evidence identities truncated before hashing", () => { + const long = "todo_" + "a".repeat(130); + const result = statusGraph({summaries: {agent: {items: [statusTodo("todo_root"), + statusTodo(long + "x", {status: "done", successor_todo_ids: ["todo_root"], evidence: "First evidence"}), + statusTodo(long + "y", {status: "done", successor_todo_ids: ["todo_root"], evidence: "Later evidence"})]}, user: {}}}); + const nodes = statusNodes(result), edges = statusEdges(result); + assert.deepEqual(nodes.map(node => node.kind), ["deliverable", "deliverable", "evidence"]); + assert.equal(nodes[2].title, "First evidence"); + assert.equal(new Set(nodes.map(node => node.node_id)).size, nodes.length); + assert.equal(new Set(edges.map(edge => edge.edge_id)).size, edges.length); +}); + + +test("status compaction fallback refuses unqualified source text", () => { + assert.throws(() => projectStatusTaskGraph({schema_version: STATUS_TASK_GRAPH_REQUEST, + goal_id: "synthetic-goal", summaries: {agent: {items: [statusTodo("todo_unqualified")]}, user: {}}, + max_status_todos_per_role: 8, latest_runs: [], public_texts: {}, + fallback_time: "2026-11-02T12:00:00Z"}), /Missing public text facts/); +}); + +test("status compaction fallback enumerates composed titles, identities, references and reasons", () => { + const result = statusGraph({summaries: { + agent: {items: [statusTodo("todo_root", {claimed_by: "builder"}), + statusTodo("todo_parent", {status: "done", successor_todo_ids: ["todo_root"], + evidence: "Checked public evidence", handoff_note: {from_agent: "builder", to_agent: "reviewer"}})]}, + user: {open_count: 6, items: [{text: "Review gate", task_class: "user_gate"}]}, + }, latest_runs: [{run_id: "run_checked", classification: "audit continued"}], replan: {}}); + assert.deepEqual(statusNodes(result).map(node => [node.kind, node.title]), [ + ["deliverable", "Review todo_root"], ["lease", "Claimed by builder"], + ["deliverable", "Review todo_parent"], ["evidence", "Checked public evidence"], + ["handoff", "Handoff from builder to reviewer"], ["gate", "Review gate"], + ["gate_summary", "5 more open user gates not expanded"], + ["validation", "Latest compact run: audit continued"], ["repair", "autonomous_replan_obligation"], + ]); + assert.deepEqual(statusEdges(result).map(edge => edge.relation), [ + "depends_on", "continues", "validates", "hands_off_to", "continues", "blocks", "blocks", + "validates", "audits", "continues", "repairs", + ]); + assert.deepEqual(statusNodes(result)[5].refs, {gate_ids: ["gate:synthetic-goal:user:1"]}); + for (const edge of statusEdges(result)) { + assert.equal(typeof edge.reason, "string"); + assert.match(String(edge.edge_id), /^edge_[a-z0-9_]+$/); + } +}); + +for (const [fromSource, toSource, expectedTitle] of [ + ["Builder", "Reviewer", "Handoff from builder to reviewer"], + [" Builder ", " Reviewer ", "Handoff from builder to reviewer"], + [" Builder\tTeam ", " Reviewer\u00a0Team ", "Handoff from builder-team to reviewer-team"], + ["\u001cBuilder\u0085", "\u001fReviewer\u001f", "Handoff from builder to reviewer"], +]) { + test(`status normalizes approved handoff identities: ${JSON.stringify(fromSource)}`, () => { + const values = { + "synthetic-goal": {"120": "synthetic-goal", "80": "synthetic-goal"}, + "todo_root": {"120": "todo_root"}, "Review todo_root": {"160": "Review todo_root"}, + "open": {"80": "open"}, "todo_done:todo_parent": {"240": "todo_done:todo_parent"}, + "todo_parent": {"120": "todo_parent"}, "Review todo_parent": {"160": "Review todo_parent"}, + "done": {"80": "done"}, + [fromSource]: {"80": compactTodoReadText(fromSource, 80)}, + [toSource]: {"80": compactTodoReadText(toSource, 80)}, + }; + const result = projectStatusTaskGraph({ + schema_version: STATUS_TASK_GRAPH_REQUEST, goal_id: "synthetic-goal", + summaries: {agent: {items: [ + statusTodo("todo_root", {resume_when: "todo_done:todo_parent"}), + statusTodo("todo_parent", {status: "done", done: true, successor_todo_ids: ["todo_root"], + handoff_note: {from_agent: fromSource, to_agent: toSource, status: "done"}}), + ]}, user: {}}, + max_status_todos_per_role: 8, latest_runs: [], public_texts: values, + fallback_time: "2026-11-02T12:00:00Z", + }); + assert.equal(statusNodes(result).find(node => node.kind === "handoff")?.title, + expectedTitle); + }); +} + +for (const rejected of [null, ""]) { + test(`status refuses normalized handoff identities with rejected source text: ${rejected}`, () => { + assert.throws(() => statusGraph({summaries: {agent: {items: [ + statusTodo("todo_root", {resume_when: "todo_done:todo_parent"}), + statusTodo("todo_parent", {status: "done", done: true, successor_todo_ids: ["todo_root"], + handoff_note: {from_agent: "Builder", to_agent: "Reviewer", status: "done"}}), + ]}, user: {}}, public_texts: {"Builder": {"80": rejected}}}), /Missing public text facts/); + }); +}