Repository navigation
fix(server): release device sessions when threads settle #32935
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Serialize session capture with in-flight opens. An open started before archive or deletion can finish after this capture. If it replaces an existing session, the queued cleanup no longer matches the new ID, so the old agent-device claim can remain owned by the archived or deleted thread. Coordinate open completion with the lifecycle transition, while preserving intentional reopens after settlement. Add a regression test that controls this ordering. Also applies to: 13519-13519
|
||
| yield* Ref.update(effects, (existing) => [ | ||
| ...existing, | ||
| { | ||
| id: `effect:${command.commandId}:device.cleanup`, | ||
| commandId: command.commandId, | ||
| threadId: command.threadId, | ||
| request: { type: "device.cleanup", sessionIds }, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keep cleanup pending while an SSH host reconnects. If five cleanup attempts occur before the SSH host reconnects, the worker marks this effect failed. The default backoff totals only 1.5 seconds, while SSH reconnection can take longer. The session and exclusive claim then remain after settlement, archive, or deletion. Use a retry policy or recovery path that can complete cleanup after reconnection. (raw.githubusercontent.com)
|
||
| } 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)), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,14 +8,15 @@ 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"; | ||
|
|
||
| export class ResourceCleanupError extends Schema.TaggedError<ResourceCleanupError>()( | ||
| "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<void, ResourceCleanupError>; | ||
| readonly cleanupTerminals: (threadId: string) => Effect.Effect<void, ResourceCleanupError>; | ||
| readonly cleanupPreviews: (threadId: string) => Effect.Effect<void, ResourceCleanupError>; | ||
| readonly cleanupDevices: ( | ||
| threadId: ThreadId, | ||
| sessionIds: ReadonlyArray<string>, | ||
| ) => Effect.Effect<void, ResourceCleanupError>; | ||
| readonly cleanupAttachments: ( | ||
| attachmentIds: ReadonlyArray<string>, | ||
| ) => Effect.Effect<void, ResourceCleanupError>; | ||
|
|
@@ -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<string>, | ||
| ) { | ||
| return devices | ||
| .close({ threadId }, { sessionIds }) | ||
| .pipe( | ||
| Effect.mapError( | ||
| (cause) => new ResourceCleanupError({ operation: "device", threadId, cause }), | ||
|
Comment on lines
+95
to
+99
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a thread with an SSH device session is settled, archived or deleted during a reconnect, AGENTS.md reference: AGENTS.md:L75-L75 Useful? React with 👍 / 👎. |
||
| ), | ||
| ); | ||
| }), | ||
| cleanupAttachments: Effect.fn("ResourceCleanupService.cleanupAttachments")(function ( | ||
| attachmentIds: ReadonlyArray<string>, | ||
| ) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the server crashes after the lifecycle command commits but before cleanup finishes, this replay-safe effect persists only UUIDs whose association with devices exists in
DeviceService.makeWithHosts's in-memoryWeakMap. The restarted service recreates that map and initializessessionsto an empty array, so replayed cleanup matches nothing and is marked successful without contacting a host. The local agent-device daemon can survive the crash and is reused byLocalDeviceHost.startAgentDeviceDaemon, leaving its exclusive claim intact and subsequent threads receivingDEVICE_IN_USE. Persist or recover the generation-qualified cleanup targets so the durable effect can actually release pre-crash claims.Useful? React with 👍 / 👎.