22
33import fcntl
44import json
5+ from collections import deque
56from contextlib import contextmanager
67from pathlib import Path
78from 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
4264def 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
224301def 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 ))
0 commit comments