From 7378b31bfe1d49763b7d4d4a201762b9adf43277 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 14 Nov 2026 02:17:28 -0500 Subject: [PATCH 1/2] feat(threads): an agent can put a thread in a checkout another thread already has An agent could only start a thread in a new worktree cut from its own commit, so a reviewer never saw the uncommitted work a coder thread had just finished (#892). - thread_start takes a per-thread `checkout` (`self` or the id of a thread the caller started) and a per-thread `access` that can only lower access. The folder and branch are resolved when asked and saved with the request; setup finds the folder among the project's worktrees again and reads its branch then. No worktree or branch is made and the setup script is not run. - thread_send takes a `checkout` that moves an idle child there before it reads the message, with the events the user's own checkout switch uses, so it keeps its conversation. A busy child, or one with background work still running, is refused. - A thread in another thread's worktree records it as `worktreeOrigin` (migration 110, a new column only). Such a guest never renames the branch, never offers the folder for removal, and always reverts selectively. A move is written on the thread, and a revert to before it is refused. - Refused while the owning thread is working, for linked computers, and while the new setting "Threads can share a checkout" is off (the default). - The approval card names whose checkout a thread shares and a lowered access level; the row under the message box reads "Shared worktree"; the sidebar hover card names whose worktree it is. --- .../Layers/CheckpointRevert.test.ts | 121 +++++++ .../checkpointing/Layers/CheckpointRevert.ts | 45 ++- apps/server/src/guide/topics.ts | 4 +- apps/server/src/mcp/McpRoomServer.ts | 10 +- .../server/src/mcp/threadToolHandlers.test.ts | 228 +++++++++++- apps/server/src/mcp/threadToolHandlers.ts | 325 ++++++++++++++++-- apps/server/src/mcp/threadTools.ts | 28 +- .../Layers/ChildThreadReactor.test.ts | 67 +++- .../Layers/ChildThreadReactor.ts | 53 ++- .../Layers/ProjectionPipeline.ts | 4 + .../Layers/ProjectionSnapshotQuery.ts | 10 + .../Layers/ProviderCommandReactor.test.ts | 46 +++ .../Layers/ProviderCommandReactor.ts | 16 +- .../Layers/ThreadBootstrap.test.ts | 38 ++ .../orchestration/Layers/ThreadBootstrap.ts | 67 +++- .../src/orchestration/childThreadDecisions.ts | 137 +++++++- .../decider.childThreads.test.ts | 189 ++++++++++ apps/server/src/orchestration/decider.ts | 87 ++--- apps/server/src/orchestration/projector.ts | 6 + .../src/orchestration/threadCheckout.ts | 95 +++++ .../persistence/Layers/ProjectionThreads.ts | 7 + apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/110_ThreadWorktreeOrigin.ts | 15 + .../persistence/Services/ProjectionThreads.ts | 3 + apps/web/src/childThreads.test.ts | 49 +++ apps/web/src/childThreads.ts | 19 + .../components/BranchToolbar.logic.test.ts | 8 + .../web/src/components/BranchToolbar.logic.ts | 16 +- apps/web/src/components/BranchToolbar.tsx | 16 +- .../BranchToolbarEnvModeSelector.tsx | 16 +- .../components/ChatView.agents.browser.tsx | 42 ++- .../web/src/components/ChatView.logic.test.ts | 15 + apps/web/src/components/ChatView.logic.ts | 14 +- .../chat/ComposerChildThreadsPanel.tsx | 58 +++- .../settings/SettingsPanels.browser.tsx | 21 ++ .../components/settings/SettingsPanels.tsx | 11 +- .../components/settings/ThreadsSettings.tsx | 49 ++- .../settings/settingsRowRegistry.tsx | 2 + .../components/sidebar/ThreadHoverCard.tsx | 10 +- apps/web/src/store.ts | 13 + apps/web/src/types.ts | 7 + apps/web/src/worktreeCleanup.test.ts | 12 + apps/web/src/worktreeCleanup.ts | 8 +- docs/design/child-threads.md | 16 +- packages/contracts/src/orchestration.ts | 83 ++++- packages/contracts/src/settings.ts | 11 + packages/shared/src/appPlaces.ts | 17 +- packages/shared/src/serverSettings.ts | 10 + 48 files changed, 2000 insertions(+), 126 deletions(-) create mode 100644 apps/server/src/checkpointing/Layers/CheckpointRevert.test.ts create mode 100644 apps/server/src/orchestration/threadCheckout.ts create mode 100644 apps/server/src/persistence/Migrations/110_ThreadWorktreeOrigin.ts diff --git a/apps/server/src/checkpointing/Layers/CheckpointRevert.test.ts b/apps/server/src/checkpointing/Layers/CheckpointRevert.test.ts new file mode 100644 index 000000000..32106a66c --- /dev/null +++ b/apps/server/src/checkpointing/Layers/CheckpointRevert.test.ts @@ -0,0 +1,121 @@ +import * as NodeFs from "node:fs"; +import * as NodeOs from "node:os"; +import * as NodePath from "node:path"; + +import { + type OrchestrationThread, + ProjectId, + ThreadCheckoutMovedActivityKind, + type ThreadCheckoutMovedPayload, + ThreadId, +} from "@threadlines/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { afterAll, describe, expect, it } from "vite-plus/test"; + +import { + ProjectionSnapshotQuery, + type ProjectionSnapshotQueryShape, +} from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + ProviderService, + type ProviderServiceShape, +} from "../../provider/Services/ProviderService.ts"; +import { CheckpointRevert } from "../Services/CheckpointRevert.ts"; +import { CheckpointStore } from "../Services/CheckpointStore.ts"; +import { makeCheckpointStoreStub } from "../testing/CheckpointStoreStub.ts"; +import { checkpointRefForThreadTurn } from "../Utils.ts"; +import { CheckpointRevertLive } from "./CheckpointRevert.ts"; + +const PROJECT = ProjectId.make("project-1"); +const THREAD = ThreadId.make("thread-reviewer"); +const CODER = ThreadId.make("thread-coder"); + +// A folder that reads as a git checkout: the revert only looks for `.git`. +const worktree = NodeFs.mkdtempSync(NodePath.join(NodeOs.tmpdir(), "threadlines-revert-")); +NodeFs.mkdirSync(NodePath.join(worktree, ".git")); +afterAll(() => NodeFs.rmSync(worktree, { recursive: true, force: true })); + +/** A thread alone in its worktree, three checkpointed turns in. */ +const thread = (overrides: Partial = {}) => + ({ + id: THREAD, + projectId: PROJECT, + worktreePath: worktree, + activities: [], + checkpoints: [1, 2, 3].map((turnCount) => ({ + checkpointTurnCount: turnCount, + checkpointRef: checkpointRefForThreadTurn(THREAD, turnCount), + })), + ...overrides, + }) as unknown as OrchestrationThread; + +/** Where a revert to `turnCount` would work, and how, for a thread no one else is near. */ +const resolve = (subject: OrchestrationThread, turnCount: number) => + Effect.gen(function* () { + const revert = yield* CheckpointRevert; + return yield* revert.resolveContext({ threadId: THREAD, turnCount }); + }).pipe( + Effect.provide( + CheckpointRevertLive.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(CheckpointStore, makeCheckpointStoreStub()), + Layer.succeed(ProjectionSnapshotQuery, { + getThreadDetailById: () => Effect.succeed(Option.some(subject)), + getProjectShellById: () => + Effect.succeed(Option.some({ id: PROJECT, workspaceRoot: "/repo" })), + getShellSnapshot: () => Effect.succeed({ threads: [subject] }), + } as unknown as ProjectionSnapshotQueryShape), + Layer.succeed(ProviderService, { + listSessions: () => Effect.succeed([]), + } as unknown as ProviderServiceShape), + ), + ), + ), + ), + Effect.runPromise, + ); + +describe("CheckpointRevert.resolveContext", () => { + it("restores the whole folder only for a thread whose worktree is its own", async () => { + expect(await resolve(thread(), 1)).toMatchObject({ + kind: "ready", + context: { mode: "workspace" }, + }); + // A guest in another thread's worktree never does, even with that thread + // gone and nothing else pointing at the folder. + const guest = thread({ worktreeOrigin: { kind: "thread", threadId: CODER } }); + expect(await resolve(guest, 1)).toMatchObject({ + kind: "ready", + context: { mode: "selective" }, + }); + }); + + it("never reaches back past the point an agent moved the thread into another checkout", async () => { + const moved = thread({ + worktreeOrigin: { kind: "thread", threadId: CODER }, + activities: [ + { + kind: ThreadCheckoutMovedActivityKind, + payload: { + ownerThreadId: CODER, + ownerTitle: "Build the retry", + fromWorktreePath: null, + toWorktreePath: worktree, + turnCount: 2, + } satisfies ThreadCheckoutMovedPayload, + }, + ] as never, + }); + // Turns one and two changed the folder it left. + const before = await resolve(moved, 1); + expect(before.kind).toBe("unavailable"); + expect(before).toMatchObject({ + detail: expect.stringContaining("moved into another checkout"), + }); + // Undoing only what it did since the move stays possible. + expect((await resolve(moved, 2)).kind).toBe("ready"); + }); +}); diff --git a/apps/server/src/checkpointing/Layers/CheckpointRevert.ts b/apps/server/src/checkpointing/Layers/CheckpointRevert.ts index c79fc0434..a7a6659d0 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointRevert.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointRevert.ts @@ -8,11 +8,18 @@ * * @module CheckpointRevertLive */ -import type { CheckpointRef, OrchestrationThread, ThreadId } from "@threadlines/contracts"; +import { + type CheckpointRef, + type OrchestrationThread, + ThreadCheckoutMovedActivityKind, + ThreadCheckoutMovedPayload, + type ThreadId, +} from "@threadlines/contracts"; import { normalizeWorkspacePath } from "@threadlines/shared/path"; 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 { CheckpointUnavailableError } from "../Errors.ts"; import { isGitRepository } from "../../git/Utils.ts"; @@ -46,6 +53,22 @@ const MAX_CONTESTED_GAP_SCANS = 100; type RevertCheckpointContext = OrchestrationThread["checkpoints"][number]; +const decodeCheckoutMoved = Schema.decodeUnknownOption(ThreadCheckoutMovedPayload); + +/** + * How many checkpointed turns a thread had when an agent last moved it into + * another checkout (the `thread.checkout.moved` activity), or null if it + * never was. + */ +function turnCountAtLastCheckoutMove(thread: OrchestrationThread): number | null { + for (const activity of thread.activities.toReversed()) { + if (activity.kind !== ThreadCheckoutMovedActivityKind) continue; + const payload = decodeCheckoutMoved(activity.payload); + if (Option.isSome(payload)) return payload.value.turnCount; + } + return null; +} + function sameId(left: string | null | undefined, right: string | null | undefined): boolean { if (left === null || left === undefined || right === null || right === undefined) { return false; @@ -78,15 +101,19 @@ const make = Effect.gen(function* () { // owner of its checkout: a dedicated worktree that is not the project // workspace root and is not shared with any other thread or provider // session. Everything else must use selective revert so other actors' work - // survives (issue #37). + // survives (issue #37). A guest in another thread's worktree + // (ThreadWorktreeOrigin) never is, even once that thread is archived or + // deleted and nothing else points at the folder: what is in it was never + // only the guest's. const isIsolatedWorkspaceForThread = Effect.fn("isIsolatedWorkspaceForThread")(function* (input: { readonly threadId: ThreadId; readonly worktreePath: string | null; + readonly guest: boolean; readonly workspaceRoot: string | undefined; readonly cwd: string; }) { const worktreePath = input.worktreePath; - if (!worktreePath) { + if (!worktreePath || input.guest) { return false; } const normalizedCwd = normalizeWorkspacePath(input.cwd); @@ -181,9 +208,21 @@ const make = Effect.gen(function* () { }; } + // Turns from before an agent moved it into another checkout changed the + // folder it left: undoing them here would rewrite files it never touched. + const movedAtTurnCount = turnCountAtLastCheckoutMove(thread); + if (movedAtTurnCount !== null && input.turnCount < movedAtTurnCount) { + return { + kind: "unavailable" as const, + detail: + "This thread moved into another checkout after that point. Its earlier changes are in the folder it left, so they can't be undone from here.", + }; + } + const isolated = yield* isIsolatedWorkspaceForThread({ threadId: input.threadId, worktreePath: thread.worktreePath, + guest: (thread.worktreeOrigin ?? null) !== null, workspaceRoot: project?.workspaceRoot, cwd, }); diff --git a/apps/server/src/guide/topics.ts b/apps/server/src/guide/topics.ts index 106f68201..55811f55d 100644 --- a/apps/server/src/guide/topics.ts +++ b/apps/server/src/guide/topics.ts @@ -80,7 +80,9 @@ Whether the thread's agent may bring in another model on its own is {place:threa {place:threads.agentThreads} decides this: Off, Ask me first (a card in the chat lists the threads and you decide), or Automatic. The same setting covers agents creating or changing scheduled tasks. An agent may start at most 5 threads and send 10 messages to them per message you send. -A started thread gets at most its parent's access level, and the parent's model unless the agent picks another. On a model already in the room it runs at the reasoning you set for that agent there, unless the agent or you name another level; the approval card shows the level. With computers linked in Fleet, an agent can also start a thread on another computer, if that computer allows it ({place:fleet.worksWith}). +A started thread works in a worktree of its own; in a project without git it shares the project's folder. With {place:threads.agentThreadsShareWorktrees} on, an agent can instead put a thread in a checkout that already exists: its own, or that of a thread it started. The new thread sees the files as they are there, uncommitted changes included, which suits a reviewer for work another thread just finished. An agent can also move an idle thread into such a checkout with a message, and the thread keeps its conversation. The thread the checkout belongs to must not be working at that moment. No worktree or branch is made and the project's setup script does not run. The two threads then share one set of files, so only one should work at a time. The thread's row under the message box reads Shared worktree. This works on one computer only, and not for threads an outside agent starts. + +A started thread gets at most its parent's access level, or a lower one the agent asks for, and the parent's model unless the agent picks another. On a model already in the room it runs at the reasoning you set for that agent there, unless the agent or you name another level; the approval card shows the level. With computers linked in Fleet, an agent can also start a thread on another computer, if that computer allows it ({place:fleet.worksWith}). A thread on another computer stays under its parent on both computers and shows how it is doing there, even after its answer came back. Once its answer reached the parent it wraps up and moves to Wrapped like any other, unless someone picked it up again there. Its row has Wrap up (and Reopen in Wrapped), which applies on both computers. To open it, connect this app to that computer.`, }, diff --git a/apps/server/src/mcp/McpRoomServer.ts b/apps/server/src/mcp/McpRoomServer.ts index 700994684..d0e1e5680 100644 --- a/apps/server/src/mcp/McpRoomServer.ts +++ b/apps/server/src/mcp/McpRoomServer.ts @@ -16,7 +16,11 @@ * (scheduleTools.ts) are served here too: they need the same caller identity * and the same per-runtime grant. */ -import { agentInvitesMode, agentThreadsMode } from "@threadlines/shared/serverSettings"; +import { + agentInvitesMode, + agentThreadsMode, + agentThreadsShareWorktrees, +} from "@threadlines/shared/serverSettings"; import type { ThreadId } from "@threadlines/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -264,6 +268,10 @@ const RoomToolHandlersLive = RoomEndpointToolkit.toLayer( Effect.map(agentThreadsMode), Effect.orElseSucceed(() => "off" as const), ), + shareWorktrees: settings.getSettings.pipe( + Effect.map(agentThreadsShareWorktrees), + Effect.orElseSucceed(() => false), + ), git, requests, ...(links !== undefined ? { links } : {}), diff --git a/apps/server/src/mcp/threadToolHandlers.test.ts b/apps/server/src/mcp/threadToolHandlers.test.ts index 07297cacf..bf0b626af 100644 --- a/apps/server/src/mcp/threadToolHandlers.test.ts +++ b/apps/server/src/mcp/threadToolHandlers.test.ts @@ -200,6 +200,10 @@ const makeTools = (options: { readonly gitRepository?: boolean; readonly links?: ThreadToolLinks; readonly userDefault?: ModelSelection; + /** "Threads can share a checkout" (default off, as the setting is). */ + readonly shareWorktrees?: boolean; + /** The branch checked out in a folder; any other folder has none. */ + readonly branches?: Readonly>; }) => Effect.gen(function* () { let caller = options.caller ?? makeThread(); @@ -253,11 +257,23 @@ const makeTools = (options: { providers: Effect.succeed(PROVIDERS), userDefaultModel: Effect.succeed(options.userDefault ?? null), threadsMode: Effect.succeed(options.mode ?? "auto"), + shareWorktrees: Effect.succeed(options.shareWorktrees ?? false), git: { execute: (input) => Effect.succeed({ - exitCode: (options.gitRepository === false ? 128 : 0) as never, - stdout: input.args.includes("--is-inside-work-tree") ? "true\n" : `${HEAD}\n`, + exitCode: (options.gitRepository === false || + (input.args[0] === "symbolic-ref" && options.branches?.[input.cwd] === undefined) + ? 128 + : 0) as never, + stdout: input.args.includes("--is-inside-work-tree") + ? "true\n" + : input.args.includes("--git-common-dir") + ? "/project/.git\n" + : input.args.includes("--show-toplevel") + ? `${input.cwd}\n` + : input.args[0] === "symbolic-ref" + ? `${options.branches?.[input.cwd] ?? ""}\n` + : `${HEAD}\n`, stderr: options.gitRepository === false ? "fatal: not a git repository (or any parent)" : "", stdoutTruncated: false, @@ -615,6 +631,214 @@ describe("thread_send", () => { ); }); +describe("an existing checkout", () => { + /** A thread the parent started that has finished its work, on its own branch. */ + const coder = (patch: Partial = {}) => + makeChild({ + title: "Coder", + branch: "feature/retry", + activities: [], + proposedPlans: [], + ...patch, + }); + const reviewer = { + title: "Review the retry change", + prompt: "Review the uncommitted changes.", + checkout: CHILD, + }; + const branches = { "/worktrees/child": "feature/retry", "/checkout": "main" }; + + it.effect("starts a thread in a finished thread's checkout, with no worktree or setup", () => + Effect.gen(function* () { + const tools = yield* makeTools({ others: [coder()], shareWorktrees: true, branches }); + const result = yield* tools.handlers.thread_start(mainCaller(), { + threads: [ + { ...reviewer, runSetup: true, access: "approval-required" }, + oneThread.threads[0]!, + ], + }); + const [start] = ofType(tools.dispatched, "thread.child.start"); + + expect(result.outcome).toBe("started"); + expect(result.threads?.map((thread) => thread.workspace)).toEqual(["checkout", "worktree"]); + expect(start?.children[0]?.launch).toMatchObject({ + // The folder and branch as git has them now, saved with the request. + checkout: { + threadId: CHILD, + title: "Coder", + worktreePath: "/worktrees/child", + branch: "feature/retry", + }, + workspace: { kind: "project_folder" }, + runSetup: false, + // Less than the caller's own access, because it asked for less. + runtimeMode: "approval-required", + }); + // The thread beside it still gets a worktree of its own. + expect(start?.children[1]?.launch).toMatchObject({ + workspace: { kind: "worktree", baseRef: HEAD }, + runSetup: true, + runtimeMode: "auto-accept-edits", + }); + expect(start?.children[1]?.launch.checkout).toBeUndefined(); + }), + ); + + it.effect("`self` is the caller's own checkout, and never more access than its own", () => + Effect.gen(function* () { + const tools = yield* makeTools({ shareWorktrees: true, branches }); + yield* tools.handlers.thread_start(mainCaller(), { + threads: [{ ...reviewer, checkout: "self", access: "full-access" }], + }); + const [start] = ofType(tools.dispatched, "thread.child.start"); + expect(start?.children[0]?.launch).toMatchObject({ + checkout: { threadId: PARENT, worktreePath: "/checkout", branch: "main" }, + runtimeMode: "auto-accept-edits", + }); + + // In the project folder itself, there is no worktree to name. + const inProject = yield* makeTools({ + caller: makeThread({ worktreePath: null }), + shareWorktrees: true, + }); + yield* inProject.handlers.thread_start(mainCaller(), { + threads: [{ ...reviewer, checkout: "self" }], + }); + expect( + ofType(inProject.dispatched, "thread.child.start")[0]?.children[0]?.launch.checkout, + ).toEqual({ threadId: PARENT, title: "Parent", worktreePath: null, branch: null }); + }), + ); + + it.effect("is refused while it is off, its thread is working, or it isn't the caller's", () => + Effect.gen(function* () { + const off = yield* makeTools({ others: [coder()] }); + const whenOff = yield* off.handlers.thread_start(mainCaller(), { threads: [reviewer] }); + expect(whenOff).toMatchObject({ outcome: "refused" }); + expect(whenOff.detail).toContain("threadlines-place:threads.agentThreadsShareWorktrees"); + + const working = yield* makeTools({ + others: [coder({ queuedFollowUps: [{}] as never })], + shareWorktrees: true, + }); + const whileWorking = yield* working.handlers.thread_start(mainCaller(), { + threads: [reviewer], + }); + expect(whileWorking).toMatchObject({ outcome: "refused" }); + expect(whileWorking.detail).toContain("'Coder' is still working"); + + const stranger = coder({ id: STRANGER, parentThreadId: null, attachedToParent: false }); + const notMine = yield* makeTools({ others: [stranger], shareWorktrees: true }); + const someoneElses = yield* notMine.handlers.thread_start(mainCaller(), { + threads: [{ ...reviewer, checkout: STRANGER }], + }); + expect(someoneElses).toMatchObject({ outcome: "refused" }); + + // A checkout is on this computer, so it can't go with another one. + const elsewhere = yield* working.handlers.thread_start(mainCaller(), { + threads: [reviewer], + computer: "Mac mini", + }); + expect(elsewhere).toMatchObject({ outcome: "refused" }); + + for (const tools of [off, working, notMine]) { + expect(tools.dispatched).toHaveLength(0); + } + }), + ); + + it.effect("moves an idle thread into another checkout with its message", () => + Effect.gen(function* () { + const architect = coder({ + id: STRANGER, + title: "Architect", + worktreePath: null, + branch: null, + }); + const tools = yield* makeTools({ + others: [coder(), architect], + shareWorktrees: true, + branches, + }); + const moved = yield* tools.handlers.thread_send(mainCaller(), { + threadId: STRANGER, + message: "Does the build match the design?", + checkout: CHILD, + }); + expect(moved.outcome).toBe("sent"); + expect(moved.detail).toContain("'Coder's checkout"); + expect(ofType(tools.dispatched, "thread.child.send")[0]).toMatchObject({ + childThreadId: STRANGER, + checkout: { threadId: CHILD, worktreePath: "/worktrees/child", branch: "feature/retry" }, + }); + + // Already there: a plain message, nothing to move. + const again = yield* tools.handlers.thread_send(mainCaller(), { + threadId: CHILD, + message: "One more thing.", + checkout: CHILD, + }); + expect(again.outcome).toBe("refused"); + const there = yield* makeTools({ + others: [coder(), coder({ id: STRANGER, title: "Architect" })], + shareWorktrees: true, + branches, + }); + yield* there.handlers.thread_send(mainCaller(), { + threadId: STRANGER, + message: "One more thing.", + checkout: CHILD, + }); + expect(ofType(there.dispatched, "thread.child.send")[0]?.checkout).toBeUndefined(); + + // A busy thread only moves between its turns. + const busy = yield* makeTools({ + others: [ + coder(), + coder({ id: STRANGER, queuedFollowUps: [{}] as never, worktreePath: null }), + ], + shareWorktrees: true, + branches, + }); + const refused = yield* busy.handlers.thread_send(mainCaller(), { + threadId: STRANGER, + message: "Look at this.", + checkout: CHILD, + }); + expect(refused.outcome).toBe("refused"); + expect(busy.dispatched).toHaveLength(0); + + // Nor one that left a dev server running: its session would stay put. + const serving = yield* makeTools({ + others: [ + coder(), + coder({ + id: STRANGER, + title: "Architect", + worktreePath: null, + session: { + status: "ready", + activeTurnId: null, + pendingBackgroundTaskCount: 1, + awaitedBackgroundTaskCount: 0, + } as never, + }), + ], + shareWorktrees: true, + branches, + }); + const stays = yield* serving.handlers.thread_send(mainCaller(), { + threadId: STRANGER, + message: "Look at this.", + checkout: CHILD, + }); + expect(stays.outcome).toBe("refused"); + expect(stays.detail).toContain("'Architect' still has background work running"); + expect(serving.dispatched).toHaveLength(0); + }), + ); +}); + describe("thread_stop", () => { it.effect("stops a working child, and says when there is nothing to stop", () => Effect.gen(function* () { diff --git a/apps/server/src/mcp/threadToolHandlers.ts b/apps/server/src/mcp/threadToolHandlers.ts index f9362c19c..b95a79bfb 100644 --- a/apps/server/src/mcp/threadToolHandlers.ts +++ b/apps/server/src/mcp/threadToolHandlers.ts @@ -28,6 +28,7 @@ import { type PeerModelReasoning, ChildRequestBatchId, ChildRequestId, + type ChildThreadCheckout, ChildThreadLaunch, type ChildThreadWorkspace, CommandId, @@ -40,11 +41,14 @@ import { type ProviderInteractionMode, ProviderInstanceId, type RoomAgentRef, + type RuntimeMode, type ServerProvider, ThreadId, TurnId, } from "@threadlines/contracts"; import { placeMarkdownLink } from "@threadlines/shared/appPlaces"; +import { areFilesystemPathsEqual } from "@threadlines/shared/path"; +import { saferRuntimeMode } from "@threadlines/shared/runtimeMode"; import { type ChildRequestRefusal, childSendRefusal, @@ -58,7 +62,11 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { childRequestCommandId, derivedUuid } from "../orchestration/childRequestIds.ts"; -import { pendingChildRequest } from "../orchestration/childThreadDecisions.ts"; +import { + checkoutMoveRefusal, + childHasLiveWork, + pendingChildRequest, +} from "../orchestration/childThreadDecisions.ts"; import type { ProjectPlace } from "../project/projectPlace.ts"; import { linkMessageId, linkThreadId } from "../links/linkIds.ts"; import { @@ -123,6 +131,8 @@ export interface ThreadToolDeps { readonly userDefaultModel: Effect.Effect; /** Whether agents may start threads, read live at every call. */ readonly threadsMode: Effect.Effect; + /** Whether a thread may be put in another thread's checkout, read live at every call. */ + readonly shareWorktrees: Effect.Effect; readonly git: Pick; readonly requests: RoomRequestRegistry; /** Linked computers (docs/design/fleet.md); absent on a server without links. */ @@ -178,6 +188,10 @@ const NOT_FOUND = "No open thread with that id is in this project. thread_list s const STILL_SETTING_UP = "That thread is still being set up: its worktree is being made. Try again in a moment."; +const CHECKOUT_OFF = `Putting a thread in another thread's checkout is turned off (${placeMarkdownLink("threads.agentThreadsShareWorktrees")}). Leave \`checkout\` out: a thread you start then gets a worktree of its own.`; +const CHECKOUT_REFERENCE = + "`checkout` takes `self` or the id of an open thread you started on this computer. thread_list shows them."; + /** A turn id no thread has: a caller outside its turn never matches it. */ const NO_TURN = TurnId.make("no-turn"); @@ -298,6 +312,22 @@ const childIsBusy = (child: OrchestrationThread, parent: OrchestrationThread) => (request.status === "starting" || request.status === "queued"), ); +/** A started thread's access: the caller's, or a lower level the call asked for, never more. */ +const launchAccess = ( + spec: { readonly access: RuntimeMode | undefined }, + caller: Pick, +): RuntimeMode => + spec.access === undefined + ? caller.runtimeMode + : saferRuntimeMode(spec.access, caller.runtimeMode); + +/** Whether a thread already works in `checkout`, with its session there too. */ +const isInCheckout = (thread: OrchestrationThread, checkout: ChildThreadCheckout) => + thread.effectiveCwd === null && + (thread.worktreePath === null || checkout.worktreePath === null + ? thread.worktreePath === checkout.worktreePath + : areFilesystemPathsEqual(thread.worktreePath, checkout.worktreePath)); + interface StartSpec { readonly title: string; readonly prompt: string; @@ -305,6 +335,10 @@ interface StartSpec { /** A reasoning level the call named, settled with the agent (settleChildReasoning). */ readonly reasoning: string | undefined; readonly runSetup: boolean; + /** `self` or a thread's id: work in that thread's checkout (resolveCheckout). */ + readonly checkout: string | undefined; + /** An access level the call asked for; the thread gets the safer of it and the caller's. */ + readonly access: RuntimeMode | undefined; } interface StartInput { @@ -322,6 +356,8 @@ interface StartInput { } | undefined; readonly runSetup?: boolean | undefined; + readonly checkout?: string | undefined; + readonly access?: RuntimeMode | undefined; }>; readonly reportBack?: boolean | undefined; /** A linked computer's name or environment id: start them there. */ @@ -542,6 +578,133 @@ export function makeThreadToolHandlers(deps: ThreadToolDeps) { }; }); + /** + * The checkout a `checkout` names (docs/design/child-threads.md, + * "Workspaces"), resolved to a folder now: the caller's own, or that of a + * thread it started, which must not be working. The folder and its branch + * are read from git here and saved with the request, so an approval or a + * restart later puts the thread in the same place. `mover` is the thread + * that would move there (thread_send); a start has none. + */ + const resolveCheckout = ( + caller: OrchestrationThread, + reference: string, + mover?: ThreadId, + ): Effect.Effect<{ readonly checkout: ChildThreadCheckout } | { readonly refused: string }> => + Effect.gen(function* () { + if (!(yield* deps.shareWorktrees)) { + return { refused: CHECKOUT_OFF }; + } + const project = yield* deps.readProject(caller.projectId); + if (project === undefined) { + return { refused: "This thread's project is gone." }; + } + if (project.kind === "general-chat") { + return { refused: "A general chat has no checkout to share." }; + } + const wanted = reference.trim(); + let owner = caller; + if (wanted.toLowerCase() !== "self") { + const ownerId = ThreadId.make(wanted); + if (remoteChildOf(caller.childRequests, ownerId) !== undefined) { + return { + refused: + "That thread is on another computer, and a checkout is only shared on the computer it is on.", + }; + } + if (wanted.length > 0 && pendingChildRequest(caller, ownerId) !== undefined) { + return { refused: STILL_SETTING_UP }; + } + const found = wanted.length > 0 ? yield* deps.readThread(ownerId) : undefined; + if ( + found === undefined || + found.parentThreadId !== caller.id || + found.projectId !== caller.projectId + ) { + return { refused: CHECKOUT_REFERENCE }; + } + if (found.id === mover) { + return { + refused: + "`checkout` names another thread's checkout to move into, never the thread's own. Leave it out.", + }; + } + if (childHasLiveWork(found)) { + return { + refused: `'${found.title}' is still working in its checkout. Wait for it to finish, then try again.`, + }; + } + owner = found; + } + // Its files are being packed for another computer, or went there. + if ((owner.move ?? null) !== null) { + return { + refused: `${owner === caller ? "This thread" : `'${owner.title}'`} is moving to another computer, so its checkout here can't be shared.`, + }; + } + const projectCwd = project.workspaceRoot; + const git = (cwd: string, args: ReadonlyArray) => + deps.git + .execute({ + operation: "ThreadTools.resolveCheckout", + cwd, + args, + allowNonZeroExit: true, + timeoutMs: GIT_TIMEOUT_MS, + maxOutputBytes: 16_384, + }) + .pipe( + Effect.map((result) => (result.exitCode === 0 ? result.stdout.trim() : null)), + Effect.orElseSucceed(() => null), + ); + const commonDir = ["rev-parse", "--path-format=absolute", "--git-common-dir"]; + const repository = yield* git(projectCwd, commonDir); + if (repository === null) { + return { + refused: + "This project has no git repository to read, so its threads share its folder already. Leave `checkout` out.", + }; + } + const whose = owner === caller ? "Your" : `'${owner.title}'s`; + const folder = roomCheckoutCwd(owner, projectCwd) ?? projectCwd; + const inProjectFolder = areFilesystemPathsEqual(folder, projectCwd); + const [folderRepository, topLevel] = inProjectFolder + ? [repository, projectCwd] + : yield* Effect.all([ + git(folder, commonDir), + git(folder, ["rev-parse", "--show-toplevel"]), + ]); + if (folderRepository === null || topLevel === null) { + return { + refused: `${whose} checkout (${folder}) is gone or is no longer a git checkout, so no thread can work there.`, + }; + } + if (!areFilesystemPathsEqual(folderRepository, repository)) { + return { + refused: `${whose} checkout is not part of this project's repository, so it can't be shared.`, + }; + } + // The folder as the owner records it, so both threads name it alike; + // where its session moved somewhere else, that checkout's root. + const worktreePath = inProjectFolder + ? null + : owner.effectiveCwd === null && owner.worktreePath !== null + ? owner.worktreePath + : topLevel; + const branch = yield* git(folder, ["symbolic-ref", "--quiet", "--short", "HEAD"]); + return { + checkout: { + threadId: owner.id, + title: owner.title, + worktreePath: + worktreePath !== null && areFilesystemPathsEqual(worktreePath, projectCwd) + ? null + : worktreePath, + branch: branch !== null && branch.length > 0 ? branch : null, + }, + }; + }); + interface StartIds { readonly batchId: ChildRequestBatchId; readonly children: ReadonlyArray<{ @@ -565,6 +728,8 @@ export function makeThreadToolHandlers(deps: ThreadToolDeps) { readonly count: number; readonly reportBack: boolean; readonly sharedFolder: boolean; + /** How many of them work in another thread's checkout (`checkout`). */ + readonly inCheckouts?: number; }) => { const threads = plural(input.count, "thread"); const these = input.count === 1 ? "it" : "them"; @@ -576,9 +741,14 @@ export function makeThreadToolHandlers(deps: ThreadToolDeps) { : input.reportBack ? `The user decides whether to start ${threads}. If they agree, each one's final reply comes back to you as a message; if they say no, you'll hear at your next turn. Don't ask again, and end your turn if you have nothing else to do.` : `The user decides whether to start ${threads}. If started, ${input.count === 1 ? "it runs" : "they run"} on ${input.count === 1 ? "its" : "their"} own and nothing comes back. Don't ask again.`; + const inCheckouts = input.inCheckouts ?? 0; + const shared = + inCheckouts === 0 + ? "" + : ` ${input.count === 1 ? "It works" : inCheckouts === input.count ? "They work" : `${inCheckouts} of them work`} in a checkout another thread has, with the same files: have only one thread work there at a time.`; return input.sharedFolder - ? `${base} This project has no git repository, so ${input.count === 1 ? "it shares" : "they share"} its folder and can change the same files you do.` - : base; + ? `${base}${shared} This project has no git repository, so ${input.count === 1 ? "it shares" : "they share"} its folder and can change the same files you do.` + : `${base}${shared}`; }; /** @@ -601,11 +771,19 @@ export function makeThreadToolHandlers(deps: ThreadToolDeps) { ? ("asked_user" as const) : ("started" as const); const sharedFolder = open.some( - (request) => request.launch?.workspace.kind === "project_folder", + (request) => + request.launch?.workspace.kind === "project_folder" && + request.launch.checkout === undefined, ); return { outcome, - detail: startedDetail({ outcome, count: open.length, reportBack, sharedFolder }), + detail: startedDetail({ + outcome, + count: open.length, + reportBack, + sharedFolder, + inCheckouts: open.filter((request) => request.launch?.checkout !== undefined).length, + }), threads: open.map((request) => ({ threadId: request.childThreadId, title: request.launch?.title ?? request.childThreadId, @@ -614,7 +792,10 @@ export function makeThreadToolHandlers(deps: ThreadToolDeps) { ? modelLabel(request.launch.modelSelection, providers) : "", ...(request.launch !== undefined ? withReasoningOf(request.launch.modelSelection) : {}), - workspace: request.launch?.workspace.kind ?? "worktree", + workspace: + request.launch?.checkout !== undefined + ? ("checkout" as const) + : (request.launch?.workspace.kind ?? "worktree"), })), } satisfies ThreadStartResult; } @@ -637,7 +818,12 @@ export function makeThreadToolHandlers(deps: ThreadToolDeps) { title: shell.title, model: modelLabel(shell.modelSelection, providers), ...withReasoningOf(shell.modelSelection), - workspace: shell.worktreePath !== null ? "worktree" : "project_folder", + workspace: + (shell.worktreeOrigin ?? null) !== null + ? "checkout" + : shell.worktreePath !== null + ? "worktree" + : "project_folder", })), } satisfies ThreadStartResult; }); @@ -691,7 +877,17 @@ export function makeThreadToolHandlers(deps: ThreadToolDeps) { } satisfies ThreadStartResult; } const reasoning = entry.agent?.reasoning?.trim() || undefined; - specs.push({ title, prompt, agent, reasoning, runSetup: entry.runSetup ?? true }); + const checkout = entry.checkout?.trim() || undefined; + specs.push({ + title, + prompt, + agent, + reasoning, + // An existing checkout was set up by the thread it belongs to. + runSetup: checkout === undefined && (entry.runSetup ?? true), + checkout, + access: entry.access, + }); } const callerTurnId = thread.session?.activeTurnId ?? null; if (callerTurnId === null) { @@ -713,6 +909,13 @@ export function makeThreadToolHandlers(deps: ThreadToolDeps) { }), ].join("\u0000"); if (computerName !== undefined) { + if (specs.some((spec) => spec.checkout !== undefined)) { + return { + outcome: "refused", + detail: + "`checkout` names a checkout on this computer, so it can't go with `computer`. Leave one of them out.", + } satisfies ThreadStartResult; + } return yield* remoteStart({ scope, thread, @@ -788,21 +991,41 @@ export function makeThreadToolHandlers(deps: ThreadToolDeps) { } selections.push(settled); } - const placement = yield* resolveWorkspace(current); - if ("refused" in placement) { + // Each existing checkout is resolved once, however many threads name it. + const checkouts = new Map(); + for (const spec of specs) { + if (spec.checkout === undefined || checkouts.has(spec.checkout)) continue; + const resolved = yield* resolveCheckout(current, spec.checkout); + if ("refused" in resolved) { + return { outcome: "refused", detail: resolved.refused } satisfies ThreadStartResult; + } + checkouts.set(spec.checkout, resolved.checkout); + } + // The rest get worktrees of their own (or the folder of a project without git). + const placement = specs.some((spec) => spec.checkout === undefined) + ? yield* resolveWorkspace(current) + : undefined; + if (placement !== undefined && "refused" in placement) { return { outcome: "refused", detail: placement.refused } satisfies ThreadStartResult; } - const { workspace, sharedFolder } = placement; - const launches = specs.map((spec, index): ChildThreadLaunch => ({ - title: spec.title, - prompt: spec.prompt, - modelSelection: selections[index]!, - runtimeMode: current.runtimeMode, - interactionMode, - reportBack, - runSetup: spec.runSetup, - workspace, - })); + const sharedFolder = placement?.sharedFolder ?? false; + const launches = specs.map((spec, index): ChildThreadLaunch => { + const checkout = spec.checkout === undefined ? undefined : checkouts.get(spec.checkout); + return { + title: spec.title, + prompt: spec.prompt, + modelSelection: selections[index]!, + runtimeMode: launchAccess(spec, current), + interactionMode, + reportBack, + runSetup: spec.runSetup, + workspace: + checkout !== undefined || placement === undefined + ? { kind: "project_folder" } + : placement.workspace, + ...(checkout !== undefined ? { checkout } : {}), + }; + }); if (!launches.every(isLaunch)) { return { outcome: "refused", @@ -837,13 +1060,17 @@ export function makeThreadToolHandlers(deps: ThreadToolDeps) { count: launches.length, reportBack, sharedFolder, + inCheckouts: launches.filter((launch) => launch.checkout !== undefined).length, }), threads: ids.children.map((child, index) => ({ threadId: child.childThreadId, title: launches[index]!.title, model: modelLabel(launches[index]!.modelSelection, providers), ...withReasoningOf(launches[index]!.modelSelection), - workspace: workspace.kind, + workspace: + launches[index]!.checkout !== undefined + ? ("checkout" as const) + : launches[index]!.workspace.kind, })), } satisfies ThreadStartResult; }), @@ -1110,7 +1337,7 @@ export function makeThreadToolHandlers(deps: ThreadToolDeps) { title: spec.title, prompt: spec.prompt, modelSelection: selections[index]!, - runtimeMode: current.runtimeMode, + runtimeMode: launchAccess(spec, current), interactionMode: current.interactionMode, reportBack, runSetup: spec.runSetup, @@ -1395,7 +1622,11 @@ export function makeThreadToolHandlers(deps: ThreadToolDeps) { const threadSend = ( scope: McpInvocationScope, - input: { readonly threadId: string; readonly message: string }, + input: { + readonly threadId: string; + readonly message: string; + readonly checkout?: string | undefined; + }, ): Effect.Effect => Effect.gen(function* () { const gated = yield* gate(scope, "thread_send"); @@ -1403,8 +1634,17 @@ export function makeThreadToolHandlers(deps: ThreadToolDeps) { return gated.refusal satisfies ThreadSendResult; } const { thread } = gated; + const checkoutReference = input.checkout?.trim() || undefined; const remoteChild = remoteChildOf(thread.childRequests, ThreadId.make(input.threadId.trim())); if (remoteChild !== undefined) { + if (checkoutReference !== undefined) { + return { + outcome: "refused", + detail: + "That thread is on another computer, so it can't move into a checkout here. Leave `checkout` out.", + threadId: input.threadId.trim(), + } satisfies ThreadSendResult; + } return yield* remoteSend(scope, thread, remoteChild, input.message); } if (pendingChildRequest(thread, ThreadId.make(input.threadId.trim())) !== undefined) { @@ -1437,6 +1677,7 @@ export function makeThreadToolHandlers(deps: ThreadToolDeps) { "thread_send", childThreadId, text, + ...(checkoutReference !== undefined ? [checkoutReference] : []), ].join("\u0000"); const requestId = ChildRequestId.make(derivedUuid(key, "request")); const childMessageId = MessageId.make(derivedUuid(key, "message")); @@ -1482,6 +1723,30 @@ export function makeThreadToolHandlers(deps: ThreadToolDeps) { detail: refusal.detail, } satisfies ThreadSendResult; } + // A move into another checkout: only between the child's turns, and + // only when it isn't there already. + let checkout: ChildThreadCheckout | undefined; + if (checkoutReference !== undefined && child !== undefined) { + const resolved = yield* resolveCheckout(caller, checkoutReference, child.id); + if ("refused" in resolved) { + return { + outcome: "refused", + detail: resolved.refused, + threadId: childThreadId, + } satisfies ThreadSendResult; + } + const stays = checkoutMoveRefusal(child); + if (stays !== null) { + return { + outcome: "refused", + detail: `'${child.title}' ${stays} Send this again after that.`, + threadId: childThreadId, + } satisfies ThreadSendResult; + } + if (!isInCheckout(child, resolved.checkout)) { + checkout = resolved.checkout; + } + } const outcome = child !== undefined && childIsBusy(child, caller) ? ("queued" as const) @@ -1496,6 +1761,7 @@ export function makeThreadToolHandlers(deps: ThreadToolDeps) { childThreadId, childMessageId, text, + ...(checkout !== undefined ? { checkout } : {}), createdAt: yield* nowIso, }); if (rejected !== undefined) { @@ -1503,7 +1769,16 @@ export function makeThreadToolHandlers(deps: ThreadToolDeps) { } return { outcome, - detail: sentDetail[outcome], + detail: + checkout === undefined + ? sentDetail[outcome] + : `It moved into ${checkout.threadId === caller.id ? "your" : `'${checkout.title}'s`} checkout, with its conversation, and reads your message there.${ + child !== undefined && + child.worktreePath !== null && + (child.worktreeOrigin ?? null) === null + ? " The worktree it had stays on disk, untouched." + : "" + } ${sentDetail[outcome]}`, threadId: childThreadId, } satisfies ThreadSendResult; }), diff --git a/apps/server/src/mcp/threadTools.ts b/apps/server/src/mcp/threadTools.ts index 0f43f1bb2..0ae0a6f17 100644 --- a/apps/server/src/mcp/threadTools.ts +++ b/apps/server/src/mcp/threadTools.ts @@ -14,6 +14,7 @@ import { AgentThreadReadResult, AgentThreadStatusWord, AgentThreadSummary, + RuntimeMode, } from "@threadlines/contracts"; import * as Schema from "effect/Schema"; import { Tool, Toolkit } from "effect/ai"; @@ -97,9 +98,10 @@ export const ThreadStartResult = Schema.Struct({ reasoning: Schema.optional(Schema.String), /** * `worktree`: its own new worktree. `project_folder`: shares the - * project folder. `remote`: on a linked computer, in its own checkout. + * project folder. `checkout`: works in the checkout of the thread its + * `checkout` named. `remote`: on a linked computer, in its own checkout. */ - workspace: Schema.Literals(["worktree", "project_folder", "remote"]), + workspace: Schema.Literals(["worktree", "project_folder", "checkout", "remote"]), /** The linked computer it runs on; absent: this one. */ computer: Schema.optional(Schema.String), }), @@ -154,6 +156,12 @@ const ThreadIdParameter = Schema.String.annotate({ description: "The thread's id, from thread_start or thread_list.", }); +/** thread_start's and thread_send's `checkout`: `what` says what the named thread's folder is for. */ +const checkoutParameter = (what: string) => + Schema.String.annotate({ + description: `${what} \`self\` for your own, or the id of a thread you started (thread_list). That thread must not be working. The user allows this in Settings; a refusal says where.`, + }); + /** What a thread runs at when an agent in a room names a model but no reasoning. */ export const ROOM_REASONING = "the reasoning the user set for an agent in this room on the same model (you first), else their default for new threads on it, else the provider's"; @@ -217,7 +225,7 @@ export const ThreadAgentsTool = readsThreads( export const ThreadStartTool = drivesThreads( Tool.make("thread_start", { description: - "Start new threads in this project to work in parallel. Each gets its own agent session and its own git worktree off your current commit (in a project without git they share the project folder). Give each a short `title` and a self-contained `prompt`: the new thread sees none of this conversation, only its prompt. `agent` picks a model from thread_agents; leave it out to use your own model and reasoning. Unless the user asked for a model or reasoning, prefer your own model or one already in this room (thread_agents' `room`), here or on another computer: a thread on a room agent's model runs at the reasoning the user set for that agent. `agent.reasoning` sets a level instead. Threads get your current access and interaction modes, never more. `runSetup: false` skips the project's setup script, for quick research. `computer` starts them on another linked computer (a name or id from thread_agents' `computers`) in its copy of this project: from your branch when you pushed it, otherwise from that computer's default branch, without your unpushed changes. With `reportBack` (default true) each thread's final reply comes back to you as a message when it finishes; with false they run on their own and nothing comes back. Returns as soon as they are recorded and does not wait for them, so don't poll: carry on, or end your turn if you have nothing else to do. At most 5 threads per call and 5 per user message.", + "Start new threads in this project to work in parallel. Each gets its own agent session and its own git worktree off your current commit (in a project without git they share the project folder). Give each a short `title` and a self-contained `prompt`: the new thread sees none of this conversation, only its prompt. `agent` picks a model from thread_agents; leave it out to use your own model and reasoning. Unless the user asked for a model or reasoning, prefer your own model or one already in this room (thread_agents' `room`), here or on another computer: a thread on a room agent's model runs at the reasoning the user set for that agent. `agent.reasoning` sets a level instead. Threads get your current access and interaction modes, never more; `access` asks for less. `runSetup: false` skips the project's setup script, for quick research. `checkout` puts a thread in a checkout that already exists instead of a new worktree, so it sees the files as they are there, uncommitted changes included (a reviewer for work another thread just finished): no worktree or branch is made and the setup script is not run. It shares that folder with the thread it belongs to, so have only one of them work at a time. `computer` starts them on another linked computer (a name or id from thread_agents' `computers`) in its copy of this project: from your branch when you pushed it, otherwise from that computer's default branch, without your unpushed changes. With `reportBack` (default true) each thread's final reply comes back to you as a message when it finishes; with false they run on their own and nothing comes back. Returns as soon as they are recorded and does not wait for them, so don't poll: carry on, or end your turn if you have nothing else to do. At most 5 threads per call and 5 per user message.", parameters: Schema.Struct({ threads: Schema.Array( Schema.Struct({ @@ -227,6 +235,15 @@ export const ThreadStartTool = drivesThreads( }), agent: Schema.optional(AgentParameter), runSetup: Schema.optional(Schema.Boolean), + checkout: Schema.optional( + checkoutParameter("Work in an existing checkout instead of a new worktree:"), + ), + access: Schema.optional( + RuntimeMode.annotate({ + description: + "Its access: `approval-required` (Supervised: the user approves its commands and edits), `auto-accept-edits`, `auto` or `full-access`. Left out, or more than your own: your own.", + }), + ), }), ), reportBack: Schema.optional(Schema.Boolean), @@ -284,10 +301,13 @@ export const ThreadReadTool = readsThreads( export const ThreadSendTool = drivesThreads( Tool.make("thread_send", { description: - "Send one of your threads a message: more work, a correction or a question. If it is idle it starts on it now; if it is busy it takes it after its current turn. Its reply comes back to you as a message, like a start. Only threads you started whose replies still come back to you. Don't poll; end your turn if you have nothing else to do.", + "Send one of your threads a message: more work, a correction or a question. If it is idle it starts on it now; if it is busy it takes it after its current turn. Its reply comes back to you as a message, like a start. Only threads you started whose replies still come back to you. `checkout` first moves an idle thread into another thread's checkout, keeping its conversation, so it reads your message there (a thread that planned the work looks at what another thread built). Don't poll; end your turn if you have nothing else to do.", parameters: Schema.Struct({ threadId: ThreadIdParameter, message: Schema.String.annotate({ description: "What you want it to do or answer." }), + checkout: Schema.optional( + checkoutParameter("Move the thread into another checkout before it reads the message:"), + ), }), success: ThreadSendResult, }).annotate(Tool.Title, "Message a thread"), diff --git a/apps/server/src/orchestration/Layers/ChildThreadReactor.test.ts b/apps/server/src/orchestration/Layers/ChildThreadReactor.test.ts index 818e3a8da..906a1bc37 100644 --- a/apps/server/src/orchestration/Layers/ChildThreadReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ChildThreadReactor.test.ts @@ -129,7 +129,12 @@ interface Harness { /** The reactor over fakes: threads in a map, dispatches recorded, setup scripted. */ function withReactor( threads: ReadonlyArray, - options: { readonly setupFails?: boolean; readonly completedTurnFor?: MessageId }, + options: { + readonly setupFails?: boolean; + readonly completedTurnFor?: MessageId; + /** "Threads can share a checkout" (default off, as the setting is). */ + readonly shareWorktrees?: boolean; + }, body: (harness: Harness) => Effect.Effect, ) { return Effect.gen(function* () { @@ -204,7 +209,9 @@ function withReactor( ], ), } as unknown as ProjectionTurnRepositoryShape), - ServerSettingsService.layerTest(), + ServerSettingsService.layerTest( + options.shareWorktrees === true ? { agentThreadsShareWorktrees: true } : {}, + ), ); return yield* Effect.gen(function* () { const reactor = yield* makeChildThreadReactor; @@ -402,6 +409,62 @@ describe("ChildThreadReactor", () => { ), ); + it.effect( + "checks a lent checkout again when the child is set up: the setting, then its thread", + () => { + const CODER = ThreadId.make("coder"); + const inCoderCheckout = request({ + status: "starting", + launch: { + ...request().launch!, + title: "Review the retry", + // The project folder: nothing on disk to look for here. + checkout: { threadId: CODER, title: "Build the retry", worktreePath: null, branch: null }, + }, + }); + const coder = (overrides: Partial = {}) => + thread(CODER, { + title: "Build the retry", + parentThreadId: PARENT, + attachedToParent: true, + ...overrides, + }); + const run = ( + threads: ReadonlyArray, + shareWorktrees: boolean, + check: (harness: Harness) => void, + ) => + withReactor(threads, { shareWorktrees }, (harness) => + Effect.gen(function* () { + yield* settle; + check(harness); + }), + ); + return Effect.gen(function* () { + // Asked while it was allowed, approved after it was turned off. + yield* run([parentWith([inCoderCheckout]), coder()], false, (harness) => { + expect(harness.startChildCalls).toEqual([]); + expect(settles(harness)).toMatchObject([{ outcome: "failed" }]); + expect(settles(harness)[0]?.reply?.text).toContain("turned off in Settings"); + }); + // Its thread took up work again while the user decided. + const working = coder({ + session: { status: "running", activeTurnId: TurnId.make("coder-turn") } as never, + }); + yield* run([parentWith([inCoderCheckout]), working], true, (harness) => { + expect(harness.startChildCalls).toEqual([]); + expect(settles(harness)[0]?.reply?.text).toContain( + "'Build the retry' is working in that checkout now", + ); + }); + yield* run([parentWith([inCoderCheckout]), coder()], true, (harness) => { + expect(harness.startChildCalls).toEqual([REQUEST]); + expect(settles(harness)).toEqual([]); + }); + }); + }, + ); + it.effect("after a restart, sends a report that was queued but never sent", () => withReactor( [ diff --git a/apps/server/src/orchestration/Layers/ChildThreadReactor.ts b/apps/server/src/orchestration/Layers/ChildThreadReactor.ts index 1b3b921e1..a03e3c0c6 100644 --- a/apps/server/src/orchestration/Layers/ChildThreadReactor.ts +++ b/apps/server/src/orchestration/Layers/ChildThreadReactor.ts @@ -1,5 +1,6 @@ import { type ChildRequestId, + type ChildThreadCheckout, CommandId, type MessageId, type OrchestrationChildRequest, @@ -9,7 +10,7 @@ import { type ThreadId, } from "@threadlines/contracts"; import { makeDrainableWorker } from "@threadlines/shared/DrainableWorker"; -import { agentThreadsMode } from "@threadlines/shared/serverSettings"; +import { agentThreadsMode, agentThreadsShareWorktrees } from "@threadlines/shared/serverSettings"; import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -23,7 +24,11 @@ import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/Projectio import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { isAgentOrigin } from "@threadlines/shared/roomAgentRequests"; -import { childReportMessageId, clampLaunchToCeiling } from "../childThreadDecisions.ts"; +import { + childHasLiveWork, + childReportMessageId, + clampLaunchToCeiling, +} from "../childThreadDecisions.ts"; import { childDeliveryAfterRestart, childDeliveryForQuietCandidate, @@ -119,6 +124,34 @@ export const makeChildThreadReactor = Effect.gen(function* () { ); }); + /** + * Why a child can't start in the checkout its launch names, or null. The + * checkout was resolved when it was asked for, and the user's yes may come + * much later: the setting may be off by then, or the thread it belongs to + * at work in it again. Its setup looks the folder itself up again + * (ThreadBootstrap). + */ + const lentCheckoutBlock = (parent: OrchestrationThread, checkout: ChildThreadCheckout) => + Effect.gen(function* () { + const allowed = yield* settingsService.getSettings.pipe( + Effect.map(agentThreadsShareWorktrees), + Effect.orElseSucceed(() => false), + ); + if (!allowed) { + return "putting a thread in another thread's checkout was turned off in Settings."; + } + if (checkout.threadId !== parent.id) { + const owner = yield* readThread(checkout.threadId); + if (owner !== undefined && childHasLiveWork(owner)) { + return `'${owner.title}' is working in that checkout now. Start it again once that thread has finished.`; + } + if (owner !== undefined && (owner.move ?? null) !== null) { + return `'${owner.title}' is moving to another computer, so its checkout here can't be shared.`; + } + } + return null; + }); + // One setup per request at a time: the same request seen twice (its event // replayed, a restart resuming it) never runs two. Likewise one pending // quiet check per request and one stop check per child, however many @@ -160,6 +193,22 @@ export const makeChildThreadReactor = Effect.gen(function* () { }); return; } + // Checked once, before the child exists: a setup resumed after its + // start landed must not fail a thread that is already working. + if ( + launch.checkout !== undefined && + (yield* readThread(request.childThreadId)) === undefined + ) { + const blocked = yield* lentCheckoutBlock(parent, launch.checkout); + if (blocked !== null) { + yield* settle(parent, request, { + outcome: "failed", + text: `Couldn't start '${launch.title}': ${blocked}`, + error: blocked, + }); + return; + } + } const exit = yield* Effect.exit( bootstrap.startChild({ lineage: { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index e6b34d50a..46a2d7237 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -558,6 +558,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti attachedToParent: event.payload.attachedToParent === true ? 1 : 0, remoteOrigin: event.payload.remoteOrigin ?? null, scheduledRun: event.payload.scheduledRun ?? null, + worktreeOrigin: event.payload.worktreeOrigin ?? null, outsideOrigin: event.payload.outsideOrigin ?? null, move: null, movedFrom: event.payload.movedFrom ?? null, @@ -1148,6 +1149,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.worktreePath !== undefined ? { worktreePath: event.payload.worktreePath } : {}), + ...(event.payload.worktreeOrigin !== undefined + ? { worktreeOrigin: event.payload.worktreeOrigin } + : {}), updatedAt: event.payload.updatedAt, }); return; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 0c077b3d3..c4092875a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -66,6 +66,7 @@ import { ThreadMovedFrom, ThreadRemoteOrigin, ThreadScheduledRun, + ThreadWorktreeOrigin, OrchestrationOutsideWork, OutsideAgentRef, RoomReviewInput, @@ -168,6 +169,7 @@ type ThreadLineageRow = Pick< | "childRequests" | "remoteOrigin" | "scheduledRun" + | "worktreeOrigin" | "outsideOrigin" | "outsideWork" | "move" @@ -188,6 +190,7 @@ const threadLineageFields = (row: ThreadLineageRow) => ({ childRequests: row.childRequests ?? EMPTY_CHILD_REQUEST_STATE, ...(row.remoteOrigin ? { remoteOrigin: row.remoteOrigin } : {}), ...(row.scheduledRun ? { scheduledRun: row.scheduledRun } : {}), + ...(row.worktreeOrigin ? { worktreeOrigin: row.worktreeOrigin } : {}), ...(row.outsideOrigin ? { outsideOrigin: row.outsideOrigin } : {}), ...(row.outsideWork && row.outsideWork.length > 0 ? { outsideWork: row.outsideWork } : {}), ...threadMoveFields(row), @@ -222,6 +225,7 @@ const shellLineageFields = (row: ThreadLineageRow) => { pendingChildApproval: hasPendingChildApproval(childRequests), ...(row.remoteOrigin ? { remoteOrigin: row.remoteOrigin } : {}), ...(row.scheduledRun ? { scheduledRun: row.scheduledRun } : {}), + ...(row.worktreeOrigin ? { worktreeOrigin: row.worktreeOrigin } : {}), ...(row.outsideOrigin ? { outsideOrigin: row.outsideOrigin } : {}), ...(row.outsideWork && row.outsideWork.length > 0 ? { outsideWork: row.outsideWork } : {}), ...threadMoveFields(row), @@ -292,6 +296,7 @@ const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( ), remoteOrigin: Schema.optional(Schema.NullOr(Schema.fromJsonString(ThreadRemoteOrigin))), scheduledRun: Schema.optional(Schema.NullOr(Schema.fromJsonString(ThreadScheduledRun))), + worktreeOrigin: Schema.optional(Schema.NullOr(Schema.fromJsonString(ThreadWorktreeOrigin))), outsideOrigin: Schema.optional(Schema.NullOr(Schema.fromJsonString(OutsideAgentRef))), outsideWork: Schema.optional( Schema.NullOr(Schema.fromJsonString(Schema.Array(OrchestrationOutsideWork))), @@ -936,6 +941,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settings_requests AS "settingsRequests", remote_origin_json AS "remoteOrigin", scheduled_run_json AS "scheduledRun", + worktree_origin_json AS "worktreeOrigin", outside_origin_json AS "outsideOrigin", outside_work_json AS "outsideWork", move_json AS "move", @@ -1003,6 +1009,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settings_requests AS "settingsRequests", remote_origin_json AS "remoteOrigin", scheduled_run_json AS "scheduledRun", + worktree_origin_json AS "worktreeOrigin", outside_origin_json AS "outsideOrigin", outside_work_json AS "outsideWork", move_json AS "move", @@ -1072,6 +1079,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settings_requests AS "settingsRequests", remote_origin_json AS "remoteOrigin", scheduled_run_json AS "scheduledRun", + worktree_origin_json AS "worktreeOrigin", outside_origin_json AS "outsideOrigin", outside_work_json AS "outsideWork", move_json AS "move", @@ -1904,6 +1912,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settings_requests AS "settingsRequests", remote_origin_json AS "remoteOrigin", scheduled_run_json AS "scheduledRun", + worktree_origin_json AS "worktreeOrigin", outside_origin_json AS "outsideOrigin", outside_work_json AS "outsideWork", move_json AS "move", @@ -3699,6 +3708,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settings_requests AS "settingsRequests", remote_origin_json AS "remoteOrigin", scheduled_run_json AS "scheduledRun", + worktree_origin_json AS "worktreeOrigin", outside_origin_json AS "outsideOrigin", outside_work_json AS "outsideWork", move_json AS "move", diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 5b151e969..94c9cf702 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -5776,6 +5776,52 @@ describe("ProviderCommandReactor", () => { expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe(PROJECT_WORKTREE_ROOT); }); + it("never renames the branch of a worktree another thread lent", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const guest = ThreadId.make("thread-guest"); + + // A reviewer put in thread-1's worktree, whose branch still has its temporary name. + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-create-guest"), + threadId: guest, + projectId: asProjectId("project-1"), + title: "Review it", + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5-codex"), + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: "threadlines/1234abcd", + worktreePath: PROJECT_WORKTREE_ROOT, + parentThreadId: ThreadId.make("thread-1"), + attachedToParent: false, + worktreeOrigin: { kind: "thread", threadId: ThreadId.make("thread-1") }, + createdAt: now, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-guest"), + threadId: guest, + message: { + messageId: asMessageId("user-message-guest"), + role: "user", + text: "Review the uncommitted changes.", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.generateBranchName).not.toHaveBeenCalled(); + expect(harness.renameBranch).not.toHaveBeenCalled(); + }); + it("forwards codex model options through session start and turn send", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 1799815b6..cdad52849 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -2842,12 +2842,16 @@ const make = Effect.gen(function* () { ...(event.payload.titleSeed !== undefined ? { titleSeed: event.payload.titleSeed } : {}), }; - yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ - threadId: event.payload.threadId, - branch: thread.branch, - worktreePath: thread.worktreePath, - ...generationInput, - }).pipe(Effect.forkScoped); + // A worktree another thread lent is that thread's to name + // (ThreadWorktreeOrigin): a guest's first message never renames it. + if ((thread.worktreeOrigin ?? null) === null) { + yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ + threadId: event.payload.threadId, + branch: thread.branch, + worktreePath: thread.worktreePath, + ...generationInput, + }).pipe(Effect.forkScoped); + } if (canReplaceThreadTitle(thread.title, event.payload.titleSeed)) { yield* maybeGenerateThreadTitleForFirstTurn({ diff --git a/apps/server/src/orchestration/Layers/ThreadBootstrap.test.ts b/apps/server/src/orchestration/Layers/ThreadBootstrap.test.ts index 0a32df898..af355975a 100644 --- a/apps/server/src/orchestration/Layers/ThreadBootstrap.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadBootstrap.test.ts @@ -453,6 +453,44 @@ describe("ThreadBootstrap.startChild", () => { }).pipe(Effect.provide(world.layer)); }); + it.effect("puts a child in another thread's worktree as a guest: no worktree, no setup", () => { + const world = makeWorld(); + const coder = "coder" as OrchestrationThread["id"]; + const inCoderWorktree = input({ + launch: launch({ + workspace: { kind: "project_folder" }, + // A saved launch that still asks for setup never runs it there. + runSetup: true, + checkout: { + threadId: coder, + title: "Build the retry", + worktreePath: "/worktrees/coder", + branch: "feature/retry", + }, + }), + }); + return Effect.gen(function* () { + // Asked for, then removed before the user said yes: nothing starts there. + const gone = yield* Effect.flip(startChild(inCoderWorktree)); + expect(gone.detail).toContain("no longer a worktree of this project"); + expect(world.starts()).toHaveLength(0); + + // Still this project's worktree, on a branch renamed since it was asked for. + world.worktrees.push({ path: "/worktrees/coder", branch: "feature/retry-backoff" }); + yield* startChild(inCoderWorktree); + + expect(world.createWorktreeCalls).toHaveLength(0); + expect(world.setupRuns).toHaveLength(0); + const [start] = world.starts(); + expect(start?.create).toMatchObject({ + branch: "feature/retry-backoff", + worktreePath: "/worktrees/coder", + worktreeOrigin: { kind: "thread", threadId: coder }, + }); + expect(start?.setupScriptClaim).toBeUndefined(); + }).pipe(Effect.provide(world.layer)); + }); + it.effect( "finishes a child an older build made without its first turn, never claiming its setup twice", () => { diff --git a/apps/server/src/orchestration/Layers/ThreadBootstrap.ts b/apps/server/src/orchestration/Layers/ThreadBootstrap.ts index 45e182da0..571fb505a 100644 --- a/apps/server/src/orchestration/Layers/ThreadBootstrap.ts +++ b/apps/server/src/orchestration/Layers/ThreadBootstrap.ts @@ -36,6 +36,7 @@ import { type ThreadId, } from "@threadlines/contracts"; import { worktreeFolderNameForBranch } from "@threadlines/shared/git"; +import { areFilesystemPathsEqual } from "@threadlines/shared/path"; import { setupProjectScript } from "@threadlines/shared/projectScripts"; import { basename, join } from "node:path"; @@ -131,6 +132,16 @@ class LockedWorktreeError extends Data.TaggedError("LockedWorktreeError")<{ } } +/** Another thread's worktree a thread was to be put in that the project no longer has. */ +class LentWorktreeGoneError extends Data.TaggedError("LentWorktreeGoneError")<{ + readonly path: string; + readonly title: string; +}> { + get detail(): string { + return `'${this.title}'s checkout (${this.path}) is no longer a worktree of this project.`; + } +} + const failedAt = (stage: ChildBootstrapStage) => (cause: unknown) => new SetupStageFailure({ stage, cause }); @@ -174,6 +185,14 @@ interface SetupPlan { readonly base: WorktreeBase; } | undefined; + /** + * Another thread's worktree the new thread is put in instead of one of its + * own (ChildThreadLaunch.checkout). It was resolved when it was asked for; + * the worktree stage finds it again among this project's worktrees, so a + * yes given much later never lands in a folder that has become something + * else, and the thread records the branch it is on now. + */ + readonly lentWorktree?: { readonly path: string; readonly title: string } | undefined; readonly runSetup: boolean; /** * The first work (a turn, or a goal set on a draft); its command id is the @@ -294,6 +313,27 @@ const make = Effect.gen(function* () { } satisfies PlacedWorktree; }); + /** + * Stage 1 for a thread put in another thread's worktree: nothing is made. + * The folder must still be one of this project's worktrees; its branch is + * read now, whatever it was when the thread was asked for. + */ + const findLentWorktree = (projectId: ProjectId, lent: NonNullable) => + Effect.gen(function* () { + const project = yield* projectionSnapshotQuery + .getProjectShellById(projectId) + .pipe(Effect.map(Option.getOrUndefined)); + const worktrees = + project === undefined + ? [] + : yield* gitWorkflow.listWorktrees({ cwd: project.workspaceRoot }); + const found = worktrees.find((entry) => areFilesystemPathsEqual(entry.path, lent.path)); + if (found === undefined) { + return yield* new LentWorktreeGoneError(lent); + } + return { branch: found.branch ?? null }; + }); + /** * A start the decider refused for good leaves no thread to hold the * worktree this run made: it goes, and its branch with it, without force, @@ -457,6 +497,13 @@ const make = Effect.gen(function* () { ? undefined : yield* ensureWorktree(plan.worktree).pipe(Effect.mapError(failedAt("worktree"))); + const lent = + plan.lentWorktree === undefined || existing !== undefined || plan.create === undefined + ? undefined + : yield* findLentWorktree(plan.create.projectId, plan.lentWorktree).pipe( + Effect.mapError(failedAt("worktree")), + ); + yield* plan.proceed("turn"); const projectId = existing?.projectId ?? plan.create?.projectId; const worktreePath = @@ -479,6 +526,7 @@ const make = Effect.gen(function* () { ...(placed !== undefined ? { branch: placed.branch, worktreePath: placed.path } : {}), + ...(lent !== undefined ? { branch: lent.branch } : {}), }, } : {}), @@ -616,8 +664,13 @@ const make = Effect.gen(function* () { modelSelection: launch.modelSelection, runtimeMode: launch.runtimeMode, interactionMode: launch.interactionMode, - branch: null, - worktreePath: null, + // Another thread's checkout, as it was when it was asked for; its own + // worktree, when it gets one, is made and filled in by the worktree stage. + branch: launch.checkout?.branch ?? null, + worktreePath: launch.checkout?.worktreePath ?? null, + ...(launch.checkout !== undefined && launch.checkout.worktreePath !== null + ? { worktreeOrigin: { kind: "thread" as const, threadId: launch.checkout.threadId } } + : {}), ...(input.lineage.kind === "local" ? { parentThreadId: input.lineage.parentThreadId, @@ -644,14 +697,20 @@ const make = Effect.gen(function* () { }, create: childCreate(input, launch), worktree: - workspace.kind === "worktree" + workspace.kind === "worktree" && launch.checkout === undefined ? { projectCwd: workspace.projectCwd, branch: childWorktreeBranch(input.requestId), base: { kind: "ref", ref: workspace.baseRef }, } : undefined, - runSetup: launch.runSetup, + ...(launch.checkout !== undefined && launch.checkout.worktreePath !== null + ? { + lentWorktree: { path: launch.checkout.worktreePath, title: launch.checkout.title }, + } + : {}), + // Another thread's checkout was set up by that thread. + runSetup: launch.runSetup && launch.checkout === undefined, first: { type: "thread.turn.start", commandId: childRequestCommandId(input.requestId, "turn"), diff --git a/apps/server/src/orchestration/childThreadDecisions.ts b/apps/server/src/orchestration/childThreadDecisions.ts index df4ef6f9f..f095a6d8b 100644 --- a/apps/server/src/orchestration/childThreadDecisions.ts +++ b/apps/server/src/orchestration/childThreadDecisions.ts @@ -17,6 +17,8 @@ import { ChildRequestBatchId, ChildRequestId, type ChildRequestOutcome, + type ChildThreadCheckout, + EventId, MessageId, type OrchestrationChildRequest, type OrchestrationCommand, @@ -26,6 +28,8 @@ import { type OrchestrationThread, type ProviderInteractionMode, type RuntimeMode, + ThreadCheckoutMovedActivityKind, + type ThreadCheckoutMovedPayload, type ThreadId, type RemoteChildThread, } from "@threadlines/contracts"; @@ -47,6 +51,7 @@ import { isAgentOrigin } from "@threadlines/shared/roomAgentRequests"; import { activeParticipants } from "@threadlines/shared/threadParticipants"; import { handOffReplyText } from "./agentRequestDecisions.ts"; +import { checkoutSelectedEvents } from "./threadCheckout.ts"; import { currentAgentModel, messageAgentModels } from "./messageAgentModels.ts"; type PlannedEvent = Omit; @@ -111,6 +116,7 @@ export function claimsServerLineage(fields: { readonly remoteOrigin?: unknown; readonly scheduledRun?: unknown; readonly outsideOrigin?: unknown; + readonly worktreeOrigin?: unknown; }): boolean { return ( fields.parentThreadId !== undefined || @@ -118,7 +124,8 @@ export function claimsServerLineage(fields: { fields.attachedToParent !== undefined || fields.remoteOrigin !== undefined || fields.scheduledRun !== undefined || - fields.outsideOrigin !== undefined + fields.outsideOrigin !== undefined || + fields.worktreeOrigin !== undefined ); } @@ -337,6 +344,64 @@ const quotedList = (titles: ReadonlyArray) => ? `'${titles[0]}'` : `${titles.length} threads (${titles.map((title) => `'${title}'`).join(", ")})`; +/** + * Why `parent`'s agent may not put a thread in `checkout`, or null: a + * checkout is lent only by the parent itself or by a thread it started, in + * the same project. Whether its owner is working is the caller's to check + * when the thread is about to work there (thread_start, a child's setup, + * thread_send); a request waiting for the user's yes outlives that moment. + */ +export function checkoutLendingRefusal( + readModel: OrchestrationReadModel, + parent: OrchestrationThread, + checkout: ChildThreadCheckout, +): string | null { + const owner = findThread(readModel, checkout.threadId); + if (owner === null) { + return `'${checkout.title}' is gone, so no thread can be put in its checkout.`; + } + if ( + owner.projectId !== parent.projectId || + (owner.id !== parent.id && owner.parentThreadId !== parent.id) + ) { + return "A thread can only be put in the checkout of the thread that starts it, or of one that thread started."; + } + return null; +} + +/** + * Why a checkout can't take another thread right now, or null: the thread it + * belongs to is at work in it. The parent's own is always free to lend: it is + * in its turn whenever it asks. + */ +export function checkoutOwnerBusy( + readModel: OrchestrationReadModel, + parent: OrchestrationThread, + checkout: ChildThreadCheckout, +): string | null { + const owner = checkout.threadId === parent.id ? null : findThread(readModel, checkout.threadId); + return owner !== null && childHasLiveWork(owner) + ? `'${owner.title}' is working in its checkout. Wait for it to finish, then try again.` + : null; +} + +/** + * Why a thread can't move to another checkout now, as the rest of a sentence + * about it ("... is busy"), or null. It moves only between its turns, and + * only with nothing of its still running: a session with background work + * left (a dev server, say) stays in the folder it is in until that ends, so + * its next turn would read the message there, not where it was sent. + */ +export function checkoutMoveRefusal(thread: OrchestrationThread): string | null { + if (childHasLiveWork(thread)) { + return "is busy, and a thread only moves to another checkout between its turns."; + } + if ((thread.session?.pendingBackgroundTaskCount ?? 0) > 0) { + return "still has background work running where it is (a dev server, say), and it can't move while that runs. Stop it first."; + } + return null; +} + /** Start threads (`thread.child.start`). */ export function decideChildStart( readModel: OrchestrationReadModel, @@ -370,6 +435,18 @@ export function decideChildStart( if ((child.launch.target === undefined) !== (child.launch.workspace.kind !== "remote")) { return refuse("A thread on another computer needs that computer's workspace, and only it."); } + const checkout = child.launch.checkout; + if (checkout !== undefined) { + if (child.launch.workspace.kind !== "project_folder" || child.launch.runSetup) { + return refuse( + "A thread that works in another thread's checkout gets no worktree or setup of its own.", + ); + } + const lent = checkoutLendingRefusal(readModel, parent, checkout); + if (lent !== null) { + return refuse(lent); + } + } } return command.children.map((child): PlannedEvent => ({ ...baseFor(parent.id), @@ -469,12 +546,70 @@ export function decideChildSend( ) { return refuse("That message was already sent."); } + // A move into another thread's checkout first: only between the child's + // turns, with nothing of its still running in the folder it leaves, and + // not while the thread that checkout belongs to is at work in it. + const checkout = command.checkout; + if (checkout !== undefined) { + const stays = checkoutMoveRefusal(child); + const lent = + checkout.threadId === child.id + ? "A thread moves into another thread's checkout, never its own." + : (checkoutLendingRefusal(readModel, parent, checkout) ?? + checkoutOwnerBusy(readModel, parent, checkout) ?? + (stays !== null ? `That thread ${stays}` : null)); + if (lent !== null) { + return refuse(lent); + } + } const fromThread = { threadId: parent.id, requestId: command.requestId, kind: "request" as const, }; return [ + ...(checkout === undefined + ? [] + : [ + ...checkoutSelectedEvents({ + thread: child, + branch: checkout.branch, + worktreePath: checkout.worktreePath, + // The project folder is nobody's worktree: only a worktree is lent. + ...(checkout.worktreePath !== null + ? { worktreeOrigin: { kind: "thread" as const, threadId: checkout.threadId } } + : {}), + occurredAt: command.createdAt, + base: () => baseFor(child.id), + }), + // The move, on the child's record: its line in the chat, and the + // point no revert reaches back past (CheckpointRevert). + { + ...baseFor(child.id), + type: "thread.activity-appended", + payload: { + threadId: child.id, + activity: { + id: EventId.make(crypto.randomUUID()), + tone: "info", + kind: ThreadCheckoutMovedActivityKind, + summary: `Moved into the checkout of '${checkout.threadId === parent.id ? parent.title : checkout.title}'`, + payload: { + ownerThreadId: checkout.threadId, + ownerTitle: checkout.title, + fromWorktreePath: child.worktreePath, + toWorktreePath: checkout.worktreePath, + turnCount: child.checkpoints.reduce( + (latest, checkpoint) => Math.max(latest, checkpoint.checkpointTurnCount), + 0, + ), + } satisfies ThreadCheckoutMovedPayload, + turnId: null, + createdAt: command.createdAt, + }, + }, + } satisfies PlannedEvent, + ]), { ...baseFor(parent.id), type: "thread.child-request-submitted", diff --git a/apps/server/src/orchestration/decider.childThreads.test.ts b/apps/server/src/orchestration/decider.childThreads.test.ts index 3963b2e81..ba551c5bb 100644 --- a/apps/server/src/orchestration/decider.childThreads.test.ts +++ b/apps/server/src/orchestration/decider.childThreads.test.ts @@ -1011,6 +1011,195 @@ async function familyWithRemoteChild() { return { started, model: recorded.model }; } +describe("decider child threads in another thread's checkout", () => { + const CODER = ThreadId.make("coder"); + const ARCHITECT = ThreadId.make("architect"); + const ours = { parentThreadId: PARENT, attachedToParent: true }; + const coderCheckout = { + threadId: CODER, + title: "Build the retry", + worktreePath: "/worktrees/coder", + branch: "feature/retry", + }; + const inCoderCheckout: Partial = { + workspace: { kind: "project_folder" }, + runSetup: false, + checkout: coderCheckout, + }; + /** A parent mid-turn with a finished coder in its own worktree and an architect in the project folder. */ + const family = (architect: Partial = {}) => + readModel([ + thread(PARENT, { session: session(PARENT) }), + thread(CODER, { ...ours, worktreePath: "/worktrees/coder", branch: "feature/retry" }), + thread(ARCHITECT, { ...ours, ...architect }), + ]); + const sendToArchitect = ( + checkout: typeof coderCheckout, + ): Extract => ({ + type: "thread.child.send", + commandId: CommandId.make("send-architect"), + threadId: PARENT, + requestId: ChildRequestId.make("send-architect"), + from: PRIMARY, + callerTurnId: CALLER_TURN, + childThreadId: ARCHITECT, + childMessageId: MessageId.make("architect-message"), + text: "Does the build match the design?", + checkout, + createdAt: at(2), + }); + + it("starts a thread only in the checkout of its parent or a sibling, with nothing of its own", async () => { + const started = await run(family(), startCommand("auto", inCoderCheckout)); + expect(threadIn(started.model, PARENT).childRequests.open[0]?.launch?.checkout).toEqual( + coderCheckout, + ); + // A checkout that is lent comes with no worktree or setup of the thread's own. + expect( + await refused(family(), startCommand("auto", { ...inCoderCheckout, runSetup: true })), + ).toBe(true); + expect( + await refused(family(), startCommand("auto", { checkout: coderCheckout, runSetup: false })), + ).toBe(true); + // Nor is a thread outside the family's ever lent. + const stranger = readModel([ + thread(PARENT, { session: session(PARENT) }), + thread(CODER, { worktreePath: "/worktrees/coder" }), + ]); + expect(await refused(stranger, startCommand("auto", inCoderCheckout))).toBe(true); + }); + + it("makes the thread a guest in that worktree until it moves somewhere else", async () => { + const created = await run(family(), { + type: "thread.create", + commandId: CommandId.make("create-reviewer"), + threadId: CHILD, + projectId, + title: "Review the retry", + modelSelection: model, + runtimeMode: "auto-accept-edits", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: "feature/retry", + worktreePath: "/worktrees/coder", + worktreeOrigin: { kind: "thread", threadId: CODER }, + ...ours, + parentTurnId: CALLER_TURN, + createdAt: at(2), + }); + expect(threadIn(created.model, CHILD)).toMatchObject({ + worktreePath: "/worktrees/coder", + worktreeOrigin: { kind: "thread", threadId: CODER }, + }); + // A branch-only update (the owner's rename, followed) keeps it a guest. + const renamed = await run(created.model, { + type: "thread.meta.update", + commandId: CommandId.make("follow-rename"), + threadId: CHILD, + branch: "feature/retry-backoff", + worktreePath: "/worktrees/coder", + }); + expect(threadIn(renamed.model, CHILD).worktreeOrigin).toEqual({ + kind: "thread", + threadId: CODER, + }); + // The user moving it to the project folder ends the stay. + const moved = await run(renamed.model, { + type: "thread.checkout.select", + commandId: CommandId.make("move-reviewer"), + threadId: CHILD, + branch: "main", + worktreePath: null, + }); + expect(threadIn(moved.model, CHILD)).toMatchObject({ + worktreePath: null, + worktreeOrigin: null, + }); + // Only a thread another thread started is ever a guest. + expect( + await refused(family(), { + type: "thread.create", + commandId: CommandId.make("create-loose"), + threadId: CHILD, + projectId, + title: "Loose", + modelSelection: model, + runtimeMode: "auto-accept-edits", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: "feature/retry", + worktreePath: "/worktrees/coder", + worktreeOrigin: { kind: "thread", threadId: CODER }, + createdAt: at(2), + }), + ).toBe(true); + }); + + it("moves an idle child into the checkout before its message, and never a busy one", async () => { + const sent = await run(family(), sendToArchitect(coderCheckout)); + const architect = threadIn(sent.model, ARCHITECT); + expect(architect).toMatchObject({ + worktreePath: "/worktrees/coder", + branch: "feature/retry", + worktreeOrigin: { kind: "thread", threadId: CODER }, + }); + expect(architect.queuedFollowUps?.map((entry) => entry.text)).toEqual([ + "Does the build match the design?", + ]); + // The move is decided before the message that is read there, and stays + // on the thread's record: no revert reaches back past it. + expect(sent.events.map((event) => event.type)).toEqual([ + "thread.meta-updated", + "thread.activity-appended", + "thread.child-request-submitted", + "thread.message-sent", + "thread.follow-up-queued", + ]); + expect(architect.activities.at(-1)).toMatchObject({ + kind: "thread.checkout.moved", + summary: "Moved into the checkout of 'Build the retry'", + payload: { ownerThreadId: CODER, fromWorktreePath: null, toWorktreePath: "/worktrees/coder" }, + }); + + // Its first message still waits to run, so a second move is refused. + expect( + await refused(sent.model, { + ...sendToArchitect(coderCheckout), + commandId: CommandId.make("send-architect-again"), + requestId: ChildRequestId.make("send-architect-again"), + childMessageId: MessageId.make("architect-message-again"), + }), + ).toBe(true); + + const busy = family({ session: session(ARCHITECT, { activeTurnId: TurnId.make("turn-a") }) }); + expect(await refused(busy, sendToArchitect(coderCheckout))).toBe(true); + // Idle, but with a dev server left running: its session would stay where + // it is, and read the message there. + const servesStill = family({ + session: session(ARCHITECT, { + status: "ready", + activeTurnId: null, + pendingBackgroundTaskCount: 1, + awaitedBackgroundTaskCount: 0, + }), + }); + expect(await refused(servesStill, sendToArchitect(coderCheckout))).toBe(true); + // The coder took up work again between the call and the decision. + const coderWorking = readModel([ + thread(PARENT, { session: session(PARENT) }), + thread(CODER, { + ...ours, + worktreePath: "/worktrees/coder", + session: session(CODER, { activeTurnId: TurnId.make("coder-turn") }), + }), + thread(ARCHITECT, ours), + ]); + expect(await refused(coderWorking, sendToArchitect(coderCheckout))).toBe(true); + // A thread is never moved into its own checkout. + expect( + await refused(family(), sendToArchitect({ ...coderCheckout, threadId: ARCHITECT })), + ).toBe(true); + }); +}); + describe("decider child threads on a linked computer", () => { it("keeps a start for a linked computer on the parent only, waiting to be sent", async () => { const { started, model: family } = await familyWithRemoteChild(); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 362a5662e..27eedb00a 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -104,6 +104,7 @@ import { } from "./childThreadDecisions.ts"; import { childDeliveryForQuietCandidate } from "./childThreadDelivery.ts"; import { projectEvent } from "./projector.ts"; +import { checkoutSelectedEvents, leavesLentWorktree, movesCheckout } from "./threadCheckout.ts"; import { canReplaceThreadTitle } from "./threadTitle.ts"; import { decideHistorySeedDelivered, @@ -1098,6 +1099,21 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" detail: "A thread an outside agent starts has no other origin.", }); } + // Another thread's worktree is lent only to a thread an agent started + // here (ThreadWorktreeOrigin), and only as a worktree. + const createdWorktreePath = normalizeWorktreePath( + command.worktreePath, + project.workspaceRoot, + ); + if ( + command.worktreeOrigin !== undefined && + (parentThreadId === undefined || createdWorktreePath === null) + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Only a thread another thread started can work in that thread's worktree.", + }); + } // A thread set up as a room before its first message starts with its // agents: all of them are checked before any is recorded. const startingAgents = command.participants ?? []; @@ -1137,7 +1153,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" runtimeMode: command.runtimeMode, interactionMode: command.interactionMode, branch: command.branch, - worktreePath: normalizeWorktreePath(command.worktreePath, project.workspaceRoot), + worktreePath: createdWorktreePath, ...(parentThreadId !== undefined ? { parentThreadId, @@ -1149,6 +1165,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" : {}), ...(command.remoteOrigin !== undefined ? { remoteOrigin: command.remoteOrigin } : {}), ...(command.scheduledRun !== undefined ? { scheduledRun: command.scheduledRun } : {}), + ...(command.worktreeOrigin !== undefined + ? { worktreeOrigin: command.worktreeOrigin } + : {}), ...(command.outsideOrigin !== undefined ? { outsideOrigin: command.outsideOrigin } : {}), createdAt: command.createdAt, updatedAt: command.createdAt, @@ -1225,6 +1244,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }); } + const forkWorktreePath = normalizeWorktreePath(command.worktreePath, project.workspaceRoot); + // A fork that stays in a worktree its source was lent is a guest there too. + const forkWorktreeOrigin = + forkWorktreePath !== null && !movesCheckout(sourceThread, forkWorktreePath) + ? (sourceThread.worktreeOrigin ?? null) + : null; const createdEvent: Omit = { ...withEventBase({ aggregateKind: "thread", @@ -1241,7 +1266,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" runtimeMode: command.runtimeMode, interactionMode: command.interactionMode, branch: command.branch, - worktreePath: normalizeWorktreePath(command.worktreePath, project.workspaceRoot), + worktreePath: forkWorktreePath, + ...(forkWorktreeOrigin !== null ? { worktreeOrigin: forkWorktreeOrigin } : {}), createdAt: command.createdAt, updatedAt: command.createdAt, }, @@ -2612,6 +2638,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ? undefined : normalizeWorktreePath(command.worktreePath, project.workspaceRoot); const occurredAt = yield* nowIso; + const checkoutChanged = worktreePath !== undefined && movesCheckout(thread, worktreePath); const metaUpdatedEvent = { ...withEventBase({ aggregateKind: "thread", @@ -2628,6 +2655,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" : {}), ...(command.branch !== undefined ? { branch: command.branch } : {}), ...(worktreePath !== undefined ? { worktreePath } : {}), + ...(checkoutChanged ? leavesLentWorktree(thread) : {}), updatedAt: occurredAt, }, } as const; @@ -2639,11 +2667,6 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // already know what thread.effective-cwd-set means. Only an actual // move clears — branch-only updates carry the unchanged worktree path // and must not wipe a valid cwd-follow value. - const checkoutChanged = - worktreePath !== undefined && - (worktreePath === null || thread.worktreePath === null - ? worktreePath !== thread.worktreePath - : !areFilesystemPathsEqual(worktreePath, thread.worktreePath)); const sessionInactive = thread.session == null || thread.session.status === "stopped"; if (!checkoutChanged || !sessionInactive || thread.effectiveCwd == null) { return metaUpdatedEvent; @@ -2679,54 +2702,20 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, projectId: thread.projectId, }); - const worktreePath = normalizeWorktreePath(command.worktreePath, project.workspaceRoot); const occurredAt = yield* nowIso; - const metaUpdatedEvent = { - ...withEventBase({ - aggregateKind: "thread", - aggregateId: command.threadId, - occurredAt, - commandId: command.commandId, - }), - type: "thread.meta-updated", - payload: { - threadId: command.threadId, - branch: command.branch, - worktreePath, - updatedAt: occurredAt, - }, - } as const; - const hasLiveWork = - thread.session?.status === "starting" || - thread.session?.status === "running" || - thread.session?.activeTurnId != null || - (thread.session?.pendingBackgroundTaskCount ?? 0) > 0; - const selectionAuthorityRequired = thread.effectiveCwd !== null || hasLiveWork; - if (!selectionAuthorityRequired && thread.effectiveCwdSource !== "selection") { - return metaUpdatedEvent; - } - if (selectionAuthorityRequired && thread.effectiveCwdSource === "selection") { - return metaUpdatedEvent; - } - return [ - metaUpdatedEvent, - { - ...withEventBase({ + return checkoutSelectedEvents({ + thread, + branch: command.branch, + worktreePath: normalizeWorktreePath(command.worktreePath, project.workspaceRoot), + occurredAt, + base: () => + withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, }), - causationEventId: metaUpdatedEvent.eventId, - type: "thread.effective-cwd-set", - payload: { - threadId: command.threadId, - effectiveCwd: null, - ...(selectionAuthorityRequired ? { effectiveCwdSource: "selection" as const } : {}), - updatedAt: occurredAt, - }, - }, - ]; + }); } case "thread.runtime-mode.set": { diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 85da4cee9..581dae02b 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -448,6 +448,9 @@ export function projectEvent( attachedToParent: payload.attachedToParent ?? false, ...(payload.remoteOrigin !== undefined ? { remoteOrigin: payload.remoteOrigin } : {}), ...(payload.scheduledRun !== undefined ? { scheduledRun: payload.scheduledRun } : {}), + ...(payload.worktreeOrigin !== undefined + ? { worktreeOrigin: payload.worktreeOrigin } + : {}), ...(payload.outsideOrigin !== undefined ? { outsideOrigin: payload.outsideOrigin } : {}), @@ -1290,6 +1293,9 @@ export function projectEvent( : {}), ...(payload.branch !== undefined ? { branch: payload.branch } : {}), ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), + ...(payload.worktreeOrigin !== undefined + ? { worktreeOrigin: payload.worktreeOrigin } + : {}), updatedAt: payload.updatedAt, }), })), diff --git a/apps/server/src/orchestration/threadCheckout.ts b/apps/server/src/orchestration/threadCheckout.ts new file mode 100644 index 000000000..c48b36e23 --- /dev/null +++ b/apps/server/src/orchestration/threadCheckout.ts @@ -0,0 +1,95 @@ +/** + * A thread's move to another checkout, as the events both ways in decide it: + * the user choosing one (`thread.checkout.select`) and an agent moving a + * thread it steers into another thread's (`thread.child.send` with a + * `checkout`, docs/design/child-threads.md, "Workspaces"). + */ +import type { + OrchestrationEvent, + OrchestrationThread, + ThreadWorktreeOrigin, +} from "@threadlines/contracts"; +import { areFilesystemPathsEqual } from "@threadlines/shared/path"; + +type PlannedEvent = Omit; +type EventBase = Omit; + +/** Whether `worktreePath` (already normalized: null is the project folder) is another checkout than the thread's. */ +export function movesCheckout( + thread: Pick, + worktreePath: string | null, +): boolean { + return worktreePath === null || thread.worktreePath === null + ? worktreePath !== thread.worktreePath + : !areFilesystemPathsEqual(worktreePath, thread.worktreePath); +} + +/** + * A thread leaving another thread's worktree (ThreadWorktreeOrigin) stops + * being its guest: the meta update that moves it says so. + */ +export function leavesLentWorktree(thread: Pick) { + return (thread.worktreeOrigin ?? null) !== null ? { worktreeOrigin: null } : {}; +} + +/** + * The events that point a thread at a checkout chosen for it, for its next + * safe turn. The choice outranks where its session was last seen working: + * while that still shadows it (or work is live, so it may yet), a + * `selection` marker holds the choice; with nothing left to outrank, a stale + * marker is cleared. `worktreeOrigin` names whose worktree it is, when it is + * another thread's; left out, a move ends any earlier stay as a guest. + */ +export function checkoutSelectedEvents(input: { + readonly thread: Pick< + OrchestrationThread, + "id" | "worktreePath" | "worktreeOrigin" | "effectiveCwd" | "effectiveCwdSource" | "session" + >; + readonly branch: string | null; + /** Already normalized: null is the project folder. */ + readonly worktreePath: string | null; + readonly worktreeOrigin?: ThreadWorktreeOrigin | undefined; + readonly occurredAt: string; + /** A fresh event base on the thread's aggregate, one per call. */ + readonly base: () => EventBase; +}): ReadonlyArray { + const { thread, occurredAt } = input; + const metaUpdatedEvent = { + ...input.base(), + type: "thread.meta-updated", + payload: { + threadId: thread.id, + branch: input.branch, + worktreePath: input.worktreePath, + ...(input.worktreeOrigin !== undefined + ? { worktreeOrigin: input.worktreeOrigin } + : movesCheckout(thread, input.worktreePath) + ? leavesLentWorktree(thread) + : {}), + updatedAt: occurredAt, + }, + } as const satisfies PlannedEvent; + const hasLiveWork = + thread.session?.status === "starting" || + thread.session?.status === "running" || + thread.session?.activeTurnId != null || + (thread.session?.pendingBackgroundTaskCount ?? 0) > 0; + const selectionAuthorityRequired = thread.effectiveCwd !== null || hasLiveWork; + if (selectionAuthorityRequired === (thread.effectiveCwdSource === "selection")) { + return [metaUpdatedEvent]; + } + return [ + metaUpdatedEvent, + { + ...input.base(), + causationEventId: metaUpdatedEvent.eventId, + type: "thread.effective-cwd-set", + payload: { + threadId: thread.id, + effectiveCwd: null, + ...(selectionAuthorityRequired ? { effectiveCwdSource: "selection" as const } : {}), + updatedAt: occurredAt, + }, + }, + ]; +} diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 341a52ad1..b41fecdd2 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -34,6 +34,7 @@ import { OrchestrationOutsideWork, OutsideAgentRef, ThreadScheduledRun, + ThreadWorktreeOrigin, PositiveInt, } from "@threadlines/contracts"; @@ -58,6 +59,7 @@ const ProjectionThreadDbRow = ProjectionThread.mapFields( ), remoteOrigin: Schema.NullOr(Schema.fromJsonString(ThreadRemoteOrigin)), scheduledRun: Schema.NullOr(Schema.fromJsonString(ThreadScheduledRun)), + worktreeOrigin: Schema.NullOr(Schema.fromJsonString(ThreadWorktreeOrigin)), outsideOrigin: Schema.NullOr(Schema.fromJsonString(OutsideAgentRef)), outsideWork: Schema.NullOr(Schema.fromJsonString(Schema.Array(OrchestrationOutsideWork))), move: Schema.NullOr(Schema.fromJsonString(OrchestrationThreadMove)), @@ -119,6 +121,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { settings_requests, remote_origin_json, scheduled_run_json, + worktree_origin_json, outside_origin_json, outside_work_json, move_json, @@ -185,6 +188,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { }, ${row.remoteOrigin ? JSON.stringify(row.remoteOrigin) : null}, ${row.scheduledRun ? JSON.stringify(row.scheduledRun) : null}, + ${row.worktreeOrigin ? JSON.stringify(row.worktreeOrigin) : null}, ${row.outsideOrigin ? JSON.stringify(row.outsideOrigin) : null}, ${row.outsideWork && row.outsideWork.length > 0 ? JSON.stringify(row.outsideWork) : null}, ${row.move ? JSON.stringify(row.move) : null}, @@ -243,6 +247,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { settings_requests = excluded.settings_requests, remote_origin_json = excluded.remote_origin_json, scheduled_run_json = excluded.scheduled_run_json, + worktree_origin_json = excluded.worktree_origin_json, outside_origin_json = excluded.outside_origin_json, outside_work_json = excluded.outside_work_json, move_json = excluded.move_json, @@ -308,6 +313,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { settings_requests AS "settingsRequests", remote_origin_json AS "remoteOrigin", scheduled_run_json AS "scheduledRun", + worktree_origin_json AS "worktreeOrigin", outside_origin_json AS "outsideOrigin", outside_work_json AS "outsideWork", move_json AS "move", @@ -375,6 +381,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { settings_requests AS "settingsRequests", remote_origin_json AS "remoteOrigin", scheduled_run_json AS "scheduledRun", + worktree_origin_json AS "worktreeOrigin", outside_origin_json AS "outsideOrigin", outside_work_json AS "outsideWork", move_json AS "move", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index c88362702..87c1181cd 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -129,6 +129,7 @@ import Migration0106 from "./Migrations/106_ThemeShowcaseSubmissions.ts"; import Migration0107 from "./Migrations/107_ThemeEntryOffer.ts"; import Migration0108 from "./Migrations/108_ThemePick.ts"; import Migration0109 from "./Migrations/109_ThemeShowcaseTakenBack.ts"; +import Migration0110 from "./Migrations/110_ThreadWorktreeOrigin.ts"; /** * Migration loader with all migrations defined inline. @@ -250,6 +251,7 @@ export const migrationEntries = [ [107, "ThemeEntryOffer", Migration0107], [108, "ThemePick", Migration0108], [109, "ThemeShowcaseTakenBack", Migration0109], + [110, "ThreadWorktreeOrigin", Migration0110], ] as const; /** Highest id in the full registry, so the "n of total" count is the real total. */ diff --git a/apps/server/src/persistence/Migrations/110_ThreadWorktreeOrigin.ts b/apps/server/src/persistence/Migrations/110_ThreadWorktreeOrigin.ts new file mode 100644 index 000000000..72ca7bd45 --- /dev/null +++ b/apps/server/src/persistence/Migrations/110_ThreadWorktreeOrigin.ts @@ -0,0 +1,15 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +/** + * Whose worktree a thread works in when it is not its own + * (ThreadWorktreeOrigin, docs/design/child-threads.md, "Workspaces"): + * `worktree_origin_json` on `projection_threads`, null for every thread that + * works in a worktree made for it or in the project folder. + * + * A new column only; no row is rewritten. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`ALTER TABLE projection_threads ADD COLUMN worktree_origin_json TEXT`; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 0c82e8268..595b29db6 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -37,6 +37,7 @@ import { OrchestrationOutsideWork, OutsideAgentRef, ThreadScheduledRun, + ThreadWorktreeOrigin, } from "@threadlines/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -158,6 +159,8 @@ export const ProjectionThread = Schema.Struct({ remoteOrigin: Schema.optional(Schema.NullOr(ThreadRemoteOrigin)), /** See `OrchestrationThread.scheduledRun`. Absent or null reads as none. */ scheduledRun: Schema.optional(Schema.NullOr(ThreadScheduledRun)), + /** See `OrchestrationThread.worktreeOrigin`. Absent or null reads as none. */ + worktreeOrigin: Schema.optional(Schema.NullOr(ThreadWorktreeOrigin)), /** See `OrchestrationThread.outsideOrigin`. Absent or null reads as none. */ outsideOrigin: Schema.optional(Schema.NullOr(OutsideAgentRef)), /** See `OrchestrationThread.outsideWork`. Absent, null or empty reads as none. */ diff --git a/apps/web/src/childThreads.test.ts b/apps/web/src/childThreads.test.ts index 9cffadf4a..25312759b 100644 --- a/apps/web/src/childThreads.test.ts +++ b/apps/web/src/childThreads.test.ts @@ -339,7 +339,9 @@ describe("the approval card", () => { }; const card = pendingChildThreads( { + id: ThreadId.make("thread-parent"), modelSelection: sol("xhigh"), + runtimeMode: "full-access", childRequests: { ...EMPTY_CHILD_REQUEST_STATE, open: [ @@ -360,4 +362,51 @@ describe("the approval card", () => { null, ]); }); + + it("says whose checkout a thread would share, and its access when it is lower", () => { + const base = waitingRequest("reviewer", "batch-1"); + const inCheckout = ( + id: string, + owner: string, + patch: Partial> = {}, + ): OrchestrationChildRequest => ({ + ...waitingRequest(id, "batch-1"), + launch: { + ...base.launch!, + title: id, + checkout: { + threadId: ThreadId.make(owner), + title: "Coder", + worktreePath: "/worktrees/coder", + branch: "feature/retry", + }, + ...patch, + }, + }); + const card = pendingChildThreads( + { + id: ThreadId.make("thread-parent"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6-astra" }, + runtimeMode: "full-access", + childRequests: { + ...EMPTY_CHILD_REQUEST_STATE, + open: [ + base, + inCheckout("reviewer", "thread-coder", { runtimeMode: "approval-required" }), + inCheckout("architect", "thread-parent"), + ], + }, + }, + [], + (model) => model.name, + ); + expect( + card?.threads.map((thread) => ({ checkout: thread.checkout, access: thread.access })), + ).toEqual([ + { checkout: null, access: null }, + { checkout: { title: "Coder" }, access: "Supervised" }, + // The asking thread's own checkout has no other thread to name. + { checkout: { title: null }, access: null }, + ]); + }); }); diff --git a/apps/web/src/childThreads.ts b/apps/web/src/childThreads.ts index 6f19d01d5..0c6679128 100644 --- a/apps/web/src/childThreads.ts +++ b/apps/web/src/childThreads.ts @@ -12,11 +12,13 @@ import type { OrchestrationChildRequest, OrchestrationChildRequestState, RemoteChildThread, + RuntimeMode, ThreadId, ThreadMessageOrigin, TurnId, } from "@threadlines/contracts"; import { awaitingChildBatch } from "@threadlines/shared/childThreads"; +import { RUNTIME_MODE_LABELS } from "@threadlines/shared/runtimeMode"; import { findReasoningOptionDescriptor, findReasoningSelection, @@ -200,6 +202,13 @@ export interface PendingChildThread { readonly entry: ProviderInstanceEntry | undefined; /** False in a project without git: it shares the project folder. */ readonly ownWorktree: boolean; + /** + * Another thread's checkout it would work in instead: that thread's title + * when it was asked for, or null when it is the asking thread's own. + */ + readonly checkout: { readonly title: string | null } | null; + /** The access it would run at, by name ("Supervised"), when below the asking thread's. */ + readonly access: string | null; /** The linked computer it would run on (by its name), or null for this one. */ readonly computer: string | null; } @@ -243,7 +252,9 @@ function launchReasoning( /** The batch the approval card asks about, or null when none waits. */ export function pendingChildThreads( thread: RoomThreadLike & { + readonly id: ThreadId; readonly modelSelection: ModelSelection; + readonly runtimeMode: RuntimeMode; readonly agentRole?: string | undefined; readonly childRequests?: OrchestrationChildRequestState | undefined; }, @@ -287,6 +298,14 @@ export function pendingChildThreads( ), entry, ownWorktree: launch.workspace.kind === "worktree", + checkout: + launch.checkout === undefined + ? null + : { title: launch.checkout.threadId === thread.id ? null : launch.checkout.title }, + access: + launch.runtimeMode !== thread.runtimeMode + ? RUNTIME_MODE_LABELS[launch.runtimeMode] + : null, computer: launch.workspace.kind === "remote" ? (launch.target?.label ?? null) : null, }, ]; diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 5130c8f50..1e347610d 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -191,6 +191,14 @@ describe("resolveLockedWorkspaceLabel", () => { it("uses a shorter label for an attached worktree", () => { expect(resolveLockedWorkspaceLabel("/repo/.threadlines/worktrees/feature-a")).toBe("Worktree"); }); + + it("says when the worktree is another thread's", () => { + expect(resolveLockedWorkspaceLabel("/repo/.threadlines/worktrees/feature-a", true)).toBe( + "Shared worktree", + ); + // The project folder is nobody's worktree. + expect(resolveLockedWorkspaceLabel(null, true)).toBe("Local checkout"); + }); }); describe("resolveActiveWorktreePath", () => { diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index a79b2d3a3..899bce645 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -57,8 +57,20 @@ export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local"); } -export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): string { - return activeWorktreePath ? "Worktree" : "Local checkout"; +/** + * Where a started thread works, as the row under the message box names it. + * `shared`: its worktree is another thread's (ThreadWorktreeOrigin). + */ +export function resolveLockedWorkspaceLabel( + activeWorktreePath: string | null, + shared = false, +): string { + return activeWorktreePath ? (shared ? "Shared worktree" : "Worktree") : "Local checkout"; +} + +/** Whose worktree a shared one is, for the label's hover text. */ +export function resolveSharedWorktreeTitle(ownerTitle: string | null): string { + return ownerTitle === null ? "Another thread's worktree" : `${ownerTitle}'s worktree`; } /** diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 86960a360..783f31c4b 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -11,6 +11,7 @@ import { } from "lucide-react"; import { memo, useMemo } from "react"; +import { useThreadTitle } from "../childThreads"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { useIsMobile } from "../hooks/useMediaQuery"; import { useStore } from "../store"; @@ -85,6 +86,8 @@ interface MobileRunContextSelectorProps { onSetUpElsewhere: (() => void) | undefined; effectiveEnvMode: EnvMode; activeWorktreePath: string | null; + /** See BranchToolbarEnvModeSelector's `sharedWorktreeOwner`. */ + sharedWorktreeOwner: string | null | undefined; onEnvModeChange: (mode: EnvMode) => void; } @@ -98,6 +101,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ onSetUpElsewhere, effectiveEnvMode, activeWorktreePath, + sharedWorktreeOwner, onEnvModeChange, }: MobileRunContextSelectorProps) { const activeEnvironment = useMemo( @@ -111,7 +115,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ ? FolderGitIcon : FolderIcon; const workspaceLabel = envModeLocked - ? resolveLockedWorkspaceLabel(activeWorktreePath) + ? resolveLockedWorkspaceLabel(activeWorktreePath, sharedWorktreeOwner !== undefined) : effectiveEnvMode === "worktree" ? resolveEnvModeLabel("worktree") : resolveCurrentWorkspaceLabel(activeWorktreePath); @@ -268,6 +272,14 @@ export const BranchToolbar = memo(function BranchToolbar({ // matches the source-control surfaces. Mode/lock semantics stay keyed to // the configured worktree. const displayWorktreePath = serverThread?.effectiveCwd ?? activeWorktreePath; + // A worktree another thread lent it (ThreadWorktreeOrigin), while its + // session works there: the label says it is shared, and whose it is. + const worktreeLenderId = + serverThread?.effectiveCwd == null && activeWorktreePath !== null + ? (serverThread?.worktreeOrigin?.threadId ?? null) + : null; + const worktreeLenderTitle = useThreadTitle(environmentId, worktreeLenderId); + const sharedWorktreeOwner = worktreeLenderId === null ? undefined : worktreeLenderTitle; // A checkout switch only takes effect on the next turn, so say so while the // live session still runs in the checkout it started in. const pendingCheckoutSwitch = resolvePendingCheckoutSwitch({ @@ -380,6 +392,7 @@ export const BranchToolbar = memo(function BranchToolbar({ onSetUpElsewhere={onSetUpElsewhere} effectiveEnvMode={effectiveEnvMode} activeWorktreePath={displayWorktreePath} + sharedWorktreeOwner={sharedWorktreeOwner} onEnvModeChange={onEnvModeChange} /> ) : ( @@ -400,6 +413,7 @@ export const BranchToolbar = memo(function BranchToolbar({ envLocked={envModeLocked} effectiveEnvMode={effectiveEnvMode} activeWorktreePath={displayWorktreePath} + sharedWorktreeOwner={sharedWorktreeOwner} onEnvModeChange={onEnvModeChange} /> diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 68ec52851..f148628e5 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -5,6 +5,7 @@ import { resolveCurrentWorkspaceLabel, resolveEnvModeLabel, resolveLockedWorkspaceLabel, + resolveSharedWorktreeTitle, resolveWorkspaceDescription, resolveWorkspacePhrase, type EnvMode, @@ -26,6 +27,11 @@ interface BranchToolbarEnvModeSelectorProps { envLocked: boolean; effectiveEnvMode: EnvMode; activeWorktreePath: string | null; + /** + * The thread works in another thread's worktree: that thread's title, or + * null when this device doesn't know it. Absent: its own, or none. + */ + sharedWorktreeOwner?: string | null | undefined; onEnvModeChange: (mode: EnvMode) => void; } @@ -47,6 +53,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe envLocked, effectiveEnvMode, activeWorktreePath, + sharedWorktreeOwner, onEnvModeChange, }: BranchToolbarEnvModeSelectorProps) { // The trigger shows the chosen item's label, so the sentence gets the @@ -75,11 +82,16 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe if (envLocked) { return ( - + {activeWorktreePath ? ( <> - {resolveLockedWorkspaceLabel(activeWorktreePath)} + {resolveLockedWorkspaceLabel(activeWorktreePath, sharedWorktreeOwner !== undefined)} ) : ( <> diff --git a/apps/web/src/components/ChatView.agents.browser.tsx b/apps/web/src/components/ChatView.agents.browser.tsx index 8e573c60f..768125632 100644 --- a/apps/web/src/components/ChatView.agents.browser.tsx +++ b/apps/web/src/components/ChatView.agents.browser.tsx @@ -974,6 +974,37 @@ describe("ChatView (full app): agent browser tabs and rooms", () => { }, createdAt: isoAt(301), }, + { + requestId: ChildRequestId.make("request-start-review"), + batchId, + kind: "start" as const, + from: { participantId: null }, + callerTurnId: "turn-start" as TurnId, + deliveryEpoch: 0, + status: "awaiting_user" as const, + childThreadId: "thread-child-review" as ThreadId, + childMessageId: "msg-child-review" as MessageId, + launch: { + title: "Review the changelog", + prompt: "Review the uncommitted changelog.", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5", + }, + runtimeMode: "approval-required" as const, + interactionMode: "default" as const, + reportBack: true, + runSetup: false, + workspace: { kind: "project_folder" as const }, + checkout: { + threadId: childThreadId, + title: "Write the changelog", + worktreePath: "/repo/worktrees/changelog", + branch: "docs/changelog", + }, + }, + createdAt: isoAt(301), + }, { requestId: ChildRequestId.make("request-start-2"), batchId, @@ -1030,10 +1061,19 @@ describe("ChatView (full app): agent browser tabs and rooms", () => { () => document.querySelector('[data-testid="child-threads-panel"]'), "Unable to find the approval card.", ); - expect(panel.textContent).toContain("wants to start 2 threads"); + expect(panel.textContent).toContain("wants to start 3 threads"); expect(panel.textContent).toContain("Fix the flaky cleanup test"); // No git to isolate it in: the card says it shares the folder. expect(panel.textContent).toContain("shares this folder"); + // One put in another thread's checkout names that thread, and its + // access where the agent asked for less than its own. + const review = [...panel.querySelectorAll('[data-testid="child-threads-panel-thread"]')].find( + (row) => row.textContent?.includes("Review the changelog"), + ); + expect(review?.querySelector("[data-child-thread-checkout]")?.textContent).toBe( + "in\u00a0Write the changelog's checkout", + ); + expect(review?.querySelector("[data-child-thread-access]")?.textContent).toBe("Supervised"); // One for a linked computer says where it would run, and at the // reasoning the agent settled for it there. expect(panel.textContent).toContain("on Windows PC"); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index e12ad05a9..c8b32d0ee 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -3218,6 +3218,21 @@ describe("resolveThreadBranchToRecord", () => { }), ).toBeNull(); }); + + it("follows the owner's rename for a guest still on the temporary name", () => { + // Nothing renames a branch for a guest, so only this catches it up. + expect( + resolveThreadBranchToRecord({ + thread: { + ...thread, + branch: buildTemporaryWorktreeBranchName(), + worktreeOrigin: { kind: "thread", threadId: ThreadId.make("thread-coder") }, + }, + cwd: "C:/wt/one", + checkoutRef: "feature/two", + }), + ).toBe("feature/two"); + }); }); describe("createUpwardScrollDetector", () => { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index bf3ce8627..35621818f 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -2651,10 +2651,12 @@ export function resolveRemoteBehindCount( * being read is that worktree's: a shared checkout's ref says nothing about * any one thread in it, and a status read from elsewhere says nothing about * this one. Temporary worktree branches are the server's to rename, so those - * are left alone on both sides. Null when there is nothing to record. + * are left alone on both sides; a guest in another thread's worktree + * (ThreadWorktreeOrigin) is never renamed for, so it follows that thread's + * rename off a temporary name. Null when there is nothing to record. */ export function resolveThreadBranchToRecord(input: { - readonly thread: Pick | undefined; + readonly thread: Pick | undefined; readonly cwd: string | null; readonly checkoutRef: string | null; }): string | null { @@ -2665,7 +2667,13 @@ export function resolveThreadBranchToRecord(input: { if (!isFilesystemPathWithin(cwd, thread.worktreePath)) return null; if (checkoutRef === thread.branch) return null; if (isTemporaryWorktreeBranch(checkoutRef)) return null; - if (thread.branch !== null && isTemporaryWorktreeBranch(thread.branch)) return null; + if ( + thread.branch !== null && + isTemporaryWorktreeBranch(thread.branch) && + (thread.worktreeOrigin ?? null) === null + ) { + return null; + } return checkoutRef; } diff --git a/apps/web/src/components/chat/ComposerChildThreadsPanel.tsx b/apps/web/src/components/chat/ComposerChildThreadsPanel.tsx index 3f0de7667..9a35406f1 100644 --- a/apps/web/src/components/chat/ComposerChildThreadsPanel.tsx +++ b/apps/web/src/components/chat/ComposerChildThreadsPanel.tsx @@ -11,6 +11,26 @@ import { failureNotice, toastManager } from "../ui/toast"; import { addComputerNotConnectedNotice } from "../computerConnectionNotices"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; +/** + * Where a thread that gets no worktree of its own would work: another + * thread's checkout, or the asking thread's (`title` null). A long title is + * cut short, never the words around it, so the line always says "checkout". + */ +function ChildThreadCheckoutLabel(props: { title: string | null }) { + if (props.title === null) { + return in this thread's checkout; + } + return ( + + in  + + {props.title} + + 's checkout + + ); +} + /** * An agent asks to start threads of its own (child threads, "Ask me first"). * Docked above the message box like an invite: the box stays usable, so the @@ -70,11 +90,21 @@ export const ComposerChildThreadsPanel = memo(function ComposerChildThreadsPanel {request.threads.map((thread) => (
  • {thread.title} - + {thread.entry ? ( ) : null} - {thread.modelName} + {thread.modelName} {thread.reasoning !== null ? ( <> · {thread.reasoning} ) : null} + {thread.access !== null ? ( + <> + · + + {thread.access} + + + ) : null} · - - {thread.computer !== null - ? `on ${thread.computer}` - : thread.ownWorktree - ? "own worktree" - : "shares this folder"} - + {thread.computer !== null ? ( + on {thread.computer} + ) : thread.checkout !== null ? ( + + ) : ( + {thread.ownWorktree ? "own worktree" : "shares this folder"} + )}
  • ))} diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 12f610a65..b7da647bc 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -4218,6 +4218,27 @@ describe("ThreadsSettingsPanel new thread defaults", () => { }); }); + it("lets threads share a checkout only when chosen, and only while agents can start threads", async () => { + const updateSettings = await mountWith({}); + const sharing = page.getByRole("switch", { name: "Threads can share a checkout" }); + await expect.element(sharing).not.toBeChecked(); + await sharing.click(); + await vi.waitFor(() => { + expect(updateSettings).toHaveBeenCalledWith({ agentThreadsShareWorktrees: true }); + }); + + const teardown = mounted?.cleanup ?? mounted?.unmount; + await teardown?.call(mounted); + // Chosen, but agents can't start threads at all: it reads off and can't be changed. + await mountWith({ agentThreads: "off", agentThreadsShareWorktrees: true }); + const riding = page.getByRole("switch", { name: "Threads can share a checkout" }); + await expect.element(riding).not.toBeChecked(); + await expect.element(riding).toBeDisabled(); + await expect + .element(page.getByText("Turn on Agents can start threads to use this.")) + .toBeInTheDocument(); + }); + it("picks the branch new worktrees start from among every project's branches", async () => { const environmentId = createBaseServerConfig().environment.environmentId; const project = (id: string, title: string) => ({ diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 91c7b20e4..19a9e053e 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -25,7 +25,11 @@ import { } from "@threadlines/contracts"; import { scopeThreadRef } from "@threadlines/client-runtime"; import { themeGlassLevel } from "@threadlines/shared/themeGlassLevel"; -import { agentInvitesChoice, agentThreadsMode } from "@threadlines/shared/serverSettings"; +import { + agentInvitesChoice, + agentThreadsMode, + agentThreadsShareWorktrees, +} from "@threadlines/shared/serverSettings"; import { OPEN_LINKS_IN_LABELS, placeById, @@ -537,6 +541,9 @@ export function useSettingsRestore(onRestored?: () => void) { : []), ...(agentInvitesChoice(settings) !== "ask" ? [placeById("threads.agentInvites").title] : []), ...(agentThreadsMode(settings) !== "ask" ? [placeById("threads.agentThreads").title] : []), + ...(agentThreadsShareWorktrees(settings) + ? [placeById("threads.agentThreadsShareWorktrees").title] + : []), ...(settings.wrapUpChildThreadsOnFinish !== DEFAULT_UNIFIED_SETTINGS.wrapUpChildThreadsOnFinish ? ["Wrap up finished child threads"] @@ -571,6 +578,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.startAgentsWhileTyping, settings.agentInvites, settings.agentThreads, + settings.agentThreadsShareWorktrees, settings.wrapUpChildThreadsOnFinish, settings.addProjectBaseDirectory, settings.openLinksIn, @@ -651,6 +659,7 @@ export function useSettingsRestore(onRestored?: () => void) { startAgentsWhileTyping: true, agentInvites: "ask", agentThreads: "ask", + agentThreadsShareWorktrees: false, wrapUpChildThreadsOnFinish: DEFAULT_UNIFIED_SETTINGS.wrapUpChildThreadsOnFinish, textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, textGenerationBackupModelSelection: diff --git a/apps/web/src/components/settings/ThreadsSettings.tsx b/apps/web/src/components/settings/ThreadsSettings.tsx index 2cf285635..7ce182533 100644 --- a/apps/web/src/components/settings/ThreadsSettings.tsx +++ b/apps/web/src/components/settings/ThreadsSettings.tsx @@ -16,7 +16,11 @@ import { THREAD_ENV_MODE_LABELS, } from "@threadlines/shared/appPlaces"; import { createModelSelection } from "@threadlines/shared/model"; -import { agentInvitesChoice, agentThreadsMode } from "@threadlines/shared/serverSettings"; +import { + agentInvitesChoice, + agentThreadsMode, + agentThreadsShareWorktrees, +} from "@threadlines/shared/serverSettings"; import * as Equal from "effect/Equal"; import { useState } from "react"; @@ -71,6 +75,7 @@ export function ThreadsSettingsPanel({ surface = "full" }: { surface?: "full" | + @@ -858,6 +863,48 @@ export function AgentThreadsRow() { ); } +/** + * Whether a thread an agent starts or messages may work in another thread's + * checkout instead of a new worktree (docs/design/child-threads.md, + * "Workspaces"). Off unless chosen: two threads then share one set of files. + * It rides on agents starting threads, so it is off while that is. + */ +export function AgentThreadsShareWorktreesRow() { + const threadsOn = useSettings((settings) => agentThreadsMode(settings) !== "off"); + const sharing = useSettings((settings) => agentThreadsShareWorktrees(settings)); + const { updateSettings } = useUpdateSettings(); + return ( + updateSettings({ agentThreadsShareWorktrees: false })} + /> + ) : null + } + control={ + + updateSettings({ agentThreadsShareWorktrees: Boolean(checked) }) + } + aria-label="Threads can share a checkout" + /> + } + /> + ); +} + /** What both writing-model rows read: the writing model, its backup, and who can serve them. */ function useWritingModels() { const settings = useSettings(); diff --git a/apps/web/src/components/settings/settingsRowRegistry.tsx b/apps/web/src/components/settings/settingsRowRegistry.tsx index 5fbaf0fa7..685fa74a0 100644 --- a/apps/web/src/components/settings/settingsRowRegistry.tsx +++ b/apps/web/src/components/settings/settingsRowRegistry.tsx @@ -48,6 +48,7 @@ import { AgentInvitesRow, AgentPagesRow, AgentThreadsRow, + AgentThreadsShareWorktreesRow, BackupWritingModelRow, ClaudeArtifactsRow, ClaudeSuggestionsRow, @@ -133,6 +134,7 @@ export const SETTINGS_ROWS = { "threads.startIn": live(DefaultThreadEnvModeRow), "threads.worktreesFrom": live(NewWorktreeBaseRow), "threads.agentThreads": live(AgentThreadsRow), + "threads.agentThreadsShareWorktrees": live(AgentThreadsShareWorktreesRow), "threads.agentPages": live(AgentPagesRow), "threads.claudeArtifacts": live(ClaudeArtifactsRow), "threads.claudeWorkflowSize": live(ClaudeWorkflowSizeRow), diff --git a/apps/web/src/components/sidebar/ThreadHoverCard.tsx b/apps/web/src/components/sidebar/ThreadHoverCard.tsx index c97c75eea..73f7a2f04 100644 --- a/apps/web/src/components/sidebar/ThreadHoverCard.tsx +++ b/apps/web/src/components/sidebar/ThreadHoverCard.tsx @@ -29,6 +29,7 @@ import { import { usePrimaryEnvironmentId } from "../../environments/primary"; import { useServerProviders } from "../../rpc/serverState"; import { selectProjectByRef, useStore } from "../../store"; +import { useThreadTitle } from "../../childThreads"; import { useGitStatus } from "../../lib/gitStatusState"; import { resolveThreadWorkingCwd } from "@threadlines/shared/threadCwd"; import { createThreadSelectorByRef } from "../../storeSelectors"; @@ -242,6 +243,9 @@ function ThreadHoverCardContent({ thread, status, lineage }: ThreadHoverCardPayl // A worktree is the more specific fact when present: it implies the branch // and tells you the checkout is isolated. const worktreeName = thread.worktreePath ? thread.worktreePath.split("/").pop() : null; + // Another thread's worktree it was put in (ThreadWorktreeOrigin): say whose. + const worktreeLenderId = thread.worktreeOrigin?.threadId ?? null; + const worktreeLenderTitle = useThreadTitle(thread.environmentId, worktreeLenderId); // The row prints only a pinned branch; here there is room to answer the // question a branchless thread leaves open -- which ref its checkout is on. const gitStatus = useGitStatus({ @@ -347,7 +351,11 @@ function ThreadHoverCardContent({ thread, status, lineage }: ThreadHoverCardPayl {" · checkout"} ) : null} {worktreeName && branch ? ( - {" · worktree"} + + {worktreeLenderId === null + ? " · worktree" + : ` · ${worktreeLenderTitle ?? "another thread"}'s worktree`} + ) : null} ) : null} diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index a85c76027..99d6b905b 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -53,6 +53,7 @@ import type { OutsideAgentRef, ThreadId, ThreadScheduledRun, + ThreadWorktreeOrigin, TurnId, } from "@threadlines/contracts"; import * as Schema from "effect/Schema"; @@ -419,6 +420,7 @@ function threadLineage(thread: { readonly handedBackAt?: string | null | undefined; readonly handedBackTurnId?: TurnId | null | undefined; readonly scheduledRun?: ThreadScheduledRun | null | undefined; + readonly worktreeOrigin?: ThreadWorktreeOrigin | null | undefined; readonly outsideOrigin?: OutsideAgentRef | null | undefined; }): Pick< ThreadShell, @@ -428,6 +430,7 @@ function threadLineage(thread: { | "handedBackAt" | "handedBackTurnId" | "scheduledRun" + | "worktreeOrigin" | "outsideOrigin" > { return { @@ -437,6 +440,7 @@ function threadLineage(thread: { handedBackAt: thread.handedBackAt ?? null, handedBackTurnId: thread.handedBackTurnId ?? null, scheduledRun: thread.scheduledRun ?? null, + worktreeOrigin: thread.worktreeOrigin ?? null, outsideOrigin: thread.outsideOrigin ?? null, }; } @@ -660,6 +664,7 @@ function sidebarLineage( | "pendingChildApproval" | "queuedFollowUps" | "scheduledRun" + | "worktreeOrigin" | "outsideOrigin" | "remoteOrigin" | "remoteChildren" @@ -678,6 +683,7 @@ function sidebarLineage( | "pendingChildApproval" | "queuedFollowUpCount" | "scheduledRun" + | "worktreeOrigin" | "outsideOrigin" | "remoteOrigin" | "remoteChildren" @@ -688,6 +694,7 @@ function sidebarLineage( const moveStatus = threadMoveStatus(thread.move); return { scheduledRun: thread.scheduledRun ?? null, + worktreeOrigin: thread.worktreeOrigin ?? null, outsideOrigin: thread.outsideOrigin ?? null, parentThreadId: thread.parentThreadId ?? null, parentTurnId: thread.parentTurnId ?? null, @@ -1315,6 +1322,7 @@ function sidebarThreadSummariesEqual( (left.pendingScheduleApproval ?? false) === (right.pendingScheduleApproval ?? false) && (left.queuedFollowUpCount ?? 0) === (right.queuedFollowUpCount ?? 0) && scheduledRunsEqual(left.scheduledRun, right.scheduledRun) && + (left.worktreeOrigin?.threadId ?? null) === (right.worktreeOrigin?.threadId ?? null) && outsideOriginsEqual(left.outsideOrigin, right.outsideOrigin) && remoteOriginsEqual(left.remoteOrigin, right.remoteOrigin) && remoteChildrenEqual(left.remoteChildren, right.remoteChildren) && @@ -1412,6 +1420,7 @@ function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): b (left.pendingChildApproval ?? false) === (right.pendingChildApproval ?? false) && (left.pendingSettingsChange ?? false) === (right.pendingSettingsChange ?? false) && scheduledRunsEqual(left.scheduledRun, right.scheduledRun) && + (left.worktreeOrigin?.threadId ?? null) === (right.worktreeOrigin?.threadId ?? null) && outsideOriginsEqual(left.outsideOrigin, right.outsideOrigin) && outsideWorkEqual(left.outsideWork, right.outsideWork) && remoteOriginsEqual(left.remoteOrigin, right.remoteOrigin) && @@ -2609,6 +2618,7 @@ function applyEnvironmentOrchestrationEvent( archivedWithParentAt: null, childRequests: EMPTY_CHILD_REQUEST_STATE, scheduledRun: event.payload.scheduledRun ?? null, + worktreeOrigin: event.payload.worktreeOrigin ?? null, outsideOrigin: event.payload.outsideOrigin ?? null, ...(event.payload.remoteOrigin ? { remoteOrigin: event.payload.remoteOrigin } : {}), ...(event.payload.movedFrom ? { movedFrom: event.payload.movedFrom } : {}), @@ -2772,6 +2782,9 @@ function applyEnvironmentOrchestrationEvent( ...(event.payload.worktreePath !== undefined ? { worktreePath: event.payload.worktreePath } : {}), + ...(event.payload.worktreeOrigin !== undefined + ? { worktreeOrigin: event.payload.worktreeOrigin } + : {}), updatedAt: event.payload.updatedAt, })); diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index ca471b4d3..8f1d41c28 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -41,6 +41,7 @@ import type { ProjectScript as ContractProjectScript, ThreadId, ThreadScheduledRun, + ThreadWorktreeOrigin, SideTurnId, ThreadParticipantId, ProjectId, @@ -266,6 +267,8 @@ export interface Thread { handedBackTurnId?: TurnId | null; /** Started by a scheduled task's run: "Scheduled · " on its inbox row. */ scheduledRun?: ThreadScheduledRun | null; + /** Its worktree is another thread's (ThreadWorktreeOrigin): a guest there, not its owner. */ + worktreeOrigin?: ThreadWorktreeOrigin | null; /** See ThreadShell.outsideOrigin. */ outsideOrigin?: OutsideAgentRef | null; /** See ThreadShell.outsideWork. */ @@ -379,6 +382,8 @@ export interface ThreadShell { handedBackTurnId?: TurnId | null; /** Started by a scheduled task's run: "Scheduled · " on its inbox row. */ scheduledRun?: ThreadScheduledRun | null; + /** Its worktree is another thread's (ThreadWorktreeOrigin): a guest there, not its owner. */ + worktreeOrigin?: ThreadWorktreeOrigin | null; /** * Started by an agent outside Threadlines (docs/design/outside-agents.md): * labelled with its name. Absent or null: started in Threadlines. @@ -499,6 +504,8 @@ export interface SidebarThreadSummary { attachedToParent?: boolean; /** See ThreadShell.scheduledRun. */ scheduledRun?: ThreadScheduledRun | null; + /** Its worktree is another thread's (ThreadWorktreeOrigin): a guest there, not its owner. */ + worktreeOrigin?: ThreadWorktreeOrigin | null; /** See ThreadShell.outsideOrigin. */ outsideOrigin?: OutsideAgentRef | null; /** See ThreadShell.handedBackTurnId: drives "wrap up finished child threads". */ diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 321d3ce76..ea321d931 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -89,6 +89,18 @@ describe("getOrphanedWorktreePathForThread", () => { expect(result).toBeNull(); }); + // A thread put in another thread's worktree never made that folder, so it + // never offers it, even as the last thread still pointing there. + it("returns null for a guest in another thread's worktree", () => { + const threads = [ + makeThread({ + worktreePath: "/tmp/repo/worktrees/feature-a", + worktreeOrigin: { kind: "thread", threadId: ThreadId.make("thread-coder") }, + }), + ]; + expect(getOrphanedWorktreePathForThread(threads, ThreadId.make("thread-1"))).toBeNull(); + }); + // An archived thread can bring its checkout back through checkout recovery, // so its link has to count in both directions. it("returns null when only an archived thread links to the same worktree", () => { diff --git a/apps/web/src/worktreeCleanup.ts b/apps/web/src/worktreeCleanup.ts index a016eb13e..1cfc4e35d 100644 --- a/apps/web/src/worktreeCleanup.ts +++ b/apps/web/src/worktreeCleanup.ts @@ -18,6 +18,8 @@ const isGitCommandError = Schema.is(GitCommandError); export interface WorktreeLinkedThread { readonly id: string; readonly worktreePath: string | null; + /** Its worktree is another thread's (ThreadWorktreeOrigin): never its to remove. */ + readonly worktreeOrigin?: unknown; } function normalizeWorktreePath(path: string | null): string | null { @@ -36,6 +38,10 @@ function normalizeWorktreePath(path: string | null): string | null { * checkout recovery, so deleting a live thread must not offer to remove the * folder an archived one is waiting on -- and the same in reverse. The target * thread may come from either list. + * + * A thread put in another thread's worktree leaves nothing behind either: the + * folder was never its own, so deleting it never offers the folder, even + * once the thread it belongs to is gone. The cleanup list still shows it. */ export function getOrphanedWorktreePathForThread( threads: readonly WorktreeLinkedThread[], @@ -50,7 +56,7 @@ export function getOrphanedWorktreePathForThread( } const targetWorktreePath = normalizeWorktreePath(targetThread.worktreePath); - if (!targetWorktreePath) { + if (!targetWorktreePath || (targetThread.worktreeOrigin ?? null) !== null) { return null; } diff --git a/docs/design/child-threads.md b/docs/design/child-threads.md index 0a325ed6c..611a135c0 100644 --- a/docs/design/child-threads.md +++ b/docs/design/child-threads.md @@ -143,10 +143,24 @@ Side runtimes never get these tools: `roomToolsFor(side)` excludes them and the ### Workspaces -- **Git project.** Every child gets its own new worktree off the caller's current branch HEAD, resolved and saved at submit time. Branches use the placeholder and are renamed by kind after the first turn, as today. Children never share the parent's checkout: we can't make a shared-checkout child reliably read-only on every provider, and concurrent edits to one checkout are the failure we're avoiding. +- **Git project.** Every child gets its own new worktree off the caller's current branch HEAD, resolved and saved at submit time. Branches use the placeholder and are renamed by kind after the first turn, as today. A child never shares a checkout unless the user allowed it and the agent asked for it (next section): we can't make a shared-checkout child reliably read-only on every provider, and concurrent edits to one checkout are the failure we're avoiding. - **`runSetup: false`.** Skips the project setup script, for cheap research children. - **Non-git project.** Children share the project folder because no isolation is possible. The approval card and the tool result say so. +### An existing checkout (issue #892, Nov 2026) + +A chain like orchestrator, coder, reviewer needs the reviewer to read the coder's files as they are, uncommitted changes included, with a fresh conversation. A new worktree can't give that: it is cut from the caller's commit. So an agent may put a thread in a checkout that already exists. + +- **The setting.** "Threads can share a checkout" (`agentThreadsShareWorktrees`, Settings › Threads, under "Agents can start threads"), off unless chosen. Read at every call and again when the thread is set up. Off, the tools refuse and name the setting. +- **The handle is a thread, never a path.** `thread_start`'s per-thread `checkout` and `thread_send`'s `checkout` take `self` (the caller's own checkout) or the id of a thread the caller started, attached or separated, open, on this computer, in the same project. An agent can't name a folder Threadlines doesn't know. +- **Resolved when asked.** The handler reads the folder from the owner (where its session works, else its worktree, else the project folder), checks with git that it is a checkout of this project's repository, and reads its branch. The result is saved with the request as `ChildThreadLaunch.checkout` (`ChildThreadCheckout`: owner id and title, folder, branch), so an approval or a restart later puts the thread in the same place. `workspace` stays `project_folder`, which here means only "no worktree is made", so an older client reads the launch as a shared folder. +- **Not while its owner works.** A call is refused while the owning thread has live work (a turn, background work it awaits, a queued message, something waiting for the user), except `self`: the caller is in its own turn by definition. The user's yes can come much later, so a child's setup checks again: the reactor rereads the setting and the owner, and the bootstrap's worktree stage finds the folder among this project's worktrees again and records the branch it is on then (a folder that is gone, or no longer this project's, fails the start with a reason). A move by `thread_send` is checked against the owner in the decision itself. After the thread starts, the folder is live. Nothing stops the owner from being messaged again; the tool descriptions tell the agent to have one thread work there at a time. We chose this over a checkout lock: the user's own threads already share checkouts without one, and a lock can't cover terminals or other editors. +- **A guest, not an owner.** A thread started in another thread's worktree records it as `worktreeOrigin` (`ThreadWorktreeOrigin`, on the thread, its shell and `thread.created`). A guest's first turn never renames the branch, the project's setup script is never run for it (`runSetup` is forced off), deleting it never offers to remove the folder, even as the last thread pointing there, and a revert in it is always selective, never the whole-folder restore (CheckpointRevert), even once its owner is archived or deleted. The folder still blocks removal while any thread points at it (worktreeUsage.ts), and the cleanup list still shows it. The project folder is nobody's worktree: a thread put there has no `worktreeOrigin`. +- **Moving an existing thread.** `thread_send` with `checkout` moves an idle child there before it reads the message, with the same events the user's own checkout switch uses (threadCheckout.ts), so its session restarts in the new folder and resumes its conversation. A busy child is refused: a thread only moves between its turns. So is one with any background work left running (a dev server): its session can't restart while that runs, so it would read the message in the folder it is in. The move is written on the child as a `thread.checkout.moved` activity, which is its line in the chat and a boundary: a revert to a turn from before the move is refused, because those turns changed the folder it left. The worktree a moved child had of its own stays on disk. Any move to another checkout, the user's included, ends an earlier stay as a guest. +- **Access.** `thread_start`'s per-thread `access` asks for a lower level than the caller's; the child gets the safer of the two. There is no read-only level, and Supervised is enforced differently by each provider, so nothing here promises a guest can't write. +- **Not covered.** Linked computers (`computer` with `checkout` is refused, and so is a child on another computer as owner or as the thread to move) and the tools outside agents and assistants use. +- **On screen.** The approval card's line ends "in 's checkout" (or "in this thread's checkout"), with the access level when it is lower. The row under the message box reads "Shared worktree", and the sidebar's hover card names whose worktree it is. + ## Data model (packages/contracts) ### Threads (`OrchestrationThread`, `OrchestrationThreadShell`, `ThreadCreatedPayload`) diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index f1b513a54..33ceaf9e2 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -666,6 +666,22 @@ export const ThreadScheduledRun = Schema.Struct({ }); export type ThreadScheduledRun = typeof ThreadScheduledRun.Type; +/** + * Where a thread's worktree came from, when Threadlines did not make it for + * that thread: another thread's, which the agent steering both pointed this + * one at (thread_start and thread_send `checkout`, docs/design/child-threads.md, + * "Workspaces"). The thread works there without owning it: its first turn + * never renames the branch, the project's setup script is not run for it, and + * deleting it never offers to remove the folder. It describes the current + * `worktreePath`, and goes when the thread moves to another checkout. + */ +export const ThreadWorktreeOrigin = Schema.Struct({ + kind: Schema.Literal("thread"), + /** The thread whose worktree it is. It may be archived or deleted since. */ + threadId: ThreadId, +}); +export type ThreadWorktreeOrigin = typeof ThreadWorktreeOrigin.Type; + /** * Always-on assistants Threadlines knows by name and mark * (docs/design/assistants.md). Several sign in through someone else's @@ -981,6 +997,26 @@ export const ThreadCheckoutSwitchDeferredPayload = Schema.Struct({ }); export type ThreadCheckoutSwitchDeferredPayload = typeof ThreadCheckoutSwitchDeferredPayload.Type; +/** + * Payload of the `thread.checkout.moved` activity: the agent that steers a + * thread moved it into another thread's checkout with a message (thread_send + * `checkout`). Its turns up to `turnCount` worked in the folder it left, so + * their changes can't be undone from the new one: a revert to an earlier + * turn is refused (CheckpointRevert). + */ +export const ThreadCheckoutMovedActivityKind = "thread.checkout.moved"; +export const ThreadCheckoutMovedPayload = Schema.Struct({ + /** The thread whose checkout it moved into, with its title then. */ + ownerThreadId: ThreadId, + ownerTitle: TrimmedNonEmptyString, + /** The checkout it left and the one it moved into; null is the project folder. */ + fromWorktreePath: Schema.NullOr(TrimmedNonEmptyString), + toWorktreePath: Schema.NullOr(TrimmedNonEmptyString), + /** How many of its turns had a checkpoint when it moved. */ + turnCount: NonNegativeInt, +}); +export type ThreadCheckoutMovedPayload = typeof ThreadCheckoutMovedPayload.Type; + /** * Payload of the `thread.access.raise-deferred` activity. A raise in the * thread's access restarts its runtime, which would kill the runtime's @@ -2077,6 +2113,23 @@ export const ChildThreadWorkspace = Schema.Union([ ]); export type ChildThreadWorkspace = typeof ChildThreadWorkspace.Type; +/** + * An existing checkout a thread works in instead of a new worktree of its + * own: another thread's, named by the agent that steers both (thread_start + * and thread_send `checkout`) and resolved to a folder at that moment, so an + * approval or a restart later puts the thread in the same place. + */ +export const ChildThreadCheckout = Schema.Struct({ + /** The thread whose checkout it is, with its title when it was asked for. */ + threadId: ThreadId, + title: TrimmedNonEmptyString, + /** Its folder: a worktree's, or null for the project folder. */ + worktreePath: Schema.NullOr(TrimmedNonEmptyString), + /** The branch checked out there then; null when none is. */ + branch: Schema.NullOr(TrimmedNonEmptyString), +}); +export type ChildThreadCheckout = typeof ChildThreadCheckout.Type; + /** The linked computer a child runs on (docs/design/fleet.md, Part 2). */ export const ChildThreadTarget = Schema.Struct({ environmentId: EnvironmentId, @@ -2087,7 +2140,8 @@ export type ChildThreadTarget = typeof ChildThreadTarget.Type; /** * Everything it takes to set up a child, saved with its request when it is * asked for, so approval and a restart rebuild it exactly. Its access is the - * asking thread's at that moment, never more. + * asking thread's at that moment, or a lower level the agent asked for, + * never more. */ export const ChildThreadLaunch = Schema.Struct({ title: TrimmedNonEmptyString, @@ -2099,6 +2153,12 @@ export const ChildThreadLaunch = Schema.Struct({ reportBack: Schema.Boolean, runSetup: Schema.Boolean, workspace: ChildThreadWorkspace, + /** + * Another thread's checkout to work in (ChildThreadCheckout). `workspace` + * is then `project_folder`, which here means only that no worktree is made: + * an older client reads it as a shared folder, which it is. + */ + checkout: Schema.optional(ChildThreadCheckout), /** * A linked computer to start it on, instead of this one. `modelSelection` * then names one of that computer's own agents (its instance id). @@ -2461,6 +2521,8 @@ export const OrchestrationThread = Schema.Struct({ remoteOrigin: Schema.optional(Schema.NullOr(ThreadRemoteOrigin)), /** See OrchestrationThreadShell.scheduledRun. */ scheduledRun: Schema.optional(Schema.NullOr(ThreadScheduledRun)), + /** See OrchestrationThreadShell.worktreeOrigin. */ + worktreeOrigin: Schema.optional(Schema.NullOr(ThreadWorktreeOrigin)), /** See OrchestrationThreadShell.outsideOrigin. */ outsideOrigin: Schema.optional(Schema.NullOr(OutsideAgentRef)), /** See OrchestrationThreadShell.outsideWork. */ @@ -2698,6 +2760,11 @@ export const OrchestrationThreadShell = Schema.Struct({ * ". Absent or null: not a run thread. */ scheduledRun: Schema.optional(Schema.NullOr(ThreadScheduledRun)), + /** + * Its worktree is another thread's (ThreadWorktreeOrigin): it works there + * without owning it. Absent or null: its own, or the project folder. + */ + worktreeOrigin: Schema.optional(Schema.NullOr(ThreadWorktreeOrigin)), /** * Started by an outside agent (OutsideAgentRef): labelled with its name. * Absent or null: started in Threadlines. @@ -2909,6 +2976,8 @@ const ThreadCreateLineageFields = { scheduledRun: Schema.optional(ThreadScheduledRun), /** Started by an outside agent: see OutsideAgentRef. Only `/mcp/connect` sets it. */ outsideOrigin: Schema.optional(OutsideAgentRef), + /** Started in another thread's worktree: see ThreadWorktreeOrigin. Only a child's setup sets it. */ + worktreeOrigin: Schema.optional(ThreadWorktreeOrigin), }; // The normalizer refuses these on commands that arrive from a client. @@ -4449,6 +4518,11 @@ const ThreadChildSendCommand = Schema.Struct({ childThreadId: ThreadId, childMessageId: MessageId, text: TrimmedNonEmptyString, + /** + * Another thread's checkout the child moves to before it reads the message + * (ChildThreadCheckout): it keeps its conversation. Only an idle child moves. + */ + checkout: Schema.optional(ChildThreadCheckout), createdAt: IsoDateTime, }); @@ -4949,6 +5023,8 @@ export const ThreadCreatedPayload = Schema.Struct({ remoteOrigin: Schema.optional(ThreadRemoteOrigin), /** See OrchestrationThreadShell.scheduledRun. */ scheduledRun: Schema.optional(ThreadScheduledRun), + /** See OrchestrationThreadShell.worktreeOrigin. */ + worktreeOrigin: Schema.optional(ThreadWorktreeOrigin), /** See OrchestrationThreadShell.outsideOrigin. */ outsideOrigin: Schema.optional(OutsideAgentRef), /** Imported by a move: see ThreadMovedFrom and OrchestrationThread.historySeedOwed. */ @@ -5278,6 +5354,11 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + /** + * Whose worktree the thread is in from this event on (ThreadWorktreeOrigin); + * null: its own again, or none. Absent: unchanged. + */ + worktreeOrigin: Schema.optional(Schema.NullOr(ThreadWorktreeOrigin)), updatedAt: IsoDateTime, }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index dd6fc2d09..33f3831d4 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1029,6 +1029,16 @@ export const ServerSettings = Schema.Struct({ * like `agentInvites`. Independent of Rooms. */ agentThreads: AgentThreadsMode.pipe(Schema.withDecodingDefault(Effect.succeed("ask" as const))), + /** + * Whether a thread an agent starts or messages may work in another + * thread's worktree instead of one of its own (thread_start and thread_send + * `checkout`, docs/design/child-threads.md, "Workspaces"). Read at every + * call and again when the thread is set up, so turning it off applies at + * once. Threads already working in one stay. Never chosen: off. + */ + agentThreadsShareWorktrees: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), /** * Rooms: more than one agent in a thread. Gates every entry point in the * clients (agents in the model picker, the Rooms filter) and agents @@ -1280,6 +1290,7 @@ export const ServerSettingsPatch = Schema.Struct({ providerInstances: Schema.optionalKey(Schema.Record(ProviderInstanceId, ProviderInstanceConfig)), agentInvites: Schema.optionalKey(AgentInvitesMode), agentThreads: Schema.optionalKey(AgentThreadsMode), + agentThreadsShareWorktrees: Schema.optionalKey(Schema.Boolean), enableRooms: Schema.optionalKey(Schema.Boolean), enableAgentPages: Schema.optionalKey(Schema.Boolean), enableClaudeArtifacts: Schema.optionalKey(Schema.Boolean), diff --git a/packages/shared/src/appPlaces.ts b/packages/shared/src/appPlaces.ts index b59acae5d..914492a84 100644 --- a/packages/shared/src/appPlaces.ts +++ b/packages/shared/src/appPlaces.ts @@ -38,7 +38,11 @@ import { RESPONSIVE_PAGE_SIZE_ID, RESPONSIVE_PAGE_SIZE_LABEL, } from "./browserPageSizes.ts"; -import { agentInvitesChoice, agentThreadsMode } from "./serverSettings.ts"; +import { + agentInvitesChoice, + agentThreadsMode, + agentThreadsShareWorktrees, +} from "./serverSettings.ts"; import { themeGlassLevel } from "./themes/glassLevel.ts"; /** Where a page lives in the app, and which computer it can show. */ @@ -1403,6 +1407,17 @@ const ROWS = [ }, widensAgents: true, }, + { + id: "threads.agentThreadsShareWorktrees", + kind: "row", + parent: "threads.newThreads", + title: "Threads can share a checkout", + about: + "An agent can put a thread it starts or messages in another thread's checkout instead of a new worktree, so both work on the same files.", + keywords: ["worktree", "same folder", "existing checkout", "reviewer", "thread_start"], + value: onOff((settings) => agentThreadsShareWorktrees(settings)), + widensAgents: true, + }, { id: "threads.agentPages", kind: "row", diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index d63fac74d..fce228031 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -202,3 +202,13 @@ export const agentInvitesMode = (settings: RoomsSettings): AgentInvitesMode => export const agentThreadsMode = ( settings: Pick, "agentThreads"> | null | undefined, ): AgentThreadsMode => settings?.agentThreads ?? "ask"; + +/** + * Whether a thread an agent starts or messages may work in another thread's + * worktree (child threads, "Workspaces"): the user's choice, never chosen + * meaning no. The thread tools, a child's setup and the settings page all + * read this one rule. + */ +export const agentThreadsShareWorktrees = ( + settings: Pick, "agentThreadsShareWorktrees"> | null | undefined, +): boolean => settings?.agentThreadsShareWorktrees === true; From c1ed92a6f231652accce5dd4ea01c54aa1ff328e Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 14 Nov 2026 02:27:17 -0500 Subject: [PATCH 2/2] fix(web): the thread title hook no longer pulls child-thread code into startup The sidebar hover card and the row under the message box now read another thread's title, and importing that hook from childThreads.ts dragged the whole module into the startup code (about 6 KB raw, 2 KB gzip), which put the startup size 563 B over its budget in CI. The hook moves to a file of its own; childThreads.ts re-exports it. --- apps/web/src/childThreads.ts | 13 ++---------- apps/web/src/components/BranchToolbar.tsx | 2 +- .../components/sidebar/ThreadHoverCard.tsx | 2 +- apps/web/src/hooks/useThreadTitle.ts | 20 +++++++++++++++++++ 4 files changed, 24 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/hooks/useThreadTitle.ts diff --git a/apps/web/src/childThreads.ts b/apps/web/src/childThreads.ts index 0c6679128..b0d6d3719 100644 --- a/apps/web/src/childThreads.ts +++ b/apps/web/src/childThreads.ts @@ -28,6 +28,7 @@ import { useMemo } from "react"; import { useShallow } from "zustand/react/shallow"; import { formatChildThreadCount } from "./components/Sidebar.logic"; +import { useThreadTitle } from "./hooks/useThreadTitle"; import { toSortableTimestamp } from "./lib/threadSort"; import type { ProviderInstanceEntry } from "./providerInstances"; import { getProviderModelCapabilities } from "./providerModels"; @@ -44,17 +45,7 @@ import type { SidebarThreadSummary } from "./types"; const NO_CHILD_THREADS: readonly SidebarThreadSummary[] = []; -/** Another thread's title, live, for a link to it; null when this device does not know it. */ -export function useThreadTitle( - environmentId: EnvironmentId | null, - threadId: ThreadId | null | undefined, -): string | null { - return useStore((state) => - threadId == null || environmentId === null - ? null - : (selectEnvironmentState(state, environmentId).threadShellById[threadId]?.title ?? null), - ); -} +export { useThreadTitle }; const NO_REMOTE_CHILDREN: readonly RemoteChildThread[] = []; diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 783f31c4b..8b7b6b60a 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -11,7 +11,7 @@ import { } from "lucide-react"; import { memo, useMemo } from "react"; -import { useThreadTitle } from "../childThreads"; +import { useThreadTitle } from "../hooks/useThreadTitle"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { useIsMobile } from "../hooks/useMediaQuery"; import { useStore } from "../store"; diff --git a/apps/web/src/components/sidebar/ThreadHoverCard.tsx b/apps/web/src/components/sidebar/ThreadHoverCard.tsx index 73f7a2f04..43ffb626e 100644 --- a/apps/web/src/components/sidebar/ThreadHoverCard.tsx +++ b/apps/web/src/components/sidebar/ThreadHoverCard.tsx @@ -29,7 +29,7 @@ import { import { usePrimaryEnvironmentId } from "../../environments/primary"; import { useServerProviders } from "../../rpc/serverState"; import { selectProjectByRef, useStore } from "../../store"; -import { useThreadTitle } from "../../childThreads"; +import { useThreadTitle } from "../../hooks/useThreadTitle"; import { useGitStatus } from "../../lib/gitStatusState"; import { resolveThreadWorkingCwd } from "@threadlines/shared/threadCwd"; import { createThreadSelectorByRef } from "../../storeSelectors"; diff --git a/apps/web/src/hooks/useThreadTitle.ts b/apps/web/src/hooks/useThreadTitle.ts new file mode 100644 index 000000000..a93d6327c --- /dev/null +++ b/apps/web/src/hooks/useThreadTitle.ts @@ -0,0 +1,20 @@ +import type { EnvironmentId, ThreadId } from "@threadlines/contracts"; + +import { selectEnvironmentState, useStore } from "../store"; + +/** + * Another thread's title, live, for a link to it; null when this device does + * not know it. In a file of its own: the sidebar and the row under the + * message box read it at startup, and importing it from `childThreads.ts` + * would pull that whole module into the startup code with it. + */ +export function useThreadTitle( + environmentId: EnvironmentId | null, + threadId: ThreadId | null | undefined, +): string | null { + return useStore((state) => + threadId == null || environmentId === null + ? null + : (selectEnvironmentState(state, environmentId).threadShellById[threadId]?.title ?? null), + ); +}