Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions packages/sdk/src/internal/core/destroy-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
Expand Down
19 changes: 17 additions & 2 deletions packages/sdk/src/internal/providers/qoder/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,8 +459,23 @@ export class QoderAdapter implements ProviderAdapter {

async deleteEnvironment(id: string, cascade = false, mode: ProviderResourceMode = "managed"): Promise<void> {
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}`);
Expand Down
26 changes: 26 additions & 0 deletions packages/sdk/tests/unit/destroy-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
16 changes: 16 additions & 0 deletions packages/sdk/tests/unit/qoder-forward-template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down