Skip to content

Commit 8d0738a

Browse files
MambaMamba
authored andcommitted
fix(qoder): recover incomplete forward destroys
1 parent 89b7744 commit 8d0738a

4 files changed

Lines changed: 69 additions & 6 deletions

File tree

packages/sdk/src/internal/core/destroy-runtime.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,19 @@ export function planDestroyProjectContext(ctx: ProjectRuntimeContext): DestroyPl
8686
for (const [agentName, agent] of Object.entries(ctx.config.agents ?? {})) {
8787
if (!agent.default_memory_store || agent.delivery?.qoder?.type !== "forward") continue;
8888
if (agent.provider && agent.provider !== "qoder") continue;
89+
const identityId = identityName
90+
? (ctx.state.getResource({ type: "identity", name: identityName, provider: "qoder" })?.remote_id ?? null)
91+
: null;
92+
const templateId =
93+
ctx.state.getResource({ type: "template", name: agentName, provider: "qoder" })?.remote_id ?? null;
94+
// A system-managed Store cannot exist until both of its owners have been applied.
95+
// Do not block cleanup of an independently-created resource after an incomplete apply.
96+
if (!identityId && !templateId) continue;
8997
defaultMemoryStores.push({
9098
agentName,
9199
provider: "qoder",
92-
identityId: identityName
93-
? (ctx.state.getResource({ type: "identity", name: identityName, provider: "qoder" })?.remote_id ?? null)
94-
: null,
95-
templateId: ctx.state.getResource({ type: "template", name: agentName, provider: "qoder" })?.remote_id ?? null,
100+
identityId,
101+
templateId,
96102
deleteOnDestroy: agent.default_memory_store.delete_on_destroy ?? false,
97103
});
98104
}

packages/sdk/src/internal/providers/qoder/adapter.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -459,8 +459,23 @@ export class QoderAdapter implements ProviderAdapter {
459459

460460
async deleteEnvironment(id: string, cascade = false, mode: ProviderResourceMode = "managed"): Promise<void> {
461461
if (mode === "forward") {
462-
await this.forwardClient.delete(`/environments/${id}`);
463-
return;
462+
try {
463+
await this.forwardClient.delete(`/environments/${id}`);
464+
return;
465+
} catch (err) {
466+
const isConflict = err instanceof ApiError && (err.statusCode === 409 || err.responseBody.includes("in use"));
467+
if (!isConflict) throw err;
468+
if (!cascade) {
469+
throw new UserError(
470+
`Environment ${id} is referenced by one or more Forward sessions. ` +
471+
`Use --cascade to archive the environment.`,
472+
);
473+
}
474+
// Qoder Forward may retain a session reference after the session is
475+
// gone. Its API requires archiving the environment in that case.
476+
await this.forwardClient.post(`/environments/${id}/archive`, {});
477+
return;
478+
}
464479
}
465480
try {
466481
await this.client.delete(`/environments/${id}`);

packages/sdk/tests/unit/destroy-runtime.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,32 @@ describe("destroy runtime", () => {
144144
expect(result.partial).toBe(false);
145145
});
146146

147+
test("does not block destroy for a default Store whose Template and Identity were never recorded", async () => {
148+
const calls: string[] = [];
149+
const runtime = await ctx([resource("environment", "oncall-env", "env_1")], adapter(calls));
150+
runtime.config.defaults = { provider: "qoder", identity: "oncall" };
151+
runtime.config.identities = {
152+
oncall: { external_id: "oncall" },
153+
};
154+
runtime.config.agents = {
155+
"oncall-agent": {
156+
model: { qoder: "auto" },
157+
instructions: "Help.",
158+
delivery: { qoder: { type: "forward" } },
159+
default_memory_store: { name: "Oncall memory", delete_on_destroy: true },
160+
},
161+
};
162+
163+
const plan = planDestroyProjectContext(runtime);
164+
expect(plan.defaultMemoryStores).toEqual([]);
165+
166+
const result = await destroyPlannedProjectResources(plan);
167+
168+
expect(calls).toEqual(["environment:env_1:plain"]);
169+
expect(result.destroyed).toBe(1);
170+
expect(result.partial).toBe(false);
171+
});
172+
147173
test("aborts destroy when the default Store preflight cannot capture its ID", async () => {
148174
const calls: string[] = [];
149175
const runtime = await ctx(

packages/sdk/tests/unit/qoder-forward-template.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,22 @@ describe("Qoder Forward Template mapping and lifecycle", () => {
218218
expect(calls).toEqual(["managed POST /environments", "forward POST /environments"]);
219219
});
220220

221+
test("archives a Forward Environment with a stale session reference when cascade is enabled", async () => {
222+
const calls: string[] = [];
223+
const adapter = new QoderAdapter("pt-test") as any;
224+
adapter.forwardClient = {
225+
delete: async (path: string) => {
226+
calls.push(`DELETE ${path}`);
227+
throw new ApiError(409, "Environment is in use. Archive the environment instead.", "Forward API");
228+
},
229+
post: async (path: string) => calls.push(`POST ${path}`),
230+
};
231+
232+
await adapter.deleteEnvironment("env_forward", true, "forward");
233+
234+
expect(calls).toEqual(["DELETE /environments/env_forward", "POST /environments/env_forward/archive"]);
235+
});
236+
221237
test("resolves an external Environment id from either API domain", async () => {
222238
const calls: string[] = [];
223239
const adapter = new QoderAdapter("pt-test") as any;

0 commit comments

Comments
 (0)