| author | |
| committer | |
| log | 1a0c915c41024ddb47e16befd9fc730c1173af02 |
| tree | d249003d1c51e52f50e56517e006e64a3263d561 |
| parent | fd996d15e220144ac6e66670169aa87d074ce846 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 26 | |
| 27 | 27 | import * as CheckpointStore from "../src/checkpointing/CheckpointStore.ts"; |
| 28 | 28 | import { TextGeneration } from "../src/textGeneration/TextGeneration.ts"; |
| 29 | import * as TerminalManager from "../src/terminal/Manager.ts"; | |
| 29 | 30 | import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; |
| 30 | 31 | import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; |
| 31 | 32 | import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts"; |
| ... | ... | @@ -340,6 +341,7 @@ export const makeOrchestrationIntegrationHarness = ( |
| 340 | 341 | tryHandlePromptCommand: () => Effect.succeed(false), |
| 341 | 342 | }), |
| 342 | 343 | ), |
| 344 | Layer.provide(Layer.mock(TerminalManager.TerminalManager)({ closeIdle: () => Effect.void })), | |
| 343 | 345 | Layer.provideMerge(runtimeServicesLayer), |
| 344 | 346 | Layer.provideMerge(gitWorkflowLayer), |
| 345 | 347 | Layer.provideMerge(textGenerationLayer), |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+75| ... | ... | @@ -55,6 +55,7 @@ import { |
| 55 | 55 | import { ProviderAuthService } from "../../provider/Services/ProviderAuthService.ts"; |
| 56 | 56 | import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts"; |
| 57 | 57 | import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; |
| 58 | import { TerminalManager } from "../../terminal/Manager.ts"; | |
| 58 | 59 | import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; |
| 59 | 60 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 60 | 61 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| ... | ... | @@ -307,6 +308,7 @@ describe("ProviderCommandReactor", () => { |
| 307 | 308 | }), |
| 308 | 309 | ); |
| 309 | 310 | const pruneWorktrees = vi.fn((_: { readonly cwd: string }) => Effect.void); |
| 311 | const closeIdleTerminals = vi.fn((_: { readonly threadId: string }) => Effect.void); | |
| 310 | 312 | const createWorktree = vi.fn( |
| 311 | 313 | (input: { readonly refName: string; readonly path: string | null }) => |
| 312 | 314 | Effect.succeed({ worktree: { path: input.path ?? "", refName: input.refName } }), |
| ... | ... | @@ -490,6 +492,7 @@ describe("ProviderCommandReactor", () => { |
| 490 | 492 | generateThreadTitle, |
| 491 | 493 | }), |
| 492 | 494 | ), |
| 495 | Layer.provideMerge(Layer.mock(TerminalManager)({ closeIdle: closeIdleTerminals })), | |
| 493 | 496 | Layer.provideMerge(ServerSettingsService.layerTest()), |
| 494 | 497 | Layer.provideMerge(SqlitePersistenceMemory), |
| 495 | 498 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), |
| ... | ... | @@ -621,6 +624,7 @@ describe("ProviderCommandReactor", () => { |
| 621 | 624 | renameBranch, |
| 622 | 625 | pruneWorktrees, |
| 623 | 626 | createWorktree, |
| 627 | closeIdleTerminals, | |
| 624 | 628 | refreshStatus, |
| 625 | 629 | generateBranchName, |
| 626 | 630 | generateThreadTitle, |
| ... | ... | @@ -4339,6 +4343,77 @@ describe("ProviderCommandReactor", () => { |
| 4339 | 4343 | expect(thread?.settledOverride).toBe("settled"); |
| 4340 | 4344 | expect(thread?.session?.status).toBe("stopped"); |
| 4341 | 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 | 65 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 66 | 66 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; |
| 67 | 67 | import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; |
| 68 | import * as TerminalManager from "../../terminal/Manager.ts"; | |
| 68 | 69 | const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError); |
| 69 | 70 | const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); |
| 70 | 71 | const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); |
| ... | ... | @@ -222,6 +223,7 @@ const make = Effect.gen(function* () { |
| 222 | 223 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; |
| 223 | 224 | const textGeneration = yield* TextGeneration; |
| 224 | 225 | const serverSettingsService = yield* ServerSettingsService; |
| 226 | const terminalManager = yield* TerminalManager.TerminalManager; | |
| 225 | 227 | /** Environment settings with the thread's project overrides applied. */ |
| 226 | 228 | const projectSettingsForThread = Effect.fnUntraced(function* (threadId: ThreadId) { |
| 227 | 229 | const settings = yield* serverSettingsService.getSettings; |
| ... | ... | @@ -1822,11 +1824,14 @@ const make = Effect.gen(function* () { |
| 1822 | 1824 | return; |
| 1823 | 1825 | case "thread.settled": { |
| 1824 | 1826 | const thread = yield* projectionSnapshotQuery.getThreadShellById(event.payload.threadId); |
| 1825 | if ( | |
| 1826 | Option.isNone(thread) || | |
| 1827 | thread.value.session == null || | |
| 1828 | thread.value.session.status === "stopped" | |
| 1829 | ) { | |
| 1827 | // A thread re-engaged before this event ran keeps its shells and session. | |
| 1828 | if (Option.isNone(thread) || thread.value.settledOverride !== "settled") { | |
| 1829 | return; | |
| 1830 | } | |
| 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 | 1835 | return; |
| 1831 | 1836 | } |
| 1832 | 1837 | yield* orchestrationEngine.dispatch({ |
apps/server/src/project/AgentSessionImporter.test.ts+2| ... | ... | @@ -56,6 +56,7 @@ import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistr |
| 56 | 56 | import { ServerSettingsService } from "../serverSettings.ts"; |
| 57 | 57 | import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; |
| 58 | 58 | import { TextGeneration } from "../textGeneration/TextGeneration.ts"; |
| 59 | import { TerminalManager } from "../terminal/Manager.ts"; | |
| 59 | 60 | import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; |
| 60 | 61 | import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; |
| 61 | 62 | import { importRecentAgentThreads } from "./AgentSessionImporter.ts"; |
| ... | ... | @@ -931,6 +932,7 @@ it.layer(integrationLayer)("AgentSessionImporter integration", (it) => { |
| 931 | 932 | Layer.provide(Layer.mock(GitWorkflowService)({})), |
| 932 | 933 | Layer.provide(Layer.mock(VcsStatusBroadcaster)({})), |
| 933 | 934 | Layer.provide(Layer.mock(TextGeneration)({})), |
| 935 | Layer.provide(Layer.mock(TerminalManager)({ closeIdle: () => Effect.void })), | |
| 934 | 936 | Layer.provide(ServerSettingsService.layerTest()), |
| 935 | 937 | ); |
| 936 | 938 |
apps/server/src/project/ProjectSetupScriptRunner.test.ts+72-2| ... | ... | @@ -58,7 +58,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => |
| 58 | 58 | }); |
| 59 | 59 | |
| 60 | 60 | type TerminalOverrides = Pick<TerminalManager.TerminalManager["Service"], "open" | "write"> & |
| 61 | Partial<Pick<TerminalManager.TerminalManager["Service"], "subscribe">>; | |
| 61 | Partial<Pick<TerminalManager.TerminalManager["Service"], "subscribe" | "closeIdle">>; | |
| 62 | 62 | |
| 63 | 63 | const makeTerminalManagerLayer = (overrides: TerminalOverrides) => |
| 64 | 64 | Layer.succeed(TerminalManager.TerminalManager, { |
| ... | ... | @@ -67,6 +67,7 @@ const makeTerminalManagerLayer = (overrides: TerminalOverrides) => |
| 67 | 67 | clear: () => Effect.void, |
| 68 | 68 | restart: () => Effect.die(new Error("unused")), |
| 69 | 69 | close: () => Effect.void, |
| 70 | closeIdle: () => Effect.void, | |
| 70 | 71 | subscribe: () => Effect.succeed(() => undefined), |
| 71 | 72 | subscribeMetadata: () => Effect.succeed(() => undefined), |
| 72 | 73 | ...overrides, |
| ... | ... | @@ -262,6 +263,7 @@ describe("ProjectSetupScriptRunner", () => { |
| 262 | 263 | listener = null; |
| 263 | 264 | }); |
| 264 | 265 | }); |
| 266 | const closeIdle = vi.fn(() => Effect.void); | |
| 265 | 267 | const project = makeProject([ |
| 266 | 268 | { |
| 267 | 269 | id: "setup", |
| ... | ... | @@ -329,14 +331,82 @@ describe("ProjectSetupScriptRunner", () => { |
| 329 | 331 | ]); |
| 330 | 332 | // The subscription is torn down once the sentinel arrives. |
| 331 | 333 | expect(listener).toBeNull(); |
| 334 | // A failed run keeps its shell open for a look. | |
| 335 | expect(closeIdle).not.toHaveBeenCalled(); | |
| 332 | 336 | }).pipe( |
| 333 | Effect.provide(testLayer(project, { open, write, subscribe })), | |
| 337 | Effect.provide(testLayer(project, { open, write, subscribe, closeIdle })), | |
| 334 | 338 | Effect.provideService(HostProcessPlatform, "linux"), |
| 335 | 339 | Effect.provideService(HostProcessEnvironment, { SHELL: "/bin/zsh" }), |
| 336 | 340 | ); |
| 337 | 341 | }, |
| 338 | 342 | ); |
| 339 | 343 | |
| 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 | 410 | it.effect("unsubscribes from terminal output when the command cannot be written", () => { |
| 341 | 411 | const open = vi.fn(() => |
| 342 | 412 | Effect.succeed({ |
apps/server/src/project/ProjectSetupScriptRunner.ts+12-1| ... | ... | @@ -36,6 +36,7 @@ export interface ProjectSetupScriptRunnerResultStarted { |
| 36 | 36 | * Resolves when the script's shell prints the completion sentinel. The |
| 37 | 37 | * exit code is null when the terminal exited or was closed before the |
| 38 | 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 | 41 | readonly completion?: Effect.Effect<ProjectSetupScriptCompletion>; |
| 41 | 42 | } |
| ... | ... | @@ -412,6 +413,16 @@ export const make = Effect.gen(function* () { |
| 412 | 413 | Effect.tapError(() => Effect.sync(() => observed?.unsubscribe())), |
| 413 | 414 | ); |
| 414 | 415 | |
| 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 | 426 | return { |
| 416 | 427 | status: "started", |
| 417 | 428 | scriptId: script.id, |
| ... | ... | @@ -420,7 +431,7 @@ export const make = Effect.gen(function* () { |
| 420 | 431 | terminalId, |
| 421 | 432 | cwd, |
| 422 | 433 | async: script.async !== false, |
| 423 | ...(observed ? { completion: observed.completion } : {}), | |
| 434 | ...(completion ? { completion } : {}), | |
| 424 | 435 | } as const; |
| 425 | 436 | }); |
| 426 | 437 |
apps/server/src/terminal/Manager.test.ts+67| ... | ... | @@ -1230,6 +1230,73 @@ it.layer( |
| 1230 | 1230 | }), |
| 1231 | 1231 | ); |
| 1232 | 1232 | |
| 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 | 1300 | it.effect("backs off the spawned fallback when the resource monitor snapshot fails", () => |
| 1234 | 1301 | Effect.gen(function* () { |
| 1235 | 1302 | const fallbackCalls: Array<number> = []; |
apps/server/src/terminal/Manager.ts+75-2| ... | ... | @@ -198,6 +198,17 @@ export class TerminalManager extends Context.Service< |
| 198 | 198 | */ |
| 199 | 199 | readonly close: (input: TerminalCloseInput) => Effect.Effect<void, TerminalError>; |
| 200 | 200 | |
| 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 | 213 | * Subscribe to terminal runtime events with a direct callback. |
| 203 | 214 | * |
| ... | ... | @@ -275,6 +286,8 @@ interface TerminalSessionState { |
| 275 | 286 | exitSignal: number | null; |
| 276 | 287 | updatedAt: string; |
| 277 | 288 | eventSequence: number; |
| 289 | /** Counts writes, so closeIdle can see input that has not echoed yet. */ | |
| 290 | inputCount: number; | |
| 278 | 291 | cols: number; |
| 279 | 292 | rows: number; |
| 280 | 293 | process: PtyAdapter.PtyProcess | null; |
| ... | ... | @@ -692,7 +705,17 @@ function deriveSubprocessInspectResult( |
| 692 | 705 | terminalPid: number, |
| 693 | 706 | platform: NodeJS.Platform, |
| 694 | 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 | 719 | if (childPid === undefined) { |
| 697 | 720 | return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; |
| 698 | 721 | } |
| ... | ... | @@ -707,7 +730,7 @@ function deriveSubprocessInspectResult( |
| 707 | 730 | pending.push(pid); |
| 708 | 731 | } |
| 709 | 732 | } |
| 710 | const normalized = normalizeChildCommandName(snapshot.commandById.get(childPid) ?? "", platform); | |
| 733 | const normalized = commandName(childPid); | |
| 711 | 734 | return { |
| 712 | 735 | hasRunningSubprocess: true, |
| 713 | 736 | childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, |
| ... | ... | @@ -2537,6 +2560,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 2537 | 2560 | exitSignal: null, |
| 2538 | 2561 | updatedAt: yield* nowIso, |
| 2539 | 2562 | eventSequence: 0, |
| 2563 | inputCount: 0, | |
| 2540 | 2564 | cols, |
| 2541 | 2565 | rows, |
| 2542 | 2566 | process: null, |
| ... | ... | @@ -2877,6 +2901,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 2877 | 2901 | terminalId, |
| 2878 | 2902 | }); |
| 2879 | 2903 | } |
| 2904 | session.inputCount += 1; | |
| 2880 | 2905 | yield* Effect.try({ |
| 2881 | 2906 | try: () => process.write(input.data), |
| 2882 | 2907 | catch: (cause) => |
| ... | ... | @@ -2958,6 +2983,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 2958 | 2983 | exitSignal: null, |
| 2959 | 2984 | updatedAt: yield* nowIso, |
| 2960 | 2985 | eventSequence: 0, |
| 2986 | inputCount: 0, | |
| 2961 | 2987 | cols, |
| 2962 | 2988 | rows, |
| 2963 | 2989 | process: null, |
| ... | ... | @@ -3042,6 +3068,52 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 3042 | 3068 | }), |
| 3043 | 3069 | ); |
| 3044 | 3070 | |
| 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 | 3117 | return TerminalManager.of({ |
| 3046 | 3118 | open, |
| 3047 | 3119 | attachStream, |
| ... | ... | @@ -3050,6 +3122,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 3050 | 3122 | clear, |
| 3051 | 3123 | restart, |
| 3052 | 3124 | close, |
| 3125 | closeIdle, | |
| 3053 | 3126 | subscribe, |
| 3054 | 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 | 90 | without deleting the conversation. **Un-settle thread** restores it to active work |
| 91 | 91 | and prevents automatic settlement until new activity resumes the usual rules. |
| 92 | 92 | Manually settling an idle thread dismisses unanswered async questions without |
| 93 | sending an answer or restarting the agent. | |
| 93 | sending an answer or restarting the agent. Settling also closes the thread's | |
| 94 | terminals that wait at an idle prompt, and keeps their output. A terminal that | |
| 95 | runs a command, such as a dev server, stays open. | |
| 94 | 96 | |
| 95 | 97 | By default, environments settle inactive threads after three days and settle |
| 96 | 98 | threads whose pull request merged. A closed pull request can also settle an idle |