Skip to content

Commit 03abc38

Browse files
authored
fix(tasks): cascade dependency cleanup on delete (#724) (#796)
* fix(tasks): cascade dependency cleanup on delete (#724) tasks.delete() left the deleted id in other tasks' depends_on and pointed users to a delete_cascade() that never existed. A dependent kept a dangling depends_on entry; readiness needs deps.issubset(completed), so it could never become ready — deleting a task silently and permanently stranded its dependents. delete() now strips the id from every dependent's depends_on in the same transaction as the row delete; docstring reference to the nonexistent delete_cascade() removed and the new behavior documented. Closes #724 * fix(tasks): only cascade after a real delete; test missing-task + updated_at (#724 review)
1 parent 0920ded commit 03abc38

2 files changed

Lines changed: 77 additions & 6 deletions

File tree

codeframe/core/tasks.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -725,18 +725,20 @@ def get_dependents(workspace: Workspace, task_id: str) -> list[Task]:
725725

726726

727727
def delete(workspace: Workspace, task_id: str) -> bool:
728-
"""Delete a task by ID.
728+
"""Delete a task by ID, cascading the dependency cleanup.
729+
730+
The deleted id is also stripped from every other task's ``depends_on``
731+
(#724). Without this a dependent keeps a ``depends_on`` entry pointing at a
732+
now-missing task, and ``get_ready_tasks`` (which needs
733+
``deps.issubset(completed)``) can never mark it ready — deleting a task used
734+
to silently and permanently strand its dependents.
729735
730736
Args:
731737
workspace: Target workspace
732738
task_id: Task ID to delete
733739
734740
Returns:
735-
True if task was deleted, False if not found
736-
737-
Note:
738-
This does NOT remove the task from other tasks' depends_on lists.
739-
Use delete_cascade() if you need to clean up dependencies.
741+
True if the task was deleted, False if not found
740742
"""
741743
conn = get_db_connection(workspace)
742744
try:
@@ -750,6 +752,30 @@ def delete(workspace: Workspace, task_id: str) -> bool:
750752
(workspace.id, task_id),
751753
)
752754
deleted = cursor.rowcount > 0
755+
756+
# Only cascade if the task actually existed, and only after the DELETE
757+
# so the deleted row can't appear in the scan below. Same transaction
758+
# (single commit) so no dangling reference can survive a rollback.
759+
# (This is the write-side counterpart to get_dependents()'s read: the
760+
# tasks that carry task_id in their depends_on JSON.) The full-workspace
761+
# scan is fine for typical task counts; add a LIKE pre-filter if a
762+
# workspace ever holds thousands of tasks.
763+
if deleted:
764+
now = _utc_now().isoformat()
765+
cursor.execute(
766+
"SELECT id, depends_on FROM tasks WHERE workspace_id = ?",
767+
(workspace.id,),
768+
)
769+
for dep_row_id, dep_json in cursor.fetchall():
770+
deps = json.loads(dep_json or "[]")
771+
if task_id in deps:
772+
deps = [d for d in deps if d != task_id]
773+
cursor.execute(
774+
"UPDATE tasks SET depends_on = ?, updated_at = ? "
775+
"WHERE workspace_id = ? AND id = ?",
776+
(json.dumps(deps), now, workspace.id, dep_row_id),
777+
)
778+
753779
conn.commit()
754780
finally:
755781
conn.close()

tests/core/test_task_dependencies.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,48 @@ def test_task_created_before_migration_has_empty_depends_on(self, workspace):
192192
task = tasks.create(workspace, title="Test task")
193193
retrieved = tasks.get(workspace, task.id)
194194
assert retrieved.depends_on == []
195+
196+
197+
class TestDeleteCascade:
198+
"""#724 / P0.13: deleting a task must strip its id from dependents'
199+
depends_on, or they strand (a dangling dep can never be satisfied)."""
200+
201+
def test_delete_strips_id_from_dependents(self, workspace):
202+
a = tasks.create(workspace, title="A")
203+
b = tasks.create(workspace, title="B", depends_on=[a.id])
204+
assert a.id in tasks.get(workspace, b.id).depends_on
205+
206+
assert tasks.delete(workspace, a.id) is True
207+
208+
b2 = tasks.get(workspace, b.id)
209+
assert a.id not in b2.depends_on
210+
assert b2.depends_on == [] # no dangling reference → not stranded
211+
# updated_at is bumped so caches/UI detect the cascade edit.
212+
assert b2.updated_at >= b.updated_at
213+
214+
def test_delete_missing_task_does_not_touch_dependents(self, workspace):
215+
"""A delete that finds nothing (returns False) must not mutate dependents."""
216+
a = tasks.create(workspace, title="A")
217+
b = tasks.create(workspace, title="B", depends_on=[a.id])
218+
before = tasks.get(workspace, b.id).updated_at
219+
220+
assert tasks.delete(workspace, "does-not-exist") is False
221+
222+
b2 = tasks.get(workspace, b.id)
223+
assert b2.depends_on == [a.id] # untouched
224+
assert b2.updated_at == before
225+
226+
def test_delete_keeps_other_dependencies(self, workspace):
227+
a = tasks.create(workspace, title="A")
228+
c = tasks.create(workspace, title="C")
229+
b = tasks.create(workspace, title="B", depends_on=[a.id, c.id])
230+
231+
tasks.delete(workspace, a.id)
232+
233+
b2 = tasks.get(workspace, b.id)
234+
assert b2.depends_on == [c.id] # only the deleted dep is removed
235+
236+
def test_delete_task_without_dependents(self, workspace):
237+
a = tasks.create(workspace, title="A")
238+
assert tasks.delete(workspace, a.id) is True
239+
assert tasks.get(workspace, a.id) is None

0 commit comments

Comments
 (0)