Skip to content
Merged
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
78 changes: 76 additions & 2 deletions apps/server/src/project/ProjectSetupScriptRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
});

type TerminalOverrides = Pick<TerminalManager.TerminalManager["Service"], "open" | "write"> &
Partial<Pick<TerminalManager.TerminalManager["Service"], "subscribe">>;
Partial<Pick<TerminalManager.TerminalManager["Service"], "subscribe" | "closeCompletedSetup">>;

const makeTerminalManagerLayer = (overrides: TerminalOverrides) =>
Layer.succeed(TerminalManager.TerminalManager, {
Expand All @@ -67,6 +67,7 @@ const makeTerminalManagerLayer = (overrides: TerminalOverrides) =>
clear: () => Effect.void,
restart: () => Effect.die(new Error("unused")),
close: () => Effect.void,
closeCompletedSetup: () => Effect.void,
subscribe: () => Effect.succeed(() => undefined),
subscribeMetadata: () => Effect.succeed(() => undefined),
...overrides,
Expand Down Expand Up @@ -262,6 +263,7 @@ describe("ProjectSetupScriptRunner", () => {
listener = null;
});
});
const closeCompletedSetup = vi.fn(() => Effect.void);
const project = makeProject([
{
id: "setup",
Expand Down Expand Up @@ -329,14 +331,86 @@ describe("ProjectSetupScriptRunner", () => {
]);
// The subscription is torn down once the sentinel arrives.
expect(listener).toBeNull();
// A failed run keeps its shell open for a look.
expect(closeCompletedSetup).not.toHaveBeenCalled();
}).pipe(
Effect.provide(testLayer(project, { open, write, subscribe })),
Effect.provide(testLayer(project, { open, write, subscribe, closeCompletedSetup })),
Effect.provideService(HostProcessPlatform, "linux"),
Effect.provideService(HostProcessEnvironment, { SHELL: "/bin/zsh" }),
);
},
);

it.effect("closes the idle setup shell after a clean exit", () => {
const open = vi.fn(() =>
Effect.succeed({
threadId: "thread-1",
terminalId: "setup-setup",
cwd: "/repo/worktrees/a",
worktreePath: "/repo/worktrees/a",
status: "running" as const,
pid: 123,
history: "",
exitCode: null,
exitSignal: null,
label: "setup-setup",
updatedAt: "2026-01-01T00:00:00.000Z",
}),
);
let written = "";
const write = vi.fn((input: { data: string }) =>
Effect.sync(() => void (written = input.data)),
);
let listener: ((event: TerminalEvent) => Effect.Effect<void>) | null = null;
const subscribe = vi.fn((next: (event: TerminalEvent) => Effect.Effect<void>) => {
listener = next;
return Effect.succeed(() => {
listener = null;
});
});
const closeCompletedSetup = vi.fn(() => Effect.void);
const project = makeProject([
{
id: "setup",
name: "Setup",
command: "bun install",
icon: "configure",
runOnWorktreeCreate: true,
},
]);

return Effect.gen(function* () {
const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
const result = yield* runner.runForThread({
threadId: "thread-1",
projectCwd: "/repo/project",
worktreePath: "/repo/worktrees/a",
observeCompletion: {},
});
if (result.status !== "started" || !result.completion) {
return yield* Effect.die("expected an observed setup run");
}
const sentinel = /__T3_SETUP_DONE___[0-9a-f]{32}:/.exec(written)?.[0];
yield* listener!({
threadId: "thread-1",
terminalId: "setup-setup",
type: "output",
data: `${sentinel}0\r\n`,
});

expect((yield* result.completion).exitCode).toBe(0);
expect(closeCompletedSetup).toHaveBeenCalledWith({
threadId: "thread-1",
terminalId: "setup-setup",
expectedInputCount: 1,
});
}).pipe(
Effect.provide(testLayer(project, { open, write, subscribe, closeCompletedSetup })),
Effect.provideService(HostProcessPlatform, "linux"),
Effect.provideService(HostProcessEnvironment, { SHELL: "/bin/zsh" }),
);
});

it.effect("unsubscribes from terminal output when the command cannot be written", () => {
const open = vi.fn(() =>
Effect.succeed({
Expand Down
17 changes: 16 additions & 1 deletion apps/server/src/project/ProjectSetupScriptRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface ProjectSetupScriptRunnerResultStarted {
* Resolves when the script's shell prints the completion sentinel. The
* exit code is null when the terminal exited or was closed before the
* sentinel arrived. Only present when `observeCompletion` was requested.
* An exit code of 0 closes the setup shell if it has nothing left running.
*/
readonly completion?: Effect.Effect<ProjectSetupScriptCompletion>;
}
Expand Down Expand Up @@ -412,6 +413,20 @@ export const make = Effect.gen(function* () {
Effect.tapError(() => Effect.sync(() => observed?.unsubscribe())),
);

// A clean run leaves only an idle prompt behind; its output stays in the
// terminal history. A failed run keeps its shell open for a look.
const completion = observed?.completion.pipe(
Effect.tap(({ exitCode }) =>
exitCode === 0
? terminalManager.closeCompletedSetup({
threadId: input.threadId,
terminalId,
expectedInputCount: 1,
})
: Effect.void,
),
);

return {
status: "started",
scriptId: script.id,
Expand All @@ -420,7 +435,7 @@ export const make = Effect.gen(function* () {
terminalId,
cwd,
async: script.async !== false,
...(observed ? { completion: observed.completion } : {}),
...(completion ? { completion } : {}),
} as const;
});

Expand Down
184 changes: 178 additions & 6 deletions apps/server/src/terminal/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1084,9 +1084,13 @@ it.layer(
const runCalls: Array<{ command: string; args: ReadonlyArray<string> }> = [];
// FakePtyAdapter assigns pids starting at 9000, so the two terminals
// opened below run as pids 9000 and 9001.
const psStdout = [" 100 9000 vim", " 101 100 git", " 200 9001 /usr/bin/python3"].join(
"\n",
);
const psStdout = [
" 9000 1 zsh",
" 9001 1 zsh",
" 100 9000 vim",
" 101 100 git",
" 200 9001 /usr/bin/python3",
].join("\n");
const processRunner: ProcessRunner.ProcessRunner["Service"] = {
run: (input) =>
Effect.sync(() => {
Expand Down Expand Up @@ -1153,7 +1157,7 @@ it.layer(
Effect.sync(() => {
if (failSnapshots) failedCalls += 1;
return {
stdout: failSnapshots ? "" : " 100 9000 vim",
stdout: failSnapshots ? "" : " 9000 1 zsh\n 100 9000 vim",
stderr: "",
code: ChildProcessSpawner.ExitCode(failSnapshots ? 1 : 0),
timedOut: false,
Expand Down Expand Up @@ -1214,8 +1218,8 @@ it.layer(
return {
stdout:
platform === "win32"
? "100|9000|vim.exe\n101|9001|ping.exe"
: "100 9000 vim\n101 9001 ping",
? "9000|1|pwsh.exe\n9001|1|pwsh.exe\n100|9000|vim.exe\n101|9001|ping.exe"
: "9000 1 zsh\n9001 1 zsh\n100 9000 vim\n101 9001 ping",
stderr: "",
code: ChildProcessSpawner.ExitCode(0),
timedOut: false,
Expand All @@ -1237,6 +1241,8 @@ it.layer(
reason: "test sidecar unavailable",
});
return [
{ pid: 9000, ppid: 1, name: "zsh" },
{ pid: 9001, ppid: 1, name: "zsh" },
{ pid: 100, ppid: 9000, name: "vim" },
{ pid: 101, ppid: 9001, name: "ping" },
];
Expand Down Expand Up @@ -1273,6 +1279,172 @@ it.layer(
}).pipe(Effect.provide(TestClock.layer())),
);

it.effect("closes a completed setup shell but keeps shells with child processes", () =>
Effect.gen(function* () {
// FakePtyAdapter assigns pids from 9000 in open order.
const { manager, ptyAdapter } = yield* createManager(5, {
shellResolver: () => "/bin/zsh",
processTable: Effect.succeed([
{ pid: 9000, ppid: 1, name: "zsh" },
{ pid: 9001, ppid: 1, name: "zsh" },
// A same-name child may be running a builtin, so keep it.
{ pid: 100, ppid: 9001, name: "zsh" },
{ pid: 9002, ppid: 1, name: "zsh" },
{ pid: 300, ppid: 9002, name: "node" },
{ pid: 9003, ppid: 1, name: "zsh" },
]),
}).pipe(Effect.provide(withHostPlatform("linux")));
yield* manager.open(openInput({ terminalId: "idle" }));
yield* manager.open(openInput({ terminalId: "builtin-subshell" }));
yield* manager.open(openInput({ terminalId: "dev-server" }));
yield* manager.open(openInput({ threadId: "thread-2" }));
yield* manager.write({ threadId: "thread-1", terminalId: "idle", data: "setup\r" });

yield* manager.closeCompletedSetup({
threadId: "thread-1",
terminalId: "idle",
expectedInputCount: 1,
});
yield* manager.write({
threadId: "thread-1",
terminalId: "builtin-subshell",
data: "setup\r",
});
yield* manager.closeCompletedSetup({
threadId: "thread-1",
terminalId: "builtin-subshell",
expectedInputCount: 1,
});
yield* manager.write({ threadId: "thread-1", terminalId: "dev-server", data: "setup\r" });
yield* manager.closeCompletedSetup({
threadId: "thread-1",
terminalId: "dev-server",
expectedInputCount: 1,
});

expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([
true,
false,
false,
false,
]);
}),
);

it.effect("keeps a command that replaced its shell at the PTY PID", () =>
Effect.gen(function* () {
const { manager, ptyAdapter } = yield* createManager(5, {
shellResolver: () => "/bin/zsh",
processTable: Effect.succeed([
{ pid: 9000, ppid: 1, name: "sleep" },
// A missing PID cannot prove that a shell is idle.
{ pid: 9002, ppid: 1, name: "zsh" },
{ pid: 9003, ppid: 1, name: "zsh" },
]),
}).pipe(Effect.provide(withHostPlatform("linux")));
yield* manager.open(openInput({ terminalId: "exec-command" }));
yield* manager.open(openInput({ terminalId: "unknown-process" }));
yield* manager.open(openInput({ terminalId: "idle" }));
yield* manager.open(openInput({ terminalId: "reused" }));
for (const terminalId of ["exec-command", "unknown-process", "idle"]) {
yield* manager.write({ threadId: "thread-1", terminalId, data: "setup\r" });
yield* manager.closeCompletedSetup({
threadId: "thread-1",
terminalId,
expectedInputCount: 1,
});
}
yield* manager.write({ threadId: "thread-1", terminalId: "reused", data: "setup\r" });
yield* manager.write({ threadId: "thread-1", terminalId: "reused", data: "read\r" });
yield* manager.closeCompletedSetup({
threadId: "thread-1",
terminalId: "reused",
expectedInputCount: 1,
});

expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([
false,
false,
true,
false,
]);
}),
);

it.effect("keeps a terminal that emits output during completion inspection", () =>
Effect.gen(function* () {
const ptyAdapter = new FakePtyAdapter();
let duringCheck: (pid: number) => Effect.Effect<void> = () => Effect.void;
const { manager, getEvents } = yield* createManager(5, {
ptyAdapter,
subprocessPollIntervalMs: 60_000,
subprocessInspector: (pid) =>
duringCheck(pid).pipe(
Effect.as({ hasRunningSubprocess: false, childCommand: null, processIds: [] }),
),
});
yield* manager.open(openInput({ terminalId: "echoed" }));
yield* manager.write({ threadId: "thread-1", terminalId: "echoed", data: "setup\r" });
const [echoed] = ptyAdapter.processes;
duringCheck = () =>
Effect.gen(function* () {
echoed!.emitData("make build\r\n");
yield* waitFor(
Effect.map(getEvents, (events) => events.some((event) => event.type === "output")),
);
}).pipe(Effect.orDie);

yield* manager.closeCompletedSetup({
threadId: "thread-1",
terminalId: "echoed",
expectedInputCount: 1,
});

expect(echoed?.killed).toBe(false);
}),
);

it.effect("rejects input queued while a completed setup shell closes", () =>
Effect.gen(function* () {
const inspectionStarted = yield* Deferred.make<void>();
const releaseInspection = yield* Deferred.make<void>();
const { manager, ptyAdapter } = yield* createManager(5, {
subprocessPollIntervalMs: 60_000,
subprocessInspector: () =>
Deferred.succeed(inspectionStarted, undefined).pipe(
Effect.andThen(Deferred.await(releaseInspection)),
Effect.as({ hasRunningSubprocess: false, childCommand: null, processIds: [] }),
),
});
yield* manager.open(openInput({ terminalId: "setup" }));
yield* manager.write({ threadId: "thread-1", terminalId: "setup", data: "setup\r" });
const closeFiber = yield* manager
.closeCompletedSetup({ threadId: "thread-1", terminalId: "setup", expectedInputCount: 1 })
.pipe(Effect.forkScoped);
yield* Deferred.await(inspectionStarted);
const writeStarted = yield* Deferred.make<void>();
const writeFiber = yield* Deferred.succeed(writeStarted, undefined).pipe(
Effect.andThen(
manager.write({ threadId: "thread-1", terminalId: "setup", data: "read\r" }),
),
Effect.forkScoped,
);
yield* Deferred.await(writeStarted);
yield* Effect.yieldNow;
expect(ptyAdapter.processes[0]?.writes).toEqual(["setup\r"]);
yield* Deferred.succeed(releaseInspection, undefined);
yield* Fiber.join(closeFiber);
const writeError = yield* Effect.flip(Fiber.join(writeFiber));
expect(writeError).toMatchObject({
_tag: "TerminalSessionLookupError",
threadId: "thread-1",
terminalId: "setup",
});
expect(ptyAdapter.processes[0]?.writes).toEqual(["setup\r"]);
expect(ptyAdapter.processes[0]?.killed).toBe(true);
}),
);

it.effect("caps persisted history to configured line limit", () =>
Effect.gen(function* () {
const { manager, ptyAdapter } = yield* createManager(3);
Expand Down
Loading
Loading