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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2664,6 +2664,13 @@ The original direction remains; execution cards expand these stages rather than

#### Durability execution cards

The task graph's T3 topology consumer now shares the inventory/horizon relation
catalog and consumes one supplied status snapshot. Its missing/truncated metrics
describe read completeness, not canonical validity or promotion qualification.
File/SQLite reader replay with a missing Markdown display must remain read-only;
the graph never repairs display or changes authority. This retires duplicate
Python relationship/traversal knowledge without changing the D1–D3 gates below.

Capability-gap consumers now share the TS requirement/resolution owner across
legacy and canonical inputs, including quota's Monitor capability partition.
The old Python missing-set and owner/repair decision builders are removed;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2111,6 +2111,12 @@ selector 见 TS RFC 的 T3 卡。真实 FileAuthorityStore CLI 测试覆盖展

#### 持久化执行卡

Task graph 的 T3 topology consumer 现共用 inventory/horizon 关系目录,消费
一次已提供的 status 快照。缺失/截断指标描述读取完整度,不代表 canonical
有效性或 promotion 资格。File/SQLite 在 Markdown 展示缺失时的 reader 回放
必须只读:图不修复展示,也不改变 authority。本批删除 Python 重复关系与
遍历知识,不改变以下 D1–D3 门禁。

命令清单、update/monitor 事务和 consumer 删除统一按
[TS 执行卡](typescript-control-plane-migration-v0.zh-CN.md#当前-stack-合入后的执行卡)
推进,不在这里复制第二套实现路线,也不把 read-policy PR 合并视为存储就绪。
Expand Down
12 changes: 12 additions & 0 deletions docs/architecture/rfcs/typescript-control-plane-migration-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,18 @@ delivery. This does not finish all T2 commands or authorize whole-Goal promotion

**T3 — close remaining structured consumers, then remove their old reads.**

Task-graph topology now shares `work_items/planning_relations.ts` with inventory
and horizon. One pure TS request owns relationship discovery, deterministic
bounded traversal, edge deduplication and missing/truncated completeness; the
Python predecessor indexes, condition parser and traversal are retired. Python
retains status source adaptation and public-safe node/evidence/handoff rendering.
This intentionally distinguishes successor lineage from completion dependencies,
corrects unblocks direction, includes Monitor generation conditions and preserves
parallel/diamond edges at the node cap. See the [graph contract](../../reference/protocols/task-graph-projection-v0.md#typed-todo-topology).
It does not change lifecycle admission, claim/lease semantics or default provider.
The status source can still be incomplete: this closes one T3 interpretation
boundary, not all graph source delivery or the remaining T1–T4 work.

Capability resolution now shares `agents/capability_gate.ts`: missing prerequisites,
repair outputs, owner/agent resolution and blocked-Todo bindings have one typed
owner. Quota planning v1 passes normalized requirements, not Python-computed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,15 @@ promotion 已完成。

**T3 — 闭合剩余 structured consumer,删除各自旧读路径。**

Task graph topology 与 inventory/horizon 共用 `work_items/planning_relations.ts`。
一轮纯 TS 请求拥有关系发现、稳定有界遍历、边去重与缺失/截断完整度;删除
Python 的前驱索引、条件拆解和遍历。Python 保留 status 来源适配及节点、
evidence/handoff 的脱敏展示。明确的语义修正:successor 谱系不再冒充完成
依赖,unblocks 方向修正,补 Monitor generation 条件,上限处保留平行关系
和菱形汇合边。详见[图协议](../../reference/protocols/task-graph-projection-v0.md#typed-todo-topology)。
不改变生命周期准入、claim/lease 或默认 provider。来源仍可能不完整:本批
闭合一个 T3 解释边界,不宣称所有图来源交付或 T1–T4 已完成。

Quota 的 scope/claim 消费者现通过每个 source 一次 `todo.quota_planning.project`,
组合选择、有限展示与既有 resume planner。`quota_selection.ts` 替代 Python
claim-visibility 模块及 Agent-scope 中独立的 User gate/action 过滤器。Python
Expand Down
41 changes: 40 additions & 1 deletion docs/reference/protocols/task-graph-projection-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,46 @@ compact blocker or validation writebacks:
- `audits` says compact run-history evidence reviews, checks, or bounds a
selected work lane.
- `continues` says compact run-history evidence is a continuation of a selected
work lane.
work lane, or a Todo follows a predecessor through explicit successor lineage.

### Typed Todo topology

Planning inventory, horizon and task graph share the TS `planning_relations`
catalog. The graph is a different **read lens**, not another lifecycle reducer:

| Persisted relation | Graph direction and label | Meaning |
| --- | --- | --- |
| A has successor B | B → A, `continues` | Lineage only; does not require A to complete |
| A is superseded by B | B → A, `supersedes` | Lineage only; does not authorize a transition |
| A unblocks B | B → A, `depends_on` | Typed lifecycle link, not the reverse dependency |
| A resumes when B is done | A → B, `depends_on` | Completion condition; the resume evaluator owns readiness |
| A resumes when Monitor M changes | A → M, `depends_on` | Generation-change condition, not Monitor completion |

These are intentional corrections to the old graph, which collapsed successor
lineage into dependencies, reversed unblocks discovery, and omitted Monitor
conditions. Parallel lineage and condition edges are retained. Opaque route,
capability and unknown-condition suffixes must not be interpreted as Todo IDs
by either graph or horizon. Existing read-only node kinds, status normalization,
claim presentation and evidence/handoff renderers are unchanged.

The predecessor lens expands the selected Todo and completed predecessors;
open predecessors are visible boundaries, not recursive traversal roots.
It admits at most four predecessor nodes, in deterministic breadth-first and
Todo-ID order. Cycles do not duplicate nodes. Reaching the node cap must not
discard another edge between already admitted nodes (including diamond joins).

`limits.missing_predecessor_count` counts unique referenced predecessors absent
from the supplied renderable snapshot. `source_truncated` records upstream
omission; `predecessor_truncated` records display-limit omission. The additive
`topology_complete` flag is true only if none of those conditions applies,
**within this expansion policy**, not for the entire Goal graph. No missing
target creates a phantom node, a provider read, a repair write, or an execution
permission. No extra complete-state read is introduced: status supplies its
existing source, and an incomplete summary stays explicitly incomplete.

中文:谱系不等于依赖;Monitor 的 generation 条件不等于完成 Monitor。
节点上限不应吞掉已展示节点间的边。完整度只针对上述有界展开策略,不能
把缺失、上游裁剪或展示裁剪说成完整 Goal 图;图始终没有写入或准入权限。

These relations may help a dashboard or reviewer explain why a work item is
still active, stale, repaired, or safe to hand off. They must not create a graph
Expand Down
16 changes: 8 additions & 8 deletions examples/control_plane/task-graph-projection-fixture-smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,10 +482,10 @@ def assert_diamond_dag_predecessor_edges() -> None:
a_id = node_ids["todo_a"]
b_id = node_ids["todo_b"]
shared_id = node_ids["todo_shared"]
assert (root_id, a_id, "depends_on") in edge_pairs, edge_pairs
assert (root_id, b_id, "depends_on") in edge_pairs, edge_pairs
assert (a_id, shared_id, "depends_on") in edge_pairs, edge_pairs
assert (b_id, shared_id, "depends_on") in edge_pairs, edge_pairs
assert (root_id, a_id, "continues") in edge_pairs, edge_pairs
assert (root_id, b_id, "continues") in edge_pairs, edge_pairs
assert (a_id, shared_id, "continues") in edge_pairs, edge_pairs
assert (b_id, shared_id, "continues") in edge_pairs, edge_pairs
assert projection["limits"]["predecessor_truncated"] is False


Expand Down Expand Up @@ -700,15 +700,15 @@ def assert_cycle_predecessor_safety() -> None:
# Should not have exploded; should have exactly 3 deliverable nodes
deliverable_nodes = [n for n in projection["nodes"] if n["kind"] == "deliverable"]
assert len(deliverable_nodes) == 3, deliverable_nodes
# root->a and a->b edges should exist; b->a is correctly skipped due to cycle detection
# All lineage edges survive; visited nodes, not edges, bound cycle traversal.
edge_pairs = {(e["from_node_id"], e["to_node_id"], e["relation"]) for e in projection["edges"]}
node_ids = {n["refs"]["todo_ids"][0]: n["node_id"] for n in deliverable_nodes}
root_id = node_ids["todo_cycle_root"]
a_id = node_ids["todo_cycle_a"]
b_id = node_ids["todo_cycle_b"]
assert (root_id, a_id, "depends_on") in edge_pairs
assert (a_id, b_id, "depends_on") in edge_pairs
assert (b_id, a_id, "depends_on") in edge_pairs
assert (root_id, a_id, "continues") in edge_pairs
assert (a_id, b_id, "continues") in edge_pairs
assert (b_id, a_id, "continues") in edge_pairs


def main() -> int:
Expand Down
2 changes: 2 additions & 0 deletions loopx/control_plane/effect_runtime_handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ import {
qualifyActionSelection,
} from "./work_items/action_portfolio.ts";
import { projectQuotaPlanningHorizon } from "./work_items/planning_horizon.ts";
import { projectTaskGraphTopology } from "./work_items/task_graph.ts";
import { projectDeliveryHistory, projectDeliveryResponse } from "./work_items/delivery_history.ts";
import { validateDeliveryClaim } from "./work_items/delivery_outcome.ts";
import {
Expand Down Expand Up @@ -426,6 +427,7 @@ export function createEffectRuntimeHandlers(
["work_item.action_portfolio.project", projectQuotaActionPortfolio],
["work_item.action_selection.qualify", qualifyActionSelection],
["work_item.planning_horizon.project", projectQuotaPlanningHorizon],
["work_item.task_graph.topology", projectTaskGraphTopology],
["work_item.planning_inventory.project", projectTodoPlanningInventory],
["work_item.planning_inventory.detail", projectTodoPlanningInventoryDetail],
["work_item.refresh_recommendation.resolve", resolveRefreshRecommendation],
Expand Down
4 changes: 1 addition & 3 deletions loopx/control_plane/work_items/planning_horizon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,10 @@ import {
} from "../runtime_decode.ts";
import {
decodeTodoPlanningInventory,
relationKey,
todoRef,
type PlanningInventoryItem,
type PlanningInventoryRelation,
type PlanningState,
} from "./planning_inventory.ts";
import { relationKey, todoRef, type PlanningInventoryRelation } from "./planning_relations.ts";

import type { JsonObject } from "../effect_program.ts";

Expand Down
84 changes: 4 additions & 80 deletions loopx/control_plane/work_items/planning_inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ export const TODO_PLANNING_INVENTORY_DETAIL_SCHEMA_VERSION =

const MAX_REQUEST_ITEMS_PER_LANE = 128;
const MAX_INVENTORY_ITEMS = 64;
const TODO_ID = /^todo_[A-Za-z0-9_-]{3,80}$/;

export type PlanningState =
| "selected"
Expand All @@ -30,24 +29,10 @@ export type PlanningState =

export type ClaimState = "current_agent" | "unclaimed" | "other_agent";

const ENFORCEMENT_BY_RELATION = {
successor: "lineage_only",
unblocks: "typed_lifecycle",
resumes_when: "typed_condition",
superseded_by: "lineage_only",
routes_via: "read_only_context",
} as const;

export type PlanningRelationKind = keyof typeof ENFORCEMENT_BY_RELATION;

export type PlanningInventoryRelation = {
[Kind in PlanningRelationKind]: JsonObject & {
from_todo_id: string;
to_ref: string;
relation: Kind;
enforcement: (typeof ENFORCEMENT_BY_RELATION)[Kind];
};
}[PlanningRelationKind];
import {
projectRelations, relation, ENFORCEMENT_BY_RELATION, PLANNING_TODO_ID as TODO_ID,
type PlanningInventoryRelation, type PlanningRelationKind,
} from "./planning_relations.ts";

interface Candidate extends JsonObject {
todo_id: string;
Expand Down Expand Up @@ -211,67 +196,6 @@ function planningState(
return "context";
}

function relation<Kind extends PlanningRelationKind>(
fromTodoId: string,
toRef: string,
kind: Kind,
): Extract<PlanningInventoryRelation, { relation: Kind }> {
return {
from_todo_id: fromTodoId,
to_ref: toRef,
relation: kind,
enforcement: ENFORCEMENT_BY_RELATION[kind],
} as Extract<PlanningInventoryRelation, { relation: Kind }>;
}

function candidateRelations(item: Candidate): PlanningInventoryRelation[] {
const projected: PlanningInventoryRelation[] = [];
for (const successor of new Set(item.successor_todo_ids)) {
if (successor !== item.todo_id) {
projected.push(relation(item.todo_id, successor, "successor"));
}
}
if (item.unblocks_todo_id && item.unblocks_todo_id !== item.todo_id) {
projected.push(relation(item.todo_id, item.unblocks_todo_id, "unblocks"));
}
if (item.resume_when) {
projected.push(relation(item.todo_id, item.resume_when, "resumes_when"));
}
if (item.superseded_by && item.superseded_by !== item.todo_id) {
projected.push(relation(item.todo_id, item.superseded_by, "superseded_by"));
}
const routeRef = item.route_id || item.route_key;
if (routeRef) {
projected.push(relation(item.todo_id, `route:${routeRef}`, "routes_via"));
}
return projected;
}

export function relationKey(value: PlanningInventoryRelation): string {
return `${value.from_todo_id}\u0000${value.relation}\u0000${value.to_ref}`;
}

function projectRelations(items: readonly Candidate[]): PlanningInventoryRelation[] {
const projected: PlanningInventoryRelation[] = [];
const seen = new Set<string>();
for (const item of items) {
for (const value of candidateRelations(item)) {
const key = relationKey(value);
if (seen.has(key)) continue;
seen.add(key);
projected.push(value);
}
}
return projected;
}

export function todoRef(value: string): string | null {
if (TODO_ID.test(value)) return value;
const separator = value.indexOf(":");
if (separator < 0) return null;
const suffix = value.slice(separator + 1);
return TODO_ID.test(suffix) ? suffix : null;
}

function inventoryItem(
item: Candidate,
Expand Down
97 changes: 97 additions & 0 deletions loopx/control_plane/work_items/planning_relations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { JsonObject } from "../effect_program.ts";
import { normalizeTodoResumeWhen, TODO_RESUME_NORMALIZE_REQUEST_SCHEMA_VERSION } from "../todos/resume_condition.ts";

export const PLANNING_TODO_ID = /^todo_[A-Za-z0-9_-]{3,80}$/;

export const ENFORCEMENT_BY_RELATION = {
successor: "lineage_only",
unblocks: "typed_lifecycle",
resumes_when: "typed_condition",
superseded_by: "lineage_only",
routes_via: "read_only_context",
} as const;

export type PlanningRelationKind = keyof typeof ENFORCEMENT_BY_RELATION;

export type PlanningInventoryRelation = {
[Kind in PlanningRelationKind]: JsonObject & {
from_todo_id: string;
to_ref: string;
relation: Kind;
enforcement: (typeof ENFORCEMENT_BY_RELATION)[Kind];
};
}[PlanningRelationKind];

export function relation<Kind extends PlanningRelationKind>(
fromTodoId: string,
toRef: string,
kind: Kind,
): Extract<PlanningInventoryRelation, { relation: Kind }> {
return {
from_todo_id: fromTodoId,
to_ref: toRef,
relation: kind,
enforcement: ENFORCEMENT_BY_RELATION[kind],
} as Extract<PlanningInventoryRelation, { relation: Kind }>;
}

function candidateRelations(item: PlanningRelationSource): PlanningInventoryRelation[] {
const projected: PlanningInventoryRelation[] = [];
for (const successor of new Set(item.successor_todo_ids)) {
if (successor !== item.todo_id) {
projected.push(relation(item.todo_id, successor, "successor"));
}
}
if (item.unblocks_todo_id && item.unblocks_todo_id !== item.todo_id) {
projected.push(relation(item.todo_id, item.unblocks_todo_id, "unblocks"));
}
if (item.resume_when) {
projected.push(relation(item.todo_id, item.resume_when, "resumes_when"));
}
if (item.superseded_by && item.superseded_by !== item.todo_id) {
projected.push(relation(item.todo_id, item.superseded_by, "superseded_by"));
}
const routeRef = item.route_id || item.route_key;
if (routeRef) {
projected.push(relation(item.todo_id, `route:${routeRef}`, "routes_via"));
}
return projected;
}

export function relationKey(value: PlanningInventoryRelation): string {
return `${value.from_todo_id}\u0000${value.relation}\u0000${value.to_ref}`;
}

export function projectRelations(items: readonly PlanningRelationSource[]): PlanningInventoryRelation[] {
const projected: PlanningInventoryRelation[] = [];
const seen = new Set<string>();
for (const item of items) {
for (const value of candidateRelations(item)) {
const key = relationKey(value);
if (seen.has(key)) continue;
seen.add(key);
projected.push(value);
}
}
return projected;
}

export function todoRef(value: string): string | null {
if (PLANNING_TODO_ID.test(value)) return value;
const condition = normalizeTodoResumeWhen({
schema_version: TODO_RESUME_NORMALIZE_REQUEST_SCHEMA_VERSION, resume_when: value,
});
if (!condition) return null;
const [kind, target] = condition.split(":");
return kind === "todo_done" || kind === "monitor_changed" ? target : null;
}

export interface PlanningRelationSource {
todo_id: string;
successor_todo_ids: string[];
unblocks_todo_id?: string;
resume_when?: string;
superseded_by?: string;
route_id?: string;
route_key?: string;
}
Loading