Skip to content

Commit 87111cb

Browse files
committed
fix(todo): align claim agent-id folding and rejection exception contract
Two caller-observable compatibility gaps from the todo-migration bug hunt (r2), both Low severity, fixed on the TypeScript-owner side to match the migration direction of #3972/#4000/#4005. Agent id normalization: the Python kernel's normalize_todo_claimed_by folds every whitespace run (tabs included) into a single "-", while normalizeTodoAgent only replaced literal spaces, so a claim typed as "agent\ta" succeeded before promotion and failed with 'claimed_by must be a public-safe agent id' after it. normalizeTodoAgent now folds \s+ runs the same way. Python stays untouched: its normalizer feeds 30+ call sites (agent registry, thread bindings, event-sourced state) whose legacy behavior must not change. Claim rejection exception contract: promoted rejections raised LocalCoordinationAuthorityUnavailable (RuntimeError) while the legacy kernel raised ValueError, so external Python API callers using 'except ValueError' stopped catching claim rejections after cutover. Decision rejections (status="failed" results from the TypeScript owner) now raise LocalCoordinationAuthorityRejection, a ValueError that remains an unavailable subclass so already-migrated callers keep working. Protocol failures (invalid_local_coordination_todo_claim_request) and infrastructure outages still raise the plain unavailable error, which is not a ValueError. Tests: TS conformance folds tab ids end-to-end through the promoted claim path; Python covers the ValueError contract (mocked and real TypeScript-runtime rejections), the protocol-failure control, and a real promoted claim with claimed_by "Agent\tA" normalizing to agent-a. Signed-off-by: now-ing <now-ing@users.noreply.github.com>
1 parent a8ee09b commit 87111cb

4 files changed

Lines changed: 303 additions & 1 deletion

File tree

‎loopx/control_plane/coordination/local_authority.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,23 @@ def __init__(self, message: str, *, code: str, payload: Mapping[str, Any]) -> No
4343
self.payload = dict(payload)
4444

4545

46+
class LocalCoordinationAuthorityRejection(LocalCoordinationAuthorityUnavailable, ValueError):
47+
"""The TypeScript coordination owner definitively rejected a claim.
48+
49+
The legacy Python kernel raised ``ValueError`` for every claim rejection
50+
(todo_not_open, claim_owner_mismatch, unregistered actor, ...). After
51+
promotion those rejections surface as ``status="failed"`` results from the
52+
TypeScript transaction owner; re-raising them through this class keeps the
53+
legacy ``except ValueError`` contract intact for Python API callers while
54+
remaining catchable as an authority outage. Infrastructure and protocol
55+
failures keep raising :class:`LocalCoordinationAuthorityUnavailable`, which
56+
is not a ``ValueError``.
57+
"""
58+
59+
def __init__(self, message: str, *, code: str, payload: Mapping[str, Any]) -> None:
60+
super().__init__(message, code=code, payload=payload)
61+
62+
4663
def local_authority_is_promoted(*, runtime_root: Path, goal_id: str) -> bool:
4764
fence_path = legacy_coordination_writer_fence_path(
4865
runtime_root=runtime_root,
@@ -103,6 +120,19 @@ def claim_canonical_todo_if_promoted(
103120
)
104121
payload = dict(result)
105122
accepted = {"applied", "recovered", "replayed", "no_change", "planned"}
123+
if (
124+
payload.get("status") == "failed"
125+
and payload.get("reason_code") != "invalid_local_coordination_todo_claim_request"
126+
):
127+
# The TypeScript owner returned a definitive decision rejection. The
128+
# legacy kernel raised ValueError for the same rejections, so keep that
129+
# caller-observable contract; the wrapper error code is a protocol
130+
# failure and stays an infrastructure outage instead.
131+
raise LocalCoordinationAuthorityRejection(
132+
str(payload.get("reason") or "canonical Todo claim was rejected"),
133+
code=str(payload.get("reason_code") or "claim_rejected"),
134+
payload=payload,
135+
)
106136
if (
107137
payload.get("status") not in accepted
108138
or payload.get("source_authority") != "file_v0"

‎loopx/control_plane/coordination/todo_agents.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ export function normalizeTodoAgent(value: unknown, label: string): string {
44
if (typeof value !== "string") {
55
throw new AuthorityStoreProtocolError(`${label} must be a public-safe agent id`);
66
}
7-
const candidate = value.trim().toLowerCase().replaceAll(" ", "-");
7+
// Collapse every whitespace run to one "-" so ids typed with tabs or other
8+
// whitespace fold exactly like the Python kernel's compact_todo_text path
9+
// (loopx/control_plane/todos/contract.py normalize_todo_claimed_by).
10+
const candidate = value.trim().toLowerCase().replace(/\s+/gu, "-");
811
if (!/^[a-z][a-z0-9_.:@-]{0,79}$/u.test(candidate)) {
912
throw new AuthorityStoreProtocolError(`${label} must be a public-safe agent id`);
1013
}

‎tests/control_plane/test_local_coordination_authority.py‎

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import pytest
1111

1212
from loopx.control_plane.coordination.local_authority import (
13+
LocalCoordinationAuthorityRejection,
1314
LocalCoordinationAuthorityUnavailable,
1415
claim_canonical_todo_if_promoted,
1516
read_canonical_todos_if_promoted,
@@ -250,6 +251,228 @@ def test_engaged_fence_never_falls_back_when_provider_is_missing(
250251
assert exc_info.value.code == "local_authority_todo_list_unavailable"
251252

252253

254+
def _claim_registry(tmp_path: Path) -> Path:
255+
registry = tmp_path / "registry.json"
256+
registry.write_text(
257+
json.dumps(
258+
{
259+
"schema_version": 1,
260+
"goals": [
261+
{
262+
"id": "goal-a",
263+
"coordination": {"registered_agents": ["agent-a"]},
264+
}
265+
],
266+
}
267+
),
268+
encoding="utf-8",
269+
)
270+
return registry
271+
272+
273+
def _seed_promoted_store(runtime_root: Path) -> None:
274+
"""Promote one open agent Todo through the real TypeScript runtime."""
275+
276+
projection = build_todo_runtime_shadow_projection(
277+
goal_id="goal-a",
278+
todos=[
279+
{
280+
"schema_version": "todo_item_v0",
281+
"index": 1,
282+
"done": False,
283+
"text": "Claim through the promoted provider head",
284+
"todo_id": "todo_a",
285+
"role": "agent",
286+
"status": "open",
287+
"archive_state": "active",
288+
"source_section": TODO_SECTION_HEADINGS["agent"],
289+
}
290+
],
291+
)
292+
canonical_bytes = json.dumps(
293+
projection,
294+
ensure_ascii=False,
295+
sort_keys=True,
296+
separators=(",", ":"),
297+
).encode("utf-8")
298+
projection_sha256 = hashlib.sha256(canonical_bytes).hexdigest()
299+
bootstrap = effect_runtime_result(
300+
"coordination.runtime_shadow.bootstrap",
301+
{
302+
"schema_version": "loopx_coordination_runtime_shadow_bootstrap_v0",
303+
"runtime_root": str(runtime_root),
304+
"goal_id": "goal-a",
305+
"operation_id": "bootstrap:goal-a:f34",
306+
"source_version": "state:f34:0",
307+
"projection": projection,
308+
},
309+
)
310+
assert bootstrap["status"] == "applied"
311+
mirrored = effect_runtime_result(
312+
"coordination.runtime_shadow.commit",
313+
{
314+
"schema_version": "loopx_coordination_runtime_shadow_commit_v0",
315+
"runtime_root": str(runtime_root),
316+
"goal_id": "goal-a",
317+
"operation_id": "todo:goal-a:f34:qualify",
318+
"event_kind": "todo_update",
319+
"source_version": "state:f34:1",
320+
"projection": projection,
321+
},
322+
)
323+
assert mirrored["status"] == "applied"
324+
provider_revision = str(mirrored["provider_revision"])
325+
fence = {
326+
"schema_version": "loopx_legacy_coordination_writer_fence_v0",
327+
"state": "engaged",
328+
"goal_id": "goal-a",
329+
"fence_id": "legacy-writer-fence:goal-a:f34",
330+
"source_version": "state:f34:1",
331+
"source_projection_sha256": projection_sha256,
332+
"expected_shadow_provider_revision": provider_revision,
333+
}
334+
engaged = effect_runtime_result(
335+
"coordination.local_authority.legacy_writer_fence.engage",
336+
{
337+
"schema_version": "loopx_legacy_coordination_writer_fence_engage_request_v0",
338+
"runtime_root": str(runtime_root),
339+
"goal_id": "goal-a",
340+
"fence": fence,
341+
},
342+
)
343+
assert engaged["status"] == "applied"
344+
promoted = effect_runtime_result(
345+
"coordination.local_authority.promote",
346+
{
347+
"schema_version": "loopx_local_coordination_promotion_request_v0",
348+
"runtime_root": str(runtime_root),
349+
"goal_id": "goal-a",
350+
"operation_id": "promote:goal-a:f34",
351+
"expected_shadow_provider_revision": provider_revision,
352+
"expected_shadow_projection_sha256": projection_sha256,
353+
"minimum_operations": 1,
354+
"required_event_kinds": ["todo_update"],
355+
"writer_fence": fence,
356+
},
357+
)
358+
assert promoted["status"] == "applied"
359+
360+
361+
def test_promoted_claim_rejection_preserves_legacy_valueerror_contract(
362+
monkeypatch: pytest.MonkeyPatch,
363+
tmp_path: Path,
364+
) -> None:
365+
"""Promoted claim rejections must stay catchable via ``except ValueError``.
366+
367+
The legacy kernel raised ValueError for decision rejections such as
368+
todo_not_open; external Python API callers rely on that contract.
369+
"""
370+
_engage_fence(tmp_path)
371+
monkeypatch.setattr(
372+
"loopx.control_plane.coordination.local_authority.effect_runtime_result",
373+
lambda method, params: {
374+
"status": "failed",
375+
"reason_code": "todo_not_open",
376+
"reason": "todo claim requires status=open",
377+
"source_authority": "file_v0",
378+
"decision_read_from_provider": True,
379+
"legacy_fallback_used": False,
380+
},
381+
)
382+
with pytest.raises(ValueError) as exc_info:
383+
claim_canonical_todo_if_promoted(
384+
registry_path=_claim_registry(tmp_path),
385+
runtime_root=tmp_path,
386+
goal_id="goal-a",
387+
todo_id="todo_a",
388+
role="agent",
389+
claimed_by="agent-a",
390+
actor_agent_id="agent-a",
391+
dry_run=False,
392+
)
393+
rejection = exc_info.value
394+
assert isinstance(rejection, LocalCoordinationAuthorityRejection)
395+
# Callers that already migrated to the authority-unavailable handling of
396+
# promoted claims keep working: the rejection is still its subclass.
397+
assert isinstance(rejection, LocalCoordinationAuthorityUnavailable)
398+
assert rejection.code == "todo_not_open"
399+
assert str(rejection) == "todo claim requires status=open"
400+
401+
402+
def test_promoted_claim_protocol_failure_stays_infrastructure_outage(
403+
monkeypatch: pytest.MonkeyPatch,
404+
tmp_path: Path,
405+
) -> None:
406+
"""Protocol-level request failures must not masquerade as ValueErrors."""
407+
_engage_fence(tmp_path)
408+
monkeypatch.setattr(
409+
"loopx.control_plane.coordination.local_authority.effect_runtime_result",
410+
lambda method, params: {
411+
"status": "failed",
412+
"reason_code": "invalid_local_coordination_todo_claim_request",
413+
"reason": "registered_agents must be a JSON array",
414+
"source_authority": "file_v0",
415+
"decision_read_from_provider": True,
416+
"legacy_fallback_used": False,
417+
},
418+
)
419+
with pytest.raises(LocalCoordinationAuthorityUnavailable) as exc_info:
420+
claim_canonical_todo_if_promoted(
421+
registry_path=_claim_registry(tmp_path),
422+
runtime_root=tmp_path,
423+
goal_id="goal-a",
424+
todo_id="todo_a",
425+
role="agent",
426+
claimed_by="agent-a",
427+
actor_agent_id="agent-a",
428+
dry_run=False,
429+
)
430+
assert exc_info.value.code == "invalid_local_coordination_todo_claim_request"
431+
assert not isinstance(exc_info.value, ValueError)
432+
assert not isinstance(exc_info.value, LocalCoordinationAuthorityRejection)
433+
434+
435+
def test_promoted_claim_folds_agent_id_whitespace_like_legacy(tmp_path: Path) -> None:
436+
"""Tabs in claimed_by must fold to "-" before and after promotion alike.
437+
438+
Legacy normalize_todo_claimed_by collapses any whitespace run (Python
439+
compact_todo_text) to a single "-"; the TypeScript owner now folds the
440+
same way, so the identical claim command keeps succeeding post-cutover.
441+
"""
442+
_seed_promoted_store(tmp_path)
443+
result = claim_canonical_todo_if_promoted(
444+
registry_path=_claim_registry(tmp_path),
445+
runtime_root=tmp_path,
446+
goal_id="goal-a",
447+
todo_id="todo_a",
448+
role="agent",
449+
claimed_by="Agent\tA",
450+
actor_agent_id=None,
451+
dry_run=False,
452+
)
453+
assert result is not None and result["ok"] is True
454+
assert result["status"] == "applied"
455+
assert result["claimed_by"] == "agent-a"
456+
457+
458+
def test_promoted_claim_missing_todo_rejection_is_valueerror(tmp_path: Path) -> None:
459+
"""End-to-end: a real TypeScript decision rejection raises ValueError."""
460+
_seed_promoted_store(tmp_path)
461+
with pytest.raises(ValueError) as exc_info:
462+
claim_canonical_todo_if_promoted(
463+
registry_path=_claim_registry(tmp_path),
464+
runtime_root=tmp_path,
465+
goal_id="goal-a",
466+
todo_id="todo_missing",
467+
role="agent",
468+
claimed_by="agent-a",
469+
actor_agent_id="agent-a",
470+
dry_run=False,
471+
)
472+
assert isinstance(exc_info.value, LocalCoordinationAuthorityRejection)
473+
assert exc_info.value.code == "todo_not_found"
474+
475+
253476
def test_todo_list_uses_provider_after_cutover_even_when_markdown_disagrees(
254477
monkeypatch: pytest.MonkeyPatch,
255478
tmp_path: Path,

‎tests/control_plane_ts/local_authority_runtime.test.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,52 @@ test("provider-first Todo claim preserves the complete record and is replay-safe
512512
assert.equal(repeated.changed, false);
513513
});
514514

515+
test("agent id normalization folds any whitespace run like the Python kernel", async () => {
516+
// Parity with loopx/control_plane/todos/contract.py normalize_todo_claimed_by:
517+
// compact_todo_text collapses every whitespace run (tabs included) into one
518+
// space before mapping it to "-", so the same claim command keeps working
519+
// before and after promotion.
520+
const root = await mkdtemp(join(tmpdir(), "loopx-local-authority-claim-tab-"));
521+
const store = new FileAuthorityStore(join(root, "authority", "file-v0"), "goal-a");
522+
const seeded = await store.commitAuthority({
523+
expected_provider_revision: null,
524+
operation_id: "promote:claim-tab-test",
525+
events: [{ schema_version: "promotion_v0" }],
526+
next_projection: withTodoReadModel({
527+
goal_id: "goal-a",
528+
handoff_mode: "soft_claim",
529+
todos: [todoRecord()],
530+
leases: [],
531+
}),
532+
receipts: [],
533+
});
534+
assert.equal(seeded.status, "applied");
535+
536+
const applied = await claimLocalCoordinationTodo({
537+
schema_version: LOCAL_COORDINATION_TODO_CLAIM_REQUEST_SCHEMA,
538+
runtime_root: root,
539+
goal_id: "goal-a",
540+
todo_id: "todo_a",
541+
role: "agent",
542+
claimed_by: "Agent\tA",
543+
actor_agent_id: "Agent \t A",
544+
registered_agents: ["agent-a"],
545+
operation_id: "todo-claim:goal-a:todo_a:tab",
546+
observed_at: "2026-09-05T04:30:00Z",
547+
dry_run: false,
548+
});
549+
assert.equal(applied.status, "applied", JSON.stringify(applied));
550+
551+
const read = await readLocalCoordinationTodo({
552+
schema_version: LOCAL_COORDINATION_TODO_READ_REQUEST_SCHEMA,
553+
runtime_root: root,
554+
goal_id: "goal-a",
555+
todo_id: "todo_a",
556+
});
557+
assert.equal(read.status, "found");
558+
assert.equal((read.todo as Record<string, unknown>).claimed_by, "agent-a");
559+
});
560+
515561
test("one TypeScript decision owns promoted and legacy Todo claims", () => {
516562
const input = {
517563
goal_id: "goal-a",

0 commit comments

Comments
 (0)