From 4972f9725b72b927dae33ea3930979477c91cf22 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:33:37 -0700 Subject: [PATCH 1/3] fix(agents): every path that removes an agent's last install revokes its runtime token (TASK-625) The uninstall routes revoke the token after the last uninstall (#2221). Three other paths ended an agent's installs without doing so: - deleting a pod (deletePodCascade) removed its installation rows - the stale-install prune deleted the rows it had marked stale - hosted deprovision stopped the worker and left its token for the next provision to rotate Pod deletion and the prune now call revokeRuntimeTokensAfterLastUninstall per identity, so an agent still installed elsewhere keeps its token. Deprovision revokes the stopped worker's token; provision already mints a fresh one. Service test: delete the only pod, or prune the last stale install, then reinstall the identity; the old token gets 401. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DRK3nqxjeAV7xL8uhg --- .../uninstallRevokesRuntimeCredential.test.js | 111 ++++++++++++++++++ backend/__tests__/unit/routes/hosted.test.js | 4 + backend/routes/hosted.ts | 12 +- .../agentInstallationCleanupService.ts | 5 + backend/services/podDeletionService.ts | 17 +++ 5 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 backend/__tests__/service/uninstallRevokesRuntimeCredential.test.js diff --git a/backend/__tests__/service/uninstallRevokesRuntimeCredential.test.js b/backend/__tests__/service/uninstallRevokesRuntimeCredential.test.js new file mode 100644 index 0000000000..24f70e9cd0 --- /dev/null +++ b/backend/__tests__/service/uninstallRevokesRuntimeCredential.test.js @@ -0,0 +1,111 @@ +// Every way an agent's last installation goes away retires its runtime +// token (TASK-625), the way the uninstall routes do (#2221): deleting the pod +// and pruning a stale install now do it too, and a reinstall of the same +// identity starts from a fresh token. +// +// A real mongod, because the claim is about which stored records survive; the +// probe is the real runtime auth middleware, so "dead" means what an agent +// would see: its next call is a 401. +const express = require('express'); +const rateLimit = require('express-rate-limit'); +const request = require('supertest'); +const mongoose = require('mongoose'); +const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../utils/testUtils'); + +process.env.PG_HOST = ''; +process.env.NODE_ENV = 'test'; + +jest.mock('../../services/starterTaskService', () => ({ completeConnectAgentStarterTask: jest.fn() })); + +const Pod = require('../../models/Pod'); +const User = require('../../models/User'); +const AgentCredential = require('../../models/AgentCredential'); +const { AgentInstallation } = require('../../models/AgentRegistry'); +const AgentIdentityService = require('../../services/agentIdentityService'); +const { issueRuntimeTokenForAgent } = require('../../routes/registry/tokens'); +const { deletePodCascade } = require('../../services/podDeletionService'); +const { pruneStaleInstallations } = require('../../services/agentInstallationCleanupService'); +const agentRuntimeAuth = require('../../middleware/agentRuntimeAuth'); + +const app = express(); +const probeLimit = rateLimit({ windowMs: 60 * 1000, max: 10000 }); +app.get('/probe', probeLimit, agentRuntimeAuth, (req, res) => res.json({ agent: req.agentUser?.username || null })); +const probe = (token) => request(app).get('/probe').set('Authorization', `Bearer ${token}`); + +const AGENT = 'maya-claude'; +const ownerId = new mongoose.Types.ObjectId(); + +const makePod = (name) => Pod.create({ name, type: 'chat', createdBy: ownerId, members: [ownerId] }); +const install = (pod, extra = {}) => AgentInstallation.create({ + agentName: AGENT, + instanceId: 'default', + podId: pod._id, + installedBy: ownerId, + status: 'active', + displayName: 'Maya Claude', + version: '1.0.0', + ...extra, +}); + +const issue = async (installation) => { + const agentUser = await AgentIdentityService.getOrCreateAgentUser(AGENT, { instanceId: 'default' }); + const issued = await issueRuntimeTokenForAgent(agentUser, 'CLI key', installation, { ownerUserId: ownerId }); + return issued.token; +}; + +const credentialStatus = async () => { + const rows = await AgentCredential.find({ kind: 'runtime' }).select('status').lean(); + return rows.map((row) => row.status); +}; + +describe('the last installation going away revokes the runtime token', () => { + beforeAll(async () => { await setupMongoDb(); }); + afterAll(async () => { await closeMongoDb(); }); + beforeEach(async () => { + await clearMongoDb(); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => jest.restoreAllMocks()); + + test('deleting the only pod revokes it, and a reinstall does not revive it', async () => { + const pod = await makePod('Only pod'); + const token = await issue(await install(pod)); + expect((await probe(token)).status).toBe(200); + + await deletePodCascade(String(pod._id)); + + expect(await credentialStatus()).toEqual(['revoked']); + expect(await User.countDocuments({ 'agentRuntimeTokens.0': { $exists: true } })).toBe(0); + await install(await makePod('New pod')); + expect((await probe(token)).status).toBe(401); + }); + + test('deleting one of two pods leaves the token working', async () => { + const pod = await makePod('Doomed'); + const keep = await makePod('Kept'); + const token = await issue(await install(pod)); + await install(keep); + + await deletePodCascade(String(pod._id)); + + expect(await credentialStatus()).toEqual(['active']); + expect((await probe(token)).status).toBe(200); + }); + + test('pruning the last stale install revokes it, and a reinstall does not revive it', async () => { + const pod = await makePod('Stale pod'); + const installation = await install(pod); + const token = await issue(installation); + await AgentInstallation.updateOne( + { _id: installation._id }, + { $set: { status: 'stale', staleSince: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) } }, + ); + + const result = await pruneStaleInstallations(14); + + expect(result).toEqual({ deleted: 1, failures: 0 }); + expect(await credentialStatus()).toEqual(['revoked']); + await install(pod); + expect((await probe(token)).status).toBe(401); + }); +}); diff --git a/backend/__tests__/unit/routes/hosted.test.js b/backend/__tests__/unit/routes/hosted.test.js index f3051f544f..a1bb258979 100644 --- a/backend/__tests__/unit/routes/hosted.test.js +++ b/backend/__tests__/unit/routes/hosted.test.js @@ -405,6 +405,10 @@ describe('/api/hosted', () => { expect(res.status).toBe(200); expect(res.body).toMatchObject({ deprovisioned: true }); expect(installation.config.get('hosted').provisionedAt).toBeNull(); + // The stopped worker held the only copy; nothing else may keep using it. + expect(mockRevokeRuntimeTokens).toHaveBeenCalledWith(expect.objectContaining({ + agentName: 'scout', instanceId: 'default', installation, + })); }); // TASK-265: a migration that marks the row native while the diff --git a/backend/routes/hosted.ts b/backend/routes/hosted.ts index f3e3957e96..f6175518c0 100644 --- a/backend/routes/hosted.ts +++ b/backend/routes/hosted.ts @@ -354,8 +354,7 @@ router.post('/provision', hostedRateLimit, auth, async (req: any, res: any) => { * POST /api/hosted/deprovision { agentName, instanceId? } * Stops the runtime. The installation and identity stay — ADR-001 §3 identity * continuity; re-provisioning finds the same memory. The token does NOT - * survive: provision rotates on every call, so a - * re-provision mints fresh and orphans anything still holding the old one. + * survive: it is revoked here, and provision mints a fresh one. */ router.post('/deprovision', hostedRateLimit, auth, async (req: any, res: any) => { try { @@ -375,6 +374,15 @@ router.post('/deprovision', hostedRateLimit, auth, async (req: any, res: any) => } if (!requireConfigured(res, hostedEngineOf(installation))) return undefined; await hostedRuntime.deprovisionAgent(agentName, instanceId); + // The stopped runtime was the token's only holder. Revoke it now rather + // than at the next provision, which may never come. + const agentUser = await User.findOne({ + username: AgentIdentityService.buildAgentUsername(agentName, instanceId), + isBot: true, + }); + if (agentUser) { + await revokeRuntimeTokensForAgent({ agentUser, agentName, instanceId, installation }); + } // A row migrated meanwhile keeps the migration's record; the worker is // stopped either way, which is what both wanted. await writeDurableObjectRecord(installation, { provisionedAt: null, deprovisionedAt: new Date() }); diff --git a/backend/services/agentInstallationCleanupService.ts b/backend/services/agentInstallationCleanupService.ts index cee9dbee81..52888f966e 100644 --- a/backend/services/agentInstallationCleanupService.ts +++ b/backend/services/agentInstallationCleanupService.ts @@ -313,6 +313,11 @@ export async function pruneStaleInstallations( } await AgentInstallation.deleteOne({ _id: inst._id }); + // A pruned install is an uninstall: once none is active, the agent's + // runtime token stops authenticating too. + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const { revokeRuntimeTokensAfterLastUninstall } = require('../routes/registry/tokens'); + await revokeRuntimeTokensAfterLastUninstall(inst.agentName, inst.instanceId || 'default'); deleted += 1; } catch (err) { console.error( diff --git a/backend/services/podDeletionService.ts b/backend/services/podDeletionService.ts index af68e53a3e..1b48740ce2 100644 --- a/backend/services/podDeletionService.ts +++ b/backend/services/podDeletionService.ts @@ -25,6 +25,10 @@ export const deletePodCascade = async (podId: string): Promise => { // A hierarchy link is not ownership. Keep children and their contents. await Pod.updateMany({ parentPod: podId }, { $unset: { parentPod: 1 } }); await Message.deleteMany({ podId }); + // Read before the rows go: an agent whose last install was in this pod + // must lose its runtime token with it, same as an uninstall. + const installedAgents = await AgentInstallation.find({ podId }) + .select('agentName instanceId').lean(); if (PGMessage) { await PGMessage.deleteByPodId(podId); } @@ -38,6 +42,19 @@ export const deletePodCascade = async (podId: string): Promise => { AgentProfile.deleteMany({ podId }), ]); + const identities = new Map(); + for (const row of installedAgents || []) { + const instanceId = row.instanceId || 'default'; + identities.set(`${row.agentName}:${instanceId}`, { agentName: row.agentName, instanceId }); + } + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const { revokeRuntimeTokensAfterLastUninstall } = require('../routes/registry/tokens'); + // The helper counts active installs first, so an agent still installed in + // another pod keeps its token, and a failed delete above revokes nothing. + for (const { agentName, instanceId } of identities.values()) { + await revokeRuntimeTokensAfterLastUninstall(agentName, instanceId); + } + if (PGPod) { await PGPod.delete(podId); } From 5864011dbc03a7b345ecf26d37ddd12d2dd06f74 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:42:32 -0700 Subject: [PATCH 2/3] fix(agents): revoke before deleting, so a failed revoke is retried (TASK-625) - Pod deletion marks the pod's installs uninstalled and revokes before deleting the rows; a revoke that throws leaves them for the retry. - The stale prune revokes before deleting the row (a stale row is not active), so a failure is retried on the next run. - Hosted deprovision revokes installation copies even when no bot user resolves. - podController unit mock gains find/updateMany; service test covers the prune retry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DRK3nqxjeAV7xL8uhg --- .../uninstallRevokesRuntimeCredential.test.js | 22 ++++++++++++- .../unit/controllers/podController.test.js | 3 ++ backend/routes/hosted.ts | 5 ++- .../agentInstallationCleanupService.ts | 5 +-- backend/services/podDeletionService.ts | 32 ++++++++++--------- 5 files changed, 46 insertions(+), 21 deletions(-) diff --git a/backend/__tests__/service/uninstallRevokesRuntimeCredential.test.js b/backend/__tests__/service/uninstallRevokesRuntimeCredential.test.js index 24f70e9cd0..e2f36ee5c0 100644 --- a/backend/__tests__/service/uninstallRevokesRuntimeCredential.test.js +++ b/backend/__tests__/service/uninstallRevokesRuntimeCredential.test.js @@ -22,7 +22,9 @@ const User = require('../../models/User'); const AgentCredential = require('../../models/AgentCredential'); const { AgentInstallation } = require('../../models/AgentRegistry'); const AgentIdentityService = require('../../services/agentIdentityService'); -const { issueRuntimeTokenForAgent } = require('../../routes/registry/tokens'); +const tokens = require('../../routes/registry/tokens'); + +const { issueRuntimeTokenForAgent } = tokens; const { deletePodCascade } = require('../../services/podDeletionService'); const { pruneStaleInstallations } = require('../../services/agentInstallationCleanupService'); const agentRuntimeAuth = require('../../middleware/agentRuntimeAuth'); @@ -108,4 +110,22 @@ describe('the last installation going away revokes the runtime token', () => { await install(pod); expect((await probe(token)).status).toBe(401); }); + test('a prune whose revoke fails keeps the row, and the next run finishes the job', async () => { + const pod = await makePod('Retry pod'); + const installation = await install(pod); + await issue(installation); + await AgentInstallation.updateOne( + { _id: installation._id }, + { $set: { status: 'stale', staleSince: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) } }, + ); + jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(tokens, 'revokeRuntimeTokensAfterLastUninstall').mockRejectedValueOnce(new Error('db blip')); + + expect(await pruneStaleInstallations(14)).toEqual({ deleted: 0, failures: 1 }); + expect(await AgentInstallation.countDocuments({ _id: installation._id })).toBe(1); + expect(await credentialStatus()).toEqual(['active']); + + expect(await pruneStaleInstallations(14)).toEqual({ deleted: 1, failures: 0 }); + expect(await credentialStatus()).toEqual(['revoked']); + }); }); diff --git a/backend/__tests__/unit/controllers/podController.test.js b/backend/__tests__/unit/controllers/podController.test.js index 2bd4a235c7..2c522fc598 100644 --- a/backend/__tests__/unit/controllers/podController.test.js +++ b/backend/__tests__/unit/controllers/podController.test.js @@ -30,6 +30,9 @@ jest.mock('../../../models/AgentRegistry', () => ({ isInstalled: jest.fn(), install: jest.fn(), deleteMany: jest.fn(), + // deletePodCascade reads the pod's agents before deleting them. + find: jest.fn(() => ({ select: () => ({ lean: async () => [] }) })), + updateMany: jest.fn(), }, })); jest.mock('../../../models/AgentProfile', () => ({ diff --git a/backend/routes/hosted.ts b/backend/routes/hosted.ts index f6175518c0..1e40d68e52 100644 --- a/backend/routes/hosted.ts +++ b/backend/routes/hosted.ts @@ -376,13 +376,12 @@ router.post('/deprovision', hostedRateLimit, auth, async (req: any, res: any) => await hostedRuntime.deprovisionAgent(agentName, instanceId); // The stopped runtime was the token's only holder. Revoke it now rather // than at the next provision, which may never come. + // Without a bot user the installation copies are still revoked. const agentUser = await User.findOne({ username: AgentIdentityService.buildAgentUsername(agentName, instanceId), isBot: true, }); - if (agentUser) { - await revokeRuntimeTokensForAgent({ agentUser, agentName, instanceId, installation }); - } + await revokeRuntimeTokensForAgent({ agentUser, agentName, instanceId, installation }); // A row migrated meanwhile keeps the migration's record; the worker is // stopped either way, which is what both wanted. await writeDurableObjectRecord(installation, { provisionedAt: null, deprovisionedAt: new Date() }); diff --git a/backend/services/agentInstallationCleanupService.ts b/backend/services/agentInstallationCleanupService.ts index 52888f966e..3b86b43746 100644 --- a/backend/services/agentInstallationCleanupService.ts +++ b/backend/services/agentInstallationCleanupService.ts @@ -312,12 +312,13 @@ export async function pruneStaleInstallations( await Pod.updateOne({ _id: inst.podId }, { $pull: { members: agentUser._id } }); } - await AgentInstallation.deleteOne({ _id: inst._id }); // A pruned install is an uninstall: once none is active, the agent's - // runtime token stops authenticating too. + // runtime token stops authenticating too. A stale row is not active, so + // revoke first; if it throws, the row survives for the next run. // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports const { revokeRuntimeTokensAfterLastUninstall } = require('../routes/registry/tokens'); await revokeRuntimeTokensAfterLastUninstall(inst.agentName, inst.instanceId || 'default'); + await AgentInstallation.deleteOne({ _id: inst._id }); deleted += 1; } catch (err) { console.error( diff --git a/backend/services/podDeletionService.ts b/backend/services/podDeletionService.ts index 1b48740ce2..53a67c5872 100644 --- a/backend/services/podDeletionService.ts +++ b/backend/services/podDeletionService.ts @@ -25,23 +25,18 @@ export const deletePodCascade = async (podId: string): Promise => { // A hierarchy link is not ownership. Keep children and their contents. await Pod.updateMany({ parentPod: podId }, { $unset: { parentPod: 1 } }); await Message.deleteMany({ podId }); - // Read before the rows go: an agent whose last install was in this pod - // must lose its runtime token with it, same as an uninstall. - const installedAgents = await AgentInstallation.find({ podId }) - .select('agentName instanceId').lean(); if (PGMessage) { await PGMessage.deleteByPodId(podId); } - await Promise.allSettled([ - Post.deleteMany({ podId }), - Summary.deleteMany({ podId }), - PodAsset.deleteMany({ podId }), - Integration.deleteMany({ podId }), - AgentInstallation.deleteMany({ podId }), - AgentProfile.deleteMany({ podId }), - ]); - + // An agent whose last install was in this pod loses its runtime token, as + // on uninstall. Uninstall the rows first and revoke before deleting them: + // if a revoke throws, the rows are still here and a retried delete finds + // them again. The helper counts active installs, so an agent installed in + // another pod keeps its token. + const installedAgents = await AgentInstallation.find({ podId }) + .select('agentName instanceId').lean(); + await AgentInstallation.updateMany({ podId }, { $set: { status: 'uninstalled' } }); const identities = new Map(); for (const row of installedAgents || []) { const instanceId = row.instanceId || 'default'; @@ -49,12 +44,19 @@ export const deletePodCascade = async (podId: string): Promise => { } // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports const { revokeRuntimeTokensAfterLastUninstall } = require('../routes/registry/tokens'); - // The helper counts active installs first, so an agent still installed in - // another pod keeps its token, and a failed delete above revokes nothing. for (const { agentName, instanceId } of identities.values()) { await revokeRuntimeTokensAfterLastUninstall(agentName, instanceId); } + await Promise.allSettled([ + Post.deleteMany({ podId }), + Summary.deleteMany({ podId }), + PodAsset.deleteMany({ podId }), + Integration.deleteMany({ podId }), + AgentInstallation.deleteMany({ podId }), + AgentProfile.deleteMany({ podId }), + ]); + if (PGPod) { await PGPod.delete(podId); } From 6f5d0ebb86bd128c8294e4e7fd8e57f5d646d419 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:53:01 -0700 Subject: [PATCH 3/3] fix(agents): revoke before any deletion; fence the prune; drop deprovisioned sockets (TASK-625) - Pod deletion revokes before touching messages or the hierarchy, so a failed revoke leaves the pod whole for the retry (service test). - The prune deletes a row only if it is still stale. - Hosted deprovision disconnects sockets of the revoked identity. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DRK3nqxjeAV7xL8uhg --- .../uninstallRevokesRuntimeCredential.test.js | 16 ++++++++++++++++ backend/routes/hosted.ts | 3 +++ .../agentInstallationCleanupService.ts | 5 +++-- backend/services/podDeletionService.ts | 18 ++++++++++-------- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/backend/__tests__/service/uninstallRevokesRuntimeCredential.test.js b/backend/__tests__/service/uninstallRevokesRuntimeCredential.test.js index e2f36ee5c0..16dc485ea9 100644 --- a/backend/__tests__/service/uninstallRevokesRuntimeCredential.test.js +++ b/backend/__tests__/service/uninstallRevokesRuntimeCredential.test.js @@ -18,6 +18,7 @@ process.env.NODE_ENV = 'test'; jest.mock('../../services/starterTaskService', () => ({ completeConnectAgentStarterTask: jest.fn() })); const Pod = require('../../models/Pod'); +const Message = require('../../models/Message'); const User = require('../../models/User'); const AgentCredential = require('../../models/AgentCredential'); const { AgentInstallation } = require('../../models/AgentRegistry'); @@ -128,4 +129,19 @@ describe('the last installation going away revokes the runtime token', () => { expect(await pruneStaleInstallations(14)).toEqual({ deleted: 1, failures: 0 }); expect(await credentialStatus()).toEqual(['revoked']); }); + test('a pod deletion whose revoke fails deletes nothing, and the retry revokes', async () => { + const pod = await makePod('Retry delete'); + await issue(await install(pod)); + await Message.create({ podId: pod._id, userId: ownerId, content: 'keep me' }); + jest.spyOn(tokens, 'revokeRuntimeTokensAfterLastUninstall').mockRejectedValueOnce(new Error('db blip')); + + await expect(deletePodCascade(String(pod._id))).rejects.toThrow('db blip'); + expect(await Pod.countDocuments({ _id: pod._id })).toBe(1); + expect(await Message.countDocuments({ podId: pod._id })).toBe(1); + expect(await AgentInstallation.countDocuments({ podId: pod._id })).toBe(1); + + await deletePodCascade(String(pod._id)); + expect(await credentialStatus()).toEqual(['revoked']); + expect(await Pod.countDocuments({ _id: pod._id })).toBe(0); + }); }); diff --git a/backend/routes/hosted.ts b/backend/routes/hosted.ts index 1e40d68e52..a931788716 100644 --- a/backend/routes/hosted.ts +++ b/backend/routes/hosted.ts @@ -382,6 +382,9 @@ router.post('/deprovision', hostedRateLimit, auth, async (req: any, res: any) => isBot: true, }); await revokeRuntimeTokensForAgent({ agentUser, agentName, instanceId, installation }); + // A socket authenticated with the old token stays open until told to go. + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + require('../services/agentWebSocketService').disconnectIdentity(agentName, instanceId); // A row migrated meanwhile keeps the migration's record; the worker is // stopped either way, which is what both wanted. await writeDurableObjectRecord(installation, { provisionedAt: null, deprovisionedAt: new Date() }); diff --git a/backend/services/agentInstallationCleanupService.ts b/backend/services/agentInstallationCleanupService.ts index 3b86b43746..455507b03f 100644 --- a/backend/services/agentInstallationCleanupService.ts +++ b/backend/services/agentInstallationCleanupService.ts @@ -318,8 +318,9 @@ export async function pruneStaleInstallations( // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports const { revokeRuntimeTokensAfterLastUninstall } = require('../routes/registry/tokens'); await revokeRuntimeTokensAfterLastUninstall(inst.agentName, inst.instanceId || 'default'); - await AgentInstallation.deleteOne({ _id: inst._id }); - deleted += 1; + // Only if still stale: a reinstall may have reactivated the row meanwhile. + const removed = await AgentInstallation.deleteOne({ _id: inst._id, status: 'stale' }); + if (removed?.deletedCount) deleted += 1; } catch (err) { console.error( '[installation-cleanup] failed to prune install %s (%s/%s): %s', diff --git a/backend/services/podDeletionService.ts b/backend/services/podDeletionService.ts index 53a67c5872..9d08dd3075 100644 --- a/backend/services/podDeletionService.ts +++ b/backend/services/podDeletionService.ts @@ -22,15 +22,8 @@ if (process.env.PG_HOST) { } export const deletePodCascade = async (podId: string): Promise => { - // A hierarchy link is not ownership. Keep children and their contents. - await Pod.updateMany({ parentPod: podId }, { $unset: { parentPod: 1 } }); - await Message.deleteMany({ podId }); - if (PGMessage) { - await PGMessage.deleteByPodId(podId); - } - // An agent whose last install was in this pod loses its runtime token, as - // on uninstall. Uninstall the rows first and revoke before deleting them: + // on uninstall. Uninstall the rows and revoke before deleting anything: // if a revoke throws, the rows are still here and a retried delete finds // them again. The helper counts active installs, so an agent installed in // another pod keeps its token. @@ -48,6 +41,15 @@ export const deletePodCascade = async (podId: string): Promise => { await revokeRuntimeTokensAfterLastUninstall(agentName, instanceId); } + // Nothing irreversible runs before the revoke above, so a failure there + // leaves the pod and its content for the retry. + // A hierarchy link is not ownership. Keep children and their contents. + await Pod.updateMany({ parentPod: podId }, { $unset: { parentPod: 1 } }); + await Message.deleteMany({ podId }); + if (PGMessage) { + await PGMessage.deleteByPodId(podId); + } + await Promise.allSettled([ Post.deleteMany({ podId }), Summary.deleteMany({ podId }),