diff --git a/src/ida/IdaSessionClient.test.ts b/src/ida/IdaSessionClient.test.ts index 4de487689..d126cfbfd 100644 --- a/src/ida/IdaSessionClient.test.ts +++ b/src/ida/IdaSessionClient.test.ts @@ -1077,6 +1077,7 @@ describe("headless IDA cleanup diagnostics", () => { backend: "worker", owned: false, input_path: producer.inputPath, + is_active: false, }, ), ); @@ -1368,6 +1369,62 @@ describe("headless IDA cleanup verification", () => { }); }); +it("retains a conflicting requested session ID during pre-close recovery", async () => { + const { producer, client, root } = await fixture("headless"); + producer.overrideCall = (name) => + name === "idb_list" + ? { + sessions: [ + { + session_id: String(producer.database), + input_path: "/conflicting/input", + owned: true, + backend: "worker", + is_active: false, + }, + ], + } + : undefined; + expect((await client.execute("health", {})).ok).toBe(false); + const requested = String(producer.database); + const result = await client.close(); + expect(result).toMatchObject( + reportsSessions({ + session_id: requested, + input_path: "/conflicting/input", + owned: true, + backend: "worker", + is_active: false, + }), + ); + expect(producer.calls.some(({ name }) => name === "idb_close")).toBe(false); + expect( + (await readdir(root)).some((name) => name.startsWith("rea-ida-")), + ).toBe(true); +}); + +it.each(["unavailable", "malformed"] as const)( + "does not invent a worker after explicit rejection and %s inventory", + async (kind) => { + const { producer, client } = await fixture("headless"); + producer.overrideCall = (name) => + name === "idb_open" + ? { error: "rejected before worker creation" } + : name === "idb_list" && kind === "malformed" + ? {} + : undefined; + expect((await client.execute("health", {})).ok).toBe(false); + producer.beforeCall = async (name) => { + if (name === "idb_list" && kind === "unavailable") + throw new Error("inventory unavailable"); + }; + const result = await client.close(); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected retained cleanup"); + expect(result.error).not.toHaveProperty("diagnostics.upstream_sessions"); + }, +); + it.each(["idb_close", "idb_list", "connection_close"])( "preserves transport stderr through cleanup at %s", async (stage) => { diff --git a/src/ida/IdaSessionClient.ts b/src/ida/IdaSessionClient.ts index cff968132..d157b5209 100644 --- a/src/ida/IdaSessionClient.ts +++ b/src/ida/IdaSessionClient.ts @@ -98,6 +98,8 @@ interface UpstreamSession { readonly owned: boolean | null; /** Last inventoried input binding; null until upstream reports it. */ readonly input_path: string | null; + /** Last inventoried activity; null before upstream reports it. */ + readonly is_active: boolean | null; } /** A failed cleanup retains its private workspace and reports the remaining resource. */ @@ -130,15 +132,16 @@ class IdaCleanupError extends ProviderAdapterError { upstream_sessions: { state: "unverified", sessions: sessions.map( - ({ session_id, backend, owned, input_path }) => ({ + ({ session_id, backend, owned, input_path, is_active }) => ({ session_id, backend, owned, input_path, + is_active, }), ), reason: - "Release of upstream sessions matching the requested session ID or private input was not confirmed; input_path preserves the last observed binding and is null when no inventory has reported it. A worker outlives its supervisor and may keep running until it finishes and reaches its idle timeout; exiting REA does not stop it. A gui session belongs to an IDA instance REA does not control. An empty session_id means upstream reported none; owned is null when no inventory has reported ownership; an empty list means the requested worker's close was confirmed but no inventory has identified any remaining session.", + "Release of upstream sessions matching the requested session ID or private input was not confirmed; input_path preserves the last observed binding and is null when no inventory has reported it. is_active preserves registry activity and is null when unknown; it does not establish process liveness. A worker outlives its supervisor and may keep running until it finishes and reaches its idle timeout; exiting REA does not stop it. A gui session belongs to an IDA instance REA does not control. An empty session_id means upstream reported none; owned is null when no inventory has reported ownership; an empty list means the requested worker's close was confirmed but no inventory has identified any remaining session.", }, }), }, @@ -691,7 +694,12 @@ export class IdaSessionClient implements AnalysisClient { */ #unreleasedSessions(): readonly UpstreamSession[] | undefined { if (this.#blockingSessions !== undefined) return this.#blockingSessions; - if (!this.#openAttempted || this.#workspace === undefined) return undefined; + if ( + !this.#openAttempted || + this.#workspace === undefined || + this.#openRejected + ) + return undefined; return this.#databaseCloseCompleted ? [] : [ @@ -700,6 +708,7 @@ export class IdaSessionClient implements AnalysisClient { backend: "worker", owned: null, input_path: null, + is_active: null, }, ]; } @@ -719,12 +728,15 @@ export class IdaSessionClient implements AnalysisClient { ): boolean { this.#blockingSessions = entries.length > 0 - ? entries.map(({ session_id, backend, owned, input_path }) => ({ - session_id, - backend, - owned, - input_path, - })) + ? entries.map( + ({ session_id, backend, owned, input_path, is_active }) => ({ + session_id, + backend, + owned, + input_path, + is_active, + }), + ) : undefined; return entries.length > 0; } @@ -781,7 +793,7 @@ export class IdaSessionClient implements AnalysisClient { ); if (this.#database === undefined && blocked) throw new AnalysisProtocolError( - "An unowned worker still uses the private workspace; it was retained.", + "A registered session still matches the requested ID or private input without a verified private worker binding; the workspace was retained.", ); if ( this.#database === undefined && diff --git a/tests/boundary/cli/idaRetainedCleanupCli.test.ts b/tests/boundary/cli/idaRetainedCleanupCli.test.ts index b31903489..826bb7f1d 100644 --- a/tests/boundary/cli/idaRetainedCleanupCli.test.ts +++ b/tests/boundary/cli/idaRetainedCleanupCli.test.ts @@ -79,6 +79,7 @@ describe("headless IDA timeout through the one-shot CLI", () => { backend: "worker", owned: null, input_path: null, + is_active: null, }, ], },