Skip to content

fix(db): revive soft-deleted push configs and checkpoints on re-upsert - #2488

Closed
QuentinBisson wants to merge 1 commit into
kagent-dev:mainfrom
QuentinBisson:fix/soft-deleted-upsert-tombstones
Closed

fix(db): revive soft-deleted push configs and checkpoints on re-upsert#2488
QuentinBisson wants to merge 1 commit into
kagent-dev:mainfrom
QuentinBisson:fix/soft-deleted-upsert-tombstones

Conversation

@QuentinBisson

Copy link
Copy Markdown
Contributor

Same class of bug as #2279, on the tables that issue did not cover.

The rule

Every table with a deleted_at column is read through a deleted_at IS NULL filter. So an upsert whose ON CONFLICT target can match a tombstone has to say what it means:

  • clear deleted_at, and the row comes back, or
  • refuse to write, and tell the caller.

Doing neither updates a tombstone that stays deleted, so the write lands where no read can see it and the caller is told nothing.

Where each table stood

table upsert on a tombstone
agent deleted_at = NULL comes back
tool, toolserver deleted_at = NULL comes back
crewai_agent_memory, crewai_flow_state deleted_at = NULL comes back
task WHERE task.deleted_at IS NULL refuses, id stays burned
session unguarded, fixed in #2471 refuses, id stays burned
push_notification neither invisible write
lg_checkpoint neither invisible write
lg_checkpoint_write neither invisible write
event, feedback no upsert not affected

agent_instance, agent_instance_task and agent_instance_task_event have no deleted_at and are hard-deleted with a cascade, so they are outside this.

The fix

The three unguarded upserts clear deleted_at, which is the same choice the other state tables already make.

These three hold writer-owned state, not an audited identity. A client re-registering a push notification config under an id it used before, or a checkpointer writing a thread's state again, is describing what should be current, so the right outcome is that the row comes back. Sessions and tasks are the opposite case, an identity that must not be reused, and that decision stays as it is.

Both soft-delete paths are reachable from the database client, DeletePushNotification and DeleteCheckpoint, so this is a live trap for anything that calls them rather than a hypothetical one.

Tests

  • TestDeletedPushConfigIsRevivedByReRegistration: register a config, delete the task's configs, register the same id again, and it is readable and listed once.
  • TestDeletedCheckpointRowsAreRevivedByANewWrite: write a checkpoint and its write, delete the thread, write the same checkpoint id again, and both are readable with the new content.

Both run against a real Postgres and both fail without the query change: the list comes back empty and the get returns not-found while the write reported success.

Every table with a deleted_at column is read through a deleted_at IS NULL
filter, so an upsert that can match a tombstone has to state what it means:
either it clears deleted_at and the row comes back, or it refuses to write and
the caller is told. agent, tool, toolserver and the CrewAI memory tables clear
it; task refuses. push_notification, lg_checkpoint and lg_checkpoint_write did
neither, so a re-upsert updated a tombstone that stayed deleted and the write
was invisible to every read, silently, with no error for the caller.

These three carry writer-owned state rather than an audited identity: a client
re-registering a push config, or a checkpointer writing a thread's state again,
is describing what should be current, so the row comes back. That matches the
other state tables, and the sessions and tasks decision not to resurrect an
identity stays as it is.

Both paths are reachable through DeletePushNotification and DeleteCheckpoint on
the database client.

Signed-off-by: QuentinBisson <quentin@giantswarm.io>
@github-actions github-actions Bot added the bug Something isn't working label Aug 18, 2026
@QuentinBisson

Copy link
Copy Markdown
Contributor Author

Closing: this fixes an unreachable path. Nothing soft-deletes push_notification, lg_checkpoint or lg_checkpoint_write today, DeletePushNotification and DeleteCheckpoint have no callers, and the A2A push-config RPCs pass through to the upstream agent rather than this table. With no tombstone the unguarded upsert cannot misbehave, so there is nothing to fix yet. Noting it on #2279 instead: whoever wires up either delete inherits that bug.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant