Skip to content

Commit a17ab2e

Browse files
only21milclaudemaria-rcks
authored
fix(server): release agent-device sessions when device_close runs (#21478)
Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: maria-rcks <[email protected]>
1 parent 6511796 commit a17ab2e

13 files changed

Lines changed: 711 additions & 81 deletions

‎apps/server/src/device/AgentDeviceTarget.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import { DeviceOperationError } from "@t3tools/contracts";
12
import * as Crypto from "effect/Crypto";
3+
import * as Duration from "effect/Duration";
24
import * as Effect from "effect/Effect";
35
import * as Hex from "effect/encoding/Hex";
46
import * as Schema from "effect/Schema";
57
import * as FileSystem from "effect/FileSystem";
68
import * as Path from "effect/Path";
9+
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
710

811
import type { AgentDaemonEndpoint } from "./DeviceHost.ts";
912

@@ -62,3 +65,36 @@ export const writeAgentDeviceConfig = Effect.fn("AgentDeviceTarget.writeConfig")
6265
yield* fs.rename(temporary, file);
6366
}).pipe(Effect.ensuring(fs.remove(temporary, { force: true }).pipe(Effect.ignore)));
6467
});
68+
69+
const AgentCloseResult = Schema.Union([
70+
Schema.Struct({ result: Schema.Struct({ ok: Schema.Literal(true) }) }),
71+
Schema.Struct({
72+
error: Schema.Struct({
73+
data: Schema.Struct({ code: Schema.Literal("SESSION_NOT_FOUND") }),
74+
}),
75+
}),
76+
]);
77+
78+
/** Ends an agent-device session on a running daemon; an already-released session succeeds. */
79+
export const releaseAgentDeviceSession =
80+
(http: HttpClient.HttpClient, endpoint: AgentDaemonEndpoint) => (session: string) =>
81+
HttpClientRequest.post(`${endpoint.baseUrl}/rpc`).pipe(
82+
HttpClientRequest.bearerToken(endpoint.token),
83+
HttpClientRequest.bodyJson({
84+
jsonrpc: "2.0",
85+
id: session,
86+
method: "agent_device.command",
87+
params: { command: "close", session, positionals: [], flags: {} },
88+
}),
89+
Effect.flatMap((request) => http.pipe(HttpClient.withScope).execute(request)),
90+
// agent-device reports an already-released session as HTTP 404.
91+
Effect.filterOrElse((response) => response.status === 404, HttpClientResponse.filterStatusOk),
92+
Effect.flatMap(HttpClientResponse.schemaBodyJson(AgentCloseResult)),
93+
Effect.scoped,
94+
Effect.timeout(Duration.seconds(15)),
95+
Effect.asVoid,
96+
Effect.mapError(
97+
(cause) =>
98+
new DeviceOperationError({ operation: "close", reason: "request_failed", cause }),
99+
),
100+
);

‎apps/server/src/device/DeviceHost.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,16 @@ export interface DeviceOperations {
145145

146146
export interface DeviceHostReady {
147147
readonly nodePath: string;
148+
/**
149+
* Ends an agent-device session on the daemon that serves `deviceId`. Hosts
150+
* with a shared daemon provide it only while that daemon runs; hosts with a
151+
* daemon per device succeed when the device's daemon is gone. Cleanup never
152+
* starts a daemon or a device.
153+
*/
154+
readonly releaseAgentSession?: (
155+
session: string,
156+
deviceId: string,
157+
) => Effect.Effect<void, DeviceOperationError>;
148158
readonly hub: DeviceHubEndpoint;
149159
readonly devices: DeviceOperations;
150160
readonly run: (

‎apps/server/src/device/DeviceMultiHost.test.ts‎

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
66
import * as Effect from "effect/Effect";
77
import { HttpClient, HttpClientResponse } from "effect/http";
88
import * as ServerSettings from "../serverSettings.ts";
9+
import { releaseAgentDeviceSession } from "./AgentDeviceTarget.ts";
910
import * as DeviceHost from "./DeviceHost.ts";
1011
import { makeDeviceHubOperations } from "./DeviceHubOperations.ts";
1112
import * as DeviceService from "./DeviceService.ts";
@@ -16,19 +17,23 @@ it.effect("keeps hosts independent when serials collide and another host fails",
1617
Effect.succeed(
1718
HttpClientResponse.fromWeb(
1819
request,
19-
Response.json({
20-
simulators: [],
21-
emulators: [
22-
{
23-
id: "emulator-5554",
24-
name: "Pixel",
25-
version: "36",
26-
platform: "android",
27-
booted: true,
28-
physical: false,
29-
},
30-
],
31-
}),
20+
Response.json(
21+
request.url.endsWith("/rpc")
22+
? { result: { ok: true } }
23+
: {
24+
simulators: [],
25+
emulators: [
26+
{
27+
id: "emulator-5554",
28+
name: "Pixel",
29+
version: "36",
30+
platform: "android",
31+
booted: true,
32+
physical: false,
33+
},
34+
],
35+
},
36+
),
3237
),
3338
),
3439
);
@@ -50,6 +55,10 @@ it.effect("keeps hosts independent when serials collide and another host fails",
5055
androidAvailable: Effect.succeed(true),
5156
}),
5257
agentDevice: { baseUrl: `http://${id}`, token: "test", entryPath: "/agent-device" },
58+
releaseAgentSession: releaseAgentDeviceSession(http, {
59+
baseUrl: `http://${id}`,
60+
token: "test",
61+
}),
5362
run,
5463
helpers: { serveSimAxSettings: null, serveSimCli: null },
5564
};

0 commit comments

Comments
 (0)