fix(projects): cascade dependents when deleting a project or agent

Removing a repo returned 500. `delete_project` deleted only the projects
row, but `commands`, `agents`, `approvals`, and `schedules` all carry a
foreign key to `projects.id` with no ON DELETE CASCADE — and every real
project has at least the `sync` command queued at registration referencing
it, so Postgres rejected the delete with a ForeignKeyViolation
(commands_project_id_fkey). The existing tests only deleted dependent-free
projects, so it went unnoticed (and SQLite, though it has FK enforcement on
here, was never exercised with a referencing row).

delete_project now clears dependents in FK-safe order: agent-owned rows
(checkmarks before log_entries per the use_alter cycle, approvals authored
by those agents, and shared_context attribution nulled since it's a global
table), then schedules (which reference commands via last_command_id),
then the project-scoped approvals/commands/agents, then the project.

delete_agent had the same latent bug — a spawned agent always accrues a
checkmark + log entries via the hooks, which the log_entries/checkmarks FKs
would block — so it shares the same _purge_agent_dependents helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BxKY28XKCM6o4ag3nmaVsZ
This commit is contained in:
Claude
2026-07-16 19:05:52 +00:00
parent 072f63bf2d
commit 43e1fa3619
2 changed files with 92 additions and 0 deletions
+45
View File
@@ -24,6 +24,51 @@ def test_delete_agent_row(conn):
assert repo.get_agent_by_name(conn, "p", "api") is None
def test_delete_project_cascades_all_dependents(conn):
"""A real project always has FK-referencing rows (the sync command, agent history,
approvals, schedules). Deleting it must clear them, not raise a ForeignKeyViolation."""
from datetime import UTC, datetime
repo.create_project(conn, "p", "/tmp/p", git_remote="https://github.com/me/p.git")
# The sync command queued at registration — the exact row that blocked the delete.
repo.enqueue_command(conn, "sync", project_id="p", requested_by="operator:web")
agent = repo.create_agent(conn, "p", "api", "/tmp/p/api")
repo.insert_log_entry(conn, agent_id=agent["id"], status="working", summary="did work")
repo.upsert_checkmark_row(conn, agent_id=agent["id"], status="working")
repo.set_shared_context(conn, "db", "postgres", agent["id"])
repo.record_approval(conn, "p", "feat/x", "approved", approved_by_agent_id=agent["id"])
repo.create_schedule(
conn, "p", "nightly", "run the thing", 3600, datetime.now(UTC)
)
assert repo.delete_project(conn, "p") is True
# Project and everything scoped to it are gone.
assert repo.get_project(conn, "p") is None
assert repo.get_agent_by_name(conn, "p", "api") is None
assert repo.list_commands(conn, project_id="p") == []
assert repo.list_approvals(conn, "p") == []
assert repo.list_schedules(conn) == []
# The global shared-context row survives, with its agent attribution cleared.
ctx = repo.get_shared_context_key(conn, "db")
assert ctx is not None and ctx["value"] == "postgres"
assert ctx["set_by_agent_id"] is None
def test_delete_agent_cascades_log_and_checkmark(conn):
"""Every spawned agent accrues a checkmark + log entries via the hooks; removing the
agent must clear them rather than trip the log_entries/checkmarks foreign keys."""
repo.create_project(conn, "p", "/tmp/p")
agent = repo.create_agent(conn, "p", "api", "/tmp/p/api")
repo.insert_log_entry(conn, agent_id=agent["id"], status="working", summary="x")
repo.upsert_checkmark_row(conn, agent_id=agent["id"], status="working")
assert repo.delete_agent(conn, "p", "api") is True
assert repo.get_agent_by_name(conn, "p", "api") is None
assert repo.get_checkmark(conn, agent["id"]) is None
assert repo.get_log(conn, agent["id"]) == []
def test_enqueue_get_and_list_command(conn):
repo.create_project(conn, "p", "/tmp/p")
cmd = repo.enqueue_command(