From 077964b1e8650b79d5ef241019e30e4317fde862 Mon Sep 17 00:00:00 2001 From: Arun Sasidharan Date: Mon, 9 Nov 2026 11:10:39 +0530 Subject: [PATCH] fix(server): release device sessions when threads settle --- apps/server/src/device/DeviceService.ts | 22 ++- .../src/guest/GuestShareService.test.ts | 1 + .../src/orchestration-v2/EffectOutbox.ts | 5 + .../src/orchestration-v2/EffectWorker.ts | 11 ++ .../src/orchestration-v2/Orchestrator.ts | 21 +++ .../ResourceCleanupService.ts | 21 ++- .../orchestration-v2/ThreadDeletion.test.ts | 3 + .../src/orchestration-v2/ThreadDeletion.ts | 10 ++ .../src/orchestration-v2/runtimeLayer.test.ts | 160 ++++++++++++++++++ .../project/ProjectService.deletion.test.ts | 10 +- apps/server/src/project/ProjectService.ts | 5 + .../src/provider/userInputAttachments.test.ts | 2 + docs/user/devices.md | 4 + 13 files changed, 270 insertions(+), 5 deletions(-) diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 790daabe7a445..3a49bfd522626 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -51,6 +51,7 @@ import { } from "./AgentDeviceTarget.ts"; import * as Context from "effect/Context"; import * as Digest from "effect/crypto/Digest"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -197,7 +198,10 @@ export class DeviceService extends Context.Service< input: DeviceOpenInput, context?: DeviceHost.DeviceOpenContext, ) => Effect.Effect; - readonly close: (input: DeviceCloseInput) => Effect.Effect; + readonly close: ( + input: DeviceCloseInput, + options?: { readonly sessionIds: ReadonlyArray }, + ) => Effect.Effect; readonly shutdown: (input: DeviceShutdownInput) => Effect.Effect; readonly detail: (input: DeviceDetailInput) => Effect.Effect; readonly action: (input: DeviceActionInput) => Effect.Effect; @@ -235,6 +239,8 @@ export class DeviceService extends Context.Service< ) => Effect.Effect; readonly currentReadiness: (hostId?: DeviceHostId) => Effect.Effect; readonly sessionsForThread: (threadId: ThreadId) => Effect.Effect>; + /** Opaque cleanup identities, renewed on every open. */ + readonly sessionIdsForThread: (threadId: ThreadId) => Effect.Effect>; } >()("t3/device/DeviceService") {} @@ -279,6 +285,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ) { const settings = yield* ServerSettings.ServerSettingsService; const digest = yield* Digest.Digest; + const random = yield* SecureRandom.SecureRandom; + const sessionIds = new WeakMap(); const lifecycleLock = yield* Semaphore.make(1); const readDeviceSettings = settings.getSettings.pipe( Effect.map((value) => ({ @@ -853,6 +861,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* hostLabel: host.label, reason: "Device support was turned off while the device was opening.", }); + sessionIds.set(session, yield* random.uuidV4()); yield* publish((current) => ({ ...current, sessions: [ @@ -897,7 +906,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }); const close: DeviceService["Service"]["close"] = Effect.fn("DeviceService.close")( - function* (input) { + function* (input, options) { // Open publishes under the same lock; an old close must not release a reopened claim. const closing = yield* lifecycleLock.withPermit( Effect.gen(function* () { @@ -906,7 +915,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* (session) => session.threadId === input.threadId && (input.hostId === undefined || session.hostId === input.hostId) && - (input.deviceId === undefined || session.deviceId === input.deviceId), + (input.deviceId === undefined || session.deviceId === input.deviceId) && + (options === undefined || + options.sessionIds.some((id) => id === sessionIds.get(session))), ); const ending = input.shutdown ? state.sessions.filter((session) => @@ -1226,6 +1237,11 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* agentReadinessIfSupported, currentReadiness, sessionsForThread, + sessionIdsForThread: Effect.fn("DeviceService.sessionIdsForThread")(function* (threadId) { + return (yield* sessionsForThread(threadId)) + .map((session) => sessionIds.get(session)) + .filter((id) => id !== undefined); + }), }), setHostStatus, withLifecycleLock: lifecycleLock.withPermit, diff --git a/apps/server/src/guest/GuestShareService.test.ts b/apps/server/src/guest/GuestShareService.test.ts index 7140222478720..3233474f43ce6 100644 --- a/apps/server/src/guest/GuestShareService.test.ts +++ b/apps/server/src/guest/GuestShareService.test.ts @@ -100,6 +100,7 @@ const layerCleanup = Layer.effect( cleanupScheduledTasks: () => Effect.void, cleanupTerminals: () => Effect.void, cleanupPreviews: () => Effect.void, + cleanupDevices: () => Effect.void, cleanupAttachments: (attachmentIds) => Effect.forEach( attachmentIds, diff --git a/apps/server/src/orchestration-v2/EffectOutbox.ts b/apps/server/src/orchestration-v2/EffectOutbox.ts index 92e1e01ad43f2..9c81ceec768cf 100644 --- a/apps/server/src/orchestration-v2/EffectOutbox.ts +++ b/apps/server/src/orchestration-v2/EffectOutbox.ts @@ -104,6 +104,10 @@ export const OrchestrationEffectRequestV2 = Schema.Union([ Schema.Struct({ type: Schema.Literal("preview.cleanup"), }), + Schema.Struct({ + type: Schema.Literal("device.cleanup"), + sessionIds: Schema.Array(Schema.String), + }), Schema.Struct({ type: Schema.Literal("attachment.cleanup"), attachmentIds: Schema.Array(Schema.String), @@ -130,6 +134,7 @@ export const REPLAY_SAFE_EFFECT_TYPES_AFTER_PROCESS_LOSS = [ "scheduled-tasks.cleanup", "terminal.cleanup", "preview.cleanup", + "device.cleanup", "attachment.cleanup", "thread-title.generate", "delegated-tasks.stop", diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index c5b5ea9b383a9..014b95ac9f44e 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -527,6 +527,17 @@ export const layerExecutor: Layer.Layer< }), ), ); + case "device.cleanup": + return resourceCleanup.cleanupDevices(effect.threadId, effect.request.sessionIds).pipe( + Effect.mapError( + (cause) => + new OrchestrationEffectExecutionError({ + effectId: effect.id, + effectType: effect.request.type, + cause, + }), + ), + ); case "attachment.cleanup": return resourceCleanup.cleanupAttachments(effect.request.attachmentIds).pipe( Effect.mapError( diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 6c3114329da21..0121c850fb1c2 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -141,6 +141,7 @@ import { type GuestShareCommand, } from "./GuestShareDecider.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; +import * as DeviceService from "../device/DeviceService.ts"; import { withCommandMetrics } from "../observability/Metrics.ts"; import type { OrchestrationEffectRequestV2, PendingOrchestrationEffectV2 } from "./EffectOutbox.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -1099,6 +1100,12 @@ function lastDeliveredRunForProviderThread( } const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(function* () { + const devices = yield* Effect.serviceOption(DeviceService.DeviceService); + const captureDeviceSessions = Effect.fn("orchestrationV2.captureDeviceSessions")(function* ( + threadId: ThreadId, + ) { + return Option.isSome(devices) ? yield* devices.value.sessionIdsForThread(threadId) : []; + }); const checkpointService = yield* CheckpointServiceV2; const commandPolicy = yield* CommandPolicyV2; const contextHandoffService = yield* ContextHandoffServiceV2; @@ -4504,6 +4511,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); } + if (command.type === "thread.archive" || command.type === "thread.settle") { + const sessionIds = yield* captureDeviceSessions(command.threadId); + yield* Ref.update(effects, (existing) => [ + ...existing, + { + id: `effect:${command.commandId}:device.cleanup`, + commandId: command.commandId, + threadId: command.threadId, + request: { type: "device.cleanup", sessionIds }, + } satisfies PendingOrchestrationEffectV2, + ]); + } + if (command.type === "thread.archive") { yield* Ref.update(effects, (existing) => [ ...existing, @@ -13496,6 +13516,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio planThreadDeletion({ command: deletion, projection, + deviceSessionIds: yield* captureDeviceSessions(deletion.threadId), attachmentIds: yield* projectionStore .getThreadAttachmentIds(deletion.threadId) .pipe(mapDispatchError(command)), diff --git a/apps/server/src/orchestration-v2/ResourceCleanupService.ts b/apps/server/src/orchestration-v2/ResourceCleanupService.ts index ff56b42c492c1..47081bcb7a234 100644 --- a/apps/server/src/orchestration-v2/ResourceCleanupService.ts +++ b/apps/server/src/orchestration-v2/ResourceCleanupService.ts @@ -8,6 +8,7 @@ import * as Schema from "effect/Schema"; import { resolveAttachmentRelativePath } from "../attachmentPaths.ts"; import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; +import * as DeviceService from "../device/DeviceService.ts"; import * as PreviewManager from "../preview/Manager.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as TerminalManager from "../terminal/Manager.ts"; @@ -15,7 +16,7 @@ import * as TerminalManager from "../terminal/Manager.ts"; export class ResourceCleanupError extends Schema.TaggedError()( "ResourceCleanupError", { - operation: Schema.Literals(["terminal", "preview", "attachment", "scheduled-tasks"]), + operation: Schema.Literals(["terminal", "preview", "device", "attachment", "scheduled-tasks"]), threadId: Schema.optional(Schema.String), attachmentId: Schema.optional(Schema.String), cause: Schema.Defect(), @@ -26,6 +27,10 @@ export class ResourceCleanupService extends Context.Reference<{ readonly cleanupScheduledTasks: (threadId: ThreadId) => Effect.Effect; readonly cleanupTerminals: (threadId: string) => Effect.Effect; readonly cleanupPreviews: (threadId: string) => Effect.Effect; + readonly cleanupDevices: ( + threadId: ThreadId, + sessionIds: ReadonlyArray, + ) => Effect.Effect; readonly cleanupAttachments: ( attachmentIds: ReadonlyArray, ) => Effect.Effect; @@ -34,6 +39,7 @@ export class ResourceCleanupService extends Context.Reference<{ cleanupScheduledTasks: () => Effect.void, cleanupTerminals: () => Effect.void, cleanupPreviews: () => Effect.void, + cleanupDevices: () => Effect.void, cleanupAttachments: () => Effect.void, }), }) {} @@ -43,6 +49,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const terminals = yield* TerminalManager.TerminalManager; const previews = yield* PreviewManager.PreviewManager; + const devices = yield* DeviceService.DeviceService; const fileSystem = yield* FileSystem.FileSystem; const config = yield* ServerConfig.ServerConfig; const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService; @@ -81,6 +88,18 @@ export const layer = Layer.effect( ), ); }), + cleanupDevices: Effect.fn("ResourceCleanupService.cleanupDevices")(function ( + threadId: ThreadId, + sessionIds: ReadonlyArray, + ) { + return devices + .close({ threadId }, { sessionIds }) + .pipe( + Effect.mapError( + (cause) => new ResourceCleanupError({ operation: "device", threadId, cause }), + ), + ); + }), cleanupAttachments: Effect.fn("ResourceCleanupService.cleanupAttachments")(function ( attachmentIds: ReadonlyArray, ) { diff --git a/apps/server/src/orchestration-v2/ThreadDeletion.test.ts b/apps/server/src/orchestration-v2/ThreadDeletion.test.ts index ef7c23bf75120..1962e6d3c5cc8 100644 --- a/apps/server/src/orchestration-v2/ThreadDeletion.test.ts +++ b/apps/server/src/orchestration-v2/ThreadDeletion.test.ts @@ -180,6 +180,7 @@ it.effect("cancels active work without reviving a run while disposing delegated ], }; const plan = yield* planThreadDeletion({ + deviceSessionIds: [], command, projection, attachmentIds: projection.messages.flatMap((message) => @@ -267,6 +268,7 @@ it.effect("queues provider and resource cleanup and preserves an earlier deletio })), }; const plan = yield* planThreadDeletion({ + deviceSessionIds: [], command, projection, attachmentIds: projection.messages.flatMap((message) => @@ -293,6 +295,7 @@ it.effect("queues provider and resource cleanup and preserves an earlier deletio { type: "scheduled-tasks.cleanup" }, { type: "terminal.cleanup" }, { type: "preview.cleanup" }, + { type: "device.cleanup", sessionIds: [] }, { type: "attachment.cleanup", attachmentIds: ["shared_file"] }, ], ); diff --git a/apps/server/src/orchestration-v2/ThreadDeletion.ts b/apps/server/src/orchestration-v2/ThreadDeletion.ts index 67a27fcf76962..cbaabaaec851d 100644 --- a/apps/server/src/orchestration-v2/ThreadDeletion.ts +++ b/apps/server/src/orchestration-v2/ThreadDeletion.ts @@ -21,6 +21,7 @@ export const planThreadDeletion = Effect.fn("ThreadDeletion.planThreadDeletion") "thread" | "runs" | "attempts" | "nodes" | "runtimeRequests" | "subagents" | "providerSessions" >; readonly attachmentIds: ReadonlyArray; + readonly deviceSessionIds: ReadonlyArray; readonly now: DateTime.Utc; readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; }): Effect.fn.Return { @@ -226,6 +227,15 @@ export const planThreadDeletion = Effect.fn("ThreadDeletion.planThreadDeletion") threadId: command.threadId, request: { type: "preview.cleanup" }, }); + effects.push({ + id: `effect:${command.commandId}:device.cleanup`, + commandId: command.commandId, + threadId: command.threadId, + request: { + type: "device.cleanup", + sessionIds: input.deviceSessionIds, + }, + }); const attachmentIds = Array.from(new Set(input.attachmentIds)); if (attachmentIds.length > 0) { effects.push({ diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index b9f5cd049eec9..0046ea0f03ee5 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 DeviceService from "../device/DeviceService.ts"; +import * as DeviceHost from "../device/DeviceHost.ts"; import * as PreviewManager from "../preview/Manager.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as ResourceCleanupService from "./ResourceCleanupService.ts"; @@ -349,6 +351,58 @@ const layerScheduledTasksTest = ScheduledTaskService.layer.pipe( ), ), ); +const layerDeviceLifecycleTest = Layer.effect( + DeviceService.DeviceService, + Effect.gen(function* () { + const ready: DeviceHost.DeviceHostAgentReady = { + nodePath: process.execPath, + hub: { origin: "http://device.test" }, + agentDevice: null, + helpers: { serveSimAxSettings: null, serveSimCli: null }, + run: () => Effect.die("Device 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("Device is already booted"), + attach: () => Effect.void, + shutdown: () => Effect.die("Thread cleanup must keep the device booted"), + screenshot: () => Effect.die("Unused screenshot"), + }, + }; + const host = 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(ready), + ensureAgentReady: () => Effect.succeed(ready), + current: Effect.succeed(ready), + stopAgent: Effect.void, + stop: Effect.void, + }); + return yield* DeviceService.makeWithHosts(new Map([[host.id, host]])); + }), +).pipe(Layer.provide(NodeCrypto.layer)); + const layerResourceCleanupTest = ResourceCleanupService.layer.pipe( Layer.provideMerge(layerScheduledTasksTest), Layer.provide( @@ -357,6 +411,7 @@ const layerResourceCleanupTest = ResourceCleanupService.layer.pipe( Layer.mock(TerminalManager.TerminalManager)({ close: () => Effect.void }), ), ), + Layer.provideMerge(layerDeviceLifecycleTest), ); const layerProjectDeletionTest = Layer.mergeAll( @@ -400,6 +455,111 @@ const layerProjectDeletionTest = Layer.mergeAll( ); it.layer(layerProjectDeletionTest)("project deletion during thread commands", (it) => { + it.effect.each([ + "thread.settle", + "thread.auto-settle", + "thread.archive", + "thread.delete", + ] as const)("releases devices without powering them off on %s", (type) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const devices = yield* DeviceService.DeviceService; + yield* devices.configure({ enabled: true, agentAccessEnabled: true }); + const threadId = ThreadId.make(`device-cleanup:${type}`); + const otherThreadId = ThreadId.make(`device-surviving:${type}`); + for (const id of [threadId, otherThreadId]) { + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`device-create:${id}`), + createdBy: "user", + creationSource: "web", + threadId: id, + projectId: ProjectId.make("device-lifecycle-project"), + title: "Device lifecycle", + 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 before = yield* orchestrator.getThreadProjection(threadId); + const command = + type === "thread.auto-settle" + ? { + type, + commandId: CommandId.make(`device-cleanup-command:${type}`), + threadId, + snapshotAt: before.thread.updatedAt, + } + : { + type, + commandId: CommandId.make(`device-cleanup-command:${type}`), + threadId, + }; + yield* orchestrator.dispatch(command); + yield* worker.drain(); + assert.deepEqual(yield* devices.sessionsForThread(threadId), []); + assert.deepEqual(yield* devices.sessionsForThread(otherThreadId), otherSessions); + assert.isTrue((yield* devices.state).devices[0]?.booted); + yield* orchestrator.dispatch(command); + yield* worker.drain(); + assert.deepEqual(yield* devices.sessionsForThread(otherThreadId), otherSessions); + if (type === "thread.settle" || type === "thread.auto-settle") { + yield* orchestrator.dispatch({ + type: "thread.unsettle", + commandId: CommandId.make(`device-unsettle:${type}`), + threadId, + reason: "user", + }); + yield* devices.open({ threadId, deviceId: "phone", platform: "ios" }); + assert.lengthOf(yield* devices.sessionsForThread(threadId), 1); + } + }), + ); + + it.effect("preserves devices reopened after settlement while cleanup is queued", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const devices = yield* DeviceService.DeviceService; + yield* devices.configure({ enabled: true, agentAccessEnabled: true }); + const threadId = ThreadId.make("device-delayed-cleanup"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("device-delayed-create"), + createdBy: "user", + creationSource: "web", + threadId, + projectId: ProjectId.make("device-lifecycle-project"), + title: "Delayed device cleanup", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* devices.open({ threadId, deviceId: "phone", platform: "ios" }); + yield* orchestrator.dispatch({ + type: "thread.settle", + commandId: CommandId.make("device-delayed-settle"), + threadId, + }); + yield* orchestrator.dispatch({ + type: "thread.unsettle", + commandId: CommandId.make("device-delayed-unsettle"), + threadId, + reason: "user", + }); + const reopened = yield* devices.open({ threadId, deviceId: "phone", platform: "ios" }); + yield* worker.drain(); + assert.deepEqual(yield* devices.sessionsForThread(threadId), [reopened]); + }), + ); + it.effect.each(["thread", "project"] as const)( "deleting a %s removes its thread-bound automations through the effect worker", (deletion) => diff --git a/apps/server/src/project/ProjectService.deletion.test.ts b/apps/server/src/project/ProjectService.deletion.test.ts index 28b1c40541ddf..8d98bea1c570c 100644 --- a/apps/server/src/project/ProjectService.deletion.test.ts +++ b/apps/server/src/project/ProjectService.deletion.test.ts @@ -203,6 +203,7 @@ it.effect("retries a partial project deletion without repeating child events or assert.deepEqual( partialCleanup.map((effect) => [effect.thread_id, effect.effect_type]), [ + [firstThreadId, "device.cleanup"], [firstThreadId, "preview.cleanup"], [firstThreadId, "scheduled-tasks.cleanup"], [firstThreadId, "terminal.cleanup"], @@ -228,7 +229,7 @@ it.effect("retries a partial project deletion without repeating child events or assert.deepEqual(finalEvents[0], partialEvents[0]); assert.equal(finalEvents[2]?.command_id, commandId); const finalCleanup = yield* readCleanup; - assert.lengthOf(finalCleanup, 6); + assert.lengthOf(finalCleanup, 8); assert.deepEqual( finalCleanup.filter((effect) => effect.thread_id === firstThreadId), partialCleanup, @@ -238,6 +239,12 @@ it.effect("retries a partial project deletion without repeating child events or assert.deepEqual( finalCleanup.filter((effect) => effect.thread_id === threadId), [ + { + effect_id: `effect:${expectedCommandId}:device.cleanup`, + thread_id: threadId, + command_id: expectedCommandId, + effect_type: "device.cleanup", + }, { effect_id: `effect:${expectedCommandId}:preview.cleanup`, thread_id: threadId, @@ -465,6 +472,7 @@ it.effect("deletes a project without force once its imported threads were delete }; const now = yield* DateTime.now; const plan = yield* planThreadDeletion({ + deviceSessionIds: [], command, projection: yield* projections.getThreadRecords(threadId, [ "runs", diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 9afaf22087a33..dccc07ba5232a 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -31,6 +31,7 @@ import { import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts"; +import * as DeviceService from "../device/DeviceService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -164,6 +165,7 @@ export class ProjectService extends Context.Service< >()("t3/project/ProjectService") {} export const make = Effect.gen(function* () { + const devices = yield* Effect.serviceOption(DeviceService.DeviceService); const projects = yield* ProjectStore.ProjectStoreV2; const projectEnrichment = yield* ProjectEnrichmentService.ProjectEnrichmentService; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; @@ -444,6 +446,9 @@ export const make = Effect.gen(function* () { const plan = yield* planThreadDeletion({ command, projection, + deviceSessionIds: Option.isSome(devices) + ? yield* devices.value.sessionIdsForThread(threadId) + : [], attachmentIds: yield* threadProjections.getThreadAttachmentIds(threadId), now, idAllocator, diff --git a/apps/server/src/provider/userInputAttachments.test.ts b/apps/server/src/provider/userInputAttachments.test.ts index f9b41babaea2f..aacc345b3cfef 100644 --- a/apps/server/src/provider/userInputAttachments.test.ts +++ b/apps/server/src/provider/userInputAttachments.test.ts @@ -16,6 +16,7 @@ import * as ServerConfig from "../config.ts"; import * as DocumentText from "./DocumentText.ts"; import { extractDocumentText } from "./documentTextWorker.ts"; import * as Cleanup from "../orchestration-v2/ResourceCleanupService.ts"; +import * as DeviceService from "../device/DeviceService.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as PreviewManager from "../preview/Manager.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; @@ -328,6 +329,7 @@ describe("question answer paths", () => { Layer.mergeAll( Layer.mock(TerminalManager.TerminalManager)({}), Layer.mock(PreviewManager.PreviewManager)({}), + Layer.mock(DeviceService.DeviceService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ), ), diff --git a/docs/user/devices.md b/docs/user/devices.md index ac053b36c081f..0e6d539362f22 100644 --- a/docs/user/devices.md +++ b/docs/user/devices.md @@ -85,6 +85,10 @@ taps build a small test runner on first use, which takes a couple of minutes once per server. Restart an existing agent session after granting access so it receives the device CLI environment. +Settling, archiving, or deleting a thread releases its device sessions so other +threads can use the devices. Simulators and emulators keep running. Reopen a +device from the Device panel when you return to the thread. + To keep agents away from simulators, turn off **Agents can use devices** in **Settings → Devices**. This hides the device tools from agents started from then on; your own Device panel is unaffected.