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
66 changes: 66 additions & 0 deletions docs/reference/canonical-todo-completion-update.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ snapshot extraction and writeback, not a second scope/resume implementation.
| Approve a linked gate | Consume only covered required scopes and their recorded negative outcomes; preserve independent requirements. |
| Reject or cancel a linked gate | Keep requirements, replace the latest outcome for that exact scope, preserve independent outcomes, and block the active target. |
| Complete a linked User action | Attempt resume without consuming decision authority. |
| Cancel a linked User action | Close only the source reminder; do not edit or resume its target. |
| Another active linked User Todo, remaining requirement or negative outcome | Keep the blocked target blocked. |
| Explicit blocker task | Require explicit blocker repair. |
| Completed, deferred or archived target | Do not change or reactivate it. |
Expand Down Expand Up @@ -196,6 +197,71 @@ User Todo、剩余要求或拒绝结果仍会阻止恢复;已完成、延期
重放返回历史回执,不补做旧版本遗漏的联动,也不产生新的授权;历史不一致须根据
原决定显式核对修复。目标任务的执行租约与用户批准仍是不同合同。

## Closing an ordinary bound User action / 关闭普通绑定用户事项

For a promoted Goal in `hard_lease` mode, the exact registered bound Agent may
close an ordinary `user_action` without acquiring an execution lease. User
actions cannot acquire execution leases; this is an administrative terminal
edit under the existing provider CAS, not an execution grant. A foreign actor,
an excluded/unregistered actor, an active lease holder or stale explicit lease
proof is not exempted. No claim, lease generation or decision scope is created.

```sh
loopx todo complete --goal-id example --todo-id todo_observation \
--role user --agent-id agent-a --decision-outcome cancel \
--evidence 'The observation request was withdrawn'
```

Only `cancel` is accepted as an explicit ordinary-action outcome. A linked
reminder reports `decision_cancelled` and leaves its Agent Todo, requirements and scope
outcomes unchanged. Ordinary completion with no decision outcome retains the
existing guarded resume behavior. Gate approval/rejection/cancellation retains
its explicit gate contract. Cancelling a reminder is not cancelling an order,
withdrawing an external message or authorizing a trade; expiry is not detected
or acted on automatically by this change.

Compatibility: canonical `todo complete` continues to accept a `user_gate`
without `--decision-outcome` as closure only, not approval, rejection or
cancellation. Its required scopes, scope outcomes and blocked dependents remain
unchanged, just as for `todo update --status done`. To record a decision and its
linked effects, explicitly supply `approve|reject|cancel`. The legacy Markdown
explicit-completion adapter keeps its pre-existing requirement for a decision;
that adapter's stricter input rule is not imposed on native callers. Historical
successful receipts remain replayable as recorded.

The delivered cancellation entry point is CLI/managed CLI. Existing Chat
completion continues through the shared terminal owner, and frontend/Lark
consumers read the canonical completed status; no new cancellation button,
configuration setting or chat-specific authority is introduced. Direct
frontend/Lark cancellation controls are not part of this bounded slice. The
canonical transaction is qualified on File, SQLite and real PostgreSQL. The
legacy Markdown adapter shares outcome validation and cancellation effects,
but its separate hard-lease terminal fence is not changed. Historical receipts
are replayed as recorded, not reinterpreted as a new cancellation.

已晋升且启用 `hard_lease` 的 Goal 中,精确绑定、已注册且未被排除的 Agent 可以
关闭普通 `user_action`,无需取得执行租约。用户事项本来不能领取执行租约;这里是
既有 provider CAS 下的行政关闭,不产生认领、租约代次或批准权限。异主体、活跃
租约及显式过期/错误租约证明仍不绕过检查。

普通事项仅接受显式 `cancel`:关联提醒报告 `decision_cancelled`,不修改或恢复关联 Agent
任务,不消解要求或写入决策范围结果。不带决定的普通完成保留既有受保护恢复行为;
用户 gate 的批准、拒绝和取消仍遵循原契约。取消提醒不等于撤单、撤回外部消息或
交易授权,本改动也不自动检测到期。

保持兼容:canonical `todo complete` 继续接受未带 `--decision-outcome` 的
`user_gate`,仅关闭事项,不视为批准、拒绝或取消决定;关联任务的阻塞、范围要求
和范围结果保持不变,与 `todo update --status done` 一样。如需记录决定及关联
效果,须显式传入 `approve|reject|cancel`。旧 Markdown 显式完成适配器保留原有的
决定必填规则,不将该适配器更严格的输入规则强加给原生调用方。历史成功回执仍
按原记录重放。

本交付的取消入口是 CLI/managed CLI;既有 Chat 完成入口复用同一终结权威,前端和
Lark 读取 canonical 完成状态,但不新增取消按钮、配置或独立聊天权威。直接前端/
Lark 取消控件不属于本有界切片。File、SQLite 和真实 PostgreSQL 已验证该事务;旧
Markdown 路径复用结果校验和取消联动,但不改变其独立 hard-lease 终结门禁。历史
回执按原记录重放,不会被重新解释成新的取消。

## Recovery and callers

- Historical replay precedes current source admission and returns the original
Expand Down
4 changes: 3 additions & 1 deletion loopx/cli_commands/todo_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,9 @@ def register_todo_command(
choices=["approve", "reject", "cancel"],
help=(
"For todo complete on a user_gate, record the explicit owner decision. "
"Only approve consumes authority and resumes linked work."
"For a user_action, only cancel is accepted; it closes the reminder "
"without approving or resuming linked work. Only gate approval "
"consumes decision authority."
),
)
todo_parser.add_argument(
Expand Down
15 changes: 15 additions & 0 deletions loopx/control_plane/coordination/todo_lifecycle_decision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,21 @@ function terminalFence(
const explicitFence = request.lease_idempotency_key !== null ||
request.lease_expected_version !== null;
const delegated = authorityMode === "delegated_orchestration_override";
// User actions cannot claim execution leases. Closing an ordinary reminder
// by its exact registered bound actor is instead a provider-CAS lifecycle
// edit. Never bypass an active holder or an explicitly supplied fence, and
// never mint a gate lease, execution claim or broader decision authority.
if (request.command === "complete" && request.handoff_mode === "hard_lease" &&
request.todo.role === "user" && request.todo.task_class === "user_action" &&
request.todo.bound_agent !== null && request.todo.bound_agent === request.actor_agent_id &&
!delegated && !timeActive && !explicitFence &&
ownerIdentityEligible(request, request.actor_agent_id)) {
return result("apply", "terminal_fence_not_required", {
authority_mode: authorityMode, lease_fence: "not_required",
next_lease: lease?.present && lease.status !== "released"
? {...lease, active: false, status: "released"} : null,
});
}
// Deferred work cannot acquire a lease. Superseding a retired wait is a
// terminal lifecycle edit, not execution, and the provider CAS retires any
// expired lease lineage together with the Todo transition.
Expand Down
10 changes: 9 additions & 1 deletion loopx/control_plane/coordination/todo_terminal_lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {planUserCompletion} from "../todos/user_completion.ts";
import {planUserCompletion, requireCompletionDecisionOutcome} from "../todos/user_completion.ts";
import {AUTHORITY_SOURCE_CHANGED, uncheckedAuthoritySource, type AuthoritySourceCheck} from "./authority_source.ts";
import {normalizeTodoUpdateInput, prepareUpdatedTodo, type CoordinationTodoUpdateInput, type TodoCompletionEdit} from "./todo_update_intent.ts";
import {todoUpdateAdmissionRejection} from "./todo_update_admission.ts";
Expand Down Expand Up @@ -1204,6 +1204,14 @@ export async function executeCoordinationTodoTerminalLifecycle(
"decision_rejection",
);
}
if (update === undefined && input.command === "complete" && authority.outcome === "apply") {
try {
requireCompletionDecisionOutcome(todo, input.decision_outcome);
} catch (error) {
return terminalFailure("invalid_coordination_todo_terminal_lifecycle",
error instanceof Error ? error.message : "invalid completion outcome");
}
}
const implicitMonitorNoChange =
normalized.operation_identity.kind === "current_monitor_cycle" && authority.outcome === "no_change";

Expand Down
40 changes: 14 additions & 26 deletions loopx/control_plane/todos/unblock_resume.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,7 @@
from typing import Any, Callable

from .active_state_editing import section_bounds, todo_blocks
from .contract import (
TODO_TASK_CLASS_USER_GATE,
normalize_todo_id,
require_todo_decision_outcome,
)
from .contract import normalize_todo_id


def require_completion_decision_outcome(
Expand All @@ -16,27 +12,19 @@ def require_completion_decision_outcome(
*,
materialized: bool,
) -> str | None:
is_user_gate = (
str((completion_todo or {}).get("role") or "") == "user"
and str((completion_todo or {}).get("task_class") or "")
== TODO_TASK_CLASS_USER_GATE
)
if not is_user_gate:
if decision_outcome is not None:
raise ValueError(
"decision_outcome is only valid when completing a user_gate"
)
return None
if decision_outcome is None:
raise ValueError(
"user_gate completion requires decision_outcome=approve, reject, or cancel"
)
if not materialized:
raise ValueError(
"event-projected user_gate completion must first materialize the gate "
"in active state so its decision outcome is durable"
)
return require_todo_decision_outcome(decision_outcome)
from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result

# The shared plan validates its input even with no dependent rows. Python
# transports the locked source fact; it does not own a second outcome rule.
try:
effect_runtime_result("todo.user_completion.plan", {
"schema_version": "todo_user_completion_request_v0",
"source": dict(completion_todo or {}), "todos": [],
"decision_outcome": decision_outcome, "materialized": materialized,
})
except EffectRuntimeRejected as exc:
raise ValueError(str(exc)) from None
return decision_outcome


def _find_todo(
Expand Down
38 changes: 34 additions & 4 deletions loopx/control_plane/todos/user_completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
* under their existing lock/CAS; replay returns the original transaction receipt.
*/
import type {JsonObject} from "../effect_program.ts";
import {requireJsonObject, requireStringLiteral} from "../runtime_decode.ts";
import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts";
import {requireBoolean, requireJsonObject, requireStringLiteral} from "../runtime_decode.ts";
import {decisionScopeCovers} from "./decision_scope.ts";
import {normalizeTodoDecisionScope, normalizeTodoRequiredDecisionScopes} from "./decision_metadata.ts";

Expand Down Expand Up @@ -31,6 +32,28 @@ const unavailableSource = (todo: JsonObject): boolean => {
!["open", "blocked", "deferred"].includes(status);
};

/** Shared outcome semantics with each caller's existing presence contract.
* Cancelling a reminder is not an owner approval or a decision-scope outcome.
* Native Gate closure may omit a decision; the legacy explicit bridge cannot.
*/
export function requireCompletionDecisionOutcome(
source: JsonObject, outcome: DecisionOutcome | null, materialized = true, gateOutcomeRequired = false,
): DecisionOutcome | null {
if (source.role !== "user" || source.task_class !== "user_gate") {
if (outcome !== null && !(source.role === "user" &&
source.task_class === "user_action" && outcome === "cancel")) {
throw new EffectRuntimeRequestError("decision_outcome is only valid when completing a user_gate or cancelling a user_action");
}
return outcome;
}
if (outcome === null) {
if (gateOutcomeRequired) throw new EffectRuntimeRequestError("user_gate completion requires decision_outcome=approve, reject, or cancel");
return null;
}
if (!materialized) throw new EffectRuntimeRequestError("event-projected user_gate completion must first materialize the gate in active state so its decision outcome is durable");
return outcome;
}

export function planUserCompletion(
source: JsonObject, todos: readonly JsonObject[], outcome: DecisionOutcome | null,
): UserCompletionPlan {
Expand All @@ -41,6 +64,10 @@ export function planUserCompletion(
const id = source.unblocks_todo_id;
const base = {schema_version: "todo_unblock_resume_v0", source_todo_id: source.todo_id,
target_todo_id: id, changed: false};
// Ordinary cancellation closes only its source. It neither approves nor
// rejects a scope, and must not resume or otherwise edit its dependent.
if (!gate && outcome === "cancel") return {...empty,
unblock_resume: {...base, state: "decision_cancelled"}};
const target = todos.find(row => row.todo_id === id && row.role === "agent" && row.archive_state !== "archive");
const scope = gate ? normalizeTodoDecisionScope(source.decision_scope) : null;
if (!target) return {...empty, unblock_resume: {...base,
Expand Down Expand Up @@ -101,7 +128,10 @@ export function evaluateUserCompletion(value: unknown): UserCompletionPlan {
if (request.schema_version !== "todo_user_completion_request_v0" || !Array.isArray(request.todos)) {
throw new TypeError("invalid user completion snapshot");
}
return planUserCompletion(requireJsonObject(request.source, "source"),
request.todos.map(row => requireJsonObject(row, "todo")), request.decision_outcome == null ? null :
requireStringLiteral(request.decision_outcome, ["approve", "reject", "cancel"] as const, "decision_outcome"));
const source = requireJsonObject(request.source, "source");
const outcome = request.decision_outcome == null ? null :
requireStringLiteral(request.decision_outcome, ["approve", "reject", "cancel"] as const, "decision_outcome");
requireCompletionDecisionOutcome(source, outcome, request.materialized === undefined
? true : requireBoolean(request.materialized, "materialized"), true);
return planUserCompletion(source, request.todos.map(row => requireJsonObject(row, "todo")), outcome);
}
Loading
Loading