-
Notifications
You must be signed in to change notification settings - Fork 0
fix(engine): tombstone on node-completed release instead of deleting #330
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| import { afterEach, beforeEach, describe, expect, it } from 'vitest'; | ||
| import { and, eq } from 'drizzle-orm'; | ||
| import { attachDirectNodeSocket, createWorkspace, makeNodeStack, registerAgent, type TestStack } from './harness.js'; | ||
| import { actionInvocations, agents, messages } from '../../db/schema.js'; | ||
|
|
||
| /** | ||
| * `relaycast#309` gave the LOCAL release path (`dispatchRelease` -> | ||
| * `completeLocally`) a tombstone, because four FKs reference `agents.id` with | ||
| * no ON DELETE action — `messages.agent_id`, `channels.created_by`, | ||
| * `files.uploaded_by`, `webhooks.created_by` — so a bare DELETE is refused for | ||
| * any agent that has ever spoken. | ||
| * | ||
| * The NODE-COMPLETED path (`applyReleaseCompletionEffect`) kept the bare | ||
| * DELETE. Because it runs inside the completion's atomic unit, the FK refusal | ||
| * aborts the invocation completion along with it: the invocation is stuck at | ||
| * `dispatched` forever, the seat and the name stay claimed, and the caller sees | ||
| * a plausible-looking receipt. Observed in production on 2026-08-15, where the | ||
| * split was exactly "agent has authored messages / has not" across five agents. | ||
| */ | ||
| describe('node-completed release preserves attributed history', () => { | ||
| let stack: TestStack; | ||
|
|
||
| beforeEach(() => { | ||
| stack = makeNodeStack(); | ||
| }); | ||
|
|
||
| afterEach(() => stack.close()); | ||
|
|
||
| async function post(token: string, text: string) { | ||
| const res = await stack.app.request('/v1/channels/general/messages', { | ||
| method: 'POST', | ||
| headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, | ||
| body: JSON.stringify({ text }), | ||
| }); | ||
| expect(res.status).toBe(201); | ||
| } | ||
|
|
||
| async function release(workspaceKey: string, name: string) { | ||
| const res = await stack.app.request('/v1/agents/release', { | ||
| method: 'POST', | ||
| headers: { 'content-type': 'application/json', authorization: `Bearer ${workspaceKey}` }, | ||
| body: JSON.stringify({ name, delete_agent: true }), | ||
| }); | ||
| expect(res.status).toBe(201); | ||
| return (await res.json()) as { data: { status: string; invocation_id: string } }; | ||
| } | ||
|
|
||
| // MUST-FIRE: fails before the fix — the completion aborts on the FK and the | ||
| // agent is never released. | ||
| it('tombstones an agent that has spoken when a NODE completes the release', async () => { | ||
| const ws = await createWorkspace(stack.app, 'node-release-with-history'); | ||
| const target = await registerAgent(stack.app, ws.workspaceKey, 'spoke-then-released'); | ||
| await post(target.token, 'this message must keep its author'); | ||
| // A LIVE node binding is what routes the release through | ||
| // `applyReleaseCompletionEffect` instead of the local tombstone path. | ||
| const { handle } = await attachDirectNodeSocket(stack, ws.workspaceId, target); | ||
|
|
||
| const { data } = await release(ws.workspaceKey, target.name); | ||
| expect(data.status).toBe('dispatched'); | ||
|
|
||
| // Drive the node side of the handshake: this is what the broker does. | ||
| await handle.handleMessage(JSON.stringify({ | ||
| v: 1, | ||
| type: 'action.result', | ||
| invocation_id: data.invocation_id, | ||
| output: { released: true }, | ||
| })); | ||
|
|
||
| const [invocation] = await stack.runtime.deps.db | ||
| .select({ status: actionInvocations.status }) | ||
| .from(actionInvocations) | ||
| .where(eq(actionInvocations.id, data.invocation_id)); | ||
| expect(invocation.status).toBe('completed'); | ||
|
|
||
| // The name — the scarce resource — must be free for immediate reuse. | ||
| expect( | ||
| await stack.runtime.deps.db | ||
| .select() | ||
| .from(agents) | ||
| .where(and(eq(agents.workspaceId, ws.workspaceId), eq(agents.name, target.name))), | ||
| ).toHaveLength(0); | ||
|
|
||
| // The row survives as a tombstone so history keeps its author. | ||
| const [tombstone] = await stack.runtime.deps.db | ||
| .select({ name: agents.name, status: agents.status, tokenHash: agents.tokenHash }) | ||
| .from(agents) | ||
| .where(eq(agents.id, target.agentId)); | ||
| expect(tombstone).toMatchObject({ | ||
| name: `${target.name}#released-${target.agentId}`, | ||
| status: 'released', | ||
| }); | ||
|
|
||
| // Attribution intact, and the old credential is dead. | ||
| expect( | ||
| await stack.runtime.deps.db.select().from(messages).where(eq(messages.agentId, target.agentId)), | ||
| ).toHaveLength(1); | ||
| const reuse = await stack.app.request('/v1/channels/general/messages', { | ||
| method: 'POST', | ||
| headers: { 'content-type': 'application/json', authorization: `Bearer ${target.token}` }, | ||
| body: JSON.stringify({ text: 'should be rejected' }), | ||
| }); | ||
| expect(reuse.status).toBeGreaterThanOrEqual(400); | ||
|
|
||
| // And the freed name is genuinely reusable. | ||
| const successor = await registerAgent(stack.app, ws.workspaceKey, target.name); | ||
| expect(successor.agentId).not.toBe(target.agentId); | ||
| }); | ||
|
|
||
| // MUST-NOT-FIRE: an agent with no history must release identically, so the | ||
| // fix cannot pass by making the silent case behave differently. | ||
| it('releases an agent that never spoke through the same node-completed path', async () => { | ||
| const ws = await createWorkspace(stack.app, 'node-release-no-history'); | ||
| const target = await registerAgent(stack.app, ws.workspaceKey, 'never-spoke'); | ||
| const { handle } = await attachDirectNodeSocket(stack, ws.workspaceId, target); | ||
|
|
||
| const { data } = await release(ws.workspaceKey, target.name); | ||
| expect(data.status).toBe('dispatched'); | ||
| await handle.handleMessage(JSON.stringify({ | ||
| v: 1, | ||
| type: 'action.result', | ||
| invocation_id: data.invocation_id, | ||
| output: { released: true }, | ||
| })); | ||
|
|
||
| const [invocation] = await stack.runtime.deps.db | ||
| .select({ status: actionInvocations.status }) | ||
| .from(actionInvocations) | ||
| .where(eq(actionInvocations.id, data.invocation_id)); | ||
| expect(invocation.status).toBe('completed'); | ||
| expect( | ||
| await stack.runtime.deps.db | ||
| .select() | ||
| .from(agents) | ||
| .where(and(eq(agents.workspaceId, ws.workspaceId), eq(agents.name, target.name))), | ||
| ).toHaveLength(0); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -1358,7 +1358,31 @@ async function applyReleaseCompletionEffect( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (input.delete_agent === true) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await db.delete(agents).where(and(eq(agents.workspaceId, workspaceId), eq(agents.id, agent.id))); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Tombstone rather than DELETE, matching `dispatchRelease`'s | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // `completeLocally`. Four FKs reference `agents.id` without an ON DELETE | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // action (`messages.agent_id`, `channels.created_by`, `files.uploaded_by`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // `webhooks.created_by`), so a bare delete is refused for any agent that | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // has ever spoken — and this runs inside the completion's atomic unit, so | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // that refusal aborts the invocation completion too. The seat and the name | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // then stay claimed forever and the caller only ever sees `dispatched`. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Renaming frees the unique `(workspace_id, name)` immediately while every | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // FK target stays valid and every message keeps its sender. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const releasedName = releasedAgentName(agent.name, agent.id); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // The row survives, so its credential must not. `token_hash` is NOT NULL | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // UNIQUE and cannot be cleared, so rotate it to a value nobody holds. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const releasedTokenHash = await sha256Hex(`released:${agent.id}:${randomHex(16)}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await db | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .update(agents) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .set({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: releasedName, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| handle: `@${releasedName}`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| status: RELEASED_AGENT_STATUS, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tokenHash: releasedTokenHash, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+1376
to
+1380
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a live node completes a Useful? React with 👍 / 👎. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| locationType: 'self_connected', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| locationNodeId: null, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| lastSeen: new Date(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .where(and(eq(agents.workspaceId, workspaceId), eq(agents.id, agent.id))); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+1374
to
+1385
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Persist release metadata in the node-completed path. This update does not write Proposed fix const releasedName = releasedAgentName(agent.name, agent.id);
// The row survives, so its credential must not. `token_hash` is NOT NULL
// UNIQUE and cannot be cleared, so rotate it to a value nobody holds.
const releasedTokenHash = await sha256Hex(`released:${agent.id}:${randomHex(16)}`);
+ const releasedAt = new Date();
await db
.update(agents)
.set({
name: releasedName,
handle: `@${releasedName}`,
status: RELEASED_AGENT_STATUS,
tokenHash: releasedTokenHash,
locationType: 'self_connected',
locationNodeId: null,
- lastSeen: new Date(),
+ lastSeen: releasedAt,
+ metadata: sql`json_patch(COALESCE(${agents.metadata}, '{}'), ${JSON.stringify({
+ release: {
+ reason: typeof input.reason === 'string' ? input.reason : null,
+ released_at: releasedAt.toISOString(),
+ previous_name: agent.name,
+ },
+ })})`,
})📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const implicitNodeId = `node_direct_${agent.id}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await db.delete(nodes).where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, implicitNodeId))); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a user-visible engine fix for releases that otherwise remain stuck, but I checked
CHANGELOG.mdandpackages/engine/CHANGELOG.mdand both still have empty[Unreleased]sections. Add concise Patch-level pending entries to both changelogs so the behavior change is included in release notes and the required release heading is raised.AGENTS.md reference: AGENTS.md:L39-L44
Useful? React with 👍 / 👎.