Skip to content

Commit 8fafd5a

Browse files
committed
fix: add cycle detection and atomic writes to task dependency updates
1 parent 536011c commit 8fafd5a

5 files changed

Lines changed: 232 additions & 51 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ venv/
1313
.ruff_cache/
1414
*.so
1515
.DS_Store
16+
.mcp.json

src/claude_teams/server.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,10 @@ def task_update(
285285
@mcp.tool
286286
def task_list(team_name: str) -> list[dict]:
287287
"""List all tasks for a team with their current status and assignments."""
288-
result = tasks.list_tasks(team_name)
288+
try:
289+
result = tasks.list_tasks(team_name)
290+
except ValueError as e:
291+
raise ToolError(str(e))
289292
return [t.model_dump(by_alias=True, exclude_none=True) for t in result]
290293

291294

src/claude_teams/tasks.py

Lines changed: 130 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import fcntl
44
import json
5+
from collections import deque
56
from contextlib import contextmanager
67
from pathlib import Path
78
from typing import Any
@@ -30,13 +31,34 @@ def _tasks_dir(base_dir: Path | None = None) -> Path:
3031
_STATUS_ORDER = {"pending": 0, "in_progress": 1, "completed": 2}
3132

3233

33-
def _sync_dependency(team_dir: Path, target_id: str, field: str, value: str) -> None:
34-
fpath = team_dir / f"{target_id}.json"
35-
other = TaskFile(**json.loads(fpath.read_text()))
36-
lst = getattr(other, field)
37-
if value not in lst:
38-
lst.append(value)
39-
fpath.write_text(json.dumps(other.model_dump(by_alias=True, exclude_none=True)))
34+
def _flush_pending_writes(pending_writes: dict[Path, TaskFile]) -> None:
35+
for path, task_obj in pending_writes.items():
36+
path.write_text(json.dumps(task_obj.model_dump(by_alias=True, exclude_none=True)))
37+
38+
39+
def _would_create_cycle(
40+
team_dir: Path, from_id: str, to_id: str, pending_edges: dict[str, set[str]]
41+
) -> bool:
42+
"""True if making from_id blocked_by to_id creates a cycle.
43+
44+
BFS from to_id through blocked_by chains (on-disk + pending);
45+
cycle if it reaches from_id.
46+
"""
47+
visited: set[str] = set()
48+
queue = deque([to_id])
49+
while queue:
50+
current = queue.popleft()
51+
if current == from_id:
52+
return True
53+
if current in visited:
54+
continue
55+
visited.add(current)
56+
fpath = team_dir / f"{current}.json"
57+
if fpath.exists():
58+
task = TaskFile(**json.loads(fpath.read_text()))
59+
queue.extend(d for d in task.blocked_by if d not in visited)
60+
queue.extend(d for d in pending_edges.get(current, set()) if d not in visited)
61+
return False
4062

4163

4264
def next_task_id(team_name: str, base_dir: Path | None = None) -> str:
@@ -110,51 +132,43 @@ def update_task(
110132
fpath = team_dir / f"{task_id}.json"
111133

112134
with file_lock(lock_path):
135+
# --- Phase 1: Read ---
113136
task = TaskFile(**json.loads(fpath.read_text()))
114137

115-
if subject is not None:
116-
task.subject = subject
117-
if description is not None:
118-
task.description = description
119-
if active_form is not None:
120-
task.active_form = active_form
121-
if owner is not None:
122-
task.owner = owner
138+
# --- Phase 2: Validate (no disk writes) ---
139+
pending_edges: dict[str, set[str]] = {}
123140

124141
if add_blocks:
125142
for b in add_blocks:
126143
if b == task_id:
127144
raise ValueError(f"Task {task_id} cannot block itself")
128145
if not (team_dir / f"{b}.json").exists():
129146
raise ValueError(f"Referenced task {b!r} does not exist")
130-
existing = set(task.blocks)
131147
for b in add_blocks:
132-
if b not in existing:
133-
task.blocks.append(b)
134-
existing.add(b)
135-
_sync_dependency(team_dir, b, "blocked_by", task_id)
148+
pending_edges.setdefault(b, set()).add(task_id)
136149

137150
if add_blocked_by:
138151
for b in add_blocked_by:
139152
if b == task_id:
140153
raise ValueError(f"Task {task_id} cannot be blocked by itself")
141154
if not (team_dir / f"{b}.json").exists():
142155
raise ValueError(f"Referenced task {b!r} does not exist")
143-
existing = set(task.blocked_by)
144156
for b in add_blocked_by:
145-
if b not in existing:
146-
task.blocked_by.append(b)
147-
existing.add(b)
148-
_sync_dependency(team_dir, b, "blocks", task_id)
157+
pending_edges.setdefault(task_id, set()).add(b)
149158

150-
if metadata is not None:
151-
current = task.metadata or {}
152-
for k, v in metadata.items():
153-
if v is None:
154-
current.pop(k, None)
155-
else:
156-
current[k] = v
157-
task.metadata = current if current else None
159+
if add_blocks:
160+
for b in add_blocks:
161+
if _would_create_cycle(team_dir, b, task_id, pending_edges):
162+
raise ValueError(
163+
f"Adding block {task_id} -> {b} would create a circular dependency"
164+
)
165+
166+
if add_blocked_by:
167+
for b in add_blocked_by:
168+
if _would_create_cycle(team_dir, task_id, b, pending_edges):
169+
raise ValueError(
170+
f"Adding dependency {task_id} blocked_by {b} would create a circular dependency"
171+
)
158172

159173
if status is not None and status != "deleted":
160174
cur_order = _STATUS_ORDER[task.status]
@@ -165,8 +179,11 @@ def update_task(
165179
raise ValueError(
166180
f"Cannot transition from {task.status!r} to {status!r}"
167181
)
168-
if status in ("in_progress", "completed") and task.blocked_by:
169-
for blocker_id in task.blocked_by:
182+
effective_blocked_by = set(task.blocked_by)
183+
if add_blocked_by:
184+
effective_blocked_by.update(add_blocked_by)
185+
if status in ("in_progress", "completed") and effective_blocked_by:
186+
for blocker_id in effective_blocked_by:
170187
blocker_path = team_dir / f"{blocker_id}.json"
171188
if blocker_path.exists():
172189
blocker = TaskFile(**json.loads(blocker_path.read_text()))
@@ -175,8 +192,60 @@ def update_task(
175192
f"Cannot set status to {status!r}: "
176193
f"blocked by task {blocker_id} (status: {blocker.status!r})"
177194
)
178-
task.status = status
179195

196+
# --- Phase 3: Mutate (in-memory only) ---
197+
pending_writes: dict[Path, TaskFile] = {}
198+
199+
if subject is not None:
200+
task.subject = subject
201+
if description is not None:
202+
task.description = description
203+
if active_form is not None:
204+
task.active_form = active_form
205+
if owner is not None:
206+
task.owner = owner
207+
208+
if add_blocks:
209+
existing = set(task.blocks)
210+
for b in add_blocks:
211+
if b not in existing:
212+
task.blocks.append(b)
213+
existing.add(b)
214+
b_path = team_dir / f"{b}.json"
215+
if b_path in pending_writes:
216+
other = pending_writes[b_path]
217+
else:
218+
other = TaskFile(**json.loads(b_path.read_text()))
219+
if task_id not in other.blocked_by:
220+
other.blocked_by.append(task_id)
221+
pending_writes[b_path] = other
222+
223+
if add_blocked_by:
224+
existing = set(task.blocked_by)
225+
for b in add_blocked_by:
226+
if b not in existing:
227+
task.blocked_by.append(b)
228+
existing.add(b)
229+
b_path = team_dir / f"{b}.json"
230+
if b_path in pending_writes:
231+
other = pending_writes[b_path]
232+
else:
233+
other = TaskFile(**json.loads(b_path.read_text()))
234+
if task_id not in other.blocks:
235+
other.blocks.append(task_id)
236+
pending_writes[b_path] = other
237+
238+
if metadata is not None:
239+
current = task.metadata or {}
240+
for k, v in metadata.items():
241+
if v is None:
242+
current.pop(k, None)
243+
else:
244+
current[k] = v
245+
task.metadata = current if current else None
246+
247+
if status is not None and status != "deleted":
248+
task.status = status
180249
if status == "completed":
181250
for f in team_dir.glob("*.json"):
182251
try:
@@ -185,22 +254,27 @@ def update_task(
185254
continue
186255
if f.stem == task_id:
187256
continue
188-
other = TaskFile(**json.loads(f.read_text()))
257+
if f in pending_writes:
258+
other = pending_writes[f]
259+
else:
260+
other = TaskFile(**json.loads(f.read_text()))
189261
if task_id in other.blocked_by:
190262
other.blocked_by.remove(task_id)
191-
f.write_text(
192-
json.dumps(other.model_dump(by_alias=True, exclude_none=True))
193-
)
263+
pending_writes[f] = other
194264

195265
if status == "deleted":
196266
task.status = "deleted"
197-
fpath.unlink()
198267
for f in team_dir.glob("*.json"):
199268
try:
200269
int(f.stem)
201270
except ValueError:
202271
continue
203-
other = TaskFile(**json.loads(f.read_text()))
272+
if f.stem == task_id:
273+
continue
274+
if f in pending_writes:
275+
other = pending_writes[f]
276+
else:
277+
other = TaskFile(**json.loads(f.read_text()))
204278
changed = False
205279
if task_id in other.blocked_by:
206280
other.blocked_by.remove(task_id)
@@ -209,21 +283,26 @@ def update_task(
209283
other.blocks.remove(task_id)
210284
changed = True
211285
if changed:
212-
f.write_text(
213-
json.dumps(other.model_dump(by_alias=True, exclude_none=True))
214-
)
215-
return task
286+
pending_writes[f] = other
216287

217-
fpath.write_text(
218-
json.dumps(task.model_dump(by_alias=True, exclude_none=True))
219-
)
288+
# --- Phase 4: Write ---
289+
if status == "deleted":
290+
_flush_pending_writes(pending_writes)
291+
fpath.unlink()
292+
else:
293+
fpath.write_text(
294+
json.dumps(task.model_dump(by_alias=True, exclude_none=True))
295+
)
296+
_flush_pending_writes(pending_writes)
220297

221298
return task
222299

223300

224301
def list_tasks(
225302
team_name: str, base_dir: Path | None = None
226303
) -> list[TaskFile]:
304+
if not team_exists(team_name, base_dir):
305+
raise ValueError(f"Team {team_name!r} does not exist")
227306
team_dir = _tasks_dir(base_dir) / team_name
228307
tasks: list[TaskFile] = []
229308
for f in team_dir.glob("*.json"):
@@ -250,7 +329,8 @@ def reset_owner_tasks(
250329
continue
251330
task = TaskFile(**json.loads(f.read_text()))
252331
if task.owner == agent_name:
253-
task.status = "pending"
332+
if task.status != "completed":
333+
task.status = "pending"
254334
task.owner = None
255335
f.write_text(
256336
json.dumps(task.model_dump(by_alias=True, exclude_none=True))

tests/test_server.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,13 @@ async def test_task_update_wraps_validation_error(self, client: Client):
425425
assert result.is_error is True
426426
assert "cannot transition" in result.content[0].text.lower()
427427

428+
async def test_task_list_wraps_nonexistent_team(self, client: Client):
429+
result = await client.call_tool(
430+
"task_list", {"team_name": "ghost-team"}, raise_on_error=False,
431+
)
432+
assert result.is_error is True
433+
assert "does not exist" in result.content[0].text.lower()
434+
428435

429436
class TestPollInbox:
430437
async def test_should_return_empty_on_timeout(self, client: Client):

tests/test_tasks.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,3 +339,93 @@ def test_delete_task_cleans_up_stale_refs(tmp_claude_dir, team_tasks_dir):
339339
t2_after = get_task("test-team", t2.id, base_dir=tmp_claude_dir)
340340
assert t1.id not in t2_after.blocked_by
341341
assert t1.id not in t2_after.blocks
342+
343+
344+
def test_no_partial_write_when_status_validation_fails(tmp_claude_dir, team_tasks_dir):
345+
blocker = create_task("test-team", "Blocker", "b", base_dir=tmp_claude_dir)
346+
task = create_task("test-team", "Task", "t", base_dir=tmp_claude_dir)
347+
blocker_before = get_task("test-team", blocker.id, base_dir=tmp_claude_dir)
348+
task_before = get_task("test-team", task.id, base_dir=tmp_claude_dir)
349+
with pytest.raises(ValueError, match="blocked by task"):
350+
update_task(
351+
"test-team", task.id,
352+
add_blocked_by=[blocker.id], status="in_progress",
353+
base_dir=tmp_claude_dir,
354+
)
355+
blocker_after = get_task("test-team", blocker.id, base_dir=tmp_claude_dir)
356+
task_after = get_task("test-team", task.id, base_dir=tmp_claude_dir)
357+
assert blocker_after.blocks == blocker_before.blocks
358+
assert task_after.blocked_by == task_before.blocked_by
359+
360+
361+
def test_no_partial_write_on_add_blocks_with_failed_status(tmp_claude_dir, team_tasks_dir):
362+
task = create_task("test-team", "Task", "t", base_dir=tmp_claude_dir)
363+
other = create_task("test-team", "Other", "o", base_dir=tmp_claude_dir)
364+
update_task("test-team", task.id, status="in_progress", base_dir=tmp_claude_dir)
365+
with pytest.raises(ValueError, match="Cannot transition"):
366+
update_task(
367+
"test-team", task.id,
368+
add_blocks=[other.id], status="pending",
369+
base_dir=tmp_claude_dir,
370+
)
371+
other_after = get_task("test-team", other.id, base_dir=tmp_claude_dir)
372+
task_after = get_task("test-team", task.id, base_dir=tmp_claude_dir)
373+
assert task_after.blocks == []
374+
assert other_after.blocked_by == []
375+
376+
377+
def test_rejects_simple_circular_dependency(tmp_claude_dir, team_tasks_dir):
378+
a = create_task("test-team", "A", "d", base_dir=tmp_claude_dir)
379+
b = create_task("test-team", "B", "d", base_dir=tmp_claude_dir)
380+
update_task("test-team", a.id, add_blocked_by=[b.id], base_dir=tmp_claude_dir)
381+
with pytest.raises(ValueError, match="circular dependency"):
382+
update_task("test-team", b.id, add_blocked_by=[a.id], base_dir=tmp_claude_dir)
383+
384+
385+
def test_rejects_transitive_circular_dependency(tmp_claude_dir, team_tasks_dir):
386+
a = create_task("test-team", "A", "d", base_dir=tmp_claude_dir)
387+
b = create_task("test-team", "B", "d", base_dir=tmp_claude_dir)
388+
c = create_task("test-team", "C", "d", base_dir=tmp_claude_dir)
389+
update_task("test-team", a.id, add_blocked_by=[b.id], base_dir=tmp_claude_dir)
390+
update_task("test-team", b.id, add_blocked_by=[c.id], base_dir=tmp_claude_dir)
391+
with pytest.raises(ValueError, match="circular dependency"):
392+
update_task("test-team", c.id, add_blocked_by=[a.id], base_dir=tmp_claude_dir)
393+
394+
395+
def test_rejects_circular_via_add_blocks(tmp_claude_dir, team_tasks_dir):
396+
a = create_task("test-team", "A", "d", base_dir=tmp_claude_dir)
397+
b = create_task("test-team", "B", "d", base_dir=tmp_claude_dir)
398+
update_task("test-team", a.id, add_blocked_by=[b.id], base_dir=tmp_claude_dir)
399+
with pytest.raises(ValueError, match="circular dependency"):
400+
update_task("test-team", a.id, add_blocks=[b.id], base_dir=tmp_claude_dir)
401+
402+
403+
def test_allows_non_cyclic_diamond_dependency(tmp_claude_dir, team_tasks_dir):
404+
a = create_task("test-team", "A", "d", base_dir=tmp_claude_dir)
405+
b = create_task("test-team", "B", "d", base_dir=tmp_claude_dir)
406+
c = create_task("test-team", "C", "d", base_dir=tmp_claude_dir)
407+
d = create_task("test-team", "D", "d", base_dir=tmp_claude_dir)
408+
update_task("test-team", d.id, add_blocked_by=[b.id, c.id], base_dir=tmp_claude_dir)
409+
update_task("test-team", b.id, add_blocked_by=[a.id], base_dir=tmp_claude_dir)
410+
update_task("test-team", c.id, add_blocked_by=[a.id], base_dir=tmp_claude_dir)
411+
d_after = get_task("test-team", d.id, base_dir=tmp_claude_dir)
412+
assert set(d_after.blocked_by) == {b.id, c.id}
413+
414+
415+
def test_list_tasks_rejects_nonexistent_team(tmp_claude_dir):
416+
with pytest.raises(ValueError, match="does not exist"):
417+
list_tasks("no-such-team", base_dir=tmp_claude_dir)
418+
419+
420+
def test_reset_owner_tasks_preserves_completed_status(tmp_claude_dir, team_tasks_dir):
421+
task = create_task("test-team", "Sub", "desc", base_dir=tmp_claude_dir)
422+
update_task(
423+
"test-team", task.id,
424+
owner="w", status="in_progress",
425+
base_dir=tmp_claude_dir,
426+
)
427+
update_task("test-team", task.id, status="completed", base_dir=tmp_claude_dir)
428+
reset_owner_tasks("test-team", "w", base_dir=tmp_claude_dir)
429+
after = get_task("test-team", task.id, base_dir=tmp_claude_dir)
430+
assert after.status == "completed"
431+
assert after.owner is None

0 commit comments

Comments
 (0)