Skip to content

[Bug]: Archived or deleted threads keep holding their devices #34165

Closed as not planned

Description

@esoxjem

Before 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

  1. In thread A, have the agent open a simulator with device_open and run a command on it.
  2. Archive thread A without closing the device first. Deleting thread A, or its project, has the same effect.
  3. 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:

Context

This is the remaining part of #17470:

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.

Activity

  1. changed the title [-][Bug]: Archiving or deleting a thread leaves its devices claimed[/-] [+][Bug]: Archived or deleted threads keep holding their devices[/+] on Nov 11, 2026
  2. juliusmarminge commented on Nov 11, 2026

    @juliusmarminge
    Member

    I tried to reproduce this on the current build and could not.

    On origin/main ed52af3 (Linux, vite-plus with pnpm 12), I applied the reporter's test to apps/server/src/orchestration-v2/runtimeLayer.test.ts unchanged. It connects the real DeviceService (makeWithHosts) to a stub host with one booted iOS simulator and runs it through the real orchestrator, effect worker and ProjectService. I ran vp test run src/orchestration-v2/runtimeLayer.test.ts from apps/server. Result: 3 failed, 122 passed. The archive, thread delete and project delete cases each fail on assert.deepEqual(yield* devices.sessionsForThread(threadId), []) with 'expected [ { …(5) } ] to deeply equal []': the archived or deleted thread still holds its 'phone' session. I did not run a live app: opening a simulator from a thread needs a device host the server can use, and T3's device access is off on this VM. That is why reproduced is false, even though the server test shows the defect on current main.

    Closing as not planned. If it still happens for you, reply with the exact steps and the version you are on and it will be looked at again.

  3. esoxjem commented on Nov 11, 2026

    @esoxjem
    ContributorAuthor

    Reproduced live in the desktop app.

    Version: T3 Code (Nightly) 0.1.9-nightly.20261111.4501 (c90b67859), macOS 27.2.1, Xcode 27.2, iPhone Air simulator on iOS 26.0, local device host.

    Steps

    1. In thread A, the agent calls device_open on the iPhone Air, then runs agent-device open com.apple.Preferences with the command it gets back.
    2. Archive thread A without closing the device.
    3. In thread B, the agent calls device_open on the same iPhone Air, then runs agent-device open com.apple.Preferences.

    Result: thread B is blocked by thread A's session, which was still held 1 min 40 s after the archive.

      $ agent-device open com.apple.Preferences   # thread B, 27 s after archiving thread A
    - Error (DEVICE_IN_USE): Device is already in use by session "t3-cc0d7f3c4241f43c9cf94480".

    t3-cc0d7f3c4241f43c9cf94480 is the session thread A opened in step 1.

    Releasing it by hand works: I unarchived thread A and had it call device_close. The claim went away, and the same command in thread B then succeeded:

      $ agent-device open com.apple.Preferences   # thread B, after thread A called device_close
    + Opened: com.apple.Preferences

    I tested archive live. Thread delete and project delete are covered by the server test you ran.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions