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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions apps/server/src/checkpointing/Layers/CheckpointRevert.test.ts
Original file line number Diff line number Diff line change
@@ -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<OrchestrationThread> = {}) =>
({
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");
});
});
45 changes: 42 additions & 3 deletions apps/server/src/checkpointing/Layers/CheckpointRevert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
});
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/guide/topics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
},
Expand Down
10 changes: 9 additions & 1 deletion apps/server/src/mcp/McpRoomServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 } : {}),
Expand Down
Loading
Loading