Skip to content
Merged
16 changes: 16 additions & 0 deletions loopx/cli_commands/todo_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
from collections.abc import Callable
from pathlib import Path

from ..control_plane.coordination.local_authority import (
LocalCoordinationAuthorityRejection,
)
from ..control_plane.todos.external_wait_contract import TodoExternalWaitAuthoringError
from ..control_plane.todos.handoff_mode import HandoffModeError
from ..control_plane.todos.contract import decision_scope_metadata_value
Expand Down Expand Up @@ -43,12 +46,25 @@ def todo_error_payload(args: argparse.Namespace, exc: Exception) -> dict[str, ob
if isinstance(exc, (TaskLeaseError, HandoffModeError)):
payload["error_code"] = exc.code
payload.update(exc.payload)
elif isinstance(exc, LocalCoordinationAuthorityRejection):
payload["error_code"] = exc.code
payload["code"] = exc.code
for key, value in exc.payload.items():
if key not in {
"schema_version",
"status",
"failure_kind",
"reason_code",
"reason",
}:
payload[key] = value
elif isinstance(exc, TodoExternalWaitAuthoringError):
payload["error_code"] = exc.code
if exc.authoring_contract is not None:
payload["authoring_contract"] = exc.authoring_contract
return payload


def append_todo_rollout_event(
payload: dict[str, object],
*,
Expand Down
63 changes: 54 additions & 9 deletions loopx/control_plane/coordination/local_authority.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,25 @@ def __init__(self, message: str, *, code: str, payload: Mapping[str, Any]) -> No
self.payload = dict(payload)


class LocalCoordinationAuthorityRejection(
LocalCoordinationAuthorityUnavailable, ValueError
):
"""The TypeScript coordination owner definitively rejected a claim.

The legacy Python kernel raised ``ValueError`` for every claim rejection
(todo_not_open, claim_owner_mismatch, unregistered actor, ...). After
promotion those rejections surface as ``status="failed"`` results from the
TypeScript transaction owner; re-raising them through this class keeps the
legacy ``except ValueError`` contract intact for Python API callers while
remaining catchable as an authority outage. Infrastructure and protocol
failures keep raising :class:`LocalCoordinationAuthorityUnavailable`, which
is not a ``ValueError``.
"""

def __init__(self, message: str, *, code: str, payload: Mapping[str, Any]) -> None:
super().__init__(message, code=code, payload=payload)


def local_authority_is_promoted(*, runtime_root: Path, goal_id: str) -> bool:
fence_path = legacy_coordination_writer_fence_path(
runtime_root=runtime_root,
Expand Down Expand Up @@ -94,7 +113,11 @@ def claim_canonical_todo_if_promoted(
"registered_agents": registered_agent_ids_from_registry(
registry_path, goal_id
),
"operation_id": operation_id if operation_id is not None else f"todo-claim:{goal_id}:{todo_id}:{uuid4().hex}",
"operation_id": (
operation_id
if operation_id is not None
else f"todo-claim:{goal_id}:{todo_id}:{uuid4().hex}"
),
"lease_request": (
{
"idempotency_key": task_lease_idempotency_key,
Expand All @@ -116,6 +139,19 @@ def claim_canonical_todo_if_promoted(
)
payload = dict(result)
accepted = {"applied", "recovered", "replayed", "no_change", "planned"}
if (
payload.get("status") == "failed"
and payload.get("failure_kind") == "decision_rejection"
):
# The TypeScript owner classifies this failure as a definitive claim
# decision. The legacy kernel raised ValueError for the same
# rejections, so keep that caller-observable contract; protocol and
# storage-integrity failures stay infrastructure outages.
raise LocalCoordinationAuthorityRejection(
str(payload.get("reason") or "canonical Todo claim was rejected"),
code=str(payload.get("reason_code") or "claim_rejected"),
payload=payload,
)
if (
payload.get("status") not in accepted
or payload.get("source_authority") != "file_v0"
Expand Down Expand Up @@ -188,7 +224,9 @@ def read_canonical_todos_if_promoted(
):
raise LocalCoordinationAuthorityUnavailable(
str(payload.get("reason") or "canonical Todo authority is unavailable"),
code=str(payload.get("reason_code") or "local_authority_todo_list_unavailable"),
code=str(
payload.get("reason_code") or "local_authority_todo_list_unavailable"
),
payload=payload,
)
payload["todos"] = [dict(item) for item in todos]
Expand All @@ -206,7 +244,8 @@ def canonical_todo_summary_fields(
from ..todos.todo_summary import compact_todo_group, count_advancement_todos

native_archived = {
item["todo_id"] for item in todos
item["todo_id"]
for item in todos
if item.get("schema_version") == TODO_DOMAIN_ITEM_SCHEMA_VERSION
and item.get("archive_state") == "archive"
}
Expand All @@ -217,12 +256,14 @@ def canonical_todo_summary_fields(
**item,
"schema_version": TODO_ITEM_SCHEMA_VERSION,
"source_section": (
"Completed Work Archive" if item["archive_state"] == "archive"
"Completed Work Archive"
if item["archive_state"] == "archive"
else TODO_SECTION_HEADINGS[item["role"]]
),
"index": index,
}
if item.get("schema_version") == TODO_DOMAIN_ITEM_SCHEMA_VERSION else item
if item.get("schema_version") == TODO_DOMAIN_ITEM_SCHEMA_VERSION
else item
for index, item in enumerate(todos, 1)
]
fields: dict[str, Any] = {}
Expand All @@ -243,10 +284,14 @@ def canonical_todo_summary_fields(
)
if summary:
if role == "agent":
archived_done = count_advancement_todos([
item for item in todos
if item.get("todo_id") in native_archived and item.get("done") is True
])
archived_done = count_advancement_todos(
[
item
for item in todos
if item.get("todo_id") in native_archived
and item.get("done") is True
]
)
if archived_done:
summary["archived_advancement_done_count"] = archived_done
summary["advancement_done_count"] = (
Expand Down
23 changes: 22 additions & 1 deletion loopx/control_plane/coordination/todo_agents.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,31 @@
import {AuthorityStoreProtocolError} from "./authority_store_codec.ts";

// Python str.split() and isspace() recognize exactly 29 Unicode whitespace
// code points: ASCII \t\n\v\f\r and space, ASCII information separators
// U+001C..U+001F, the C1 control NEL (U+0085), and Unicode whitespace blocks
// (NBSP U+00A0, Ogham space mark U+1680, en/em/thin spaces U+2000..U+200A,
// line/paragraph separators U+2028/U+2029, mathematical/ideographic spaces
// U+202F/U+205F/U+3000). Notably, ECMAScript \s omits U+001C..U+001F and
// U+0085 while including BOM (U+FEFF), which Python rejects as whitespace.
// Explicitly match Python's exact 29-code-point whitespace set.
const PYTHON_WHITESPACE_CLASS =
"[\\t\\n\\v\\f\\r \\u001c-\\u001f\\u0085\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000]";
const PYTHON_LEADING_TRAILING_WHITESPACE = new RegExp(
`^${PYTHON_WHITESPACE_CLASS}+|${PYTHON_WHITESPACE_CLASS}+$`,
"gu",
);
const PYTHON_WHITESPACE_RUN = new RegExp(`${PYTHON_WHITESPACE_CLASS}+`, "gu");

export function normalizeTodoAgent(value: unknown, label: string): string {
if (typeof value !== "string") {
throw new AuthorityStoreProtocolError(`${label} must be a public-safe agent id`);
}
const candidate = value.trim().toLowerCase().replaceAll(" ", "-");
// Trim and collapse every Python-equivalent whitespace run to one "-" so ids
// typed with any Python whitespace (including U+0085 NEL, U+001C..U+001F,
// tabs, and NBSP) fold exactly like the Python kernel's compact_todo_text path
// (loopx/control_plane/todos/contract.py normalize_todo_claimed_by).
const stripped = value.replace(PYTHON_LEADING_TRAILING_WHITESPACE, "");
const candidate = stripped.toLowerCase().replace(PYTHON_WHITESPACE_RUN, "-");
if (!/^[a-z][a-z0-9_.:@-]{0,79}$/u.test(candidate)) {
throw new AuthorityStoreProtocolError(`${label} must be a public-safe agent id`);
}
Expand Down
47 changes: 41 additions & 6 deletions loopx/control_plane/coordination/todo_claim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,21 @@ function normalizeExcludedAgents(value: unknown): string[] {
return normalized;
}

function failure(code: string, reason: string, detail: JsonObject = {}): CoordinationTodoClaimResult {
type CoordinationTodoClaimFailureKind =
| "decision_rejection"
| "protocol_failure";

function failure(
code: string,
reason: string,
detail: JsonObject = {},
kind: CoordinationTodoClaimFailureKind = "protocol_failure",
): CoordinationTodoClaimResult {
return {
...detail,
schema_version: COORDINATION_TODO_CLAIM_RESULT_SCHEMA,
status: "failed",
failure_kind: kind,
reason_code: code,
reason,
};
Expand Down Expand Up @@ -422,9 +432,12 @@ export async function executeCoordinationTodoClaim(
}
const todo = projection.todos.get(input.todo_id);
if (todo === undefined) {
return failure("todo_not_found", "Todo is missing from the canonical provider head", {
todo_id: input.todo_id,
});
return failure(
"todo_not_found",
"Todo is missing from the canonical provider head",
{ todo_id: input.todo_id },
"decision_rejection",
);
}

let authority: ReturnType<typeof evaluateCoordinationTodoClaimDecision>;
Expand All @@ -441,6 +454,7 @@ export async function executeCoordinationTodoClaim(
typeof authority.reason_code === "string" ? authority.reason_code : "invalid_coordination_todo_claim",
typeof authority.reason === "string" ? authority.reason : "Todo claim was rejected",
authority,
"decision_rejection",
);
}

Expand All @@ -455,6 +469,7 @@ export async function executeCoordinationTodoClaim(
"claim_lease_requires_hard_lease",
"atomic Todo claim and lease acquire requires handoff_mode=hard_lease",
{ todo_id: input.todo_id, handoff_mode: handoffMode },
"decision_rejection",
);
}

Expand Down Expand Up @@ -557,14 +572,34 @@ export async function executeCoordinationTodoClaim(
actor_agent_id: authority.owner,
lease_decision: decision,
},
"decision_rejection",
);
}
} else if (handoffMode === "hard_lease" &&
!activeLeaseForOwner(currentLease, authority.owner, input.now)) {
const existingVersion = currentLease !== undefined
? (leaseInteger(currentLease, "version") ?? 0)
: null;
const expectedVersionGuidance = existingVersion !== null
? `; specify --task-lease-expected-version ${existingVersion} to match the existing canonical lease version`
: "; provide --task-lease-expected-version if a canonical lease already exists";
return failure(
"handoff_mode_requires_lease",
"hard_lease Todo claim requires an active canonical lease held by the claiming agent",
{ todo_id: input.todo_id, actor_agent_id: authority.owner },
`hard_lease Todo claim requires an active canonical lease held by the claiming agent; ` +
`retry with \`loopx todo claim --task-lease-idempotency-key <key>\`${expectedVersionGuidance}`,
{
todo_id: input.todo_id,
actor_agent_id: authority.owner,
handoff_mode: handoffMode,
recovery: {
command: "loopx todo claim",
requires_flags: ["--task-lease-idempotency-key"],
optional_flags: ["--task-lease-expected-version"],
expected_version: existingVersion,
expected_version_required: existingVersion !== null,
},
},
"decision_rejection",
);
}
} catch (error) {
Expand Down
Loading