| 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,6 +26,7 @@ import * as Tracer from "effect/Tracer"; |
| 26 | 26 | ||
| 27 | import * as CheckpointStore from "../src/checkpointing/CheckpointStore.ts"; | 27 | import * as CheckpointStore from "../src/checkpointing/CheckpointStore.ts"; |
| 28 | import { TextGeneration } from "../src/textGeneration/TextGeneration.ts"; | 28 | import { TextGeneration } from "../src/textGeneration/TextGeneration.ts"; |
| 29 | import * as TerminalManager from "../src/terminal/Manager.ts"; | ||
| 29 | import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; | 30 | import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; |
| 30 | import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; | 31 | import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; |
| 31 | import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts"; | 32 | import { 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 { |
| 55 | import { ProviderAuthService } from "../../provider/Services/ProviderAuthService.ts"; | 55 | import { ProviderAuthService } from "../../provider/Services/ProviderAuthService.ts"; |
| 56 | import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts"; | 56 | import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts"; |
| 57 | import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; | 57 | import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; |
| 58 | import { TerminalManager } from "../../terminal/Manager.ts"; | ||
| 58 | import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; | 59 | import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; |
| 59 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | 60 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 60 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | 61 | import { 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 { |
| 65 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; | 65 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 66 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; | 66 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; |
| 67 | import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; | 67 | import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; |
| 68 | import * as TerminalManager from "../../terminal/Manager.ts"; | ||
| 68 | const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError); | 69 | const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError); |
| 69 | const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); | 70 | const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); |
| 70 | const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); | 71 | const 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 |
| 56 | import { ServerSettingsService } from "../serverSettings.ts"; | 56 | import { ServerSettingsService } from "../serverSettings.ts"; |
| 57 | import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; | 57 | import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; |
| 58 | import { TextGeneration } from "../textGeneration/TextGeneration.ts"; | 58 | import { TextGeneration } from "../textGeneration/TextGeneration.ts"; |
| 59 | import { TerminalManager } from "../terminal/Manager.ts"; | ||
| 59 | import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; | 60 | import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; |
| 60 | import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; | 61 | import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; |
| 61 | import { importRecentAgentThreads } from "./AgentSessionImporter.ts"; | 62 | import { 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 | ); |
| 936 | 938 |
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 | }); |
| 59 | 59 | ||
| 60 | type TerminalOverrides = Pick<TerminalManager.TerminalManager["Service"], "open" | "write"> & | 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 | const makeTerminalManagerLayer = (overrides: TerminalOverrides) => | 63 | const 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 | ); |
| 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 | 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. The | 36 | * Resolves when the script's shell prints the completion sentinel. The |
| 37 | * exit code is null when the terminal exited or was closed before the | 37 | * 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 | ); |
| 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 | 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 | }); |
| 426 | 437 |
apps/server/src/terminal/Manager.test.ts+67| ... | @@ -1230,6 +1230,73 @@ it.layer( | ... | @@ -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 | 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>; |
| 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 | * 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 | ); |
| 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 | 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 |
| 90 | without deleting the conversation. **Un-settle thread** restores it to active work | 90 | without deleting the conversation. **Un-settle thread** restores it to active work |
| 91 | and prevents automatic settlement until new activity resumes the usual rules. | 91 | and prevents automatic settlement until new activity resumes the usual rules. |
| 92 | Manually settling an idle thread dismisses unanswered async questions without | 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 | By default, environments settle inactive threads after three days and settle | 97 | By default, environments settle inactive threads after three days and settle |
| 96 | threads whose pull request merged. A closed pull request can also settle an idle | 98 | threads whose pull request merged. A closed pull request can also settle an idle |