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 @@ -150,6 +150,12 @@ without scalar RPCs;
`todo.completion_state.require_metadata` are retired after their production
callers exit. Existing semantic owners execute in process inside the transaction.

Native summary composition now passes rows and succession records directly.
The retired summary RPC's request versions, column codecs and unused handoff
carrier are removed. Full-source succession, field, frontier, lane and closure
checks stay with their existing owners; public read and summary outputs remain
compatible.

CLI query results carry a query-scoped `projection_envelope`. A read grants no
execution, acceptance, lease or mutation authority. The shared wire and private
snapshot budgets are unchanged. Oversized Python facts use an owned temporary
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ Python 保留 registry、文档/材料 IO、独立的 Vision-wait 消费者及
`todo.completion_state.normalize` 与 `todo.completion_state.require_metadata`。
事务在进程内执行既有语义 owner。

原生摘要组合现在直接传递行对象与继任记录。
已退役摘要 RPC 的请求版本、列式 codec 和无调用者的 handoff carrier 一并删除。
完整来源的继任、字段、frontier、lane 与 closure 校验仍由既有 owner 负责;
公开读取与摘要输出保持兼容。

CLI 查询结果携带查询范围内的 `projection_envelope`。读取不授予执行、验收、
租约或修改权限。共享 wire 与私有快照预算不变。超大 Python 事实使用私有临时
源,不增加持久化存储;完整结果通过摘要绑定的片段传输。
Expand Down
6 changes: 6 additions & 0 deletions docs/reference/todo-work-counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ The old `todo.summary.project` transport and Python ordinal materializer are
retired. Large source/result transport preserves complete decisions and refuses
changed continuations; display limits still disclose shown and available counts.

Native summary composition passes row objects and succession records directly.
Its obsolete request-version and column codecs, plus the unused summary handoff
carrier, are removed. Semantic validation remains with the existing owners.
The separate handoff-context interface and public read/summary outputs retain
their contracts.

Route-continuation visibility also uses the existing typed quota planning batch.
It shares the normal claim/exclusion rule; eligibility, first-identity deduplication,
sorting and counts precede the display cap. Disabled candidates do not hide later
Expand Down
13 changes: 3 additions & 10 deletions loopx/control_plane/todos/read_projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@ import {requireJsonObject, requireBoolean} from "../runtime_decode.ts";
import {schedulerTimestampMilliseconds} from "../scheduler/state_store.ts";
import {evaluateTodoResumeConditionsFromSource} from "./resume_condition.ts";
import {readTodoPriority, todoPriorityRank} from "./priority.ts";
import {evaluateTodoSuccession, validateTodoSuccession, SUCCESSION_FACT_COLUMNS,
SUCCESSION_EVALUATION_COLUMNS, SUCCESSION_CONTEXT_FIELDS} from "./succession.ts";
import {projectTodoSummary, TODO_SUMMARY_PROJECTION_COLUMNS} from "./summary_projection.ts";
import {evaluateTodoSuccession, validateTodoSuccession, SUCCESSION_CONTEXT_FIELDS} from "./succession.ts";
import {projectTodoSummary} from "./summary_projection.ts";
import {projectTodoHandoffContext} from "./handoff_projection.ts";
import {projectStandingDecisions} from "./standing_decision.ts";
import {configuredGoalChecks} from "../goals/acceptance_contract.ts";
Expand Down Expand Up @@ -158,14 +157,8 @@ export async function projectTodoReadGroup(input: JsonObject): Promise<JsonObjec
}
const preferred = new Set(Array.isArray(input.preferred_todo_ids) ? input.preferred_todo_ids : []);
const rows = items.map((item, i) => summaryRow(item, decisions[i]!, preferred));
const contexts = facts.map(row => row.context_fields!);
const compacts = items.map(compactRecord);
const projected = projectTodoSummary({schema_version: "todo_summary_projection_request_v3", columns: [...TODO_SUMMARY_PROJECTION_COLUMNS],
rows: rows.map(row => TODO_SUMMARY_PROJECTION_COLUMNS.map(name => row[name]!)),
succession: {schema_version: "todo_succession_request_v1", row_columns: [...SUCCESSION_FACT_COLUMNS],
rows: facts.map((row, i) => SUCCESSION_FACT_COLUMNS.map(name => name === "context_fields" ? i : row[name]!)),
context_field_sets: contexts, evaluation_columns: [...SUCCESSION_EVALUATION_COLUMNS],
evaluations: decisions.map(row => SUCCESSION_EVALUATION_COLUMNS.map(name => row[name]!))},
const projected = projectTodoSummary({rows, succession: {facts, evaluations: decisions},
frontier_rows: input.role === "agent" ? frontierFacts(items) : null,
observed_at: input.observed_at!, selection: input.selection ?? null, role: input.role ?? null,
source_section: input.source_section ?? null, item_limit: input.item_limit ?? null, full_selection: input.full_selection ?? true});
Expand Down
84 changes: 13 additions & 71 deletions loopx/control_plane/todos/summary_projection.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import {projectTodoHandoffContext} from "./handoff_projection.ts";
/** Whole-source summary decisions. The native read owner materializes public
* display records; counts and closure never depend on a presentation budget. */
import type {JsonObject} from "../effect_program.ts";
Expand All @@ -7,7 +6,7 @@ import {requireBoolean, requireJsonObject, requireStringLiteral} from "../runtim
import {parseTodoTimestampMicros} from "../runtime_timestamp.ts";
import {countDeferredTodoWork, projectTodoSummaryLanes, type TodoSummaryLane} from "./summary_lanes.ts";
import {projectSummaryFrontierIndex} from "./frontier_revision.ts";
import {projectTodoClosure, projectTodoSuccession, SUCCESSION_FACT_COLUMNS, SUCCESSION_EVALUATION_COLUMNS} from "./succession.ts";
import {projectTodoClosure, validateTodoSuccession} from "./succession.ts";

type Format = "raw" | "compact" | "active" | "recent" | "gap";
interface DisplayLane {indices: number[]; format: Format}
Expand All @@ -18,57 +17,17 @@ interface SummaryProjection {
fields: JsonObject;
lanes: Record<string, DisplayLane>;
orchestration: {candidate_items: number[]; user_blocker_items: number[]};
handoff_context?: JsonObject[];
}

/** The declared order is this request version's schema, not a hint: the same
* cells in a different order would silently change meaning. */
export const TODO_SUMMARY_PROJECTION_COLUMNS = [
"status", "done", "task_class", "has_resume", "resume_ready", "resume_evaluated",
"acceptance_blocked", "claimed", "preferred", "watch_only", "due_at", "expires_at",
"sort", "completed_at", "updated_at", "completion_index", "linked_user_action",
"no_followup", "successor_gap", "handoff_state", "replan", "todo_id", "claim",
"bound", "blocks", "global", "excluded",
] as const;

/** One whole-source batch carries every Todo, so the co-deployed adapter sends
* columnar facts. Decoding restores the row objects the lane and closure owners
* already validate; nothing is defaulted or inferred from absence. */
function decodeRows(request: JsonObject): JsonObject[] {
const columns = request.columns;
if (!Array.isArray(columns) || columns.length !== TODO_SUMMARY_PROJECTION_COLUMNS.length ||
columns.some((name, index) => name !== TODO_SUMMARY_PROJECTION_COLUMNS[index])) {
throw new EffectRuntimeRequestError("Todo summary row columns do not match the typed adapter order");
}
if (!Array.isArray(request.rows)) {
throw new EffectRuntimeRequestError("Todo summary rows must be a list");
}
return request.rows.map((value, ordinal) => {
if (!Array.isArray(value) || value.length !== columns.length) {
throw new EffectRuntimeRequestError(`Todo summary row ${ordinal} does not match its declared columns`);
}
return Object.fromEntries(TODO_SUMMARY_PROJECTION_COLUMNS.map((name, index) => [name, value[index]]));
});
}

/** Co-deployed callers can fuse evidence validation with summary projection.
* This is the same succession owner, not a cache, graph truncation or bypass.
* Legacy v1 callers retain their existing path; v2 requires the carrier. */
function validateSummarySuccession(request: JsonObject, rows: readonly JsonObject[]): void {
if (request.schema_version === "todo_summary_projection_request_v1") return;
const source = requireJsonObject(request.succession, "summary succession");
if (source.evaluations === undefined) {
throw new EffectRuntimeRequestError("summary requires full-source succession evaluations");
}
const result = projectTodoSuccession(source);
const evaluations = result.evaluations as unknown[][];
const facts = source.rows as unknown[][];
if (evaluations.length !== rows.length) {
if (!Array.isArray(source.facts) || !Array.isArray(source.evaluations) ||
source.facts.length !== rows.length || source.evaluations.length !== rows.length) {
throw new EffectRuntimeRequestError("summary succession cardinality mismatch");
}
for (const [index, row] of rows.entries()) {
const fact = Object.fromEntries(SUCCESSION_FACT_COLUMNS.map((name, column) => [name, facts[index][column]]));
const evaluation = Object.fromEntries(SUCCESSION_EVALUATION_COLUMNS.map((name, column) => [name, evaluations[index][column]]));
const fact = requireJsonObject(source.facts[index], "summary succession facts");
const evaluation = validateTodoSuccession(fact, source.evaluations[index]);
if (row.todo_id !== fact.todo_id || row.status !== fact.status || row.no_followup !== fact.no_followup ||
(row.task_class === "advancement_task") !== fact.advancement ||
row.successor_gap !== evaluation.successor_gap || row.handoff_state !== evaluation.handoff_state ||
Expand Down Expand Up @@ -102,11 +61,6 @@ function claimedVisibility(indices: readonly number[], rows: readonly JsonObject

export function projectTodoSummary(value: unknown): SummaryProjection {
const request = requireJsonObject(value, "Todo summary request");
if (request.schema_version !== "todo_summary_projection_request_v1" &&
request.schema_version !== "todo_summary_projection_request_v2" &&
request.schema_version !== "todo_summary_projection_request_v3") {
throw new EffectRuntimeRequestError("Todo summary request schema mismatch");
}
const role = request.role === null ? null : requireStringLiteral(request.role, ["user", "agent"], "role");
if (request.source_section !== null && typeof request.source_section !== "string") {
throw new EffectRuntimeRequestError("source_section must be text or null");
Expand All @@ -116,13 +70,11 @@ export function projectTodoSummary(value: unknown): SummaryProjection {
throw new EffectRuntimeRequestError("item_limit must be a non-negative integer or null");
}
const full = requireBoolean(request.full_selection, "full_selection");
const rows = decodeRows(request);
if (!Array.isArray(request.rows)) throw new EffectRuntimeRequestError("Todo summary rows must be a list");
const rows = request.rows.map(value => requireJsonObject(value, "Todo summary row"));
validateSummarySuccession(request, rows);
if (request.handoff_sources !== undefined && (!Array.isArray(request.handoff_sources) || request.handoff_sources.length !== rows.length)) {
throw new EffectRuntimeRequestError("summary handoff cardinality mismatch");
}

// The co-deployed adapter sends source facts, not prose or full Todo bodies.
// Source validation precedes every selection and display limit.
for (const row of rows) {
if ((row.claim !== null && typeof row.claim !== "string") || row.claimed !== Boolean(row.claim)) {
throw new EffectRuntimeRequestError("summary claimant facts disagree");
Expand Down Expand Up @@ -154,12 +106,10 @@ export function projectTodoSummary(value: unknown): SummaryProjection {
})), selected.deferred_items.length),
};
const lanes: Record<string, DisplayLane> = {};
if (request.schema_version === "todo_summary_projection_request_v3") {
if (role === "agent") {
fields.advancement_frontier_revision_index = projectSummaryFrontierIndex(request.frontier_rows, rows, source);
} else if (request.frontier_rows !== null) {
throw new EffectRuntimeRequestError("non-Agent summary must omit frontier source facts");
}
if (role === "agent") {
fields.advancement_frontier_revision_index = projectSummaryFrontierIndex(request.frontier_rows, rows, source);
} else if (request.frontier_rows !== null) {
throw new EffectRuntimeRequestError("non-Agent summary must omit frontier source facts");
}
const lane = (name: string, indices: readonly number[], cap: number | null = null, format: Format = "compact") => {
lanes[name] = {indices: cap === null ? [...indices] : indices.slice(0, cap), format};
Expand Down Expand Up @@ -233,15 +183,7 @@ export function projectTodoSummary(value: unknown): SummaryProjection {
Object.assign(fields, projectTodoClosure({schema_version: "todo_closure_request_v0", role,
source_section: request.source_section, full_selection: fullSelection, rows: source.map(index => rows[index])}));
return {schema_version: "todo_summary_projection_v0",
...(request.handoff_sources === undefined ? {} : {handoff_context: projectTodoHandoffContext(
(request.handoff_sources as unknown[]).map((value, index) => {
const context = requireJsonObject(value, "summary handoff source");
// Reuse the same normalized source facts, rather than sending another
// Todo identity/claim/exclusion/class copy for every historical row.
return {...context, metadata: {...requireJsonObject(context.metadata ?? {}, "handoff metadata"),
todo_id: rows[index].todo_id, claimed_by: rows[index].claim, excluded_agents: rows[index].excluded},
texts: {...requireJsonObject(context.texts ?? {}, "handoff texts"), task_class: rows[index].task_class}};
}))}), source_indices: source, full_selection: fullSelection, fields, lanes,
source_indices: source, full_selection: fullSelection, fields, lanes,
orchestration: {
candidate_items: role === "agent" ? selected.projected_open_items.filter(index => rows[index].task_class === "advancement_task") : [],
user_blocker_items: role === "user" ? selected.projected_open_items.filter(index => rows[index].linked_user_action === true) : [],
Expand Down
Loading
Loading