Skip to content

Freeing an agent name requires deleting its history — and the delete is refused for any agent that has ever spoken #309

Description

@khaliqgant

Summary

There is no way to free an agent name without destroying the agent's history, so the operator escape hatch fails in exactly the situation that needs it: an agent that has been doing work and now needs its name back.

deleteAgent issues a bare delete:

// packages/engine/src/engine/agent.ts:277
export async function deleteAgent(db: Db, workspaceId: string, name: string) {
  const [agent] = await db
    .select()
    .from(agents)
    .where(and(eq(agents.workspaceId, workspaceId), eq(agents.name, name)));

  if (!agent) return false;

  await db.delete(agents).where(eq(agents.id, agent.id));
  await db.delete(nodes).where(eq(nodes.id, directNodeIdForAgent(agent.id)));
  return true;
}

Four foreign keys to agents.id are declared bare — no onDelete, i.e. RESTRICT:

table / column packages/engine/src/db/schema.ts
channels.created_by :455
messages sender :503
files :666
webhooks.created_by :759

Every other FK to agents.id in that file carries an explicit onDelete: 'cascade' or 'set null'. These four do not, so the delete is refused for any agent that has ever created a channel, sent a message, uploaded a file, or created a webhook — which is every agent worth reclaiming a name from.

The failure is server-side, so agent-relay agent remove <name> fails too. There is currently no supported operation that frees a name.

Why this is worth fixing first

This is what turned a two-minute node restart into an unrecoverable name loss on 2026-08-07. A broker whose process is killed uncleanly never deregisters; the record stays behind holding the name; the name cannot be freed; the node has to come back under a different name and every --node <name> reference in the fleet has to be rewritten. With this fixed the same incident is a ten-second recovery.

It is also the only path that recovers an already-stranded name.

Do not fix with cascade

Adding onDelete: 'cascade' to these four would make the delete succeed by deleting every message the agent ever sent, plus the channels it created and the files it uploaded. The history is the point — that is a worse outcome than the current failure, and it is silent.

Proposed fix — tombstone rename

The unique constraint is (workspace_id, name). The name only has to stop colliding; the row does not have to go away.

Rename the row to a tombstone and mark it inactive:

  • name<name>#released-<timestamp> (and handle to match)
  • status → inactive/released
  • record the release in metadata — note that metadata.release { reason, releasedAt } is already an established shape on live records, so this reuses an existing convention rather than inventing one

Result: the name is immediately free for re-registration, all four FK targets remain valid, every message keeps its sender, and no schema migration is required.

Worth deciding as part of the fix:

  • whether this replaces deleteAgent or becomes a sibling releaseAgentName, and what agent-relay agent remove maps to
  • whether listing endpoints filter tombstoned rows by default (they should, or agent list fills with #released- entries)
  • tombstone name collision if the same name is released twice in the same second

Verification

deleteAgent and the four schema lines read from main at 08ddec7. The FK audit is the complete set of bare .references(() => agents.id) in schema.ts — lines 217, 257, 286, 353, 393, 476, 538, 570, 644, 711, 807, 852, 921, 950 all carry an explicit onDelete; only 455, 503, 666 and 759 do not.

Context

Filed from an incident brief covering the 2026-08-07 node outage. Two companion issues cover the relay side. Do not merge without Khaliq — he owns the merge gate.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions