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
2 changes: 1 addition & 1 deletion loopx/cli_commands/todo_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def _route_native_recovery(
value = descriptor.get(field) or getattr(args, field, None)
if flag not in arguments and value is not None:
arguments.extend(cli_option_args(flag, value))
if arguments[:1] == ["todo"]:
if step in {"claim", "retry"} and arguments[start:start + 2] == command[1:3]:
for flag, field in (("--agent-id", "agent_id"), ("--role", "role"),
("--project", "project"), ("--state-file", "state_file")):
value = getattr(args, field, None)
Expand Down
26 changes: 20 additions & 6 deletions loopx/control_plane/coordination/claim_execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,38 +26,52 @@ type ExecutionInput = {goal_id: string; todo_id: string; actor_agent_id: string

export type ClaimWorkspaceObserver = (writeScopes: readonly string[]) => Promise<LeaseWorkspace>;

/** A diagnostic distinction, never permission to renew or adopt another grant. */
export class ClaimExecutionUnavailable extends AuthorityStoreProtocolError {
readonly sameExecutionExpired: boolean;
constructor(message: string, sameExecutionExpired = false) {
super(message);
this.sameExecutionExpired = sameExecutionExpired;
}
}

/** Resolve only this accepted execution. No acquisition, adoption of a newer
* generation, or write retry is hidden behind a progress/completion request. */
export async function claimExecutionProof(store: AuthorityStore, head: JsonObject,
input: ExecutionInput, operationId: string, allowReleasedCloseout = false): Promise<{lease_idempotency_key: string; lease_expected_version: number} | null> {
const receipt = await store.readReceipt(operationId);
if (receipt.status !== "found" || receipt.receipts.length !== 1) {
throw new AuthorityStoreProtocolError("Original task claim unavailable; recover that claim before writing");
throw new ClaimExecutionUnavailable("Original task claim unavailable; recover that claim before writing");
}
const original = receipt.receipts[0];
const result = canonicalAuthorityObject(original.result, "original task claim");
if (original.schema_version !== "loopx_coordination_todo_claim_receipt_v0" ||
original.operation_id !== operationId || original.goal_id !== input.goal_id ||
result.todo_id !== input.todo_id || result.claimed_by !== input.actor_agent_id ||
result.execution_managed !== true) throw new AuthorityStoreProtocolError(
result.execution_managed !== true) throw new ClaimExecutionUnavailable(
"The claim reference does not name this actor's managed task execution");
// A soft claim has no execution lease. Its normal writer still checks policy.
if (result.handoff_mode !== "hard_lease") {
if (result.handoff_mode === "soft_claim" && head.handoff_mode === "soft_claim") return null;
throw new AuthorityStoreProtocolError("Task ownership policy changed; review a new task claim");
throw new ClaimExecutionUnavailable("Task ownership policy changed; review a new task claim");
}
if (head.handoff_mode !== "hard_lease") throw new AuthorityStoreProtocolError("Task ownership policy changed; review the current task before continuing");
if (head.handoff_mode !== "hard_lease") throw new ClaimExecutionUnavailable("Task ownership policy changed; review the current task before continuing");
const accepted = canonicalTaskLease(canonicalAuthorityObject(result.lease, "accepted execution"), input.goal_id, input.todo_id);
const index = indexCoordinationProjection(head, input.goal_id);
const raw = index.leases.get(input.todo_id);
const current = raw && canonicalTaskLease(raw, input.goal_id, input.todo_id);
const retainedCloseout = allowReleasedCloseout && current?.status === "released" &&
index.todos.get(input.todo_id)?.status === "done";
if (accepted.owner !== input.actor_agent_id || !current || !(leaseIsActive(current, input.now) || retainedCloseout) || current.owner !== input.actor_agent_id || current.idempotency_key !== accepted.idempotency_key ||
const active = current !== undefined && leaseIsActive(current, input.now);
if (accepted.owner !== input.actor_agent_id || !current || current.owner !== input.actor_agent_id || current.idempotency_key !== accepted.idempotency_key ||
leaseEpoch(current) !== leaseEpoch(accepted) || leaseVersion(current) < leaseVersion(accepted) ||
!sameLeaseWorkspace(current.write_workspace, accepted.write_workspace) ||
leaseWriteRepository(current.write_repository) !== leaseWriteRepository(accepted.write_repository)) {
throw new AuthorityStoreProtocolError("Original task execution changed; reconcile task ownership before a new claim");
throw new ClaimExecutionUnavailable("Original task execution changed; reconcile task ownership before a new claim");
}
if (!active && !retainedCloseout) {
throw new ClaimExecutionUnavailable("Original task execution changed; reconcile task ownership before a new claim",
current.status === "active");
}
return {lease_idempotency_key: String(current.idempotency_key), lease_expected_version: leaseVersion(current)};
}
Expand Down
51 changes: 51 additions & 0 deletions loopx/control_plane/coordination/claim_execution_recovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/** Reuse ordinary claim admission for actionable, effect-free expiry guidance. */
import type {JsonObject} from "../effect_program.ts";
import type {AuthorityStore} from "./authority_store.ts";
import type {AuthoritySourceCheck} from "./authority_source.ts";
import {canonicalAuthoritySha256} from "./authority_store_codec.ts";
import {ClaimExecutionUnavailable, type ClaimWorkspaceObserver} from "./claim_execution.ts";
import {executeCoordinationTodoClaim, type CoordinationTodoClaimInput} from "./todo_claim.ts";

type RecoveryInput = Pick<CoordinationTodoClaimInput, "goal_id" | "todo_id" | "actor_agent_id" |
"registered_agents" | "expected_role" | "operation_id" | "now"> & {
claim_operation_id: string; expected_provider_revision: string;
};

export async function claimExecutionRecovery(store: AuthorityStore, input: RecoveryInput,
command: "update" | "complete", error: unknown,
authoritySourcesCurrent: AuthoritySourceCheck,
observeWorkspace?: ClaimWorkspaceObserver): Promise<JsonObject | undefined> {
if (!(error instanceof ClaimExecutionUnavailable)) return undefined;
if (!error.sameExecutionExpired || input.actor_agent_id === null) {
return {action: "reconcile_lease_owner", execution_authority_granted: false,
reason: "Inspect this Todo and reconcile its original claim and current ownership before preparing a new claim. Do not replace the original execution proof with another grant's key or version.",
inspect: {command: "loopx todo list", cli_args: ["todo", "list", "--goal-id", input.goal_id,
"--todo-id", input.todo_id]}};
}
// Pin the failed observation. A newer head must not make another execution
// look like recovery of the expired one. Real claim still rechecks its CAS.
const preview = await executeCoordinationTodoClaim(store, {
goal_id: input.goal_id, todo_id: input.todo_id, actor_agent_id: input.actor_agent_id,
claimed_by: input.actor_agent_id, expected_role: input.expected_role,
registered_agents: input.registered_agents, manage_execution: true, dry_run: true,
operation_id: `claim-recovery-preview-${canonicalAuthoritySha256({operation_id: input.operation_id,
claim_operation_id: input.claim_operation_id, command}).slice(0, 32)}`,
expected_provider_revision: input.expected_provider_revision, now: input.now,
}, authoritySourcesCurrent, observeWorkspace).catch(() => null);
// An optional observation failure must not hide the original proof refusal.
if (preview === null) return undefined;
if (preview.dry_run === true && ["planned", "no_change"].includes(String(preview.status))) {
return {action: "claim_task", lease_state: "expired", execution_authority_granted: false,
reason: "The original execution expired. Explicitly run a new ordinary Todo claim, then retry the unchanged operation with its returned claim_operation_id. Do not reuse the expired claim id. Claim rechecks current ownership and eligibility; this preview grants no execution authority.",
claim: {command: "loopx todo claim", cli_args: ["todo", "claim", "--goal-id", input.goal_id,
"--todo-id", input.todo_id, "--agent-id", input.actor_agent_id, "--claimed-by", input.actor_agent_id]},
retry: {command: `loopx todo ${command}`, requires_flags: ["--claim-operation-id"],
reference_source: "claim_operation_id_from_claim"}};
}
if (typeof preview.reason_code === "string") {
return {action: "resolve_acquire_rejection", execution_authority_granted: false,
reason_code: preview.reason_code, reason: typeof preview.reason === "string"
? preview.reason : "Task preparation is not currently available."};
}
return undefined;
}
10 changes: 7 additions & 3 deletions loopx/control_plane/coordination/local_authority.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,12 @@ def local_authority_is_promoted(*, runtime_root: Path, goal_id: str) -> bool:
return True


def claim_worktree_candidate() -> Path | None:
"""Locate the caller; the TS lease owner still verifies physical isolation."""
worktree = _git_worktree_root(Path.cwd())
return worktree if worktree is not None and (worktree / ".git").is_file() else None


def claim_canonical_todo_if_promoted(
*,
registry_path: Path,
Expand All @@ -164,9 +170,7 @@ def claim_canonical_todo_if_promoted(
registered = registered_agent_ids_from_registry(registry_path, goal_id)
# This is only a candidate location. The lease owner observes physical
# isolation and the current canonical scopes before granting execution.
worktree = _git_worktree_root(Path.cwd()) if manage_execution else None
if worktree is not None and not (worktree / ".git").is_file():
worktree = None
worktree = claim_worktree_candidate() if manage_execution else None
result = effect_runtime_result(
LOCAL_COORDINATION_TODO_CLAIM_METHOD,
{
Expand Down
38 changes: 27 additions & 11 deletions loopx/control_plane/coordination/local_authority_runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {reviewedPromotionPlan, promotionPlanDigest, decodeReviewedPromotionOpera
import {registryAuthoritySourceCheck} from "./authority_source.ts";
import {ResearchTerminalEvidenceHost} from "../capabilities/explore_research_terminal.ts";
import {decodeTaskLeaseProof} from "./task_lease_proof.ts";
import type {ClaimWorkspaceObserver} from "./claim_execution.ts";
import {LeaseWorktreeObservationError, observeLeaseWorktree} from "../work_items/task_lease_workspace.ts";
import {evaluateTaskLeaseWriteScopesOverlap} from "../work_items/task_lease_acquire_decision.ts";
import {COORDINATION_TODO_ARCHIVE_RESULT_SCHEMA} from "./todo_archive.ts";
Expand Down Expand Up @@ -529,6 +530,23 @@ function claimObservedAt(value: unknown): Date {
return observedAt;
}

/** The same scope-aware observation serves ordinary claims and recovery previews. */
function claimWorkspaceObserver(worktree: unknown): ClaimWorkspaceObserver | undefined {
if (worktree === undefined) return undefined;
if (typeof worktree !== "string" || !isAbsolute(worktree)) {
throw new TypeError("Worktree observation requires an absolute caller path");
}
return async writeScopes => {
try {
return await observeLeaseWorktree(worktree, path =>
evaluateTaskLeaseWriteScopesOverlap({left: [...writeScopes], right: [path]}).overlap === true);
} catch (error) {
throw new Error(error instanceof LeaseWorktreeObservationError ? error.message
: "Cannot verify independent Git worktree and origin for task claim");
}
};
}

function authorityDirectory(root: string): string {
return join(root, "authority", "file-v0");
}
Expand Down Expand Up @@ -1092,15 +1110,7 @@ export async function claimLocalCoordinationTodo(
...(input.manage_execution === undefined ? {} : {manage_execution: input.manage_execution as boolean}),
dry_run: input.dry_run === true,
now: claimObservedAt(input.observed_at),
}, authoritySourcesCurrent, worktree === undefined ? undefined : async writeScopes => {
try {
return await observeLeaseWorktree(worktree as string, path =>
evaluateTaskLeaseWriteScopesOverlap({left: [...writeScopes], right: [path]}).overlap === true);
} catch (error) {
throw new Error(error instanceof LeaseWorktreeObservationError ? error.message
: "Cannot verify independent Git worktree and origin for task claim");
}
});
}, authoritySourcesCurrent, claimWorkspaceObserver(worktree));
return {
...result,
source_authority: sourceAuthority,
Expand Down Expand Up @@ -1214,6 +1224,9 @@ export async function updateLocalCoordinationTodo(
}
if (Object.hasOwn(input, "claim_operation_id") && input.schema_version !== LOCAL_COORDINATION_TODO_EXECUTION_UPDATE_REQUEST_SCHEMA)
throw new TypeError("Claim execution reference requires update request v6");
if (Object.hasOwn(input, "write_worktree") && (input.schema_version !== LOCAL_COORDINATION_TODO_EXECUTION_UPDATE_REQUEST_SCHEMA ||
input.claim_operation_id == null)) throw new TypeError("Worktree recovery observation requires an original claim execution reference");
const observeWorkspace = claimWorkspaceObserver(input.write_worktree);
const planningIntent = input.planning_intent == null ? undefined :
requireJsonObject(input.planning_intent, "Todo planning intent");
if (planningIntent && Object.keys(planningIntent).length &&
Expand Down Expand Up @@ -1293,7 +1306,7 @@ export async function updateLocalCoordinationTodo(
clear_fields: input.clear_fields.map((field) => claimAgentValue(field, "clear field")),
dry_run: input.dry_run as boolean,
now: claimObservedAt(input.observed_at),
}, authoritySourcesCurrent), ...providerEvidence};
}, authoritySourcesCurrent, observeWorkspace), ...providerEvidence};
}, {businessWrite: "todo_update"});
} catch (error) {
return {schema_version: COORDINATION_TODO_UPDATE_RESULT_SCHEMA, status: "failed",
Expand Down Expand Up @@ -1322,6 +1335,9 @@ export async function terminalLifecycleLocalCoordinationTodo(
}
if (Object.hasOwn(input, "claim_operation_id") && input.schema_version !== LOCAL_COORDINATION_TODO_EXECUTION_TERMINAL_REQUEST_SCHEMA)
throw new TypeError("Claim execution reference requires terminal request v4");
if (Object.hasOwn(input, "write_worktree") && (input.schema_version !== LOCAL_COORDINATION_TODO_EXECUTION_TERMINAL_REQUEST_SCHEMA ||
input.claim_operation_id == null)) throw new TypeError("Worktree recovery observation requires an original claim execution reference");
const observeWorkspace = claimWorkspaceObserver(input.write_worktree);
const operationIntent = decodeTerminalOperationIntent(input);
const reviewBasis = input.review_basis == null ? undefined : requireJsonObject(input.review_basis, "terminal review basis");
const authoritySourcesCurrent = registryAuthoritySourceCheck(input, true, reviewBasis?.registry_sha256);
Expand Down Expand Up @@ -1419,7 +1435,7 @@ export async function terminalLifecycleLocalCoordinationTodo(
dry_run: input.dry_run as boolean,
now: claimObservedAt(input.observed_at),
}, async () => await authoritySourcesCurrent() && researchEvidence.current(),
researchEvidence.qualify, request => verifyReentrySettlement(root, request)), ...providerEvidence};
researchEvidence.qualify, request => verifyReentrySettlement(root, request), observeWorkspace), ...providerEvidence};
} finally {await researchEvidence.close();}
}, {businessWrite: "todo_terminal"});
} catch (error) {
Expand Down
19 changes: 14 additions & 5 deletions loopx/control_plane/coordination/todo_terminal_lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {claimExecutionReference, claimExecutionProof} from "./claim_execution.ts";
import {claimExecutionReference, claimExecutionProof, type ClaimWorkspaceObserver} from "./claim_execution.ts";
import {claimExecutionRecovery} from "./claim_execution_recovery.ts";
import {executeCoordinationTodoClaim} from "./todo_claim.ts";
import {HANDOFF_MODE_RETIRED_REASON} from "./handoff_mode_policy.ts";
import {planUserCompletion, requireCompletionDecisionOutcome} from "../todos/user_completion.ts";
Expand Down Expand Up @@ -1207,6 +1208,7 @@ export async function executeCoordinationTodoTerminalLifecycle(
authoritySourcesCurrent: AuthoritySourceCheck = uncheckedAuthoritySource,
terminalEvidenceGuard: TodoTerminalEvidenceGuard | null = null,
verifyLifecycleSettlement?: (input: CoordinationTodoTerminalLifecycleInput) => Promise<JsonObject>,
observeWorkspace?: ClaimWorkspaceObserver,
): Promise<CoordinationTodoTerminalLifecycleResult> {
let normalized: CoordinationTodoTerminalLifecycleInput;
try {
Expand Down Expand Up @@ -1275,9 +1277,16 @@ export async function executeCoordinationTodoTerminalLifecycle(
}
head = observation.authority;
}
if (input.claim_operation_id !== undefined) {
try { input = {...input, ...await claimExecutionProof(store, head.head, input, input.claim_operation_id, input.requested_no_followup), claim_operation_id: undefined}; }
catch (error) { return terminalFailure("claim_execution_unavailable", error instanceof Error ? error.message : "Original task claim unavailable"); }
const claimReference = input.claim_operation_id;
if (claimReference !== undefined) {
try { input = {...input, ...await claimExecutionProof(store, head.head, input, claimReference, input.requested_no_followup), claim_operation_id: undefined}; }
catch (error) {
const recovery = input.command === "complete" ? await claimExecutionRecovery(store,
{...input, claim_operation_id: claimReference, expected_provider_revision: head.provider_revision},
"complete", error, authoritySourcesCurrent, observeWorkspace) : undefined;
return terminalFailure("claim_execution_unavailable", error instanceof Error ? error.message : "Original task claim unavailable",
recovery === undefined ? {} : {handoff_mode: head.head.handoff_mode, recovery});
}
}
let update: NormalizedCoordinationTodoUpdateInput | undefined;
if (input.user_update !== undefined) {
Expand Down Expand Up @@ -1427,7 +1436,7 @@ export async function executeCoordinationTodoTerminalLifecycle(
operation_id: `terminal-claim-preview-${canonicalAuthoritySha256({operation_id: input.operation_id,
goal_id: input.goal_id, todo_id: input.todo_id, actor_agent_id: input.actor_agent_id}).slice(0, 32)}`,
now: input.now,
}, authoritySourcesCurrent).catch(() => null);
}, authoritySourcesCurrent, observeWorkspace).catch(() => null);
// Optional guidance cannot replace the already established rejection
// with an unrelated provider/read failure.
if (preview?.dry_run === true && ["planned", "no_change"].includes(String(preview.status))) {
Expand Down
Loading
Loading