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 @@ -2632,6 +2632,23 @@ superseded;前提是没有仍在有效期内的租约,也未提交旧执行
执行必须重新获取租约。`complete`、挤占有效租约、跨负责人修改及混入执行内容的
更新仍受原有门禁约束。

Owner suspension closes the reverse transition as well: an open Agent Todo's
current claim/lease holder may atomically set `deferred` with an explicit wait
and reason while releasing that live execution generation. No work-content or
ownership edits are bundled. Retained lease lineage applies in legacy mode too;
reopening then follows the same no-live-holder rule. The shared TS owner and
provider CAS preserve receipts and retries. Pending registered Todo/monitor
waits remain eligible for blocked, no-spend closeout after deferral, retaining
the original Turn binding. See [causal closeout](../../reference/protocols/quota-blocked-causal-closeout-v0.md).
This closes an S3 owner-wait lifecycle gap; it does not qualify general shared
amendment or SQLite default admission.

反向转换也由同一 TS owner 负责:当前 claim/lease 持有者可凭有效证明,把开放任务
原子延期并释放租约;不混入任务内容或所有权修改。有租约历史的 legacy 模式同样
适用,恢复遵守无活跃持有者规则。延期后的已注册 Todo/monitor 等待仍能按原 Turn
身份完成无扣额阻塞结算。该交付收敛 S3 等待生命周期,不等于通用 amendment 或
SQLite 默认准入已验收。

### Relation to Staged Delivery

Mapped to the five-stage plan from the #2787 review: the characterization
Expand Down
43 changes: 39 additions & 4 deletions docs/reference/protocols/quota-blocked-causal-closeout-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ facts; it does not implement a second wait decision. The existing

- Preflight recomputes the existing Todo resume condition from the current
waiting Todo and its unique registered dependency. The waiting Agent Todo
must remain open, active and pending. A monitor must remain open, have a
must remain open or deferred, active and pending. A monitor must remain open, have a
captured non-negative generation, and still match that baseline exactly.
A completed, archived, missing, self-referential, malformed or stale target
is not proof; the monitor's current generation must be explicit.
Expand All @@ -28,7 +28,7 @@ facts; it does not implement a second wait decision. The existing
durable-writeback receipts, no debit and no delivery credit. Exact refresh
retry replays the same result. A later spend request is a no-op for this
already-closed identity; an existing debit is never erased.
- The Todo remains open with its original completion validator and canonical
- The Todo remains unfinished with its original completion validator and canonical
wait. A fresh Turn can select independent work; existing Todo resume semantics
still decide when this Todo is ready. Do not replace a causal dependency with
a short timer, force an early monitor poll, or treat closeout as completion.
Expand All @@ -46,6 +46,26 @@ CLI/provider acceptance checks both dependency kinds, replay, no debit,
original validator preservation and independent next-Turn selection. It does
not claim live research adoption or PostgreSQL qualification.

### Suspending leased work

For an open, leased Agent Todo, the current claim/lease owner can use the
existing `todo update` command with `--status deferred --resume-when
todo_done:todo_dependency --reason "Dependency pending"` and the current
`--task-lease-idempotency-key` / `--task-lease-expected-version`. Submit only
those lifecycle fields. Todo deferral and lease release commit in one provider
CAS, in legacy as well as hard-lease mode when retained lease lineage exists.
Dry-run changes nothing; retry replays the receipt. Ownership, scope, work
requirements and completion validation cannot be amended through this path.
Monitor-driven automatic waiting retains `status=open`; its existing authoring
contract rejects deferral so a generation change can make it runnable again.
Reopen explicitly deferred work with `--status open --clear-resume-when` and acquire a fresh execution
lease before work; the old proof remains invalid.

A `pr_merged` condition is still a valid Todo scheduling condition, but a PR
number alone is not a qualified blocked-closeout proof. Register a real
monitor or dependency Todo and use `monitor_changed` / `todo_done` for causal
closeout. Unsupported PR waits now name this recovery route explicitly.

## 中文

已准入的 advancement Turn 可以发现真实依赖,以
Expand All @@ -57,7 +77,7 @@ not claim live research adoption or PostgreSQL qualification.
只传当前 Todo 事实,不另建判断源。`quota.settlement.read` 依据
`loopx_quota_blocked_wait_request_v0` 区分预检与原持久结算读回。

- 复用 Todo resume owner,以当前开放、active、pending 的 Agent Todo 与唯一注册
- 复用 Todo resume owner,以当前 open 或 deferred、active、pending 的 Agent Todo 与唯一注册
依赖重算条件。Monitor 须仍开放,非负 generation 与登记基线精确相等;目标
已完成、归档、缺失、自引用、格式错误、代际推进/倒退或陈旧投影均不算等待
证明;Monitor 当前 generation 必须显式存在。
Expand All @@ -66,7 +86,7 @@ not claim live research adoption or PostgreSQL qualification.
- `typed_blocked_writeback_no_spend` 仅含 validation 与 durable-writeback 回执,
不扣额、不计交付进展。精确刷新幂等重放;已关闭身份的 spend 请求不再追加,
已有真实扣额不会被抹去。
- Todo 保持开放、原验收器和 canonical 等待不变。新 Turn 可选独立工作;何时恢复
- Todo 保持未完成、原验收器和 canonical 等待不变。新 Turn 可选独立工作;何时恢复
仍由原 Todo resume 语义判断。不得用短定时器替换依赖、强迫提前 poll,或将
Turn 结算当成 Todo 完成。
- 保留旧 v0 有界等待与 promoted Turn 自有五分钟重试。降级前须用兼容运行时
Expand All @@ -77,3 +97,18 @@ Dashboard 已消费 canonical 等待与回执,Chat/Lark 仍复用 Todo updat
不新增前端控件、Lark 命令或独立 UI 权威。File、SQLite 的真实 CLI/provider
验收覆盖两种依赖、重放、零扣额、原验收器保留与下一 Turn 独立选择;不据此
宣称投研真实采用或 PostgreSQL 资格已通过。

### 有租约任务的延期

当前 claim/lease 持有者可沿用 `todo update`,只提交 `--status deferred`、
`--resume-when todo_done:todo_dependency`、`--reason`,并带当前
`--task-lease-idempotency-key` 和 `--task-lease-expected-version`。
TS 在一个 provider CAS 内同时延期 Todo、释放租约;保留租约历史的 legacy
模式也适用。Dry-run 不写入,重试重放原回执,不允许夹带任务内容、权限或验收修改。
`monitor_changed` 的自动等待仍须保持 open,代际变化后才能自动进入可执行队列;
其原有 authoring 规则继续拒绝延期。显式延期的普通依赖任务恢复时用
`--status open --clear-resume-when`,执行前重新获取租约,旧证明仍失效。

`pr_merged` 仍是合法的调度等待条件,但 PR 编号本身不能证明阻塞结算所需的
真实依赖。应登记实际 monitor/依赖 Todo,以 `monitor_changed`/`todo_done`
完成因果结算;错误信息明确给出此恢复路径。
9 changes: 4 additions & 5 deletions loopx/control_plane/coordination/task_lease_proof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {canonicalTaskLeaseAcquireFacts} from "./task_lease_state.ts";
import {coordinationTodoWriteScopes} from "./todo_write_scopes.ts";
import {decideTaskLeaseAcquire} from "../work_items/task_lease_acquire_decision.ts";
import {leaseOwnerRejection} from "../work_items/task_lease_eligibility.ts";
import type {CoordinationTodoUpdateInput} from "./todo_update_intent.ts";
import {isOwnerDeferral} from "./todo_deferred_lifecycle.ts";
import {TODO_WORK_REQUIREMENT_FIELDS} from "../todos/work_requirements.ts";
import {acceptanceWorkGuard} from "../goals/acceptance_contract.ts";
import {leaseEpoch} from "../work_items/task_lease_acquire.ts";
Expand Down Expand Up @@ -89,10 +91,7 @@ export function leasedTodoEditRejection(todo: JsonObject, intent: JsonObject): {

/** Diagnostic only: acquisition still rechecks the current head and its CAS.
* Reuse admission rather than recommend a new lease solely from its expiry. */
export function todoUpdateLeaseRecovery(head: JsonObject, input: {
goal_id: string; todo_id: string; actor_agent_id: string | null;
registered_agents: readonly string[]; now: Date; planning_intent?: JsonObject;
}, mode: string): TodoUpdateLeaseRecovery {
export function todoUpdateLeaseRecovery(head: JsonObject, input: CoordinationTodoUpdateInput, mode: string): TodoUpdateLeaseRecovery {
const index = indexCoordinationProjection(head, input.goal_id);
const facts = canonicalTaskLeaseAcquireFacts(index, input.goal_id, input.todo_id,
input.registered_agents, input.now);
Expand All @@ -111,7 +110,7 @@ export function todoUpdateLeaseRecovery(head: JsonObject, input: {
};
const todo = index.todos.get(input.todo_id)!;
const intent = input.planning_intent ?? {};
const editRejection = lease === null ? null : leasedTodoEditRejection(todo, intent);
const editRejection = lease === null || isOwnerDeferral(input, todo) ? null : leasedTodoEditRejection(todo, intent);
if (editRejection !== null) {
return {...base, action: "resolve_lifecycle_edit", reason_code: editRejection.code,
reason: "This edit changes leased work requirements or status. Use the owning lifecycle transition; reacquiring a lease alone cannot authorize this metadata edit."};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** A deferred Todo cannot acquire a hard lease until it is open. Reopening is
* therefore a narrow lifecycle transition, not an unfenced execution edit. */
/** Deferred work suspends execution; resuming it requires a fresh grant.
* Todo status and lease retirement commit through the same provider CAS. */
import type {JsonObject} from "../effect_program.ts";
import type {CoordinationProjectionMutation} from "./coordination_projection.ts";
import type {CoordinationTodoUpdateInput} from "./todo_update_intent.ts";
Expand All @@ -18,6 +18,17 @@ export function isDeferredReopen(input: CoordinationTodoUpdateInput, todo: JsonO
Object.keys(intent).every(field => REOPEN_FIELDS.has(field));
}

/** The current holder may suspend unchanged work with its live execution proof.
* Admission validates that proof; this predicate never grants authority. */
export function isOwnerDeferral(input: CoordinationTodoUpdateInput, todo: JsonObject): boolean {
const intent = input.planning_intent ?? {};
return todo.role === "agent" && todo.status === "open" && intent.status === "deferred" &&
typeof intent.resume_when === "string" && Boolean(intent.resume_when.trim()) &&
typeof intent.reason === "string" && Boolean(intent.reason.trim()) &&
Object.keys(input.patch).length === 0 && input.clear_fields.length === 0 &&
Object.keys(intent).every(field => ["status", "resume_when", "reason"].includes(field));
}

/** Actor, claim, exclusion and binding admission happens before this check.
* A retained inactive generation is history; a currently active one blocks
* the transition even if its holder also owns the Todo. */
Expand All @@ -43,22 +54,23 @@ export function deferredReopenRejection(input: {
return null;
}

/** Provider CAS commits the Todo reopening and any stale lease retirement
* together. The next execution still has to acquire a fresh lease. */
export function planDeferredReopen(input: {
/** Admission has verified a live owner proof for suspension, or absence of a
* live holder for reopening. The next execution must acquire a fresh lease. */
export function planDeferredLifecycle(input: {
goal_id: string; before: JsonObject; after: JsonObject;
lease: JsonObject | undefined; now: Date;
}): {mutations: CoordinationProjectionMutation[]; transition: JsonObject | null} {
if (input.before.status !== "deferred" || input.after.status !== "open" ||
const deferring = input.before.status === "open" && input.after.status === "deferred";
if ((!deferring && !(input.before.status === "deferred" && input.after.status === "open")) ||
input.before.role !== "agent") return {mutations: [], transition: null};
const lease = input.lease === undefined ? null :
canonicalTaskLease(input.lease, input.goal_id, String(input.before.todo_id));
const retiring = lease !== null && lease.status !== "released";
return {
mutations: retiring ? [{kind: "lease_upsert", lease: releasedTaskLeaseRecord(lease, input.now)}] : [],
transition: {kind: "deferred_resumed", execution_authority_granted: false,
transition: {kind: deferring ? "todo_deferred" : "deferred_resumed", execution_authority_granted: false,
lease_retirement: lease === null ? "absent" : retiring ? "released" : "already_released",
next_execution: "acquire_fresh_lease",
next_execution: deferring ? "wait_then_acquire_fresh_lease" : "acquire_fresh_lease",
...(lease === null ? {} : {retired_lease_version: leaseVersion(lease),
retired_lease_epoch: leaseEpoch(lease)})},
};
Expand Down
18 changes: 12 additions & 6 deletions loopx/control_plane/coordination/todo_update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
} from "./coordination_projection.ts";

import {planMonitorCycleTransition} from "./todo_monitor_cycle.ts";
import {isDeferredReopen, planDeferredReopen} from "./todo_deferred_reopen.ts";
import {isDeferredReopen, isOwnerDeferral, planDeferredLifecycle} from "./todo_deferred_lifecycle.ts";
import {isBlockedLifecycleTransition, planBlockedLifecycleTransition} from "./todo_blocked_lifecycle.ts";
import {todoUpdateAdmissionRejection} from "./todo_update_admission.ts";
import { CoordinationCommandReceipt } from "./command_receipt.ts";
Expand Down Expand Up @@ -71,6 +71,8 @@ function updateReceipt(input: CoordinationTodoUpdateInput, requestSha: string) {
monitor_poll_transition: canonicalAuthorityObject(original.monitor_poll_transition, "Monitor update receipt transition")}),
...(original.monitor_lifecycle_transition === undefined ? {} : {monitor_lifecycle_transition:
canonicalAuthorityObject(original.monitor_lifecycle_transition, "Monitor lifecycle receipt transition")}),
...(original.deferred_transition === undefined ? {} : {deferred_transition:
canonicalAuthorityObject(original.deferred_transition, "Deferred lifecycle receipt transition")}),
...(original.deferred_resume_transition === undefined ? {} : {deferred_resume_transition:
canonicalAuthorityObject(original.deferred_resume_transition, "Deferred resume receipt transition")}),
...(original.blocked_lifecycle_transition === undefined ? {} : {blocked_lifecycle_transition:
Expand Down Expand Up @@ -226,13 +228,14 @@ export async function executeCoordinationTodoUpdate(
}
}
let cycle: ReturnType<typeof planMonitorCycleTransition>;
let deferredCycle: ReturnType<typeof planDeferredReopen> | null = null;
let deferredCycle: ReturnType<typeof planDeferredLifecycle> | null = null;
let blockedCycle: ReturnType<typeof planBlockedLifecycleTransition> | null = null;
try {
cycle = planMonitorCycleTransition({goal_id: input.goal_id, before: target.todo, after: next,
lease: target.leases.get(input.todo_id), handoff_mode: head.head.handoff_mode, now: input.now});
if (head.head.handoff_mode === "hard_lease" && isDeferredReopen(input, target.todo)) {
deferredCycle = planDeferredReopen({goal_id: input.goal_id, before: target.todo, after: next,
if ((head.head.handoff_mode === "hard_lease" || target.leases.has(input.todo_id)) &&
(isDeferredReopen(input, target.todo) || isOwnerDeferral(input, target.todo))) {
deferredCycle = planDeferredLifecycle({goal_id: input.goal_id, before: target.todo, after: next,
lease: target.leases.get(input.todo_id), now: input.now});
}
if (head.head.handoff_mode === "hard_lease" && isBlockedLifecycleTransition(input, target.todo)) {
Expand All @@ -242,6 +245,9 @@ export async function executeCoordinationTodoUpdate(
} catch (error) {
return failure("invalid_coordination_projection", error instanceof Error ? error.message : "invalid retained lease");
}
const deferredTransition = deferredCycle?.transition == null ? {} : {
[isOwnerDeferral(input, target.todo) ? "deferred_transition" : "deferred_resume_transition"]: deferredCycle.transition,
};
const commit: AuthorityStoreCommit = changed ? prepareCoordinationProjectionCommit({
goal_id: input.goal_id, operation_id: input.operation_id,
expected_provider_revision: head.provider_revision, projection: head.head,
Expand Down Expand Up @@ -269,7 +275,7 @@ export async function executeCoordinationTodoUpdate(
...(completionValidationRevisionReceipt === null ? {} :
{completion_validation_revision: completionValidationRevisionReceipt}),
...(cycle.transition === null ? {} : {monitor_lifecycle_transition: cycle.transition}),
...(deferredCycle?.transition == null ? {} : {deferred_resume_transition: deferredCycle.transition}),
...deferredTransition,
...(blockedCycle?.transition == null ? {} : {blocked_lifecycle_transition: blockedCycle.transition})};
commit.receipts = [{schema_version: COORDINATION_TODO_UPDATE_RECEIPT_SCHEMA,
operation_id: input.operation_id, goal_id: input.goal_id,
Expand All @@ -278,7 +284,7 @@ export async function executeCoordinationTodoUpdate(
...(completionValidationRevisionReceipt === null ? {} :
{completion_validation_revision: completionValidationRevisionReceipt}),
...(cycle.transition === null ? {} : {monitor_lifecycle_transition: cycle.transition}),
...(deferredCycle?.transition == null ? {} : {deferred_resume_transition: deferredCycle.transition}),
...deferredTransition,
...(blockedCycle?.transition == null ? {} : {blocked_lifecycle_transition: blockedCycle.transition})}];
return receipt.commit(store, commit);
}
6 changes: 3 additions & 3 deletions loopx/control_plane/coordination/todo_update_admission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {TODO_OWNERSHIP_INTENT_FIELDS} from "../todos/authoring_scope.ts";
import {evaluateCoordinationTodoMutationDecision,
COORDINATION_TODO_MUTATION_DECISION_REQUEST_SCHEMA} from "./todo_lifecycle_decision.ts";
import {decodeTaskLeaseProof, evaluateCanonicalTaskLeaseProof, todoUpdateLeaseRecovery, leasedTodoEditRejection} from "./task_lease_proof.ts";
import {deferredReopenRejection, isDeferredReopen} from "./todo_deferred_reopen.ts";
import {deferredReopenRejection, isDeferredReopen, isOwnerDeferral} from "./todo_deferred_lifecycle.ts";
import {blockedLifecycleRejection, isBlockedLifecycleTransition} from "./todo_blocked_lifecycle.ts";

interface TodoUpdateRejection {code: string; reason: string; handoff_mode?: string; recovery?: JsonObject}
Expand Down Expand Up @@ -125,7 +125,7 @@ export function todoUpdateAdmissionRejection(
error instanceof Error ? error.message : "invalid retained lease facts");
}
}
if (mode === "hard_lease" && isDeferredReopen(input, todo)) {
if ((mode === "hard_lease" || lease !== undefined) && isDeferredReopen(input, todo)) {
try {
return deferredReopenRejection({goal_id: input.goal_id, todo_id: input.todo_id,
actor_agent_id: input.actor_agent_id, registered_agents: input.registered_agents,
Expand Down Expand Up @@ -156,7 +156,7 @@ export function todoUpdateAdmissionRejection(
return reject("update_owner_mismatch", "Leased Todo update requires the current claim owner");
}
if (lease !== undefined) {
return leasedTodoEditRejection(todo, input.planning_intent ?? {});
return isOwnerDeferral(input, todo) ? null : leasedTodoEditRejection(todo, input.planning_intent ?? {});
}
} catch (error) {
return reject("invalid_coordination_projection",
Expand Down
Loading
Loading