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
24 changes: 24 additions & 0 deletions docs/project-agent-todo-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -1196,6 +1196,30 @@ loopx todo list --goal-id <goal>
Preview with `--dry-run` before the real attempt. A cleared resume condition also
clears its generation fence; an omitted condition is retained. Empty successor
arrays and explicit `no_followup=false` in API intent remain meaningful values.
For a promoted hard-lease Agent Todo that the owner has paused, use a narrow
nonterminal lifecycle edit after its execution lease has been released or has
expired:

```bash
loopx todo update --goal-id <goal> --todo-id <todo> --agent-id <owner-or-granted-controller> \
--status blocked --clear-resume-when --reason '<public-safe pause reason>' \
--update-operation-id <stable-pause-id> --update-expected-provider-revision <readback-revision>
loopx todo list --goal-id <goal> --todo-id <todo> --role agent
```

The same provider CAS blocks the Todo and retires only an inactive retained
lease. An active lease must be released first. The edit grants no execution,
does not complete validation or spend quota, and preserves claim and successor
links. Reopening requires another explicit `--status open --clear-resume-when
--reason ...` operation and a fresh execution lease. Do not combine this
transition with text, ownership, work requirements or a lease proof.

已晋升的 hard-lease Agent Todo 如被 owner 暂停,应先确认旧执行租约已释放或到期,
再以稳定操作 ID、读回的 provider revision 和明确原因执行上述窄范围状态更新。
同一次 CAS 会把 Todo 标为 `blocked` 并退休非活跃租约,同时清除旧等待条件及其派生观察;
它不会完成验收、扣额或授予新的执行权。活跃租约须先释放;恢复执行要明确改回 `open`
并重新领取租约,不能把阻塞记录当作交付完成。

Dependency validation sees the complete canonical inventory, not a hot-path
summary or a Markdown buffer. A satisfied Monitor wait is not silently re-armed
by an evidence edit; changing its topology requires clearing that old condition.
Expand Down
7 changes: 6 additions & 1 deletion loopx/chat_todo_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,16 @@ def _run_todo_update(
status=status,
**({"role": "user", "no_followup": bool(parameters.get("no_followup", True))}
if operation == "complete" else {}),
note=parameters.get("note"),
# A reviewed block is a lifecycle transition, not a copy edit.
# Its UI/Lark note supplies the public reason; a combined note
# patch would cross the hard-lease execution fence.
note=(parameters.get("note") if operation != "block" or basis is None else None),
reason=(parameters.get("note") if operation == "block" and basis is not None else None),
claimed_by=(
parameters.get("agent_id") if operation == "reassign" else None
),
resume_when=parameters.get("resume_when"),
clear_resume_when=operation == "block" and basis is not None,
successor_todo_ids=parameters.get("successor_todo_ids"),
agent_id=parameters.get("agent_id"),
authority_reason="owner-confirmed typed Chat action",
Expand Down
71 changes: 71 additions & 0 deletions loopx/control_plane/coordination/todo_blocked_lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/** A user-directed pause is a Todo lifecycle change, not a completed delivery.
* Retire only an inactive execution generation in the same provider CAS; a
* live holder must release its lease before another actor can pause the work. */
import type {JsonObject} from "../effect_program.ts";
import type {CoordinationProjectionMutation} from "./coordination_projection.ts";
import type {CoordinationTodoUpdateInput} from "./todo_update_intent.ts";
import {canonicalTaskLease} from "./task_lease_state.ts";
import {leaseEpoch, leaseIsActive, leaseVersion} from "../work_items/task_lease_acquire.ts";
import {releasedTaskLeaseRecord} from "../work_items/task_lease_lifecycle_decision.ts";

const LIFECYCLE_FIELDS = new Set(["status", "reason", "clear_resume_when"]);

export function isBlockedLifecycleTransition(
input: CoordinationTodoUpdateInput, todo: JsonObject,
): boolean {
const intent = input.planning_intent ?? {};
return todo.role === "agent" &&
((todo.status === "open" && intent.status === "blocked") ||
(todo.status === "blocked" && intent.status === "open")) &&
intent.clear_resume_when === true &&
typeof intent.reason === "string" && Boolean(intent.reason.trim()) &&
Object.keys(input.patch).length === 0 && input.clear_fields.length === 0 &&
Object.keys(intent).every(field => LIFECYCLE_FIELDS.has(field));
}

export function blockedLifecycleRejection(input: {
goal_id: string; todo_id: string; actor_agent_id: string | null;
registered_agents: readonly string[]; lease: JsonObject | undefined;
lease_idempotency_key: string | null; lease_expected_version: number | null;
now: Date;
}): {code: string; reason: string} | null {
if (input.actor_agent_id === null || !input.registered_agents.includes(input.actor_agent_id)) {
return {code: "actor_not_registered", reason: "Blocked lifecycle transition requires a registered actor"};
}
if (input.lease_idempotency_key !== null || input.lease_expected_version !== null) {
return {code: "blocked_lifecycle_execution_proof_not_allowed",
reason: "Blocked lifecycle transition does not consume an old execution lease"};
}
if (input.lease === undefined) return null;
const lease = canonicalTaskLease(input.lease, input.goal_id, input.todo_id);
if (leaseIsActive(lease, input.now)) {
return {code: "blocked_lifecycle_active_lease",
reason: "Release the active execution lease before changing the Todo lifecycle"};
}
return null;
}

export function planBlockedLifecycleTransition(input: {
goal_id: string; before: JsonObject; after: JsonObject;
lease: JsonObject | undefined; now: Date;
}): {mutations: CoordinationProjectionMutation[]; transition: JsonObject | null} {
const from = input.before.status;
const to = input.after.status;
if (input.before.role !== "agent" ||
!((from === "open" && to === "blocked") || (from === "blocked" && to === "open"))) {
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: to === "blocked" ? "todo_blocked" : "todo_reopened",
execution_authority_granted: false,
lease_retirement: lease === null ? "absent" : retiring ? "released" : "already_released",
next_execution: to === "open" ? "acquire_fresh_lease" : "wait_for_explicit_resume",
...(lease === null ? {} : {retired_lease_version: leaseVersion(lease),
retired_lease_epoch: leaseEpoch(lease)}),
},
};
}
18 changes: 14 additions & 4 deletions loopx/control_plane/coordination/todo_update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {

import {planMonitorCycleTransition} from "./todo_monitor_cycle.ts";
import {isDeferredReopen, planDeferredReopen} from "./todo_deferred_reopen.ts";
import {isBlockedLifecycleTransition, planBlockedLifecycleTransition} from "./todo_blocked_lifecycle.ts";
import {todoUpdateAdmissionRejection} from "./todo_update_admission.ts";
import { CoordinationCommandReceipt } from "./command_receipt.ts";
import {canonicalTodoRecord} from "./todo_presentation.ts";
Expand Down Expand Up @@ -71,7 +72,9 @@ function updateReceipt(input: CoordinationTodoUpdateInput, requestSha: string) {
...(original.monitor_lifecycle_transition === undefined ? {} : {monitor_lifecycle_transition:
canonicalAuthorityObject(original.monitor_lifecycle_transition, "Monitor lifecycle receipt transition")}),
...(original.deferred_resume_transition === undefined ? {} : {deferred_resume_transition:
canonicalAuthorityObject(original.deferred_resume_transition, "Deferred resume receipt transition")})}, changed: original.changed};
canonicalAuthorityObject(original.deferred_resume_transition, "Deferred resume receipt transition")}),
...(original.blocked_lifecycle_transition === undefined ? {} : {blocked_lifecycle_transition:
canonicalAuthorityObject(original.blocked_lifecycle_transition, "Blocked lifecycle receipt transition")})}, changed: original.changed};
}});
}

Expand Down Expand Up @@ -222,21 +225,26 @@ export async function executeCoordinationTodoUpdate(
}
let cycle: ReturnType<typeof planMonitorCycleTransition>;
let deferredCycle: ReturnType<typeof planDeferredReopen> | 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,
lease: target.leases.get(input.todo_id), now: input.now});
}
if (head.head.handoff_mode === "hard_lease" && isBlockedLifecycleTransition(input, target.todo)) {
blockedCycle = planBlockedLifecycleTransition({goal_id: input.goal_id, before: target.todo, after: next,
lease: target.leases.get(input.todo_id), now: input.now});
}
} catch (error) {
return failure("invalid_coordination_projection", error instanceof Error ? error.message : "invalid retained lease");
}
const commit: AuthorityStoreCommit = changed ? prepareCoordinationProjectionCommit({
goal_id: input.goal_id, operation_id: input.operation_id,
expected_provider_revision: head.provider_revision, projection: head.head,
mutations: [{kind: "todo_upsert", todo: next, clear_fields: clearFields},
...cycle.mutations, ...(deferredCycle?.mutations ?? [])],
...cycle.mutations, ...(deferredCycle?.mutations ?? []), ...(blockedCycle?.mutations ?? [])],
}) : {operation_id: input.operation_id,
expected_provider_revision: head.provider_revision, next_projection: head.head,
events: [], receipts: []};
Expand All @@ -259,14 +267,16 @@ 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})};
...(deferredCycle?.transition == null ? {} : {deferred_resume_transition: deferredCycle.transition}),
...(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,
todo_id: input.todo_id, request_sha256: requestSha, changed,
...(prepared.monitorTransition ? {monitor_poll_transition: prepared.monitorTransition} : {}),
...(completionValidationRevisionReceipt === null ? {} :
{completion_validation_revision: completionValidationRevisionReceipt}),
...(cycle.transition === null ? {} : {monitor_lifecycle_transition: cycle.transition}),
...(deferredCycle?.transition == null ? {} : {deferred_resume_transition: deferredCycle.transition})}];
...(deferredCycle?.transition == null ? {} : {deferred_resume_transition: deferredCycle.transition}),
...(blockedCycle?.transition == null ? {} : {blocked_lifecycle_transition: blockedCycle.transition})}];
return receipt.commit(store, commit);
}
12 changes: 12 additions & 0 deletions loopx/control_plane/coordination/todo_update_admission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {evaluateCoordinationTodoMutationDecision,
COORDINATION_TODO_MUTATION_DECISION_REQUEST_SCHEMA} from "./todo_lifecycle_decision.ts";
import {decodeTaskLeaseProof, evaluateCanonicalTaskLeaseProof} from "./task_lease_proof.ts";
import {deferredReopenRejection, isDeferredReopen} from "./todo_deferred_reopen.ts";
import {blockedLifecycleRejection, isBlockedLifecycleTransition} from "./todo_blocked_lifecycle.ts";

interface TodoUpdateRejection {code: string; reason: string}
const reject = (code: string, reason: string): TodoUpdateRejection => ({code, reason});
Expand Down Expand Up @@ -113,6 +114,17 @@ export function todoUpdateAdmissionRejection(
}
return null;
}
if (mode === "hard_lease" && isBlockedLifecycleTransition(input, todo)) {
try {
return blockedLifecycleRejection({goal_id: input.goal_id, todo_id: input.todo_id,
actor_agent_id: input.actor_agent_id, registered_agents: input.registered_agents,
lease, lease_idempotency_key: input.lease_idempotency_key ?? null,
lease_expected_version: input.lease_expected_version ?? null, now: input.now});
} catch (error) {
return reject("invalid_coordination_projection",
error instanceof Error ? error.message : "invalid retained lease facts");
}
}
if (mode === "hard_lease" && isDeferredReopen(input, todo)) {
try {
return deferredReopenRejection({goal_id: input.goal_id, todo_id: input.todo_id,
Expand Down
9 changes: 9 additions & 0 deletions loopx/control_plane/coordination/todo_update_intent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,15 @@ export function prepareUpdatedTodo(
if (value === null || value === "") { delete next[field]; clearFields.add(field); }
else next[field] = value;
}
if (input.planning_intent?.clear_resume_when === true) {
// These are observations of the old condition, not independent resume
// authority. Keeping either after an explicit clear resurrects a stale
// wait in canonical readback even though resume_when is absent.
delete next.resume_condition;
delete next.resume_ready;
clearFields.add("resume_condition");
clearFields.add("resume_ready");
}
next.done = next.status === "done" || next.status === "deferred";
}
// Derive text/title/priority together from the original record and caller intent.
Expand Down
21 changes: 21 additions & 0 deletions tests/control_plane/test_reviewed_canonical_edits.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,27 @@ def test_registry_change_invalidates_uncommitted_review(tmp_path):
assert records(registry) == before


@pytest.mark.parametrize("provider", ["file", "sqlite"])
def test_reviewed_chat_block_uses_the_narrow_lifecycle_intent(tmp_path, provider):
registry, state, service = service_fixture(tmp_path, provider)
request = {"action_kind": "todo.update", "summary": "Pause research",
"normalized_parameters": {"goal_id": "goal-a", "todo_id": "todo_target",
"agent_id": "agent-a", "operation": "block",
"note": "Owner paused this research lane"},
"context": {}, "idempotency_key": "reviewed-pause"}
before = records(registry)
proposal = service.preview(request)
assert records(registry) == before and not state.exists()
applied = service.apply(proposal["proposal_id"])["proposal"]
assert applied["status"] == "applied"
todo = records(registry)["todo_target"]
assert todo["status"] == "blocked"
assert todo["reason"] == "Owner paused this research lane"
assert todo.get("resume_when") is None
assert todo["claimed_by"] == "agent-a"
assert service.apply(proposal["proposal_id"])["proposal"] == applied


@pytest.mark.parametrize("provider", ["file", "sqlite"])
def test_cli_reason_is_evidence_not_a_grant_and_cas_is_recoverable(tmp_path, provider):
registry, _state, service = service_fixture(tmp_path, provider)
Expand Down
Loading
Loading