[Bug]: Archived or deleted threads keep holding their devices #34173
Send to my agentBefore submitting#
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area#
apps/server
Steps to reproduce#
- In thread A, have the agent open a simulator with
device_openand run a command on it. - Archive thread A without closing the device first. Deleting thread A, or its project, has the same effect.
- In thread B, try to use the same simulator.
Expected behavior#
Archiving or deleting a thread releases its devices, the same way device_close does. The simulator stays on.
Actual behavior#
Thread A keeps holding the simulator, so thread B gets DEVICE_IN_USE. Once thread A is deleted, nothing is left that can release it.
Impact#
Major degradation or frequent failure
Version or commit#
main @ 0f13d2ccbd
Environment#
Linux, Node 24. Reproduced with a server test, not on a real simulator.
Reproduction test#
The test opens one simulator from two threads, then archives or deletes the first one. It checks that the first thread no longer holds the simulator, the second thread still does, and the simulator is still on.
All three cases fail on main because the first thread keeps its device session:
FAIL archive releases the thread's devices and keeps them booted
FAIL thread delete releases the thread's devices and keeps them booted
FAIL project delete releases the thread's devices and keeps them booted
AssertionError: expected [ { …(5) } ] to deeply equal []
- Expected
+ Received
- []
+ [
+ {
+ "deviceId": "phone",
+ "hostId": "local",
+ "openedAt": "1970-01-01T00:00:00.000Z",
+ "platform": "ios",
+ "threadId": "device-cleanup-thread:archive",
+ },
+ ]
Tests 3 failed | 122 passed (125)
Test code (applies to main at 0f13d2ccbd)
Run it with:
vp test run apps/server/src/orchestration-v2/runtimeLayer.test.ts -t "releases the thread's devices"
diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts
index a49e323770..bccb3db73c 100644
--- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts
+++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts
@@ -87,6 +87,8 @@ import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts
import * as ManagedPolicy from "../policy/ManagedPolicy.ts";
import * as Scheduler from "../scheduling/Scheduler.ts";
import * as SecretRequests from "../secrets/SecretRequests.ts";
+import * as DeviceHost from "../device/DeviceHost.ts";
+import * as DeviceService from "../device/DeviceService.ts";
import * as PreviewManager from "../preview/Manager.ts";
import * as TerminalManager from "../terminal/Manager.ts";
import * as ResourceCleanupService from "./ResourceCleanupService.ts";
@@ -367,8 +369,59 @@ const layerScheduledTasksTest = ScheduledTaskService.layer.pipe(
),
),
);
+const bootedSimulatorReady: DeviceHost.DeviceHostAgentReady = {
+ nodePath: process.execPath,
+ hub: { origin: "http://device.test" },
+ agentDevice: null,
+ helpers: { serveSimAxSettings: null, serveSimCli: null },
+ run: () => Effect.die("Thread cleanup must not run host commands"),
+ releaseAgentSession: () => Effect.void,
+ devices: {
+ list: Effect.succeed({
+ devices: [
+ {
+ id: "phone",
+ name: "iPhone",
+ platform: "ios",
+ version: "26",
+ booted: true,
+ physical: false,
+ },
+ ],
+ detail: undefined,
+ }),
+ boot: () => Effect.die("The simulator is already booted"),
+ attach: () => Effect.void,
+ shutdown: () => Effect.die("Thread cleanup must keep the simulator booted"),
+ screenshot: () => Effect.die("unused screenshot"),
+ },
+};
+const bootedSimulatorHost = DeviceHost.DeviceHost.of({
+ id: "local",
+ label: "Local",
+ summary: Effect.succeed({
+ id: "local",
+ label: "Local",
+ kind: "local",
+ hubInstalled: true,
+ agentDeviceInstalled: true,
+ platforms: [{ platform: "ios", available: true }],
+ }),
+ platformAvailability: (platform) => Effect.succeed({ platform, available: true }),
+ ensureReady: () => Effect.succeed(bootedSimulatorReady),
+ ensureAgentReady: () => Effect.succeed(bootedSimulatorReady),
+ current: Effect.succeed(bootedSimulatorReady),
+ stopAgent: Effect.void,
+ stop: Effect.void,
+});
const layerResourceCleanupTest = ResourceCleanupService.layer.pipe(
Layer.provideMerge(layerScheduledTasksTest),
+ Layer.provideMerge(
+ Layer.effect(
+ DeviceService.DeviceService,
+ DeviceService.makeWithHosts(new Map([[bootedSimulatorHost.id, bootedSimulatorHost]])),
+ ),
+ ),
Layer.provide(
Layer.mergeAll(
Layer.mock(PreviewManager.PreviewManager)({ close: () => Effect.void }),
@@ -544,6 +597,61 @@ it.layer(layerProjectDeletionTest)("project deletion during thread commands", (i
}),
);
+ it.effect.each(["archive", "thread delete", "project delete"] as const)(
+ "%s releases the thread's devices and keeps them booted",
+ (lifecycle) =>
+ Effect.gen(function* () {
+ const projects = yield* ProjectService.ProjectService;
+ const orchestrator = yield* Orchestrator.OrchestratorV2;
+ const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
+ const devices = yield* DeviceService.DeviceService;
+ yield* devices.configure({ enabled: true, agentAccessEnabled: true });
+ const projectId = ProjectId.make(`device-cleanup-project:${lifecycle}`);
+ const threadId = ThreadId.make(`device-cleanup-thread:${lifecycle}`);
+ const otherThreadId = ThreadId.make(`device-surviving-thread:${lifecycle}`);
+ yield* projects.create({
+ commandId: CommandId.make(`device-cleanup-create-project:${lifecycle}`),
+ projectId,
+ title: "Device cleanup",
+ workspaceRoot: `/work/device-cleanup/${lifecycle}`,
+ });
+ for (const id of [threadId, otherThreadId]) {
+ yield* orchestrator.dispatch({
+ type: "thread.create",
+ commandId: CommandId.make(`device-cleanup-create:${id}`),
+ createdBy: "user",
+ creationSource: "web",
+ threadId: id,
+ projectId: id === threadId ? projectId : ProjectId.make("device-cleanup-other-project"),
+ title: "Device user",
+ modelSelection,
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ });
+ yield* devices.open({ threadId: id, deviceId: "phone", platform: "ios" });
+ }
+ const otherSessions = yield* devices.sessionsForThread(otherThreadId);
+
+ const commandId = CommandId.make(`device-cleanup:${lifecycle}`);
+ if (lifecycle === "project delete") {
+ yield* projects.delete({ commandId, projectId, force: true });
+ } else {
+ yield* orchestrator.dispatch({
+ type: lifecycle === "archive" ? "thread.archive" : "thread.delete",
+ commandId,
+ threadId,
+ });
+ }
+ yield* worker.drain();
+
+ assert.deepEqual(yield* devices.sessionsForThread(threadId), []);
+ assert.deepEqual(yield* devices.sessionsForThread(otherThreadId), otherSessions);
+ assert.isTrue((yield* devices.state).devices[0]?.booted);
+ }),
+ );
+
it.effect("waits for an in-flight thread update before planning deletion", () =>
Effect.gen(function* () {
const projects = yield* ProjectService.ProjectService;
Why it happens#
Archiving or deleting a thread already cleans up its terminals, and deleting also cleans up its previews. Neither one cleans up its devices:
- Orchestrator.ts#L4640 (archive)
- ThreadDeletion.ts#L221-L227 (delete)
Context#
This is the remaining part of #17470:
device_closewas fixed in #21478.- Settling a thread is fixed in #32524. That PR leaves archive and delete unchanged.
- #25832 and #32935 also fixed archive and delete. Both were closed in favor of #32524, with a request to track archive and delete separately. This issue tracks that work.
Out of scope here: showing who holds a device in device_list, and idle timeouts.
Workaround#
Run device_close before you archive or delete a thread. If the thread is already deleted, run agent-device close --session <session> by hand.
esoxjem(GitHub profile, opens in a new tab) added labelsbug
esoxjem(GitHub profile, opens in a new tab) added labelsneeds-triage
esoxjem(GitHub profile, opens in a new tab) closed this
Sign in to join this conversation.