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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const PAGE_PROJECT_KEYS: Record<SettingsPage, readonly ProjectScopedServerSettin
"enableAgentBrowserAccess",
"sidebarAutoSettleOnMerge",
"sidebarAutoSettleAfterDays",
"closeRunningTerminalsOnSettle",
],
"source-control": ["defaultAutoPull", "removeAgentCreditsOnMerge"],
worktrees: [
Expand Down
10 changes: 10 additions & 0 deletions apps/mobile/src/features/settings/ThreadSettingsSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,16 @@ export function AutoSettleSettingsRows(props: {
</View>
) : null}
</SettingsSection>
<SettingsSection title="On settle">
<SettingsSwitchRow
icon="terminal"
label="Close running terminals on settle"
subtitle="Stop all of the thread's terminals, including dev servers, when it settles manually or automatically. Off closes idle terminals and actions configured to stop on settle."
value={uniformMobileSetting(displayTargets, "closeRunningTerminalsOnSettle")}
disabled={disabledFor("closeRunningTerminalsOnSettle")}
onValueChange={(value) => props.write({ closeRunningTerminalsOnSettle: value })}
/>
</SettingsSection>
{!props.pending && mismatches.length > 0 ? (
<SettingsSection title="Across environments">
<View className="gap-3 p-4">
Expand Down
46 changes: 43 additions & 3 deletions apps/server/src/orchestration-v2/ThreadSettlementService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
const domainEvents = yield* PubSub.unbounded<OrchestrationV2DomainEvent>();
const observedEvents = yield* Queue.unbounded<EventId>();
const closedIdle = yield* Queue.unbounded<{ readonly threadId: string }>();
const closedAll = yield* Queue.unbounded<{ readonly threadId: string }>();
const scriptRuns =
yield* Queue.unbounded<ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput>();

Expand Down Expand Up @@ -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)),
}),
Expand Down Expand Up @@ -748,6 +750,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
summaryRecovery,
invalidatedCwds,
closedIdle,
closedAll,
scriptRuns,
observedEvents,
publishEvent: (event: OrchestrationV2DomainEvent) => PubSub.publish(domainEvents, event),
Expand Down Expand Up @@ -1836,14 +1839,29 @@ 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");
const missing = appThread("removed-worktree-thread", "settled", "/worktrees/removed");
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"],
});
Expand All @@ -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));
}),
),
Expand Down Expand Up @@ -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"],
});

Expand All @@ -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));
}),
),
Expand All @@ -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: () =>
Expand All @@ -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));
}),
),
Expand Down
22 changes: 17 additions & 5 deletions apps/server/src/orchestration-v2/ThreadSettlementService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,8 @@ export const make = Effect.gen(function* () {
const shellClosedThreads = new Set<ThreadId>();
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<ThreadId, number>();
const settleActionRunAt = new Map<ThreadId, number>();

const sweep = Effect.fn("ThreadSettlementServiceV2.sweep")(function* (
Expand Down Expand Up @@ -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 (
Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/terminal/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>();
const releaseOutput = yield* Deferred.make<void>();
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);
}),
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/terminal/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1931,6 +1931,25 @@ export function ThreadsSettingsPanel() {
</SettingsSection>
) : null}

<SettingsSection id="settle" title="On settle">
<SettingsRow
serverScoped
settingKeys={["closeRunningTerminalsOnSettle"]}
{...searchableSetting("close-running-terminals-on-settle")}
description="Stop all of the thread's terminals, including dev servers, when it settles manually or automatically. Off closes idle terminals and actions configured to stop on settle."
control={
<ScopedSwitch
settingKeys={["closeRunningTerminalsOnSettle"]}
checked={settings.closeRunningTerminalsOnSettle}
onCheckedChange={(checked) =>
updateSettings({ closeRunningTerminalsOnSettle: checked })
}
aria-label="Close running terminals on settle"
/>
}
/>
</SettingsSection>

<SettingsSection id="text-generation" title="Text generation">
<SettingsRow
serverScoped
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,13 @@ export const SETTINGS_SEARCH_ITEMS = [
requiresThreadAutoSettlement: true,
scope: "project-defaults",
},
{
id: "close-running-terminals-on-settle",
title: "Close running terminals on settle",
to: "/settings/threads",
searchTerms: ["kill stop dev server shell process cleanup settlement"],
scope: "project-defaults",
},
{
id: "thread-notifications",
title: "Thread notifications",
Expand Down
14 changes: 9 additions & 5 deletions docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,11 +167,15 @@ and prevents automatic settlement until new activity resumes the usual rules.
Manually settling an idle thread dismisses unanswered async questions without
sending an answer or restarting the agent. Settling also closes the thread's
terminals that wait at an idle prompt, and keeps their output. A terminal that
runs a command, such as a dev server, stays open unless its action has **Stop this action
when the thread settles** enabled in Settings > 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
Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1176,6 +1176,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [
"sidebarAutoSettleOnMerge",
"sidebarAutoSettleAfterDays",
"sidebarAutoSettleKeepPullRequests",
"closeRunningTerminalsOnSettle",
"continueThreadsAfterServerUpdate",
"responseStreamingMode",
"speechProjectCustomWords",
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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),
Expand Down
Loading