diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index ae9a93e9fc3..93463e11696 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -179,6 +179,90 @@ describe("buildThreadFeed", () => { ); }); + it("collapses a setup run into its latest state across interleaved activity", () => { + const thread = makeThread({ + id: ThreadId.make("thread-setup-collapse"), + projectId: ProjectId.make("project-1"), + title: "Setup lifecycle", + activities: [ + makeActivity({ + id: EventId.make("setup-requested"), + kind: "setup-script.requested", + summary: "Starting setup script", + createdAt: "2026-04-01T00:00:01.000Z", + payload: { runId: "setup-run-1" }, + }), + makeActivity({ + id: EventId.make("unrelated-work"), + kind: "runtime.info", + summary: "Created worktree", + createdAt: "2026-04-01T00:00:02.000Z", + }), + makeActivity({ + id: EventId.make("setup-started"), + kind: "setup-script.started", + summary: "Setup script started", + createdAt: "2026-04-01T00:00:03.000Z", + payload: { runId: "setup-run-1" }, + }), + makeActivity({ + id: EventId.make("setup-failed"), + kind: "setup-script.failed", + tone: "error", + summary: "Setup script failed", + createdAt: "2026-04-01T00:00:04.000Z", + payload: { runId: "setup-run-1", exitCode: 1 }, + }), + ], + }); + + const activities = buildThreadFeed(thread).flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + ); + + expect(activities).toHaveLength(2); + expect(activities[0]).toMatchObject({ + id: "setup-requested", + createdAt: "2026-04-01T00:00:01.000Z", + summary: "Setup script failed", + status: "failure", + }); + expect(activities[1]?.summary).toBe("Created worktree"); + }); + + it("keeps separate setup runs and preserves completed labels", () => { + const thread = makeThread({ + id: ThreadId.make("thread-separate-setup-runs"), + projectId: ProjectId.make("project-1"), + title: "Separate setup runs", + activities: [ + makeActivity({ + id: EventId.make("setup-one"), + kind: "setup-script.completed", + summary: "Setup script completed", + createdAt: "2026-04-01T00:00:01.000Z", + payload: { runId: "setup-run-1" }, + }), + makeActivity({ + id: EventId.make("setup-two"), + kind: "setup-script.completed", + summary: "Setup script completed", + createdAt: "2026-04-01T00:00:02.000Z", + payload: { runId: "setup-run-2" }, + }), + ], + }); + + const activities = buildThreadFeed(thread).flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + ); + + expect(activities.map((activity) => activity.summary)).toEqual([ + "Setup script completed", + "Setup script completed", + ]); + }); + it("keeps MCP inputs available to expanded mobile work rows", () => { const turnId = TurnId.make("turn-mcp"); const thread = makeThread({ diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 886644bf83e..bd2a8be2dc0 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -80,6 +80,7 @@ interface WorkLogEntry { interface DerivedWorkLogEntry extends WorkLogEntry { activityKind: OrchestrationThreadActivity["kind"]; collapseKey?: string; + setupRunId?: string; /** Grouping key for subagent lifecycle rows (one row per agent). */ taskId?: string; } @@ -408,6 +409,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (requestKind) { entry.requestKind = requestKind; } + if (activity.kind.startsWith("setup-script.") && typeof payload?.runId === "string") { + entry.setupRunId = payload.runId; + } let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload); if (!toolLifecycleStatus && activity.kind === "tool.completed") { toolLifecycleStatus = "completed"; @@ -426,10 +430,28 @@ function collapseDerivedWorkLogEntries( entries: ReadonlyArray, ): DerivedWorkLogEntry[] { const collapsed: DerivedWorkLogEntry[] = []; + const setupRowIndex = new Map(); // Subagent rows collapse by identity, not adjacency (quiet-timeline // guarantee; mirrors web's session-logic). const taskRowIndex = new Map(); for (const entry of entries) { + if (entry.setupRunId !== undefined) { + const existingIndex = setupRowIndex.get(entry.setupRunId); + if (existingIndex !== undefined) { + const existing = collapsed[existingIndex]!; + collapsed[existingIndex] = { + ...mergeDerivedWorkLogEntries(existing, entry), + id: existing.id, + createdAt: existing.createdAt, + turnId: existing.turnId, + setupRunId: entry.setupRunId, + }; + continue; + } + setupRowIndex.set(entry.setupRunId, collapsed.length); + collapsed.push(entry); + continue; + } const isTaskRow = entry.taskId !== undefined && (entry.activityKind === "task.progress" || @@ -485,6 +507,7 @@ function mergeDerivedWorkLogEntries( const collapseKey = next.collapseKey ?? previous.collapseKey; const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; const toolData = next.toolData ?? previous.toolData; + const setupRunId = next.setupRunId ?? previous.setupRunId; return { ...previous, ...next, @@ -498,6 +521,7 @@ function mergeDerivedWorkLogEntries( ...(collapseKey ? { collapseKey } : {}), ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}), ...(toolData !== undefined ? { toolData } : {}), + ...(setupRunId !== undefined ? { setupRunId } : {}), }; } @@ -689,7 +713,10 @@ function capitalizePhrase(value: string): string { return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; } -function workEntryHeading(workEntry: WorkLogEntry): string { +function workEntryHeading(workEntry: DerivedWorkLogEntry): string { + if (workEntry.activityKind.startsWith("setup-script.")) { + return capitalizePhrase(workEntry.label); + } if (!workEntry.toolTitle) { return capitalizePhrase(normalizeCompactToolLabel(workEntry.label)); } diff --git a/apps/server/src/observability/Metrics.ts b/apps/server/src/observability/Metrics.ts index 886833d6e2c..c8df095a48d 100644 --- a/apps/server/src/observability/Metrics.ts +++ b/apps/server/src/observability/Metrics.ts @@ -74,6 +74,14 @@ export const terminalRestartsTotal = Metric.counter("t3_terminal_restarts_total" description: "Total terminal restart requests handled.", }); +export const setupScriptRunsTotal = Metric.counter("t3_setup_script_runs_total", { + description: "Total setup script runs by terminal outcome.", +}); + +export const setupScriptDuration = Metric.timer("t3_setup_script_duration", { + description: "Setup script run duration.", +}); + export const metricAttributes = ( attributes: Readonly>, ): ReadonlyArray<[string, string]> => Object.entries(compactMetricAttributes(attributes)); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 2b4d3771605..1155d15fa47 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -1552,6 +1552,54 @@ describe("ProviderCommandReactor", () => { }); }); + it("keeps setup-script work-log activities out of provider input", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make("cmd-setup-script-started"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-setup-script-started"), + tone: "info", + kind: "setup-script.started", + summary: "Setup script started", + payload: { + runId: "setup-run-1", + command: "bun install", + }, + turnId: null, + createdAt: now, + }, + createdAt: now, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-after-setup-activity"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-after-setup-activity"), + role: "user", + text: "Implement the requested change.", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + const request = harness.sendTurn.mock.calls[0]?.[0]; + expect(request).toMatchObject({ input: "Implement the requested change." }); + expect(JSON.stringify(request)).not.toContain("Setup script started"); + expect(JSON.stringify(request)).not.toContain("bun install"); + }); + it("forwards claude effort options through session start and turn send", async () => { const harness = await createHarness({ threadModelSelection: { diff --git a/apps/server/src/persistence/Layers/ProjectionThreadActivities.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadActivities.test.ts new file mode 100644 index 00000000000..d46bc8c7a07 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionThreadActivities.test.ts @@ -0,0 +1,89 @@ +import { assert, it } from "@effect/vitest"; +import { EventId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { ProjectionThreadActivityRepository } from "../Services/ProjectionThreadActivities.ts"; +import { ProjectionThreadActivityRepositoryLive } from "./ProjectionThreadActivities.ts"; +import { SqlitePersistenceMemory } from "./Sqlite.ts"; + +const layer = it.layer( + ProjectionThreadActivityRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), +); + +layer("ProjectionThreadActivityRepository", (it) => { + it.effect("lists requested and started setup lifecycle rows without a persisted outcome", () => + Effect.gen(function* () { + const repository = yield* ProjectionThreadActivityRepository; + const threadId = ThreadId.make("thread-setup-recovery"); + const payload = { + runId: "run-1", + scriptId: "setup", + scriptName: "Setup", + command: "bun install", + terminalId: "setup-setup", + worktreePath: "/repo/worktree", + }; + const append = (id: string, kind: string, sequence: number) => + repository.upsert({ + activityId: EventId.make(id), + threadId, + turnId: null, + tone: "info", + kind, + summary: kind, + payload, + sequence, + createdAt: `2026-01-01T00:00:0${sequence}.000Z`, + }); + + yield* append("requested", "setup-script.requested", 1); + yield* append("started", "setup-script.started", 2); + yield* append("unrelated", "file-edit", 3); + yield* append("completed", "setup-script.completed", 4); + yield* repository.upsert({ + activityId: EventId.make("unfinished-requested"), + threadId, + turnId: null, + tone: "info", + kind: "setup-script.requested", + summary: "setup-script.requested", + payload: { ...payload, runId: "run-2" }, + sequence: 5, + createdAt: "2026-01-01T00:00:05.000Z", + }); + yield* repository.upsert({ + activityId: EventId.make("unfinished-requested-before-start"), + threadId, + turnId: null, + tone: "info", + kind: "setup-script.requested", + summary: "setup-script.requested", + payload: { ...payload, runId: "run-3" }, + sequence: 6, + createdAt: "2026-01-01T00:00:06.000Z", + }); + yield* repository.upsert({ + activityId: EventId.make("unfinished-started"), + threadId, + turnId: null, + tone: "info", + kind: "setup-script.started", + summary: "setup-script.started", + payload: { ...payload, runId: "run-3" }, + sequence: 7, + createdAt: "2026-01-01T00:00:07.000Z", + }); + + const rows = yield* repository.listUnfinishedSetupRuns(); + assert.deepEqual( + rows.map((row) => row.activityId), + [ + EventId.make("unfinished-requested"), + EventId.make("unfinished-requested-before-start"), + EventId.make("unfinished-started"), + ], + ); + }), + ); +}); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts index 2f4815f9654..d788f0c273a 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts @@ -106,6 +106,42 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { `, }); + const listUnfinishedSetupRunRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadActivityDbRowSchema, + execute: () => + sql` + SELECT + lifecycle.activity_id AS "activityId", + lifecycle.thread_id AS "threadId", + lifecycle.turn_id AS "turnId", + lifecycle.tone, + lifecycle.kind, + lifecycle.summary, + lifecycle.payload_json AS "payload", + lifecycle.sequence, + lifecycle.created_at AS "createdAt" + FROM projection_thread_activities AS lifecycle + WHERE lifecycle.kind IN ( + 'setup-script.requested', + 'setup-script.started' + ) + AND json_extract(lifecycle.payload_json, '$.runId') IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM projection_thread_activities AS finished + WHERE finished.thread_id = lifecycle.thread_id + AND finished.kind IN ( + 'setup-script.completed', + 'setup-script.failed' + ) + AND json_extract(finished.payload_json, '$.runId') = + json_extract(lifecycle.payload_json, '$.runId') + ) + ORDER BY lifecycle.sequence ASC, lifecycle.created_at ASC, lifecycle.activity_id ASC + `, + }); + const upsert: ProjectionThreadActivityRepositoryShape["upsert"] = (row) => upsertProjectionThreadActivityRow(row).pipe( Effect.mapError( @@ -146,9 +182,34 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { ), ); + const listUnfinishedSetupRuns: ProjectionThreadActivityRepositoryShape["listUnfinishedSetupRuns"] = + () => + listUnfinishedSetupRunRows().pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadActivityRepository.listUnfinishedSetupRuns:query", + "ProjectionThreadActivityRepository.listUnfinishedSetupRuns:decodeRows", + ), + ), + Effect.map((rows) => + rows.map((row) => ({ + activityId: row.activityId, + threadId: row.threadId, + turnId: row.turnId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), + createdAt: row.createdAt, + })), + ), + ); + return { upsert, listByThreadId, + listUnfinishedSetupRuns, deleteByThreadId, } satisfies ProjectionThreadActivityRepositoryShape; }); diff --git a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts index 47cb6073c47..62ffb609e2c 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts @@ -67,6 +67,12 @@ export interface ProjectionThreadActivityRepositoryShape { input: ListProjectionThreadActivitiesInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** List setup starts that have no persisted terminal outcome. */ + readonly listUnfinishedSetupRuns: () => Effect.Effect< + ReadonlyArray, + ProjectionRepositoryError + >; + /** * Delete projected thread activity rows by thread. */ diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 5c5da4666b0..e1bff54ede9 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -1,11 +1,21 @@ import { describe, expect, it, vi } from "@effect/vitest"; -import { type OrchestrationProject, ProjectId } from "@t3tools/contracts"; +import { + type OrchestrationCommand, + type OrchestrationProject, + EventId, + ProjectId, + ThreadId, + type TerminalEvent, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProjectionThreadActivities from "../persistence/Services/ProjectionThreadActivities.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts"; @@ -48,32 +58,103 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => }); const makeTerminalManagerLayer = ( - overrides: Pick, + overrides: Pick & + Partial>, ) => Layer.succeed(TerminalManager.TerminalManager, { ...overrides, + open: () => Effect.die(new Error("unused")), attachStream: () => Effect.die(new Error("unused")), + write: () => Effect.die(new Error("unused")), resize: () => Effect.void, clear: () => Effect.void, restart: () => Effect.die(new Error("unused")), close: () => Effect.void, - subscribe: () => Effect.succeed(() => undefined), + subscribe: overrides.subscribe ?? (() => Effect.succeed(() => undefined)), subscribeMetadata: () => Effect.succeed(() => undefined), }); const testLayer = ( project: OrchestrationProject, - terminal: Pick, + terminal: Pick & + Partial>, + commands: OrchestrationCommand[] = [], ) => ProjectSetupScriptRunner.layer.pipe( Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), Layer.provideMerge(makeTerminalManagerLayer(terminal)), + Layer.provideMerge( + Layer.succeed(OrchestrationEngine.OrchestrationEngineService, { + dispatch: (command) => + Effect.sync(() => { + commands.push(command); + return { sequence: commands.length }; + }), + readEvents: () => Stream.empty, + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + }), + ), + Layer.provideMerge( + Layer.succeed(ProjectionThreadActivities.ProjectionThreadActivityRepository, { + upsert: () => Effect.void, + listByThreadId: () => Effect.succeed([]), + listUnfinishedSetupRuns: () => Effect.succeed([]), + deleteByThreadId: () => Effect.void, + }), + ), ); describe("ProjectSetupScriptRunner", () => { + it("derives requested or started setup runs without a terminal outcome as unfinished", () => { + const activity = ( + runId: string, + kind: string, + sequence: number, + ): ProjectionThreadActivities.ProjectionThreadActivity => ({ + activityId: EventId.make(`activity-${sequence}`), + threadId: ThreadId.make("thread-1"), + turnId: null, + tone: "info", + kind, + summary: kind, + payload: { + runId, + scriptId: "setup", + scriptName: "Setup", + command: "bun install", + terminalId: "setup-setup", + worktreePath: "/repo/worktrees/a", + }, + sequence, + createdAt: `2026-01-01T00:00:0${sequence}.000Z`, + }); + + const runs = ProjectSetupScriptRunner.deriveUnfinishedSetupRuns([ + activity("requested-only-run", "setup-script.requested", 1), + activity("finished-run", "setup-script.requested", 2), + activity("finished-run", "setup-script.started", 3), + activity("finished-run", "setup-script.completed", 4), + activity("unfinished-run", "setup-script.requested", 5), + activity("unfinished-run", "setup-script.started", 6), + ]); + + expect(runs).toMatchObject([ + { + runId: "requested-only-run", + startedAt: "2026-01-01T00:00:01.000Z", + startedActivityRecorded: false, + }, + { + runId: "unfinished-run", + startedAt: "2026-01-01T00:00:06.000Z", + startedActivityRecorded: true, + }, + ]); + }); + it.effect("returns no-script when no setup script exists", () => { - const open = vi.fn(() => Effect.die("unexpected open")); - const write = vi.fn(() => Effect.die("unexpected write")); + const openCommand = vi.fn(() => Effect.die("unexpected open")); const project = makeProject([]); return Effect.gen(function* () { @@ -85,75 +166,224 @@ describe("ProjectSetupScriptRunner", () => { }); expect(result).toEqual({ status: "no-script" }); - expect(open).not.toHaveBeenCalled(); - expect(write).not.toHaveBeenCalled(); - }).pipe(Effect.provide(testLayer(project, { open, write }))); + expect(openCommand).not.toHaveBeenCalled(); + }).pipe(Effect.provide(testLayer(project, { openCommand }))); + }); + + it.effect("opens the deterministic setup terminal with the command as its PTY process", () => { + const commands: OrchestrationCommand[] = []; + const openCommand = 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", + }), + ); + 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", + }); + + expect(result).toMatchObject({ + status: "started", + scriptId: "setup", + scriptName: "Setup", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + }); + expect(openCommand).toHaveBeenCalledWith({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + env: { + T3CODE_PROJECT_ROOT: "/repo/project", + T3CODE_WORKTREE_PATH: "/repo/worktrees/a", + }, + command: "bun install", + }); + expect( + commands.flatMap((command) => + command.type === "thread.activity.append" ? [command.activity.kind] : [], + ), + ).toEqual(["setup-script.requested", "setup-script.started"]); + }).pipe(Effect.provide(testLayer(project, { openCommand }, commands))); }); - it.effect( - "opens the deterministic setup terminal with worktree env and writes the command", - () => { - const open = vi.fn(() => - Effect.succeed({ + it.effect("records setup command success and all interruptions as failure outcomes", () => { + const commands: OrchestrationCommand[] = []; + let terminalListener: ((event: TerminalEvent) => Effect.Effect) | undefined; + let exitDuringOpen: Extract | null = { + type: "exited", + threadId: "thread-1", + terminalId: "setup-setup", + exitCode: 7, + exitSignal: null, + }; + 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", + projectId: "project-1", + worktreePath: "/repo/worktrees/a", + }); + expect(result.status).toBe("started"); + if (!terminalListener) throw new Error("terminal listener was not registered"); + exitDuringOpen = null; + + const activities = commands.flatMap((command) => + command.type === "thread.activity.append" ? [command.activity] : [], + ); + expect(activities.map((activity) => activity.kind)).toEqual([ + "setup-script.requested", + "setup-script.started", + "setup-script.failed", + ]); + expect(activities.at(-1)?.payload).toMatchObject({ + outcome: "failed", + failureReason: "command-exit", + exitCode: 7, + exitSignal: null, + command: "bun install", + }); + + yield* runner.runForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath: "/repo/worktrees/a", + }); + yield* terminalListener({ + type: "exited", + threadId: "thread-1", + terminalId: "setup-setup", + exitCode: 0, + exitSignal: null, + }); + yield* runner.runForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath: "/repo/worktrees/a", + }); + yield* terminalListener({ + type: "closed", + threadId: "thread-1", + terminalId: "setup-setup", + }); + yield* runner.runForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath: "/repo/worktrees/a", + }); + yield* terminalListener({ + type: "restarted", + threadId: "thread-1", + terminalId: "setup-setup", + snapshot: { threadId: "thread-1", terminalId: "setup-setup", cwd: "/repo/worktrees/a", worktreePath: "/repo/worktrees/a", - status: "running" as const, - pid: 123, + status: "running", + pid: 456, history: "", exitCode: null, exitSignal: null, label: "setup-setup", updatedAt: "2026-01-01T00:00:00.000Z", - }), - ); - const write = 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", - }); + }); - expect(result).toEqual({ - status: "started", - scriptId: "setup", - scriptName: "Setup", - terminalId: "setup-setup", - cwd: "/repo/worktrees/a", - }); - expect(open).toHaveBeenCalledWith({ - threadId: "thread-1", - terminalId: "setup-setup", - cwd: "/repo/worktrees/a", - worktreePath: "/repo/worktrees/a", - env: { - T3CODE_PROJECT_ROOT: "/repo/project", - T3CODE_WORKTREE_PATH: "/repo/worktrees/a", + const terminalOutcomes = commands.flatMap((command) => + command.type === "thread.activity.append" && + ["setup-script.completed", "setup-script.failed"].includes(command.activity.kind) + ? [command.activity.kind] + : [], + ); + expect(terminalOutcomes).toEqual([ + "setup-script.failed", + "setup-script.completed", + "setup-script.failed", + "setup-script.failed", + ]); + expect( + commands + .flatMap((command) => + command.type === "thread.activity.append" ? [command.activity] : [], + ) + .at(-1)?.payload, + ).toMatchObject({ + outcome: "failed", + failureReason: "terminal-restarted", + }); + }).pipe( + Effect.provide( + testLayer( + project, + { + openCommand: () => + Effect.gen(function* () { + if (exitDuringOpen && terminalListener) { + yield* terminalListener(exitDuringOpen); + } + return { + 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", + }; + }), + subscribe: (listener) => + Effect.sync(() => { + terminalListener = listener; + return () => undefined; + }), }, - }); - expect(write).toHaveBeenCalledWith({ - threadId: "thread-1", - terminalId: "setup-setup", - data: "bun install\r", - }); - }).pipe(Effect.provide(testLayer(project, { open, write }))); - }, - ); + commands, + ), + ), + ); + }); it.effect("keeps terminal failures as the exact cause of a structured operation error", () => { + const commands: OrchestrationCommand[] = []; const rootCause = new Error("stat failed"); const terminalError = new TerminalManager.TerminalCwdStatError({ cwd: "/repo/worktrees/a", @@ -190,9 +420,21 @@ describe("ProjectSetupScriptRunner", () => { } }).pipe( Effect.provide( - testLayer(project, { - open: () => Effect.fail(terminalError), - write: () => Effect.die("unexpected write"), + testLayer( + project, + { + openCommand: () => Effect.fail(terminalError), + }, + commands, + ), + ), + Effect.tap(() => + Effect.sync(() => { + expect( + commands.flatMap((command) => + command.type === "thread.activity.append" ? [command.activity.kind] : [], + ), + ).toEqual(["setup-script.requested", "setup-script.failed"]); }), ), ); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 41bf0fabf48..9285f1a93c1 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -1,20 +1,100 @@ -import { ProjectId } from "@t3tools/contracts"; +import { CommandId, EventId, ProjectId, ThreadId } from "@t3tools/contracts"; import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts"; import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Metric from "effect/Metric"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import { + increment, + metricAttributes, + setupScriptDuration, + setupScriptRunsTotal, +} from "../observability/Metrics.ts"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProjectionThreadActivities from "../persistence/Services/ProjectionThreadActivities.ts"; +import { forkParked } from "../serverActivation.ts"; import * as TerminalManager from "../terminal/Manager.ts"; +type SetupRunOutcome = + | { + readonly outcome: "succeeded"; + readonly exitCode: number; + readonly exitSignal: number | null; + } + | { + readonly outcome: "failed"; + readonly reason: + | "command-exit" + | "launch-error" + | "server-restarted" + | "terminal-closed" + | "terminal-error" + | "terminal-restarted"; + readonly exitCode: number | null; + readonly exitSignal: number | null; + readonly detail?: string; + }; + +const SetupRunActivityPayload = Schema.Struct({ + runId: Schema.String, + scriptId: Schema.String, + scriptName: Schema.String, + command: Schema.String, + terminalId: Schema.String, + worktreePath: Schema.String, +}); +const decodeSetupRunActivityPayload = Schema.decodeUnknownOption(SetupRunActivityPayload); + +interface ActiveSetupRun { + readonly runId: string; + readonly threadId: string; + readonly scriptId: string; + readonly scriptName: string; + readonly command: string; + readonly terminalId: string; + readonly worktreePath: string; + readonly startedAt: string; + readonly startedActivityRecorded: boolean; + readonly pendingOutcome: SetupRunOutcome | null; +} + +export function deriveUnfinishedSetupRuns( + activities: ReadonlyArray, +): ReadonlyArray { + const unfinished = new Map(); + for (const activity of activities) { + const payload = decodeSetupRunActivityPayload(activity.payload); + if (Option.isNone(payload)) continue; + const runId = payload.value.runId; + if (activity.kind === "setup-script.requested" || activity.kind === "setup-script.started") { + unfinished.set(runId, { + ...payload.value, + threadId: activity.threadId, + startedAt: activity.createdAt, + startedActivityRecorded: activity.kind === "setup-script.started", + pendingOutcome: null, + }); + continue; + } + unfinished.delete(runId); + } + return [...unfinished.values()]; +} + export interface ProjectSetupScriptRunnerResultNoScript { readonly status: "no-script"; } export interface ProjectSetupScriptRunnerResultStarted { readonly status: "started"; + readonly runId: string; readonly scriptId: string; readonly scriptName: string; readonly terminalId: string; @@ -40,7 +120,7 @@ export class ProjectSetupScriptOperationError extends Schema.TaggedErrorClass()); + let nextRunSequence = 0; + + const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + const terminalKey = (threadId: string, terminalId: string) => `${threadId}\0${terminalId}`; + + const appendActivity = Effect.fn("ProjectSetupScriptRunner.appendActivity")(function* (input: { + readonly threadId: string; + readonly runId: string; + readonly kind: string; + readonly summary: string; + readonly tone: "info" | "error"; + readonly createdAt: string; + readonly payload: Record; + }) { + const activityId = EventId.make(`setup:${input.runId}:${input.kind}`); + yield* orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make(`server:setup-script:${input.runId}:${input.kind}`), + threadId: ThreadId.make(input.threadId), + activity: { + id: activityId, + tone: input.tone, + kind: input.kind, + summary: input.summary, + payload: input.payload, + turnId: null, + createdAt: input.createdAt, + }, + createdAt: input.createdAt, + }); + }); + + const recordOutcome = Effect.fn("ProjectSetupScriptRunner.recordOutcome")(function* ( + run: ActiveSetupRun, + outcome: SetupRunOutcome, + ) { + const finishedAt = yield* nowIso; + const durationMs = Math.max(0, Date.parse(finishedAt) - Date.parse(run.startedAt)); + const succeeded = outcome.outcome === "succeeded"; + const interrupted = + outcome.outcome === "failed" && + (outcome.reason === "server-restarted" || + outcome.reason === "terminal-closed" || + outcome.reason === "terminal-restarted"); + yield* appendActivity({ + threadId: run.threadId, + runId: run.runId, + kind: succeeded ? "setup-script.completed" : "setup-script.failed", + summary: succeeded + ? "Setup script completed" + : interrupted + ? "Setup script stopped" + : "Setup script failed", + tone: succeeded ? "info" : "error", + createdAt: finishedAt, + payload: { + runId: run.runId, + scriptId: run.scriptId, + scriptName: run.scriptName, + command: run.command, + terminalId: run.terminalId, + worktreePath: run.worktreePath, + outcome: outcome.outcome, + ...(outcome.outcome === "failed" ? { failureReason: outcome.reason } : {}), + exitCode: outcome.exitCode, + exitSignal: outcome.exitSignal, + durationMs, + ...(outcome.outcome === "failed" && outcome.detail !== undefined + ? { detail: outcome.detail } + : {}), + }, + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to record setup script outcome", { + threadId: run.threadId, + runId: run.runId, + outcome: outcome.outcome, + cause, + }), + ), + ); + yield* increment(setupScriptRunsTotal, { outcome: outcome.outcome }); + yield* Metric.update( + Metric.withAttributes(setupScriptDuration, metricAttributes({ outcome: outcome.outcome })), + Duration.millis(durationMs), + ); + }); + + const finishRun = Effect.fn("ProjectSetupScriptRunner.finishRun")(function* ( + threadId: string, + terminalId: string, + outcome: SetupRunOutcome, + ) { + const ready = yield* SynchronizedRef.modify(activeRunsRef, (runs) => { + const key = terminalKey(threadId, terminalId); + const run = runs.get(key); + if (!run) return [Option.none(), runs] as const; + if (!run.startedActivityRecorded) { + const next = new Map(runs); + next.set(key, { ...run, pendingOutcome: outcome }); + return [Option.none(), next] as const; + } + const next = new Map(runs); + next.delete(key); + return [Option.some(run), next] as const; + }); + if (Option.isSome(ready)) { + yield* recordOutcome(ready.value, outcome); + } + }); + + const unsubscribe = yield* terminalManager.subscribe((event) => { + switch (event.type) { + case "exited": + return finishRun( + event.threadId, + event.terminalId, + event.exitCode === 0 && (event.exitSignal === null || event.exitSignal === 0) + ? { + outcome: "succeeded", + exitCode: event.exitCode, + exitSignal: event.exitSignal, + } + : { + outcome: "failed", + reason: "command-exit", + exitCode: event.exitCode, + exitSignal: event.exitSignal, + }, + ); + case "closed": + return finishRun(event.threadId, event.terminalId, { + outcome: "failed", + reason: "terminal-closed", + exitCode: null, + exitSignal: null, + }); + case "error": + return finishRun(event.threadId, event.terminalId, { + outcome: "failed", + reason: "terminal-error", + exitCode: null, + exitSignal: null, + detail: event.message, + }); + case "restarted": + return finishRun(event.threadId, event.terminalId, { + outcome: "failed", + reason: "terminal-restarted", + exitCode: null, + exitSignal: null, + }); + default: + return Effect.void; + } + }); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + const recoverInterruptedRuns = Effect.fn("ProjectSetupScriptRunner.recoverInterruptedRuns")( + function* () { + const activities = yield* projectionThreadActivities.listUnfinishedSetupRuns(); + yield* Effect.forEach( + deriveUnfinishedSetupRuns(activities), + (run) => + recordOutcome(run, { + outcome: "failed", + reason: "server-restarted", + exitCode: null, + exitSignal: null, + }), + { concurrency: 1, discard: true }, + ); + }, + Effect.catchCause((cause) => + Effect.logWarning("failed to recover interrupted setup script runs", { cause }), + ), + ); + yield* forkParked(recoverInterruptedRuns()); const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn( "ProjectSetupScriptRunner.runForThread", @@ -133,18 +395,56 @@ export const make = Effect.gen(function* () { const terminalId = input.preferredTerminalId ?? `setup-${script.id}`; const cwd = input.worktreePath; + const requestedAt = yield* nowIso; + nextRunSequence += 1; + const runId = `${input.threadId}:${script.id}:${requestedAt}:${nextRunSequence}`; const env = projectScriptRuntimeEnv({ project: { cwd: project.workspaceRoot }, worktreePath: input.worktreePath, }); - yield* terminalManager - .open({ + const activeRun: ActiveSetupRun = { + runId, + threadId: input.threadId, + scriptId: script.id, + scriptName: script.name, + command: script.command, + terminalId, + worktreePath: input.worktreePath, + startedAt: requestedAt, + startedActivityRecorded: false, + pendingOutcome: null, + }; + yield* SynchronizedRef.update(activeRunsRef, (runs) => { + const next = new Map(runs); + next.set(terminalKey(input.threadId, terminalId), activeRun); + return next; + }); + yield* appendActivity({ + threadId: input.threadId, + runId, + kind: "setup-script.requested", + summary: "Starting setup script", + tone: "info", + createdAt: requestedAt, + payload: { + runId, + scriptId: script.id, + scriptName: script.name, + command: script.command, + terminalId, + worktreePath: input.worktreePath, + }, + }).pipe(Effect.ignoreCause({ log: true })); + + const terminal = yield* terminalManager + .openCommand({ threadId: input.threadId, terminalId, cwd, worktreePath: input.worktreePath, env, + command: script.command, }) .pipe( Effect.mapError( @@ -155,26 +455,84 @@ export const make = Effect.gen(function* () { cause, }), ), - ); - yield* terminalManager - .write({ - threadId: input.threadId, - terminalId, - data: `${script.command}\r`, - }) - .pipe( - Effect.mapError( - (cause) => - new ProjectSetupScriptOperationError({ - ...errorContext, - operation: "writeCommand", - cause, - }), + Effect.tapError((error) => + SynchronizedRef.update(activeRunsRef, (runs) => { + const next = new Map(runs); + next.delete(terminalKey(input.threadId, terminalId)); + return next; + }).pipe( + Effect.andThen( + recordOutcome(activeRun, { + outcome: "failed", + reason: "launch-error", + exitCode: null, + exitSignal: null, + detail: error.message, + }), + ), + ), ), ); + if (terminal.status === "error") { + const startError = new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "openTerminal", + cause: new Error(`Setup terminal '${terminalId}' failed to start.`), + }); + yield* SynchronizedRef.update(activeRunsRef, (runs) => { + const next = new Map(runs); + next.delete(terminalKey(input.threadId, terminalId)); + return next; + }); + yield* recordOutcome(activeRun, { + outcome: "failed", + reason: "launch-error", + exitCode: null, + exitSignal: null, + detail: startError.message, + }); + return yield* startError; + } + + const startedAt = yield* nowIso; + yield* appendActivity({ + threadId: input.threadId, + runId, + kind: "setup-script.started", + summary: "Setup script started", + tone: "info", + createdAt: startedAt, + payload: { + runId, + scriptId: script.id, + scriptName: script.name, + command: script.command, + terminalId, + worktreePath: input.worktreePath, + }, + }).pipe(Effect.ignoreCause({ log: true })); + + const pendingOutcome = yield* SynchronizedRef.modify(activeRunsRef, (runs) => { + const key = terminalKey(input.threadId, terminalId); + const run = runs.get(key); + if (!run) return [Option.none(), runs] as const; + if (run.pendingOutcome) { + const next = new Map(runs); + next.delete(key); + return [Option.some([run, run.pendingOutcome] as const), next] as const; + } + const next = new Map(runs); + next.set(key, { ...run, startedActivityRecorded: true }); + return [Option.none(), next] as const; + }); + if (Option.isSome(pendingOutcome)) { + yield* recordOutcome(...pendingOutcome.value); + } + return { status: "started", + runId, scriptId: script.id, scriptName: script.name, terminalId, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index d982c2e192c..e59992a1591 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -7237,6 +7237,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ) => Effect.succeed({ status: "started" as const, + runId: "run-setup", scriptId: "setup", scriptName: "Setup", terminalId: "setup-setup", @@ -7310,16 +7311,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - assert.equal(response.sequence, 5); + assert.equal(response.sequence, 3); assert.deepEqual( dispatchedCommands.map((command) => command.type), - [ - "thread.create", - "thread.meta.update", - "thread.activity.append", - "thread.activity.append", - "thread.turn.start", - ], + ["thread.create", "thread.meta.update", "thread.turn.start"], ); assert.deepEqual(createWorktree.mock.calls[0]?.[0], { cwd: "/tmp/project", @@ -7351,15 +7346,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); assert.deepEqual(refreshStatus.mock.calls[0]?.[0], "/tmp/bootstrap-worktree"); - const setupActivities = dispatchedCommands.filter( - (command): command is Extract => - command.type === "thread.activity.append", - ); - assert.deepEqual( - setupActivities.map((command) => command.activity.kind), - ["setup-script.requested", "setup-script.started"], - ); - const finalCommand = dispatchedCommands[4]; + const finalCommand = dispatchedCommands[2]; assertTrue(finalCommand?.type === "thread.turn.start"); if (finalCommand?.type === "thread.turn.start") { assert.equal(finalCommand.bootstrap, undefined); @@ -7471,7 +7458,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("records setup-script failures without aborting bootstrap turn start", () => + it.effect("records setup-script resolution failures without aborting bootstrap turn start", () => Effect.gen(function* () { const dispatchedCommands: Array = []; const createWorktree = vi.fn( @@ -7493,8 +7480,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { new ProjectSetupScriptRunner.ProjectSetupScriptOperationError({ threadId: input.threadId, worktreePath: input.worktreePath, - operation: "openTerminal", - cause: { message: "pty unavailable" }, + operation: "resolveProject", + cause: { message: "project unavailable" }, }), ), ); @@ -7569,14 +7556,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.equal(setupFailureActivity?.activity.kind, "setup-script.failed"); assert.deepEqual(setupFailureActivity?.activity.payload, { - detail: "pty unavailable", + detail: "project unavailable", worktreePath: "/tmp/bootstrap-worktree", }); assertTrue(dispatchedCommands.every((command) => command.type !== "thread.delete")); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("does not misattribute setup activity dispatch failures as setup launch failures", () => + it.effect("does not duplicate runner-owned setup activities in bootstrap", () => Effect.gen(function* () { const dispatchedCommands: Array = []; const createWorktree = vi.fn( @@ -7596,41 +7583,24 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ) => Effect.succeed({ status: "started" as const, + runId: "run-setup", scriptId: "setup", scriptName: "Setup", terminalId: "setup-setup", cwd: "/tmp/bootstrap-worktree", }), ); - let setupActivityAppendAttempt = 0; - yield* buildAppUnderTest({ layers: { gitVcsDriver: { createWorktree, }, orchestrationEngine: { - dispatch: (command) => { - if ( - command.type === "thread.activity.append" && - command.activity.kind.startsWith("setup-script.") - ) { - setupActivityAppendAttempt += 1; - if (setupActivityAppendAttempt === 2) { - return Effect.fail( - new OrchestrationListenerCallbackError({ - listener: "domain-event", - detail: "failed to append setup-script.started activity", - }), - ); - } - } - - return Effect.sync(() => { + dispatch: (command) => + Effect.sync(() => { dispatchedCommands.push(command); return { sequence: dispatchedCommands.length }; - }); - }, + }), readEvents: () => Stream.empty, }, projectSetupScriptRunner: { @@ -7679,19 +7649,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - assert.equal(response.sequence, 4); + assert.equal(response.sequence, 3); assert.deepEqual( dispatchedCommands.map((command) => command.type), - ["thread.create", "thread.meta.update", "thread.activity.append", "thread.turn.start"], + ["thread.create", "thread.meta.update", "thread.turn.start"], ); const setupActivities = dispatchedCommands.filter( (command): command is Extract => command.type === "thread.activity.append", ); - assert.deepEqual( - setupActivities.map((command) => command.activity.kind), - ["setup-script.requested"], - ); + assert.deepEqual(setupActivities, []); assertTrue( setupActivities.every((command) => command.activity.kind !== "setup-script.failed"), ); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index ed25a0880b4..684785be104 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -10,6 +10,7 @@ import { } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Data from "effect/Data"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; @@ -1490,6 +1491,33 @@ it.layer( }), ); + it.effect("runs a terminal command through the shell and reports its exit status", () => + Effect.gen(function* () { + if ((yield* HostProcessPlatform) === "win32") return; + const { manager, ptyAdapter } = yield* createManager(5, { + shellResolver: () => "/bin/zsh", + }); + const exited = yield* Deferred.make>(); + const unsubscribe = yield* manager.subscribe((event) => + event.type === "exited" ? Deferred.succeed(exited, event).pipe(Effect.asVoid) : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + yield* manager.openCommand({ + ...openInput({ terminalId: "setup-setup" }), + command: "bun install", + }); + expect(ptyAdapter.spawnInputs[0]?.args).toEqual(["-o", "nopromptsp", "-ic", "bun install"]); + + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + process?.emitExit({ exitCode: 9, signal: null }); + const exitEvent = yield* Deferred.await(exited); + expect(exitEvent.exitCode).toBe(9); + expect(exitEvent.exitSignal).toBeNull(); + }), + ); + it.effect("bridges PTY callbacks back into Effect-managed event streaming", () => Effect.gen(function* () { const { manager, ptyAdapter, getEvents } = yield* createManager(5, { diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 6dc9e1892b6..ac8c1133ab9 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -127,6 +127,15 @@ export class TerminalManager extends Context.Service< input: TerminalOpenInput, ) => Effect.Effect; + /** + * Open a terminal whose shell executes one finite command and exits with + * that command's status. This is server-only; interactive clients use + * {@link open} and {@link write} instead. + */ + readonly openCommand: ( + input: TerminalCommandOpenInput, + ) => Effect.Effect; + /** * Attach to a terminal and stream its initial snapshot followed by live events. * @@ -227,6 +236,11 @@ export interface ShellCandidate { export interface TerminalStartInput extends TerminalOpenInput { cols: number; rows: number; + command?: string; +} + +export interface TerminalCommandOpenInput extends TerminalOpenInput { + readonly command: string; } export interface TerminalSessionState { @@ -254,6 +268,7 @@ export interface TerminalSessionState { /** Normalized child command name when `hasRunningSubprocess`; cleared when idle. */ childCommandLabel: string | null; runtimeEnv: Record | null; + launchCommand: string | null; } interface PersistHistoryRequest { @@ -564,6 +579,24 @@ function resolveShellCandidates( ]); } +function shellCandidateForCommand( + candidate: ShellCandidate, + command: string, + platform: NodeJS.Platform, +): ShellCandidate { + const shellName = basenameForPlatform(candidate.shell, platform).toLowerCase(); + const existingArgs = candidate.args ?? []; + if (platform === "win32") { + if (shellName === "pwsh.exe" || shellName === "powershell.exe") { + return { ...candidate, args: [...existingArgs, "-Command", command] }; + } + if (shellName === "cmd.exe") { + return { ...candidate, args: [...existingArgs, "/d", "/s", "/c", command] }; + } + } + return { ...candidate, args: [...existingArgs, "-ic", command] }; +} + function isRetryableShellSpawnError(error: PtyAdapter.PtySpawnError): boolean { const queue: unknown[] = [error]; const seen = new Set(); @@ -1831,6 +1864,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func shellCandidates: ReadonlyArray, spawnEnv: NodeJS.ProcessEnv, session: TerminalSessionState, + command: string | null, index = 0, lastError: PtyAdapter.PtySpawnError | null = null, ): Effect.fn.Return< @@ -1845,8 +1879,8 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); } - const candidate = shellCandidates[index]; - if (!candidate) { + const baseCandidate = shellCandidates[index]; + if (!baseCandidate) { return yield* ( lastError ?? new PtyAdapter.PtySpawnError({ @@ -1855,6 +1889,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }) ); } + const candidate = command + ? shellCandidateForCommand(baseCandidate, command, platform) + : baseCandidate; const attempt = yield* Effect.result( options.ptyAdapter.spawn({ @@ -1879,7 +1916,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return yield* spawnError; } - return yield* trySpawn(shellCandidates, spawnEnv, session, index + 1, spawnError); + return yield* trySpawn(shellCandidates, spawnEnv, session, command, index + 1, spawnError); }); const startSession = Effect.fn("terminal.startSession")(function* ( @@ -1922,7 +1959,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func Effect.gen(function* () { const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv); const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv); - const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); + const spawnResult = yield* trySpawn( + shellCandidates, + terminalEnv, + session, + input.command ?? null, + ); ptyProcess = spawnResult.process; startedShell = spawnResult.shellLabel; @@ -2180,8 +2222,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }).pipe(Effect.ignoreCause({ log: true })), ); - const openLocked = Effect.fn("terminal.openLocked")(function* (input: TerminalOpenInput) { + const openLocked = Effect.fn("terminal.openLocked")(function* ( + input: TerminalOpenInput | TerminalCommandOpenInput, + ) { const terminalId = input.terminalId; + const launchCommand = "command" in input ? input.command : null; yield* assertValidCwd(input.cwd); const sessionKey = toSessionKey(input.threadId, terminalId); @@ -2215,6 +2260,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func hasRunningSubprocess: false, childCommandLabel: null, runtimeEnv: normalizedRuntimeEnv(input.env), + launchCommand, }; const createdSession = session; @@ -2235,6 +2281,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func cols, rows, ...(input.env ? { env: input.env } : {}), + ...(launchCommand ? { command: launchCommand } : {}), }, "started", ); @@ -2247,11 +2294,13 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const targetCols = input.cols ?? liveSession.cols; const targetRows = input.rows ?? liveSession.rows; const runtimeEnvChanged = !Equal.equals(currentRuntimeEnv, nextRuntimeEnv); + const nextLaunchCommand = launchCommand; const nextWorktreePath = input.worktreePath !== undefined ? (input.worktreePath ?? null) : liveSession.worktreePath; const launchContextChanged = liveSession.cwd !== input.cwd || runtimeEnvChanged || + liveSession.launchCommand !== nextLaunchCommand || liveSession.worktreePath !== nextWorktreePath; if (launchContextChanged) { @@ -2259,6 +2308,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func liveSession.cwd = input.cwd; liveSession.worktreePath = nextWorktreePath; liveSession.runtimeEnv = nextRuntimeEnv; + liveSession.launchCommand = nextLaunchCommand; liveSession.history = ""; liveSession.pendingHistoryControlSequence = ""; liveSession.pendingProcessEvents = []; @@ -2267,6 +2317,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history); } else if (liveSession.status === "exited" || liveSession.status === "error") { liveSession.runtimeEnv = nextRuntimeEnv; + liveSession.launchCommand = nextLaunchCommand; liveSession.worktreePath = nextWorktreePath; liveSession.history = ""; liveSession.pendingHistoryControlSequence = ""; @@ -2287,6 +2338,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func cols: targetCols, rows: targetRows, ...(input.env ? { env: input.env } : {}), + ...(launchCommand ? { command: launchCommand } : {}), }, "started", ); @@ -2306,6 +2358,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const open: TerminalManager["Service"]["open"] = (input) => withThreadLock(input.threadId, openLocked(input)); + const openCommand: TerminalManager["Service"]["openCommand"] = (input) => + withThreadLock(input.threadId, openLocked(input)); + const openOrAttachForStream = (input: TerminalAttachInput) => withThreadLock( input.threadId, @@ -2627,6 +2682,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func hasRunningSubprocess: false, childCommandLabel: null, runtimeEnv: normalizedRuntimeEnv(input.env), + launchCommand: null, }; const createdSession = session; yield* modifyManagerState((state) => { @@ -2641,6 +2697,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.cwd = input.cwd; session.worktreePath = input.worktreePath ?? null; session.runtimeEnv = normalizedRuntimeEnv(input.env); + session.launchCommand = null; } const cols = input.cols ?? session.cols; @@ -2693,6 +2750,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return TerminalManager.of({ open, + openCommand, attachStream, write, resize, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fda8f1353e3..a09d02eb41f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -502,7 +502,7 @@ const makeWsRpcLayer = ( const appendSetupScriptActivity = (input: { readonly threadId: ThreadId; - readonly kind: "setup-script.requested" | "setup-script.started" | "setup-script.failed"; + readonly kind: "setup-script.failed"; readonly summary: string; readonly createdAt: string; readonly payload: Record; @@ -784,6 +784,16 @@ const makeWsRpcLayer = ( readonly worktreePath: string; }) => { const detail = projectSetupScriptCompatibilityDetail(input.error); + if ( + input.error._tag === "ProjectSetupScriptOperationError" && + input.error.operation === "openTerminal" + ) { + return Effect.logWarning("bootstrap turn start failed to launch setup script", { + threadId: command.threadId, + worktreePath: input.worktreePath, + detail, + }); + } return appendSetupScriptActivity({ threadId: command.threadId, kind: "setup-script.failed", @@ -806,55 +816,6 @@ const makeWsRpcLayer = ( ); }; - const recordSetupScriptStarted = (input: { - readonly requestedAt: string; - readonly worktreePath: string; - readonly scriptId: string; - readonly scriptName: string; - readonly terminalId: string; - }) => - Effect.gen(function* () { - const startedAt = yield* nowIso; - const payload = { - scriptId: input.scriptId, - scriptName: input.scriptName, - terminalId: input.terminalId, - worktreePath: input.worktreePath, - }; - yield* Effect.all([ - appendSetupScriptActivity({ - threadId: command.threadId, - kind: "setup-script.requested", - summary: "Starting setup script", - createdAt: input.requestedAt, - payload, - tone: "info", - }), - appendSetupScriptActivity({ - threadId: command.threadId, - kind: "setup-script.started", - summary: "Setup script started", - createdAt: startedAt, - payload, - tone: "info", - }), - ]).pipe( - Effect.asVoid, - Effect.catch((error) => - Effect.logWarning( - "bootstrap turn start launched setup script but failed to record setup activity", - { - threadId: command.threadId, - worktreePath: input.worktreePath, - scriptId: input.scriptId, - terminalId: input.terminalId, - detail: error.message, - }, - ), - ), - ); - }); - const runSetupProgram = () => Effect.gen(function* () { if (!bootstrap?.runSetupScript || !targetWorktreePath) { @@ -881,13 +842,7 @@ const makeWsRpcLayer = ( if (setupResult.status !== "started") { return Effect.void; } - return recordSetupScriptStarted({ - requestedAt, - worktreePath, - scriptId: setupResult.scriptId, - scriptName: setupResult.scriptName, - terminalId: setupResult.terminalId, - }); + return Effect.void; }, }), ); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index a0b6ded9d89..c749bc27611 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -554,6 +554,30 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Work Log"); }); + it("keeps the completed state in setup lifecycle labels", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Setup script completed"); + }); + it("formats changed file paths from the workspace root", () => { const markup = renderToStaticMarkup( { }); describe("deriveWorkLogEntries", () => { + it("collapses a setup run into its latest state across interleaved activity", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "setup-requested", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "setup-script.requested", + summary: "Starting setup script", + payload: { runId: "setup-run-1" }, + }), + makeActivity({ + id: "unrelated-work", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "runtime.info", + summary: "Created worktree", + }), + makeActivity({ + id: "setup-started", + createdAt: "2026-02-23T00:00:03.000Z", + kind: "setup-script.started", + summary: "Setup script started", + payload: { runId: "setup-run-1" }, + }), + makeActivity({ + id: "setup-completed", + createdAt: "2026-02-23T00:00:04.000Z", + kind: "setup-script.completed", + summary: "Setup script completed", + payload: { runId: "setup-run-1", durationMs: 3_000, exitCode: 0 }, + }), + ]; + + const entries = deriveWorkLogEntries(activities); + + expect(entries).toHaveLength(2); + expect(entries[0]).toMatchObject({ + id: "setup-requested", + createdAt: "2026-02-23T00:00:01.000Z", + label: "Setup script completed", + sourceActivityKind: "setup-script.completed", + }); + expect(entries[0]).not.toHaveProperty("setupRunId"); + expect(entries[1]?.label).toBe("Created worktree"); + }); + + it("does not collapse separate setup runs", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "setup-one", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "setup-script.failed", + summary: "Setup script failed", + payload: { runId: "setup-run-1" }, + }), + makeActivity({ + id: "setup-two", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "setup-script.completed", + summary: "Setup script completed", + payload: { runId: "setup-run-2" }, + }), + ]; + + expect(deriveWorkLogEntries(activities).map((entry) => entry.label)).toEqual([ + "Setup script failed", + "Setup script completed", + ]); + }); + it("omits tool started entries and keeps completed entries", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 3d502ae89ff..cf2fe8657bc 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -100,6 +100,7 @@ export interface WorkLogEntry { interface DerivedWorkLogEntry extends WorkLogEntry { activityKind: OrchestrationThreadActivity["kind"]; collapseKey?: string; + setupRunId?: string; toolCallId?: string; isWorkflowCoordinator?: boolean; /** Shell/monitor/plan tasks: ordinary work-log rows, never spawn CTAs. */ @@ -770,7 +771,7 @@ export function deriveWorkLogEntries( entries.push(toDerivedWorkLogEntry(activity)); } return collapseDerivedWorkLogEntries(entries).map((entry) => { - const { activityKind, collapseKey: _collapseKey, ...rest } = entry; + const { activityKind, collapseKey: _collapseKey, setupRunId: _setupRunId, ...rest } = entry; return Object.assign(rest, { sourceActivityKind: activityKind }); }); } @@ -888,6 +889,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (toolCallId) { entry.toolCallId = toolCallId; } + if (activity.kind.startsWith("setup-script.") && typeof payload?.runId === "string") { + entry.setupRunId = payload.runId; + } let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload); if (!toolLifecycleStatus && activity.kind === "tool.completed") { toolLifecycleStatus = "completed"; @@ -948,6 +952,10 @@ function collapseDerivedWorkLogEntries( entries: ReadonlyArray, ): DerivedWorkLogEntry[] { const collapsed: DerivedWorkLogEntry[] = []; + // Setup lifecycle events describe one operation. Keep the row anchored at + // the launch point while its visible state advances, even when unrelated + // work-log activity lands between lifecycle events. + const setupRowIndex = new Map(); // Subagent rows collapse by spawn group, not adjacency: a workflow run (or // a turn's batch of direct spawns) is ONE narrative event in the chat — a // CTA row that opens the Agents panel — no matter how many agents it @@ -961,6 +969,23 @@ function collapseDerivedWorkLogEntries( // rows (live-test finding, thread 7ac7ef05). const groupKeyByTaskId = new Map(); for (const entry of entries) { + if (entry.setupRunId !== undefined) { + const existingIndex = setupRowIndex.get(entry.setupRunId); + if (existingIndex !== undefined) { + const existing = collapsed[existingIndex]!; + collapsed[existingIndex] = { + ...mergeDerivedWorkLogEntries(existing, entry), + id: existing.id, + createdAt: existing.createdAt, + turnId: existing.turnId ?? null, + setupRunId: entry.setupRunId, + }; + continue; + } + setupRowIndex.set(entry.setupRunId, collapsed.length); + collapsed.push(entry); + continue; + } const isTaskRow = entry.taskId !== undefined && !entry.isBackgroundTask && @@ -1055,6 +1080,7 @@ function mergeDerivedWorkLogEntries( const toolCallId = next.toolCallId ?? previous.toolCallId; const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; const toolData = next.toolData ?? previous.toolData; + const setupRunId = next.setupRunId ?? previous.setupRunId; return { ...previous, ...next, @@ -1070,6 +1096,7 @@ function mergeDerivedWorkLogEntries( ...(toolCallId ? { toolCallId } : {}), ...(toolLifecycleStatus !== undefined ? { toolLifecycleStatus } : {}), ...(toolData !== undefined ? { toolData } : {}), + ...(setupRunId !== undefined ? { setupRunId } : {}), }; }