Skip to content

Commit b8670eb

Browse files
authored
Merge pull request #4048 from xyx2002OvO/fix/issue-3893-todo-concurrency
test(todos): cover concurrent add-update serialization
2 parents eb2a2bb + 33534af commit b8670eb

1 file changed

Lines changed: 152 additions & 0 deletions

File tree

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
from __future__ import annotations
2+
3+
from concurrent.futures import ThreadPoolExecutor
4+
from contextlib import contextmanager
5+
import json
6+
from pathlib import Path
7+
from threading import Barrier, Lock
8+
from typing import Any, Iterator
9+
10+
import pytest
11+
12+
from loopx.control_plane.coordination import legacy_writer_fence
13+
from loopx.control_plane.coordination.legacy_writer_fence import (
14+
legacy_coordination_todo_lock_path,
15+
)
16+
from loopx.status import parse_active_state_todos
17+
from loopx.todos import add_goal_todo, update_goal_todo
18+
19+
20+
GOAL_ID = "todo-write-serialization"
21+
AGENT_ID = "fixture-agent"
22+
INITIAL_TODO = "Update this Todo while another Todo is added."
23+
CONCURRENT_TODO = "Preserve this concurrently added Todo."
24+
UPDATE_REASON = "waiting-for-concurrent-dependency"
25+
26+
27+
def _write_fixture(tmp_path: Path) -> tuple[Path, Path, Path]:
28+
project = tmp_path / "project"
29+
project.mkdir()
30+
state_file = project / "ACTIVE_GOAL_STATE.md"
31+
state_file.write_text(
32+
"---\n"
33+
f"goal_id: {GOAL_ID}\n"
34+
"updated_at: 2026-09-07T00:00:00+00:00\n"
35+
"---\n\n"
36+
"## Agent Todo\n\n",
37+
encoding="utf-8",
38+
)
39+
runtime_root = tmp_path / "runtime"
40+
registry_path = tmp_path / "registry.global.json"
41+
registry_path.write_text(
42+
json.dumps(
43+
{
44+
"common_runtime_root": str(runtime_root),
45+
"goals": [
46+
{
47+
"id": GOAL_ID,
48+
"domain": "todo-write-serialization-fixture",
49+
"status": "active",
50+
"repo": str(project),
51+
"state_file": state_file.name,
52+
"adapter": {"kind": "fixture"},
53+
"coordination": {
54+
"agent_model": "peer_v1",
55+
"registered_agents": [AGENT_ID],
56+
},
57+
}
58+
],
59+
}
60+
),
61+
encoding="utf-8",
62+
)
63+
return registry_path, state_file, runtime_root
64+
65+
66+
def test_concurrent_add_and_update_share_the_goal_todo_write_lock(
67+
tmp_path: Path,
68+
monkeypatch: pytest.MonkeyPatch,
69+
) -> None:
70+
registry_path, state_file, runtime_root = _write_fixture(tmp_path)
71+
target = add_goal_todo(
72+
registry_path=registry_path,
73+
goal_id=GOAL_ID,
74+
role="agent",
75+
text=INITIAL_TODO,
76+
task_class="advancement_task",
77+
claimed_by=AGENT_ID,
78+
)
79+
target_id = str(target["todo_id"])
80+
goal_lock_path = legacy_coordination_todo_lock_path(
81+
runtime_root=runtime_root,
82+
goal_id=GOAL_ID,
83+
)
84+
85+
arrivals = Barrier(2)
86+
records_guard = Lock()
87+
locks_by_path: dict[Path, Lock] = {}
88+
lock_attempts: list[tuple[Path, str | None]] = []
89+
90+
@contextmanager
91+
def deterministic_lock(
92+
path: Path,
93+
**kwargs: Any,
94+
) -> Iterator[Path]:
95+
with records_guard:
96+
path_lock = locks_by_path.setdefault(path, Lock())
97+
lock_attempts.append((path, kwargs.get("operation")))
98+
if path == goal_lock_path:
99+
arrivals.wait(timeout=5)
100+
with path_lock:
101+
yield path
102+
103+
monkeypatch.setattr(
104+
legacy_writer_fence,
105+
"exclusive_file_lock",
106+
deterministic_lock,
107+
)
108+
109+
with ThreadPoolExecutor(max_workers=2) as executor:
110+
add_future = executor.submit(
111+
add_goal_todo,
112+
registry_path=registry_path,
113+
goal_id=GOAL_ID,
114+
role="agent",
115+
text=CONCURRENT_TODO,
116+
task_class="advancement_task",
117+
claimed_by=AGENT_ID,
118+
)
119+
update_future = executor.submit(
120+
update_goal_todo,
121+
registry_path=registry_path,
122+
goal_id=GOAL_ID,
123+
todo_id=target_id,
124+
status="blocked",
125+
reason=UPDATE_REASON,
126+
agent_id=AGENT_ID,
127+
)
128+
added = add_future.result(timeout=10)
129+
updated = update_future.result(timeout=10)
130+
131+
assert added["added"] is True, added
132+
assert updated["changed"] is True, updated
133+
assert [attempt for attempt in lock_attempts if attempt[0] == goal_lock_path] == [
134+
(goal_lock_path, "legacy_coordination_todo_write"),
135+
(goal_lock_path, "legacy_coordination_todo_write"),
136+
]
137+
assert sorted(
138+
operation
139+
for path, operation in lock_attempts
140+
if path == state_file
141+
) == ["todo_add", "todo_update"]
142+
143+
parsed = parse_active_state_todos(state_file.read_text(encoding="utf-8"))
144+
items = parsed["agent_todos"]["items"]
145+
todo_ids = [str(item["todo_id"]) for item in items]
146+
assert len(todo_ids) == len(set(todo_ids)), items
147+
148+
by_id = {str(item["todo_id"]): item for item in items}
149+
assert str(added["todo_id"]) in by_id, items
150+
assert by_id[str(added["todo_id"])]["text"] == CONCURRENT_TODO
151+
assert by_id[target_id]["status"] == "blocked"
152+
assert by_id[target_id]["reason"] == UPDATE_REASON

0 commit comments

Comments
 (0)