Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export const make = Effect.gen(function* () {
connectionProbe: true,
threadSettlement: true,
threadSnooze: true,
settledTerminalCleanup: true,
...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
},
};
Expand Down
206 changes: 206 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
64 changes: 64 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
AssetWorkspaceContextNotFoundError,
AssetWorkspaceContextResolutionError,
EnvironmentAuthorizationError,
type OrchestrationThreadShell,
ThreadId,
type TerminalAttachStreamEvent,
type TerminalError,
Expand All @@ -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";
Expand Down Expand Up @@ -352,6 +354,7 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([
[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],
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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<OrchestrationThreadShell>()));
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,
Expand Down
37 changes: 37 additions & 0 deletions apps/web/src/components/SidebarV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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<ReadonlySet<string>>(new Set());
useEffect(() => {
const alreadyReported = reportedSettledKeysRef.current;
const stillSettled = new Set<string>();

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.
Expand Down
Loading
Loading