From 8d0738a02ffec8a8ad312656de657d9c97dc3e3b Mon Sep 17 00:00:00 2001 From: Mamba Date: Sun, 23 Aug 2026 21:18:54 +0800 Subject: [PATCH] fix(qoder): recover incomplete forward destroys --- .../sdk/src/internal/core/destroy-runtime.ts | 14 +++++++--- .../src/internal/providers/qoder/adapter.ts | 19 ++++++++++++-- .../sdk/tests/unit/destroy-runtime.test.ts | 26 +++++++++++++++++++ .../tests/unit/qoder-forward-template.test.ts | 16 ++++++++++++ 4 files changed, 69 insertions(+), 6 deletions(-) diff --git a/packages/sdk/src/internal/core/destroy-runtime.ts b/packages/sdk/src/internal/core/destroy-runtime.ts index ebaa6c1..7b83c36 100644 --- a/packages/sdk/src/internal/core/destroy-runtime.ts +++ b/packages/sdk/src/internal/core/destroy-runtime.ts @@ -86,13 +86,19 @@ export function planDestroyProjectContext(ctx: ProjectRuntimeContext): DestroyPl for (const [agentName, agent] of Object.entries(ctx.config.agents ?? {})) { if (!agent.default_memory_store || agent.delivery?.qoder?.type !== "forward") continue; if (agent.provider && agent.provider !== "qoder") continue; + const identityId = identityName + ? (ctx.state.getResource({ type: "identity", name: identityName, provider: "qoder" })?.remote_id ?? null) + : null; + const templateId = + ctx.state.getResource({ type: "template", name: agentName, provider: "qoder" })?.remote_id ?? null; + // A system-managed Store cannot exist until both of its owners have been applied. + // Do not block cleanup of an independently-created resource after an incomplete apply. + if (!identityId && !templateId) continue; defaultMemoryStores.push({ agentName, provider: "qoder", - identityId: identityName - ? (ctx.state.getResource({ type: "identity", name: identityName, provider: "qoder" })?.remote_id ?? null) - : null, - templateId: ctx.state.getResource({ type: "template", name: agentName, provider: "qoder" })?.remote_id ?? null, + identityId, + templateId, deleteOnDestroy: agent.default_memory_store.delete_on_destroy ?? false, }); } diff --git a/packages/sdk/src/internal/providers/qoder/adapter.ts b/packages/sdk/src/internal/providers/qoder/adapter.ts index 89f52b1..718a9ce 100644 --- a/packages/sdk/src/internal/providers/qoder/adapter.ts +++ b/packages/sdk/src/internal/providers/qoder/adapter.ts @@ -459,8 +459,23 @@ export class QoderAdapter implements ProviderAdapter { async deleteEnvironment(id: string, cascade = false, mode: ProviderResourceMode = "managed"): Promise { if (mode === "forward") { - await this.forwardClient.delete(`/environments/${id}`); - return; + try { + await this.forwardClient.delete(`/environments/${id}`); + return; + } catch (err) { + const isConflict = err instanceof ApiError && (err.statusCode === 409 || err.responseBody.includes("in use")); + if (!isConflict) throw err; + if (!cascade) { + throw new UserError( + `Environment ${id} is referenced by one or more Forward sessions. ` + + `Use --cascade to archive the environment.`, + ); + } + // Qoder Forward may retain a session reference after the session is + // gone. Its API requires archiving the environment in that case. + await this.forwardClient.post(`/environments/${id}/archive`, {}); + return; + } } try { await this.client.delete(`/environments/${id}`); diff --git a/packages/sdk/tests/unit/destroy-runtime.test.ts b/packages/sdk/tests/unit/destroy-runtime.test.ts index 560b333..7f32273 100644 --- a/packages/sdk/tests/unit/destroy-runtime.test.ts +++ b/packages/sdk/tests/unit/destroy-runtime.test.ts @@ -144,6 +144,32 @@ describe("destroy runtime", () => { expect(result.partial).toBe(false); }); + test("does not block destroy for a default Store whose Template and Identity were never recorded", async () => { + const calls: string[] = []; + const runtime = await ctx([resource("environment", "oncall-env", "env_1")], adapter(calls)); + runtime.config.defaults = { provider: "qoder", identity: "oncall" }; + runtime.config.identities = { + oncall: { external_id: "oncall" }, + }; + runtime.config.agents = { + "oncall-agent": { + model: { qoder: "auto" }, + instructions: "Help.", + delivery: { qoder: { type: "forward" } }, + default_memory_store: { name: "Oncall memory", delete_on_destroy: true }, + }, + }; + + const plan = planDestroyProjectContext(runtime); + expect(plan.defaultMemoryStores).toEqual([]); + + const result = await destroyPlannedProjectResources(plan); + + expect(calls).toEqual(["environment:env_1:plain"]); + expect(result.destroyed).toBe(1); + expect(result.partial).toBe(false); + }); + test("aborts destroy when the default Store preflight cannot capture its ID", async () => { const calls: string[] = []; const runtime = await ctx( diff --git a/packages/sdk/tests/unit/qoder-forward-template.test.ts b/packages/sdk/tests/unit/qoder-forward-template.test.ts index 36f7a66..7bddb1d 100644 --- a/packages/sdk/tests/unit/qoder-forward-template.test.ts +++ b/packages/sdk/tests/unit/qoder-forward-template.test.ts @@ -218,6 +218,22 @@ describe("Qoder Forward Template mapping and lifecycle", () => { expect(calls).toEqual(["managed POST /environments", "forward POST /environments"]); }); + test("archives a Forward Environment with a stale session reference when cascade is enabled", async () => { + const calls: string[] = []; + const adapter = new QoderAdapter("pt-test") as any; + adapter.forwardClient = { + delete: async (path: string) => { + calls.push(`DELETE ${path}`); + throw new ApiError(409, "Environment is in use. Archive the environment instead.", "Forward API"); + }, + post: async (path: string) => calls.push(`POST ${path}`), + }; + + await adapter.deleteEnvironment("env_forward", true, "forward"); + + expect(calls).toEqual(["DELETE /environments/env_forward", "POST /environments/env_forward/archive"]); + }); + test("resolves an external Environment id from either API domain", async () => { const calls: string[] = []; const adapter = new QoderAdapter("pt-test") as any;