Skip to content

Commit 31c52f5

Browse files
committed
fix(storage): recover original team plans during a writer hold
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
1 parent 60d833b commit 31c52f5

5 files changed

Lines changed: 177 additions & 26 deletions

File tree

‎docs/architecture/rfcs/typescript-control-plane-migration-v0.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,16 @@ The existing delivery Todos should carry these concrete exits:
113113
This updates the existing R5/T4 frontier and release proposal; it creates no
114114
parallel migration owner, runtime gate or release activation.
115115

116+
Team-plan recovery prerequisite: an unpromoted plan that published its original
117+
receipt can now recover through the Chat confirmation owner while shadow
118+
bootstrap is interrupted. Registry/Todo/source locks still protect the read,
119+
and the existing typed identity validator rejects changed intent, actor or
120+
reviewed basis. Recovery verifies source durability without loading the capture
121+
producer or admitting new writes; fresh plans still obey all source, management
122+
and promotion guards. This repairs the existing 1.x recovery path and does not
123+
activate the 2.0 cutoff or settle the remaining lease/outbox/Host obligations.
124+
125+
116126
The retained import-compatible field writer now composes resume normalization
117127
and contradictory-clear rejection in its existing typed field plan. Keep blank
118128
Python input omission and locked IO; retire the duplicate normalization crossing,

‎loopx/control_plane/coordination/legacy_writer_fence.py‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,22 @@ def require_legacy_coordination_write_allowed(
339339
)
340340

341341

342+
def require_legacy_todo_write_allowed(
343+
*, registry_path: Path, runtime_root: Path, goal_id: str, state_file: Path,
344+
) -> None:
345+
"""Admit a fresh source mutation while its Todo/state locks are held.
346+
347+
Historical receipt reads use the same locks but do not admit a new write.
348+
Keep source binding, management and promotion checks together for writers.
349+
"""
350+
require_registry_source_write_allowed(
351+
registry_path=registry_path, runtime_root=runtime_root, goal_id=goal_id,
352+
state_file=state_file,
353+
)
354+
require_shadow_primary_write_allowed(runtime_root, goal_id)
355+
require_legacy_coordination_write_allowed(runtime_root=runtime_root, goal_id=goal_id)
356+
357+
342358
@contextmanager
343359
def legacy_todo_write_transaction(
344360
registry_path: Path,
@@ -376,13 +392,8 @@ def legacy_todo_write_transaction(
376392
operation=operation,
377393
):
378394
if not dry_run:
379-
require_registry_source_write_allowed(
380-
registry_path=registry_path, runtime_root=resolved_runtime_root, goal_id=goal_id,
381-
state_file=state_file,
382-
)
383-
require_shadow_primary_write_allowed(resolved_runtime_root, goal_id)
384-
require_legacy_coordination_write_allowed(
385-
runtime_root=resolved_runtime_root,
386-
goal_id=goal_id,
395+
require_legacy_todo_write_allowed(
396+
registry_path=registry_path, runtime_root=resolved_runtime_root,
397+
goal_id=goal_id, state_file=state_file,
387398
)
388399
yield

‎loopx/control_plane/work_items/team_plan_adapter.py‎

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -231,39 +231,52 @@ def previous_receipt(original: str) -> dict[str, Any] | None:
231231
result["projection_delivery"] = projected.get("projection_delivery")
232232
return result
233233

234-
# Only unpromoted Goals need the source writer and capture producer. Keep
235-
# canonical replay and projection independent of this compatibility path.
236-
from ..coordination.legacy_writer_fence import legacy_todo_write_transaction
237-
from ..coordination.runtime_shadow_writer_adapter import (
238-
begin_todo_runtime_shadow_capture, settle_todo_runtime_shadow_capture,
239-
)
234+
# Lock the original source before consulting its receipt. A management hold
235+
# stops fresh writes, not readback of an already-published operation. Keep
236+
# receipt identity validation in the shared typed planner.
237+
from ..coordination.legacy_writer_fence import require_legacy_todo_write_allowed
240238
from ..runtime.document_io import verify_state_text_durable
241-
from ..todos.active_state_editing import (
242-
insert_into_existing_section, insert_new_section, section_bounds,
243-
replace_updated_at,
244-
)
245-
from ..todos.contract import format_todo_metadata_line, parse_todo_metadata_line
246239

247240
with exclusive_cross_runtime_file_lock(registry_path, operation="team_plan_commit"), \
248-
legacy_todo_write_transaction(registry_path, goal_id, state, agent_id,
249-
"todo_add", False, runtime_root=runtime):
241+
exclusive_cross_runtime_file_lock(
242+
legacy_coordination_todo_lock_path(runtime_root=runtime, goal_id=goal_id),
243+
agent_id=agent_id, operation="legacy_coordination_todo_write"), \
244+
exclusive_cross_runtime_file_lock(state, agent_id=agent_id, operation="todo_add"):
250245
read_source_request()
251246
original = state.read_text(encoding="utf-8")
252247
previous = previous_receipt(original)
248+
if previous is not None:
249+
# Promotion may have completed while these locks were acquired.
250+
# An unavailable selected provider must still fail closed; this is
251+
# original receipt recovery, never a Markdown authority fallback.
252+
read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=goal_id)
253+
replay = effect_runtime_result("work_items.team_plan.plan", {
254+
**request, "previous_receipt": previous})
255+
verify_state_text_durable(state, original)
256+
return dict(replay["result"])
257+
require_legacy_todo_write_allowed(
258+
registry_path=registry_path, runtime_root=runtime, goal_id=goal_id, state_file=state)
259+
# Recovery above needs neither the source producer nor Markdown editing.
260+
from ..coordination.runtime_shadow_writer_adapter import (
261+
begin_todo_runtime_shadow_capture, settle_todo_runtime_shadow_capture,
262+
)
263+
from ..todos.active_state_editing import (
264+
insert_into_existing_section, insert_new_section, section_bounds,
265+
replace_updated_at,
266+
)
267+
from ..todos.contract import format_todo_metadata_line, parse_todo_metadata_line
268+
253269
existing = [metadata for line in original.splitlines()
254270
if (metadata := parse_todo_metadata_line(line)) is not None]
255271
current_fingerprint = read_fingerprint() if read_fingerprint else None
256272
try:
257273
planned = effect_runtime_result("work_items.team_plan.plan", {
258-
**request, "previous_receipt": previous, "todos": existing,
274+
**request, "todos": existing,
259275
"current_state_fingerprint": current_fingerprint,
260276
})
261277
except EffectRuntimeRejected as error:
262278
raise TeamPlanCommitError(error.diagnostic_code, str(error), current_fingerprint) from None
263279
result = dict(planned["result"])
264-
if planned["replayed"]:
265-
verify_state_text_durable(state, original)
266-
return result
267280
capture = begin_todo_runtime_shadow_capture(registry_path=registry_path,
268281
runtime_root=runtime, goal_id=goal_id, state_path=state,
269282
write_class="todo_add", original_text=original)

‎loopx/semantics/project_registry_io_manifest_v1.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1119,7 +1119,7 @@
11191119
},
11201120
{
11211121
"site": "loopx/control_plane/coordination/legacy_writer_fence.py::<module>.legacy_todo_write_transaction::codec_read:load_registry#1",
1122-
"line": 362,
1122+
"line": 378,
11231123
"column": 9,
11241124
"kind": "codec_read",
11251125
"api": "load_registry",

‎tests/control_plane/test_team_plan_authority.py‎

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,3 +478,120 @@ def test_capture_free_chat_preserves_pre_promotion_receipt(tmp_path, monkeypatch
478478
assert replay["lane_todo_ids"] == applied["receipt"]["resource_ids"]["lane_todo_ids"]
479479
assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="goal-a") == current
480480
assert state.read_bytes() == display
481+
482+
# Simulate promotion finishing after the first mode observation but before
483+
# acquiring source locks; its selected backend subsequently goes offline.
484+
from loopx.control_plane.coordination.local_authority import LocalCoordinationAuthorityUnavailable
485+
from loopx.control_plane.work_items import team_plan_adapter
486+
read_authority = team_plan_adapter.read_canonical_todos_if_promoted
487+
observed = []
488+
489+
def promotion_race(**kwargs):
490+
observed.append(True)
491+
return None if len(observed) == 1 else read_authority(**kwargs)
492+
493+
backend = runtime / "authority" / current["source_authority"].replace("_", "-")
494+
offline = backend.with_name(backend.name + "-offline")
495+
backend.rename(offline)
496+
try:
497+
with monkeypatch.context() as race:
498+
race.setattr(team_plan_adapter, "read_canonical_todos_if_promoted", promotion_race)
499+
saved = service.store.load(preview["proposal_id"])
500+
with pytest.raises(LocalCoordinationAuthorityUnavailable):
501+
team_plan_adapter.settle_team_plan(
502+
registry_path=registry, goal_id="goal-a", agent_id=None,
503+
proposal={**saved["normalized_parameters"]["plan"], "proposal_id": preview["proposal_id"]},
504+
expected_state_fingerprint=saved["expected_state_fingerprint"], basis_agent_id="alpha")
505+
assert len(observed) == 2
506+
assert state.read_bytes() == display and not backend.exists()
507+
finally:
508+
offline.rename(backend)
509+
assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="goal-a") == current
510+
511+
512+
@pytest.mark.skipif(os.name != "posix", reason="real bootstrap crash barrier uses select on a pipe")
513+
def test_legacy_chat_replay_survives_interrupted_shadow_bootstrap(tmp_path, monkeypatch):
514+
"""A stopped writer must not strand an already-published plan receipt."""
515+
import select
516+
from loopx.control_plane.work_items import team_plan_adapter
517+
from loopx.control_plane.coordination.runtime_shadow import build_runtime_shadow_source_snapshot
518+
from loopx.control_plane.coordination.shadow_management import read_shadow_management_state
519+
from loopx.control_plane.coordination.coordination_state_contract_generated import (
520+
COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_REQUEST_SCHEMA,
521+
)
522+
523+
runtime = tmp_path / "runtime"
524+
state = tmp_path / "state.md"
525+
state.write_text("# Goal\n\n## Agent Todo\n\n")
526+
goal = {"id": "goal-a", "repo": str(tmp_path), "state_file": "state.md",
527+
"coordination": {"registered_agents": ["alpha"], "agent_model": "peer_v1"}}
528+
registry = tmp_path / "registry.json"
529+
registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [goal]}))
530+
service = ChatActionService(store=ChatActionStore(runtime / "chat/actions"), registry_path=registry)
531+
preview = service.preview({"action_kind": "team.plan", "summary": "Assign work", "context": {},
532+
"normalized_parameters": {"goal_id": "goal-a", "plan": _agent_plan()},
533+
"idempotency_key": "lost-original-response"})
534+
write = team_plan_adapter.write_captured_todo_state
535+
536+
def lose_response(*args, **kwargs):
537+
write(*args, **kwargs)
538+
raise OSError("response lost after publication")
539+
540+
with monkeypatch.context() as failed_response:
541+
failed_response.setattr(team_plan_adapter, "write_captured_todo_state", lose_response)
542+
assert service.apply(preview["proposal_id"])["proposal"]["status"] == "failed"
543+
assert state.read_text().count("loopx:team-plan:") == 1
544+
state.write_text(state.read_text() + "\nLater owner data must survive.\n")
545+
original = state.read_bytes()
546+
projection, snapshot = build_runtime_shadow_source_snapshot(
547+
goal=goal, runtime_root=runtime, state_path=state, registry_path=registry)
548+
request = {"schema_version": COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_REQUEST_SCHEMA,
549+
"runtime_root": str(runtime), "goal_id": "goal-a", "operation_id": "bootstrap:plan-recovery",
550+
"source_version": "source:before-crash", "projection": projection, "source_snapshot": snapshot}
551+
repo = Path(__file__).resolve().parents[2]
552+
child = subprocess.Popen(["node", "--no-warnings", "--experimental-strip-types",
553+
str(repo / "tests/control_plane_ts/fixtures/shadow_management_crash_worker.ts"),
554+
"bootstrap-public", json.dumps(request), "bootstrap_prepared"],
555+
cwd=repo, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
556+
try:
557+
ready, _, _ = select.select([child.stdout], [], [], 15)
558+
assert ready, "bootstrap did not reach its real filesystem barrier"
559+
assert child.stdout.readline().strip() == "ready:bootstrap_prepared"
560+
finally:
561+
child.kill()
562+
child.communicate(timeout=10)
563+
pending = read_shadow_management_state(runtime, "goal-a")
564+
assert pending["status"] == "bootstrapping"
565+
# Restart from an isolated package with the old capture producer absent.
566+
invoke = _capture_free_chat((runtime, state, service, preview), tmp_path)
567+
replay = invoke("historical")
568+
assert replay["action"] == "reused" and replay["created_todo_ids"] == []
569+
recovered = service.apply(preview["proposal_id"])["proposal"]
570+
assert recovered["status"] == "applied", recovered
571+
assert replay["lane_todo_ids"] == recovered["receipt"]["resource_ids"]["lane_todo_ids"]
572+
assert state.read_bytes() == original
573+
assert read_shadow_management_state(runtime, "goal-a") == pending
574+
575+
# Recovery is tied to the exact intent, actor and original reviewed basis.
576+
saved = service.store.load(preview["proposal_id"])
577+
original_plan = {**saved["normalized_parameters"]["plan"], "proposal_id": preview["proposal_id"]}
578+
for changed in (
579+
{"proposal": {**original_plan, "objective": "Changed intent"}},
580+
{"agent_id": "alpha"},
581+
{"expected_state_fingerprint": "different-basis"},
582+
):
583+
with pytest.raises(team_plan_adapter.EffectRuntimeRejected, match="identity mismatch"):
584+
team_plan_adapter.settle_team_plan(**{
585+
"registry_path": registry, "goal_id": "goal-a", "agent_id": None,
586+
"proposal": original_plan, "expected_state_fingerprint": saved["expected_state_fingerprint"],
587+
"basis_agent_id": "alpha", **changed,
588+
})
589+
assert state.read_bytes() == original
590+
# A different plan still needs normal admission; recovery cannot restart work.
591+
fresh = service.preview({"action_kind": "team.plan", "summary": "New work", "context": {},
592+
"normalized_parameters": {"goal_id": "goal-a", "plan": _agent_plan()},
593+
"idempotency_key": "new-request-during-hold"})
594+
refused = service.apply(fresh["proposal_id"])["proposal"]
595+
assert refused["status"] == "failed", refused
596+
assert state.read_bytes() == original
597+
assert read_shadow_management_state(runtime, "goal-a") == pending

0 commit comments

Comments
 (0)