diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 793e3ae75412c..27d8ba4913de3 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -61,6 +61,7 @@ const PAGE_PROJECT_KEYS: Record ) : null} + + props.write({ closeRunningTerminalsOnSettle: value })} + /> + {!props.pending && mismatches.length > 0 ? ( diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index cca6ee08e7116..7f72d00309b19 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -595,6 +595,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: const domainEvents = yield* PubSub.unbounded(); const observedEvents = yield* Queue.unbounded(); const closedIdle = yield* Queue.unbounded<{ readonly threadId: string }>(); + const closedAll = yield* Queue.unbounded<{ readonly threadId: string }>(); const scriptRuns = yield* Queue.unbounded(); @@ -695,6 +696,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: dispatch, }), Layer.mock(TerminalManager.TerminalManager)({ + close: (input) => Queue.offer(closedAll, input).pipe(Effect.asVoid), closeOnSettle: (input) => Queue.offer(closedIdle, input).pipe(Effect.andThen(options.onCloseIdle?.() ?? Effect.void)), }), @@ -748,6 +750,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: summaryRecovery, invalidatedCwds, closedIdle, + closedAll, scriptRuns, observedEvents, publishEvent: (event: OrchestrationV2DomainEvent) => PubSub.publish(domainEvents, event), @@ -1836,7 +1839,12 @@ describe("ThreadSettlementServiceV2 terminals", () => { ), ); - it.effect("runs the settle script in the thread's own worktree", () => + it.effect.each([ + { enabled: false, override: undefined, closeAll: false }, + { enabled: true, override: undefined, closeAll: true }, + { enabled: false, override: true, closeAll: true }, + { enabled: true, override: false, closeAll: false }, + ])("cleans up terminals before the settle script: %j", ({ enabled, override, closeAll }) => Effect.scoped( Effect.gen(function* () { const shared = appThread("shared-checkout-thread", "settled"); @@ -1844,6 +1852,16 @@ describe("ThreadSettlementServiceV2 terminals", () => { const worktree = appThread("worktree-thread", "settled", "/worktrees/thread"); const fixture = yield* makeHarness({ snapshot: makeSnapshot([]), + settings: { + ...DEFAULT_SERVER_SETTINGS, + closeRunningTerminalsOnSettle: enabled, + projectSettingsOverrides: { + [ProjectId.make("other-project")]: { closeRunningTerminalsOnSettle: !enabled }, + ...(override === undefined + ? {} + : { [PROJECT_ID]: { closeRunningTerminalsOnSettle: override } }), + }, + }, currentThreads: [shared, missing, worktree], existingWorktreePaths: ["/worktrees/thread"], }); @@ -1858,6 +1876,15 @@ describe("ThreadSettlementServiceV2 terminals", () => { assert.strictEqual(run.threadId, worktree.id); assert.strictEqual(run.worktreePath, "/worktrees/thread"); assert.strictEqual(run.trigger, "settle"); + const expectedClosed = [shared, missing, worktree].map((thread) => ({ + threadId: thread.id, + })); + assert.strictEqual(yield* Queue.size(fixture.closedAll), closeAll ? 3 : 0); + assert.strictEqual(yield* Queue.size(fixture.closedIdle), closeAll ? 0 : 3); + assert.deepStrictEqual( + [...(yield* Queue.takeAll(closeAll ? fixture.closedAll : fixture.closedIdle))], + expectedClosed, + ); }).pipe(Effect.provide(fixture.layer)); }), ), @@ -1925,14 +1952,16 @@ describe("ThreadSettlementServiceV2 terminals", () => { ), ); - it.effect("runs the settle script once when a settlement is re-emitted", () => + it.effect.each([false, true])("cleans up once per settlement with close all %s", (closeAll) => Effect.scoped( Effect.gen(function* () { const repeated = appThread("repeated-thread", "settled", "/worktrees/repeated"); const marker = appThread("marker-thread", "settled", "/worktrees/marker"); + const currentThreads = [repeated, marker]; const fixture = yield* makeHarness({ snapshot: makeSnapshot([]), - currentThreads: [repeated, marker], + settings: { ...DEFAULT_SERVER_SETTINGS, closeRunningTerminalsOnSettle: closeAll }, + currentThreads, existingWorktreePaths: ["/worktrees/repeated", "/worktrees/marker"], }); @@ -1944,6 +1973,15 @@ describe("ThreadSettlementServiceV2 terminals", () => { yield* fixture.publishEvent(settledEvent(marker)); assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, repeated.id); assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, marker.id); + const closed = closeAll ? fixture.closedAll : fixture.closedIdle; + assert.strictEqual(yield* Queue.size(closed), 2); + currentThreads[0] = { + ...repeated, + settledAt: DateTime.add(repeated.settledAt!, { seconds: 1 }), + }; + yield* fixture.publishEvent(settledEvent(currentThreads[0])); + assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, repeated.id); + assert.strictEqual(yield* Queue.size(closed), 3); }).pipe(Effect.provide(fixture.layer)); }), ), @@ -1956,6 +1994,7 @@ describe("ThreadSettlementServiceV2 terminals", () => { const starts = yield* Ref.make(0); const fixture = yield* makeHarness({ snapshot: makeSnapshot([]), + settings: { ...DEFAULT_SERVER_SETTINGS, closeRunningTerminalsOnSettle: true }, currentThreads: [thread], existingWorktreePaths: ["/worktrees/failed-start"], onScriptRun: () => @@ -1973,6 +2012,7 @@ describe("ThreadSettlementServiceV2 terminals", () => { yield* fixture.publishEvent(settledEvent(thread)); assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, thread.id); assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, thread.id); + assert.strictEqual(yield* Queue.size(fixture.closedAll), 1); }).pipe(Effect.provide(fixture.layer)); }), ), diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 05a13cd8a399f..21e0b2f509f26 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -312,7 +312,8 @@ export const make = Effect.gen(function* () { const shellClosedThreads = new Set(); const terminals = yield* TerminalManager.TerminalManager; const projectScripts = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; - // Repeated thread.settled retains settledAt; run the action only once per settlement. + // Repeated thread.settled retains settledAt; cleanup must not stop the settle script. + const settleTerminalsClosedAt = new Map(); const settleActionRunAt = new Map(); const sweep = Effect.fn("ThreadSettlementServiceV2.sweep")(function* ( @@ -627,14 +628,25 @@ export const make = Effect.gen(function* () { function* (threadId: ThreadId) { const settled = yield* projections.getThread(threadId); if (settled.settledOverride !== "settled") return; - yield* terminals.closeOnSettle({ threadId }); + const settledAtMs = toMillis(settled.settledAt); + if (settledAtMs === null) return; + if (settleTerminalsClosedAt.get(threadId) !== settledAtMs) { + const settings = resolveProjectSettings( + yield* settingsService.getSettings, + settled.projectId, + ).settings; + yield* settings.closeRunningTerminalsOnSettle + ? terminals.close({ threadId }) + : terminals.closeOnSettle({ threadId }); + settleTerminalsClosedAt.set(threadId, settledAtMs); + } const worktreePath = settled.worktreePath; if (worktreePath === null || !(yield* fileSystem.exists(worktreePath))) return; // Recheck after closing and worktree I/O; re-engaged threads must not be cleaned up. const thread = yield* projections.getThread(threadId); - if (thread.settledOverride !== "settled") return; - const settledAtMs = toMillis(thread.settledAt); - if (settledAtMs === null || settleActionRunAt.get(threadId) === settledAtMs) return; + if (thread.settledOverride !== "settled" || toMillis(thread.settledAt) !== settledAtMs) + return; + if (settleActionRunAt.get(threadId) === settledAtMs) return; // The parent's worktree belongs to the parent; shared-worktree children // skip duplicate cleanup there. if ( diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 5c65b9d973baa..677f57b2d020e 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1137,11 +1137,23 @@ it.layer( // Mojibake like "â€\u009d" (for ”) carries C1 code points that libghostty prints as text. const before = "café\t界🙂\u0007\u009c\u0085 â€\u009d Ð\u0090 \u009e\u009f\u009b6n"; const after = "e\u0301\r\n"; + const outputReached = yield* Deferred.make(); + const releaseOutput = yield* Deferred.make(); + const unsubscribe = yield* manager.subscribe((event) => + event.type === "output" && event.data === before + ? Deferred.succeed(outputReached, undefined).pipe( + Effect.andThen(Deferred.await(releaseOutput)), + ) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); process.emitData(before); + yield* Deferred.await(outputReached); process.emitData("\u001b[5"); process.emitData(`n${after}`); yield* manager.close({ threadId: "thread-1" }); + yield* Deferred.succeed(releaseOutput, undefined); expect((yield* manager.open(openInput())).history).toBe(before + after); }), diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 625b678446e17..a3b29a194d6ff 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -2331,6 +2331,17 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const updatedAt = yield* nowIso; yield* modifyManagerState((state) => { cleanupProcessHandles(session); + // Closing must retain output queued behind a slow event listener. + while (session.pendingProcessEventIndex < session.pendingProcessEvents.length) { + const event = session.pendingProcessEvents[session.pendingProcessEventIndex++]; + if (event?.type !== "output") break; + const sanitized = sanitizeTerminalHistoryChunk( + session.pendingHistoryControlSequence, + event.data, + ); + session.pendingHistoryControlSequence = sanitized.pendingControlSequence; + session.history.append(sanitized.visibleText); + } session.process = null; session.pid = null; session.hasRunningSubprocess = false; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d46c1875876f4..ef7a02a2b6145 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1931,6 +1931,25 @@ export function ThreadsSettingsPanel() { ) : null} + + + updateSettings({ closeRunningTerminalsOnSettle: checked }) + } + aria-label="Close running terminals on settle" + /> + } + /> + + Projects > Actions. You can also set -`"stopOnSettle": true` on a `t3.json` action and import it there. Start the command through -that action so T3 can stop its terminal and child processes. Commands started through an -agent's own shell tools are managed by the provider. +runs a command, such as a dev server, stays open by default. Enable **Close running +terminals on settle** in **Settings → Threads** to stop those too, on manual or +automatic settlement. Use the project filter to override this for a project. + +An action can also stop its terminal when **Stop this action when the thread settles** +is enabled in Settings > Projects > Actions, or `"stopOnSettle": true` is set on its +`t3.json` action. Start the command through that action so T3 can stop its terminal +and child processes. Commands started through an agent's own shell tools are managed +by the provider. To reclaim disk space from settled work, turn on **Run in the thread's worktree when the thread settles** for one of the project's actions, or set `"runOnSettle": true` on a diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 5622b7d32a86f..e479126be59e4 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1176,6 +1176,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "sidebarAutoSettleOnMerge", "sidebarAutoSettleAfterDays", "sidebarAutoSettleKeepPullRequests", + "closeRunningTerminalsOnSettle", "continueThreadsAfterServerUpdate", "responseStreamingMode", "speechProjectCustomWords", @@ -1214,6 +1215,7 @@ export const ProjectSettingsOverrides = Schema.Struct({ sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarAutoSettleKeepPullRequests: Schema.optionalKey(Schema.Boolean), + closeRunningTerminalsOnSettle: Schema.optionalKey(Schema.Boolean), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), speechProjectCustomWords: Schema.optionalKey(SpeechCustomWords), @@ -1458,6 +1460,9 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(false)), ), sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + closeRunningTerminalsOnSettle: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), backgroundActivity: BackgroundActivitySettings, automaticGitFetchInterval: Schema.DurationFromMillis.pipe( @@ -1864,6 +1869,7 @@ export const ServerSettingsPatch = Schema.Struct({ idleCompaction: Schema.optionalKey(IdleCompactionMode), sidebarAutoSettleKeepPullRequests: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), + closeRunningTerminalsOnSettle: Schema.optionalKey(Schema.Boolean), autoResumeLimitedThreads: Schema.optionalKey(Schema.Boolean), snoozeLimitedThreads: Schema.optionalKey(Schema.Boolean), snoozePresets: Schema.optionalKey(SnoozePresetSettings),