From 9795ea3ad77c92f087497a9317b8831cb738d980 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Mon, 27 Jul 2026 14:36:45 -0700 Subject: [PATCH] feat(terminal): close thread terminals when a thread settles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settling a thread means the work is done, but any dev server it started keeps holding its port and CPU. This closes a settled thread's terminals, behind a server setting that defaults to off. Both settle paths are covered, and they need different mechanisms because only one of them is an event: - Explicit settle dispatches thread.settle, so the ws handler closes terminals right after the command is accepted. A settle the decider rejects (active session, pending approval, queued turn start) fails before that point, so blocked work never loses its terminals. - Auto-settle (inactivity window, merged/closed PR) emits nothing at all: effectiveSettled is a clock-derived predicate, and its inputs are a per-client setting and change-request state the server never persists. The client is therefore the only observer of that transition, so it reports it via terminal.close-settled. The report is advisory: the server re-derives canSettle against the thread shell and ignores any report for a thread still holding live work. canSettle and hasQueuedTurnStart move to @t3tools/shared so the server applies the same rules as the client instead of growing another copy of the settle predicates; client-runtime re-exports them, so import sites are unchanged. Terminals close without deleteHistory, so scrollback survives and the terminal can be reopened. Unsettling never restarts anything. Snooze is deliberately excluded — it means "not now", not "done". Co-Authored-By: Claude --- .../src/environment/ServerEnvironment.ts | 1 + apps/server/src/server.test.ts | 206 ++++++++++++++++++ apps/server/src/ws.ts | 64 ++++++ apps/web/src/components/SidebarV2.tsx | 37 ++++ .../components/settings/SettingsPanels.tsx | 34 +++ packages/client-runtime/src/state/terminal.ts | 6 + .../client-runtime/src/state/threadSettled.ts | 72 +----- packages/contracts/src/environment.ts | 5 + packages/contracts/src/rpc.ts | 8 + packages/contracts/src/settings.test.ts | 13 ++ packages/contracts/src/settings.ts | 8 + packages/contracts/src/terminal.ts | 9 + packages/shared/package.json | 4 + packages/shared/src/threadSettled.ts | 77 +++++++ 14 files changed, 481 insertions(+), 63 deletions(-) create mode 100644 packages/shared/src/threadSettled.ts diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 0eaf5a7c16a1..2247f63854b7 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -142,6 +142,7 @@ export const make = Effect.gen(function* () { connectionProbe: true, threadSettlement: true, threadSnooze: true, + settledTerminalCleanup: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), }, }; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index a3b2e3715fd6..c916fc5c4102 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -6442,6 +6442,212 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("closes thread terminals after settle when the setting is enabled", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-settle-closes-terminals"); + const effects: string[] = []; + + yield* buildAppUnderTest({ + layers: { + serverSettings: { + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + closeTerminalsOnThreadSettle: true, + }), + }, + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + effects.push(`dispatch:${command.type}`); + return { sequence: 1 }; + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.settle", + commandId: CommandId.make("cmd-thread-settle"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + // Terminals close only after the settle command itself is accepted. + assert.deepEqual(effects, ["dispatch:thread.settle", `terminal.close:${threadId}`]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("leaves thread terminals running after settle by default", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-settle-keeps-terminals"); + const effects: string[] = []; + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + effects.push(`dispatch:${command.type}`); + return { sequence: 1 }; + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.settle", + commandId: CommandId.make("cmd-thread-settle-default"), + threadId, + }), + ), + ); + + // closeTerminalsOnThreadSettle defaults to false: settling must not be + // destructive unless the user opted in. + assert.deepEqual(effects, ["dispatch:thread.settle"]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("closes terminals when a client reports a thread auto-settled", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-auto-settled"); + const effects: string[] = []; + + yield* buildAppUnderTest({ + layers: { + serverSettings: { + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + closeTerminalsOnThreadSettle: true, + }), + }, + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.terminalCloseSettled]({ threadId })), + ); + + assert.deepEqual(effects, [`terminal.close:${threadId}`]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("ignores an auto-settled report for a thread with a live session", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-auto-settled-running"); + const effects: string[] = []; + + yield* buildAppUnderTest({ + layers: { + serverSettings: { + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + closeTerminalsOnThreadSettle: true, + }), + }, + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: "2026-01-01T00:00:00.000Z", + session: { + threadId, + status: "running", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }), + ), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.terminalCloseSettled]({ threadId })), + ); + + // The client's report is advisory: a live session outranks it, so the + // agent never loses its terminals mid-run. + assert.deepEqual(effects, []); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("ignores an auto-settled report when the setting is off", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-auto-settled-disabled"); + const effects: string[] = []; + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.terminalCloseSettled]({ threadId })), + ); + + assert.deepEqual(effects, []); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("checks session status before archiving removes the thread from active lookups", () => Effect.gen(function* () { const threadId = ThreadId.make("thread-archive-precheck"); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 2a8be25a728a..564ff90af21f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -52,6 +52,7 @@ import { AssetWorkspaceContextNotFoundError, AssetWorkspaceContextResolutionError, EnvironmentAuthorizationError, + type OrchestrationThreadShell, ThreadId, type TerminalAttachStreamEvent, type TerminalError, @@ -60,6 +61,7 @@ import { WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; +import { canSettle } from "@t3tools/shared/threadSettled"; import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; @@ -352,6 +354,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.terminalClear, AuthTerminalOperateScope], [WS_METHODS.terminalRestart, AuthTerminalOperateScope], [WS_METHODS.terminalClose, AuthTerminalOperateScope], + [WS_METHODS.terminalCloseSettled, AuthTerminalOperateScope], [WS_METHODS.subscribeTerminalEvents, AuthTerminalOperateScope], [WS_METHODS.subscribeTerminalMetadata, AuthTerminalOperateScope], [WS_METHODS.previewOpen, AuthOrchestrationOperateScope], @@ -1174,6 +1177,33 @@ const makeWsRpcLayer = ( ), ); } + // Settling means "I'm done here", so the dev servers the thread + // spawned are dead weight. Only the explicit thread.settle + // command reaches this: auto-settle (inactivity / merged PR) is + // a clock-derived client predicate that emits no event, so it + // deliberately leaves terminals alone. Opt-in, and only after + // dispatch succeeds — a settle the decider rejected (active + // session, pending approval, queued turn) fails above and never + // gets here, so blocked work never loses its terminals. + if (normalizedCommand.type === "thread.settle") { + const closeTerminalsOnSettle = yield* serverSettings.getSettings.pipe( + Effect.map((settings) => settings.closeTerminalsOnThreadSettle), + Effect.orElseSucceed(() => false), + ); + if (closeTerminalsOnSettle) { + // History is retained (no deleteHistory): reopening the + // terminal brings back the scrollback, and unsettling never + // restarts anything on its own. + yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to close thread terminals after settle", { + threadId: normalizedCommand.threadId, + error: error.message, + }), + ), + ); + } + } return result; }).pipe( Effect.mapError((cause) => @@ -1906,6 +1936,40 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.terminalClose, terminalManager.close(input), { "rpc.aggregate": "terminal", }), + // A client reporting that a thread auto-settled. The settled windows + // (inactivity, merged PR) depend on per-client settings and change + // request state the server cannot see, so the client is the only one + // that can detect the transition — but its report is advisory, never + // authoritative. Re-derive the guards we CAN see before acting, so a + // stale or buggy client cannot kill terminals out from under live + // work. Idempotent: closing already-closed sessions is a no-op, so + // several clients reporting the same thread is harmless. + [WS_METHODS.terminalCloseSettled]: (input) => + observeRpcEffect( + WS_METHODS.terminalCloseSettled, + Effect.gen(function* () { + const enabled = yield* serverSettings.getSettings.pipe( + Effect.map((settings) => settings.closeTerminalsOnThreadSettle), + Effect.orElseSucceed(() => false), + ); + if (!enabled) return; + + const shell = yield* projectionSnapshotQuery + .getThreadShellById(ThreadId.make(input.threadId)) + .pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isNone(shell)) return; + // Archived threads already had their terminals closed on + // archive; deleted ones are gone. Nothing left to reclaim. + if (shell.value.archivedAt !== null) return; + // An explicit unsettle pins the thread active: the user said + // "keep this warm" after whatever the client observed. + if (shell.value.settledOverride === "active") return; + if (!canSettle(shell.value, { now: yield* nowIso })) return; + + yield* terminalManager.close({ threadId: input.threadId }); + }), + { "rpc.aggregate": "terminal" }, + ), [WS_METHODS.subscribeTerminalEvents]: (_input) => observeRpcStream( WS_METHODS.subscribeTerminalEvents, diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 8677ee1149a1..94b5574b5e3e 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -94,6 +94,7 @@ import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../s import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; import { projectEnvironment } from "../state/projects"; +import { terminalEnvironment } from "../state/terminal"; import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; @@ -1007,6 +1008,9 @@ export default function SidebarV2() { const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const closeSettledTerminals = useAtomCommand(terminalEnvironment.closeSettled, { + reportFailure: false, + }); const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false, }); @@ -1418,6 +1422,39 @@ export default function SidebarV2() { threads, ]); + // Report threads that settled on their own so the server can reclaim the + // dev servers they left running. The server cannot detect this itself: the + // inactivity window is a per-client setting and change-request state is + // never persisted, so this classification loop is the only place the + // transition is observable. The report is advisory — the server re-checks + // the guards it can see and ignores anything still holding live work. + const reportedSettledKeysRef = useRef>(new Set()); + useEffect(() => { + const alreadyReported = reportedSettledKeysRef.current; + const stillSettled = new Set(); + + for (const thread of settledThreads) { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + stillSettled.add(threadKey); + if (alreadyReported.has(threadKey)) continue; + // An explicit settle already closed terminals during dispatch; only + // the derived windows need reporting. + if (thread.settledOverride === "settled") continue; + const supportsCleanup = + serverConfigs.get(thread.environmentId)?.environment.capabilities.settledTerminalCleanup === + true; + if (!supportsCleanup) continue; + void closeSettledTerminals({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); + } + + // Threads that left the settled list are forgotten, so a thread that + // wakes on new activity and later re-settles reports again. + reportedSettledKeysRef.current = stillSettled; + }, [closeSettledTerminals, serverConfigs, settledThreads]); + // Arm a timeout for the earliest upcoming wake so the shelf empties the // moment a snooze expires instead of on the next minute tick. Sorted // soonest-first, so entry 0 is the boundary. diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index fa7c7299667e..d555b378a080 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -436,6 +436,10 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin ? ["New worktrees start from origin"] : []), + ...(settings.closeTerminalsOnThreadSettle !== + DEFAULT_UNIFIED_SETTINGS.closeTerminalsOnThreadSettle + ? ["Close terminals on settle"] + : []), ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory ? ["Add project base directory"] : []), @@ -455,6 +459,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, + settings.closeTerminalsOnThreadSettle, settings.diffIgnoreWhitespace, settings.glassOpacity, settings.automaticGitFetchInterval, @@ -492,6 +497,7 @@ export function useSettingsRestore(onRestored?: () => void) { automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, + closeTerminalsOnThreadSettle: DEFAULT_UNIFIED_SETTINGS.closeTerminalsOnThreadSettle, addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, @@ -918,6 +924,34 @@ export function GeneralSettingsPanel() { /> ) : null} + + updateSettings({ + closeTerminalsOnThreadSettle: + DEFAULT_UNIFIED_SETTINGS.closeTerminalsOnThreadSettle, + }) + } + /> + ) : null + } + control={ + + updateSettings({ closeTerminalsOnThreadSettle: Boolean(checked) }) + } + aria-label="Close thread terminals when a thread settles" + /> + } + /> + ( scheduler: lifecycleScheduler, concurrency: lifecycleConcurrency, }), + closeSettled: createEnvironmentRpcCommand(runtime, { + label: "environment-data:terminal:close-settled", + tag: WS_METHODS.terminalCloseSettled, + scheduler: lifecycleScheduler, + concurrency: lifecycleConcurrency, + }), }; } diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 0d077c892bb7..55337a9ea641 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -1,4 +1,9 @@ import type { OrchestrationThreadShell } from "@t3tools/contracts"; +import { + canSettle, + hasQueuedTurnStart, + QUEUED_TURN_START_GRACE_MS, +} from "@t3tools/shared/threadSettled"; export type ChangeRequestStateLike = "open" | "closed" | "merged"; @@ -26,69 +31,10 @@ export function threadLastActivityAt(shell: OrchestrationThreadShell): string | return latest; } -/** - * A queued turn start lives for at most this long: session adoption takes - * seconds, so a user message still unadopted after the grace window is a - * failed start (or stale data — shells from older servers can carry user - * messages with no latestTurn at all), not pending work. Without this bound - * such threads would be permanently unsettleable. - */ -export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; - -/** - * A user message no turn has picked up yet: the turn.start command was - * dispatched (message-sent + turn-start-requested) but no session has - * adopted it, so `session` is still null and the pending work is invisible - * to the session-status checks. Detectable as a user message strictly newer - * than every timestamp on the latest turn — on adoption the new turn's - * requestedAt equals the message time, clearing the condition — and only - * within the adoption grace window. - */ -export function hasQueuedTurnStart( - shell: Pick, - options: { readonly now: string }, -): boolean { - if (shell.latestUserMessageAt == null) return false; - // A failed session start clears the queued state: the failure is already - // visible (status edge / error). - if (shell.session?.status === "error") return false; - const messageAt = Date.parse(shell.latestUserMessageAt); - if (Number.isNaN(messageAt)) return false; - const nowMs = Date.parse(options.now); - if (Number.isNaN(nowMs)) return false; - // Bounded on both sides: message timestamps originate on whichever device - // sent the message, so a clock ahead of this one yields a negative age - // that would otherwise hold the queued state for the whole skew. Mirrors - // the decider's guard. - if (Math.abs(nowMs - messageAt) > QUEUED_TURN_START_GRACE_MS) return false; - const turn = shell.latestTurn; - if (turn === null) return true; - return [turn.requestedAt, turn.startedAt, turn.completedAt].every( - (candidate) => candidate == null || Date.parse(candidate) < messageAt, - ); -} - -/** - * A thread may be settled only when none of effectiveSettled's activity - * blockers hold. This is deliberately the same list: anything the partition - * refuses to CLASSIFY as settled must also be refused as a settle TARGET. - * The server enforces its own invariants; this client-side twin exists so - * the UI can disable/reject before a round trip. - */ -export function canSettle( - shell: Pick< - OrchestrationThreadShell, - "hasPendingApprovals" | "hasPendingUserInput" | "session" | "latestUserMessageAt" | "latestTurn" - >, - options: { readonly now: string }, -): boolean { - if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; - if (shell.session?.status === "starting" || shell.session?.status === "running") return false; - // Queued work is as blocked-on-progress as a live session: settling it - // (or auto-settling it on a closed PR) would hide a just-requested turn. - if (hasQueuedTurnStart(shell, options)) return false; - return true; -} +// The shell-shaped guards live in @t3tools/shared so the server can apply the +// same rules when vetoing a client's auto-settle report. Re-exported here to +// keep this module the single import site for the settled lifecycle. +export { canSettle, hasQueuedTurnStart, QUEUED_TURN_START_GRACE_MS }; /** * The snooze lifecycle fields plus everything needed to detect a raised diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 7f4b6c165410..830fa022d34b 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -47,6 +47,11 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands thread.snooze / thread.unsnooze commands. Same version-skew contract as threadSettlement. */ threadSnooze: Schema.optionalKey(Schema.Boolean), + /** Server understands terminal.close-settled, the report a client sends + when it observes a thread reach the settled state on its own (the + inactivity / merged-PR windows the server cannot evaluate). Same + version-skew contract as threadSettlement. */ + settledTerminalCleanup: Schema.optionalKey(Schema.Boolean), /** The update path clients should offer for this server. Absent on servers that must be relaunched manually (dev checkouts, Windows foreground runs, pre-update servers). */ diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index fa2d23b8ef29..c17f0d39af2f 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -83,6 +83,7 @@ import { TerminalAttachStreamEvent, TerminalClearInput, TerminalCloseInput, + TerminalCloseSettledInput, TerminalError, TerminalEvent, TerminalMetadataStreamEvent, @@ -190,6 +191,7 @@ export const WS_METHODS = { terminalClear: "terminal.clear", terminalRestart: "terminal.restart", terminalClose: "terminal.close", + terminalCloseSettled: "terminal.close-settled", // Preview methods previewOpen: "preview.open", @@ -534,6 +536,11 @@ export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), }); +export const WsTerminalCloseSettledRpc = Rpc.make(WS_METHODS.terminalCloseSettled, { + payload: TerminalCloseSettledInput, + error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), +}); + export const WsPreviewOpenRpc = Rpc.make(WS_METHODS.previewOpen, { payload: PreviewOpenInput, success: PreviewSessionSnapshot, @@ -745,6 +752,7 @@ export const WsRpcGroup = RpcGroup.make( WsTerminalClearRpc, WsTerminalRestartRpc, WsTerminalCloseRpc, + WsTerminalCloseSettledRpc, WsSubscribeTerminalEventsRpc, WsSubscribeTerminalMetadataRpc, WsPreviewOpenRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index e8eaf723e174..665f7d674070 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -136,6 +136,19 @@ describe("ServerSettings worktree defaults", () => { }); }); +describe("ServerSettings settle terminal cleanup", () => { + it("defaults close-terminals-on-settle off for existing configs", () => { + expect(decodeServerSettings({}).closeTerminalsOnThreadSettle).toBe(false); + }); + + it("accepts close-terminals-on-settle updates", () => { + expect( + decodeServerSettingsPatch({ closeTerminalsOnThreadSettle: true }) + .closeTerminalsOnThreadSettle, + ).toBe(true); + }); +}); + describe("ServerSettingsPatch.providerInstances", () => { it("treats providerInstances as an optional whole-map replacement", () => { const patch = decodeServerSettingsPatch({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 06f7de3db67a..8c00a6c692e1 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -411,6 +411,13 @@ export const ServerSettings = Schema.Struct({ newWorktreesStartFromOrigin: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), + // Closing a thread's terminals on settle reclaims dev servers the thread + // spawned, but it is destructive to state the user may still want (a + // running watcher, a REPL). Off by default so settling — a high-frequency, + // deliberately silent action — never surprises anyone. + closeTerminalsOnThreadSettle: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( @@ -543,6 +550,7 @@ export const ServerSettingsPatch = Schema.Struct({ automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), + closeTerminalsOnThreadSettle: Schema.optionalKey(Schema.Boolean), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), observability: Schema.optionalKey( diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index fa5f18211695..b938a12c67cb 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -90,6 +90,15 @@ export const TerminalCloseInput = Schema.Struct({ }); export type TerminalCloseInput = typeof TerminalCloseInput.Type; +/** + * A client reporting that a thread reached the settled state on its own. + * Deliberately carries no terminalId: this is a whole-thread lifecycle + * signal, not a request to close one tab. The server re-derives whether the + * thread is safe to act on rather than trusting the report. + */ +export const TerminalCloseSettledInput = TerminalThreadInput; +export type TerminalCloseSettledInput = typeof TerminalCloseSettledInput.Type; + export const TerminalSessionStatus = Schema.Literals(["starting", "running", "exited", "error"]); export type TerminalSessionStatus = typeof TerminalSessionStatus.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index 8a45591fd369..4ed88ddc53c5 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -55,6 +55,10 @@ "types": "./src/DrainableWorker.ts", "import": "./src/DrainableWorker.ts" }, + "./threadSettled": { + "types": "./src/threadSettled.ts", + "import": "./src/threadSettled.ts" + }, "./KeyedCoalescingWorker": { "types": "./src/KeyedCoalescingWorker.ts", "import": "./src/KeyedCoalescingWorker.ts" diff --git a/packages/shared/src/threadSettled.ts b/packages/shared/src/threadSettled.ts new file mode 100644 index 000000000000..912ab0c1d8e3 --- /dev/null +++ b/packages/shared/src/threadSettled.ts @@ -0,0 +1,77 @@ +import type { OrchestrationThreadShell } from "@t3tools/contracts"; + +/** + * The settle guards that read nothing but an OrchestrationThreadShell, so + * both sides can share one copy. They live here rather than in client-runtime + * because the server needs them too: when a client reports that a thread + * auto-settled, the server re-checks these before acting on that report. + * + * The rest of the settled lifecycle (effectiveSettled, snooze) stays in + * client-runtime — it depends on per-client settings and change-request state + * the server has no access to. + */ + +/** + * A queued turn start lives for at most this long: session adoption takes + * seconds, so a user message still unadopted after the grace window is a + * failed start (or stale data — shells from older servers can carry user + * messages with no latestTurn at all), not pending work. Without this bound + * such threads would be permanently unsettleable. + */ +export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; + +/** + * A user message no turn has picked up yet: the turn.start command was + * dispatched (message-sent + turn-start-requested) but no session has + * adopted it, so `session` is still null and the pending work is invisible + * to the session-status checks. Detectable as a user message strictly newer + * than every timestamp on the latest turn — on adoption the new turn's + * requestedAt equals the message time, clearing the condition — and only + * within the adoption grace window. + */ +export function hasQueuedTurnStart( + shell: Pick, + options: { readonly now: string }, +): boolean { + if (shell.latestUserMessageAt == null) return false; + // A failed session start clears the queued state: the failure is already + // visible (status edge / error). + if (shell.session?.status === "error") return false; + const messageAt = Date.parse(shell.latestUserMessageAt); + if (Number.isNaN(messageAt)) return false; + const nowMs = Date.parse(options.now); + if (Number.isNaN(nowMs)) return false; + // Bounded on both sides: message timestamps originate on whichever device + // sent the message, so a clock ahead of this one yields a negative age + // that would otherwise hold the queued state for the whole skew. Mirrors + // the decider's guard. + if (Math.abs(nowMs - messageAt) > QUEUED_TURN_START_GRACE_MS) return false; + const turn = shell.latestTurn; + if (turn === null) return true; + return [turn.requestedAt, turn.startedAt, turn.completedAt].every( + (candidate) => candidate == null || Date.parse(candidate) < messageAt, + ); +} + +/** + * A thread may be settled only when none of effectiveSettled's activity + * blockers hold. This is deliberately the same list: anything the partition + * refuses to CLASSIFY as settled must also be refused as a settle TARGET. + * The server enforces its own invariants; this shell-shaped twin exists so + * the UI can disable/reject before a round trip, and so the server can veto + * a client's auto-settle report against the same rules. + */ +export function canSettle( + shell: Pick< + OrchestrationThreadShell, + "hasPendingApprovals" | "hasPendingUserInput" | "session" | "latestUserMessageAt" | "latestTurn" + >, + options: { readonly now: string }, +): boolean { + if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; + if (shell.session?.status === "starting" || shell.session?.status === "running") return false; + // Queued work is as blocked-on-progress as a live session: settling it + // (or auto-settling it on a closed PR) would hide a just-requested turn. + if (hasQueuedTurnStart(shell, options)) return false; + return true; +}