From fc571cd75e0c1648102aa02ebdf0ab47a96cb886 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 00:23:38 -0700 Subject: [PATCH 1/4] fix(server): worktree setup waits for the shell, so startup prompts can't eat it A prompt in the user's shell rc (for example fnm offering to install a missing Node version) read the typed setup command as its answer. The shell then got half a command, the completion marker never printed, and setup spun forever. Wait for the shell's line editor (bracketed paste on) before typing, or for a quiet shell with no child process when there is no line editor. Co-Authored-By: Claude Opus 5.5 --- .../project/ProjectSetupScriptRunner.test.ts | 106 ++++++++++++++++++ .../src/project/ProjectSetupScriptRunner.ts | 94 +++++++++++++--- 2 files changed, 182 insertions(+), 18 deletions(-) create mode 100644 apps/server/src/project/ProjectSetupScriptRunner.test.ts diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts new file mode 100644 index 0000000000000..3fea0960599ae --- /dev/null +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -0,0 +1,106 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import { describe, expect, it } from "@effect/vitest"; +import { ProjectId, type TerminalEvent } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import { TestClock } from "effect/testing"; + +import * as ServerSettings from "../serverSettings.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; +import * as ProjectService from "./ProjectService.ts"; +import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts"; + +const threadId = "thread-1"; +const terminalId = "setup-setup"; + +type TerminalCall = + | { readonly type: "subscribe"; readonly listener: (event: TerminalEvent) => Effect.Effect } + | { readonly type: "write"; readonly data: string }; + +const setup = Effect.gen(function* () { + const calls = yield* Queue.unbounded(); + const terminals = Layer.mock(TerminalManager.TerminalManager)({ + open: (input) => + Effect.succeed({ + threadId: input.threadId, + terminalId: input.terminalId ?? terminalId, + cwd: input.cwd, + worktreePath: null, + status: "running", + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "zsh", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + subscribe: (listener) => + Queue.offer(calls, { type: "subscribe", listener }).pipe(Effect.as(() => {})), + write: (input) => Queue.offer(calls, { type: "write", data: input.data }).pipe(Effect.asVoid), + }); + const runner = yield* ProjectSetupScriptRunner.make.pipe( + Effect.provide( + Layer.mergeAll( + terminals, + Layer.mock(ProjectService.ProjectService)({}), + ServerSettings.layerTest(), + NodeCrypto.layer, + ), + ), + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HostProcess.Environment, { SHELL: "/bin/zsh" }), + ); + const run = yield* runner + .runForThread({ + threadId, + worktreePath: "/repo/.t3/worktrees/feature", + project: { + id: ProjectId.make("project-1"), + workspaceRoot: "/repo", + scripts: [ + { + id: "setup", + name: "Setup", + command: "vp i", + icon: "configure", + runOnWorktreeCreate: true, + }, + ], + }, + }) + .pipe(Effect.forkChild); + const first = yield* Queue.take(calls); + expect(first.type).toBe("subscribe"); + const emit = first.type === "subscribe" ? first.listener : () => Effect.void; + const event = { threadId, terminalId, sequence: 1, createdAt: "2026-01-01T00:00:00.000Z" }; + return { run, calls, emit, event }; +}); + +describe("ProjectSetupScriptRunner", () => { + it.effect("waits for the line editor so a startup prompt cannot read the command", () => + Effect.gen(function* () { + const { run, calls, emit, event } = yield* setup; + yield* emit({ ...event, type: "output", data: "Do you want to install it? [y/N]: " }); + yield* emit({ ...event, type: "activity", hasRunningSubprocess: true, label: "fnm" }); + yield* TestClock.adjust("1 minute"); + expect(yield* Queue.size(calls)).toBe(0); + + yield* emit({ ...event, type: "activity", hasRunningSubprocess: false, label: "zsh" }); + yield* emit({ ...event, type: "output", data: "\r\n% \x1b[?2004h" }); + yield* Fiber.join(run); + expect(yield* Queue.take(calls)).toEqual({ type: "write", data: " vp i\r" }); + }), + ); + + it.effect("writes to a quiet shell that has no line editor", () => + Effect.gen(function* () { + const { run, calls } = yield* setup; + yield* TestClock.adjust("2 seconds"); + yield* Fiber.join(run); + expect(yield* Queue.take(calls)).toEqual({ type: "write", data: " vp i\r" }); + }), + ); +}); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index da8bb9a6c56a9..b91f29d228c2e 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -139,6 +139,17 @@ function stripTerminalControl(text: string): string { ); } +// Line editors (zsh, fish, bash 5.1+) enable bracketed paste when they start reading a command. +const BRACKETED_PASTE_ON = "\x1b[?2004h"; +const BRACKETED_PASTE_OFF = "\x1b[?2004l"; +const SHELL_QUIET_FALLBACK = "2 seconds"; + +function lineEditorReading(text: string, previous: boolean): boolean { + const on = text.lastIndexOf(BRACKETED_PASTE_ON); + const off = text.lastIndexOf(BRACKETED_PASTE_OFF); + return on === off ? previous : on > off; +} + type CompletionShell = "posix" | "fish" | "powershell"; function resolveCompletionShell( @@ -267,6 +278,47 @@ export const make = Effect.gen(function* () { }, ); + // Input typed during shell startup can be read by an rc-file prompt (fnm, nvm) instead of the + // shell, which loses the command. Wait for the line editor, or for a quiet shell without one. + const awaitShellReady = Effect.fn("ProjectSetupScriptRunner.awaitShellReady")(function* (input: { + readonly threadId: string; + readonly terminalId: string; + readonly history: string; + }) { + if (lineEditorReading(input.history, false)) return; + const ready = yield* Deferred.make(); + let reading = false; + let carry = ""; + let busy = false; + let activity = 0; + const unsubscribe = yield* terminalManager.subscribe((event) => { + if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { + return Effect.void; + } + activity += 1; + if (event.type === "activity") busy = event.hasRunningSubprocess; + if (event.type === "output") { + const text = carry + event.data; + carry = text.slice(-(BRACKETED_PASTE_ON.length - 1)); + reading = lineEditorReading(text, reading); + } + const done = reading || event.type === "exited" || event.type === "closed"; + return done ? Deferred.succeed(ready, undefined).pipe(Effect.asVoid) : Effect.void; + }); + // A startup prompt runs as a child process, so a busy shell keeps waiting for the user. + const quiet = Effect.gen(function* () { + while (true) { + const seen = activity; + yield* Effect.sleep(SHELL_QUIET_FALLBACK); + if (activity === seen && !busy) return; + } + }); + yield* Deferred.await(ready).pipe( + Effect.raceFirst(quiet), + Effect.ensuring(Effect.sync(() => unsubscribe())), + ); + }); + const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn( "ProjectSetupScriptRunner.runForThread", )(function* (input) { @@ -361,7 +413,7 @@ export const make = Effect.gen(function* () { ) : script.command; - yield* terminalManager + const terminal = yield* terminalManager .open({ threadId: input.threadId, terminalId, @@ -392,23 +444,29 @@ export const make = Effect.gen(function* () { }) : undefined; - yield* terminalManager - .write({ - threadId: input.threadId, - terminalId, - data: projectScriptTerminalInput(commandLine), - }) - .pipe( - Effect.mapError( - (cause) => - new ProjectSetupScriptOperationError({ - ...errorContext, - operation: "writeCommand", - cause, - }), - ), - Effect.tapError(() => Effect.sync(() => observed?.unsubscribe())), - ); + // PowerShell has no line-editor signal; keep its immediate write. + yield* ( + completionShell === "powershell" + ? Effect.void + : awaitShellReady({ threadId: input.threadId, terminalId, history: terminal.history }) + ).pipe( + Effect.andThen( + terminalManager.write({ + threadId: input.threadId, + terminalId, + data: projectScriptTerminalInput(commandLine), + }), + ), + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "writeCommand", + cause, + }), + ), + Effect.onError(() => Effect.sync(() => observed?.unsubscribe())), + ); const completion = observed?.completion.pipe( Effect.tap(({ exitCode }) => From b4023dd113d509f33ebc72913e131b3eaeb3ae90 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 02:18:22 -0700 Subject: [PATCH 2/4] fix(server): show the setup terminal while a startup prompt waits Review follow-ups: - Return once the terminal opens and type in the background, so the setup card offers Open terminal and the agent start is not delayed. - Show a waiting prompt line that has no newline in the setup output. - Subscribe before opening so early subprocess activity is not lost. - Do not type into a shell that exited during startup. Co-Authored-By: Claude Opus 5.5 --- .../project/ProjectSetupScriptRunner.test.ts | 96 +++++----- .../src/project/ProjectSetupScriptRunner.ts | 176 ++++++++++-------- 2 files changed, 157 insertions(+), 115 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 3fea0960599ae..c2235cd62436d 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "@effect/vitest"; import { ProjectId, type TerminalEvent } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; import { TestClock } from "effect/testing"; @@ -15,13 +14,12 @@ import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts"; const threadId = "thread-1"; const terminalId = "setup-setup"; - -type TerminalCall = - | { readonly type: "subscribe"; readonly listener: (event: TerminalEvent) => Effect.Effect } - | { readonly type: "write"; readonly data: string }; +const event = { threadId, terminalId, sequence: 1, createdAt: "2026-01-01T00:00:00.000Z" }; const setup = Effect.gen(function* () { - const calls = yield* Queue.unbounded(); + const listeners = new Set<(event: TerminalEvent) => Effect.Effect>(); + const writes = yield* Queue.unbounded(); + const outputLines = yield* Queue.unbounded(); const terminals = Layer.mock(TerminalManager.TerminalManager)({ open: (input) => Effect.succeed({ @@ -38,8 +36,12 @@ const setup = Effect.gen(function* () { updatedAt: "2026-01-01T00:00:00.000Z", }), subscribe: (listener) => - Queue.offer(calls, { type: "subscribe", listener }).pipe(Effect.as(() => {})), - write: (input) => Queue.offer(calls, { type: "write", data: input.data }).pipe(Effect.asVoid), + Effect.sync(() => { + listeners.add(listener); + return () => listeners.delete(listener); + }), + write: (input) => Queue.offer(writes, input.data).pipe(Effect.asVoid), + closeIdle: () => Effect.void, }); const runner = yield* ProjectSetupScriptRunner.make.pipe( Effect.provide( @@ -53,54 +55,64 @@ const setup = Effect.gen(function* () { Effect.provideService(HostProcess.Platform, "darwin"), Effect.provideService(HostProcess.Environment, { SHELL: "/bin/zsh" }), ); - const run = yield* runner - .runForThread({ - threadId, - worktreePath: "/repo/.t3/worktrees/feature", - project: { - id: ProjectId.make("project-1"), - workspaceRoot: "/repo", - scripts: [ - { - id: "setup", - name: "Setup", - command: "vp i", - icon: "configure", - runOnWorktreeCreate: true, - }, - ], - }, - }) - .pipe(Effect.forkChild); - const first = yield* Queue.take(calls); - expect(first.type).toBe("subscribe"); - const emit = first.type === "subscribe" ? first.listener : () => Effect.void; - const event = { threadId, terminalId, sequence: 1, createdAt: "2026-01-01T00:00:00.000Z" }; - return { run, calls, emit, event }; + const result = yield* runner.runForThread({ + threadId, + worktreePath: "/repo/.t3/worktrees/feature", + observeCompletion: { onOutputLine: (line) => Queue.offer(outputLines, line) }, + project: { + id: ProjectId.make("project-1"), + workspaceRoot: "/repo", + scripts: [ + { + id: "setup", + name: "Setup", + command: "vp i", + icon: "configure", + runOnWorktreeCreate: true, + }, + ], + }, + }); + const completion = result.status === "started" ? result.completion : undefined; + if (!completion) return yield* Effect.die("setup did not start an observed run"); + const emit = (terminalEvent: TerminalEvent) => + Effect.forEach([...listeners], (listener) => listener(terminalEvent), { discard: true }); + return { result, completion, writes, outputLines, emit }; }); describe("ProjectSetupScriptRunner", () => { - it.effect("waits for the line editor so a startup prompt cannot read the command", () => + it.effect("shows a startup prompt and types only once the line editor starts", () => Effect.gen(function* () { - const { run, calls, emit, event } = yield* setup; - yield* emit({ ...event, type: "output", data: "Do you want to install it? [y/N]: " }); + const { result, writes, outputLines, emit } = yield* setup; + expect(result).toMatchObject({ status: "started", terminalId }); + + yield* emit({ ...event, type: "output", data: "Missing Node.\r\nInstall it? [y/N]: " }); yield* emit({ ...event, type: "activity", hasRunningSubprocess: true, label: "fnm" }); yield* TestClock.adjust("1 minute"); - expect(yield* Queue.size(calls)).toBe(0); + expect(yield* Queue.size(writes)).toBe(0); + expect(yield* Queue.takeAll(outputLines)).toEqual(["Missing Node.", "Install it? [y/N]:"]); yield* emit({ ...event, type: "activity", hasRunningSubprocess: false, label: "zsh" }); yield* emit({ ...event, type: "output", data: "\r\n% \x1b[?2004h" }); - yield* Fiber.join(run); - expect(yield* Queue.take(calls)).toEqual({ type: "write", data: " vp i\r" }); + expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/); }), ); - it.effect("writes to a quiet shell that has no line editor", () => + it.effect("types into a quiet shell that has no line editor", () => Effect.gen(function* () { - const { run, calls } = yield* setup; + const { writes } = yield* setup; yield* TestClock.adjust("2 seconds"); - yield* Fiber.join(run); - expect(yield* Queue.take(calls)).toEqual({ type: "write", data: " vp i\r" }); + expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/); + }), + ); + + it.effect("does not type into a shell that exits during startup", () => + Effect.gen(function* () { + const { completion, writes, emit } = yield* setup; + yield* emit({ ...event, type: "exited", exitCode: 1, exitSignal: null }); + expect(yield* completion).toMatchObject({ exitCode: null }); + yield* TestClock.adjust("1 minute"); + expect(yield* Queue.size(writes)).toBe(0); }), ); }); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index b91f29d228c2e..5f9f30c0f9f30 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -74,7 +74,7 @@ export class ProjectSetupScriptOperationError extends Schema.TaggedError Deferred.await(promptReturned).pipe(Effect.timeoutOption("1 second"))), Effect.ensuring(Effect.sync(() => unsubscribe())), ); - return { completion, unsubscribe }; + // A startup prompt waits for input on a line that has no newline yet. + const flushPartialLine = Effect.suspend(() => { + const partial = lineBuffer; + lineBuffer = ""; + return handleLine(partial); + }); + const abandon = settle(null).pipe( + Effect.andThen(Deferred.succeed(promptReturned, undefined)), + ); + return { completion, unsubscribe, flushPartialLine, abandon }; }, ); // Input typed during shell startup can be read by an rc-file prompt (fnm, nvm) instead of the - // shell, which loses the command. Wait for the line editor, or for a quiet shell without one. - const awaitShellReady = Effect.fn("ProjectSetupScriptRunner.awaitShellReady")(function* (input: { - readonly threadId: string; - readonly terminalId: string; - readonly history: string; - }) { - if (lineEditorReading(input.history, false)) return; - const ready = yield* Deferred.make(); - let reading = false; - let carry = ""; - let busy = false; - let activity = 0; - const unsubscribe = yield* terminalManager.subscribe((event) => { - if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { - return Effect.void; - } - activity += 1; - if (event.type === "activity") busy = event.hasRunningSubprocess; - if (event.type === "output") { - const text = carry + event.data; - carry = text.slice(-(BRACKETED_PASTE_ON.length - 1)); - reading = lineEditorReading(text, reading); - } - const done = reading || event.type === "exited" || event.type === "closed"; - return done ? Deferred.succeed(ready, undefined).pipe(Effect.asVoid) : Effect.void; - }); - // A startup prompt runs as a child process, so a busy shell keeps waiting for the user. - const quiet = Effect.gen(function* () { - while (true) { - const seen = activity; - yield* Effect.sleep(SHELL_QUIET_FALLBACK); - if (activity === seen && !busy) return; - } - }); - yield* Deferred.await(ready).pipe( - Effect.raceFirst(quiet), - Effect.ensuring(Effect.sync(() => unsubscribe())), - ); - }); + // shell, which loses the command. Subscribe before the terminal opens so no activity is missed. + const watchShellReadiness = Effect.fn("ProjectSetupScriptRunner.watchShellReadiness")( + function* (input: { + readonly threadId: string; + readonly terminalId: string; + readonly onStartupPrompt: Effect.Effect; + }) { + const outcome = yield* Deferred.make<"ready" | "exited">(); + let reading = false; + let carry = ""; + let busy = false; + let activity = 0; + const unsubscribe = yield* terminalManager.subscribe((event) => { + if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { + return Effect.void; + } + activity += 1; + if (event.type === "activity") busy = event.hasRunningSubprocess; + if (event.type === "output") { + const text = carry + event.data; + carry = text.slice(-(BRACKETED_PASTE_ON.length - 1)); + reading = lineEditorReading(text, reading); + } + if (event.type === "exited" || event.type === "closed") { + return Deferred.succeed(outcome, "exited").pipe(Effect.asVoid); + } + return reading ? Deferred.succeed(outcome, "ready").pipe(Effect.asVoid) : Effect.void; + }); + // A startup prompt runs as a child process, so a busy shell keeps waiting for the user. + const quiet = Effect.gen(function* () { + while (true) { + const seen = activity; + yield* Effect.sleep(SHELL_QUIET_FALLBACK); + if (activity !== seen) continue; + if (!busy) return "ready" as const; + yield* input.onStartupPrompt; + } + }); + const wait = (history: string) => + (lineEditorReading(history, false) + ? Effect.succeed("ready" as const) + : Deferred.await(outcome).pipe(Effect.raceFirst(quiet)) + ).pipe(Effect.ensuring(Effect.sync(() => unsubscribe()))); + return { wait, unsubscribe }; + }, + ); const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn( "ProjectSetupScriptRunner.runForThread", @@ -413,6 +430,28 @@ export const make = Effect.gen(function* () { ) : script.command; + // Subscribe before opening so neither startup activity nor the sentinel can race the listeners. + const observed = + observe && completionToken + ? yield* observeTerminalCompletion({ + threadId: input.threadId, + terminalId, + sentinel: completionSentinel(completionToken), + sentinelPattern: completionSentinelPattern(completionToken), + echoedWrapperLines: commandLine.split("\r").filter((line) => line.length > 0), + onOutputLine: observe.onOutputLine, + }) + : undefined; + // PowerShell has no line-editor signal; keep its immediate write. + const readiness = + completionShell === "powershell" + ? undefined + : yield* watchShellReadiness({ + threadId: input.threadId, + terminalId, + onStartupPrompt: observed?.flushPartialLine ?? Effect.void, + }); + const terminal = yield* terminalManager .open({ threadId: input.threadId, @@ -430,42 +469,33 @@ export const make = Effect.gen(function* () { cause, }), ), + Effect.tapError(() => + Effect.sync(() => { + observed?.unsubscribe(); + readiness?.unsubscribe(); + }), + ), ); - // Subscribe before writing so the sentinel cannot race past the listener. - const observed = - observe && completionToken - ? yield* observeTerminalCompletion({ - threadId: input.threadId, - terminalId, - sentinel: completionSentinel(completionToken), - sentinelPattern: completionSentinelPattern(completionToken), - echoedWrapperLines: commandLine.split("\r").filter((line) => line.length > 0), - onOutputLine: observe.onOutputLine, - }) - : undefined; - // PowerShell has no line-editor signal; keep its immediate write. - yield* ( - completionShell === "powershell" - ? Effect.void - : awaitShellReady({ threadId: input.threadId, terminalId, history: terminal.history }) - ).pipe( - Effect.andThen( - terminalManager.write({ - threadId: input.threadId, - terminalId, - data: projectScriptTerminalInput(commandLine), - }), + // Return once the terminal exists so callers can show it while a startup prompt waits. + yield* (readiness?.wait(terminal.history) ?? Effect.succeed("ready" as const)).pipe( + Effect.flatMap((shell) => + shell === "exited" + ? Effect.void + : terminalManager.write({ + threadId: input.threadId, + terminalId, + data: projectScriptTerminalInput(commandLine), + }), ), - Effect.mapError( - (cause) => - new ProjectSetupScriptOperationError({ - ...errorContext, - operation: "writeCommand", - cause, - }), + Effect.catchCause((cause) => + Effect.logWarning("project script command was not written", { + ...errorContext, + terminalId, + cause, + }).pipe(Effect.andThen(observed?.abandon ?? Effect.void)), ), - Effect.onError(() => Effect.sync(() => observed?.unsubscribe())), + Effect.forkIn(serviceScope), ); const completion = observed?.completion.pipe( From ad0ec9b2b80cfe05b06ec5657ee59540ae553aaa Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 02:23:14 -0700 Subject: [PATCH 3/4] fix(server): ignore restored terminal history and clean up on cancel - Only live output marks the shell ready; restored history can hold an earlier shell's line editor state. - Drop terminal listeners when cancelled while the terminal opens. - Raise the quiet fallback to 3s, past one subprocess poll. Co-Authored-By: Claude Opus 5.5 --- .../project/ProjectSetupScriptRunner.test.ts | 81 ++++++++++++++----- .../src/project/ProjectSetupScriptRunner.ts | 17 ++-- 2 files changed, 67 insertions(+), 31 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index c2235cd62436d..8432b5e6885d6 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -2,7 +2,9 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { describe, expect, it } from "@effect/vitest"; import { ProjectId, type TerminalEvent } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; import { TestClock } from "effect/testing"; @@ -16,25 +18,33 @@ const threadId = "thread-1"; const terminalId = "setup-setup"; const event = { threadId, terminalId, sequence: 1, createdAt: "2026-01-01T00:00:00.000Z" }; -const setup = Effect.gen(function* () { +const makeHarness = Effect.fn(function* (options: { + readonly history?: string; + readonly opening?: Deferred.Deferred; +}) { const listeners = new Set<(event: TerminalEvent) => Effect.Effect>(); const writes = yield* Queue.unbounded(); const outputLines = yield* Queue.unbounded(); const terminals = Layer.mock(TerminalManager.TerminalManager)({ open: (input) => - Effect.succeed({ - threadId: input.threadId, - terminalId: input.terminalId ?? terminalId, - cwd: input.cwd, - worktreePath: null, - status: "running", - pid: 123, - history: "", - exitCode: null, - exitSignal: null, - label: "zsh", - updatedAt: "2026-01-01T00:00:00.000Z", - }), + (options.opening + ? Deferred.succeed(options.opening, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.void + ).pipe( + Effect.as({ + threadId: input.threadId, + terminalId: input.terminalId ?? terminalId, + cwd: input.cwd, + worktreePath: null, + status: "running", + pid: 123, + history: options.history ?? "", + exitCode: null, + exitSignal: null, + label: "zsh", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ), subscribe: (listener) => Effect.sync(() => { listeners.add(listener); @@ -55,7 +65,7 @@ const setup = Effect.gen(function* () { Effect.provideService(HostProcess.Platform, "darwin"), Effect.provideService(HostProcess.Environment, { SHELL: "/bin/zsh" }), ); - const result = yield* runner.runForThread({ + const start = runner.runForThread({ threadId, worktreePath: "/repo/.t3/worktrees/feature", observeCompletion: { onOutputLine: (line) => Queue.offer(outputLines, line) }, @@ -73,17 +83,23 @@ const setup = Effect.gen(function* () { ], }, }); - const completion = result.status === "started" ? result.completion : undefined; - if (!completion) return yield* Effect.die("setup did not start an observed run"); const emit = (terminalEvent: TerminalEvent) => Effect.forEach([...listeners], (listener) => listener(terminalEvent), { discard: true }); - return { result, completion, writes, outputLines, emit }; + return { start, listeners, writes, outputLines, emit }; +}); + +const setup = Effect.fn(function* (options: { readonly history?: string } = {}) { + const harness = yield* makeHarness(options); + const result = yield* harness.start; + const completion = result.status === "started" ? result.completion : undefined; + if (!completion) return yield* Effect.die("setup did not start an observed run"); + return { ...harness, result, completion }; }); describe("ProjectSetupScriptRunner", () => { it.effect("shows a startup prompt and types only once the line editor starts", () => Effect.gen(function* () { - const { result, writes, outputLines, emit } = yield* setup; + const { result, writes, outputLines, emit } = yield* setup(); expect(result).toMatchObject({ status: "started", terminalId }); yield* emit({ ...event, type: "output", data: "Missing Node.\r\nInstall it? [y/N]: " }); @@ -100,15 +116,36 @@ describe("ProjectSetupScriptRunner", () => { it.effect("types into a quiet shell that has no line editor", () => Effect.gen(function* () { - const { writes } = yield* setup; - yield* TestClock.adjust("2 seconds"); + const { writes } = yield* setup(); + yield* TestClock.adjust("3 seconds"); expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/); }), ); + it.effect("ignores line editor state restored from an earlier shell", () => + Effect.gen(function* () { + const { writes, emit } = yield* setup({ history: "old run\r\n% \x1b[?2004h" }); + yield* emit({ ...event, type: "activity", hasRunningSubprocess: true, label: "fnm" }); + yield* TestClock.adjust("1 minute"); + expect(yield* Queue.size(writes)).toBe(0); + }), + ); + + it.effect("drops its terminal listeners when cancelled while the terminal opens", () => + Effect.gen(function* () { + const opening = yield* Deferred.make(); + const { start, listeners } = yield* makeHarness({ opening }); + const fiber = yield* Effect.forkChild(start); + yield* Deferred.await(opening); + expect(listeners.size).toBe(2); + yield* Fiber.interrupt(fiber); + expect(listeners.size).toBe(0); + }), + ); + it.effect("does not type into a shell that exits during startup", () => Effect.gen(function* () { - const { completion, writes, emit } = yield* setup; + const { completion, writes, emit } = yield* setup(); yield* emit({ ...event, type: "exited", exitCode: 1, exitSignal: null }); expect(yield* completion).toMatchObject({ exitCode: null }); yield* TestClock.adjust("1 minute"); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 5f9f30c0f9f30..c1b64668530aa 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -142,7 +142,8 @@ function stripTerminalControl(text: string): string { // Line editors (zsh, fish, bash 5.1+) enable bracketed paste when they start reading a command. const BRACKETED_PASTE_ON = "\x1b[?2004h"; const BRACKETED_PASTE_OFF = "\x1b[?2004l"; -const SHELL_QUIET_FALLBACK = "2 seconds"; +// Longer than one subprocess poll plus its ps timeout, so a startup prompt is seen as busy first. +const SHELL_QUIET_FALLBACK = "3 seconds"; function lineEditorReading(text: string, previous: boolean): boolean { const on = text.lastIndexOf(BRACKETED_PASTE_ON); @@ -327,11 +328,8 @@ export const make = Effect.gen(function* () { yield* input.onStartupPrompt; } }); - const wait = (history: string) => - (lineEditorReading(history, false) - ? Effect.succeed("ready" as const) - : Deferred.await(outcome).pipe(Effect.raceFirst(quiet)) - ).pipe(Effect.ensuring(Effect.sync(() => unsubscribe()))); + // Restored history can hold a previous shell's line editor state, so only live output counts. + const wait = Deferred.await(outcome).pipe(Effect.raceFirst(quiet)); return { wait, unsubscribe }; }, ); @@ -452,7 +450,7 @@ export const make = Effect.gen(function* () { onStartupPrompt: observed?.flushPartialLine ?? Effect.void, }); - const terminal = yield* terminalManager + yield* terminalManager .open({ threadId: input.threadId, terminalId, @@ -469,7 +467,7 @@ export const make = Effect.gen(function* () { cause, }), ), - Effect.tapError(() => + Effect.onError(() => Effect.sync(() => { observed?.unsubscribe(); readiness?.unsubscribe(); @@ -478,7 +476,7 @@ export const make = Effect.gen(function* () { ); // Return once the terminal exists so callers can show it while a startup prompt waits. - yield* (readiness?.wait(terminal.history) ?? Effect.succeed("ready" as const)).pipe( + yield* (readiness?.wait ?? Effect.succeed("ready" as const)).pipe( Effect.flatMap((shell) => shell === "exited" ? Effect.void @@ -495,6 +493,7 @@ export const make = Effect.gen(function* () { cause, }).pipe(Effect.andThen(observed?.abandon ?? Effect.void)), ), + Effect.ensuring(Effect.sync(() => readiness?.unsubscribe())), Effect.forkIn(serviceScope), ); From a1b69821be50b5e78d1934a14c3a7b8c8204a91f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 02:32:51 -0700 Subject: [PATCH 4/4] fix(server): forget startup readiness state when the shell restarts The terminal manager resets its subprocess state on start without an activity event, so a busy startup child could keep setup waiting. Co-Authored-By: Claude Opus 5.5 --- .../project/ProjectSetupScriptRunner.test.ts | 47 ++++++++++++------- .../src/project/ProjectSetupScriptRunner.ts | 6 +++ 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 8432b5e6885d6..c295a06ac39ac 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -1,6 +1,6 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { describe, expect, it } from "@effect/vitest"; -import { ProjectId, type TerminalEvent } from "@t3tools/contracts"; +import { ProjectId, type TerminalEvent, type TerminalSessionSnapshot } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -18,6 +18,20 @@ const threadId = "thread-1"; const terminalId = "setup-setup"; const event = { threadId, terminalId, sequence: 1, createdAt: "2026-01-01T00:00:00.000Z" }; +const openSnapshot = (history: string): TerminalSessionSnapshot => ({ + threadId, + terminalId, + cwd: "/repo/.t3/worktrees/feature", + worktreePath: null, + status: "running", + pid: 123, + history, + exitCode: null, + exitSignal: null, + label: "zsh", + updatedAt: "2026-01-01T00:00:00.000Z", +}); + const makeHarness = Effect.fn(function* (options: { readonly history?: string; readonly opening?: Deferred.Deferred; @@ -26,25 +40,11 @@ const makeHarness = Effect.fn(function* (options: { const writes = yield* Queue.unbounded(); const outputLines = yield* Queue.unbounded(); const terminals = Layer.mock(TerminalManager.TerminalManager)({ - open: (input) => + open: () => (options.opening ? Deferred.succeed(options.opening, undefined).pipe(Effect.andThen(Effect.never)) : Effect.void - ).pipe( - Effect.as({ - threadId: input.threadId, - terminalId: input.terminalId ?? terminalId, - cwd: input.cwd, - worktreePath: null, - status: "running", - pid: 123, - history: options.history ?? "", - exitCode: null, - exitSignal: null, - label: "zsh", - updatedAt: "2026-01-01T00:00:00.000Z", - }), - ), + ).pipe(Effect.as(openSnapshot(options.history ?? ""))), subscribe: (listener) => Effect.sync(() => { listeners.add(listener); @@ -131,6 +131,19 @@ describe("ProjectSetupScriptRunner", () => { }), ); + it.effect("forgets a busy startup child when the shell restarts", () => + Effect.gen(function* () { + const { writes, emit } = yield* setup(); + yield* emit({ ...event, type: "activity", hasRunningSubprocess: true, label: "fnm" }); + yield* TestClock.adjust("1 minute"); + expect(yield* Queue.size(writes)).toBe(0); + yield* emit({ ...event, type: "restarted", snapshot: openSnapshot("") }); + // The restart lands inside a quiet window, so the next full window decides. + yield* TestClock.adjust("6 seconds"); + expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/); + }), + ); + it.effect("drops its terminal listeners when cancelled while the terminal opens", () => Effect.gen(function* () { const opening = yield* Deferred.make(); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index c1b64668530aa..a2f8e0fc8e201 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -307,6 +307,12 @@ export const make = Effect.gen(function* () { return Effect.void; } activity += 1; + // A new shell process starts idle; the manager resets its subprocess state without an event. + if (event.type === "started" || event.type === "restarted") { + busy = false; + reading = false; + carry = ""; + } if (event.type === "activity") busy = event.hasRunningSubprocess; if (event.type === "output") { const text = carry + event.data;