authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-25 13:09:30-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-25 13:09:30-07:00
log1a0c915c41024ddb47e16befd9fc730c1173af02
treed249003d1c51e52f50e56517e006e64a3263d561
parentfd996d15e220144ac6e66670169aa87d074ce846
signature Signed by PGP key B5690EEEBB952194

fix(terminal): settling a thread closes its idle shells (#13673)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

9 files changed, 318 insertions(+), 11 deletions(-)

apps/server/integration/OrchestrationEngineHarness.integration.ts+2
...@@ -26,6 +26,7 @@ import * as Tracer from "effect/Tracer";...@@ -26,6 +26,7 @@ import * as Tracer from "effect/Tracer";
2626
27import * as CheckpointStore from "../src/checkpointing/CheckpointStore.ts";27import * as CheckpointStore from "../src/checkpointing/CheckpointStore.ts";
28import { TextGeneration } from "../src/textGeneration/TextGeneration.ts";28import { TextGeneration } from "../src/textGeneration/TextGeneration.ts";
29import * as TerminalManager from "../src/terminal/Manager.ts";
29import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";30import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";
30import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts";31import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts";
31import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts";32import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts";
...@@ -340,6 +341,7 @@ export const makeOrchestrationIntegrationHarness = (...@@ -340,6 +341,7 @@ export const makeOrchestrationIntegrationHarness = (
340 tryHandlePromptCommand: () => Effect.succeed(false),341 tryHandlePromptCommand: () => Effect.succeed(false),
341 }),342 }),
342 ),343 ),
344 Layer.provide(Layer.mock(TerminalManager.TerminalManager)({ closeIdle: () => Effect.void })),
343 Layer.provideMerge(runtimeServicesLayer),345 Layer.provideMerge(runtimeServicesLayer),
344 Layer.provideMerge(gitWorkflowLayer),346 Layer.provideMerge(gitWorkflowLayer),
345 Layer.provideMerge(textGenerationLayer),347 Layer.provideMerge(textGenerationLayer),
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+75
...@@ -55,6 +55,7 @@ import {...@@ -55,6 +55,7 @@ import {
55import { ProviderAuthService } from "../../provider/Services/ProviderAuthService.ts";55import { ProviderAuthService } from "../../provider/Services/ProviderAuthService.ts";
56import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts";56import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts";
57import { TextGeneration } from "../../textGeneration/TextGeneration.ts";57import { TextGeneration } from "../../textGeneration/TextGeneration.ts";
58import { TerminalManager } from "../../terminal/Manager.ts";
58import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";59import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
59import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";60import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
60import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";61import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
...@@ -307,6 +308,7 @@ describe("ProviderCommandReactor", () => {...@@ -307,6 +308,7 @@ describe("ProviderCommandReactor", () => {
307 }),308 }),
308 );309 );
309 const pruneWorktrees = vi.fn((_: { readonly cwd: string }) => Effect.void);310 const pruneWorktrees = vi.fn((_: { readonly cwd: string }) => Effect.void);
311 const closeIdleTerminals = vi.fn((_: { readonly threadId: string }) => Effect.void);
310 const createWorktree = vi.fn(312 const createWorktree = vi.fn(
311 (input: { readonly refName: string; readonly path: string | null }) =>313 (input: { readonly refName: string; readonly path: string | null }) =>
312 Effect.succeed({ worktree: { path: input.path ?? "", refName: input.refName } }),314 Effect.succeed({ worktree: { path: input.path ?? "", refName: input.refName } }),
...@@ -490,6 +492,7 @@ describe("ProviderCommandReactor", () => {...@@ -490,6 +492,7 @@ describe("ProviderCommandReactor", () => {
490 generateThreadTitle,492 generateThreadTitle,
491 }),493 }),
492 ),494 ),
495 Layer.provideMerge(Layer.mock(TerminalManager)({ closeIdle: closeIdleTerminals })),
493 Layer.provideMerge(ServerSettingsService.layerTest()),496 Layer.provideMerge(ServerSettingsService.layerTest()),
494 Layer.provideMerge(SqlitePersistenceMemory),497 Layer.provideMerge(SqlitePersistenceMemory),
495 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),498 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),
...@@ -621,6 +624,7 @@ describe("ProviderCommandReactor", () => {...@@ -621,6 +624,7 @@ describe("ProviderCommandReactor", () => {
621 renameBranch,624 renameBranch,
622 pruneWorktrees,625 pruneWorktrees,
623 createWorktree,626 createWorktree,
627 closeIdleTerminals,
624 refreshStatus,628 refreshStatus,
625 generateBranchName,629 generateBranchName,
626 generateThreadTitle,630 generateThreadTitle,
...@@ -4339,6 +4343,77 @@ describe("ProviderCommandReactor", () => {...@@ -4339,6 +4343,77 @@ describe("ProviderCommandReactor", () => {
4339 expect(thread?.settledOverride).toBe("settled");4343 expect(thread?.settledOverride).toBe("settled");
4340 expect(thread?.session?.status).toBe("stopped");4344 expect(thread?.session?.status).toBe("stopped");
4341 expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work"));4345 expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work"));
4346 expect(harness.closeIdleTerminals).toHaveBeenCalledWith({
4347 threadId: ThreadId.make("thread-1"),
4348 });
4342 }),4349 }),
4343 );4350 );
4351
4352 effectIt.effect("closes idle terminals when a thread without a session settles", () =>
4353 Effect.gen(function* () {
4354 const harness = yield* Effect.promise(() => createHarness());
4355 const terminalsClosed = yield* Deferred.make<void>();
4356 harness.closeIdleTerminals.mockImplementation(() =>
4357 Deferred.succeed(terminalsClosed, undefined).pipe(Effect.asVoid),
4358 );
4359
4360 yield* harness.engine.dispatch({
4361 type: "thread.settle",
4362 commandId: CommandId.make("cmd-settle-without-session"),
4363 threadId: ThreadId.make("thread-1"),
4364 });
4365 yield* Deferred.await(terminalsClosed);
4366 yield* Effect.promise(() => harness.drain());
4367
4368 expect(harness.closeIdleTerminals).toHaveBeenCalledWith({
4369 threadId: ThreadId.make("thread-1"),
4370 });
4371 expect(harness.stopSession).not.toHaveBeenCalled();
4372 }),
4373 );
4374
4375 effectIt.effect(
4376 "keeps terminals when the thread is un-settled before its settle event runs",
4377 () =>
4378 Effect.gen(function* () {
4379 const harness = yield* Effect.promise(() => createHarness());
4380 const threadId = ThreadId.make("thread-1");
4381 const firstCloseStarted = yield* Deferred.make<void>();
4382 const releaseFirstClose = yield* Deferred.make<void>();
4383 harness.closeIdleTerminals.mockImplementationOnce(() =>
4384 Deferred.succeed(firstCloseStarted, undefined).pipe(
4385 Effect.andThen(Deferred.await(releaseFirstClose)),
4386 ),
4387 );
4388
4389 yield* harness.engine.dispatch({
4390 type: "thread.settle",
4391 commandId: CommandId.make("cmd-settle-first"),
4392 threadId,
4393 });
4394 // The reactor is busy with the first settle while the user changes their mind.
4395 yield* Deferred.await(firstCloseStarted);
4396 yield* harness.engine.dispatch({
4397 type: "thread.unsettle",
4398 commandId: CommandId.make("cmd-unsettle-first"),
4399 threadId,
4400 reason: "user",
4401 });
4402 yield* harness.engine.dispatch({
4403 type: "thread.settle",
4404 commandId: CommandId.make("cmd-settle-second"),
4405 threadId,
4406 });
4407 yield* harness.engine.dispatch({
4408 type: "thread.unsettle",
4409 commandId: CommandId.make("cmd-unsettle-second"),
4410 threadId,
4411 reason: "user",
4412 });
4413 yield* Deferred.succeed(releaseFirstClose, undefined);
4414 yield* Effect.promise(() => harness.drain());
4415
4416 expect(harness.closeIdleTerminals).toHaveBeenCalledTimes(1);
4417 }),
4418 );
4344});4419});
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+10-5
...@@ -65,6 +65,7 @@ import {...@@ -65,6 +65,7 @@ import {
65import { resolveProjectSettings } from "@t3tools/shared/projectSettings";65import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
66import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";66import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
67import { GitWorkflowService } from "../../git/GitWorkflowService.ts";67import { GitWorkflowService } from "../../git/GitWorkflowService.ts";
68import * as TerminalManager from "../../terminal/Manager.ts";
68const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError);69const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError);
69const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);70const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);
70const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError);71const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError);
...@@ -222,6 +223,7 @@ const make = Effect.gen(function* () {...@@ -222,6 +223,7 @@ const make = Effect.gen(function* () {
222 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;223 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
223 const textGeneration = yield* TextGeneration;224 const textGeneration = yield* TextGeneration;
224 const serverSettingsService = yield* ServerSettingsService;225 const serverSettingsService = yield* ServerSettingsService;
226 const terminalManager = yield* TerminalManager.TerminalManager;
225 /** Environment settings with the thread's project overrides applied. */227 /** Environment settings with the thread's project overrides applied. */
226 const projectSettingsForThread = Effect.fnUntraced(function* (threadId: ThreadId) {228 const projectSettingsForThread = Effect.fnUntraced(function* (threadId: ThreadId) {
227 const settings = yield* serverSettingsService.getSettings;229 const settings = yield* serverSettingsService.getSettings;
...@@ -1822,11 +1824,14 @@ const make = Effect.gen(function* () {...@@ -1822,11 +1824,14 @@ const make = Effect.gen(function* () {
1822 return;1824 return;
1823 case "thread.settled": {1825 case "thread.settled": {
1824 const thread = yield* projectionSnapshotQuery.getThreadShellById(event.payload.threadId);1826 const thread = yield* projectionSnapshotQuery.getThreadShellById(event.payload.threadId);
1825 if (1827 // A thread re-engaged before this event ran keeps its shells and session.
1826 Option.isNone(thread) ||1828 if (Option.isNone(thread) || thread.value.settledOverride !== "settled") {
1827 thread.value.session == null ||1829 return;
1828 thread.value.session.status === "stopped"1830 }
1829 ) {1831 // Idle shells close so they stop holding the worktree. A terminal that
1832 // runs a command (a dev server, an editor) stays for the user to close.
1833 yield* terminalManager.closeIdle({ threadId: event.payload.threadId });
1834 if (thread.value.session == null || thread.value.session.status === "stopped") {
1830 return;1835 return;
1831 }1836 }
1832 yield* orchestrationEngine.dispatch({1837 yield* orchestrationEngine.dispatch({
apps/server/src/project/AgentSessionImporter.test.ts+2
...@@ -56,6 +56,7 @@ import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistr...@@ -56,6 +56,7 @@ import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistr
56import { ServerSettingsService } from "../serverSettings.ts";56import { ServerSettingsService } from "../serverSettings.ts";
57import * as AnalyticsService from "../telemetry/AnalyticsService.ts";57import * as AnalyticsService from "../telemetry/AnalyticsService.ts";
58import { TextGeneration } from "../textGeneration/TextGeneration.ts";58import { TextGeneration } from "../textGeneration/TextGeneration.ts";
59import { TerminalManager } from "../terminal/Manager.ts";
59import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts";60import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts";
60import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts";61import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts";
61import { importRecentAgentThreads } from "./AgentSessionImporter.ts";62import { importRecentAgentThreads } from "./AgentSessionImporter.ts";
...@@ -931,6 +932,7 @@ it.layer(integrationLayer)("AgentSessionImporter integration", (it) => {...@@ -931,6 +932,7 @@ it.layer(integrationLayer)("AgentSessionImporter integration", (it) => {
931 Layer.provide(Layer.mock(GitWorkflowService)({})),932 Layer.provide(Layer.mock(GitWorkflowService)({})),
932 Layer.provide(Layer.mock(VcsStatusBroadcaster)({})),933 Layer.provide(Layer.mock(VcsStatusBroadcaster)({})),
933 Layer.provide(Layer.mock(TextGeneration)({})),934 Layer.provide(Layer.mock(TextGeneration)({})),
935 Layer.provide(Layer.mock(TerminalManager)({ closeIdle: () => Effect.void })),
934 Layer.provide(ServerSettingsService.layerTest()),936 Layer.provide(ServerSettingsService.layerTest()),
935 );937 );
936938
apps/server/src/project/ProjectSetupScriptRunner.test.ts+72-2
...@@ -58,7 +58,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>...@@ -58,7 +58,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
58 });58 });
5959
60type TerminalOverrides = Pick<TerminalManager.TerminalManager["Service"], "open" | "write"> &60type TerminalOverrides = Pick<TerminalManager.TerminalManager["Service"], "open" | "write"> &
61 Partial<Pick<TerminalManager.TerminalManager["Service"], "subscribe">>;61 Partial<Pick<TerminalManager.TerminalManager["Service"], "subscribe" | "closeIdle">>;
6262
63const makeTerminalManagerLayer = (overrides: TerminalOverrides) =>63const makeTerminalManagerLayer = (overrides: TerminalOverrides) =>
64 Layer.succeed(TerminalManager.TerminalManager, {64 Layer.succeed(TerminalManager.TerminalManager, {
...@@ -67,6 +67,7 @@ const makeTerminalManagerLayer = (overrides: TerminalOverrides) =>...@@ -67,6 +67,7 @@ const makeTerminalManagerLayer = (overrides: TerminalOverrides) =>
67 clear: () => Effect.void,67 clear: () => Effect.void,
68 restart: () => Effect.die(new Error("unused")),68 restart: () => Effect.die(new Error("unused")),
69 close: () => Effect.void,69 close: () => Effect.void,
70 closeIdle: () => Effect.void,
70 subscribe: () => Effect.succeed(() => undefined),71 subscribe: () => Effect.succeed(() => undefined),
71 subscribeMetadata: () => Effect.succeed(() => undefined),72 subscribeMetadata: () => Effect.succeed(() => undefined),
72 ...overrides,73 ...overrides,
...@@ -262,6 +263,7 @@ describe("ProjectSetupScriptRunner", () => {...@@ -262,6 +263,7 @@ describe("ProjectSetupScriptRunner", () => {
262 listener = null;263 listener = null;
263 });264 });
264 });265 });
266 const closeIdle = vi.fn(() => Effect.void);
265 const project = makeProject([267 const project = makeProject([
266 {268 {
267 id: "setup",269 id: "setup",
...@@ -329,14 +331,82 @@ describe("ProjectSetupScriptRunner", () => {...@@ -329,14 +331,82 @@ describe("ProjectSetupScriptRunner", () => {
329 ]);331 ]);
330 // The subscription is torn down once the sentinel arrives.332 // The subscription is torn down once the sentinel arrives.
331 expect(listener).toBeNull();333 expect(listener).toBeNull();
334 // A failed run keeps its shell open for a look.
335 expect(closeIdle).not.toHaveBeenCalled();
332 }).pipe(336 }).pipe(
333 Effect.provide(testLayer(project, { open, write, subscribe })),337 Effect.provide(testLayer(project, { open, write, subscribe, closeIdle })),
334 Effect.provideService(HostProcessPlatform, "linux"),338 Effect.provideService(HostProcessPlatform, "linux"),
335 Effect.provideService(HostProcessEnvironment, { SHELL: "/bin/zsh" }),339 Effect.provideService(HostProcessEnvironment, { SHELL: "/bin/zsh" }),
336 );340 );
337 },341 },
338 );342 );
339343
344 it.effect("closes the idle setup shell after a clean exit", () => {
345 const open = vi.fn(() =>
346 Effect.succeed({
347 threadId: "thread-1",
348 terminalId: "setup-setup",
349 cwd: "/repo/worktrees/a",
350 worktreePath: "/repo/worktrees/a",
351 status: "running" as const,
352 pid: 123,
353 history: "",
354 exitCode: null,
355 exitSignal: null,
356 label: "setup-setup",
357 updatedAt: "2026-01-01T00:00:00.000Z",
358 }),
359 );
360 let written = "";
361 const write = vi.fn((input: { data: string }) =>
362 Effect.sync(() => void (written = input.data)),
363 );
364 let listener: ((event: TerminalEvent) => Effect.Effect<void>) | null = null;
365 const subscribe = vi.fn((next: (event: TerminalEvent) => Effect.Effect<void>) => {
366 listener = next;
367 return Effect.succeed(() => {
368 listener = null;
369 });
370 });
371 const closeIdle = vi.fn(() => Effect.void);
372 const project = makeProject([
373 {
374 id: "setup",
375 name: "Setup",
376 command: "bun install",
377 icon: "configure",
378 runOnWorktreeCreate: true,
379 },
380 ]);
381
382 return Effect.gen(function* () {
383 const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
384 const result = yield* runner.runForThread({
385 threadId: "thread-1",
386 projectCwd: "/repo/project",
387 worktreePath: "/repo/worktrees/a",
388 observeCompletion: {},
389 });
390 if (result.status !== "started" || !result.completion) {
391 return yield* Effect.die("expected an observed setup run");
392 }
393 const sentinel = /__T3_SETUP_DONE___[0-9a-f]{32}:/.exec(written)?.[0];
394 yield* listener!({
395 threadId: "thread-1",
396 terminalId: "setup-setup",
397 type: "output",
398 data: `${sentinel}0\r\n`,
399 });
400
401 expect((yield* result.completion).exitCode).toBe(0);
402 expect(closeIdle).toHaveBeenCalledWith({ threadId: "thread-1", terminalId: "setup-setup" });
403 }).pipe(
404 Effect.provide(testLayer(project, { open, write, subscribe, closeIdle })),
405 Effect.provideService(HostProcessPlatform, "linux"),
406 Effect.provideService(HostProcessEnvironment, { SHELL: "/bin/zsh" }),
407 );
408 });
409
340 it.effect("unsubscribes from terminal output when the command cannot be written", () => {410 it.effect("unsubscribes from terminal output when the command cannot be written", () => {
341 const open = vi.fn(() =>411 const open = vi.fn(() =>
342 Effect.succeed({412 Effect.succeed({
apps/server/src/project/ProjectSetupScriptRunner.ts+12-1
...@@ -36,6 +36,7 @@ export interface ProjectSetupScriptRunnerResultStarted {...@@ -36,6 +36,7 @@ export interface ProjectSetupScriptRunnerResultStarted {
36 * Resolves when the script's shell prints the completion sentinel. The36 * Resolves when the script's shell prints the completion sentinel. The
37 * exit code is null when the terminal exited or was closed before the37 * exit code is null when the terminal exited or was closed before the
38 * sentinel arrived. Only present when `observeCompletion` was requested.38 * sentinel arrived. Only present when `observeCompletion` was requested.
39 * An exit code of 0 closes the setup shell if it has nothing left running.
39 */40 */
40 readonly completion?: Effect.Effect<ProjectSetupScriptCompletion>;41 readonly completion?: Effect.Effect<ProjectSetupScriptCompletion>;
41}42}
...@@ -412,6 +413,16 @@ export const make = Effect.gen(function* () {...@@ -412,6 +413,16 @@ export const make = Effect.gen(function* () {
412 Effect.tapError(() => Effect.sync(() => observed?.unsubscribe())),413 Effect.tapError(() => Effect.sync(() => observed?.unsubscribe())),
413 );414 );
414415
416 // A clean run leaves only an idle prompt behind; its output stays in the
417 // terminal history. A failed run keeps its shell open for a look.
418 const completion = observed?.completion.pipe(
419 Effect.tap(({ exitCode }) =>
420 exitCode === 0
421 ? terminalManager.closeIdle({ threadId: input.threadId, terminalId })
422 : Effect.void,
423 ),
424 );
425
415 return {426 return {
416 status: "started",427 status: "started",
417 scriptId: script.id,428 scriptId: script.id,
...@@ -420,7 +431,7 @@ export const make = Effect.gen(function* () {...@@ -420,7 +431,7 @@ export const make = Effect.gen(function* () {
420 terminalId,431 terminalId,
421 cwd,432 cwd,
422 async: script.async !== false,433 async: script.async !== false,
423 ...(observed ? { completion: observed.completion } : {}),434 ...(completion ? { completion } : {}),
424 } as const;435 } as const;
425 });436 });
426437
apps/server/src/terminal/Manager.test.ts+67
...@@ -1230,6 +1230,73 @@ it.layer(...@@ -1230,6 +1230,73 @@ it.layer(
1230 }),1230 }),
1231 );1231 );
12321232
1233 it.effect("closes only a thread's idle shells, ignoring a helper forked from the shell", () =>
1234 Effect.gen(function* () {
1235 // FakePtyAdapter assigns pids from 9000 in open order.
1236 const { manager, ptyAdapter } = yield* createManager(5, {
1237 processTable: Effect.succeed([
1238 { pid: 9000, ppid: 1, name: "zsh" },
1239 // An async prompt worker: a copy of the shell with no children.
1240 { pid: 100, ppid: 9000, name: "zsh" },
1241 { pid: 9001, ppid: 1, name: "zsh" },
1242 { pid: 200, ppid: 9001, name: "node" },
1243 { pid: 9002, ppid: 1, name: "zsh" },
1244 // A subshell with a child is real work.
1245 { pid: 300, ppid: 9002, name: "zsh" },
1246 { pid: 301, ppid: 300, name: "sleep" },
1247 { pid: 9003, ppid: 1, name: "zsh" },
1248 ]),
1249 }).pipe(Effect.provide(withHostPlatform("linux")));
1250 yield* manager.open(openInput({ terminalId: "idle" }));
1251 yield* manager.open(openInput({ terminalId: "dev-server" }));
1252 yield* manager.open(openInput({ terminalId: "subshell" }));
1253 yield* manager.open(openInput({ threadId: "thread-2" }));
1254
1255 yield* manager.closeIdle({ threadId: "thread-1" });
1256
1257 expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([
1258 true,
1259 false,
1260 false,
1261 false,
1262 ]);
1263 }),
1264 );
1265
1266 it.effect("keeps terminals that get input or output while closeIdle checks them", () =>
1267 Effect.gen(function* () {
1268 const ptyAdapter = new FakePtyAdapter();
1269 // The typed command's process misses the snapshot, but its input or echo lands.
1270 let duringCheck: (pid: number) => Effect.Effect<void> = () => Effect.void;
1271 const { manager, getEvents } = yield* createManager(5, {
1272 ptyAdapter,
1273 subprocessPollIntervalMs: 60_000,
1274 subprocessInspector: (pid) =>
1275 duringCheck(pid).pipe(
1276 Effect.as({ hasRunningSubprocess: false, childCommand: null, processIds: [] }),
1277 ),
1278 });
1279 yield* manager.open(openInput({ terminalId: "typed" }));
1280 yield* manager.open(openInput({ terminalId: "echoed" }));
1281 const [typed, echoed] = ptyAdapter.processes;
1282 duringCheck = (pid) =>
1283 pid === typed!.pid
1284 ? manager
1285 .write({ threadId: "thread-1", terminalId: "typed", data: "make build\r" })
1286 .pipe(Effect.orDie)
1287 : Effect.gen(function* () {
1288 echoed!.emitData("make build\r\n");
1289 yield* waitFor(
1290 Effect.map(getEvents, (events) => events.some((event) => event.type === "output")),
1291 );
1292 }).pipe(Effect.orDie);
1293
1294 yield* manager.closeIdle({ threadId: "thread-1" });
1295
1296 expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([false, false]);
1297 }),
1298 );
1299
1233 it.effect("backs off the spawned fallback when the resource monitor snapshot fails", () =>1300 it.effect("backs off the spawned fallback when the resource monitor snapshot fails", () =>
1234 Effect.gen(function* () {1301 Effect.gen(function* () {
1235 const fallbackCalls: Array<number> = [];1302 const fallbackCalls: Array<number> = [];
apps/server/src/terminal/Manager.ts+75-2
...@@ -198,6 +198,17 @@ export class TerminalManager extends Context.Service<...@@ -198,6 +198,17 @@ export class TerminalManager extends Context.Service<
198 */198 */
199 readonly close: (input: TerminalCloseInput) => Effect.Effect<void, TerminalError>;199 readonly close: (input: TerminalCloseInput) => Effect.Effect<void, TerminalError>;
200200
201 /**
202 * Close a thread's terminals that wait at an idle shell prompt. A terminal
203 * that runs a command stays open. When `terminalId` is set, only that
204 * terminal is considered. Used when a thread settles and when a setup
205 * script finishes.
206 */
207 readonly closeIdle: (input: {
208 readonly threadId: string;
209 readonly terminalId?: string;
210 }) => Effect.Effect<void>;
211
201 /**212 /**
202 * Subscribe to terminal runtime events with a direct callback.213 * Subscribe to terminal runtime events with a direct callback.
203 *214 *
...@@ -275,6 +286,8 @@ interface TerminalSessionState {...@@ -275,6 +286,8 @@ interface TerminalSessionState {
275 exitSignal: number | null;286 exitSignal: number | null;
276 updatedAt: string;287 updatedAt: string;
277 eventSequence: number;288 eventSequence: number;
289 /** Counts writes, so closeIdle can see input that has not echoed yet. */
290 inputCount: number;
278 cols: number;291 cols: number;
279 rows: number;292 rows: number;
280 process: PtyAdapter.PtyProcess | null;293 process: PtyAdapter.PtyProcess | null;
...@@ -692,7 +705,17 @@ function deriveSubprocessInspectResult(...@@ -692,7 +705,17 @@ function deriveSubprocessInspectResult(
692 terminalPid: number,705 terminalPid: number,
693 platform: NodeJS.Platform,706 platform: NodeJS.Platform,
694): TerminalSubprocessInspectResult {707): TerminalSubprocessInspectResult {
695 const childPid = (snapshot.childrenByParent.get(terminalPid) ?? [])[0];708 const commandName = (pid: number) =>
709 normalizeChildCommandName(snapshot.commandById.get(pid) ?? "", platform);
710 const shellName = commandName(terminalPid);
711 // Async prompt themes fork the shell into a helper that waits with no
712 // children of its own. That copy is not a command the user started.
713 const childPid = (snapshot.childrenByParent.get(terminalPid) ?? []).find(
714 (pid) =>
715 shellName === null ||
716 commandName(pid) !== shellName ||
717 (snapshot.childrenByParent.get(pid)?.length ?? 0) > 0,
718 );
696 if (childPid === undefined) {719 if (childPid === undefined) {
697 return { hasRunningSubprocess: false, childCommand: null, processIds: [] };720 return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
698 }721 }
...@@ -707,7 +730,7 @@ function deriveSubprocessInspectResult(...@@ -707,7 +730,7 @@ function deriveSubprocessInspectResult(
707 pending.push(pid);730 pending.push(pid);
708 }731 }
709 }732 }
710 const normalized = normalizeChildCommandName(snapshot.commandById.get(childPid) ?? "", platform);733 const normalized = commandName(childPid);
711 return {734 return {
712 hasRunningSubprocess: true,735 hasRunningSubprocess: true,
713 childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,736 childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,
...@@ -2537,6 +2560,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -2537,6 +2560,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
2537 exitSignal: null,2560 exitSignal: null,
2538 updatedAt: yield* nowIso,2561 updatedAt: yield* nowIso,
2539 eventSequence: 0,2562 eventSequence: 0,
2563 inputCount: 0,
2540 cols,2564 cols,
2541 rows,2565 rows,
2542 process: null,2566 process: null,
...@@ -2877,6 +2901,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -2877,6 +2901,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
2877 terminalId,2901 terminalId,
2878 });2902 });
2879 }2903 }
2904 session.inputCount += 1;
2880 yield* Effect.try({2905 yield* Effect.try({
2881 try: () => process.write(input.data),2906 try: () => process.write(input.data),
2882 catch: (cause) =>2907 catch: (cause) =>
...@@ -2958,6 +2983,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -2958,6 +2983,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
2958 exitSignal: null,2983 exitSignal: null,
2959 updatedAt: yield* nowIso,2984 updatedAt: yield* nowIso,
2960 eventSequence: 0,2985 eventSequence: 0,
2986 inputCount: 0,
2961 cols,2987 cols,
2962 rows,2988 rows,
2963 process: null,2989 process: null,
...@@ -3042,6 +3068,52 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -3042,6 +3068,52 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
3042 }),3068 }),
3043 );3069 );
30443070
3071 const closeIdle: TerminalManager["Service"]["closeIdle"] = (input) =>
3072 withThreadLock(
3073 input.threadId,
3074 Effect.gen(function* () {
3075 const running = (yield* sessionsForThread(input.threadId)).filter(
3076 (session): session is TerminalSessionState & { pid: number } =>
3077 session.status === "running" &&
3078 Number.isInteger(session.pid) &&
3079 (input.terminalId === undefined || session.terminalId === input.terminalId),
3080 );
3081 if (running.length === 0) return;
3082 // A command started during the process check can miss the snapshot,
3083 // but its input or echo still lands. Both counters only grow, so the
3084 // sum changes when either one does.
3085 const activityMark = (session: TerminalSessionState) =>
3086 session.eventSequence + session.inputCount;
3087 const marks = new Map(
3088 running.map((session) => [session.terminalId, activityMark(session)]),
3089 );
3090 // Inspect now instead of trusting the last poll, so a command started
3091 // since then keeps its terminal.
3092 const { inspector } = yield* acquireSubprocessInspector;
3093 yield* Effect.forEach(
3094 running,
3095 (session) =>
3096 inspector(session.pid).pipe(
3097 Effect.flatMap((result) =>
3098 result.hasRunningSubprocess ||
3099 activityMark(session) !== marks.get(session.terminalId)
3100 ? Effect.void
3101 : closeSession(input.threadId, session.terminalId, false),
3102 ),
3103 ),
3104 { discard: true },
3105 );
3106 }),
3107 ).pipe(
3108 // The process check failed, so every terminal stays open.
3109 Effect.catch((error) =>
3110 Effect.logWarning("failed to close idle terminals", {
3111 threadId: input.threadId,
3112 error: error.message,
3113 }),
3114 ),
3115 );
3116
3045 return TerminalManager.of({3117 return TerminalManager.of({
3046 open,3118 open,
3047 attachStream,3119 attachStream,
...@@ -3050,6 +3122,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -3050,6 +3122,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
3050 clear,3122 clear,
3051 restart,3123 restart,
3052 close,3124 close,
3125 closeIdle,
3053 subscribe,3126 subscribe,
3054 subscribeMetadata,3127 subscribeMetadata,
3055 });3128 });
docs/user/thread-sidebar.md+3-1
...@@ -90,7 +90,9 @@ Choose **Settle thread** from its menu to move finished work out of the active l...@@ -90,7 +90,9 @@ Choose **Settle thread** from its menu to move finished work out of the active l
90without deleting the conversation. **Un-settle thread** restores it to active work90without deleting the conversation. **Un-settle thread** restores it to active work
91and prevents automatic settlement until new activity resumes the usual rules.91and prevents automatic settlement until new activity resumes the usual rules.
92Manually settling an idle thread dismisses unanswered async questions without92Manually settling an idle thread dismisses unanswered async questions without
93sending an answer or restarting the agent.93sending an answer or restarting the agent. Settling also closes the thread's
94terminals that wait at an idle prompt, and keeps their output. A terminal that
95runs a command, such as a dev server, stays open.
9496
95By default, environments settle inactive threads after three days and settle97By default, environments settle inactive threads after three days and settle
96threads whose pull request merged. A closed pull request can also settle an idle98threads whose pull request merged. A closed pull request can also settle an idle