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 @@ -5,6 +5,7 @@ import {
parsePresentationSurfaceCollectionResponse,
presentationSurfaceCollectionSchema,
presentationSurfaceSchema,
todoItemSchema,
withGoalActivationState,
} from "../src/data/status.js";
import {
Expand All @@ -23,6 +24,29 @@ function assert(condition: boolean, message: string) {
const PAYLOAD_SHA256 =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";

const revisedTodo = todoItemSchema.parse({
index: 0,
text: "Verify the replacement validator",
done: false,
todo_id: "todo_validator_revision",
role: "agent",
completion_validation_required: true,
completion_validation_sha256: PAYLOAD_SHA256,
completion_validation_revision: 1,
completion_validation_revision_history: [{
revision: 1,
previous_declaration_sha256: "1".repeat(64),
declaration_sha256: PAYLOAD_SHA256,
actor_agent_id: "agent-a",
revised_at: "2026-09-20T00:00:00Z",
}],
});
assert(
revisedTodo.completion_validation_revision_history.at(-1)?.actor_agent_id ===
"agent-a",
"Todo validator revision readback must survive status parsing",
);

function detailRef() {
return {
extension_id: "test-research-extension",
Expand Down
10 changes: 10 additions & 0 deletions apps/presentation/dashboard/src/data/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,16 @@ export const todoItemSchema = z.object({
note: z.string().optional().nullable(),
evidence: z.string().optional().nullable(),
updated_at: z.string().optional().nullable(),
completion_validation_required: z.boolean().optional().nullable(),
completion_validation_sha256: z.string().optional().nullable(),
completion_validation_revision: z.number().int().nonnegative().optional().nullable(),
completion_validation_revision_history: z.array(z.object({
revision: z.number().int().positive(),
previous_declaration_sha256: z.string(),
declaration_sha256: z.string(),
actor_agent_id: z.string(),
revised_at: z.string(),
}).passthrough()).optional().default([]),
review_materials: z.array(reviewMaterialSchema).optional().default([]),
}).passthrough();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,11 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
{selection.item.status === "deferred" || selection.item.resumeWhen ? <div><dt>{t("drawer.resumeWhen")}</dt><dd>{selection.item.resumeWhen || t("drawer.notSet")}</dd></div> : null}
{selection.item.resumeWhen ? <div><dt>{t("drawer.resumeState")}</dt><dd>{selection.item.resumeReady ? t("drawer.resumeReady") : t("drawer.resumePending")}</dd></div> : null}
{selection.item.resumeReceiptId ? <div><dt>{t("drawer.resumeReceipt")}</dt><dd>{selection.item.resumeReceiptId}</dd></div> : null}
{selection.item.validationDigest ? <>
<div><dt>{t("drawer.validationRevision")}</dt><dd>{selection.item.validationRevision ?? 0}</dd></div>
<div><dt>{t("drawer.validationDigest")}</dt><dd><code>{selection.item.validationDigest}</code></dd></div>
{selection.item.validationRevisionActor ? <div><dt>{t("drawer.validationRevisionActor")}</dt><dd>{selection.item.validationRevisionActor}</dd></div> : null}
</> : null}
<div><dt>{t("drawer.nextTransition")}</dt><dd>{selection.item.nextTransition ?? (selection.item.done ? t("drawer.taskNextCompleted") : selection.item.resumeReady ? t("drawer.taskNextResumeReady") : selection.item.status === "deferred" ? t("drawer.taskNextDeferred") : t("drawer.taskNextOpen"))}</dd></div>
</dl>
</section>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,9 @@ const en = {
"drawer.moreActions": "More actions",
"drawer.moreRunActions": "More run actions",
"drawer.nextTransition": "Next transition",
"drawer.validationRevision": "Validation revision",
"drawer.validationDigest": "Validation declaration digest",
"drawer.validationRevisionActor": "Revised by",
"drawer.noExecutionHistory": "No execution history yet.",
"drawer.noRun": "Execution Session has not started",
"drawer.noRunDescription": "Run records will appear here after an Agent starts execution.",
Expand Down Expand Up @@ -1306,6 +1309,9 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"drawer.moreActions": "更多操作",
"drawer.moreRunActions": "更多运行操作",
"drawer.nextTransition": "下一转换",
"drawer.validationRevision": "验证声明修订",
"drawer.validationDigest": "验证声明摘要",
"drawer.validationRevisionActor": "修订者",
"drawer.noExecutionHistory": "暂无执行记录。",
"drawer.noRun": "尚未启动执行 Session",
"drawer.noRunDescription": "Agent 开始执行后,运行记录会稳定显示在这里。",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ export type WorkspaceAgentTodo = {
taskDomain?: string | null;
text: string;
todoId: string;
validationDigest?: string | null;
validationRevision?: number | null;
validationRevisionActor?: string | null;
};

export type WorkspaceTodo = WorkspaceAgentTodo & {
Expand Down
7 changes: 7 additions & 0 deletions apps/presentation/dashboard/src/views/dashboard-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,9 @@ type PersonalAgentTodoItem = {
taskDomain?: string | null;
text: string;
todoId: string;
validationDigest?: string | null;
validationRevision?: number | null;
validationRevisionActor?: string | null;
};

function inferLifecyclePhase(status?: string | null, run?: RunRecord) {
Expand Down Expand Up @@ -722,6 +725,7 @@ function personalTodoResumeReceiptId(todo: TodoItem) {
}

function personalAgentTodoFromItem(todo: TodoItem, row: GoalDirectoryRow): PersonalAgentTodoItem {
const latestValidationRevision = todo.completion_validation_revision_history.at(-1);
return {
resumeWhen: todo.resume_when ?? null,
resumeReady: todo.resume_ready ?? null,
Expand All @@ -737,6 +741,9 @@ function personalAgentTodoFromItem(todo: TodoItem, row: GoalDirectoryRow): Perso
taskDomain: todo.task_domain ?? null,
text: personalTodoText(todo),
todoId: todo.todo_id?.trim() || `${row.goal.id}:agent:${todo.index}`,
validationDigest: todo.completion_validation_sha256 ?? null,
validationRevision: todo.completion_validation_revision ?? null,
validationRevisionActor: latestValidationRevision?.actor_agent_id ?? null,
};
}

Expand Down
44 changes: 44 additions & 0 deletions docs/reference/canonical-todo-completion-update.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,50 @@ uses a new id (omitting the id generates one). `--dry-run` validates and preview
without running declared validation, writing a receipt, or delivering a display.
Agent completion continues to require `loopx todo complete`.

## Revising an open Todo validator

A promoted Goal may replace an open, active Todo's declared completion
validator without recreating the Todo. Read the current provider revision, then
send the replacement as a dedicated reviewed edit:

```sh
loopx todo update --goal-id example --todo-id todo_observation \
--agent-id agent-a \
--validation-command-json '["python3","-m","pytest","-q","tests/new_test.py"]' \
--validation-label 'focused validation' \
--update-operation-id revise-validator-1 \
--update-expected-provider-revision file:42
```

The TypeScript transaction compares the current declaration digest, commits the
new digest, monotonic revision and public-safe audit receipt under one provider
CAS, and rejects terminal, archived or stale edits. The Python boundary stores
the private command declaration only after provider success and verifies its
readback. Reuse the same operation id, expected revision and replacement after
a lost response; a different intent requires a new operation id and a fresh
read. Validator replacement cannot be combined with another Todo edit.

Completion receipts for a revised validator bind the current declaration
digest. A receipt issued for the previous command, or an unbound legacy
receipt, cannot satisfy the replacement. CLI and managed Turn use the same
facade. The Dashboard Todo details show the current revision, digest and last
actor; it is readback only, so no second editor or Lark-specific authority is
introduced.

## 修改开放 Todo 的验证器

已晋升 Goal 可以在不重建 Todo 的前提下替换开放且仍 active 的完成验证器。调用方先
读取当前 provider revision,再把新命令作为独立的 reviewed edit 提交。TypeScript
事务在同一次 provider CAS 中核对旧声明摘要,并提交新摘要、单调递增的 revision 和
公开安全的审计回执;已完成、已归档或基于旧 revision 的修改会被拒绝。Python 边界
只在 provider 成功后保存私有命令声明,并校验读回结果。丢失响应时复用相同的
operation id、expected revision 和替换内容;新的意图必须使用新的 operation id 并
重新读取。验证器修改不能和其他 Todo 编辑合并提交。

修改后的完成回执必须绑定当前声明摘要,因此旧命令产生的回执或未绑定摘要的历史
回执都不能完成新验证器。CLI 与 managed Turn 复用同一 facade;Dashboard 的 Todo
详情只读展示 revision、digest 与最后修改者,不新增第二套编辑权威或 Lark 专用状态。

## One edit, one terminal transaction

The update decoder, authoring planner and record materializer are shared with
Expand Down
4 changes: 4 additions & 0 deletions loopx/cli_commands/todo.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,10 @@ def handle_todo_command(
status=args.status,
role=args.role,
note=args.note,
validation_command=args.validation_command,
validation_command_json=args.validation_command_json,
validation_label=args.validation_label,
validation_timeout_seconds=args.validation_timeout_seconds,
evidence=args.evidence,
reason=args.reason,
task_class=args.task_class,
Expand Down
34 changes: 34 additions & 0 deletions loopx/cli_commands/todo_argument_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@
"global_gate", "clear_global_gate", "unblocks_todo_id", "successor_todo_ids",
"resume_when", "clear_resume_when", "no_follow_up", "monitor_target_key",
"cadence", "next_due_at", "expires_at", "watch_only", "clear_claim",
"validation_command", "validation_command_json", "validation_label",
"validation_timeout_seconds",
)

_TODO_UPDATE_UNSUPPORTED_FIELDS = (
Expand Down Expand Up @@ -370,6 +372,38 @@ def validate_todo_update_options(args: argparse.Namespace) -> None:
)
if not any(getattr(args, field) for field in _TODO_UPDATE_MUTABLE_FIELDS):
raise ValueError("todo update requires at least one mutable todo field")
validation_fields = (
args.validation_command,
args.validation_command_json,
args.validation_label,
args.validation_timeout_seconds,
)
if any(value is not None for value in validation_fields):
if bool(args.validation_command) == bool(args.validation_command_json):
raise ValueError(
"todo update validation revision requires exactly one of "
"--validation-command or --validation-command-json"
)
if not args.update_operation_id or not args.update_expected_provider_revision:
raise ValueError(
"todo update validation revision requires --update-operation-id "
"and --update-expected-provider-revision"
)
if not args.agent_id:
raise ValueError(
"todo update validation revision requires a registered --agent-id"
)
other_fields = tuple(
field for field in _TODO_UPDATE_MUTABLE_FIELDS
if field not in {
"validation_command", "validation_command_json",
"validation_label", "validation_timeout_seconds",
}
)
if any(getattr(args, field) for field in other_fields):
raise ValueError(
"todo update validation revision cannot be combined with another Todo edit"
)
if args.no_follow_up and not (args.note or args.reason or args.evidence):
raise ValueError("--no-follow-up requires --note, --reason, or --evidence")
for field, message in _TODO_UPDATE_UNSUPPORTED_FIELDS:
Expand Down
13 changes: 8 additions & 5 deletions loopx/cli_commands/todo_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ def register_todo_command(
todo_parser.add_argument("--todo-id", help="Structured todo id from status/quota, such as todo_ab12cd34ef56.")
todo_parser.add_argument(
"--update-operation-id",
help=("For promoted text/note, planning or User completion update, reuse this operation id after a lost response; "
help=("For promoted text/note, planning, validator revision or User completion update, reuse this operation id after a lost response; "
"changed intent is rejected. Planning supports status, evidence, reason, resume conditions and successor links; "
"User status=done uses terminal validation and lease release; other leased status changes remain unsupported."),
)
Expand Down Expand Up @@ -104,8 +104,9 @@ def register_todo_command(
help=(
"Caller-approved validation command (no shell) to run before a "
"todo's completion commits, e.g. 'pytest -q tests/test_x.py'. Set "
"on `todo add`; completion runs it independently and blocks on a "
"non-zero exit."
"on `todo add`, or replace it on a promoted open Todo with `todo "
"update` plus operation id, provider revision and agent id; completion "
"runs it independently and blocks on a non-zero exit."
),
)
todo_parser.add_argument(
Expand All @@ -118,7 +119,8 @@ def register_todo_command(
"Trusted JSON string array (argv form, no shell parsing) for the "
"completion validation command, e.g. '[\"pytest\",\"-q\",\"tests/"
"test_x.py\"]'. Mutually exclusive with --validation-command; set "
"on `todo add`."
"on `todo add`, or replace it through the reviewed promoted `todo "
"update` path."
),
)
todo_parser.add_argument(
Expand All @@ -127,7 +129,8 @@ def register_todo_command(
help=(
"Per-todo timeout for the caller-approved validation command. "
"Only meaningful with --validation-command or "
"--validation-command-json on `todo add`; must be 1-29 so a "
"--validation-command-json on `todo add` or validator revision; "
"must be 1-29 so a "
"timed-out validation still produces a typed receipt inside the "
"30s outer subprocess budget. Defaults to 20."
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ export const COORDINATION_STATE_CONTRACT = deepFreeze({
"superseded_by",
"completion_validation_required",
"completion_validation_sha256",
"completion_validation_revision",
"completion_validation_revision_history",
"handoff_note"
],
"required_fields": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ def _freeze(value: Any) -> Any:
'superseded_by',
'completion_validation_required',
'completion_validation_sha256',
'completion_validation_revision',
'completion_validation_revision_history',
'handoff_note'],
'required_fields': ['schema_version',
'todo_id',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@
"superseded_by",
"completion_validation_required",
"completion_validation_sha256",
"completion_validation_revision",
"completion_validation_revision_history",
"handoff_note"
],
"required_fields": [
Expand Down
17 changes: 15 additions & 2 deletions loopx/control_plane/coordination/local_authority_runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {createHash} from "node:crypto";

import type { JsonObject } from "../effect_program.ts";
import {decodeMonitorPollObservation} from "../todos/monitor_metadata.ts";
import {decodeCompletionValidationRevision} from "../todos/completion_validation_revision.ts";
import {executeCoordinationMonitorPoll, COORDINATION_MONITOR_POLL_REQUEST_SCHEMA, COORDINATION_LEASED_MONITOR_POLL_REQUEST_SCHEMA, COORDINATION_WITNESSED_MONITOR_POLL_REQUEST_SCHEMA,
COORDINATION_MONITOR_POLL_RESULT_SCHEMA} from "./todo_monitor_poll.ts";
import { requireJsonObject } from "../runtime_decode.ts";
Expand Down Expand Up @@ -71,6 +72,7 @@ import {
COORDINATION_TODO_REVIEWED_UPDATE_REQUEST_SCHEMA,
COORDINATION_TODO_COMPLETION_UPDATE_REQUEST_SCHEMA,
COORDINATION_TODO_OBSERVATION_UPDATE_REQUEST_SCHEMA,
COORDINATION_TODO_VALIDATION_REVISION_REQUEST_SCHEMA,
COORDINATION_TODO_UPDATE_RESULT_SCHEMA,
executeCoordinationTodoUpdate,
} from "./todo_update.ts";
Expand Down Expand Up @@ -1080,7 +1082,8 @@ export async function updateLocalCoordinationTodo(
input.schema_version !== COORDINATION_TODO_PLANNING_UPDATE_REQUEST_SCHEMA &&
input.schema_version !== COORDINATION_TODO_REVIEWED_UPDATE_REQUEST_SCHEMA &&
input.schema_version !== COORDINATION_TODO_COMPLETION_UPDATE_REQUEST_SCHEMA &&
input.schema_version !== COORDINATION_TODO_OBSERVATION_UPDATE_REQUEST_SCHEMA) {
input.schema_version !== COORDINATION_TODO_OBSERVATION_UPDATE_REQUEST_SCHEMA &&
input.schema_version !== COORDINATION_TODO_VALIDATION_REVISION_REQUEST_SCHEMA) {
throw new TypeError("local coordination Todo update request schema mismatch");
}
const planningIntent = input.planning_intent == null ? undefined :
Expand All @@ -1091,6 +1094,7 @@ export async function updateLocalCoordinationTodo(
}
const completionUpdate = input.schema_version === COORDINATION_TODO_COMPLETION_UPDATE_REQUEST_SCHEMA;
const observationUpdate = input.schema_version === COORDINATION_TODO_OBSERVATION_UPDATE_REQUEST_SCHEMA;
const validationRevisionUpdate = input.schema_version === COORDINATION_TODO_VALIDATION_REVISION_REQUEST_SCHEMA;
if (!observationUpdate && Object.hasOwn(input, "monitor_observation")) {
throw new TypeError("Monitor observation payload requires request v4");
}
Expand All @@ -1099,7 +1103,14 @@ export async function updateLocalCoordinationTodo(
throw new TypeError("Todo completion payload requires request v3");
}
if (completionUpdate && input.completion == null) throw new TypeError("Todo completion update requires its completion payload");
const reviewed = observationUpdate || completionUpdate || input.schema_version === COORDINATION_TODO_REVIEWED_UPDATE_REQUEST_SCHEMA;
if (!validationRevisionUpdate && Object.hasOwn(input, "completion_validation_revision")) {
throw new TypeError("Completion validation revision payload requires request v5");
}
if (validationRevisionUpdate && input.completion_validation_revision == null) {
throw new TypeError("Completion validation revision update requires its revision payload");
}
const reviewed = observationUpdate || completionUpdate || validationRevisionUpdate ||
input.schema_version === COORDINATION_TODO_REVIEWED_UPDATE_REQUEST_SCHEMA;
if (!reviewed && ["lifecycle_grants", "authority_reason", "registry_source",
"expected_provider_revision", "expected_registry_sha256"].some(field => Object.hasOwn(input, field))) {
throw new TypeError("Todo update admission and revision fields require request v2");
Expand Down Expand Up @@ -1140,6 +1151,8 @@ export async function updateLocalCoordinationTodo(
planning_intent: planningIntent,
...(observationUpdate ? {monitor_observation: decodeMonitorPollObservation(input.monitor_observation)} : {}),
...(completionUpdate ? {completion: requireJsonObject(input.completion, "Todo completion payload")} : {}),
...(validationRevisionUpdate ? {completion_validation_revision:
decodeCompletionValidationRevision(input.completion_validation_revision)} : {}),
clear_fields: input.clear_fields.map((field) => claimAgentValue(field, "clear field")),
dry_run: input.dry_run as boolean,
now: claimObservedAt(input.observed_at),
Expand Down
Loading
Loading