From f290aeb7a19197efaf3dd53377a7962156a61254 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:38:29 +0800 Subject: [PATCH 1/7] fix(todo): revise work requirements and retire old execution atomically Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../coordination/task_lease_proof.ts | 10 ++ .../control_plane/coordination/todo_update.ts | 18 ++- .../coordination/todo_update_admission.ts | 18 +++ .../todo_work_requirements_lifecycle.ts | 65 +++++++++++ .../test_todo_administrative_journey.py | 43 +++++++ .../native_planning_update_conformance.ts | 14 ++- .../todo_administrative_edits.test.ts | 109 +++++++++++++++++- tests/control_plane_ts/todo_update.test.ts | 3 +- 8 files changed, 273 insertions(+), 7 deletions(-) create mode 100644 loopx/control_plane/coordination/todo_work_requirements_lifecycle.ts diff --git a/loopx/control_plane/coordination/task_lease_proof.ts b/loopx/control_plane/coordination/task_lease_proof.ts index b52003fda3..00aca6e9ce 100644 --- a/loopx/control_plane/coordination/task_lease_proof.ts +++ b/loopx/control_plane/coordination/task_lease_proof.ts @@ -14,6 +14,7 @@ import type {CoordinationTodoUpdateInput} from "./todo_update_intent.ts"; import {deferredReopenRejection, isDeferredReopen, isOwnerDeferral} from "./todo_deferred_lifecycle.ts"; import {blockedLifecycleRejection, isBlockedLifecycleTransition} from "./todo_blocked_lifecycle.ts"; import {TODO_WORK_REQUIREMENT_FIELDS} from "../todos/work_requirements.ts"; +import {isOwnerWorkRequirementsRevision} from "./todo_work_requirements_lifecycle.ts"; import {acceptanceWorkGuard} from "../goals/acceptance_contract.ts"; import {leaseEpoch} from "../work_items/task_lease_acquire.ts"; import {leaseRepositoryRejection} from "../work_items/task_lease_repository.ts"; @@ -163,6 +164,15 @@ export function todoUpdateLeaseRecovery(head: JsonObject, input: CoordinationTod execution_authority_granted: false, }; const intent = input.planning_intent ?? {}; + if (mode === "hard_lease" && isOwnerWorkRequirementsRevision(todo, input) && + (input.lease_idempotency_key != null || input.lease_expected_version != null)) { + return {...base, action: "inspect_current_proof", + reason: "The explicit proof did not qualify. Inspect the current generation; do not replace its key/version or silently retry without proof. A separately reviewed proof-free requirement revision retires only the actor's own current grant and grants no execution."}; + } + if (mode === "hard_lease" && isOwnerWorkRequirementsRevision(todo, input) && lease !== null && !sameOwner) { + return {...base, action: "reconcile_lease_owner", + reason: "This Todo retains another holder's execution history. Inspect that holder and reconcile the ownership through its lifecycle before preparing a requirement revision; this request cannot retire foreign history, borrow proof or acquire on the holder's behalf."}; + } if ((intent.status === "blocked" || intent.status === "deferred") && (input.lease_idempotency_key != null || input.lease_expected_version != null)) { return {...base, action: "inspect_current_proof", reason: "This lifecycle request supplied execution proof that did not qualify. Inspect the current lease and review the intended operation; do not silently replace its key or version. A proof-free administrative pause is a new, explicit request, not a retry of the stale execution."}; diff --git a/loopx/control_plane/coordination/todo_update.ts b/loopx/control_plane/coordination/todo_update.ts index e988d37b71..89810da0c0 100644 --- a/loopx/control_plane/coordination/todo_update.ts +++ b/loopx/control_plane/coordination/todo_update.ts @@ -18,6 +18,7 @@ import { import {planMonitorCycleTransition} from "./todo_monitor_cycle.ts"; import {isDeferredReopen, isOwnerDeferral, planDeferredLifecycle} from "./todo_deferred_lifecycle.ts"; import {isBlockedLifecycleTransition, planBlockedLifecycleTransition} from "./todo_blocked_lifecycle.ts"; +import {isOwnerWorkRequirementsRevision, planWorkRequirementsRevision} from "./todo_work_requirements_lifecycle.ts"; import {todoUpdateAdmissionRejection} from "./todo_update_admission.ts"; import { CoordinationCommandReceipt } from "./command_receipt.ts"; import {canonicalTodoRecord} from "./todo_presentation.ts"; @@ -97,7 +98,9 @@ function updateReceipt(input: CoordinationTodoUpdateInput, requestSha: string) { ...(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: - canonicalAuthorityObject(original.blocked_lifecycle_transition, "Blocked lifecycle receipt transition")})}, changed: original.changed}; + canonicalAuthorityObject(original.blocked_lifecycle_transition, "Blocked lifecycle receipt transition")}), + ...(original.work_requirements_transition === undefined ? {} : {work_requirements_transition: + canonicalAuthorityObject(original.work_requirements_transition, "Work requirements receipt transition")})}, changed: original.changed}; }}); } @@ -261,6 +264,7 @@ export async function executeCoordinationTodoUpdate( let cycle: ReturnType; let deferredCycle: ReturnType | null = null; let blockedCycle: ReturnType | null = null; + let workRequirements: ReturnType | 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}); @@ -274,6 +278,10 @@ export async function executeCoordinationTodoUpdate( blockedCycle = planBlockedLifecycleTransition({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" && isOwnerWorkRequirementsRevision(target.todo, normalizedInput)) { + workRequirements = planWorkRequirementsRevision({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", @@ -288,11 +296,15 @@ export async function executeCoordinationTodoUpdate( ? "deferred_transition" : "deferred_resume_transition"]: deferredCycle.transition, }; + const workRequirementsTransition = workRequirements?.transition == null ? {} : { + work_requirements_transition: workRequirements.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, mutations: [{kind: "todo_upsert", todo: next, clear_fields: clearFields}, - ...cycle.mutations, ...(deferredCycle?.mutations ?? []), ...(blockedCycle?.mutations ?? [])], + ...cycle.mutations, ...(deferredCycle?.mutations ?? []), ...(blockedCycle?.mutations ?? []), + ...(workRequirements?.mutations ?? [])], }) : {operation_id: input.operation_id, expected_provider_revision: head.provider_revision, next_projection: head.head, events: [], receipts: []}; @@ -316,6 +328,7 @@ export async function executeCoordinationTodoUpdate( {completion_validation_revision: completionValidationRevisionReceipt}), ...(cycle.transition === null ? {} : {monitor_lifecycle_transition: cycle.transition}), ...deferredTransition, + ...workRequirementsTransition, ...(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, @@ -325,6 +338,7 @@ export async function executeCoordinationTodoUpdate( {completion_validation_revision: completionValidationRevisionReceipt}), ...(cycle.transition === null ? {} : {monitor_lifecycle_transition: cycle.transition}), ...deferredTransition, + ...workRequirementsTransition, ...(blockedCycle?.transition == null ? {} : {blocked_lifecycle_transition: blockedCycle.transition})}]; return receipt.commit(store, commit); } diff --git a/loopx/control_plane/coordination/todo_update_admission.ts b/loopx/control_plane/coordination/todo_update_admission.ts index 712b66695c..4407e49735 100644 --- a/loopx/control_plane/coordination/todo_update_admission.ts +++ b/loopx/control_plane/coordination/todo_update_admission.ts @@ -15,6 +15,7 @@ import {evaluateCoordinationTodoMutationDecision, import {decodeTaskLeaseProof, evaluateCanonicalTaskLeaseProof, todoUpdateLeaseRecovery, leasedTodoEditRejection, isBoundUserActionMetadataUpdate, isSoftClaimReleasedCopyUpdate, isOwnerAdministrativeCopyUpdate} from "./task_lease_proof.ts"; import {deferredReopenRejection, isDeferredReopen, isOwnerDeferral} from "./todo_deferred_lifecycle.ts"; import {blockedLifecycleRejection, isBlockedLifecycleTransition} from "./todo_blocked_lifecycle.ts"; +import {isOwnerWorkRequirementsRevision, workRequirementsRevisionRejection} from "./todo_work_requirements_lifecycle.ts"; import {indexCoordinationProjection} from "./coordination_projection.ts"; import {canonicalTaskLease} from "./task_lease_state.ts"; import {leaseIsActive} from "../work_items/task_lease_acquire.ts"; @@ -133,6 +134,23 @@ export function todoUpdateAdmissionRejection( } return null; } + if (mode === "hard_lease" && isOwnerWorkRequirementsRevision(todo, input)) { + try { + // Supplied proof pins one live generation. Never turn stale/partial proof + // into a proof-free administrative request by looking up another lease. + if (hasProof) { + const fence = evaluateCanonicalTaskLeaseProof({todo, lease, handoff_mode: mode, + registered_agents: input.registered_agents, actor_agent_id: input.actor_agent_id, + lease_idempotency_key: input.lease_idempotency_key ?? null, + lease_expected_version: input.lease_expected_version ?? null, now: input.now}); + if (fence.outcome !== "apply") return withRecovery(reject(String(fence.code), + "Explicit execution proof is stale or unavailable; inspect this Todo before retrying")); + } + return withRecovery(workRequirementsRevisionRejection(todo, input, lease)); + } catch (error) { + return reject("invalid_coordination_projection", error instanceof Error ? error.message : "invalid retained lease"); + } + } if (mode === "hard_lease" && isBlockedLifecycleTransition(input, todo)) { try { // Proof, if supplied, is an explicit generation constraint. Never replace diff --git a/loopx/control_plane/coordination/todo_work_requirements_lifecycle.ts b/loopx/control_plane/coordination/todo_work_requirements_lifecycle.ts new file mode 100644 index 0000000000..645d770b72 --- /dev/null +++ b/loopx/control_plane/coordination/todo_work_requirements_lifecycle.ts @@ -0,0 +1,65 @@ +/** Declared work may change; an old execution grant must not change with it. + * Admission and Todo/lease retirement share the update owner's provider CAS. */ +import type {JsonObject} from "../effect_program.ts"; +import {TODO_WORK_REQUIREMENT_FIELDS} from "../todos/work_requirements.ts"; +import {leaseIsActive, leaseVersion, leaseEpoch} from "../work_items/task_lease_acquire.ts"; +import {releasedTaskLeaseRecord} from "../work_items/task_lease_lifecycle_decision.ts"; +import {leaseRepositoryRejection} from "../work_items/task_lease_repository.ts"; +import {canonicalAuthoritySha256} from "./authority_store_codec.ts"; +import type {CoordinationProjectionMutation} from "./coordination_projection.ts"; +import {canonicalTaskLease} from "./task_lease_state.ts"; +import type {CoordinationTodoUpdateInput} from "./todo_update_intent.ts"; + +/** Local classification of the existing update intent, not an execution grant. + * Status, ownership, dependencies and validation changes keep their own owners. */ +export function isOwnerWorkRequirementsRevision(todo: JsonObject, input: CoordinationTodoUpdateInput): boolean { + const intent = input.planning_intent ?? {}; + return todo.role === "agent" && ["open", "blocked", "deferred"].includes(String(todo.status)) && + input.actor_agent_id !== null && (todo.claimed_by == null || todo.claimed_by === input.actor_agent_id) && + input.completion === undefined && input.completion_validation_revision === undefined && + input.monitor_observation === undefined && + TODO_WORK_REQUIREMENT_FIELDS.some(field => Object.hasOwn(intent, field)) && + Object.keys(input.patch).every(field => field === "text" || field === "note") && + input.clear_fields.every(field => field === "note") && + Object.entries(intent).every(([field, value]) => + TODO_WORK_REQUIREMENT_FIELDS.some(requirement => requirement === field) || + ["evidence", "reason", "priority", "clear_priority"].includes(field) || + (field === "status" && value === todo.status)); +} + +/** Shared actor/claim/binding admission and optional explicit proof run first. + * An inactive foreign generation is still someone else's retained authority. */ +export function workRequirementsRevisionRejection(todo: JsonObject, input: CoordinationTodoUpdateInput, + retained: JsonObject | undefined): {code: string; reason: string} | null { + if (retained === undefined) return null; + const lease = canonicalTaskLease(retained, input.goal_id, input.todo_id); + leaseIsActive(lease, input.now); // Validate the stored clock even for administrative intent. + const repository = leaseRepositoryRejection(todo, lease); + if (repository !== null) return {code: repository, reason: "Inspect the divergent execution repository before revising work"}; + if (lease.owner !== input.actor_agent_id) return {code: "lease_owner_mismatch", + reason: "Only the current owner may retire its retained execution while revising work requirements"}; + if (todo.claimed_by !== input.actor_agent_id) return {code: "update_owner_mismatch", + reason: "Requirement revision must retain the execution holder's current Todo claim"}; + return null; +} + +/** Called only after admission and normalization. Freeze history unchanged; + * any later execution is separately admitted with a new acquire identity. */ +export function planWorkRequirementsRevision(input: { + goal_id: string; before: JsonObject; after: JsonObject; lease: JsonObject | undefined; now: Date; +}): {mutations: CoordinationProjectionMutation[]; transition: JsonObject | null} { + const changed = TODO_WORK_REQUIREMENT_FIELDS.filter(field => + canonicalAuthoritySha256({value: input.before[field] ?? null}) !== + canonicalAuthoritySha256({value: input.after[field] ?? null})); + if (changed.length === 0) 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: {changed_fields: changed, execution_authority_granted: false, + lease_retirement: lease === null ? "absent" : retiring ? "released" : "already_released", + next_execution: "acquire_fresh_lease", + ...(lease === null ? {} : {retired_lease_version: leaseVersion(lease), retired_lease_epoch: leaseEpoch(lease)})}, + }; +} diff --git a/tests/control_plane/test_todo_administrative_journey.py b/tests/control_plane/test_todo_administrative_journey.py index e0e2428a32..1ff92cf484 100644 --- a/tests/control_plane/test_todo_administrative_journey.py +++ b/tests/control_plane/test_todo_administrative_journey.py @@ -106,3 +106,46 @@ def preview(key, **parameters): cli("todo", "update", "--agent-id", "agent-a", "--note", "Intervening task change") assert service.apply(priority["proposal_id"])["proposal"]["status"] == "stale" assert cli("todo", "list")["todo"].get("priority") != "P2" + + +@pytest.mark.parametrize("retained", ["active", "released"]) +def test_cli_revises_requirements_without_a_release_acquire_dead_end(journey, retained): + cli, _, _, _ = journey + lease = cli("task-lease", "acquire", "--owner", "agent-a", "--idempotency-key", "original")["lease"] + if retained == "released": + cli("task-lease", "release", "--owner", "agent-a", "--idempotency-key", "original", + "--expected-version", str(lease["version"])) + revision = ("todo", "update", "--agent-id", "agent-a", "--required-write-scope", "src/**", + "--required-capability", "filesystem_write", "--update-operation-id", "revise-once") + if retained == "active": + revision += ("--task-lease-idempotency-key", "original", "--task-lease-expected-version", str(lease["version"])) + before = cli("task-lease", "inspect")["lease"] + assert cli(*revision, "--dry-run")["status"] == "planned" + assert cli("task-lease", "inspect")["lease"] == before + applied = cli(*revision) + assert applied["work_requirements_transition"]["execution_authority_granted"] is False + assert cli(*revision)["status"] == "replayed" + todo = cli("todo", "list")["todo"] + assert todo["required_write_scopes"] == ["src/**"] + assert todo["required_capabilities"] == ["filesystem_write"] + retired = cli("task-lease", "inspect")["lease"] + assert retired["status"] == "released" and retired["version"] == lease["version"] + stale = cli("todo", "update", "--agent-id", "agent-a", "--note", "Old execution proof", + "--task-lease-idempotency-key", "original", "--task-lease-expected-version", str(lease["version"]), ok=False) + assert stale["ok"] is False + fresh = cli("task-lease", "acquire", "--owner", "agent-a", "--idempotency-key", "revised", + "--write-scope", "src/**")["lease"] + assert fresh["version"] == lease["version"] + 1 + assert fresh["write_scopes"] == ["src/**"] + + +def test_cli_repository_revision_preserves_frozen_history_and_acquires_the_new_identity(journey): + cli, _, _, _ = journey + cli("todo", "update", "--agent-id", "agent-a", "--task-repository", "git:github.com/example/original") + lease = cli("task-lease", "acquire", "--owner", "agent-a", "--idempotency-key", "original")["lease"] + cli("todo", "update", "--agent-id", "agent-a", "--task-repository", "git:github.com/example/revised") + retained = cli("task-lease", "inspect")["lease"] + assert retained["status"] == "released" and retained["write_repository"] == "git:github.com/example/original" + fresh = cli("task-lease", "acquire", "--owner", "agent-a", "--idempotency-key", "revised")["lease"] + assert fresh["version"] == lease["version"] + 1 + assert fresh["write_repository"] == "git:github.com/example/revised" diff --git a/tests/control_plane_ts/native_planning_update_conformance.ts b/tests/control_plane_ts/native_planning_update_conformance.ts index 548788a187..ff00adaf51 100644 --- a/tests/control_plane_ts/native_planning_update_conformance.ts +++ b/tests/control_plane_ts/native_planning_update_conformance.ts @@ -192,7 +192,7 @@ export function registerNativePlanningUpdateConformance(provider: string, factor }); } - test(`${provider}: production-scale planning edit preserves unrelated state and active lease`, async t => { + test(`${provider}: production-scale copy preserves execution; requirement revision retires only its own lease`, async t => { const {store} = await factory(t); const fixture = productionScaleCoordinationFixture("planning-scale"); assert.equal((await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, @@ -218,7 +218,6 @@ export function registerNativePlanningUpdateConformance(provider: string, factor for (const [operation_id, change] of [ ["partial-proof", {lease_idempotency_key: null}], ["status-change", {planning_intent: {status: "deferred", resume_when: "pr_merged:#123"}}], - ["requirements-change", {planning_intent: {required_write_scopes: ["new/**"]}}], ["wrong-owner", {actor_agent_id: "agent-b"}], ] as const) { const result = await executeCoordinationTodoUpdate(store, {...request, operation_id, ...change}); @@ -227,6 +226,17 @@ export function registerNativePlanningUpdateConformance(provider: string, factor assert.deepEqual(await head(store), after); assert.equal((await store.readReceipt(operation_id)).status, "missing"); } + const revised = await executeCoordinationTodoUpdate(store, {...request, operation_id: "requirements-change", + planning_intent: {required_write_scopes: ["new/**"]}}); + assert.equal(revised.status, "applied", JSON.stringify(revised)); + assert.equal((revised.work_requirements_transition as JsonObject).execution_authority_granted, false); + const final = await head(store); + assert.deepEqual((final.head.todos as JsonObject[]).filter(todo => todo.todo_id !== request.todo_id), + records.filter(todo => todo.todo_id !== request.todo_id)); + const leases = final.head.leases as JsonObject[]; + assert.equal(leases.find(lease => lease.todo_id === request.todo_id)!.status, "released"); + assert.deepEqual(leases.filter(lease => lease.todo_id !== request.todo_id), + (after.head.leases as JsonObject[]).filter(lease => lease.todo_id !== request.todo_id)); }); for (const native of [false, true]) test(`${provider}: update intent matrix preserves compatibility clears and user scope`, async t => { diff --git a/tests/control_plane_ts/todo_administrative_edits.test.ts b/tests/control_plane_ts/todo_administrative_edits.test.ts index 52a90949e9..f7341e7cda 100644 --- a/tests/control_plane_ts/todo_administrative_edits.test.ts +++ b/tests/control_plane_ts/todo_administrative_edits.test.ts @@ -37,6 +37,7 @@ async function fixture(t: test.TestContext, provider: "file" | "sqlite" | "postg task_class: "advancement_task", task_repository: "git:github.com/example/project", required_write_scopes: ["src/**"], completion_validation_required: true}; const lease = {schema_version: "task_lease_v0", goal_id: GOAL, todo_id: "todo_work", owner: "agent-a", idempotency_key: "execution", version: 7, lease_epoch: 3, write_scopes: ["src/**"], + write_repository: todo.task_repository, status: leaseState === "released" ? "released" : "active", expires_at: leaseState === "active" ? "2026-09-25T07:00:00Z" : "2026-09-25T05:00:00Z"}; const seeded = await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, @@ -104,7 +105,6 @@ for (const provider of ["file", "sqlite", "postgresql"] as const) { {...input, expected_provider_revision: "stale:revision"}, {...input, actor_agent_id: "agent-b"}, {...input, actor_agent_id: "unknown"}, - {...input, planning_intent: {required_write_scopes: ["other/**"]}}, ]) { assert.equal((await executeCoordinationTodoUpdate(store, request)).status, "failed"); assert.deepEqual(await head(store), before); @@ -121,4 +121,111 @@ for (const provider of ["file", "sqlite", "postgresql"] as const) { {...input, operation_id, patch: {note: operation_id}, expected_provider_revision: before.provider_revision}))); assert.equal(outcomes.filter(result => result.status === "applied").length, 1); }); + for (const state of [null, "active", "expired", "released"]) test(`${provider}: requirement revision retires old execution in the same commit (${state})`, options, async t => { + const {store, input, lease, todo} = await fixture(t, provider, state); + const revision = {...input, planning_intent: {required_write_scopes: ["src/**", "tests/**"], + required_capabilities: ["filesystem_write"], reason: "Include regression coverage"}}; + const before = await head(store); + const planned = await executeCoordinationTodoUpdate(store, {...revision, dry_run: true}); + assert.equal(planned.status, "planned", JSON.stringify(planned)); + assert.deepEqual(await head(store), before); + const result = await executeCoordinationTodoUpdate(store, revision); + assert.equal(result.status, "applied", JSON.stringify(result)); + const transition = result.work_requirements_transition as JsonObject; + assert.equal(transition.execution_authority_granted, false); + assert.equal(transition.next_execution, "acquire_fresh_lease"); + assert.equal(transition.lease_retirement, state === null ? "absent" : state === "released" ? "already_released" : "released"); + const after = await head(store); + const updated = (after.head.todos as JsonObject[])[0]!; + assert.deepEqual(updated.required_write_scopes, ["src/**", "tests/**"]); + assert.deepEqual(updated.required_capabilities, ["filesystem_write"]); + assert.equal(updated.text, todo.text); + assert.equal(updated.claimed_by, todo.claimed_by); + assert.equal(updated.status, "open"); + assert.equal(updated.completion_validation_required, true); + const retained = (after.head.leases as JsonObject[])[0]; + if (state === null) assert.equal(retained, undefined); + else { + assert.equal(retained!.status, "released"); + for (const field of ["version", "lease_epoch", "idempotency_key", "write_scopes", "write_repository"] as const) + assert.deepEqual(retained![field], lease[field]); + } + const replay = await executeCoordinationTodoUpdate(store, revision); + assert.equal(replay.status, "replayed"); + assert.deepEqual(replay.work_requirements_transition, transition); + assert.deepEqual(await head(store), after); + assert.equal((await executeCoordinationTodoUpdate(store, {...revision, + planning_intent: {required_write_scopes: ["changed/**"]}})).reason_code, "coordination_operation_identity_mismatch"); + assert.deepEqual(await head(store), after); + }); + test(`${provider}: identical requirements preserve the current grant; explicit valid proof may revise it`, options, async t => { + const {store, input} = await fixture(t, provider); + const same = {...input, planning_intent: {required_write_scopes: ["src/**"]}}; + const before = await head(store); + const result = await executeCoordinationTodoUpdate(store, same); + assert.equal(result.status, "applied", JSON.stringify(result)); + assert.equal(result.work_requirements_transition, undefined); + assert.deepEqual((await head(store)).head.leases, before.head.leases); + const changed = await executeCoordinationTodoUpdate(store, {...input, operation_id: "exact-proof", + planning_intent: {required_capabilities: ["filesystem_write"]}, + lease_idempotency_key: "execution", lease_expected_version: 7}); + assert.equal(changed.status, "applied", JSON.stringify(changed)); + assert.equal(((await head(store)).head.leases as JsonObject[])[0]!.status, "released"); + }); + test(`${provider}: requirement edits cannot heal stale proof, combine status/ownership, or bypass CAS`, options, async t => { + const {store, input} = await fixture(t, provider); + const revision = {...input, planning_intent: {required_capabilities: ["filesystem_write"]}}; + const before = await head(store); + for (const request of [ + {...revision, lease_idempotency_key: "stale", lease_expected_version: 7}, + {...revision, lease_idempotency_key: "execution", lease_expected_version: 6}, + {...revision, lease_idempotency_key: "execution"}, + {...revision, lease_expected_version: 7}, + {...revision, actor_agent_id: "agent-b"}, + {...revision, actor_agent_id: "unknown"}, + {...revision, planning_intent: {...revision.planning_intent, status: "blocked", reason: "Bundle"}}, + {...revision, planning_intent: {...revision.planning_intent, claimed_by: "agent-b"}}, + {...revision, expected_provider_revision: "stale:revision"}, + ]) { + assert.equal((await executeCoordinationTodoUpdate(store, request)).status, "failed"); + assert.deepEqual(await head(store), before); + } + const outcomes = await Promise.all(["one", "two"].map(operation_id => executeCoordinationTodoUpdate(store, + {...revision, operation_id, expected_provider_revision: before.provider_revision}))); + assert.equal(outcomes.filter(result => result.status === "applied").length, 1); + }); + for (const state of ["expired", "released"]) test(`${provider}: explicit ${state} proof is not converted into an administrative revision`, options, async t => { + const {store, input} = await fixture(t, provider, state); + const before = await head(store); + const result = await executeCoordinationTodoUpdate(store, {...input, + planning_intent: {required_capabilities: ["filesystem_write"]}, + lease_idempotency_key: "execution", lease_expected_version: 7}); + assert.equal(result.status, "failed", JSON.stringify(result)); + assert.equal((result.recovery as JsonObject).action, "inspect_current_proof"); + assert.deepEqual(await head(store), before); + }); + test(`${provider}: registry revocation cannot leave revised work or a retired lease`, options, async t => { + const {store, input} = await fixture(t, provider); + const before = await head(store); + let reads = 0; + const result = await executeCoordinationTodoUpdate(store, {...input, + planning_intent: {required_write_scopes: ["other/**"]}}, async () => ++reads === 1); + assert.equal(result.reason_code, "authority_source_changed"); + assert.deepEqual(await head(store), before); + assert.equal((await store.readReceipt(input.operation_id)).status, "missing"); + }); + for (const state of ["active", "expired", "released"]) test(`${provider}: a foreign ${state} holder cannot be retired by requirement editing`, options, async t => { + const {store, input} = await fixture(t, provider, state); + const seeded = await head(store); + await store.commitAuthority({operation_id: "foreign-holder", expected_provider_revision: seeded.provider_revision, + next_projection: {...seeded.head, leases: (seeded.head.leases as JsonObject[]).map(lease => ({...lease, owner: "agent-b"}))}, + events: [], receipts: []}); + const before = await head(store); + const result = await executeCoordinationTodoUpdate(store, {...input, + planning_intent: {required_write_scopes: ["other/**"]}}); + assert.equal(result.reason_code, "lease_owner_mismatch", JSON.stringify(result)); + assert.equal((result.recovery as JsonObject).owner_relation, "foreign_owner"); + assert.equal((result.recovery as JsonObject).action, "reconcile_lease_owner"); + assert.deepEqual(await head(store), before); + }); } diff --git a/tests/control_plane_ts/todo_update.test.ts b/tests/control_plane_ts/todo_update.test.ts index 29f82123e4..6c4624d343 100644 --- a/tests/control_plane_ts/todo_update.test.ts +++ b/tests/control_plane_ts/todo_update.test.ts @@ -1082,11 +1082,10 @@ test("recovery preserves stale-proof rejection and never offers foreign proof", assert.deepEqual(await store.loadAuthority(), before); }); -test("inactive history does not invent an executable lease acquisition", async () => { +test("inactive history keeps unsupported lifecycle edits fenced without inventing acquisition", async () => { for (const [overrides, intent, mode, expected] of [ [{status: "blocked"}, {unblocks_todo_id: "todo_other"}, "hard_lease", "resolve_acquire_rejection"], [{claimed_by: null}, {unblocks_todo_id: "todo_other"}, "hard_lease", "reconcile_lease_owner"], - [{}, {required_capabilities: ["code_review"]}, "hard_lease", "resolve_lifecycle_edit"], [{}, {status: "blocked"}, "hard_lease", "resolve_lifecycle_edit"], [{status: "blocked"}, {}, "soft_claim", "resolve_acquire_rejection"], ] as const) { From 796d1b802756226f826355b6f2c4cfa872564501 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:38:29 +0800 Subject: [PATCH 2/7] docs(todo): explain owner requirement revision and fresh acquisition Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/reference/canonical-lease-renew.md | 52 ++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/docs/reference/canonical-lease-renew.md b/docs/reference/canonical-lease-renew.md index 8e9b427abe..04b1a5021e 100644 --- a/docs/reference/canonical-lease-renew.md +++ b/docs/reference/canonical-lease-renew.md @@ -51,7 +51,9 @@ can freeze the current Todo identity. Malformed frozen identities fail closed. Renewal, transfer and release preserve the frozen value and historical receipts. Current execution proof, acquire replay and inspection reject known repository drift with `lease_repository_divergence`; cleanup still uses exact owner/key/version. -Changing retained work requirements is not a metadata-edit shortcut. +Changing retained work requirements is not a metadata-edit shortcut: the +[owner revision lifecycle](#revising-declared-work-requirements) retires the old +grant atomically and requires a fresh acquisition for subsequent execution. JSON inspection exposes `lease.write_repository`; Markdown lease readback also shows the identity or `unknown (conservative overlap)`. This is a Goal-local, @@ -461,6 +463,54 @@ provider revision 暂停并清除等待;读回后再用 新 revision 明确重新打开,最后领取新租约。不能附带旧 proof、任务正文、归属或工作要求修改, 也不能把暂停或重新打开当作执行授权;每步可原子附带备注和证据。 +### Revising declared work requirements + +In canonical **hard-lease** mode, the registered current Todo owner can revise +the existing work-requirement fields through one `todo update`. The same provider +transaction updates the declaration and releases the owner's active or expired +lease. Absent or already released history needs no acquire/release preparation. +The retained key, generation, repository and scopes remain historical facts; +the update grants no execution and does not terminate a running process. + +```bash +loopx todo update --goal-id example-goal --todo-id todo_work --agent-id owner \ + --required-write-scope 'src/**' --required-write-scope 'tests/**' \ + --required-capability filesystem_write --reason 'Include regression coverage' \ + --update-operation-id revise-work +loopx todo list --goal-id example-goal --todo-id todo_work +loopx task-lease inspect --goal-id example-goal --todo-id todo_work +``` + +`work_requirements_transition` reports changed fields, lease retirement and +`execution_authority_granted=false` in the preview, committed result and replay. +Unchanged declarations preserve the current grant. After an actual revision, +acquire a fresh generation with a new idempotency key and explicitly requested +scopes before executing. Acquisition still checks the current Todo, repository, +workspace, Goal policy and competing holders. Repository revisions preserve the +old grant's frozen repository; the next grant binds the revised identity. + +This corrects the former default rejection of every retained requirement edit; +it is limited to the existing hard-lease owner update path. Foreign retained +holders, actor exclusions/bindings, archived/completed tasks and provider or +registry conflicts remain fenced. Supplied proof must match one current active +generation: stale, partial, expired or released proof is rejected and never +silently replaced. Status, ownership, validation and dependency transitions +cannot be bundled into this revision. Optional task copy, note, priority, +reason and evidence may accompany it. Soft-claim behavior is unchanged. + +在 canonical **hard-lease** 模式中,当前已注册的 Todo owner 可通过一次既有 +`todo update` 修订工作要求。同一 provider 事务更新声明并释放本人有效或过期的 +旧租约;没有租约或已经释放时,无需先领取或再次释放。旧 key、代际、仓库和 +写入范围作为历史保留,修改不授予执行权,也不声称终止运行中的进程。 +预览、提交和重放的 `work_requirements_transition` 显示修改字段与租约结果; +相同声明不会结束现有授权。真正修改后,必须用新幂等 key、明确写入范围重新 +申请租约;仓库修订只影响下一次授权,不改写旧授权的仓库身份。 +他人保留的租约、排除/绑定限制、归档/完成、provider 与 registry 冲突仍被拒绝; +显式 proof 必须精确对应当前有效代际,陈旧、不完整、过期或已释放 proof 不会 +被自动替换。状态、归属、验证和依赖转换不能混在本次修订里;可附带普通正文、 +备注、优先级、原因及证据。此处明确改变的是 hard-lease owner 工作要求编辑的 +默认拒绝行为,soft-claim 规则保持原样。 + A successful readback is still a point-in-time proof, not a lock over subsequent external effects. Execution must retain its existing mutation fences; this change does not close the remaining external-effect fencing work. From a80d0e21110e0c9f841437a6f49e5b941a917fc9 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:07:48 +0800 Subject: [PATCH 3/7] fix(todo): preserve owner administration after repository revision Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../coordination/todo_update_admission.ts | 7 +- .../todo_work_requirements_lifecycle.ts | 4 +- .../work_items/task_lease_repository.ts | 13 ++++ .../test_todo_administrative_journey.py | 21 ++++- .../todo_administrative_edits.test.ts | 76 +++++++++++++++++++ 5 files changed, 115 insertions(+), 6 deletions(-) diff --git a/loopx/control_plane/coordination/todo_update_admission.ts b/loopx/control_plane/coordination/todo_update_admission.ts index 4407e49735..c874c4117e 100644 --- a/loopx/control_plane/coordination/todo_update_admission.ts +++ b/loopx/control_plane/coordination/todo_update_admission.ts @@ -2,7 +2,7 @@ * Grants may cross a claim owner; * exclusions, bindings and execution lineage remain independent restrictions. */ import {retiredHandoffModeRejection, HANDOFF_MODE_RETIRED_REASON} from "./handoff_mode_policy.ts"; -import {leaseRepositoryRejection} from "../work_items/task_lease_repository.ts"; +import {leaseRepositoryRejection, ownerAdministrativeRepositoryRejection} from "../work_items/task_lease_repository.ts"; import {isExploreReferenceAppend} from "../todos/field_update.ts"; import {acceptanceRestoration} from "./todo_acceptance_restoration.ts"; import {monitorMutationRejection} from "./todo_monitor_cycle.ts"; @@ -163,7 +163,8 @@ export function todoUpdateAdmissionRejection( if (fence.outcome !== "apply") return withRecovery(reject(String(fence.code), "Explicit execution proof is stale or unavailable; inspect this Todo before retrying")); } if (lease !== undefined) { - const repository = leaseRepositoryRejection(todo, canonicalTaskLease(lease, input.goal_id, input.todo_id)); + const repository = ownerAdministrativeRepositoryRejection(todo, + canonicalTaskLease(lease, input.goal_id, input.todo_id), input.actor_agent_id); if (repository !== null) return withRecovery(reject(repository, "Lifecycle change must preserve the execution repository")); } return withRecovery(blockedLifecycleRejection({goal_id: input.goal_id, todo_id: input.todo_id, @@ -196,7 +197,7 @@ export function todoUpdateAdmissionRejection( if (lease !== undefined) { const current = canonicalTaskLease(lease, input.goal_id, input.todo_id); leaseIsActive(current, input.now); // Validate the retained clock as well. - const repository = leaseRepositoryRejection(todo, current); + const repository = ownerAdministrativeRepositoryRejection(todo, current, input.actor_agent_id); if (repository !== null) return withRecovery(reject(repository, "Administrative edit must preserve the execution repository")); if (current.owner !== input.actor_agent_id) return withRecovery(reject("lease_owner_mismatch", "This Todo retains another holder's execution; reconcile that holder before editing")); diff --git a/loopx/control_plane/coordination/todo_work_requirements_lifecycle.ts b/loopx/control_plane/coordination/todo_work_requirements_lifecycle.ts index 645d770b72..fa49f26ca5 100644 --- a/loopx/control_plane/coordination/todo_work_requirements_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_work_requirements_lifecycle.ts @@ -4,7 +4,7 @@ import type {JsonObject} from "../effect_program.ts"; import {TODO_WORK_REQUIREMENT_FIELDS} from "../todos/work_requirements.ts"; import {leaseIsActive, leaseVersion, leaseEpoch} from "../work_items/task_lease_acquire.ts"; import {releasedTaskLeaseRecord} from "../work_items/task_lease_lifecycle_decision.ts"; -import {leaseRepositoryRejection} from "../work_items/task_lease_repository.ts"; +import {ownerAdministrativeRepositoryRejection} from "../work_items/task_lease_repository.ts"; import {canonicalAuthoritySha256} from "./authority_store_codec.ts"; import type {CoordinationProjectionMutation} from "./coordination_projection.ts"; import {canonicalTaskLease} from "./task_lease_state.ts"; @@ -34,7 +34,7 @@ export function workRequirementsRevisionRejection(todo: JsonObject, input: Coord if (retained === undefined) return null; const lease = canonicalTaskLease(retained, input.goal_id, input.todo_id); leaseIsActive(lease, input.now); // Validate the stored clock even for administrative intent. - const repository = leaseRepositoryRejection(todo, lease); + const repository = ownerAdministrativeRepositoryRejection(todo, lease, input.actor_agent_id); if (repository !== null) return {code: repository, reason: "Inspect the divergent execution repository before revising work"}; if (lease.owner !== input.actor_agent_id) return {code: "lease_owner_mismatch", reason: "Only the current owner may retire its retained execution while revising work requirements"}; diff --git a/loopx/control_plane/work_items/task_lease_repository.ts b/loopx/control_plane/work_items/task_lease_repository.ts index 591216ca3e..92195586d3 100644 --- a/loopx/control_plane/work_items/task_lease_repository.ts +++ b/loopx/control_plane/work_items/task_lease_repository.ts @@ -26,3 +26,16 @@ export function leaseRepositoryRejection(todo: {task_repository?: unknown} | nul return current !== null && frozen.toLowerCase() === current.toLowerCase() ? null : "lease_repository_divergence"; } + +/** A released owner's grant is immutable history during inert administration. + * Actor/claim admission and any explicit execution proof are checked by the + * caller. Execution, foreign history and unreleased drift keep the strict fence. */ +export function ownerAdministrativeRepositoryRejection( + todo: {task_repository?: unknown; claimed_by?: unknown}, + lease: {write_repository?: unknown; status?: unknown; owner?: unknown}, + actorAgentId: string | null, +): string | null { + const rejection = leaseRepositoryRejection(todo, lease); // Validate history before any exception. + return rejection !== null && lease.status === "released" && actorAgentId !== null && + lease.owner === actorAgentId && todo.claimed_by === actorAgentId ? null : rejection; +} diff --git a/tests/control_plane/test_todo_administrative_journey.py b/tests/control_plane/test_todo_administrative_journey.py index 1ff92cf484..7268b2eb5e 100644 --- a/tests/control_plane/test_todo_administrative_journey.py +++ b/tests/control_plane/test_todo_administrative_journey.py @@ -80,7 +80,9 @@ def test_cli_copy_pause_resume_defer_without_lease_bookkeeping(journey): def test_chat_uses_same_atomic_owner_and_refuses_changed_review_basis(journey, tmp_path): cli, todo_id, registry, runtime = journey + cli("todo", "update", "--agent-id", "agent-a", "--task-repository", "git:github.com/example/original") cli("task-lease", "acquire", "--owner", "agent-a", "--idempotency-key", "chat-execution") + cli("todo", "update", "--agent-id", "agent-a", "--task-repository", "git:github.com/example/revised") cli("todo", "update", "--agent-id", "agent-a", "--note", "Original task context") service = ChatActionService(store=ChatActionStore(tmp_path / "actions"), registry_path=registry, runtime_root_override=str(runtime)) @@ -146,6 +148,23 @@ def test_cli_repository_revision_preserves_frozen_history_and_acquires_the_new_i cli("todo", "update", "--agent-id", "agent-a", "--task-repository", "git:github.com/example/revised") retained = cli("task-lease", "inspect")["lease"] assert retained["status"] == "released" and retained["write_repository"] == "git:github.com/example/original" + # Correcting a declaration must leave ordinary task management usable. + # None of these operations starts a new execution or rewrites its history. + for args in [ + ("--note", "Continue after the repository correction"), + ("--required-write-scope", "tests/**"), + ("--status", "blocked", "--reason", "Owner paused"), + ("--status", "open", "--reason", "Owner resumed"), + ("--status", "deferred", "--reason", "Wait", "--resume-when", "resume_at:2099-01-01T00:00:00Z"), + ("--status", "open", "--reason", "Resume deferred work"), + ("--task-repository", "git:github.com/example/corrected"), + ]: + assert cli("todo", "update", "--agent-id", "agent-a", *args)["ok"] + assert cli("task-lease", "inspect")["lease"] == retained + refused = cli("todo", "update", "--agent-id", "agent-a", "--note", "Old proof cannot execute", + "--task-lease-idempotency-key", "original", "--task-lease-expected-version", str(lease["version"]), ok=False) + assert refused["ok"] is False + assert cli("task-lease", "inspect")["lease"] == retained fresh = cli("task-lease", "acquire", "--owner", "agent-a", "--idempotency-key", "revised")["lease"] assert fresh["version"] == lease["version"] + 1 - assert fresh["write_repository"] == "git:github.com/example/revised" + assert fresh["write_repository"] == "git:github.com/example/corrected" diff --git a/tests/control_plane_ts/todo_administrative_edits.test.ts b/tests/control_plane_ts/todo_administrative_edits.test.ts index f7341e7cda..2a638837ef 100644 --- a/tests/control_plane_ts/todo_administrative_edits.test.ts +++ b/tests/control_plane_ts/todo_administrative_edits.test.ts @@ -172,6 +172,82 @@ for (const provider of ["file", "sqlite", "postgresql"] as const) { assert.equal(changed.status, "applied", JSON.stringify(changed)); assert.equal(((await head(store)).head.leases as JsonObject[])[0]!.status, "released"); }); + test(`${provider}: repository correction preserves history and subsequent owner administration`, options, async t => { + const {store, input, lease} = await fixture(t, provider); + const correction = {...input, operation_id: "repository-correction", patch: {}, + planning_intent: {task_repository: "git:github.com/example/revised"}}; + assert.equal((await executeCoordinationTodoUpdate(store, correction)).status, "applied"); + const retired = (await head(store)).head.leases; + assert.deepEqual((retired as JsonObject[])[0], {...lease, status: "released", + released_at: NOW.toISOString().replace(".000Z", "Z"), updated_at: NOW.toISOString().replace(".000Z", "Z")}); + const steps = [ + {patch: {note: "Continue administration"}, planning_intent: {}}, + {patch: {}, planning_intent: {required_write_scopes: ["tests/**"]}}, + {patch: {}, planning_intent: {status: "blocked", reason: "Pause"}}, + {patch: {}, planning_intent: {status: "open", reason: "Resume"}}, + {patch: {}, planning_intent: {status: "deferred", reason: "Wait", resume_when: "resume_at:2099-01-01T00:00:00Z"}}, + {patch: {}, planning_intent: {status: "open", reason: "Resume deferred work"}}, + {patch: {}, planning_intent: {task_repository: "git:github.com/example/corrected"}}, + ]; + for (const [index, step] of steps.entries()) { + const request = {...input, ...step, operation_id: `continue-${index}`}; + const before = await head(store); + assert.equal((await executeCoordinationTodoUpdate(store, {...request, dry_run: true})).status, "planned"); + assert.deepEqual(await head(store), before); + const result = await executeCoordinationTodoUpdate(store, request); + assert.equal(result.status, "applied", JSON.stringify(result)); + const after = await head(store); + assert.deepEqual(after.head.leases, retired); + assert.equal((await executeCoordinationTodoUpdate(store, request)).status, "replayed"); + assert.deepEqual(await head(store), after); + for (const proof of [ + {lease_idempotency_key: "execution", lease_expected_version: 7}, + {lease_idempotency_key: "execution"}, + ]) { + const refused = await executeCoordinationTodoUpdate(store, {...request, ...proof, operation_id: `old-proof-${index}`}); + assert.equal(refused.status, "failed", JSON.stringify(refused)); + assert.deepEqual(await head(store), after); + } + } + const final = (await head(store)).head.todos as JsonObject[]; + assert.equal(final[0]!.task_repository, "git:github.com/example/corrected"); + assert.equal(final[0]!.status, "open"); + assert.equal(final[0]!.claimed_by, "agent-a"); + assert.equal(final[0]!.completion_validation_required, true); + // Retrying the original correction reports history without undoing later work. + const beforeReplay = await head(store); + assert.equal((await executeCoordinationTodoUpdate(store, correction)).status, "replayed"); + assert.deepEqual(await head(store), beforeReplay); + }); + for (const boundary of ["active", "expired", "foreign", "corrupt", "excluded", "bound"] as const) { + test(`${provider}: released-history recovery cannot hide ${boundary} repository constraints`, options, async t => { + const {store, input, todo} = await fixture(t, provider, + boundary === "active" || boundary === "expired" ? boundary : "released"); + const seeded = await head(store); + const current = {...todo, task_repository: "git:github.com/example/revised", + ...(boundary === "excluded" ? {excluded_agents: ["agent-a"]} : {}), + ...(boundary === "bound" ? {bound_agent: "agent-b"} : {})}; + const leases = (seeded.head.leases as JsonObject[]).map(lease => ({...lease, + ...(boundary === "foreign" ? {owner: "agent-b"} : {}), + ...(boundary === "corrupt" ? {write_repository: "https://github.com/example/project.git"} : {})})); + assert.equal((await store.commitAuthority({operation_id: "retained-repository-state", + expected_provider_revision: seeded.provider_revision, events: [], receipts: [], + next_projection: {...seeded.head, todos: [current], leases, + todo_read_model: coordinationTodoReadModel([current], TODO_DOMAIN_READ_RECORD_SCHEMA)}})).status, "applied"); + const before = await head(store); + for (const [index, step] of [ + {patch: {note: "Cannot bypass holder"}, planning_intent: {}}, + {patch: {}, planning_intent: {required_write_scopes: ["tests/**"]}}, + {patch: {}, planning_intent: {status: "blocked", reason: "Pause"}}, + {patch: {}, planning_intent: {status: "deferred", reason: "Wait", resume_when: "resume_at:2099-01-01T00:00:00Z"}}, + ].entries()) { + const request = {...input, ...step, operation_id: `refuse-${index}`}; + assert.equal((await executeCoordinationTodoUpdate(store, request)).status, "failed"); + assert.deepEqual(await head(store), before); + assert.equal((await store.readReceipt(request.operation_id)).status, "missing"); + } + }); + } test(`${provider}: requirement edits cannot heal stale proof, combine status/ownership, or bypass CAS`, options, async t => { const {store, input} = await fixture(t, provider); const revision = {...input, planning_intent: {required_capabilities: ["filesystem_write"]}}; From 4085d78ce527ab6254f1e6a56dbfb6889917cfd6 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:08:11 +0800 Subject: [PATCH 4/7] docs(todo): distinguish released history from execution constraints Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/reference/canonical-lease-renew.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/reference/canonical-lease-renew.md b/docs/reference/canonical-lease-renew.md index 04b1a5021e..ea02febe29 100644 --- a/docs/reference/canonical-lease-renew.md +++ b/docs/reference/canonical-lease-renew.md @@ -488,6 +488,11 @@ acquire a fresh generation with a new idempotency key and explicitly requested scopes before executing. Acquisition still checks the current Todo, repository, workspace, Goal policy and competing holders. Repository revisions preserve the old grant's frozen repository; the next grant binds the revised identity. +That released history does not block the same claim owner's subsequent notes, +pause/resume or further requirement/repository corrections without execution +proof. These operations retain the entire old grant unchanged. An unreleased +divergent grant, foreign retained holder, malformed repository or explicit old +proof still rejects; the exception never applies to execution admission. This corrects the former default rejection of every retained requirement edit; it is limited to the existing hard-lease owner update path. Foreign retained @@ -505,6 +510,9 @@ reason and evidence may accompany it. Soft-claim behavior is unchanged. 预览、提交和重放的 `work_requirements_transition` 显示修改字段与租约结果; 相同声明不会结束现有授权。真正修改后,必须用新幂等 key、明确写入范围重新 申请租约;仓库修订只影响下一次授权,不改写旧授权的仓库身份。 +已释放的旧仓库历史不再阻断同一 claim owner 不带执行 proof 的后续备注、暂停/恢复、 +范围修订及再次纠正仓库;这些操作完整保留旧授权。未释放但仓库不符的授权、他人保留 +的租约、损坏仓库与显式旧 proof 仍被拒绝,此例外不用于执行准入。 他人保留的租约、排除/绑定限制、归档/完成、provider 与 registry 冲突仍被拒绝; 显式 proof 必须精确对应当前有效代际,陈旧、不完整、过期或已释放 proof 不会 被自动替换。状态、归属、验证和依赖转换不能混在本次修订里;可附带普通正文、 From f2b7a5b0eb4c0017820af42fc5dbb79ffddca925 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:02:26 +0800 Subject: [PATCH 5/7] feat(workspace): save complete task requirements in place Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../dashboard/src/data/action-source-basis.ts | 4 +- apps/presentation/dashboard/src/data/chat.ts | 3 +- .../personal-workspace/context-drawer.tsx | 3 +- .../src/features/personal-workspace/i18n.tsx | 22 +++++ .../personal-workspace-model.ts | 2 + .../personal-workspace-page.tsx | 49 +++++++---- .../personal-workspace/personal-workspace.css | 8 ++ .../personal-workspace/task-request.tsx | 83 ++++++++++++++++--- loopx/chat_action_normalization.py | 14 +++- loopx/chat_todo_actions.py | 2 + loopx/chat_todo_detail.py | 13 +++ 11 files changed, 174 insertions(+), 29 deletions(-) diff --git a/apps/presentation/dashboard/src/data/action-source-basis.ts b/apps/presentation/dashboard/src/data/action-source-basis.ts index 622cc311b7..1f2baff596 100644 --- a/apps/presentation/dashboard/src/data/action-source-basis.ts +++ b/apps/presentation/dashboard/src/data/action-source-basis.ts @@ -9,8 +9,10 @@ const todoBasis = { // Decode existing backend contracts; effect authority and stale-source checks // remain in ChatActionService and the owning Todo/Goal services. +export const todoUpdateSourceBasisSchema = z.object({ schema_version: z.literal("loopx_chat_canonical_update_basis_v0"), ...todoBasis }); +export type TodoUpdateSourceBasis = z.infer; export const actionSourceBasisSchema = z.discriminatedUnion("schema_version", [ - z.object({ schema_version: z.literal("loopx_chat_canonical_update_basis_v0"), ...todoBasis }), + todoUpdateSourceBasisSchema, z.object({ schema_version: z.literal("loopx_chat_canonical_terminal_basis_v0"), ...todoBasis }), z.object({ schema_version: z.literal("loopx_goal_lifecycle_source_basis_v1"), diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 62fd4f375e..62fd631c53 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -3,7 +3,7 @@ import { HANDOFF_MODES, EXECUTION_HANDOFF_MODES, type ExecutionHandoffMode } fro import { normalizeGoalDraft } from "../../../../../loopx/control_plane/collaboration/goal_draft.js"; import { parseTurnStep, type TurnStep } from "./turn-steps.js"; import { z } from "zod"; -import { actionSourceBasisSchema } from "./action-source-basis.js"; +import { actionSourceBasisSchema, todoUpdateSourceBasisSchema } from "./action-source-basis.js"; import { todoApplyResultMatchesRequest, @@ -651,6 +651,7 @@ export async function readTodoRequest(goalId: string, todoId: string, signal: Ab const result = z.object({ ok: z.literal(true), goal_id: z.string(), todo_id: z.string(), text: z.string(), status: z.string(), archive_state: z.string(), updated_at: z.string().nullable(), + review_basis: todoUpdateSourceBasisSchema.optional(), }).parse(await requestJson(`/api/chat/todo/detail?${query}`, {signal})); if (result.goal_id !== goalId || result.todo_id !== todoId) throw new Error("Task source changed"); return result; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx index 4da8669fcf..566c20213c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -651,7 +651,8 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention {selection.item.priority ? {selection.item.priority} : null} {selection.item.taskClass === "advancement_task" ? t("drawer.taskAdvancement") : selection.item.taskClass ?? t("drawer.taskOrdinary")} - + {selection.item.status === "blocked" ?

{t("drawer.blockedReason")}

{selection.item.blockedReason ?? t("drawer.blockedReasonMissing")}

diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 07e6c6acfc..7dc173ee70 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -524,6 +524,17 @@ const en = { "drawer.taskDeferUntil": "Defer until", "drawer.taskDetails": "Todo details", "drawer.taskInfo": "Task information", + "drawer.requirementsEdit": "Edit requirements", + "drawer.requirementsLabel": "Complete task requirements", + "drawer.requirementsSave": "Save requirements", + "drawer.requirementsSaving": "Saving…", + "drawer.requirementsReadback": "Retry readback", + "drawer.requirementsEffect": "Update this task only. Existing acceptance may become stale; this does not start or stop a process or grant execution permission.", + "drawer.requirementsNotSaved": "The requirements were not verified as saved. Your draft is retained.", + "drawer.requirementsReadbackChanged": "The saved requirements could not be confirmed. Read the current source before editing again.", + "drawer.requirementsTooLong": "The action journal supports up to 8,000 characters. The original requirements are retained in full.", + "drawer.requirementsReload": "Read current requirements and retain draft", + "drawer.requirementsSaved": "Requirements saved and read back. Work was not started.", "drawer.requestLoading": "Showing the summary while the full request loads…", "drawer.requestError": "Only the summary is available. The full request could not be read.", "drawer.requestLocalOnly": "This source provides a summary. Open its local workspace to read the full request.", @@ -2082,6 +2093,17 @@ const zhCN: Record = { "drawer.taskDeferUntil": "暂缓至", "drawer.taskDetails": "Todo 详情", "drawer.taskInfo": "任务信息", + "drawer.requirementsEdit": "修改要求", + "drawer.requirementsLabel": "完整任务要求", + "drawer.requirementsSave": "保存要求", + "drawer.requirementsSaving": "正在保存…", + "drawer.requirementsReadback": "重试读回", + "drawer.requirementsEffect": "只修改此任务。已有验收可能失效;不会启动或停止进程,也不授予执行权限。", + "drawer.requirementsNotSaved": "尚未核实保存成功,草稿已保留。", + "drawer.requirementsReadbackChanged": "未能核实保存后的要求,请读取当前来源后再修改。", + "drawer.requirementsTooLong": "操作记录最多支持 8,000 字符,原始要求仍完整保留。", + "drawer.requirementsReload": "读取当前要求并保留草稿", + "drawer.requirementsSaved": "要求已保存并读回,未启动工作。", "drawer.requestLoading": "正在读取完整要求,暂时显示摘要…", "drawer.requestError": "当前仅有摘要,完整要求读取失败。", "drawer.requestLocalOnly": "此来源提供摘要,请在它的本机工作区读取完整要求。", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index d8eaf2c340..e80db3bcca 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -7,6 +7,7 @@ import type { GoalAcceptanceObservation } from "../../data/goal-acceptance-obser import type { AttentionDetails } from "./attention-details"; import type { WorkspaceLoadError, WorkspaceReadScope } from "../../data/workspace-progressive-status"; import type { TodoItem } from "../../data/status"; +import type { TodoUpdateSourceBasis } from "../../data/action-source-basis"; import { goalWorkKind, type GoalHostThreadActivity, type WorkspaceGoalExecution } from "./goal-activity"; import { receiptRetryReadback } from "./monitor-readback"; import type { DigestAttentionSummary } from "../../../../../../loopx/control_plane/presentation/goal_workspace_digest.js"; @@ -530,6 +531,7 @@ export type WorkspaceGoalArchiveLoadState = | { phase: "error"; error: string }; export type PersonalWorkspaceCallbacks = { + onSaveTodoRequirements?: (todo: WorkspaceTodo, text: string, basis: TodoUpdateSourceBasis, retry?: WorkspaceActionPreview) => Promise; onApplyProposal?: (proposal: WorkspaceActionPreview) => void | Promise; onApplyAttention?: (attention: WorkspaceAttention) => void; onCancelProposal?: (proposal: WorkspaceActionPreview) => void; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 689ee4db52..d029d66c77 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -1840,13 +1840,17 @@ export function PersonalWorkspacePage({ options: { keepGoalSelected?: boolean; lifecycleProjection?: GoalLifecycleProjection; - presentation?: "drawer" | "feedback"; + presentation?: "drawer" | "feedback" | "inline"; } = {}, ) { - if (proposal.reviewPlan && !proposal.reviewPlan.canApply) return; + if (proposal.reviewPlan && !proposal.reviewPlan.canApply) return proposal; if (proposal.actionKind === "team.plan" && proposal.status === "error" - && !proposal.reviewPlan?.retryOriginal) return; - const showDrawer = options.presentation !== "feedback"; + && !proposal.reviewPlan?.retryOriginal) return proposal; + const matchesTodoTarget = (observed: TypedActionProposal) => proposal.actionKind !== "todo.update" || ( + observed.normalized_parameters.goal_id === proposal.goalId + && (!proposal.sourceRequest || observed.normalized_parameters.todo_id === proposal.sourceRequest.normalizedParameters.todo_id) + ); + const showDrawer = options.presentation !== "feedback" && options.presentation !== "inline"; const inferredLifecycleChange = proposal.actionKind === "goal.lifecycle" && proposal.goalId && (proposal.lifecycleOperation === "stop" || proposal.lifecycleOperation === "resume") @@ -1885,11 +1889,12 @@ export function PersonalWorkspacePage({ if (proposal.actionKind === "todo.update") { void reconcileStatus(proposal.goalId ? [proposal.goalId] : undefined); } - return; + return applied; } const result = await applyTypedAction(proposal.previewId); if (result.proposal.proposal_id !== proposal.previewId || result.proposal.action_kind !== proposal.actionKind + || !matchesTodoTarget(result.proposal) || (proposal.actionKind === "goal.lifecycle" && ( result.proposal.normalized_parameters.goal_id !== proposal.goalId || lifecycleOperationFor(result.proposal) !== proposal.lifecycleOperation @@ -1904,7 +1909,7 @@ export function PersonalWorkspacePage({ if (lifecycleChange) { callbacks.onGoalActivationStateChange?.(lifecycleChange.goalId, lifecycleChange.previous); } - setSelection({ item: applied, kind: "proposal" }); + if (options.presentation !== "inline") setSelection({ item: applied, kind: "proposal" }); setActionFeedback( result.proposal.status === "stale" ? t("feedback.stale") @@ -1912,7 +1917,7 @@ export function PersonalWorkspacePage({ ? t("proposal.teamPlan.retryUnavailable") : t(`actionReview.${applied.reviewPlan!.reason}`), ); - return; + return applied; } setActionFeedback(t("feedback.completed", { title: applied.title })); // Keep the success receipt visible for reviewed actions. Direct actions @@ -1929,6 +1934,7 @@ export function PersonalWorkspacePage({ if (applied.actionKind === "goal.lifecycle" || applied.actionKind === "gate.resolve" || applied.actionKind === "todo.update") { void reconcileStatus(applied.goalId ? [applied.goalId] : undefined); } + return applied; } catch (error) { if (lifecycleChange) { callbacks.onGoalActivationStateChange?.(lifecycleChange.goalId, lifecycleChange.previous); @@ -1955,13 +1961,13 @@ export function PersonalWorkspacePage({ setProposals((current) => ({ ...current, [proposal.previewId]: gated })); // A newly discovered authority gate always deserves review, including // when the action started on the direct path. - setSelection({ item: gated, kind: "proposal" }); + if (options.presentation !== "inline") setSelection({ item: gated, kind: "proposal" }); setActionFeedback(t("feedback.gateRequired", { summary: gated.gate.summary })); if (proposal.actionKind === "goal.create" && proposal.goalId) { callbacks.onRefresh?.(); selectGoal(proposal.goalId); } - return; + return gated; } const stale = error instanceof ChatApiError && isStaleActionFailure(error.payload); const readbackMismatch = error instanceof ChatApiError && error.payload.error_code === "action_response_mismatch"; @@ -1970,24 +1976,25 @@ export function PersonalWorkspacePage({ const returned = error instanceof ChatApiError ? typedActionProposalSchema.safeParse(error.payload.proposal) : null; let stored = returned?.success ? returned.data : undefined; - if (!stored && ["team.plan", "goal.create"].includes(proposal.actionKind) && !readbackMismatch) { + if (!stored && ["team.plan", "goal.create", "todo.update"].includes(proposal.actionKind) && !readbackMismatch) { const readback = await actionReadback.refetch(); // Query errors retain cached data; it cannot certify this attempt. if (readback.isSuccess) stored = readback.data?.find(item => item.proposal_id === proposal.previewId); } if (stored?.proposal_id === proposal.previewId && stored.action_kind === proposal.actionKind - && (!["team.plan", "goal.create"].includes(proposal.actionKind) || stored.normalized_parameters.goal_id === proposal.goalId)) { + && matchesTodoTarget(stored) + && (!["team.plan", "goal.create", "todo.update"].includes(proposal.actionKind) || stored.normalized_parameters.goal_id === proposal.goalId)) { await actionReadback.acceptProposal(stored); const observed = workspaceProposal(stored, t); const reconciled = { ...observed, errorMessage: observed.status === "error" ? error instanceof Error ? error.message : String(error) : undefined }; setProposals(current => ({ ...current, [proposal.previewId]: reconciled })); - setSelection({ item: reconciled, kind: "proposal" }); + if (options.presentation !== "inline") setSelection({ item: reconciled, kind: "proposal" }); if (observed.reviewPlan?.creationProgress?.goalId) void callbacks.onRefresh?.(); setActionFeedback(stored.status === "stale" ? t("feedback.stale") : observed.reviewPlan?.interaction === "completed" ? t("feedback.completed", { title: observed.title }) : t("feedback.executionFailed", { error: error instanceof Error ? error.message : String(error) })); - return; + return reconciled; } const failed = { ...proposal, @@ -1998,8 +2005,9 @@ export function PersonalWorkspacePage({ status: (stale ? "stale" : "error") as "stale" | "error", }; setProposals((current) => ({ ...current, [proposal.previewId]: failed })); - setSelection({ item: failed, kind: "proposal" }); + if (options.presentation !== "inline") setSelection({ item: failed, kind: "proposal" }); setActionFeedback(t("feedback.executionFailed", { error: failed.errorMessage })); + return failed; } } @@ -2036,7 +2044,18 @@ export function PersonalWorkspacePage({ else setSelectedGoalTab("files"); callbacks.onOpenOutput?.(output); }, - onApplyProposal: applyProposal, + onApplyProposal: async proposal => { await applyProposal(proposal); }, + onSaveTodoRequirements: async (todo, text, basis, retry) => { + if (retry?.status === "applied") return retry; + const proposal = retry ?? await createPreview({actionKind: "todo.update", + context: {goal_id: todo.goalId, kind: "todo", todo_id: todo.sourceTodoId ?? todo.todoId}, + idempotencyKey: `workspace-todo-requirements-${todo.todoId}-${Date.now().toString(36)}`, + normalizedParameters: {goal_id: todo.goalId, todo_id: todo.sourceTodoId ?? todo.todoId, + ...(todo.claimedBy ? {agent_id: todo.claimedBy} : {}), operation: "edit", text, review_basis: basis}, + summary: t("drawer.requirementsSave"), + }, {select: false}); + return applyProposal(proposal, {presentation: "inline"}); + }, onExplainDecision: callbacks.onExplainDecision ?? ((attention) => draftAttentionMessage(attention, "explain")), onReplyToAttention: callbacks.onReplyToAttention ?? draftAttentionMessage, onCancelProposal: async (proposal) => { diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index a48aa2b6ec..0a2c4d4138 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -1235,6 +1235,14 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli .personal-context-drawer[data-context-kind="todo"] .personal-drawer-body { padding: 0; } .personal-task-inspector-summary { padding: 24px 24px 20px; border-bottom: 1px solid var(--pw-line); background: #fff; } .personal-task-inspector-summary h3 { margin: 14px 0 0; font-size: var(--text-heading); font-weight: 600; letter-spacing: -.015em; line-height: var(--leading-heading); overflow-wrap: anywhere; } +.personal-task-requirements-editor { display: grid; gap: 12px; margin-top: 16px; } +.personal-task-requirements-editor label { display: grid; gap: 8px; font-weight: 500; } +.personal-task-requirements-editor textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 12px; border: 1px solid var(--pw-line-strong); border-radius: 6px; color: var(--pw-text); background: var(--pw-card); font: inherit; line-height: var(--leading-prose); resize: vertical; } +.personal-task-requirements-editor p { margin: 0; color: var(--pw-muted); font-size: var(--text-caption); } +.personal-task-requirements-editor > div { display: flex; flex-wrap: wrap; gap: 8px; } +.personal-task-requirements-editor [role="alert"] { display: grid; } +.personal-task-requirements-editor [role="alert"] p { color: var(--pw-red); } +.personal-drawer-body:has(.personal-task-requirements-editor) > .personal-task-inspector-actions { display: none; } .personal-task-inspector-status { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; } .personal-task-inspector-status > span { display: inline-flex; align-items: center; gap: 6px; min-height: 24px; padding: 2px 8px; border-radius: 6px; background: #f1f2f5; color: #62697a; font-size: var(--text-caption); line-height: var(--leading-caption); font-weight: 600; } .personal-task-inspector-status > span:first-child { padding-left: 7px; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx b/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx index bec4c37ee6..0bf2f28f49 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx @@ -1,39 +1,102 @@ -import {useEffect, useState} from "react"; +import {useEffect, useRef, useState} from "react"; import {readTodoRequest} from "../../data/chat"; +import type {TodoUpdateSourceBasis} from "../../data/action-source-basis"; import {useWorkspaceI18n} from "./i18n"; import {MarkdownText} from "./markdown"; -import type {WorkspaceTodo} from "./personal-workspace-model"; +import type {PersonalWorkspaceCallbacks, WorkspaceActionPreview, WorkspaceTodo} from "./personal-workspace-model"; -// Per-open read state, never a second Task store. A different selection or -// source invalidates both the in-flight read and its earlier successful body. +// Per-open read/draft state, never a second Task or execution owner. type RequestRead = {identity: string} & ( - | {phase: "loading" | "error"; text?: never} - | {phase: "ready"; text: string} + | {phase: "loading" | "error"; detail?: never} + | {phase: "ready"; detail: Awaited>} ); -export function TaskRequest({todo, local}: {todo: WorkspaceTodo; local: boolean}) { +export function TaskRequest({todo, local, onSave}: {todo: WorkspaceTodo; local: boolean; + onSave?: PersonalWorkspaceCallbacks["onSaveTodoRequirements"]}) { const {t} = useWorkspaceI18n(); const sourceId = todo.sourceTodoId; const identity = JSON.stringify([local, todo.goalId, sourceId, todo.requestText, todo.status]); const [read, setRead] = useState(null); const [retry, setRetry] = useState(0); + const [draft, setDraft] = useState<{text: string; basis: TodoUpdateSourceBasis} | null>(null); + const [pending, setPending] = useState(null); + const [saving, setSaving] = useState(false); + const [failure, setFailure] = useState(null); + const [saved, setSaved] = useState(false); + const savingRef = useRef(false); + const rebaseRef = useRef(false); + const mounted = useRef(true); + useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); useEffect(() => { if (!local || !sourceId) return; const controller = new AbortController(); setRead({identity, phase: "loading"}); - void readTodoRequest(todo.goalId, sourceId, controller.signal).then(result => { - if (!controller.signal.aborted) setRead({identity, phase: "ready", text: result.text}); + void readTodoRequest(todo.goalId, sourceId, controller.signal).then(detail => { + if (controller.signal.aborted) return; + setRead({identity, phase: "ready", detail}); + if (rebaseRef.current && detail.review_basis) { + const basis = detail.review_basis; + setDraft(previous => previous ? {...previous, basis} : null); + setPending(null); + setFailure(null); + } + rebaseRef.current = false; }).catch(() => { if (!controller.signal.aborted) setRead({identity, phase: "error"}); }); return () => controller.abort(); }, [identity, local, retry, todo.goalId, sourceId]); const current = local && read?.identity === identity ? read : null; + const detail = current?.phase === "ready" ? current.detail : null; + const editable = local && onSave && todo.claimedBy && sourceId && detail?.review_basis + && detail.archive_state === "active" && detail.status !== "done"; + async function save() { + if (!onSave || !draft || !sourceId || savingRef.current) return; + savingRef.current = true; + setSaving(true); setFailure(null); setSaved(false); + try { + const text = draft.text.replace(/\s+/gu, " ").trim(); + const proposal = await onSave(todo, text, draft.basis, + pending?.status === "applied" || pending?.reviewPlan?.retryOriginal || pending?.reviewPlan?.canApply ? pending : undefined); + if (!mounted.current) return; + setPending(proposal); + if (proposal.status !== "applied" || proposal.reviewPlan?.interaction !== "completed") { + throw new Error(proposal.errorMessage ?? proposal.gate?.summary + ?? (proposal.reviewPlan ? t(`actionReview.${proposal.reviewPlan.reason}`) : t("drawer.requirementsNotSaved"))); + } + const observed = await readTodoRequest(todo.goalId, sourceId, new AbortController().signal); + if (!mounted.current) return; + setRead({identity, phase: "ready", detail: observed}); + if (observed.text !== text) throw new Error(t("drawer.requirementsReadbackChanged")); + setDraft(null); setPending(null); setSaved(true); + } catch (error) { + if (mounted.current) setFailure(error instanceof Error ? error.message : String(error)); + } finally { + savingRef.current = false; + if (mounted.current) setSaving(false); + } + } return
- + {!draft ? : null} {current?.phase !== "ready" ?
{t(!local ? "drawer.requestLocalOnly" : !sourceId ? "drawer.requestUnidentified" : current?.phase === "error" ? "drawer.requestError" : "drawer.requestLoading")} {current?.phase === "error" ? : null}
: null} + {editable && !draft ? : null} + {draft ?
{event.preventDefault(); void save();}}> +