From 4e904b7b8a1ae1da331fce1418328018860236a7 Mon Sep 17 00:00:00 2001 From: "Claude Fable 5.2" <354270077+abominable-intelligence@users.noreply.github.com> Date: Sat, 14 Nov 2026 16:50:13 +0100 Subject: [PATCH 1/6] fix(server): a run records its folder, and its checkpoints stay there A thread's folder can change between turns, for example when the thread gets a worktree. A thread had one root checkpoint scope, so a diff or a restore of an older turn ran Git in a folder that does not hold that turn's checkpoint. A run now records the folder its turn starts in and, outside a worktree, the repository root found on disk. A turn that starts in a new folder gets a root checkpoint scope of its own, and its baseline belongs to that scope. Diffs, rollbacks and the restore safety check use the folder of the run that took the checkpoint. The thread baseline stays with the first run's scope when that run failed without a checkpoint. The projection and the history page list the folder of each run that has a visible row, so a client can read an old row against its own folder. Co-authored-by: maria-rcks Co-Authored-By: Martin Zeman --- .../checkpointing/CheckpointDiffQuery.test.ts | 105 +++++++++++++++++- .../src/checkpointing/CheckpointDiffQuery.ts | 54 +++++++-- .../CheckpointRestoreSafety.test.ts | 40 ++++++- .../CheckpointRollbackService.test.ts | 49 ++++++-- .../CheckpointRollbackService.ts | 25 ++++- .../CheckpointScopeOwnership.test.ts | 2 + .../CheckpointService.test.ts | 39 +++++++ .../src/orchestration-v2/CheckpointService.ts | 4 +- .../src/orchestration-v2/Orchestrator.ts | 9 +- .../orchestration-v2/ProjectionStore.test.ts | 24 ++++ .../src/orchestration-v2/ProjectionStore.ts | 37 +++++- .../ProviderTurnStartService.test.ts | 34 +++++- .../ProviderTurnStartService.ts | 43 ++++++- .../testkit/ProviderReplayFixtures.ts | 1 + .../testkit/ThreadFork.integration.test.ts | 5 +- .../fixtures/opencode2_revert/input.ts | 2 +- .../testkit/fixtures/shared.ts | 9 +- .../testkit/fixtures/thread_rollback/input.ts | 1 - .../thread_rollback_after_restart/input.ts | 1 - .../thread_rollback_after_stop/input.ts | 1 - .../thread_rollback_to_stopped_turn/input.ts | 1 - .../src/provider/Drivers/ClaudeSkills.ts | 2 +- packages/contracts/src/orchestrationV2.ts | 9 ++ 23 files changed, 448 insertions(+), 49 deletions(-) diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index 7c516e97fa730..6514f9c082bf1 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -1,7 +1,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it, vi } from "@effect/vitest"; -import { CheckpointRef, CheckpointScopeId, RunId, ThreadId } from "@t3tools/contracts"; +import { CheckpointRef, CheckpointScopeId, NodeId, RunId, ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -20,6 +20,7 @@ import { CheckpointRefUnavailableError, CheckpointTurnRangeUnavailableError } fr const threadId = ThreadId.make("thread:checkpoint-diff-v2"); const firstRunId = RunId.make("run:checkpoint-diff-v2:1"); const secondRunId = RunId.make("run:checkpoint-diff-v2:2"); +const thirdRunId = RunId.make("run:checkpoint-diff-v2:3"); const firstScopeId = CheckpointScopeId.make("scope:checkpoint-diff-v2:1"); const secondScopeId = CheckpointScopeId.make("scope:checkpoint-diff-v2:2"); const secondRef = CheckpointRef.make("refs/t3/test/second"); @@ -51,12 +52,23 @@ function layerFor(input: { readonly projection: Effect.Effect; readonly diffCheckpoints?: CheckpointStore.CheckpointStore["Service"]["diffCheckpoints"]; readonly authoredPaths?: ReadonlySet; + readonly runs?: ReadonlyArray<{ + readonly id: RunId; + readonly cwd?: string; + readonly rootNodeId?: NodeId; + }>; + readonly nodes?: ReadonlyArray<{ + readonly id: NodeId; + readonly checkpointScopeId: CheckpointScopeId; + }>; }) { return CheckpointDiffQuery.layer.pipe( Layer.provide( Layer.mergeAll( Layer.mock(ThreadManagement.ThreadManagementService)({ getCheckpointContext: () => input.projection, + getThreadRecords: () => + Effect.succeed({ runs: input.runs ?? [], nodes: input.nodes ?? [] } as never), }), Layer.mock(CheckpointStore.CheckpointStore)({ diffCheckpoints: input.diffCheckpoints ?? (() => Effect.succeed("diff")), @@ -107,7 +119,6 @@ it.effect("excludes ready checkpoints from rolled-back runs", () => { }); function makeProjectionAfterStoppedRun(stoppedRunCheckpoint: boolean) { - const thirdRunId = RunId.make("run:checkpoint-diff-v2:3"); const checkpoint = ( ordinalWithinScope: number, runId: RunId | null, @@ -190,6 +201,50 @@ it.effect.each([false, true])( }, ); +it.effect("diffs a past turn in the folder its run recorded", () => { + const moved = makeProjectionAfterStoppedRun(true); + const diffCheckpoints = vi.fn((_input: CheckpointStore.DiffCheckpointsInput) => + Effect.succeed("diff"), + ); + const layer = layerFor({ + projection: Effect.succeed({ + ...moved, + runs: moved.runs.map((run) => ({ ...run, status: "completed" as const })), + checkpointScopes: [ + { id: secondScopeId, runId: thirdRunId, kind: "root_run", cwd: "/repo-c" }, + { ...moved.checkpointScopes[0]!, cwd: "/repo-c" }, + ], + }), + runs: [ + { id: firstRunId }, + { id: secondRunId, cwd: "/repo-a" }, + { id: thirdRunId, cwd: "/repo-b" }, + ], + diffCheckpoints, + }); + + return Effect.gen(function* () { + const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; + yield* query.getTurnDiff({ threadId, fromTurnCount: 1, toTurnCount: 2 }); + yield* query.getTurnDiff({ threadId, fromTurnCount: 0, toTurnCount: 1 }); + yield* query.getTurnDiff({ threadId, fromTurnCount: 2, toTurnCount: 3 }); + for (const fromTurnCount of [0, 1]) { + assert.deepInclude( + yield* query.getTurnDiff({ threadId, fromTurnCount, toTurnCount: 3 }).pipe(Effect.flip), + { _tag: "CheckpointRefUnavailableError", checkpoint: "from", turnCount: fromTurnCount }, + ); + } + assert.deepEqual( + diffCheckpoints.mock.calls.map(([input]) => input.cwd), + ["/repo-a", "/repo-c", "/repo-b"], + ); + assert.strictEqual( + diffCheckpoints.mock.calls[1]![0].fromCheckpointRef, + yield* checkpointRefForScopeOrdinal({ scopeId: firstScopeId, ordinalWithinScope: 0 }), + ); + }).pipe(Effect.provide(layer)); +}); + const layerVcsProcess = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); const layerStore = CheckpointStore.layer.pipe( Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(layerVcsProcess))), @@ -254,6 +309,7 @@ it.effect("shares the turn and full-thread diff budget across real Git batches", const query = yield* CheckpointDiffQuery.make.pipe( Effect.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed({ runs: [] } as never), getCheckpointContext: () => Effect.succeed({ runs: [{ id: runId, ordinal: 1, status: "completed" }], @@ -343,3 +399,48 @@ it.effect("preserves an exact-fit filtered patch across batches without a marker assert.strictEqual(patchReads, 2); }).pipe(Effect.provide(layer)); }); + +it.effect("retains the thread baseline when its first run failed without a checkpoint", () => { + const projection = makeProjectionAfterStoppedRun(true); + const rootNodeId = NodeId.make("node:failed-first-run"); + const diffCheckpoints = vi.fn((_input: CheckpointStore.DiffCheckpointsInput) => + Effect.succeed("diff"), + ); + return Effect.gen(function* () { + const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; + const result = yield* query.getFullThreadDiff({ threadId, toTurnCount: 3 }); + assert.strictEqual(result.diff, "diff"); + assert.strictEqual( + diffCheckpoints.mock.calls[0]![0].fromCheckpointRef, + yield* checkpointRefForScopeOrdinal({ scopeId: firstScopeId, ordinalWithinScope: 0 }), + ); + }).pipe( + Effect.provide( + layerFor({ + projection: Effect.succeed({ + ...projection, + checkpointScopes: [ + { + ...projection.checkpointScopes[0]!, + id: CheckpointScopeId.make("unused-scope"), + runId: firstRunId, + }, + ...projection.checkpointScopes, + ], + runs: projection.runs.map((run) => + run.id === firstRunId ? { ...run, status: "failed" as const } : run, + ), + checkpoints: projection.checkpoints.filter( + (checkpoint) => checkpoint.runId !== firstRunId, + ), + }), + runs: [ + { id: firstRunId, cwd: "/repo", rootNodeId }, + { id: thirdRunId, cwd: "/repo" }, + ], + nodes: [{ id: rootNodeId, checkpointScopeId: firstScopeId }], + diffCheckpoints, + }), + ), + ); +}); diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.ts index bf9543919de65..ab10f5b69194a 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.ts @@ -175,10 +175,41 @@ export const make = Effect.gen(function* () { ) : undefined; - const firstScope = + const firstRun = projection.runs.find((run) => run.ordinal === 1); + const fromCheckpoint = + parentCheckpoint ?? + readyCheckpoints.find((checkpoint) => checkpoint.appRunOrdinal === input.fromTurnCount); + // Run 1 captured the thread baseline, and each run captures its own baseline in its folder. + const fromRunId = input.fromTurnCount === 0 ? firstRun?.id : fromCheckpoint?.runId; + const threadNotFound = () => + new CheckpointThreadNotFoundError({ operation, threadId: input.threadId }); + const { runs } = yield* threads + .getThreadRecords(input.threadId, ["runs"], { + runIds: [toCheckpoint.runId, fromRunId].flatMap((runId) => + runId == null ? [] : [runId], + ), + }) + .pipe(Effect.mapError(threadNotFound)); + const firstScopeId = projection.checkpoints.find( + (checkpoint) => checkpoint.status === "ready" && checkpoint.runId === firstRun?.id, + )?.scopeId; + let firstScope = input.fromTurnCount === 0 - ? projection.checkpointScopes.find((scope) => scope.kind === "root_run") + ? projection.checkpointScopes.find((scope) => scope.id === firstScopeId) : undefined; + const firstRootNodeId = runs.find((run) => run.id === firstRun?.id)?.rootNodeId; + if (input.fromTurnCount === 0 && firstScope === undefined && firstRootNodeId != null) { + const { nodes } = yield* threads + .getThreadRecords(input.threadId, ["nodes"], { nodeIds: [firstRootNodeId] }) + .pipe(Effect.mapError(threadNotFound)); + const firstNode = nodes.find((node) => node.id === firstRootNodeId); + firstScope = projection.checkpointScopes.find( + (scope) => scope.id === firstNode?.checkpointScopeId, + ); + } + firstScope ??= projection.checkpointScopes.find( + (scope) => scope.kind === "root_run" && scope.runId === firstRun?.id, + ); const fromCheckpointRef = input.fromTurnCount === 0 ? firstScope === undefined @@ -187,13 +218,16 @@ export const make = Effect.gen(function* () { scopeId: firstScope.id, ordinalWithinScope: 0, }).pipe(Effect.provideService(Digest.Digest, digest)) - : ( - parentCheckpoint ?? - readyCheckpoints.find( - (checkpoint) => checkpoint.appRunOrdinal === input.fromTurnCount, - ) - )?.ref; - if (fromCheckpointRef === undefined) { + : fromCheckpoint?.ref; + const fromScopeId = input.fromTurnCount === 0 ? firstScope?.id : fromCheckpoint?.scopeId; + const folder = (runId: typeof fromRunId, scopeId: typeof fromScopeId) => + runs.find((run) => run.id === runId)?.cwd ?? + projection.checkpointScopes.find((scope) => scope.id === scopeId)?.cwd; + const cwd = folder(toCheckpoint.runId, toCheckpoint.scopeId) ?? toScope.cwd; + if ( + fromCheckpointRef === undefined || + (input.fromTurnCount !== input.toTurnCount - 1 && folder(fromRunId, fromScopeId) !== cwd) + ) { return yield* new CheckpointRefUnavailableError({ operation, threadId: input.threadId, @@ -203,7 +237,7 @@ export const make = Effect.gen(function* () { } const comparison = { - cwd: toScope.cwd, + cwd, fromCheckpointRef, toCheckpointRef: toCheckpoint.ref, fallbackFromToHead: false, diff --git a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts index 145de122f387a..f2890cbca1f8b 100644 --- a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts @@ -3,10 +3,12 @@ import { assert, it } from "@effect/vitest"; import { CheckpointId, CheckpointScopeId, + NodeId, ProjectId, ProviderInstanceId, ProviderSessionId, ProviderThreadId, + RunId, ThreadId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -34,6 +36,11 @@ it.effect.each([ "sibling", "shared-provider", "conversation", + "moved-run", + "legacy-run", + "baseline", + "moved-baseline", + "legacy-baseline", ] as const)("preserves overlapping workspace files, owner=%s", (owner) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -54,6 +61,22 @@ it.effect.each([ const instanceId = ProviderInstanceId.make("restore-instance"); const checkpointId = CheckpointId.make("restore-checkpoint"); const scopeId = CheckpointScopeId.make("restore-scope"); + const runId = RunId.make("restore-run"); + const rootNodeId = NodeId.make("restore-root-node"); + const run = { + id: runId, + ordinal: 1, + rootNodeId, + ...(owner.startsWith("legacy") ? {} : { cwd: owner.startsWith("moved") ? sibling : cwd }), + }; + // A run in the other folder: a baseline resolves through the run whose root node it carries. + const otherRun = { + id: RunId.make("restore-other-run"), + ordinal: 0, + rootNodeId: NodeId.make("restore-other-root-node"), + cwd: owner.startsWith("moved") ? cwd : sibling, + }; + const targetRunId = owner.endsWith("baseline") ? null : runId; const calls: string[] = []; const providerThread = { id: providerThreadId, @@ -87,7 +110,14 @@ it.effect.each([ targetAttempt: undefined, rolledBackAttemptIds: [], staleCheckpoints: [], - checkpoint: { id: checkpointId, scopeId, status: "ready", appRunOrdinal: null }, + checkpoint: { + id: checkpointId, + scopeId, + runId: targetRunId, + nodeId: rootNodeId, + status: "ready", + appRunOrdinal: null, + }, scope: { id: scopeId, cwd }, runsToRollback: [{ id: "later-run", ordinal: 1, status: "completed", rootNodeId: null }], } as unknown as ProjectionCheckpointRollbackContext; @@ -106,6 +136,12 @@ it.effect.each([ Layer.mock(ProjectionStoreV2)({ getThread: () => Effect.succeed(projection.thread), getCheckpointRollbackContext: () => Effect.succeed(projection), + getThreadRecords: (_threadId, _fields, filter) => + Effect.succeed({ + runs: [otherRun, run].filter( + (candidate) => filter?.runIds?.includes(candidate.id) ?? true, + ), + } as never), getShellSnapshot: () => Effect.succeed({ schemaVersion: 1, @@ -166,7 +202,7 @@ it.effect.each([ Effect.provide(layerTest), ); const restoreFiles = owner !== "conversation"; - const rejected = !["sibling", "shared-provider", "conversation"].includes(owner); + const rejected = /^(nested|ancestor|provider|moved)/.test(owner); if (rejected) { const error = yield* service .execute({ threadId, providerThreadId, checkpointId, scopeId, restoreFiles }) diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts index 224fafb02c92d..387ffb70ea410 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts @@ -178,9 +178,11 @@ it.effect.each([ { order: "cleanup-first", restoreFiles: false, leavesFolder: true }, { order: "cleanup-first", restoreFiles: true, leavesFolder: true, shared: true }, { order: "cleanup-first", restoreFiles: true, leavesFolder: true, createFails: true }, + { order: "cleanup-first", restoreFiles: true, baselineOfLaterRun: true }, ])("locks rollback with %s", (row) => Effect.gen(function* () { const { order, restoreFiles, unmanaged, leavesFolder, shared, createFails } = row; + const laterNodeId = NodeId.make("rollback-worktree-later-node"); const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspaceRoot = yield* fileSystem.realPath( @@ -215,7 +217,13 @@ it.effect.each([ }, providerThread, providerSession: undefined, - checkpoint: { id: checkpointId, scopeId, status: "ready", appRunOrdinal: 0 }, + checkpoint: { + id: checkpointId, + scopeId, + status: "ready", + appRunOrdinal: 0, + ...(row.baselineOfLaterRun ? { runId: null, nodeId: laterNodeId } : {}), + }, scope: { id: scopeId, cwd: worktreePath }, runsToRollback: [], targetAttempt: undefined, @@ -224,6 +232,18 @@ it.effect.each([ rootNodes: [], providerTurns: [], } as unknown as ProjectionStore.ProjectionCheckpointRollbackContext; + // The thread's first run started in the project folder, before the thread had this worktree. + const runs = row.baselineOfLaterRun + ? [ + { id: RunId.make("rollback-worktree-run-1"), ordinal: 1, cwd: workspaceRoot }, + { + id: RunId.make("rollback-worktree-run-2"), + ordinal: 2, + rootNodeId: laterNodeId, + cwd: worktreePath, + }, + ] + : []; const testLayer = CheckpointRollbackService.layer.pipe( Layer.provide( Layer.mergeAll( @@ -259,8 +279,9 @@ it.effect.each([ }), }), Layer.mock(CheckpointService.CheckpointServiceV2)({ - restore: () => + restore: ({ scope }) => Effect.gen(function* () { + assert.equal(scope.cwd, worktreePath); assert.isTrue( yield* fileSystem.exists(path.join(worktreePath, ".git")).pipe(Effect.orDie), ); @@ -278,6 +299,7 @@ it.effect.each([ Layer.mock(ProjectionStore.ProjectionStoreV2)({ getThread: () => Effect.succeed(projection.thread), getCheckpointRollbackContext: () => Effect.succeed(projection), + getThreadRecords: () => Effect.succeed({ runs } as never), getShellSnapshot: () => Effect.succeed({ schemaVersion: 1, @@ -463,6 +485,7 @@ it.effect.each([{ restoreFiles: true }, { restoreFiles: false }])( Layer.mock(ProjectionStore.ProjectionStoreV2)({ getThread: () => Effect.succeed(projection.thread), getCheckpointRollbackContext: () => Effect.succeed(projection), + getThreadRecords: () => Effect.succeed({ runs: [] } as never), getShellSnapshot: () => Effect.succeed({ schemaVersion: 1, @@ -535,6 +558,7 @@ const seedRollbackHistory = Effect.gen(function* () { const modelSelection = { instanceId: providerInstanceId, model: "test" }; const scopeId = CheckpointScopeId.make("rollback-history-root"); const otherScopeId = CheckpointScopeId.make("rollback-history-child"); + const movedCwd = import.meta.dirname; const runId = (ordinal: number) => RunId.make(`rollback-history-run-${ordinal}`); const nodeId = (ordinal: number) => NodeId.make(`rollback-history-node-${ordinal}`); const attemptId = (ordinal: number) => RunAttemptId.make(`rollback-history-attempt-${ordinal}`); @@ -643,6 +667,8 @@ const seedRollbackHistory = Effect.gen(function* () { completedAt: now, checkpointId: checkpointId(ordinal), contextHandoffId: null, + ...(ordinal === 4 ? { cwd: "/rollback-history/removed" } : {}), + ...(ordinal === 6 ? { cwd: movedCwd } : {}), }, }); yield* apply({ @@ -749,9 +775,18 @@ const seedRollbackHistory = Effect.gen(function* () { Effect.sync(() => { restored.push(checkpoint.id); }), - deleteStaleRefs: ({ checkpoints }) => - Effect.sync(() => { - deletedRefs.push(...checkpoints.map((checkpoint) => checkpoint.id)); + deleteStaleRefs: ({ scope, checkpoints }) => + Effect.gen(function* () { + const checkpointIds = checkpoints.map((checkpoint) => checkpoint.id); + if (scope.cwd === movedCwd) { + return yield* new CheckpointService.CheckpointDeleteStaleRefsError({ + scopeId: scope.id, + checkpointIds, + cause: "not a repository", + }); + } + assert.equal(scope.cwd, process.cwd()); + deletedRefs.push(...checkpointIds); }), }), Layer.mock(EventSink.EventSinkV2)({ @@ -841,7 +876,7 @@ it.layer(ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMe assert.deepEqual(history.rollbackCounts, [4]); assert.deepEqual(history.restored, [checkpointId(2)]); - assert.sameMembers(history.deletedRefs, [3, 4, 5, 6].map(checkpointId)); + assert.sameMembers(history.deletedRefs, [3, 5].map(checkpointId)); const runs = yield* sql<{ ordinal: number; status: string }>` SELECT ordinal, status FROM orchestration_v2_projection_runs WHERE thread_id = ${threadId} ORDER BY ordinal`; @@ -885,7 +920,7 @@ it.layer(ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMe yield* history.rollback(0); assert.deepEqual(history.rollbackCounts, [1, 3, 3]); - assert.sameMembers(history.deletedRefs, [1, 2, 3, 4, 5, 6].map(history.checkpointId)); + assert.sameMembers(history.deletedRefs, [1, 2, 3, 5].map(history.checkpointId)); assert.sameMembers( history.committed .filter((event) => event.type === "run.updated") diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts index 8924a3eb9a78b..9be499001ffd3 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts @@ -2,6 +2,7 @@ import { CheckpointId, CheckpointScopeId, latestProviderTurnForAttempt, + type OrchestrationV2Checkpoint, type OrchestrationV2DomainEvent, ProviderThreadId, ThreadId, @@ -221,8 +222,22 @@ export const layer: Layer.Layer< }; }); + const runIds = [checkpoint, ...staleCheckpoints].flatMap((target) => + target.runId === null ? [] : [target.runId], + ); + const { runs } = yield* projections.getThreadRecords( + input.threadId, + ["runs"], + checkpoint.runId === null ? undefined : { runIds }, + ); + // A baseline has no run. Its node is the root node of the run that materialized it. + const runCwd = (target: Pick) => + runs.find((run) => + target.runId === null ? run.rootNodeId === target.nodeId : run.id === target.runId, + )?.cwd ?? scope.cwd; + const restoreScope = { ...scope, cwd: runCwd(checkpoint) }; const restoreFiles = input.restoreFiles !== false; - const isolated = isCheckpointRestoreIsolated(context.thread, scope, { + const isolated = isCheckpointRestoreIsolated(context.thread, restoreScope, { fileSystem, projections, projects, @@ -340,9 +355,11 @@ export const layer: Layer.Layer< target: rollbackTarget, providerThreadTurns, }); - if (input.restoreFiles !== false) yield* checkpoints.restore({ scope, checkpoint }); - if (staleCheckpoints.length > 0) { - yield* checkpoints.deleteStaleRefs({ scope, checkpoints: staleCheckpoints }); + if (restoreFiles) yield* checkpoints.restore({ scope: restoreScope, checkpoint }); + for (const [cwd, stale] of Map.groupBy(staleCheckpoints, runCwd)) { + if (!(yield* fileSystem.exists(cwd))) continue; + const drop = checkpoints.deleteStaleRefs({ scope: { ...scope, cwd }, checkpoints: stale }); + yield* cwd === scope.cwd ? drop : drop.pipe(Effect.ignore({ log: true })); } // Lock against full-row thread writes that could restore the old native session after rollback. diff --git a/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts b/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts index 33a7490a0245a..6fc42f538e22b 100644 --- a/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts @@ -217,6 +217,8 @@ it.effect("resolves the thread baseline after a second root run replaces scope o Layer.mergeAll( Layer.mock(ThreadManagement.ThreadManagementService)({ getCheckpointContext: () => Effect.succeed(context), + getThreadRecords: (threadId, fields, filter) => + projections.getThreadRecords(threadId, fields, filter).pipe(Effect.orDie), }), Layer.mock(CheckpointStore.CheckpointStore)({ listAuthoredPaths: () => Effect.succeed(null), diff --git a/apps/server/src/orchestration-v2/CheckpointService.test.ts b/apps/server/src/orchestration-v2/CheckpointService.test.ts index 62fa68c4c53af..c5ad4a057496e 100644 --- a/apps/server/src/orchestration-v2/CheckpointService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointService.test.ts @@ -233,3 +233,42 @@ it.effect("records a repository probe failure as an error checkpoint, not a miss assert.deepEqual(statuses, { baseline: "error", turn: "error" }); }), ); + +it.effect("captures a separate baseline when a thread moves between worktrees", () => + Effect.gen(function* () { + const refs = new Set(); + const capturedFolders: string[] = []; + const layerTest = CheckpointService.layer.pipe( + Layer.provide( + Layer.mergeAll( + IdAllocator.layer, + Layer.mock(CheckpointStore.CheckpointStore)({ + isGitRepository: () => Effect.succeed(true), + hasCheckpointRef: ({ checkpointRef }) => Effect.succeed(refs.has(checkpointRef)), + captureCheckpoint: ({ cwd, checkpointRef }) => + Effect.sync(() => { + refs.add(checkpointRef); + capturedFolders.push(cwd); + }), + }), + ), + ), + Layer.provideMerge(NodeCrypto.layer), + ); + yield* Effect.gen(function* () { + const checkpoints = yield* CheckpointService.CheckpointServiceV2; + for (const cwd of ["/repo/source", "/repo/destination", "/repo/destination"]) { + const scope = yield* checkpoints.prepareRootRunScope({ + threadId: ThreadId.make("thread:moved"), + runId: RunId.make("run:moved"), + rootNodeId: NodeId.make("node:moved"), + providerThreadId: ProviderThreadId.make("provider-thread:moved"), + cwd, + createdAt: DateTime.makeUnsafe("2026-07-28T00:00:00.000Z"), + }); + yield* checkpoints.captureBaseline({ scope, ordinalWithinScope: 1 }); + } + assert.deepEqual(capturedFolders, ["/repo/source", "/repo/destination"]); + }).pipe(Effect.provide(layerTest)); + }), +); diff --git a/apps/server/src/orchestration-v2/CheckpointService.ts b/apps/server/src/orchestration-v2/CheckpointService.ts index 3e0fbe95e8aa4..ccde72544e4c7 100644 --- a/apps/server/src/orchestration-v2/CheckpointService.ts +++ b/apps/server/src/orchestration-v2/CheckpointService.ts @@ -24,7 +24,7 @@ import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; const CHECKPOINT_REFS_PREFIX = "refs/t3/orchestration-v2/checkpoints"; -const ROOT_CHECKPOINT_SCOPE_NAME = "root"; +export const rootCheckpointScopeName = (cwd: string) => `root:${cwd}`; // The provider turn waits on the baseline, so a workspace too slow for this starts without one. // It bounds only the snapshot: waiting on the lock lets an in-flight capture finish the same ref. const BASELINE_CAPTURE_BUDGET = "5 seconds"; @@ -188,7 +188,7 @@ const makeRootRunScope = Effect.fn("CheckpointService.makeRootRunScope")(functio }) { const scopeId = yield* input.idAllocator.allocate.checkpointScope({ threadId: input.threadId, - name: ROOT_CHECKPOINT_SCOPE_NAME, + name: rootCheckpointScopeName(input.cwd), }); return { id: scopeId, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index ad2fb655a116f..a2c8e9e0465d6 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -13794,7 +13794,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } if (command.restoreFiles !== false) { - const isolated = yield* isCheckpointRestoreIsolated(projection.thread, targetScope, { + // A baseline has no run. Its node is the root node of the run that materialized it. + const restoreRun = projection.runs.find((run) => + targetCheckpoint.runId === null + ? run.rootNodeId === targetCheckpoint.nodeId + : run.id === targetCheckpoint.runId, + ); + const restoreScope = { cwd: restoreRun?.cwd ?? targetScope.cwd }; + const isolated = yield* isCheckpointRestoreIsolated(projection.thread, restoreScope, { projects, path, fileSystem, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 50f2a0a12d1ae..02462bc43042b 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -3732,6 +3732,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { completedAt: nowIso, checkpointId: null, contextHandoffId: null, + cwd: `/repo/run-${ordinal}`, })} ) `; @@ -3902,6 +3903,10 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { const bytePage = yield* historyPage(threadId, byteCursor); assert.isAbove(bytePage.items.length, 0); assert.isBelow(bytePage.items.length, 70); + assert.deepEqual( + bytePage.runFolders?.map((run) => `${run.id} ${run.cwd}`), + bytePage.items.map(({ item }) => `${item.runId} /repo/run-${item.ordinal}`), + ); assert.isAtMost( Buffer.byteLength(JSON.stringify(bytePage.items), "utf8"), THREAD_HISTORY_PAGE_POLICY.maxEncodedBytes, @@ -9757,6 +9762,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { completedAt: now, checkpointId: null, contextHandoffId: null, + cwd: `/repo/source-${ordinal}`, }, }); yield* projectionStore.apply({ @@ -9854,6 +9860,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { completedAt: now, checkpointId: null, contextHandoffId: null, + cwd: "/repo/source-2", }, }); @@ -10122,6 +10129,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { completedAt: now, checkpointId: null, contextHandoffId: null, + cwd: "/repo/target", }, }); for (let ordinal = 1; ordinal <= 102; ordinal += 1) { @@ -10297,6 +10305,13 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { "fork", ], ); + assert.sameDeepMembers( + boundedNested.projection.runFolders?.map((run) => [run.id, run.cwd]) ?? [], + [ + [sourceRun2Id, "/repo/source-2"], + [targetRunId, "/repo/target"], + ], + ); const nestedInitial = buildBoundedThreadProjection({ policy: { ...THREAD_HISTORY_PAGE_POLICY, maxUserTurns: undefined }, @@ -10313,6 +10328,15 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { const nestedIds = nestedInitial.projection.visibleTurnItems.map((row) => String(row.sourceItemId), ); + const nestedPage = yield* projectionStore.getThreadHistoryPage( + nestedThreadId, + nestedInitial.historyCursor!, + ); + assert.deepEqual(nestedPage.runFolders?.map((run) => run.cwd).sort(), [ + "/repo/source-1", + "/repo/source-2", + "/repo/target", + ]); let nestedCursor = nestedInitial.historyCursor; let nestedPages = 1; while (nestedCursor !== null) { diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 431426270f228..20a9d111a2c5c 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -3,6 +3,7 @@ import { applyOrchestrationV2ThreadVisit, pendingGuestMessageCount } from "@t3to import { applyThreadPullRequestLinkUpdate } from "../sourceControl/threadPullRequests.ts"; import { projectTurnItemForWire } from "./WireProjection.ts"; import * as Stream from "effect/Stream"; +import * as Struct from "effect/Struct"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { makeThreadFind } from "./ThreadFind.ts"; import { unsentManualContinuationSource } from "./manualContinuation.ts"; @@ -417,7 +418,7 @@ const inheritsForkConversation = ({ export type ProjectionRecordField = Exclude< keyof OrchestrationV2ThreadProjection, - "thread" | "updatedAt" | "visibleTurnItems" + "thread" | "updatedAt" | "visibleTurnItems" | "runFolders" >; export type ProjectionRecords = Pick< OrchestrationV2ThreadProjection, @@ -1588,6 +1589,17 @@ function inheritedRunFork(thread: Pick; + +function runFoldersFor(runs: ReadonlyArray, runIds: ReadonlySet) { + const runFolders = runs.flatMap((run) => + run.cwd === undefined || !runIds.has(run.id) + ? [] + : [Struct.pick(run, ["id", "cwd", "repositoryRoot"])], + ); + return runFolders.length === 0 ? {} : { runFolders }; +} + function buildVisibleTurnItems(input: { readonly projection: OrchestrationV2ThreadProjection; readonly sourceProjection: OrchestrationV2ThreadProjection | null; @@ -2256,6 +2268,8 @@ export const layer: Layer.Layer = readonly threadIds: ReadonlyArray; readonly local: ReadonlyArray; readonly visible: ReadonlyArray; + /** The runs of the thread and of every fork source, for the folders of inherited rows. */ + readonly runs: ReadonlyArray; }; // Least recently used first; a few cover clients scrolling side by side without pinning every thread. const TIMELINE_INDEX_CACHE_LIMIT = 8; @@ -4555,12 +4569,17 @@ export const layer: Layer.Layer = new Set([...seenThreadIds, threadId]), sourceWindow, ); + const visibleTurnItems = buildVisibleTurnItems({ + projection, + sourceProjection, + }); return { ...projection, - visibleTurnItems: buildVisibleTurnItems({ - projection, - sourceProjection, - }), + visibleTurnItems, + ...runFoldersFor( + [...sourceProjection.runs, ...(sourceProjection.runFolders ?? [])], + new Set(visibleTurnItems.map((row) => row.item.runId)), + ), }; }, (effect, threadId, _seenThreadIds, _window?) => @@ -6460,7 +6479,7 @@ export const layer: Layer.Layer = const visible = local.filter((row) => isVisible(row.item)); const fork = inheritedRunFork(records.thread); if (fork === null || seen.has(fork.threadId)) - return { records, threadIds: [threadId], local, visible }; + return { records, threadIds: [threadId], local, visible, runs: records.runs }; const source = yield* readTimelineIndex( fork.threadId, new Set([...seen, threadId]), @@ -6500,6 +6519,7 @@ export const layer: Layer.Layer = records, threadIds: [threadId, ...source.threadIds], local, + runs: [...records.runs, ...source.runs], visible: [ ...inherited, { @@ -7243,6 +7263,11 @@ export const layer: Layer.Layer = items: conversationOnly ? pageItems.filter((row) => isConversationHistoryItem(row.item)) : pageItems, + // A local timeline has no fork ancestry, so the thread's own runs cover its rows. + ...runFoldersFor( + full === null ? (local?.records.runs ?? []) : full.runs, + new Set(pageItems.map((row) => row.item.runId)), + ), nextCursor: bounded.hasMoreHistory ? bounded.nextCursor : page.nextCursor, hasMoreHistory: bounded.hasMoreHistory || page.hasMoreHistory, }; diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 556e03d128712..7458abda7f6d8 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -1056,6 +1056,9 @@ function makeLocalCommandHarness(input: { ) : Effect.die("A local command must not start a native turn."), ); + const forkBaseline = vi.fn(() => + Effect.succeed(Effect.void), + ); const failReadIfRunning = Effect.suspend(() => input.failReadsAfterRunning === true && projection.runs.find((candidate) => candidate.id === runId)?.status === "running" @@ -1223,7 +1226,7 @@ function makeLocalCommandHarness(input: { Layer.mock(ProviderAuthService.ProviderAuthService)({ tryHandlePromptCommand }), Layer.mock(RunExecutionService.RunExecutionServiceV2)({ startRootRun, - forkBaseline: () => Effect.succeed(Effect.void), + forkBaseline, }), Layer.succeed(ProviderUsageAnalytics.ProviderTurnAnalytics, { runStarted: () => Effect.void, @@ -1250,6 +1253,7 @@ function makeLocalCommandHarness(input: { fallbackEnsureThread, writeIfRunCurrent, startRootRun, + forkBaseline, startTurn, forkThread, interruptTurn, @@ -1862,6 +1866,7 @@ effectIt.effect.each(["cleanup-first", "startup-first"])( fileSystem, failReadsAfterRunning: true, }); + const dispatchedScopeId = harness.projection().checkpointScopes[0]!.id; harness.createWorktree.mockImplementation(() => fileSystem.makeDirectory(worktreePath).pipe(Effect.as({} as never), Effect.orDie), ); @@ -1920,9 +1925,23 @@ effectIt.effect.each(["cleanup-first", "startup-first"])( runtimePolicy: expect.objectContaining({ cwd: worktreePath }), }), ); + const startedScope = harness.startRootRun.mock.calls[0]![0].checkpointScope; + expect(startedScope.cwd).toBe(worktreePath); + expect(startedScope.id).not.toBe(dispatchedScopeId); + // The baseline is captured before the session opens, and it belongs to the same folder. + expect(harness.forkBaseline).toHaveBeenCalledWith( + expect.objectContaining({ checkpointScope: startedScope }), + ); + expect(harness.projection().nodes.at(-1)?.checkpointScopeId).toBe(startedScope.id); + expect(harness.projection().runs.at(-1)).toMatchObject({ + cwd: worktreePath, + repositoryRoot: undefined, + }); expect(harness.startRootRun).toHaveBeenCalledWith( expect.objectContaining({ appThread: expect.objectContaining({ worktreePath }), + run: expect.objectContaining({ cwd: worktreePath }), + checkpointScope: startedScope, runtimePolicy: expect.objectContaining({ cwd: worktreePath }), }), ); @@ -2332,11 +2351,20 @@ effectIt.effect("fails the run instead of replacing a native thread another clie effectIt.effect("does not mistake a failed state read for a superseded run", () => Effect.gen(function* () { - const harness = makeLocalCommandHarness({ text: "Continue", failReadsAfterRunning: true }); + const harness = makeLocalCommandHarness({ + text: "Continue", + fileSystem: FileSystem.makeNoop({ exists: (path) => Effect.succeed(path === "/tmp/.git") }), + failReadsAfterRunning: true, + }); yield* harness.start; - expect(harness.projection().runs.at(-1)?.status).toBe("running"); + expect(harness.projection().runs.at(-1)).toMatchObject({ + status: "running", + cwd: "/tmp/native-account-command", + repositoryRoot: "/tmp", + }); + expect(harness.events.map((event) => event.type)).not.toContain("checkpoint-scope.created"); const controls = harness.startRootRun.mock.calls[0]?.[0]; expect(controls).toBeDefined(); if (controls === undefined) return; diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 15792dacecfa2..672120494cf2e 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -34,11 +34,13 @@ import { withTurnStartReservation, withWorkspaceLease } from "../workspace/works import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; +import { findRepositoryRoot } from "../provider/Drivers/ClaudeSkills.ts"; import * as ServerSettings from "../serverSettings.ts"; import { makeRestoreMissingWorktree } from "../workspace/restoreMissingWorktree.ts"; import * as EventSink from "./EventSink.ts"; import * as ProviderUsageAnalytics from "../telemetry/ProviderUsageAnalytics.ts"; import * as ContextHandoffService from "./ContextHandoffService.ts"; +import { rootCheckpointScopeName } from "./CheckpointService.ts"; import { isActiveRun } from "./CommandPolicy.ts"; import { DEFAULT_HANDOFF_TOKEN_CAP, @@ -717,11 +719,23 @@ export const layer: Layer.Layer< }); if (!repaired.committed) return; } + // A thread that moved starts its turn in a new folder, and the run's checkpoints belong there. + const startCheckpointScope = + resolvedRuntimePolicy.cwd === null || resolvedRuntimePolicy.cwd === checkpointScope.cwd + ? checkpointScope + : { + ...checkpointScope, + id: yield* idAllocator.allocate.checkpointScope({ + threadId: projection.thread.id, + name: rootCheckpointScopeName(resolvedRuntimePolicy.cwd), + }), + cwd: resolvedRuntimePolicy.cwd, + }; // Opening a session does not prompt the provider, so the workspace is still unchanged. const baseline = yield* runExecution.forkBaseline({ appThread: projection.thread, run, - checkpointScope, + checkpointScope: startCheckpointScope, }); const sessionResult = yield* Effect.result( providerSessions.open({ @@ -1500,8 +1514,18 @@ export const layer: Layer.Layer< createdAt: providerThread.createdAt, updatedAt: now, }; + const repositoryRoot = + projection.thread.worktreePath === null && resolvedRuntimePolicy.cwd !== null + ? yield* findRepositoryRoot(resolvedRuntimePolicy.cwd).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ) + : undefined; const runningRun: OrchestrationV2Run = { ...run, + ...(resolvedRuntimePolicy.cwd === null + ? {} + : { cwd: resolvedRuntimePolicy.cwd, repositoryRoot }), status: "running", startedAt: now, }; @@ -1515,6 +1539,7 @@ export const layer: Layer.Layer< }; const runningRootNode: OrchestrationV2ExecutionNode = { ...rootNode, + checkpointScopeId: startCheckpointScope.id, status: "running", startedAt: now, }; @@ -1568,6 +1593,20 @@ export const layer: Layer.Layer< }, }, ]), + ...(startCheckpointScope === checkpointScope + ? [] + : [ + { + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "checkpoint-scope.created" as const, + threadId: projection.thread.id, + runId: run.id, + nodeId: rootNode.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: startCheckpointScope, + }, + ]), // Shown only once the replacement session commits with the running transition. ...(recoveryTurnItem === undefined ? [] @@ -1764,7 +1803,7 @@ export const layer: Layer.Layer< session: deliverySession, run: runningRun, rootNode: runningRootNode, - checkpointScope, + checkpointScope: startCheckpointScope, providerThread: runningProviderThread, attempt: runningAttempt, attemptId: attempt.id, diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayFixtures.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayFixtures.ts index 1683439c96411..b04d142873d76 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayFixtures.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayFixtures.ts @@ -132,6 +132,7 @@ const runFixtureProvider = Effect.fn("runOrchestratorReplayFixture")(function* < fixtureInput, driver: input.driver.driver, modelSelection: input.driver.modelSelection, + workspace, }).pipe(Effect.provide(IdAllocator.layer), provideDeterministicTestRuntime); const scenario = { name: `${input.fixtureName}/${input.driver.driver}`, diff --git a/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts index d90886835a234..be43348f6edb2 100644 --- a/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts @@ -18,6 +18,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { ClaudeOrchestratorReplayHarness } from "../Adapters/ClaudeAdapterV2.testkit.ts"; import { CodexOrchestratorReplayHarness } from "../Adapters/CodexAdapterV2.testkit.ts"; +import { rootCheckpointScopeName } from "../CheckpointService.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; import { @@ -492,7 +493,7 @@ describe("orchestration V2 thread fork", () => { const secondRunId = ids.derive.run({ threadId: sourceThreadId, ordinal: 2 }); const checkpointScopeId = yield* ids.allocate.checkpointScope({ threadId: sourceThreadId, - name: "root", + name: rootCheckpointScopeName(cwd), }); const firstCheckpointId = yield* ids.allocate.checkpoint({ checkpointScopeId, @@ -698,7 +699,7 @@ describe("orchestration V2 thread fork", () => { const targetSecondRunId = ids.derive.run({ threadId: targetThreadId, ordinal: 2 }); const targetCheckpointScopeId = yield* ids.allocate.checkpointScope({ threadId: targetThreadId, - name: "root", + name: rootCheckpointScopeName(cwd), }); const targetFirstCheckpointId = yield* ids.allocate.checkpoint({ checkpointScopeId: targetCheckpointScopeId, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_revert/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_revert/input.ts index 14404d84b54ed..a319c63aee886 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_revert/input.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_revert/input.ts @@ -9,7 +9,7 @@ export function openCode2RevertInput(): OrchestratorFixtureInput { steps: [ { type: "message", text: OPENCODE2_REVERT_FIRST_PROMPT }, { type: "message", text: OPENCODE2_REVERT_SECOND_PROMPT }, - { type: "rollback", checkpointScopeSuffix: "root", checkpointSuffix: "1" }, + { type: "rollback", checkpointSuffix: "1" }, { type: "await_run_status", targetRunIndex: 2, status: "rolled_back" }, ], }; diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts index 63f85819acbaa..899f6079afdae 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts @@ -30,6 +30,7 @@ import type { } from "../OrchestratorScenario.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import type { RuntimePolicyV2Override } from "../../RuntimePolicy.ts"; +import { rootCheckpointScopeName } from "../../CheckpointService.ts"; export const SIMPLE_PROMPT = "Respond with the following text: fixture simple ok"; export const MULTI_TURN_FIRST_PROMPT = "Respond with exactly: first fixture turn complete"; @@ -262,7 +263,6 @@ export type OrchestratorFixtureInputStep = } | { readonly type: "rollback"; - readonly checkpointScopeSuffix: string; readonly checkpointSuffix: string; } | { @@ -488,6 +488,8 @@ export function materializeFixtureInput(input: { readonly fixtureInput: OrchestratorFixtureInput; readonly driver: ProviderDriverKind; readonly modelSelection: ModelSelection; + /** The folder runs start in; rollback steps target its root checkpoint scope. */ + readonly workspace?: string; }): Effect.Effect< MaterializedOrchestratorFixtureInput, IdAllocator.IdAllocatorV2Error, @@ -945,9 +947,12 @@ export function materializeFixtureInput(input: { break; case "rollback": { + if (input.workspace === undefined) { + return yield* Effect.die("A rollback fixture step needs the replay workspace."); + } const scopeId = yield* idAllocator.allocate.checkpointScope({ threadId: ids.threadId, - name: step.checkpointScopeSuffix, + name: rootCheckpointScopeName(input.workspace), }); pushDispatch({ type: "checkpoint.rollback", diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback/input.ts index 47371f6be7669..ad400dc6bd325 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback/input.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback/input.ts @@ -12,7 +12,6 @@ export function threadRollbackInput(): OrchestratorFixtureInput { { type: "message", text: THREAD_ROLLBACK_SECOND_PROMPT }, { type: "rollback", - checkpointScopeSuffix: "root", checkpointSuffix: "1", }, { type: "message", text: THREAD_ROLLBACK_AFTER_PROMPT }, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_restart/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_restart/input.ts index 7452f72bfbf5b..c51045d8d6a91 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_restart/input.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_restart/input.ts @@ -14,7 +14,6 @@ export function threadRollbackAfterRestartInput(): OrchestratorFixtureInput { { type: "advance_clock", duration: "31 minutes" }, { type: "rollback", - checkpointScopeSuffix: "root", checkpointSuffix: "1", }, { type: "message", text: THREAD_ROLLBACK_AFTER_PROMPT }, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_stop/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_stop/input.ts index d31d186c56f11..c3779c0c22012 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_stop/input.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_stop/input.ts @@ -16,7 +16,6 @@ export function threadRollbackAfterStopInput(): OrchestratorFixtureInput { { type: "message", text: THREAD_ROLLBACK_SECOND_PROMPT }, { type: "rollback", - checkpointScopeSuffix: "root", checkpointSuffix: "1", }, { type: "message", text: THREAD_ROLLBACK_AFTER_PROMPT }, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_to_stopped_turn/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_to_stopped_turn/input.ts index 2c304197ec0d6..b5fa63c6dc8b5 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_to_stopped_turn/input.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_to_stopped_turn/input.ts @@ -15,7 +15,6 @@ export function threadRollbackToStoppedTurnInput(): OrchestratorFixtureInput { { type: "message", text: THREAD_ROLLBACK_SECOND_PROMPT }, { type: "rollback", - checkpointScopeSuffix: "root", checkpointSuffix: "2", }, { type: "message", text: THREAD_ROLLBACK_AFTER_PROMPT }, diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index a5df2671540f8..ecb42462e84d5 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -157,7 +157,7 @@ function claudeSettingsPaths( } // Untraced: skill discovery resolves every distinct plugin project path, too often for spans. -const findRepositoryRoot = Effect.fnUntraced(function* ( +export const findRepositoryRoot = Effect.fnUntraced(function* ( cwd: string, ): Effect.fn.Return { const fileSystem = yield* FileSystem.FileSystem; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index ee950ecb43036..81e1c5d4948da 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -6,6 +6,7 @@ import * as Schema from "effect/Schema"; import * as SchemaAST from "effect/SchemaAST"; import * as SchemaGetter from "effect/SchemaGetter"; import * as SchemaTransformation from "effect/SchemaTransformation"; +import * as Struct from "effect/Struct"; import { CheckpointId, @@ -758,6 +759,8 @@ export const OrchestrationV2Run = Schema.Struct({ workspacePreparation: Schema.optional(OrchestrationV2ThreadLaunchWorkspaceStrategy), /** Starts a new provider conversation; earlier runs stay visible but never reach the provider. */ freshContext: Schema.optional(Schema.Boolean), + cwd: Schema.optional(Schema.String), + repositoryRoot: Schema.optional(Schema.String), }); export type OrchestrationV2Run = typeof OrchestrationV2Run.Type; @@ -2127,9 +2130,14 @@ export const OrchestrationV2DomainEvent = Schema.Union([ ]); export type OrchestrationV2DomainEvent = typeof OrchestrationV2DomainEvent.Type; +const OrchestrationV2RunFolder = OrchestrationV2Run.mapFields( + Struct.pick(["id", "cwd", "repositoryRoot"]), +); + export const OrchestrationV2ThreadProjection = Schema.Struct({ thread: OrchestrationV2AppThread, runs: Schema.Array(OrchestrationV2Run), + runFolders: Schema.optional(Schema.Array(OrchestrationV2RunFolder)), attempts: Schema.Array(OrchestrationV2RunAttempt), nodes: Schema.Array(OrchestrationV2ExecutionNode), subagents: Schema.Array(OrchestrationV2Subagent), @@ -4106,6 +4114,7 @@ export const OrchestrationV2ThreadHistoryPage = withRecordIdTable( item: ForwardCompatibleUnion(OrchestrationV2TurnItem.members, "type"), })), ), + runFolders: Schema.optional(Schema.Array(OrchestrationV2RunFolder)), nextCursor: Schema.NullOr(TrimmedNonEmptyString), hasMoreHistory: Schema.Boolean, idTable: IdTableMarker, From 019bb0623f6b4997abf3a7b7b9aaf8e37fa05c34 Mon Sep 17 00:00:00 2001 From: "Claude Fable 5.2" <354270077+abominable-intelligence@users.noreply.github.com> Date: Sat, 14 Nov 2026 16:50:22 +0100 Subject: [PATCH 2/6] fix(claude): a session keeps working when its thread changes folder Claude Code stores a session under the folder the session was created in. When a thread's folder changed, the adapter could reuse the idle process of the old folder, and it looked for the session's files in the new folder. The adapter now reuses an idle query only in the same folder. A wake turn that still runs keeps its process. A continuation that ends before its drain retires the wake it started under, so the next wake gets a request of its own. The adapter records the folder of a session it creates on the provider thread, and the run keeps that record when it fails before the update is stored. Subagent lookups and native forks read from the recorded folder. A caller can pass a lookup for a session that has no record. Co-Authored-By: Martin Zeman --- .../Adapters/ClaudeAdapterV2.test.ts | 578 ++++++++++++++++-- .../Adapters/ClaudeAdapterV2.ts | 123 +++- .../src/orchestration-v2/Orchestrator.ts | 1 + .../RunExecutionService.test.ts | 56 +- .../orchestration-v2/RunExecutionService.ts | 21 +- .../contracts/src/orchestrationV2.test.ts | 5 + packages/contracts/src/orchestrationV2.ts | 1 + .../src/server/ProviderAdapter.ts | 3 + 8 files changed, 706 insertions(+), 82 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index b232252ee7fbe..a56c4dc3860a9 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -208,6 +208,12 @@ const CLAUDE_TEST_RUNTIME_POLICY = ProviderAdapter.ProviderAdapterV2RuntimePolic interactionMode: "default", cwd: "/workspace", }); +const layerClaudeTestServices = Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, +); function makeClaudeTestAppThread(input: { readonly threadId: ThreadId; @@ -2692,7 +2698,18 @@ describe("ClaudeAdapterV2 attachments", () => { }); describe("ClaudeAdapterV2 native fork", () => { - it.effect("forks a session from its original directory after the workspace root changes", () => + it.effect.each([ + { from: "the session cwd", folder: "/workspace" }, + { from: "its record", recorded: "/repo/a", history: "/repo/h", folder: "/repo/a" }, + { from: "run history", history: "/repo/a", folder: "/repo/a" }, + { + from: "its record, as a whole session", + recorded: "/repo/a", + history: "/repo/h", + folder: "/repo/a", + whole: true, + }, + ])("forks a session from its original directory and records its folder from $from", (row) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; @@ -2704,14 +2721,15 @@ describe("ClaudeAdapterV2 native fork", () => { }); const sourceSessionId = "11111111-1111-4111-8111-111111111111"; const sourceCursor = "aaaaaaaa-0000-4000-8000-000000000002"; - const projectDir = NodePath.join(configDir, "projects", "-old-workspace"); + // The transcript is under the row's folder only, so a lookup in another folder misses it. + const projectDir = NodePath.join(configDir, "projects", row.folder.replaceAll("/", "-")); yield* fileSystem.makeDirectory(projectDir, { recursive: true }); const entry = (uuid: string, parentUuid: string | null, message: unknown) => ({ type: parentUuid === null ? "user" : "assistant", uuid, parentUuid, sessionId: sourceSessionId, - cwd: "/old-workspace", + cwd: row.folder, isSidechain: false, timestamp: "2026-10-01T00:00:00.000Z", message, @@ -2814,34 +2832,41 @@ describe("ClaudeAdapterV2 native fork", () => { const now = yield* DateTime.now; const providerTurnId = ProviderTurnId.make("provider-turn-claude-source"); const forkedProviderThread = yield* runtime.forkThread({ - sourceProviderThread, - sourceProviderTurns: [ - { - id: providerTurnId, - providerThreadId: sourceProviderThread.id, - nodeId: NodeId.make("node-claude-source"), - runAttemptId: RunAttemptId.make("run-attempt-claude-source"), - nativeTurnRef: { - driver: ClaudeAdapterV2.CLAUDE_PROVIDER, - nativeId: sourceCursor, - strength: "weak", - }, - ordinal: 1, - status: "completed", - startedAt: now, - completedAt: now, - }, - ], - providerTurnId, + sourceProviderThread: { + ...sourceProviderThread, + ...(row.recorded === undefined ? {} : { nativeStorageFolder: row.recorded }), + }, + ...(row.history === undefined ? {} : { sourceNativeStorageFolder: row.history }), + ...(row.whole === true + ? {} + : { + sourceProviderTurns: [ + { + id: providerTurnId, + providerThreadId: sourceProviderThread.id, + nodeId: NodeId.make("node-claude-source"), + runAttemptId: RunAttemptId.make("run-attempt-claude-source"), + nativeTurnRef: { + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeId: sourceCursor, + strength: "weak", + }, + ordinal: 1, + status: "completed", + startedAt: now, + completedAt: now, + }, + ], + providerTurnId, + }), targetThreadId, }); + // The fork names no folder: the SDK finds the session by id in this Claude home. assert.deepEqual(forkCalls, [ { sessionId: sourceSessionId, - options: { - upToMessageId: sourceCursor, - }, + options: row.whole === true ? {} : { upToMessageId: sourceCursor }, environment: { CLAUDE_CONFIG_DIR: configDir }, threadId: targetThreadId, providerSessionId, @@ -2855,7 +2880,21 @@ describe("ClaudeAdapterV2 native fork", () => { ); assert.include(forkedTranscript, "inherited answer"); assert.equal(forkedProviderThread.forkedFrom?.providerThreadId, sourceProviderThread.id); - assert.equal(forkedProviderThread.forkedFrom?.providerTurnId, providerTurnId); + assert.equal( + forkedProviderThread.forkedFrom?.providerTurnId, + row.whole === true ? undefined : providerTurnId, + ); + // The fork's head is the last message of the transcript the fork wrote, under its new id. + assert.equal( + forkedProviderThread.nativeMetadata?.forkHeadMessageId, + forkedTranscript + .trimEnd() + .split("\n") + .map((line): { readonly type: string; readonly uuid: string } => JSON.parse(line)) + .findLast((line) => line.type === "assistant")?.uuid, + ); + // The forked transcript was read from the folder that the fork records. + assert.equal(forkedProviderThread.nativeStorageFolder, row.folder); yield* runtime.startTurn({ appThread: { @@ -3178,8 +3217,9 @@ describe("ClaudeAdapterV2 native session identity", () => { updatedAt: now, }, }); - yield* runtime.startTurn( - makeClaudeTestTurnInput({ + const recordedFolders: Array = []; + yield* runtime.startTurn({ + ...makeClaudeTestTurnInput({ threadId, providerThread, now, @@ -3189,8 +3229,22 @@ describe("ClaudeAdapterV2 native session identity", () => { providerTurnOrdinal, ...(nativeThreadHasTurns === undefined ? {} : { nativeThreadHasTurns }), }), + recordNativeStorageFolder: (folder) => + Effect.sync(() => void recordedFolders.push(folder)), + }); + const published = yield* runtime.events.pipe( + Stream.filter( + (event) => + event.type === "provider_thread.updated" || event.type === "provider_turn.updated", + ), + Stream.runHead, + Effect.map((first) => + Option.isSome(first) && first.value.type === "provider_thread.updated" + ? first.value.providerThread.nativeStorageFolder + : undefined, + ), ); - return openedQueries; + return { openedQueries, storageFolder: { recorded: recordedFolders, published } }; }).pipe( Effect.provide( Layer.mergeAll( @@ -3207,29 +3261,36 @@ describe("ClaudeAdapterV2 native session identity", () => { "resumes the native session on a fresh session instance when prior provider turns exist", () => Effect.gen(function* () { - const openedQueries = yield* openTurnWithOrdinal(2); + const { openedQueries, storageFolder } = yield* openTurnWithOrdinal(2); assert.equal(openedQueries.length, 1); assert.equal(openedQueries[0]?.options.resume, "native-session-identity"); assert.equal(openedQueries[0]?.options.sessionId, undefined); assert.equal(openedQueries[0]?.options.title, "Claude attachment test"); + assert.deepEqual(storageFolder, { recorded: [], published: undefined }); }), ); it.effect("creates a fresh native session despite earlier provider-thread turns", () => Effect.gen(function* () { - const openedQueries = yield* openTurnWithOrdinal(4, false); + const { openedQueries, storageFolder } = yield* openTurnWithOrdinal(4, false); assert.equal(openedQueries[0]?.options.sessionId, "native-session-identity"); assert.equal(openedQueries[0]?.options.resume, undefined); assert.equal(openedQueries[0]?.options.title, "Claude attachment test"); + assert.deepEqual(storageFolder, { recorded: ["/workspace"], published: "/workspace" }); }), ); it.effect("resumes an imported native session on its first T3 turn", () => Effect.gen(function* () { - const openedQueries = yield* openTurnWithOrdinal(1, false, "imported-native-session"); + const { openedQueries, storageFolder } = yield* openTurnWithOrdinal( + 1, + false, + "imported-native-session", + ); assert.equal(openedQueries.length, 1); assert.equal(openedQueries[0]?.options.resume, "imported-native-session"); assert.equal(openedQueries[0]?.options.sessionId, undefined); + assert.deepEqual(storageFolder, { recorded: [], published: undefined }); }), ); }); @@ -3466,6 +3527,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly allocateSessionId?: Effect.Effect; readonly offer?: (message: SDKUserMessage) => Effect.Effect; readonly promptSuggestions?: boolean; + readonly onUsageLimits?: ClaudeAdapterV2.ClaudeAdapterV2Options["onUsageLimits"]; readonly routeModelCatalog?: ClaudeAdapterV2.ClaudeAdapterV2Options["routeModelCatalog"]; }) => Effect.gen(function* () { @@ -3520,10 +3582,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { Extract >(); let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; + const subagentLookups: Array = []; const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: options?.settings ?? DEFAULT_CLAUDE_SETTINGS, ...(options?.modelCatalog === undefined ? {} : { modelCatalog: options.modelCatalog }), + ...(options?.onUsageLimits === undefined ? {} : { onUsageLimits: options.onUsageLimits }), environment: options?.environment ?? {}, attachmentsDir, fileSystem, @@ -3631,7 +3695,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { }; }), forkSession: options?.forkSession ?? (() => Effect.die("unused forkSession")), - subagentLaunchToolUseId: () => Effect.succeed(options?.subagentLaunchToolUseId ?? null), + subagentLaunchToolUseId: (lookup) => + Effect.sync(() => { + subagentLookups.push(lookup); + return options?.subagentLaunchToolUseId ?? null; + }), subagentModel: () => Effect.succeed(options?.subagentModel ?? null), assertComplete: Effect.void, }, @@ -3713,6 +3781,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { runtimeRequestReceipts, planReceipts, providerThreadReceipts, + subagentLookups, getOpenedOptions: () => openedOptions, terminalEvents, hasPendingBackgroundWork, @@ -3723,6 +3792,23 @@ describe("ClaudeAdapterV2 background wake turns", () => { }; }); const makeWakeHarness = makeWakeHarnessWithOptions(); + const wakeTurnInput = ( + harness: { threadId: ThreadId; providerThread: OrchestrationV2ProviderThread }, + now: DateTime.Utc, + attempt: string, + providerTurnOrdinal: number, + extra: Partial[0]> = {}, + ) => + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make(attempt), + text: "Continue.", + attachments: [], + providerTurnOrdinal, + ...extra, + }); it.effect.each([false, true])( "resumes a stopped query with empty SDK content and a fresh native process, buffered: %s", @@ -6962,16 +7048,23 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); it.effect( - "applies model, effort and Fast changes to a process running a background shell, but not thinking or a cleared effort", + "applies model, effort and Fast changes to a process running a background shell, but not thinking, a cleared effort or a new cwd", () => Effect.gen(function* () { - const harness = yield* makeWakeHarness; + let closes = 0; + const harness = yield* makeWakeHarnessWithOptions({ + close: () => Effect.sync(() => closes++), + }); const now = yield* DateTime.now; const normal = { instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, model: "claude-opus-5-5", } satisfies ModelSelection; - const turn = (ordinal: number, modelSelection: ModelSelection) => + const turn = ( + ordinal: number, + modelSelection: ModelSelection, + cwd = CLAUDE_TEST_RUNTIME_POLICY.cwd, + ) => makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, @@ -6981,6 +7074,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachments: [], providerTurnOrdinal: ordinal, modelSelection, + runtimePolicy: { ...CLAUDE_TEST_RUNTIME_POLICY, cwd }, }); const settle = Effect.gen(function* () { yield* harness.offerAndWait(turnOneResult); @@ -7019,13 +7113,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.strictEqual(harness.getOpenedOptions(), originalOptions); assert.lengthOf(harness.offeredMessages, 4); - for (const [ordinal, selection] of [ - [5, CLAUDE_TEST_RESTART_MODEL_SELECTION], - [6, { ...normal, model: "claude-haiku-4-5-20251001" }], - ] as const) { - const refused = yield* harness.runtime - .startTurn(turn(ordinal, selection)) - .pipe(Effect.result); + for (const refusedTurn of [ + turn(5, CLAUDE_TEST_RESTART_MODEL_SELECTION), + turn(6, { ...normal, model: "claude-haiku-4-5-20251001" }), + turn(7, { ...normal, model: "claude-sonnet-4-6" }, "/new-workspace"), + turn(8, normal, "/new-workspace"), + ]) { + const refused = yield* harness.runtime.startTurn(refusedTurn).pipe(Effect.result); assert.equal(refused._tag, "Failure"); if (refused._tag === "Failure") { assert.instanceOf( @@ -7035,6 +7129,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { } } assert.strictEqual(harness.getOpenedOptions(), originalOptions); + assert.lengthOf(harness.processQueues, 1); + assert.equal(closes, 0); assert.lengthOf(harness.offeredMessages, 4); assert.lengthOf(harness.liveSelectionChanges, 3); assert.isTrue(yield* harness.hasPendingBackgroundWork); @@ -9694,7 +9790,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("reports the settings directory of a reused Claude query", () => + const queryBranches = ["normal", "selection-update", "continuation"] as const; + it.effect.each(queryBranches)("reopens a Claude query only in a new cwd: %s branch", (branch) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -9711,14 +9808,22 @@ describe("ClaudeAdapterV2 background wake turns", () => { JSON.stringify({ autoCompactWindow: window }), ); } + const closedProcesses: Array> = []; const harness = yield* makeWakeHarnessWithOptions({ settings: { ...DEFAULT_CLAUDE_SETTINGS, homePath: home }, + freshQueueOnReopen: true, + close: (messages) => + Effect.sync(() => { + closedProcesses.push(messages); + }).pipe(Effect.andThen(Queue.shutdown(messages))), }); let firstOptions: ReturnType; for (const [ordinal, cwd] of [ [1, first], - [2, second], + [2, first], + [3, second], ] as const) { + const continuation = branch === "continuation" && ordinal > 1; yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, @@ -9728,16 +9833,40 @@ describe("ClaudeAdapterV2 background wake turns", () => { providerTurnOrdinal: ordinal, text: "Continue.", attachments: [], + ...(continuation + ? { + messageCreatedBy: "agent", + messageCreationSource: "provider", + modelSelection: CLAUDE_TEST_RESTART_MODEL_SELECTION, + } + : branch === "selection-update" && ordinal > 1 + ? { + modelSelection: { + ...CLAUDE_TEST_MODEL_SELECTION, + model: ordinal === 2 ? "claude-opus-5-5" : "claude-sonnet-4-6", + }, + } + : {}), runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ ...CLAUDE_TEST_RUNTIME_POLICY, cwd, }), }), ); + assert.equal(harness.getOpenedOptions()?.cwd, cwd); if (ordinal === 1) firstOptions = harness.getOpenedOptions(); - else assert.strictEqual(harness.getOpenedOptions(), firstOptions); - yield* Queue.offer( - harness.sdkMessages, + else if (ordinal === 2) { + assert.strictEqual(harness.getOpenedOptions(), firstOptions); + assert.lengthOf(harness.processQueues, 1); + assert.isEmpty(closedProcesses); + } else { + assert.notStrictEqual(harness.getOpenedOptions(), firstOptions); + assert.equal(harness.getOpenedOptions()?.resume, WAKE_NATIVE_SESSION); + assert.lengthOf(harness.processQueues, 2); + assert.deepEqual(closedProcesses, [harness.sdkMessages]); + } + const offer = continuation ? () => Effect.void : harness.offerAndWait; + yield* offer( usageSnapshotFrame( `msg_cwd_${ordinal}`, `00000000-0000-4000-8000-00000000080${ordinal}`, @@ -9748,8 +9877,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }, ), ); - yield* Queue.offer( - harness.sdkMessages, + yield* offer( makeResultFrame({ uuid: `00000000-0000-4000-8000-00000000081${ordinal}`, result: "Done.", @@ -9757,13 +9885,14 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); } + assert.lengthOf(harness.liveSelectionChanges, branch === "selection-update" ? 2 : 0); assert.deepEqual( harness.events.flatMap((event) => event.type === "provider_turn.updated" && event.providerTurn.tokenUsage ? [event.providerTurn.tokenUsage.autoCompactThreshold] : [], ), - [117_000, 117_000], + branch === "continuation" ? [117_000] : [117_000, 117_000, 147_000], ); }).pipe( Effect.provide( @@ -14821,7 +14950,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("starts the wake run when Claude opens the wake turn, before its output", () => + it.effect.each(["/workspace", "/new-workspace"])("starts the wake run in %s", (cwd) => Effect.gen(function* () { const harness = yield* makeWakeHarness; const now = yield* DateTime.now; @@ -14857,8 +14986,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { providerTurnOrdinal: 2, messageCreatedBy: "agent", messageCreationSource: "provider", + runtimePolicy: { ...CLAUDE_TEST_RUNTIME_POLICY, cwd }, }), ); + assert.lengthOf(harness.processQueues, 1); assert.lengthOf(harness.terminalEvents(), 1); yield* harness.offerAndWait(wakeAssistant); @@ -14883,6 +15014,322 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect.each([ + "background work", + "a missing append-system-prompt file", + "a wake that opens during the launch file read", + "background work that starts during its launch file read", + "background work that starts during a user turn's launch file read", + "a frame still in handling when it reaches the close", + "a late cleanup after a newer continuation drained", + "a late cleanup after a user turn replaced the query", + "a late cleanup after a runtime restart", + ] as const)("requests a new continuation after one in a new cwd hits %s", (cause) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const first = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-first-" }); + const second = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-second-" }); + yield* fileSystem.writeFileString(path.join(first, "prompt.md"), "Prompt sentinel\n"); + const gatedRead = + cause === "background work" || + cause === "a missing append-system-prompt file" || + cause === "a late cleanup after a runtime restart" + ? undefined + : path.join(second, "prompt.md"); + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-home-" }); + const reading = yield* Deferred.make(); + const settingsRead = yield* Deferred.make(); + const gate = yield* Deferred.make(); + let appendReadStarted = false; + const heldRead = (filePath: string, reached: Deferred.Deferred) => + fileSystem.readFile(filePath).pipe( + Effect.exit, + Effect.tap(() => Deferred.succeed(reached, undefined)), + Effect.tap(() => Deferred.await(gate)), + Effect.flatten, + ); + if (gatedRead !== undefined) + yield* fileSystem.writeFileString(gatedRead, "Prompt sentinel\n"); + let closes = 0; + const handlingLimit = yield* Deferred.make(); + const releaseLimit = yield* Deferred.make(); + const cleanupHeld = yield* Deferred.make(); + const cleanupGate = yield* Deferred.make(); + let holdNextCleanup = false; + const harness = yield* makeWakeHarnessWithOptions({ + settings: { + ...DEFAULT_CLAUDE_SETTINGS, + homePath: home, + launchArgs: cause === "background work" ? "" : "--append-system-prompt-file prompt.md", + }, + freshQueueOnReopen: true, + close: (messages) => + Effect.sync(() => closes++).pipe(Effect.andThen(Queue.shutdown(messages))), + // Holds one turn's scope cleanup behind a gate, as a slow finalizer would. + modelCatalog: Effect.serviceOption(Scope.Scope).pipe( + Effect.tap((scope) => { + if (!holdNextCleanup || Option.isNone(scope)) return Effect.void; + holdNextCleanup = false; + return Scope.addFinalizer( + scope.value, + Deferred.succeed(cleanupHeld, undefined).pipe( + Effect.andThen(Deferred.await(cleanupGate)), + ), + ); + }), + Effect.as(BUNDLED_CLAUDE_MODEL_CATALOG), + ), + onUsageLimits: () => + Deferred.succeed(handlingLimit, undefined).pipe( + Effect.andThen(Deferred.await(releaseLimit)), + ), + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.make({ + ...fileSystem, + readFile: (filePath) => { + if (filePath === gatedRead) { + appendReadStarted = true; + return heldRead(filePath, reading); + } + // The launch settings resolve beside the append file. Their last read is the user tier. + return appendReadStarted && filePath === path.join(home, "settings.json") + ? heldRead(filePath, settingsRead) + : fileSystem.readFile(filePath); + }, + }), + ), + ); + const now = yield* DateTime.now; + const turn = (ordinal: number, cwd: string, continuation: boolean) => + wakeTurnInput(harness, now, `attempt-refused-continuation-${ordinal}`, ordinal, { + text: continuation ? "Background task completed." : "Run the builds in the background.", + runtimePolicy: { ...CLAUDE_TEST_RUNTIME_POLICY, cwd }, + ...(continuation ? { messageCreatedBy: "agent", messageCreationSource: "provider" } : {}), + }); + const secondTask = { + task_id: "bsecondtask", + tool_use_id: "toolu_second_background_task", + session_id: WAKE_NATIVE_SESSION, + }; + const secondTaskStarted = claudeSdkFrame({ + ...secondTask, + type: "system", + subtype: "task_started", + description: "Second background build", + is_backgrounded: true, + task_type: "local_bash", + uuid: "00000000-0000-4000-8000-00000000c101", + }); + + yield* harness.runtime.startTurn(turn(1, first, false)); + yield* harness.offerAndWait(wakeTaskStarted); + if (cause === "background work") { + yield* harness.offerAndWait(secondTaskStarted); + } + yield* harness.offerAndWait(turnOneResult); + yield* Queue.take(harness.terminalReceipts); + for (const frame of [wakeNotification, wakeTurnInit, wakeAssistant, wakeResult]) { + yield* harness.offerAndWait(frame); + } + assert.lengthOf(harness.continuationRequests, 1); + const wakeInit = (uuid: string) => + claudeSdkFrame({ type: "system", subtype: "init", uuid, session_id: WAKE_NATIVE_SESSION }); + const nextWakeInit = wakeInit("00000000-0000-4000-8000-00000000c103"); + + if (cause === "a wake that opens during the launch file read") { + const started = yield* Effect.forkScoped(harness.runtime.startTurn(turn(2, second, true))); + yield* Deferred.await(reading); + yield* harness.offerAndWait(nextWakeInit); + yield* Deferred.succeed(gate, undefined); + yield* Fiber.join(started); + assert.equal(harness.getOpenedOptions()?.cwd, first); + yield* harness.awaitTerminal(2); + assert.equal(harness.terminalEvents()[1]?.status, "completed"); + } else if (cause === "a frame still in handling when it reaches the close") { + const limited = yield* Effect.forkScoped( + harness.offerAndWait( + claudeSdkFrame({ + type: "rate_limit_event", + rate_limit_info: { status: "allowed", rateLimitType: "five_hour", utilization: 0.5 }, + uuid: "00000000-0000-4000-8000-00000000c105", + session_id: WAKE_NATIVE_SESSION, + }), + ), + ); + yield* Deferred.await(handlingLimit); + const started = yield* Effect.forkScoped(harness.runtime.startTurn(turn(2, second, true))); + yield* Deferred.await(reading); + yield* Deferred.await(settingsRead); + // Both launch reads wait at the gate. Released from a fork, the replacement runs inline to the close. + const closesWhileHandling = yield* Effect.forkScoped( + Deferred.succeed(gate, undefined).pipe(Effect.map(() => closes)), + ); + const closedWhileHandling = yield* Fiber.join(closesWhileHandling); + yield* Deferred.succeed(releaseLimit, undefined); + assert.equal(closedWhileHandling, 0); + yield* Fiber.join(limited); + yield* Fiber.join(started); + assert.equal(harness.getOpenedOptions()?.cwd, second); + yield* harness.awaitTerminal(2); + assert.equal(harness.terminalEvents()[1]?.status, "completed"); + } else if (cause.startsWith("a late cleanup")) { + const restart = cause === "a late cleanup after a runtime restart"; + const replaced = cause === "a late cleanup after a user turn replaced the query"; + const newerContinuation = cause === "a late cleanup after a newer continuation drained"; + yield* Deferred.succeed(gate, undefined); + holdNextCleanup = true; + const late = yield* Effect.forkScoped( + harness.runtime.startTurn(turn(2, replaced ? first : second, true)), + ); + yield* Deferred.await(cleanupHeld); + if (restart) { + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: harness.terminalEvents()[0]!.providerTurnId, + requestRuntimeRestart: true, + }); + } else { + yield* harness.awaitTerminal(2); + assert.equal(harness.terminalEvents()[1]?.status, "completed"); + // Cancelled after its drain: the interrupt lands once the held cleanup finishes. + yield* Effect.forkScoped(Fiber.interrupt(late)); + } + if (newerContinuation) { + yield* harness.offerAndWait(wakeInit("00000000-0000-4000-8000-00000000c106")); + assert.lengthOf(harness.continuationRequests, 2); + yield* harness.runtime.startTurn(turn(3, second, true)); + } else { + yield* harness.runtime.startTurn(turn(3, restart ? first : second, false)); + } + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-00000000c107", + result: "Second build done.", + ...(newerContinuation ? { origin: { kind: "task-notification" } } : {}), + }), + ); + yield* harness.awaitTerminal(restart ? 2 : 3); + assert.equal(harness.terminalEvents().at(-1)?.status, "completed"); + yield* harness.offerAndWait(wakeInit("00000000-0000-4000-8000-00000000c108")); + assert.lengthOf(harness.continuationRequests, newerContinuation ? 3 : 2); + yield* Deferred.succeed(cleanupGate, undefined); + assert.equal((yield* Fiber.await(late))._tag, "Failure"); + } else { + const userTurnFirst = + cause === "background work that starts during a user turn's launch file read"; + const started = yield* Effect.forkScoped( + harness.runtime.startTurn(turn(2, second, !userTurnFirst)).pipe(Effect.flip), + ); + if (gatedRead !== undefined) { + yield* Deferred.await(reading); + yield* harness.offerAndWait(secondTaskStarted); + yield* Deferred.succeed(gate, undefined); + } + const refused = yield* Fiber.join(started); + if (cause.startsWith("background work")) { + assert.instanceOf( + refused.cause, + ClaudeAdapterV2.ClaudeBackgroundWorkBlocksQueryReplacementError, + ); + } else { + assert.instanceOf(refused.cause, ClaudeAdapterV2.ClaudeAppendSystemPromptFileError); + } + if (userTurnFirst) { + const continuation = yield* harness.runtime + .startTurn(turn(3, second, true)) + .pipe(Effect.flip); + assert.instanceOf( + continuation.cause, + ClaudeAdapterV2.ClaudeBackgroundWorkBlocksQueryReplacementError, + ); + } + } + assert.lengthOf(harness.processQueues, closes + 1); + + if (cause.startsWith("background work")) { + yield* harness.offerAndWait( + claudeSdkFrame({ + ...secondTask, + type: "system", + subtype: "task_notification", + status: "completed", + output_file: "/tmp/claude-replay/tasks/bsecondtask.output", + summary: "Second background build completed", + uuid: "00000000-0000-4000-8000-00000000c102", + }), + ); + } + yield* harness.offerAndWait( + cause === "a wake that opens during the launch file read" + ? makeResultFrame({ + uuid: "00000000-0000-4000-8000-00000000c104", + result: "Second build done.", + origin: { kind: "task-notification" }, + }) + : nextWakeInit, + ); + assert.lengthOf( + harness.continuationRequests, + cause === "a late cleanup after a newer continuation drained" ? 3 : 2, + ); + }).pipe(Effect.scoped, Effect.provide(layerClaudeTestServices)), + ); + + it.effect("drops a wake's output when its continuation is cancelled before the drain", () => + Effect.gen(function* () { + let holdCatalog = false; + const loading = yield* Deferred.make(); + const harness = yield* makeWakeHarnessWithOptions({ + modelCatalog: Effect.suspend(() => + holdCatalog + ? Deferred.succeed(loading, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG), + ), + }); + const now = yield* DateTime.now; + const continuation = (attempt: string, ordinal: number) => + wakeTurnInput(harness, now, attempt, ordinal, { + text: "Background task completed.", + messageCreatedBy: "agent", + messageCreationSource: "provider", + }); + + yield* harness.runtime.startTurn( + wakeTurnInput(harness, now, "attempt-cancelled-continuation-1", 1, { + text: "Run the build in the background.", + }), + ); + yield* harness.offerAndWait(wakeTaskStarted); + yield* harness.offerAndWait(turnOneResult); + yield* Queue.take(harness.terminalReceipts); + for (const frame of [wakeNotification, wakeTurnInit, wakeAssistant, wakeResult]) { + yield* harness.offerAndWait(frame); + } + const request = harness.continuationRequests[0]!; + + holdCatalog = true; + const cancelled = yield* Effect.forkScoped( + harness.runtime.startTurn(continuation("attempt-cancelled-continuation-2", 2)), + ); + yield* Deferred.await(loading); + yield* Fiber.interrupt(cancelled); + holdCatalog = false; + // The run watcher reports the cancelled run after the turn start has already cleaned up. + yield* request.onRunEndedWithoutStarting!(); + + yield* harness.runtime.startTurn(continuation("attempt-cancelled-continuation-3", 3)); + yield* harness.awaitTerminal(2); + assert.isFalse( + harness.events.some( + (event) => event.type === "message.updated" && event.message.text === WAKE_ASSISTANT_TEXT, + ), + ); + }).pipe(Effect.scoped, Effect.provide(layerClaudeTestServices)), + ); + it.effect("offers a later wake after an earlier wake's continuation dispatch failed", () => Effect.gen(function* () { const SECOND_TASK_ID = "b2ndwake"; @@ -16135,7 +16582,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("settles a continuation whose wake turn a user turn absorbed", () => + it.effect("settles a continuation in a new cwd whose wake turn a user turn absorbed", () => Effect.gen(function* () { const harness = yield* makeWakeHarness; const now = yield* DateTime.now; @@ -16191,8 +16638,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { providerTurnOrdinal: 3, messageCreatedBy: "agent", messageCreationSource: "provider", + runtimePolicy: { ...CLAUDE_TEST_RUNTIME_POLICY, cwd: "/new-workspace" }, }), ); + assert.lengthOf(harness.processQueues, 2); + assert.equal(harness.getOpenedOptions()?.cwd, "/new-workspace"); yield* harness.awaitTerminal(3); assert.equal(harness.terminalEvents()[2]?.status, "completed"); assert.isTrue( @@ -21647,21 +22097,29 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("projects workflow member threads after resuming a restarted session", () => + it.effect.each([ + { from: "its record", recorded: "/repo/a", history: "/repo/h", resumed: true, dir: "/repo/a" }, + { from: "run history", history: "/repo/h", resumed: true, dir: "/repo/h" }, + { from: "the folder its query created", history: "/repo/h", resumed: false, dir: "/workspace" }, + ])("projects workflow member threads after resuming a restarted session from $from", (row) => Effect.gen(function* () { const harness = yield* makeWorkflowHarnessWithOptions({ subagentLaunchToolUseId: WORKFLOW_TOOL_USE_ID, }); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ + yield* harness.runtime.startTurn({ + ...makeClaudeTestTurnInput({ threadId: harness.threadId, - providerThread: harness.providerThread, + providerThread: { ...harness.providerThread, nativeStorageFolder: row.recorded }, now: yield* DateTime.now, attemptId: RunAttemptId.make("attempt-claude-workflow-resume"), text: "Continue the workflow.", attachments: [], + nativeThreadHasTurns: row.resumed, }), - ); + ...(row.history === undefined + ? {} + : { readNativeStorageFolder: Effect.succeed(row.history) }), + }); yield* harness.offerAndWait( claudeSdkFrame({ ...workflowToolUse, @@ -21688,6 +22146,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001041", result: "Resumed." }), ); yield* Queue.take(harness.terminalReceipts); + assert.deepEqual( + harness.subagentLookups.map((lookup) => lookup.dir), + [row.dir], + ); const member = workflowMemberEvents(harness.events, 1).at(-1)?.subagent; const coordinator = workflowCoordinatorEvents(harness.events).at(-1)?.subagent; assert.isDefined(member); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 6c30816d1ff0d..96dfd0f5c79fe 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2540,6 +2540,7 @@ function makeProviderThread(input: { readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"]; readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"]; readonly nativeThreadId: string; + readonly nativeStorageFolder?: string; readonly forkedFrom?: NonNullable; readonly nativeMetadata?: NonNullable; readonly now: DateTime.Utc; @@ -2560,6 +2561,9 @@ function makeProviderThread(input: { strength: "strong", }, nativeConversationHeadRef: null, + ...(input.nativeStorageFolder === undefined + ? {} + : { nativeStorageFolder: input.nativeStorageFolder }), status: "idle", firstRunOrdinal: null, lastRunOrdinal: null, @@ -4978,6 +4982,8 @@ interface ClaudeLiveQueryContext { // Per process, not per session: a relative temp dir follows the cwd this CLI // was launched in, and a later turn's cwd can differ. readonly taskOutputRoot: string | undefined; + // Set when this process created its session without resume: Claude stores it under this folder. + readonly createdSessionFolder: string | undefined; } interface ActiveClaudeToolCall { @@ -7035,11 +7041,16 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ) { return; } + const { input: turnInput } = resume.context; + const storageFolder = + turnInput.providerThread.nativeStorageFolder ?? + (yield* turnInput.readNativeStorageFolder ?? Effect.undefined) ?? + turnInput.runtimePolicy.cwd; const launchToolUseId = yield* queryRunner .subagentLaunchToolUseId({ sessionId: resume.nativeThreadId, agentId: resume.taskId, - dir: resume.context.input.runtimePolicy.cwd, + dir: storageFolder, threadId: resume.context.input.threadId, providerSessionId: input.providerSessionId, }) @@ -12360,10 +12371,27 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const queryPolicyKey = claudeEffectiveQueryPolicyKey(queryPolicy, mcpOverrides); const resumeSessionAt = yield* getNativeConversationHeadId(turnInput.providerThread); const existing = yield* Ref.get(queryContext); + // A continuation keeps the process its wake turn still runs in: a replacement would never finish that turn. + const keepsRunningWake = Effect.gen(function* () { + if ( + existing === null || + existing.nativeThreadId !== nativeThreadId || + existing.stopping || + !isClaudeProviderContinuationTurn(turnInput) || + (yield* Ref.get(absorbedWakes)).has(nativeThreadId) + ) { + return false; + } + const buffered = (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages ?? []; + return buffered + .slice(buffered.findLastIndex((entry) => entry.type === "result") + 1) + .some(isClaudeRootTurnFrame); + }); // Continuations drain existing output without prompting; retain the live process regardless of selection. if ( existing !== null && existing.nativeThreadId === nativeThreadId && + (existing.settingsCwd === turnInput.runtimePolicy.cwd || (yield* keepsRunningWake)) && !existing.stopping && isClaudeProviderContinuationTurn(turnInput) ) { @@ -12393,6 +12421,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( !turnInput.resume && existing !== null && existing.nativeThreadId === nativeThreadId && + existing.settingsCwd === turnInput.runtimePolicy.cwd && existing.queryPolicyKey === queryPolicyKey && !existing.stopping && existing.selection.queryIdentity !== compiledSelection.queryIdentity && @@ -12406,6 +12435,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( !turnInput.resume && existing !== null && existing.nativeThreadId === nativeThreadId && + existing.settingsCwd === turnInput.runtimePolicy.cwd && existing.queryPolicyKey === queryPolicyKey && !existing.stopping && existing.selection.queryIdentity === compiledSelection.queryIdentity @@ -12431,15 +12461,18 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } // Replacing this thread's process kills its background work, even with a new native identity. - if ( - existing !== null && - (existing.nativeThreadId === nativeThreadId || - existing.lastTurn?.input.threadId === turnInput.threadId) && - !existing.stopping && - (yield* liveProcessRunsBackgroundWork(existing)) - ) { - return yield* new ClaudeBackgroundWorkBlocksQueryReplacementError(); - } + const refuseIfBackgroundWorkRuns = Effect.gen(function* () { + if ( + existing !== null && + (existing.nativeThreadId === nativeThreadId || + existing.lastTurn?.input.threadId === turnInput.threadId) && + !existing.stopping && + (yield* liveProcessRunsBackgroundWork(existing)) + ) { + return yield* new ClaudeBackgroundWorkBlocksQueryReplacementError(); + } + }); + yield* refuseIfBackgroundWorkRuns; if (reuseLaunchSettings) { launchArgSettings = yield* readLaunchSettings; @@ -12467,7 +12500,19 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const closedExistingNativeThreadId = existing !== null ? existing.nativeThreadId : null; clearedNativeSessions.delete(nativeThreadId); if (existing !== null) { - yield* existing.query.close.pipe(Effect.ignore); + // SDK frames land under the SDK message lock; decide and close under it so a wake turn or + // background work that started during the reads above keeps its process. A stopping query may + // be closing under that lock already. + const closeUnlessWorkRuns = Effect.gen(function* () { + if (yield* keepsRunningWake) return true; + yield* refuseIfBackgroundWorkRuns; + yield* existing.query.close.pipe(Effect.ignore); + return false; + }); + const kept = yield* existing.stopping + ? closeUnlessWorkRuns + : sdkMessageLock.withPermits(1)(closeUnlessWorkRuns); + if (kept) return existing; if (existing.nativeThreadId !== nativeThreadId) { yield* clearWakeStateForNativeThread(existing.nativeThreadId); yield* resetBackgroundTaskStateForNativeThreadProcess(existing.nativeThreadId, { @@ -12565,6 +12610,20 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* resetBackgroundTaskStateForNativeThreadProcess(nativeThreadId, { status: "active", }); + if (!shouldResume && turnInput.runtimePolicy.cwd !== null) { + // Before the prompt goes out: a refused prompt or an early exit must not lose it. + yield* ( + turnInput.recordNativeStorageFolder?.(turnInput.runtimePolicy.cwd) ?? Effect.void + ); + yield* emitProviderThreadRoster({ + nativeThreadId, + providerThread: { + ...turnInput.providerThread, + nativeStorageFolder: turnInput.runtimePolicy.cwd, + }, + status: "active", + }); + } const closed = yield* Deferred.make(); const context: ClaudeLiveQueryContext = { queryPolicy, @@ -12596,6 +12655,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( resolvedTurnCwd, hostPlatform, ), + createdSessionFolder: shouldResume + ? undefined + : (turnInput.runtimePolicy.cwd ?? undefined), }; // One step, so the replaced process cannot exit between the install and the handoff. yield* Ref.update(queryContext, (replaced) => { @@ -12695,6 +12757,28 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const startTurn = Effect.fn("ClaudeAdapterV2.startTurn")( function* (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) { + // A continuation that ends before its drain retires the wake it started under, as a run + // that never started does, so the next wake gets a request of its own. A drain, or a + // request registered since, has already released this one. + const requestNativeThreadId = turnInput.providerThread.nativeThreadRef?.nativeId; + const continuationRequest = + isClaudeProviderContinuationTurn(turnInput) && + typeof requestNativeThreadId === "string" + ? (yield* Ref.get(requestedContinuations)).get(requestNativeThreadId)?.token + : undefined; + if (continuationRequest !== undefined && typeof requestNativeThreadId === "string") { + yield* Effect.addFinalizer(() => + Ref.get(requestedContinuations).pipe( + Effect.flatMap((current) => + current.get(requestNativeThreadId)?.token === continuationRequest + ? sdkMessageLock.withPermits(1)( + retireOfferedWake(requestNativeThreadId, continuationRequest), + ) + : Effect.void, + ), + ), + ); + } modelCatalog = yield* loadModelCatalog; const startedAt = yield* DateTime.now; const nativeThreadId = yield* getNativeThreadId(turnInput.providerThread); @@ -12810,13 +12894,23 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( Effect.provideService(Path.Path, path), ); const querySession = yield* openQuery(turnInput, nativeThreadId, queryPolicy); + const storedTurnInput = + querySession.createdSessionFolder === undefined + ? turnInput + : { + ...turnInput, + providerThread: { + ...turnInput.providerThread, + nativeStorageFolder: querySession.createdSessionFolder, + }, + }; const autoCompactWindow = yield* resolveAutoCompactWindow( querySession.settingsCwd, turnInput.modelSelection, querySession.launchArgSettings, ); const context: ActiveClaudeTurnContext = { - input: turnInput, + input: storedTurnInput, autoCompactWindow, queryPolicy: querySession.queryPolicy, nativeThreadId, @@ -14032,6 +14126,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const sourceNativeThreadId = yield* getNativeThreadId(forkInput.sourceProviderThread); yield* closeLiveQueryForNativeThread(sourceNativeThreadId); const upToMessageId = yield* resolveClaudeForkUpToMessageId(forkInput); + const storageFolder = + forkInput.sourceProviderThread.nativeStorageFolder ?? + forkInput.sourceNativeStorageFolder ?? + input.runtimePolicy.cwd; // A moved project still has its transcript under the original cwd. // Let the SDK find the session by ID within this Claude home. const forkOptions: ForkSessionOptions = @@ -14060,6 +14158,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ownerNodeId: forkInput.ownerNodeId ?? null, providerSessionId: input.providerSessionId, nativeThreadId: forked.sessionId, + ...(storageFolder === null ? {} : { nativeStorageFolder: storageFolder }), forkedFrom: { providerThreadId: forkInput.sourceProviderThread.id, ...(forkInput.providerTurnId === undefined diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index a2c8e9e0465d6..722e458822711 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2692,6 +2692,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...queuedProviderThread, nativeThreadRef: activeProviderThread.nativeThreadRef, nativeConversationHeadRef: activeProviderThread.nativeConversationHeadRef, + nativeStorageFolder: activeProviderThread.nativeStorageFolder, nativeMetadata: activeProviderThread.nativeMetadata, } : queuedProviderThread; diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index 0e8898cf0623b..4747e7acc276e 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -3009,17 +3009,33 @@ it.effect("refreshes pull requests after a provider stream exits with an error", }), ); -it.effect("records a provider's input rejection at turn start in the terminal error", () => +it.effect("records a provider's input rejection at turn start and the folder it recorded", () => Effect.gen(function* () { const rejection = "goal objective must be at most 4000 characters"; - const { written, observed } = yield* captureRootRunTermination({ + const ingesting = yield* Deferred.make(); + const { written, observed, finalEvents } = yield* captureRootRunTermination({ key: "start-failure:input-rejected", shouldFinalizeRun: () => Effect.succeed(true), - events: () => Stream.never, + nativeStorageFolder: "/repo/a", + // The adapter's update with the folder is still being ingested when the start fails. + events: (ids) => + Stream.make({ + type: "provider_thread.updated" as const, + driver, + providerThread: { + id: ids.providerThreadId, + driver, + nativeStorageFolder: "/repo/b", + } as OrchestrationV2ProviderThread, + }), + ingestNormalized: () => + Deferred.succeed(ingesting, undefined).pipe(Effect.andThen(Effect.never)), afterStart: (closed) => Deferred.succeed(closed, undefined), startTurn: (turnInput) => - Effect.fail( - new ProviderAdapter.ProviderAdapterTurnStartError({ + Effect.gen(function* () { + yield* turnInput.recordNativeStorageFolder?.("/repo/b") ?? Effect.void; + yield* Deferred.await(ingesting); + return yield* new ProviderAdapter.ProviderAdapterTurnStartError({ driver, threadId: turnInput.threadId, providerThreadId: turnInput.providerThread.id, @@ -3029,12 +3045,18 @@ it.effect("records a provider's input rejection at turn start in the terminal er errorMessage: rejection, method: "thread/goal/set", }), - }), - ), + }); + }), }); assert.deepEqual(observed, ["run:failed", "pull-requests-refreshed"]); const error = written.find((item) => item.type === "error"); assert.include(error?.failure.message ?? "", rejection); + assert.deepEqual( + finalEvents.flatMap((event) => + event.type === "provider-thread.updated" ? [event.payload.nativeStorageFolder] : [], + ), + ["/repo/b"], + ); }), ); @@ -3053,7 +3075,7 @@ it.effect("tells the user why /compact failed on a provider without compaction", ); it.effect.each(["failure", "defect", "interrupt-failure", "ownership-lost"] as const)( - "stops the live provider and background work before settling an ingestion %s", + "stops the live provider and background work before settling an ingestion %s with the folder it recorded", (failureMode) => Effect.gen(function* () { const started = yield* Deferred.make(); @@ -3070,8 +3092,12 @@ it.effect.each(["failure", "defect", "interrupt-failure", "ownership-lost"] as c initialProviderTurnId: null, rejectTerminalWrite: failureMode === "ownership-lost", shouldFinalizeRun: () => Effect.succeed(true), - startTurn: () => - Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(stopped))), + nativeStorageFolder: "/repo/a", + startTurn: (turnInput) => + (turnInput.recordNativeStorageFolder?.("/repo/b") ?? Effect.void).pipe( + Effect.andThen(Deferred.succeed(started, undefined)), + Effect.andThen(Deferred.await(stopped)), + ), events: () => Stream.unwrap( Deferred.await(started).pipe( @@ -3150,6 +3176,12 @@ it.effect.each(["failure", "defect", "interrupt-failure", "ownership-lost"] as c assert.deepEqual(observed, ["run:failed", "pull-requests-refreshed"]); const error = written.find((item) => item.type === "error"); assert.include(error?.failure.message ?? "", "(SQLITE_CANTOPEN)"); + assert.deepEqual( + finalEvents.flatMap((event) => + event.type === "provider-thread.updated" ? [event.payload.nativeStorageFolder] : [], + ), + ["/repo/b"], + ); const repaired = finalEvents.find((event) => event.type === "provider-turn.updated"); if (failureMode === "interrupt-failure") { assert.isUndefined(repaired); @@ -3522,6 +3554,7 @@ function captureRootRunTermination(input: { readonly loadRunInterruptReason?: () => Effect.Effect; readonly hasPendingBackgroundWorkForThread?: ProviderAdapter.ProviderAdapterV2SessionRuntime["hasPendingBackgroundWorkForThread"]; readonly seedOpenSubagent?: boolean; + readonly nativeStorageFolder?: string; readonly events?: ( ids: BackgroundScenarioIds, ) => Stream.Stream< @@ -3696,6 +3729,9 @@ function captureRootRunTermination(input: { providerThread: { id: ids.providerThreadId, driver, + ...(input.nativeStorageFolder === undefined + ? {} + : { nativeStorageFolder: input.nativeStorageFolder }), } as OrchestrationV2ProviderThread, attempt: { id: ids.attemptId, diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 49cdaf04406b7..b03bcf4c62d24 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -582,6 +582,7 @@ export interface RunExecutionServiceV2StartRootRunInput { readonly baseline?: Effect.Effect; readonly providerTurnOrdinal: number; readonly nativeThreadHasTurns?: boolean; + readonly readNativeStorageFolder?: ProviderAdapter.ProviderAdapterV2TurnInput["readNativeStorageFolder"]; readonly resume?: boolean; readonly loadInheritedBackgroundTurnItems?: () => Effect.Effect< ReadonlyArray, @@ -1063,6 +1064,15 @@ export const layer: Layer.Layer< const terminalEvent = yield* Ref.make(null); const latestTurnItemOrdinal = yield* Ref.make(input.providerTurnOrdinal * 100); const latestProviderThread = yield* Ref.make(input.providerThread); + let recordedNativeStorageFolder: string | undefined; + // A run that fails may not have stored the adapter's update that carried the folder. + const failedRunProviderThread = Ref.get(latestProviderThread).pipe( + Effect.map((providerThread) => + recordedNativeStorageFolder === undefined + ? providerThread + : { ...providerThread, nativeStorageFolder: recordedNativeStorageFolder }, + ), + ); const latestRootProviderTurn = yield* Ref.make(null); // Later root turns in one attempt are provider continuations, such as Codex goal turns. let firstRootProviderTurnId: ProviderTurnId | undefined; @@ -1796,7 +1806,7 @@ export const layer: Layer.Layer< cause, }); if (yield* Ref.get(rootRunFinalized)) return; - const providerThread = yield* Ref.get(latestProviderThread); + const providerThread = yield* failedRunProviderThread; // Stop background work too; the failed consumer cannot persist terminal events. const abandonedProviderTurn = routing.rootProviderTurnId !== null && !routing.rootTurnEnded @@ -1948,6 +1958,13 @@ export const layer: Layer.Layer< modelSelection: input.modelSelection, runtimePolicy: input.runtimePolicy, ...(appContext.length === 0 ? {} : { appContext }), + ...(input.readNativeStorageFolder === undefined + ? {} + : { readNativeStorageFolder: input.readNativeStorageFolder }), + recordNativeStorageFolder: (folder: string) => + Effect.sync(() => { + recordedNativeStorageFolder = folder; + }), }; // Check after the context read: ingestion can settle the run while // it awaits. A failed ownership read fails the start below. @@ -2009,7 +2026,7 @@ export const layer: Layer.Layer< cause, }).pipe( Effect.andThen(Fiber.interrupt(providerEventFiber)), - Effect.andThen(Ref.get(latestProviderThread)), + Effect.andThen(failedRunProviderThread), Effect.flatMap((providerThread) => Ref.get(latestTurnItemOrdinal).pipe( Effect.flatMap((latestItemOrdinal) => diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index 76f78f536d7c5..79cba2574118b 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -434,6 +434,11 @@ describe("orchestration V2 contracts", () => { expect(providerThread.pendingBackgroundTasks).toEqual([]); expect(providerThread.contextUsage).toBeNull(); expect(providerThread.nativeMetadata).toBeNull(); + expect(providerThread).not.toHaveProperty("nativeStorageFolder"); + const moved = { ...providerThread, nativeStorageFolder: "/workspace/project" }; + expect( + decodeOrchestrationV2ProviderThreadJson(encodeOrchestrationV2ProviderThreadJson(moved)), + ).toMatchObject({ nativeStorageFolder: "/workspace/project" }); const runtimeThread = decodeOrchestrationV2ProviderThread({ id: "provider-thread-2", diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 81e1c5d4948da..f78ff8270957c 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1136,6 +1136,7 @@ export const OrchestrationV2ProviderThread = Schema.Struct({ ownerNodeId: Schema.NullOr(NodeId), nativeThreadRef: Schema.NullOr(OrchestrationV2ProviderRef), nativeConversationHeadRef: Schema.NullOr(OrchestrationV2ProviderRef), + nativeStorageFolder: Schema.optional(TrimmedNonEmptyString), status: Schema.Literals(["not_loaded", "idle", "active", "archived", "closed", "error"]), firstRunOrdinal: Schema.NullOr(PositiveInt), lastRunOrdinal: Schema.NullOr(PositiveInt), diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 58f1773974069..3711664ec05a9 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -553,6 +553,8 @@ export interface ProviderAdapterV2TurnInput { readonly modelSelection: ModelSelection; readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; readonly appContext?: ReadonlyArray<{ readonly key: string; readonly text: string }>; + readonly readNativeStorageFolder?: Effect.Effect; + readonly recordNativeStorageFolder?: (folder: string) => Effect.Effect; } export type ProviderAdapterV2ResumeTurnInput = Omit< @@ -638,6 +640,7 @@ export interface ProviderAdapterV2ForkThreadInput { readonly ownerNodeId?: NodeId; readonly modelSelection?: ModelSelection; readonly runtimePolicy?: ProviderAdapterV2RuntimePolicy; + readonly sourceNativeStorageFolder?: string; } export interface ProviderAdapterV2EventSubscription { From d87bc055138fde4dc7298884765a43d17b76085e Mon Sep 17 00:00:00 2001 From: "Claude Fable 5.2" <354270077+abominable-intelligence@users.noreply.github.com> Date: Sat, 14 Nov 2026 16:50:32 +0100 Subject: [PATCH 3/6] feat(server): threads.move moves a thread to another folder `threads.move` moves a thread to another folder, or to a new scratch folder when the destination is null. The server resolves the path, checks every thread that moves along, and commits one batch: thread metadata, the worktree detach, the pull-request link for a cleared branch, scheduled tasks, and the move of Claude's session storage. Subagents and side chats move with the thread. Forks do not, and a fork inherits only the move receipts from before it was created. A thread with a running turn or background work does not move, and neither does one whose provider cannot continue in another folder. The call answers with a question before it leaves a worktree or a scratch folder, or turns a plain folder into a project. The answer names the folders the question showed, and the server refuses when they no longer match. The move leaves a "Moved to" row that records the folder it left, and the next turn tells the model that the folder changed. For a Claude thread an outbox effect renames the session's files into the new folder's directory, and a native fork of that thread waits for the rename. For a session that has no recorded folder, the server reads the folder from the thread's history. Co-Authored-By: Martin Zeman --- apps/server/src/auth/RpcAuthorization.test.ts | 8 +- .../src/environment/ServerEnvironment.ts | 1 + .../src/guest/GuestShareService.test.ts | 1 + .../src/observability/RpcInstrumentation.ts | 1 + .../Adapters/ClaudeAdapterV2.ts | 1 + .../Adapters/CodexAdapterV2.ts | 1 + .../ClaudeSessionRelocation.test.ts | 302 +++++++ .../ClaudeSessionRelocation.ts | 243 ++++++ .../src/orchestration-v2/EffectOutbox.ts | 45 + .../src/orchestration-v2/EffectWorker.test.ts | 204 +++++ .../src/orchestration-v2/EffectWorker.ts | 40 + .../FolderChangeNotice.test.ts | 138 +++ .../orchestration-v2/FolderChangeNotice.ts | 76 ++ .../NativeStorageFolder.test.ts | 125 +++ .../orchestration-v2/NativeStorageFolder.ts | 83 ++ .../Orchestrator.control-reads.test.ts | 195 +++++ .../src/orchestration-v2/Orchestrator.ts | 42 +- .../orchestration-v2/ProjectionStore.test.ts | 355 +++++++- .../src/orchestration-v2/ProjectionStore.ts | 126 ++- .../ProviderTurnStartService.test.ts | 285 ++++++- .../ProviderTurnStartService.ts | 113 ++- .../ResourceCleanupService.ts | 13 +- .../RunFinalizationQueue.test.ts | 6 + .../ThreadManagementService.ts | 2 + .../TurnItemPositionStore.test.ts | 12 + .../orchestration-v2/TurnItemPositionStore.ts | 3 +- .../src/orchestration-v2/runtimeLayer.ts | 16 + .../testkit/ProviderReplayHarness.ts | 2 + .../ProviderSwitch.integration.test.ts | 44 +- apps/server/src/project/ProjectLock.ts | 10 + .../project/ProjectService.deletion.test.ts | 2 + .../server/src/project/ProjectService.test.ts | 2 + apps/server/src/project/ProjectService.ts | 35 +- apps/server/src/project/ThreadMove.testkit.ts | 175 ++++ .../src/project/ThreadMoveDestination.test.ts | 529 ++++++++++++ .../src/project/ThreadMoveDestination.ts | 380 +++++++++ .../server/src/project/ThreadMovePlan.test.ts | 295 +++++++ apps/server/src/project/ThreadMovePlan.ts | 172 ++++ .../src/project/ThreadMoveReceipt.test.ts | 94 +++ apps/server/src/project/ThreadMoveReceipt.ts | 113 +++ .../src/project/ThreadMoveService.test.ts | 786 ++++++++++++++++++ apps/server/src/project/ThreadMoveService.ts | 397 +++++++++ .../src/relay/AgentAwarenessRelay.test.ts | 1 + apps/server/src/ws.test.ts | 10 + apps/server/src/ws.ts | 11 + .../src/state/commandPermissions.test.ts | 3 +- .../contracts/src/clientRpcPermissions.ts | 1 + packages/contracts/src/environment.ts | 1 + .../contracts/src/orchestrationV2.test.ts | 9 +- packages/contracts/src/orchestrationV2.ts | 14 + packages/contracts/src/project.test.ts | 43 + packages/contracts/src/project.ts | 81 ++ packages/contracts/src/rpc.ts | 11 + 53 files changed, 5595 insertions(+), 63 deletions(-) create mode 100644 apps/server/src/orchestration-v2/ClaudeSessionRelocation.test.ts create mode 100644 apps/server/src/orchestration-v2/ClaudeSessionRelocation.ts create mode 100644 apps/server/src/orchestration-v2/FolderChangeNotice.test.ts create mode 100644 apps/server/src/orchestration-v2/FolderChangeNotice.ts create mode 100644 apps/server/src/orchestration-v2/NativeStorageFolder.test.ts create mode 100644 apps/server/src/orchestration-v2/NativeStorageFolder.ts create mode 100644 apps/server/src/project/ProjectLock.ts create mode 100644 apps/server/src/project/ThreadMove.testkit.ts create mode 100644 apps/server/src/project/ThreadMoveDestination.test.ts create mode 100644 apps/server/src/project/ThreadMoveDestination.ts create mode 100644 apps/server/src/project/ThreadMovePlan.test.ts create mode 100644 apps/server/src/project/ThreadMovePlan.ts create mode 100644 apps/server/src/project/ThreadMoveReceipt.test.ts create mode 100644 apps/server/src/project/ThreadMoveReceipt.ts create mode 100644 apps/server/src/project/ThreadMoveService.test.ts create mode 100644 apps/server/src/project/ThreadMoveService.ts diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 37abf3d1593d3..1190e390a2cff 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -46,10 +46,10 @@ describe("RPC authorization scopes", () => { } }); - it("keeps triage, which seeds an agent that reads logs and the database, behind operate scope", () => { - expect(requiredScopeForRpcMethod(WS_METHODS.serverPrepareTriage)).toBe( - AuthOrchestrationOperateScope, - ); + it("keeps triage, which seeds an agent that reads logs and the database, and thread moves behind operate scope", () => { + for (const method of [WS_METHODS.serverPrepareTriage, WS_METHODS.threadsMove]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationOperateScope); + } }); it("separates passive terminal observation from operations that can change a shell", () => { diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 813efbe53ea30..56fff25fc2516 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -260,6 +260,7 @@ export const make = Effect.gen(function* () { threadActiveReorder: true, threadAutoSettleOptOut: true, threadProjectFromScratch: true, + threadWorkingFolderMove: true, threadTitleRegeneration: true, commandOutputStreaming: true, threadVisitedTracking: true, diff --git a/apps/server/src/guest/GuestShareService.test.ts b/apps/server/src/guest/GuestShareService.test.ts index ae687c437b681..ddb320d77e6ec 100644 --- a/apps/server/src/guest/GuestShareService.test.ts +++ b/apps/server/src/guest/GuestShareService.test.ts @@ -99,6 +99,7 @@ const layerCleanup = Layer.effect( const fileSystem = yield* FileSystem.FileSystem; return { cleanupScheduledTasks: () => Effect.void, + moveScheduledTasks: () => Effect.void, cleanupTerminals: () => Effect.void, cleanupPreviews: () => Effect.void, cleanupDevices: () => Effect.void, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 3f120a39f1870..96c293dcb4543 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -35,6 +35,7 @@ const RPC_AGGREGATES = { [ORCHESTRATION_V2_WS_METHODS.subscribeCommandOutput]: "orchestrationV2", [ORCHESTRATION_V2_WS_METHODS.subscribePromptSuggestion]: "orchestrationV2", [WS_METHODS.projectsMutate]: "orchestration", + [WS_METHODS.threadsMove]: "orchestration", [WS_METHODS.serverProbe]: "server", [WS_METHODS.serverGetConfig]: "server", [WS_METHODS.serverGetNpmRelease]: "server", diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 96dfd0f5c79fe..a16fa631a3d4a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -511,6 +511,7 @@ export const ClaudeProviderCapabilitiesV2 = { supportsProviderSwitchingViaHandoff: true, supportsRuntimeModeSwitchInSession: false, pendingRequestsSurviveRestart: false, + supportsFolderMove: true, }, threads: { canCreateEmptyThread: true, diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 1589b7d1ea46f..46551771cf8f7 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -263,6 +263,7 @@ export const CodexProviderCapabilitiesV2 = { supportsProviderSwitchingViaHandoff: true, supportsRuntimeModeSwitchInSession: true, pendingRequestsSurviveRestart: false, + supportsFolderMove: true, }, threads: { canCreateEmptyThread: true, diff --git a/apps/server/src/orchestration-v2/ClaudeSessionRelocation.test.ts b/apps/server/src/orchestration-v2/ClaudeSessionRelocation.test.ts new file mode 100644 index 0000000000000..297193abbc015 --- /dev/null +++ b/apps/server/src/orchestration-v2/ClaudeSessionRelocation.test.ts @@ -0,0 +1,302 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + ProviderDriverKind, + ProviderInstanceId, + ProviderThreadId, + ThreadId, +} from "@t3tools/contracts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; + +import * as ServerSettings from "../serverSettings.ts"; +import * as ClaudeSessionRelocation from "./ClaudeSessionRelocation.ts"; +import * as EventSink from "./EventSink.ts"; +import * as ThreadManagementService from "./ThreadManagementService.ts"; + +const sessionId = "session-relocated"; +const transcript = `${JSON.stringify({ type: "user", sessionId })}\n`; +const agentLine = '{"type":"assistant"}\n'; + +const repo = "/Users/me/repo"; +const override = (name: string) => ({ + CLAUDE_CONFIG_DIR: "/config", + CLAUDE_CODE_PROJECT_DIR_NAME: name, +}); + +it.each([ + ["/Users/me/Repo.v2", {}, "-Users-me-Repo-v2"], + // Golden value from the SDK's own projectDirName (sdk.mjs `Fu`). + [`/${"a".repeat(250)}`, {}, `-${"a".repeat(199)}-feo44x`], + [repo, override("my_proj-1"), "my_proj-1"], + [repo, { CLAUDE_CODE_PROJECT_DIR_NAME: "my_proj-1" }, "-Users-me-repo"], + [repo, override("a/b"), "-Users-me-repo"], + [repo, override("COM1"), "-Users-me-repo"], + [repo, override("x".repeat(65)), "-Users-me-repo"], +] as const)("names Claude's project folder for %s with %o", (folder, environment, name) => { + assert.equal(ClaudeSessionRelocation.claudeProjectDirName(folder, environment), name); +}); + +const errno = (code: string, pathOrDescriptor: string, method = "rename") => + PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method, + pathOrDescriptor, + cause: Object.assign(new Error(code), { code }), + }); + +const threadId = ThreadId.make("thread:claude-relocation"); +const driver = ProviderDriverKind.make("claudeAgent"); +const instanceId = ProviderInstanceId.make("claudeAgent"); +const providerThread = { + id: ProviderThreadId.make("provider-thread:claude-relocation"), + driver, + providerInstanceId: instanceId, +}; + +const setup = Effect.fnUntraced(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-relocation-" }); + const configDir = path.join(root, "config"); + const oldFolder = path.join(root, "repo-a"); + // Decomposed é: Claude names the folder from its NFC form on macOS. + const newFolder = path.join(root, "cafe\u0301"); + yield* fs.makeDirectory(oldFolder); + yield* fs.makeDirectory(newFolder); + const relocatedCwd = (yield* fs.realPath(newFolder)).normalize("NFC"); + const projectDir = (folder: string) => + path.join(configDir, "projects", ClaudeSessionRelocation.claudeProjectDirName(folder, {})); + const oldDir = projectDir((yield* fs.realPath(oldFolder)).normalize("NFC")); + const newDir = projectDir(relocatedCwd); + const oldSubs = path.join(oldDir, sessionId); + const newSubs = path.join(newDir, sessionId); + const agentFile = path.join(newSubs, "agent-1.jsonl"); + const oldFile = path.join(oldDir, `${sessionId}.jsonl`); + const newFile = path.join(newDir, `${sessionId}.jsonl`); + const record = `${JSON.stringify({ type: "relocated", sessionId, relocatedCwd })}\n`; + const writes: Array = []; + yield* fs.makeDirectory(oldSubs, { recursive: true }); + yield* fs.writeFileString(oldFile, transcript); + yield* fs.writeFileString(path.join(oldSubs, "agent-1.jsonl"), agentLine); + const run = (fileSystem: FileSystem.FileSystem, committed = true, lastAttempt = false) => + ClaudeSessionRelocation.relocateClaudeSessionStorage( + threadId, + { + type: "provider-session.relocate-storage", + driver, + providerInstanceId: instanceId, + providerThreadId: providerThread.id, + nativeSessionId: sessionId, + oldFolder, + newFolder, + }, + lastAttempt, + ).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(HostProcess.Environment, {}), + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provide( + Layer.mergeAll( + ServerSettings.layerTest({ + providerInstances: { [instanceId]: { driver, config: { homePath: configDir } } }, + }), + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed({ providerThreads: [providerThread] }) as never, + }), + Layer.mock(EventSink.EventSinkV2)({ + writeIfProviderThreadUnchanged: ({ events: [event] }) => + Effect.sync(() => { + if (event?.type === "provider-thread.updated") + writes.push(event.payload.nativeStorageFolder); + return { committed, storedEvents: [] }; + }), + }), + IdAllocator.layer, + ), + ), + ); + const expectLayout = Effect.fnUntraced(function* ( + transcriptAt: "old" | "new" | "none", + subagentsAt: "old" | "new" | "none", + tail = record, + rows = 1, + ) { + assert.equal(yield* fs.exists(oldFile), transcriptAt === "old"); + assert.equal(yield* fs.exists(newFile), transcriptAt === "new"); + if (transcriptAt !== "none") { + const file = transcriptAt === "old" ? oldFile : newFile; + assert.equal(yield* fs.readFileString(file), `${transcript}${tail}`); + } + assert.equal(yield* fs.exists(oldSubs), subagentsAt === "old"); + const agent = yield* Effect.orElseSucceed(fs.readFileString(agentFile), () => ""); + assert.equal(agent, subagentsAt === "new" ? agentLine : ""); + assert.deepEqual(writes, Array(rows).fill(newFolder)); + }); + const faultAt = (at?: string, fault = "denied", written = 0) => { + const fail = (file: string, method?: string) => + fault === "lost" ? Effect.die("process lost") : Effect.fail(errno("EACCES", file, method)); + const faultedRename = at === "transcript" ? oldFile : at === "subagents" ? oldSubs : undefined; + return FileSystem.FileSystem.of({ + ...fs, + readFileString: (file, encoding) => + at === "read" ? fail(file, "open") : fs.readFileString(file, encoding), + rename: (from, to) => (from === faultedRename ? fail(from) : fs.rename(from, to)), + writeFileString: (file, data, options) => + at === "record" + ? fs + .writeFileString(file, data.slice(0, written), options) + .pipe(Effect.andThen(fail(file, "open"))) + : fs.writeFileString(file, data, options), + }); + }; + return { fs, record, run, expectLayout, faultAt, oldDir, newDir, newFile, oldSubs, newSubs }; +}); + +it.effect("supersedes an occupant and a stale subagent folder in the new folder's storage", () => + Effect.gen(function* () { + const { fs, newDir, newFile, newSubs, run, expectLayout } = yield* setup(); + assert.isTrue(newDir.endsWith("-caf-")); + yield* fs.makeDirectory(newSubs, { recursive: true }); + yield* fs.writeFileString(`${newSubs}/stale.jsonl`, "stale\n"); + yield* fs.writeFileString(newFile, "occupant\n"); + + yield* run(fs); + + yield* expectLayout("new", "new"); + assert.equal(yield* fs.readFileString(`${newFile}.superseded-0`), "occupant\n"); + assert.isTrue(yield* fs.exists(`${newSubs}.superseded-0`)); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("copies across devices like Claude", () => + Effect.gen(function* () { + const { fs, record, oldDir, oldSubs, newSubs, run, expectLayout } = yield* setup(); + yield* fs.chmod(oldSubs, 0o755); + const crossDevice = FileSystem.FileSystem.of({ + ...fs, + rename: (from, to) => + from.startsWith(oldDir) && !to.startsWith(oldDir) + ? Effect.fail(errno("EXDEV", from)) + : fs.rename(from, to), + // Removing the folder that holds the subagent transcript deletes that file and then fails. + remove: (target, options) => + fs.remove(`${target}/agent-1.jsonl`).pipe( + Effect.matchEffect({ + onFailure: () => fs.remove(target, options), + onSuccess: () => Effect.fail(errno("EACCES", target, "remove")), + }), + ), + }); + + yield* Effect.flip(run(crossDevice, false)); + yield* run(crossDevice); + + yield* expectLayout("new", "new", record, 2); + // Windows has no directory modes. + if (HostProcess.Platform.defaultValue() !== "win32") { + assert.equal((yield* fs.stat(newSubs)).mode & 0o777, 0o700); + } + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("leaves a foreign or diskless session alone", () => + Effect.gen(function* () { + const { fs, oldDir, newDir, newFile, run, expectLayout } = yield* setup(); + const foreign = `${JSON.stringify({ type: "user", sessionId: "another-session" })}\n`; + yield* fs.remove(oldDir, { recursive: true }); + yield* fs.makeDirectory(newDir); + yield* fs.writeFileString(newFile, foreign); + assert.deepInclude(yield* Effect.flip(run(fs)), { path: newFile }); + assert.equal(yield* fs.readFileString(newFile), foreign); + + yield* fs.remove(newDir, { recursive: true }); + yield* run(fs); + yield* expectLayout("none", "none", ""); + assert.isFalse(yield* fs.exists(newDir)); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("retries an occupied rename when the destination cannot be set aside", () => + Effect.gen(function* () { + const { fs, oldSubs, newSubs, run, expectLayout } = yield* setup(); + let occupied = true; + const flaky = FileSystem.FileSystem.of({ + ...fs, + rename: (from, to) => { + if (from === newSubs) return Effect.fail(errno("EPERM", from)); + if (from === oldSubs && occupied) { + occupied = false; + return Effect.fail(errno("EEXIST", from)); + } + return fs.rename(from, to); + }, + }); + + yield* run(flaky); + + yield* expectLayout("new", "new"); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect.each([ + { name: "writes the new folder once the transcript moved" }, + { name: "fails when the provider thread changed", committed: false }, + { name: "denied transcript rename", at: "transcript", fault: "denied" }, + { name: "denied subagent rename", at: "subagents", fault: "denied" }, + { name: "denied record append", at: "record", fault: "denied" }, + { name: "lost after the transcript rename", at: "subagents", fault: "lost" }, + { name: "lost before the relocated record", at: "record", fault: "lost" }, + { name: "lost partway through the record", at: "record", fault: "lost", written: 20 }, + { name: "lost before the record's newline", at: "record", fault: "lost", written: -1 }, +])( + "relocates and records the storage folder: $name", + ({ committed = true, at, fault, written = 0 }) => + Effect.gen(function* () { + const { fs, record, run, expectLayout, faultAt } = yield* setup(); + if (at !== undefined) { + const faulted = yield* Effect.exit(run(faultAt(at, fault, written), committed)); + assert.equal(faulted._tag, "Failure"); + yield* expectLayout( + at === "transcript" ? "old" : "new", + at === "record" ? "new" : "old", + at === "record" ? record.slice(0, written) : "", + 0, + ); + } + const outcome = Effect.exit(run(fs, committed)); + + assert.equal((yield* outcome)._tag, committed ? "Success" : "Failure"); + const fragment = written === 0 ? "" : `${record.slice(0, written)}\n`; + const tail = `${fragment}${fragment === record ? "" : record}`; + yield* expectLayout("new", "new", tail); + yield* outcome; + yield* expectLayout("new", "new", tail, 2); + }).pipe(Effect.provide(NodeServices.layer)), +); + +// The worker does not repeat its last attempt, so the record must already match the disk. +it.effect.each(["read", "transcript", "subagents", "record"] as const)( + "records the folder the transcript is in when the last attempt is denied at the %s", + (at) => + Effect.gen(function* () { + const { record, run, expectLayout, faultAt } = yield* setup(); + const moved = at === "subagents" || at === "record"; + + const outcome = yield* Effect.exit(run(faultAt(at), true, true)); + + assert.equal(outcome._tag, moved ? "Success" : "Failure"); + yield* expectLayout( + moved ? "new" : "old", + at === "record" ? "new" : "old", + at === "subagents" ? record : "", + moved ? 1 : 0, + ); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/orchestration-v2/ClaudeSessionRelocation.ts b/apps/server/src/orchestration-v2/ClaudeSessionRelocation.ts new file mode 100644 index 0000000000000..44c989e28339e --- /dev/null +++ b/apps/server/src/orchestration-v2/ClaudeSessionRelocation.ts @@ -0,0 +1,243 @@ +import type { ThreadId } from "@t3tools/contracts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Arr from "effect/Array"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import type * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; + +import { deriveProviderInstanceConfigMap } from "../provider/ProviderInstanceRegistryHydration.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { resolveProviderInstanceTerminalEnvironment } from "../terminal/Manager.ts"; +import type * as EffectOutbox from "./EffectOutbox.ts"; +import * as EventSink from "./EventSink.ts"; +import * as ThreadManagementService from "./ThreadManagementService.ts"; + +class ClaudeSessionRelocationError extends Schema.TaggedError()( + "ClaudeSessionRelocationError", + { + reason: Schema.Literals(["relative-config-dir", "provider-thread-changed", "foreign-session"]), + path: Schema.optional(Schema.String), + }, +) {} + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeSessionRecord = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ sessionId: Schema.String })), +); +const errnoCode = (error: PlatformError.PlatformError) => + (error.cause as NodeJS.ErrnoException | undefined)?.code; +const isNotFound = (error: PlatformError.PlatformError) => error.reason._tag === "NotFound"; +type CopyDirectory = (from: string, to: string) => Effect.Effect; +const projectDirOverride = /^(?!(?:con|prn|aux|nul|com[0-9]|lpt[0-9])$)[A-Za-z0-9_-]{1,64}$/i; + +export function claudeConfigDirRefusal(path: Path.Path, environment: NodeJS.ProcessEnv) { + const configDir = environment.CLAUDE_CONFIG_DIR; + return configDir !== undefined && !path.isAbsolute(configDir) + ? `CLAUDE_CONFIG_DIR ("${configDir}") is not an absolute path, so Claude's configuration folder would change with the working folder.` + : undefined; +} + +export function claudeProjectDirName(folder: string, environment: NodeJS.ProcessEnv): string { + const { CLAUDE_CONFIG_DIR: configDir, CLAUDE_CODE_PROJECT_DIR_NAME: override } = environment; + if (configDir && override && projectDirOverride.test(override)) return override; + const name = folder.replace(/[^a-zA-Z0-9]/g, "-"); + if (name.length <= 200) return name; + let hash = 0; + for (let i = 0; i < folder.length; i++) hash = ((hash << 5) - hash + folder.charCodeAt(i)) | 0; + return `${name.slice(0, 200)}-${Math.abs(hash).toString(36)}`; +} + +type RelocationRequest = Extract< + EffectOutbox.OrchestrationEffectRequestV2, + { readonly type: "provider-session.relocate-storage" } +>; + +// Claude Code 2.1.319's relocateSessionTranscript; the SDK has no relocation call. +const relocateClaudeTranscript = Effect.fn("ClaudeSessionRelocation.relocateTranscript")(function* ( + environment: NodeJS.ProcessEnv, + request: RelocationRequest, + lastAttempt: boolean, +) { + const { nativeSessionId: sessionId, oldFolder, newFolder } = request; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcess.Platform; + const configDir = + environment.CLAUDE_CONFIG_DIR ?? path.join(yield* HostProcess.HomeDirectory, ".claude"); + const projectsDir = path.join(configDir.normalize("NFC"), "projects"); + const projectDir = (folder: string) => + path.join(projectsDir, claudeProjectDirName(folder, environment)); + const resolveFolder = (folder: string) => + fileSystem.realPath(folder).pipe( + Effect.orElseSucceed(() => path.resolve(folder)), + Effect.map((resolved) => (platform === "darwin" ? resolved.normalize("NFC") : resolved)), + ); + const setAside = Effect.fnUntraced(function* (destination: string) { + const now = yield* DateTime.now; + const aside = `${destination}.superseded-${DateTime.toEpochMillis(now)}`; + return yield* fileSystem.rename(destination, aside).pipe( + Effect.andThen( + fileSystem.utimes(aside, DateTime.toDate(now), DateTime.toDate(now)).pipe(Effect.ignore), + ), + Effect.as(aside), + Effect.catchIf(isNotFound, () => Effect.undefined), + ); + }); + const restore = (aside: string | undefined, destination: string) => + aside === undefined ? Effect.void : fileSystem.rename(aside, destination).pipe(Effect.ignore); + const removeTree = (target: string) => + fileSystem.remove(target, { recursive: true, force: true }).pipe(Effect.ignore); + const copyDirectory: CopyDirectory = Effect.fnUntraced(function* (from: string, to: string) { + yield* fileSystem.makeDirectory(to, { recursive: true, mode: 0o700 }); + for (const entry of yield* fileSystem.readDirectoryEntries(from)) { + yield* (entry.type === "Directory" ? copyDirectory : fileSystem.copyFile)( + path.join(from, entry.name), + path.join(to, entry.name), + ); + } + }); + const renameWithFallbacks = (from: string, to: string) => + fileSystem.rename(from, to).pipe( + Effect.catch((error) => { + const code = errnoCode(error); + if (code === "EEXIST" || code === "EPERM" || code === "EBUSY" || code === "ENOTEMPTY") { + return setAside(to).pipe( + Effect.orElseSucceed(() => undefined), + Effect.flatMap((aside) => + fileSystem.rename(from, to).pipe(Effect.tapError(() => restore(aside, to))), + ), + ); + } + if (code !== "EXDEV") return Effect.fail(error); + return fileSystem.copyFile(from, to).pipe( + Effect.catchIf( + (copyError) => ["EISDIR", "ENOTSUP", "EPERM"].includes(errnoCode(copyError) ?? ""), + () => + Effect.flatMap(setAside(to), (aside) => + copyDirectory(from, to).pipe( + Effect.tapError(() => + aside === undefined + ? Effect.void + : Effect.andThen(removeTree(to), restore(aside, to)), + ), + ), + ), + ), + // One rename takes the copied source off its path, so a repeated move cannot copy a + // partly removed source over the complete destination. + Effect.andThen(setAside(from)), + Effect.flatMap((aside) => (aside === undefined ? Effect.void : removeTree(aside))), + ); + }), + ); + + const relocatedCwd = yield* resolveFolder(newFolder); + const oldDir = projectDir(yield* resolveFolder(oldFolder)); + const newDir = projectDir(relocatedCwd); + const oldFile = path.join(oldDir, `${sessionId}.jsonl`); + const newFile = path.join(newDir, `${sessionId}.jsonl`); + const record = encodeJson({ type: "relocated", sessionId, relocatedCwd }); + const sourcePresent = yield* fileSystem.exists(oldFile); + if (!sourcePresent && !(yield* fileSystem.exists(newFile))) return; + // Read before the move, so a transcript that cannot be read stays in the folder on record. + const contents = yield* fileSystem.readFileString(sourcePresent ? oldFile : newFile); + if (sourcePresent && oldDir !== newDir) { + yield* fileSystem.makeDirectory(newDir, { recursive: true, mode: 0o700 }); + const [from, to] = yield* Effect.all([fileSystem.stat(oldDir), fileSystem.stat(newDir)]); + const sameDirectory = from.dev === to.dev && Equal.equals(from.ino, to.ino); + const superseded = sameDirectory ? undefined : yield* setAside(newFile); + yield* renameWithFallbacks(oldFile, newFile).pipe( + Effect.tapError(() => restore(superseded, newFile)), + ); + } + // As the SDK does, read which session a transcript belongs to from its first sessionId. + const owner = Arr.findFirst(contents.split("\n"), (line) => decodeSessionRecord(line)); + if (!sourcePresent && Option.getOrUndefined(owner)?.sessionId !== sessionId) { + return yield* new ClaudeSessionRelocationError({ reason: "foreign-session", path: newFile }); + } + // The transcript is in the new folder from here on. An attempt that is not repeated goes on + // past a failed step below, so the caller records the folder the transcript is in. + const unlessLastAttempt = (step: Effect.Effect) => + lastAttempt ? Effect.ignore(step) : step; + if (oldDir !== newDir) { + yield* renameWithFallbacks(path.join(oldDir, sessionId), path.join(newDir, sessionId)).pipe( + Effect.catchIf(isNotFound, () => Effect.void), + Effect.tapError((cause) => + Effect.logWarning("orchestration-v2.claude-relocation.subagent-folder-failed", { cause }), + ), + unlessLastAttempt, + ); + } + // Like Claude, end a torn last line before appending; a whole last line that is the record + // needs only its newline. + const tail = `\n${contents}`; + if (!tail.endsWith(`\n${record}\n`)) { + yield* fileSystem + .writeFileString( + newFile, + tail.endsWith(`\n${record}`) ? "\n" : `${/[^\n]$/.test(contents) ? "\n" : ""}${record}\n`, + { flag: "a" }, + ) + .pipe( + Effect.tapError((cause) => + Effect.logWarning("orchestration-v2.claude-relocation.record-failed", { cause }), + ), + unlessLastAttempt, + ); + } +}); + +export const relocateClaudeSessionStorage = Effect.fn( + "ClaudeSessionRelocation.relocateSessionStorage", +)(function* ( + threadId: ThreadId, + request: RelocationRequest, + /** True when the caller does not run a failed relocation again. */ + lastAttempt = false, +) { + if (request.driver !== "claudeAgent") return; + const path = yield* Path.Path; + const environment = yield* resolveProviderInstanceTerminalEnvironment({ + deriveProviderInstanceConfigMap, + serverSettings: yield* ServerSettings.ServerSettingsService, + path, + rawProviderInstanceId: request.providerInstanceId, + env: (yield* HostProcess.Environment) as Record, + }); + if (claudeConfigDirRefusal(path, environment) !== undefined) { + return yield* new ClaudeSessionRelocationError({ reason: "relative-config-dir" }); + } + yield* relocateClaudeTranscript(environment, request, lastAttempt).pipe( + Effect.annotateLogs({ threadId }), + ); + const { providerThreads } = + yield* (yield* ThreadManagementService.ThreadManagementService).getThreadRecords(threadId, [ + "providerThreads", + ]); + const providerThread = providerThreads.find((row) => row.id === request.providerThreadId); + if (providerThread === undefined) return; + const now = yield* DateTime.now; + const { committed } = yield* (yield* EventSink.EventSinkV2).writeIfProviderThreadUnchanged({ + expected: providerThread, + events: [ + { + id: yield* (yield* IdAllocator.IdAllocatorV2).allocate.event({ threadId }), + type: "provider-thread.updated", + threadId, + driver: providerThread.driver, + providerInstanceId: providerThread.providerInstanceId, + occurredAt: now, + payload: { ...providerThread, nativeStorageFolder: request.newFolder, updatedAt: now }, + }, + ], + }); + if (!committed) { + return yield* new ClaudeSessionRelocationError({ reason: "provider-thread-changed" }); + } +}); diff --git a/apps/server/src/orchestration-v2/EffectOutbox.ts b/apps/server/src/orchestration-v2/EffectOutbox.ts index 0a250bb2b865c..5e304e73e9282 100644 --- a/apps/server/src/orchestration-v2/EffectOutbox.ts +++ b/apps/server/src/orchestration-v2/EffectOutbox.ts @@ -6,6 +6,9 @@ import { ModelSelection, NodeId, NonNegativeInt, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, ProviderSessionId, RunAttemptId, ProviderApprovalDecision, @@ -42,6 +45,15 @@ export const OrchestrationEffectRequestV2 = Schema.Union([ detail: Schema.optional(Schema.String), revokeMcpCredential: Schema.optional(Schema.Boolean), }), + Schema.Struct({ + type: Schema.Literal("provider-session.relocate-storage"), + driver: ProviderDriverKind, + providerInstanceId: ProviderInstanceId, + providerThreadId: ProviderThreadId, + nativeSessionId: Schema.String, + oldFolder: Schema.String, + newFolder: Schema.String, + }), Schema.Struct({ type: Schema.Literal("provider-session.apply-model-selection"), providerSessionId: ProviderSessionId, @@ -108,6 +120,11 @@ export const OrchestrationEffectRequestV2 = Schema.Union([ Schema.Struct({ type: Schema.Literal("scheduled-tasks.cleanup"), }), + Schema.Struct({ + type: Schema.Literal("scheduled-tasks.move"), + threadIds: Schema.Array(ThreadId), + projectId: ProjectId, + }), Schema.Struct({ type: Schema.Literal("terminal.cleanup"), }), @@ -136,9 +153,11 @@ export type OrchestrationEffectRequestV2 = typeof OrchestrationEffectRequestV2.T export const REPLAY_SAFE_EFFECT_TYPES_AFTER_PROCESS_LOSS = [ "provider-runtime.continue", "provider-session.detach", + "provider-session.relocate-storage", "provider-thread.rollback", "checkpoint.capture", "scheduled-tasks.cleanup", + "scheduled-tasks.move", "terminal.cleanup", "preview.cleanup", "attachment.cleanup", @@ -224,6 +243,14 @@ export interface EffectOutboxV2Shape { readonly effectTypes: ReadonlyArray; readonly reason: string; }) => Effect.Effect, EffectOutboxError>; + readonly hasUnsettled: (input: { + readonly threadIds: ReadonlyArray; + readonly effectType: OrchestrationEffectRequestV2["type"]; + }) => Effect.Effect; + readonly unsettledEffectIds: (input: { + readonly threadIds: ReadonlyArray; + readonly effectType: OrchestrationEffectRequestV2["type"]; + }) => Effect.Effect, EffectOutboxError>; readonly signalCancellations: (effectIds: ReadonlyArray) => Effect.Effect; readonly awaitCancellation: (effectId: string) => Effect.Effect; readonly clearCancellation: (effectId: string) => Effect.Effect; @@ -486,6 +513,24 @@ export const layer: Layer.Layer = La }, Effect.mapError((cause) => new EffectOutboxError({ operation: "cancel-unsettled", cause })), ), + hasUnsettled: (input) => + Effect.map(service.unsettledEffectIds(input), (ids) => ids.length > 0), + unsettledEffectIds: ({ threadIds, effectType }) => + threadIds.length === 0 + ? Effect.succeed([]) + : sql<{ readonly effect_id: string }>` + SELECT effect_id + FROM orchestration_v2_effect_outbox + WHERE thread_id IN ${sql.in(threadIds)} + AND effect_type = ${effectType} + AND status IN ('pending', 'running') + `.pipe( + NodeSqliteClient.readOnly, + Effect.map((rows) => rows.map(({ effect_id }) => effect_id)), + Effect.mapError( + (cause) => new EffectOutboxError({ operation: "unsettled-effect-ids", cause }), + ), + ), signalCancellations: Effect.fn("EffectOutbox.signalCancellations")(function* (effectIds) { yield* Effect.forEach( effectIds, diff --git a/apps/server/src/orchestration-v2/EffectWorker.test.ts b/apps/server/src/orchestration-v2/EffectWorker.test.ts index fd598e95ec11a..789164c6df869 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.test.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.test.ts @@ -1,15 +1,22 @@ import { assert, it } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { CheckpointId, CheckpointScopeId, CommandId, NodeId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, ProviderSessionId, ProviderThreadId, ProviderTurnId, RunAttemptId, RunId, RuntimeRequestId, + ScheduledTaskId, ThreadId, VcsProcessExitError, } from "@t3tools/contracts"; @@ -21,22 +28,37 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as SqlError from "effect/sql/SqlError"; import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/sql/SqlClient"; import * as TestClock from "effect/testing/TestClock"; +import * as ServerConfig from "../config.ts"; +import * as DeviceService from "../device/DeviceService.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; +import * as PreviewManager from "../preview/Manager.ts"; +import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; +import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as Scheduler from "../scheduling/Scheduler.ts"; +import * as SecretRequests from "../secrets/SecretRequests.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; import * as CheckpointRollbackService from "./CheckpointRollbackService.ts"; import * as CheckpointService from "./CheckpointService.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; import * as EffectWorker from "./EffectWorker.ts"; +import * as EventSink from "./EventSink.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ResourceCleanupService from "./ResourceCleanupService.ts"; import * as RunFinalizationService from "./RunFinalizationService.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as ProviderTurnControlService from "./ProviderTurnControlService.ts"; import * as ProviderTurnStartService from "./ProviderTurnStartService.ts"; import * as RuntimeRequestService from "./RuntimeRequestService.ts"; +import * as ThreadLaunchService from "./ThreadLaunchService.ts"; import * as ThreadTitleRegenerationService from "./ThreadTitleRegenerationService.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -169,6 +191,9 @@ function layerExecutorFor(input: { Layer.mergeAll( layerDependencies, Layer.mock(ThreadManagementService.ThreadManagementService)(input.threads ?? {}), + Layer.mock(EventSink.EventSinkV2)({}), + IdAllocator.layer, + NodeServices.layer, Layer.mock(ProjectionStore.ProjectionStoreV2)({}), ServerSettings.layerTest( input.continueAfterRestart === true ? { continueThreadsAfterServerUpdate: true } : {}, @@ -402,6 +427,185 @@ it.effect("requeues a claim when a pre-execution worker check fails", () => }), ); +it.effect("moves scheduled tasks after a failed store write while the task keeps firing", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const from = ProjectId.make("project:task-move-from"); + const to = ProjectId.make("project:task-move-to"); + const sends = yield* Ref.make>([]); + const shell = yield* Ref.make({ projectId: from, deletedAt: null, archivedAt: null }); + // The thread already works in `to`; a send to any other project finds no thread there. + const layerTasks = ScheduledTaskService.layer.pipe( + Layer.provide( + Layer.mergeAll( + ManagedPolicy.layerTest(), + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ThreadManagementService.ThreadManagementService)({ + sendToThread: ({ projectId, threadId }) => + Effect.gen(function* () { + yield* Ref.update(sends, (all) => [...all, projectId]); + if (projectId === to) return {} as never; + return yield* new ThreadManagementService.ThreadManagementThreadNotFoundError({ + projectId, + threadId, + }); + }), + getThreadShell: () => Ref.get(shell) as never, + }), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + NodeCrypto.layer, + Scheduler.layer, + ), + ), + ); + const layerCleanup = ResourceCleanupService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(PreviewManager.PreviewManager)({}), + Layer.mock(TerminalManager.TerminalManager)({}), + Layer.mock(DeviceService.DeviceService)({}), + ServerConfig.layerTest(process.cwd(), { prefix: "effect-worker-task-move-" }), + ), + ), + Layer.provide(NodeServices.layer), + ); + const events = yield* Ref.make>([]); + const layer = EffectWorker.layerWithOptions({ workerId: "task-move", maxAttempts: 2 }).pipe( + Layer.provide(layerExecutorFor({ events }).pipe(Layer.provide(layerCleanup))), + Layer.provideMerge(EffectOutbox.layer), + Layer.provideMerge(layerTasks), + ); + + yield* Effect.gen(function* () { + const tasks = yield* ScheduledTaskService.ScheduledTaskService; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const { task } = yield* tasks.upsert({ + id: ScheduledTaskId.make("task:move"), + title: "Check in", + prompt: "Check progress", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: from, + threadId, + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }); + yield* Ref.update(shell, (current) => ({ ...current, projectId: to })); + const listedUnder = sql<{ readonly project_id: string }>` + SELECT project_id FROM scheduled_tasks WHERE task_id = ${task.id} + `.pipe(Effect.map((rows) => rows[0]?.project_id)); + const statusOf = (effectId: string) => + outbox.get(effectId).pipe(Effect.map((effect) => Option.getOrUndefined(effect)?.status)); + const firesToThread = Effect.gen(function* () { + yield* tasks.runNow({ id: task.id }); + const listed = yield* listedUnder; + assert.deepEqual(yield* Ref.getAndSet(sends, []), listed === to ? [to] : [from, to]); + }); + + yield* sql` + CREATE TRIGGER fail_task_move BEFORE UPDATE OF project_id ON scheduled_tasks + BEGIN SELECT RAISE(ABORT, 'task store unavailable'); END + `; + for (const recovers of [false, true]) { + const effectId = `effect:task-move:${recovers ? "retried" : "exhausted"}`; + yield* outbox.enqueue([ + { + id: effectId, + commandId: CommandId.make(`command:${effectId}`), + threadId, + request: { type: "scheduled-tasks.move", threadIds: [threadId], projectId: to }, + }, + ]); + assert.isTrue(yield* worker.runOnce()); + assert.equal(yield* statusOf(effectId), "pending"); + assert.equal(yield* listedUnder, from); + yield* firesToThread; + if (recovers) yield* sql`DROP TRIGGER fail_task_move`; + yield* TestClock.adjust("100 millis"); + assert.isTrue(yield* worker.runOnce()); + assert.equal(yield* statusOf(effectId), recovers ? "succeeded" : "failed"); + assert.equal(yield* listedUnder, recovers ? to : from); + yield* firesToThread; + } + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); + +it.effect("holds a thread's next turn until its Claude storage move settles, failed or not", () => + Effect.gen(function* () { + const events = yield* Ref.make>([]); + const logged: Array = []; + const layer = EffectWorker.layerWithOptions({ workerId: "relocation", maxAttempts: 2 }).pipe( + Layer.provide(layerExecutorFor({ events })), + Layer.provideMerge(EffectOutbox.layer), + Layer.provideMerge( + Logger.layer([Logger.make(({ message }) => void logged.push(message))], { + mergeWithExisting: false, + }), + ), + ); + + yield* Effect.gen(function* () { + const outbox = yield* EffectOutbox.EffectOutboxV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const statusOf = (effectId: string) => + outbox.get(effectId).pipe(Effect.map((effect) => Option.getOrUndefined(effect)?.status)); + // No such provider instance is configured, so every attempt fails before it reads the disk. + yield* outbox.enqueue([ + { + id: "effect:relocation", + commandId: CommandId.make("command:relocation"), + threadId, + request: { + type: "provider-session.relocate-storage", + driver: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claude-unconfigured"), + providerThreadId, + nativeSessionId: "session-relocation", + oldFolder: "/relocation/a", + newFolder: "/relocation/b", + }, + }, + { + id: "effect:next-turn", + commandId: CommandId.make("command:next-turn"), + threadId, + request: { type: "provider-turn.start", runId }, + }, + ]); + const gaveUp = () => + logged.filter( + (message) => + Array.isArray(message) && + message[0] === "orchestration-v2.claude-relocation.gave-up" && + message[1]?.threadId === threadId, + ); + + assert.isTrue(yield* worker.runOnce()); + assert.equal(yield* statusOf("effect:relocation"), "pending"); + // The move waits to run again, and the turn waits behind it. + assert.isFalse(yield* worker.runOnce()); + assert.lengthOf(gaveUp(), 0); + yield* TestClock.adjust("100 millis"); + assert.isTrue(yield* worker.runOnce()); + assert.equal(yield* statusOf("effect:relocation"), "failed"); + assert.lengthOf(gaveUp(), 1); + assert.deepEqual(yield* Ref.get(events), []); + + assert.isTrue(yield* worker.runOnce()); + assert.deepEqual(yield* Ref.get(events), ["start"]); + assert.equal(yield* statusOf("effect:next-turn"), "succeeded"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); + it.effect("keeps requeueing a failed claim while the database is locked", () => Effect.gen(function* () { const now = DateTime.formatIso(yield* DateTime.now); diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index 19a387eb8a954..1e75e3daea80c 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -19,6 +19,7 @@ import { orchestrationEffectClaimsTotal, orchestrationEffectQueueWait, } from "../observability/Metrics.ts"; +import * as ClaudeSessionRelocation from "./ClaudeSessionRelocation.ts"; import * as RunFinalizationService from "./RunFinalizationService.ts"; import * as ResourceCleanupService from "./ResourceCleanupService.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; @@ -87,6 +88,7 @@ export const layerExecutor: Layer.Layer< | ThreadManagementService.ThreadManagementService | ProjectionStore.ProjectionStoreV2 | ServerSettings.ServerSettingsService + | Effect.Services> > = Layer.effect( OrchestrationEffectExecutorV2, Effect.gen(function* () { @@ -102,9 +104,19 @@ export const layerExecutor: Layer.Layer< const threads = yield* ThreadManagementService.ThreadManagementService; const projectionStore = yield* ProjectionStore.ProjectionStoreV2; const settings = yield* ServerSettings.ServerSettingsService; + const relocationContext = + yield* Effect.context< + Effect.Services> + >(); return OrchestrationEffectExecutorV2.of({ execute: Effect.fn("OrchestrationEffectExecutorV2.execute")(function (effect, options) { const willRetry = options?.willRetry ?? false; + const executionError = (cause: unknown) => + new OrchestrationEffectExecutionError({ + effectId: effect.id, + effectType: effect.request.type, + cause, + }); switch (effect.request.type) { case "provider-runtime.continue": { const sourceRunId = effect.request.sourceRunId; @@ -646,6 +658,34 @@ export const layerExecutor: Layer.Layer< }), ), ); + case "provider-session.relocate-storage": { + const { providerThreadId, oldFolder, newFolder } = effect.request; + return ClaudeSessionRelocation.relocateClaudeSessionStorage( + effect.threadId, + effect.request, + !willRetry, + ).pipe( + Effect.provideContext(relocationContext), + // The thread's turns go on: Claude resumes the session by id from the folder it + // is still stored in, and the provider thread keeps that folder on record. + Effect.tapError((cause) => + willRetry + ? Effect.void + : Effect.logError("orchestration-v2.claude-relocation.gave-up", { + threadId: effect.threadId, + providerThreadId, + oldFolder, + newFolder, + cause, + }), + ), + Effect.mapError(executionError), + ); + } + case "scheduled-tasks.move": + return resourceCleanup + .moveScheduledTasks(effect.request) + .pipe(Effect.mapError(executionError)); } }), }); diff --git a/apps/server/src/orchestration-v2/FolderChangeNotice.test.ts b/apps/server/src/orchestration-v2/FolderChangeNotice.test.ts new file mode 100644 index 0000000000000..2123c7f95c967 --- /dev/null +++ b/apps/server/src/orchestration-v2/FolderChangeNotice.test.ts @@ -0,0 +1,138 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + MessageId, + ProviderDriverKind, + ProviderThreadId, + RunAttemptId, + RunId, + type OrchestrationV2Run, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; + +import { folderChangeNote, latestCountedRunFolder } from "./FolderChangeNotice.ts"; + +function run(ordinal: number, extra: Partial = {}): OrchestrationV2Run { + return { + id: RunId.make(`run:${ordinal}`), + ordinal, + userMessageId: MessageId.make(`message:${ordinal}`), + activeAttemptId: RunAttemptId.make(`attempt:${ordinal}`), + status: "completed", + completedAt: DateTime.makeUnsafe(ordinal * 1000), + cwd: `/folder/${ordinal}`, + ...extra, + } as OrchestrationV2Run; +} + +const codexThreadId = ProviderThreadId.make("provider-thread:codex"); +const opencodeThreadId = ProviderThreadId.make("provider-thread:opencode"); + +type Runs = ReadonlyArray; +const latest = ( + runs: Runs, + options: Partial> = {}, +) => + latestCountedRunFolder({ + runs, + attempts: [...runs, ...(options.accepted ?? [])].map((source) => ({ + id: source.activeAttemptId!, + runId: source.id, + providerThreadId: options.onOpencode?.includes(source) ? opencodeThreadId : codexThreadId, + })), + providerTurns: (options.accepted ?? runs).map((source) => ({ + runAttemptId: source.activeAttemptId!, + })), + providerThreads: [ + { id: codexThreadId, driver: ProviderDriverKind.make("codex") }, + { id: opencodeThreadId, driver: ProviderDriverKind.make("opencode") }, + ], + messages: runs.map((source) => ({ + id: source.userMessageId, + role: "user", + text: options.compactions?.includes(source) + ? "/compact" + : options.goals?.includes(source) + ? "/goal Ship it" + : "Continue", + attachments: [], + })), + }); + +it("takes the folder of the latest run the provider accepted that still stands", () => { + const r1 = run(1); + const r2 = run(2); + const starting = run(3, { completedAt: null }); + assert.strictEqual(latest([]), undefined); + assert.strictEqual(latest([r1], { accepted: [] }), undefined); + assert.strictEqual(latest([r1, r2, run(3, { status: "rolled_back" })]), "/folder/2"); + assert.strictEqual(latest([r1, run(2, { status: "interrupted" })]), "/folder/2"); + assert.strictEqual(latest([r1, r2], { accepted: [r1] }), "/folder/1"); + assert.strictEqual(latest([r1, run(2, { cwd: undefined })]), "/folder/1"); + assert.strictEqual(latest([r1, run(2, { restartContinuationOfRunId: r1.id })]), "/folder/1"); + assert.strictEqual(latest([r1, r2], { compactions: [r2] }), "/folder/1"); + assert.strictEqual(latest([run(1, { completedAt: DateTime.makeUnsafe(3000) }), r2]), "/folder/1"); + const goal = { accepted: [r1, r2], goals: [r2] }; + assert.strictEqual(latest([r1, r2, starting], { ...goal, onOpencode: [r1, r2] }), "/folder/2"); + assert.strictEqual(latest([r1, r2, starting], { ...goal, onOpencode: [starting] }), "/folder/1"); +}); + +it("counts a run the provider accepted on an attempt a steer later replaced", () => { + const steered = run(1, { activeAttemptId: RunAttemptId.make("attempt:1:steer") }); + assert.strictEqual(latest([steered], { accepted: [run(1)] }), "/folder/1"); +}); + +it.effect( + "compares folders after resolving symbolic links, a missing one by its recorded path", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-folder-change-" }); + const folder = path.join(root, "folder"); + const other = path.join(root, "other"); + const link = path.join(root, "link"); + const removed = path.join(root, "removed"); + yield* fileSystem.makeDirectory(folder); + yield* fileSystem.makeDirectory(other); + yield* fileSystem.symlink(folder, link); + const changed = (from: string, to: string) => + `The working folder changed from ${JSON.stringify(from)} to ${JSON.stringify(to)}.`; + for (const [previous, cwd, note] of [ + [undefined, folder, ""], + [folder, link, ""], + [folder, other, changed(folder, other)], + [removed, folder, changed(removed, folder)], + [removed, removed, ""], + [ + "/tmp/a\nb", + "/tmp/c\u2028d", + 'The working folder changed from "/tmp/a\\nb" to "/tmp/c\\u2028d".', + ], + ] as const) { + assert.strictEqual(yield* folderChangeNote(fileSystem, previous, cwd), note); + } + const denied = FileSystem.makeNoop({ + realPath: (target) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "realPath", + pathOrDescriptor: target, + }), + ), + }); + assert.strictEqual(yield* folderChangeNote(denied, "/a", "/a"), ""); + const error = yield* Effect.flip(folderChangeNote(denied, "/a", "/w")); + assert.strictEqual(error.reason._tag, "PermissionDenied"); + const deniedPrevious = FileSystem.makeNoop({ + realPath: (target) => (target === "/a" ? denied.realPath(target) : Effect.succeed(target)), + }); + assert.strictEqual(yield* folderChangeNote(deniedPrevious, "/a", "/w"), changed("/a", "/w")); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/orchestration-v2/FolderChangeNotice.ts b/apps/server/src/orchestration-v2/FolderChangeNotice.ts new file mode 100644 index 0000000000000..e94c45e0f7dff --- /dev/null +++ b/apps/server/src/orchestration-v2/FolderChangeNotice.ts @@ -0,0 +1,76 @@ +import { + isCompactionCommandMessage, + parseGoalCommand, + type OrchestrationV2ConversationMessage, + type OrchestrationV2ProviderThread, + type OrchestrationV2ProviderTurn, + type OrchestrationV2Run, + type OrchestrationV2RunAttempt, +} from "@t3tools/contracts"; +import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; +import * as Effect from "effect/Effect"; +import type * as FileSystem from "effect/FileSystem"; + +export function latestCountedRunFolder(projection: { + readonly runs: ReadonlyArray; + readonly attempts: ReadonlyArray< + Pick + >; + readonly providerTurns: ReadonlyArray>; + readonly providerThreads: ReadonlyArray>; + readonly messages: ReadonlyArray< + Pick + >; +}): string | undefined { + const acceptedAttemptIds = new Set(projection.providerTurns.map((turn) => turn.runAttemptId)); + const acceptedAttempts = projection.attempts.filter((attempt) => + acceptedAttemptIds.has(attempt.id), + ); + const goalThreadIds = new Set( + projection.providerThreads + .filter((thread) => thread.driver === "codex" || thread.driver === "claudeAgent") + .map((thread) => thread.id), + ); + const acceptedRunIds = new Set(acceptedAttempts.map((attempt) => attempt.runId)); + const goalAsTextRunIds = new Set( + acceptedAttempts + .filter((attempt) => !goalThreadIds.has(attempt.providerThreadId)) + .map((attempt) => attempt.runId), + ); + const messages = new Map(projection.messages.map((message) => [message.id, message])); + return projection.runs.reduce((latest, run) => { + const message = messages.get(run.userMessageId); + const goal = message !== undefined && parseGoalCommand(message) !== null; + return run.cwd !== undefined && + run.status !== "rolled_back" && + run.restartContinuationOfRunId === undefined && + !(message !== undefined && isCompactionCommandMessage(message)) && + (goal ? goalAsTextRunIds : acceptedRunIds).has(run.id) && + (latest === undefined || runRanAfter(run, latest)) + ? run + : latest; + }, undefined)?.cwd; +} + +export const folderChangeNote = Effect.fn("orchestrationV2.folderChangeNote")(function* ( + fileSystem: FileSystem.FileSystem, + previous: string | undefined, + cwd: string, +) { + if (previous === undefined || previous === cwd) return ""; + const resolve = (target: string) => + fileSystem.realPath(target).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed(target), + ), + ); + const quote = (folder: string) => + JSON.stringify(folder).replace( + /[\u0085\u2028\u2029]/gu, + (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`, + ); + return (yield* Effect.orElseSucceed(resolve(previous), () => previous)) === (yield* resolve(cwd)) + ? "" + : `The working folder changed from ${quote(previous)} to ${quote(cwd)}.`; +}); diff --git a/apps/server/src/orchestration-v2/NativeStorageFolder.test.ts b/apps/server/src/orchestration-v2/NativeStorageFolder.test.ts new file mode 100644 index 0000000000000..fdfa5520089bc --- /dev/null +++ b/apps/server/src/orchestration-v2/NativeStorageFolder.test.ts @@ -0,0 +1,125 @@ +import { it } from "@effect/vitest"; +import { + ProviderDriverKind, + ProviderThreadId, + RunId, + ThreadId, + type OrchestrationV2ProviderThread, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; +import { expect, vi } from "vite-plus/test"; + +import type * as EventStore from "./EventStore.ts"; +import { nativeStorageFolders } from "./NativeStorageFolder.ts"; + +const claude = ProviderDriverKind.make("claudeAgent"); +const row = ProviderThreadId.make("provider-thread:claude"); +const otherRow = ProviderThreadId.make("provider-thread:other"); +const codexRow = ProviderThreadId.make("provider-thread:codex"); + +const run = (ordinal: number, cwd?: string) => ({ + id: RunId.make(`run:${ordinal}`), + ordinal, + ...(cwd === undefined ? {} : { cwd }), +}); +const attempt = (ordinal: number, nativeThreadId?: string, providerThreadId = row) => ({ + runId: RunId.make(`run:${ordinal}`), + providerThreadId, + ...(nativeThreadId === undefined ? {} : { nativeThreadId }), +}); +const rootScope = (ordinal: number, cwd: string, providerThreadId = row) => ({ + kind: "root_run" as const, + runId: RunId.make(`run:${ordinal}`), + providerThreadId, + cwd, +}); +const providerThread = (nativeId: string, id = row, recorded?: string) => + ({ + id, + driver: claude, + nativeThreadRef: { driver: claude, nativeId, strength: "strong" }, + ...(recorded === undefined ? {} : { nativeStorageFolder: recorded }), + }) as OrchestrationV2ProviderThread; + +const runs = [run(4, "/repo/d"), run(1), run(3, "/repo/b"), run(2)]; +const events = [ + rootScope(4, "/repo/d"), + rootScope(3, "/repo/queued"), + rootScope(2, "/repo/a"), + rootScope(1, "/repo/other-queued", otherRow), + rootScope(1, "/repo/other", otherRow), + { kind: "subagent" as const, runId: RunId.make("run:1"), providerThreadId: row, cwd: "/repo/x" }, +].map((payload) => ({ event: { type: "checkpoint-scope.created", payload } }) as never); + +it.effect.each([ + { + name: "uses the cwd of the earliest run that sent to the session, not the row's earliest run", + providerThreads: [providerThread("b")], + attempts: [attempt(4, "b"), attempt(2, "a"), attempt(3, "b"), attempt(1, "a")], + folders: [[row, "/repo/b"]], + reads: 0, + }, + { + name: "falls back to the earliest run's latest scope event when its runs have no cwd", + providerThreads: [providerThread("a")], + attempts: [attempt(2, "a"), attempt(1, "a", otherRow)], + folders: [[row, "/repo/other"]], + reads: 1, + }, + { + name: "keeps the earliest session run's scope event when only a later run recorded a cwd", + providerThreads: [providerThread("a")], + attempts: [attempt(1, "a"), attempt(3, "a")], + folders: [[row, "/repo/other"]], + reads: 1, + }, + { + name: "ignores later runs' scope events when the earliest session run's own is pruned", + providerThreads: [providerThread("a")], + attempts: [attempt(1, "a"), attempt(3, "a")], + events: events.slice(0, 3), + folders: [], + reads: 1, + }, + { + name: "uses the row's earliest surviving scope event when its attempts predate nativeThreadId", + providerThreads: [providerThread("a")], + attempts: [attempt(1, undefined, otherRow), attempt(2), attempt(3), attempt(4)], + folders: [[row, "/repo/a"]], + reads: 1, + }, + { + name: "takes a recorded folder without reading scope events", + providerThreads: [providerThread("b", row, "/repo/b")], + attempts: [attempt(2), attempt(3)], + folders: [[row, "/repo/b"]], + reads: 0, + }, + { + name: "reads scope events once for the undecided Claude rows, none for a session that never ran", + providerThreads: [ + providerThread("a"), + providerThread("a", otherRow), + { ...providerThread("a", codexRow), driver: ProviderDriverKind.make("codex") }, + providerThread("c", ProviderThreadId.make("provider-thread:never-ran")), + ], + attempts: [attempt(2), attempt(3)], + folders: [ + [row, "/repo/a"], + [otherRow, "/repo/other"], + ], + reads: 1, + }, +])("$name", ({ providerThreads, attempts, folders, reads, events: rowEvents = events }) => + Effect.gen(function* () { + const read = vi.fn(() => Stream.fromIterable(rowEvents)); + const resolved = yield* nativeStorageFolders({ read }, ThreadId.make("thread"), { + runs, + attempts, + providerThreads, + }); + expect([...resolved]).toEqual(folders); + expect(read).toHaveBeenCalledTimes(reads); + }), +); diff --git a/apps/server/src/orchestration-v2/NativeStorageFolder.ts b/apps/server/src/orchestration-v2/NativeStorageFolder.ts new file mode 100644 index 0000000000000..4700c9c13ba28 --- /dev/null +++ b/apps/server/src/orchestration-v2/NativeStorageFolder.ts @@ -0,0 +1,83 @@ +import type { + OrchestrationV2CheckpointScope, + OrchestrationV2ProviderThread, + OrchestrationV2Run, + OrchestrationV2RunAttempt, + ProviderThreadId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; + +import type * as EventStore from "./EventStore.ts"; + +// Scopes in commit order; a run's latest scope wins. +function nativeStorageFolder(input: { + readonly providerThread: OrchestrationV2ProviderThread; + readonly runs: ReadonlyArray>; + readonly attempts: ReadonlyArray< + Pick + >; + readonly checkpointScopes: ReadonlyArray; +}): string | undefined { + const { providerThread } = input; + if (providerThread.nativeStorageFolder !== undefined) return providerThread.nativeStorageFolder; + const nativeThreadId = providerThread.nativeThreadRef?.nativeId; + const sessionRunIds = new Set( + input.attempts.flatMap((attempt) => + attempt.nativeThreadId !== undefined && attempt.nativeThreadId === nativeThreadId + ? [attempt.runId] + : [], + ), + ); + const rootScopes = new Map( + input.checkpointScopes.flatMap((scope) => + scope.kind === "root_run" && scope.runId !== null ? [[scope.runId, scope] as const] : [], + ), + ); + const runs = [...input.runs].sort((left, right) => left.ordinal - right.ordinal); + const scopes = runs.flatMap((run) => rootScopes.get(run.id) ?? []); + const first = runs.find((run) => sessionRunIds.has(run.id)); + return ( + first?.cwd ?? + (first && rootScopes.get(first.id)?.cwd) ?? + (input.attempts.some( + (attempt) => + attempt.providerThreadId === providerThread.id && attempt.nativeThreadId !== undefined, + ) + ? undefined + : scopes.find((scope) => scope.providerThreadId === providerThread.id)?.cwd) + ); +} + +export const nativeStorageFolders = Effect.fn("NativeStorageFolder.nativeStorageFolders")( + function* ( + eventStore: Pick, + threadId: ThreadId, + records: Pick[0], "runs" | "attempts"> & { + readonly providerThreads: ReadonlyArray; + }, + ) { + const scopes = yield* Effect.cached( + Effect.suspend(() => + eventStore.read({ threadId, eventType: "checkpoint-scope.created" }).pipe( + Stream.runCollect, + Effect.map((events) => + events.flatMap(({ event }) => + event.type === "checkpoint-scope.created" ? [event.payload] : [], + ), + ), + ), + ), + ); + const folders = new Map(); + for (const providerThread of records.providerThreads) { + if (providerThread.driver !== "claudeAgent") continue; + const folder = + nativeStorageFolder({ ...records, providerThread, checkpointScopes: [] }) ?? + nativeStorageFolder({ ...records, providerThread, checkpointScopes: yield* scopes }); + if (folder !== undefined) folders.set(providerThread.id, folder); + } + return folders; + }, +); diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index 2845860efac20..185970e8d51b5 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -1,5 +1,8 @@ import { assert, it } from "@effect/vitest"; import { + CheckpointId, + CheckpointRef, + CheckpointScopeId, CommandId, MessageId, EventId, @@ -29,19 +32,33 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/sql/SqlClient"; import * as Statement from "effect/sql/Statement"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { orchestrationCommandDuration, orchestrationCommandsTotal, } from "../observability/Metrics.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { providerThread, runCreated, threadCreated } from "../project/ThreadMove.testkit.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import { SHARED_WORKSPACE_RESTORE_MESSAGE } from "./CheckpointRestoreSafety.ts"; +import * as CheckpointService from "./CheckpointService.ts"; +import * as CommandPolicy from "./CommandPolicy.ts"; +import * as CommandReceiptStore from "./CommandReceiptStore.ts"; +import * as ContextHandoffService from "./ContextHandoffService.ts"; import * as DelegatedTaskQuestionReactor from "./DelegatedTaskQuestionReactor.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; import * as EventSink from "./EventSink.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ProjectStore from "./ProjectStore.ts"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; +import * as ProviderSessionManager from "./ProviderSessionManager.ts"; +import * as ProviderSwitchService from "./ProviderSwitchService.ts"; +import * as RuntimePolicy from "./RuntimePolicy.ts"; +import * as ThreadForkService from "./ThreadForkService.ts"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const instanceId = ProviderInstanceId.make("codex"); @@ -2129,6 +2146,184 @@ it.effect("dispatch records the command outcome and its duration", () => }).pipe(Effect.provide(layerTest)), ); +it.effect.each([ + { firstRun: "moved", cwd: "/baseline-restore/moved-away", admitted: false }, + { firstRun: "same", cwd: process.cwd(), admitted: true }, + { firstRun: "legacy", cwd: undefined, admitted: true }, + { + firstRun: "moved-earlier", + cwd: "/baseline-restore/moved-away", + laterRun: true, + admitted: true, + }, +])( + "admits a baseline file restore by the folder of the run that took it ($firstRun)", + ({ firstRun, cwd, laterRun, admitted }) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const name = `baseline-restore-${firstRun}`; + const threadId = ThreadId.make(`thread:${name}`); + const projectId = ProjectId.make(`project:${name}`); + const providerThreadId = ProviderThreadId.make(`provider-thread:${name}`); + const scopeId = CheckpointScopeId.make(`scope:${name}`); + const checkpointId = CheckpointId.make(`checkpoint:${name}`); + const firstRunId = RunId.make(`run:${name}`); + const firstNodeId = NodeId.make(`node:${name}`); + const runId = laterRun ? RunId.make(`run:${name}:later`) : firstRunId; + const nodeId = laterRun ? NodeId.make(`node:${name}:later`) : firstNodeId; + const now = yield* DateTime.now; + yield* projections.apply(threadCreated({ threadId, projectId, worktreePath: process.cwd() })); + yield* projections.apply({ + id: EventId.make(`${name}:provider-thread`), + type: "provider-thread.updated", + threadId, + occurredAt: now, + payload: { + ...providerThread({ + id: providerThreadId, + threadId, + providerInstanceId: instanceId, + nativeId: name, + }), + driver: adapter.driver, + providerSessionId: ProviderSessionId.make(`session:${name}`), + nativeThreadRef: null, + }, + }); + yield* projections.apply({ + id: EventId.make(`${name}:scope`), + type: "checkpoint-scope.created", + threadId, + occurredAt: now, + payload: { + id: scopeId, + threadId, + runId, + nodeId, + parentScopeId: null, + providerThreadId, + kind: "root_run", + ordinalWithinParent: 0, + advancesAppRunCount: true, + cwd: process.cwd(), + createdAt: now, + }, + }); + yield* projections.apply({ + id: EventId.make(`${name}:checkpoint`), + type: "checkpoint.captured", + threadId, + occurredAt: now, + payload: { + id: checkpointId, + threadId, + scopeId, + runId: null, + nodeId, + parentCheckpointId: null, + ordinalWithinScope: 0, + appRunOrdinal: null, + ref: CheckpointRef.make(`refs/t3/${name}`), + status: "ready", + files: [], + capturedAt: now, + }, + }); + const runs = [ + { id: firstRunId, rootNodeId: firstNodeId, cwd }, + ...(laterRun ? [{ id: runId, rootNodeId: nodeId, cwd: process.cwd() }] : []), + ]; + for (const [index, run] of runs.entries()) { + yield* projections.apply( + runCreated({ + ...run, + threadId, + ordinal: index + 1, + providerInstanceId: instanceId, + providerThreadId, + occurredAt: now, + }), + ); + } + const rollback = orchestrator.dispatch({ + type: "checkpoint.rollback", + commandId: CommandId.make(`rollback-${name}`), + threadId, + checkpointId, + scopeId, + restoreFiles: true, + }); + if (admitted) { + assert.deepEqual( + (yield* rollback).storedEvents.map((stored) => stored.event.type), + ["thread.metadata-updated", "checkpoint.rollback-requested"], + ); + } else { + assert.deepInclude(yield* rollback.pipe(Effect.flip), { + _tag: "OrchestratorDispatchError", + cause: SHARED_WORKSPACE_RESTORE_MESSAGE, + }); + } + }).pipe(Effect.provide(layerTest)), +); + +// A move detaches the thread's sessions, which ends any background work inside them. Only the +// session manager knows whether a session is live, so its answer stands in for the probe. +const layerSessionLookup = (lookup: "absent" | "fails") => + Orchestrator.layer.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + IdAllocator.layer, + ServerSettings.layerTest(), + Layer.mock(CheckpointService.CheckpointServiceV2)({}), + Layer.mock(CommandPolicy.CommandPolicyV2)({}), + Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}), + Layer.mock(ContextHandoffService.ContextHandoffServiceV2)({}), + Layer.mock(EffectOutbox.EffectOutboxV2)({}), + Layer.mock(EventSink.EventSinkV2)({ + latestSequence: () => Effect.succeed(0), + stream: () => Stream.empty, + }), + Layer.mock(ProjectStore.ProjectStoreV2)({}), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThreadRecords: () => + Effect.succeed({ + providerSessions: [{ id: ProviderSessionId.make("session:lookup") }], + providerThreads: [], + } as never), + }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({}), + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ + get: (providerSessionId) => + lookup === "absent" + ? Effect.succeedNone + : Effect.fail( + new ProviderSessionManager.ProviderSessionLookupError({ providerSessionId }), + ), + }), + Layer.mock(ProviderSwitchService.ProviderSwitchServiceV2)({}), + Layer.mock(RuntimePolicy.RuntimePolicyV2)({}), + Layer.mock(ThreadForkService.ThreadForkServiceV2)({}), + ), + ), + ); + +it.effect.each([ + { lookup: "absent", answer: false }, + // A failed lookup cannot tell a gone session from a live one, so the move must not proceed. + { lookup: "fails", answer: "OrchestratorProjectionError" }, +] as const)("threadHasBackgroundWork when the session lookup $lookup", ({ lookup, answer }) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const result = yield* orchestrator + .threadHasBackgroundWork(ThreadId.make("thread:session-lookup")) + .pipe(Effect.result); + assert.equal(Result.isSuccess(result) ? result.success : result.failure._tag, answer); + }).pipe(Effect.provide(layerSessionLookup(lookup))), +); + it.effect("a user message dispatched first stays run 1 when its lineage read is slow", () => { const threadId = ThreadId.make("thread:dispatch-order"); // A file database serves projection reads from a reader worker, so the lineage read a user diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 722e458822711..041042c4a4dc2 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -454,6 +454,9 @@ export interface OrchestratorV2Shape { fields: ReadonlyArray, filter?: ProjectionRecordFilter, ) => Effect.Effect, OrchestratorV2Error>; + readonly threadHasBackgroundWork: ( + threadId: ThreadId, + ) => Effect.Effect; readonly listAppOwnedChildThreadIds: ( threadId: ThreadId, ) => Effect.Effect, OrchestratorV2Error>; @@ -1692,15 +1695,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // Detaching kills background work running inside the provider process. // Probe even with an empty roster: Claude's native subagents never reach // pendingBackgroundTasks. Sessions shared across threads probe per thread. - const sessionHasBackgroundWork = Effect.fn("Orchestrator.sessionHasBackgroundWork")(function* ( + const runtimeHasBackgroundWork = Effect.fn("Orchestrator.runtimeHasBackgroundWork")(function* ( detaching: OrchestrationV2ThreadProjection["providerSessions"][number], + runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime, providerThreads: OrchestrationV2ThreadProjection["providerThreads"], ) { - const session = yield* providerSessions - .get(detaching.id) - .pipe(Effect.orElseSucceed(() => Option.none())); - if (Option.isNone(session)) return false; - const runtime = session.value; const probes = !detaching.capabilities.sessions.supportsMultipleProviderThreadsPerSession && runtime.hasPendingBackgroundWork !== undefined @@ -1718,6 +1717,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return false; }); + // Dispatch treats a session the manager cannot find as gone: a lookup failure here must not + // refuse the user's message. + const sessionHasBackgroundWork = Effect.fn("Orchestrator.sessionHasBackgroundWork")(function* ( + detaching: OrchestrationV2ThreadProjection["providerSessions"][number], + providerThreads: OrchestrationV2ThreadProjection["providerThreads"], + ) { + const session = yield* providerSessions + .get(detaching.id) + .pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isNone(session)) return false; + return yield* runtimeHasBackgroundWork(detaching, session.value, providerThreads); + }); + // Runs in every command transaction; avoid reading thousands of finished leaf nodes. const readCommandProjection = ( threadId: ThreadId, @@ -16137,6 +16149,24 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); }, ), + threadHasBackgroundWork: Effect.fn("OrchestratorV2.threadHasBackgroundWork")( + function* (threadId) { + const records = yield* projectionStore + .getThreadRecords(threadId, ["providerSessions", "providerThreads"]) + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); + for (const session of records.providerSessions) { + // A failed lookup cannot tell a gone session from a live one, so the caller refuses. + const runtime = yield* providerSessions + .get(session.id) + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); + if (Option.isNone(runtime)) continue; + if (yield* runtimeHasBackgroundWork(session, runtime.value, records.providerThreads)) { + return true; + } + } + return false; + }, + ), listAppOwnedChildThreadIds: Effect.fn("OrchestratorV2.listAppOwnedChildThreadIds")( function (threadId) { return projectionStore diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 02462bc43042b..f29369fd9709e 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -61,6 +61,7 @@ import { buildBoundedThreadStreamSnapshot } from "./ThreadStream.ts"; import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts"; import { layerReaderDatabase, traceSqlRouting } from "../persistence/testkit/SqliteRouting.ts"; +import { runCreated, threadCreated } from "../project/ThreadMove.testkit.ts"; declare module "node:sqlite" { interface StatementSync { @@ -9069,7 +9070,9 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { const sourceThreadId = ThreadId.make("thread:projection-imported-fork-shell:source"); const targetThreadId = ThreadId.make("thread:projection-imported-fork-shell:target"); const sideChatThreadId = ThreadId.make("thread:projection-imported-fork-shell:side-chat"); + const emptyForkThreadId = ThreadId.make("thread:projection-imported-fork-shell:empty-fork"); const sourceRunId = RunId.make("run:projection-imported-fork-shell:source"); + const emptyRunId = RunId.make("run:projection-imported-fork-shell:empty"); const rootNodeId = NodeId.make("node:projection-imported-fork-shell:source"); yield* projectionStore.apply({ @@ -9106,9 +9109,10 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { deletedAt: null, }, }); - for (const [threadId, sideChat] of [ - [targetThreadId, false], - [sideChatThreadId, true], + for (const [threadId, sideChat, runId] of [ + [targetThreadId, false, sourceRunId], + [sideChatThreadId, true, sourceRunId], + [emptyForkThreadId, false, emptyRunId], ] as const) { yield* projectionStore.apply({ id: EventId.make(`event:projection-imported-fork-shell:created:${threadId}`), @@ -9137,7 +9141,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { forkedFrom: { type: "run", threadId: sourceThreadId, - runId: sourceRunId, + runId, }, createdAt: now, updatedAt: now, @@ -9149,6 +9153,15 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }, }); } + yield* projectionStore.apply( + runCreated({ + id: emptyRunId, + threadId: sourceThreadId, + ordinal: 1, + providerInstanceId, + occurredAt: now, + }), + ); yield* projectionStore.apply({ id: EventId.make("event:projection-imported-fork-shell:source-run"), type: "run.created", @@ -9160,7 +9173,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { payload: { id: sourceRunId, threadId: sourceThreadId, - ordinal: 1, + ordinal: 2, providerInstanceId, modelSelection, providerThreadId: null, @@ -9218,6 +9231,21 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { assert.equal(targetShell.visibleItemCount, 4); assert.equal(targetProjection.visibleTurnItems.length, 4); + const emptyFork = yield* projectionStore.getThreadProjection(emptyForkThreadId); + assert.deepEqual( + emptyFork.visibleTurnItems.map((row) => + row.item.type === "assistant_message" ? row.item.text : row.item.type, + ), + ["imported-one", "imported-two", "fork"], + ); + const emptyForkPage = yield* projectionStore.getTimelinePage(emptyForkThreadId, { + limit: 20, + view: "activity", + }); + assert.deepEqual(emptyForkPage.items, emptyFork.visibleTurnItems); + const emptyForkShell = shell.threads.find((thread) => thread.id === emptyForkThreadId); + assert.equal(emptyForkShell?.visibleItemCount, 3); + const sideChatShell = shell.threads.find((thread) => thread.id === sideChatThreadId); assert.equal(sideChatShell?.visibleItemCount, 0); const sideChatProjection = yield* projectionStore.getThreadProjection(sideChatThreadId); @@ -9230,6 +9258,323 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }), ); + it.effect("inherits a native source's move receipts before the fork point", () => + Effect.gen(function* () { + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const projectId = ProjectId.make("project:projection-fork-receipt"); + const thread = (name: string) => ThreadId.make(`thread:projection-fork-receipt:${name}`); + const run = (name: string) => RunId.make(`run:projection-fork-receipt:${name}`); + const item = (name: string) => TurnItemId.make(`turn-item:projection-fork-receipt:${name}`); + const createThread = ( + threadId: ThreadId, + fork: { readonly threadId: ThreadId; readonly runId: RunId } | null, + historyOrigin?: "v1_import", + ) => { + const created = threadCreated({ + threadId, + projectId, + worktreePath: null, + providerInstanceId, + parent: fork?.threadId, + kind: fork === null ? undefined : "fork", + }); + return projectionStore.apply({ + ...created, + occurredAt: now, + payload: { + ...created.payload, + ...(historyOrigin === undefined ? {} : { historyOrigin }), + forkedFrom: fork === null ? null : { type: "run", ...fork }, + }, + }); + }; + const runEvent = (threadId: ThreadId, runId: RunId, ordinal: number, cwd?: string) => + runCreated({ + id: runId, + threadId, + ordinal, + providerInstanceId, + rootNodeId: NodeId.make(`node:${runId}`), + cwd, + occurredAt: now, + }); + const createRun = (...run: Parameters) => + projectionStore.apply(runEvent(...run)); + const addItem = (threadId: ThreadId, name: string, runId: RunId | null, ordinal: number) => + projectionStore.apply({ + id: EventId.make(`event:projection-fork-receipt:item:${name}`), + type: "turn-item.updated", + threadId, + ...(runId === null ? {} : { runId }), + occurredAt: now, + payload: { + id: item(name), + threadId, + runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed", + title: null, + startedAt: now, + completedAt: now, + updatedAt: now, + ...(runId === null + ? { type: "system_notice", message: name, previousFolder: `/${name}` } + : { + type: "assistant_message", + messageId: MessageId.make(`message:projection-fork-receipt:${name}`), + text: name, + streaming: false, + }), + }, + }); + // A source whose run 1 stayed home and run 2 moved to /b; its run-less items are receipts. + const fixture = Effect.fnUntraced(function* ( + name: string, + items: Readonly>, + forks: Readonly>, + historyOrigin?: "v1_import", + ) { + const source = thread(name); + yield* createThread(source, null, historyOrigin); + for (const [fork, at] of Object.entries(forks)) { + yield* createThread(thread(fork), { threadId: source, runId: run(`${name}-${at}`) }); + } + yield* createRun(source, run(`${name}-legacy`), 1); + yield* createRun(source, run(`${name}-moved`), 2, "/b"); + for (const [index, [suffix, at]] of Object.entries(items).entries()) { + yield* addItem(source, suffix, at === null ? null : run(`${name}-${at}`), index + 1); + } + }); + yield* fixture( + "source", + { legacy: "legacy", a: null, "moved-one": "moved", "moved-two": "moved" }, + { target: "moved", "legacy-fork": "legacy" }, + ); + yield* fixture( + "late", + { "late-moved": "moved", "late-a": null, "late-legacy": "legacy", "late-c": null }, + { "late-fork": "moved" }, + ); + yield* fixture( + "imported", + { + "imported-legacy": "legacy", + "imported-a": null, + "imported-moved-one": "moved", + "imported-moved-two": "moved", + "imported-b": null, + "imported-c": null, + }, + { "imported-fork": "legacy" }, + "v1_import", + ); + // This source rewinds its moved run, then forks from a third run that has no rows yet. + yield* fixture( + "rewound", + { + "rewound-legacy": "legacy", + "rewound-a": null, + "rewound-moved": "moved", + "rewound-b": null, + }, + {}, + ); + const rewound = thread("rewound"); + const rewoundRun = runEvent(rewound, run("rewound-moved"), 2, "/b"); + yield* projectionStore.apply({ + ...rewoundRun, + id: EventId.make("event:projection-fork-receipt:rewound"), + type: "run.updated", + payload: { ...rewoundRun.payload, status: "rolled_back" }, + }); + yield* createRun(rewound, run("rewound-empty"), 3, "/b"); + yield* createThread(thread("rewound-fork"), { + threadId: rewound, + runId: run("rewound-empty"), + }); + + const shape = (rows: ReadonlyArray<{ readonly item: OrchestrationV2TurnItem }>) => + rows.map((row) => + row.item.type === "system_notice" + ? row.item.previousFolder + : row.item.type === "assistant_message" + ? row.item.text + : row.item.type, + ); + const window = (threadId: ThreadId, rowLimit: number, anchorItemId?: TurnItemId) => + Effect.map( + projectionStore.getThreadSnapshotWindow(threadId, { rowLimit, anchorItemId }), + (snapshot) => shape(snapshot.projection.visibleTurnItems), + ); + const expectFork = Effect.fnUntraced(function* ( + fork: ThreadId, + expected: ReadonlyArray, + ) { + const projection = yield* projectionStore.getThreadProjection(fork); + const page = yield* projectionStore.getTimelinePage(fork, { view: "activity", limit: 10 }); + assert.deepEqual(page.items, projection.visibleTurnItems); + assert.deepEqual(shape(projection.visibleTurnItems), expected); + assert.deepEqual(yield* window(fork, 2), expected.slice(-3)); + assert.deepEqual(yield* window(fork, 3), expected.slice(-4)); + return projection; + }); + + const target = thread("target"); + const main = yield* expectFork(target, ["legacy", "/a", "moved-one", "moved-two", "fork"]); + assert.equal(main.visibleTurnItems[1]?.visibility, "inherited"); + const folders = main.runFolders?.map((folder) => [folder.id, folder.cwd]); + assert.deepEqual(folders, [[run("source-moved"), "/b"]]); + assert.deepEqual(yield* window(target, 2, item("moved-one")), ["/a", "moved-one", "fork"]); + assert.deepEqual(yield* window(target, 2, item("a")), ["legacy", "/a", "fork"]); + yield* expectFork(thread("legacy-fork"), ["legacy", "/a", "fork"]); + yield* expectFork(thread("late-fork"), ["late-moved", "/late-a", "fork"]); + yield* expectFork(thread("imported-fork"), ["imported-legacy", "/imported-a", "fork"]); + yield* expectFork(thread("rewound-fork"), ["rewound-legacy", "/rewound-a", "fork"]); + }), + ); + + it.effect("keeps a source's move after the fork out of the fork's history", () => + Effect.gen(function* () { + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const day = (date: number) => DateTime.makeUnsafe(`2026-03-0${date}T00:00:00.000Z`); + const projectId = ProjectId.make("project:projection-fork-later-move"); + const thread = (name: string) => ThreadId.make(`thread:projection-fork-later-move:${name}`); + const run = (name: string) => RunId.make(`run:projection-fork-later-move:${name}`); + const createThread = (name: string, createdAt: DateTime.Utc, forkOf?: string) => { + const created = threadCreated({ + threadId: thread(name), + projectId, + worktreePath: "/a", + providerInstanceId, + parent: forkOf === undefined ? undefined : thread(forkOf), + kind: forkOf === undefined ? undefined : "fork", + }); + return projectionStore.apply({ + ...created, + occurredAt: createdAt, + payload: { + ...created.payload, + createdAt, + updatedAt: createdAt, + forkedFrom: + forkOf === undefined + ? null + : { type: "run", threadId: thread(forkOf), runId: run(forkOf) }, + }, + }); + }; + const addItem = ( + threadName: string, + text: string, + at: DateTime.Utc, + ordinal: number, + previousFolder?: string, + ) => { + const runId = previousFolder === undefined ? run(threadName) : null; + return projectionStore.apply({ + id: EventId.make(`event:projection-fork-later-move:${threadName}:${ordinal}`), + type: "turn-item.updated", + threadId: thread(threadName), + ...(runId === null ? {} : { runId }), + occurredAt: at, + payload: { + id: TurnItemId.make(`turn-item:projection-fork-later-move:${threadName}:${ordinal}`), + threadId: thread(threadName), + runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed", + title: null, + startedAt: at, + completedAt: at, + updatedAt: at, + ...(previousFolder === undefined + ? { + type: "assistant_message", + messageId: MessageId.make(`message:${threadName}:${ordinal}`), + text, + streaming: false, + } + : { type: "system_notice", message: text, previousFolder }), + }, + }); + }; + // Each source answers on day 1, moves to /b on day 3 and to /c on day 5. One fork + // is from day 2, before both moves, and one from day 4, between them. + const source = Effect.fnUntraced(function* (name: string, lateAnswer: boolean) { + yield* createThread(name, day(1)); + yield* projectionStore.apply( + runCreated({ + id: run(name), + threadId: thread(name), + ordinal: 1, + providerInstanceId, + occurredAt: day(1), + }), + ); + yield* addItem(name, "answer", day(1), 1); + yield* createThread(`${name}-early`, day(2), name); + yield* addItem(name, "Moved to /b.", day(3), 2, "/a"); + yield* createThread(`${name}-between`, day(4), name); + yield* addItem(name, "Moved to /c.", day(5), 3, "/b"); + // Output of the forked run that arrives after both moves. + if (lateAnswer) yield* addItem(name, "late answer", day(6), 4); + }); + yield* source("plain", false); + yield* source("late", true); + // A fork that moves shows its own receipt. + yield* addItem("plain-early", "Moved to /x.", day(7), 1, "/a"); + + const shape = (rows: ReadonlyArray<{ readonly item: OrchestrationV2TurnItem }>) => + rows.map((row) => + row.item.type === "system_notice" + ? row.item.message + : row.item.type === "assistant_message" + ? row.item.text + : row.item.type, + ); + const expectFork = Effect.fnUntraced(function* ( + name: string, + expected: ReadonlyArray, + ) { + const projection = yield* projectionStore.getThreadProjection(thread(name)); + assert.deepEqual(shape(projection.visibleTurnItems), expected); + const page = yield* projectionStore.getTimelinePage(thread(name), { + view: "activity", + limit: 10, + }); + assert.deepEqual(page.items, projection.visibleTurnItems); + const window = yield* projectionStore.getThreadSnapshotWindow(thread(name), { + rowLimit: 10, + }); + assert.deepEqual(shape(window.projection.visibleTurnItems), expected); + }); + yield* expectFork("plain-early", ["answer", "fork", "Moved to /x."]); + yield* expectFork("plain-between", ["answer", "Moved to /b.", "fork"]); + yield* expectFork("late-early", ["answer", "late answer", "fork"]); + yield* expectFork("late-between", ["answer", "Moved to /b.", "late answer", "fork"]); + // A short window spends its rows on inherited history, not on a move the fork never made. + const shortWindow = yield* projectionStore.getThreadSnapshotWindow(thread("late-early"), { + rowLimit: 2, + }); + assert.deepEqual(shape(shortWindow.projection.visibleTurnItems), [ + "answer", + "late answer", + "fork", + ]); + }), + ); + it.effect("removes rolled back runs from the active visible projection", () => Effect.gen(function* () { const projectionStore = yield* ProjectionStore.ProjectionStoreV2; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 20a9d111a2c5c..66367aeb2f158 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1482,16 +1482,59 @@ function makeForkMarkerTurnItem(input: { }; } +type ForkHistoryItem = Pick & { + readonly previousFolder?: string | undefined; + readonly startedAt?: DateTime.Utc | null | undefined; +}; + +const isMoveReceipt = (item: ForkHistoryItem) => + item.runId === null && item.type === "system_notice" && item.previousFolder !== undefined; + +// A source that moves after the fork was created says nothing about the fork, which stayed. +const isMoveReceiptBeforeFork = (item: ForkHistoryItem, forkCreatedAt: DateTime.Utc) => + isMoveReceipt(item) && + item.startedAt != null && + !DateTime.isGreaterThan(item.startedAt, forkCreatedAt); + +// Same boundary as covering_receipt in readProjection's fork window SQL. +function forkBoundaryOrdinal( + items: ReadonlyArray, + runOrdinalById: ReadonlyMap, number>, + sourceRunOrdinal: number, + historyOrigin: OrchestrationV2ThreadProjection["thread"]["historyOrigin"], + forkCreatedAt: DateTime.Utc, +): number { + let boundaryRun = -1; + let boundary = -1; + if (historyOrigin === "v1_import") + boundary = items.findLast((item) => item.runId === null && !isMoveReceipt(item))?.ordinal ?? -1; + for (const item of items) { + const run = item.runId === null ? undefined : runOrdinalById.get(item.runId); + if (run === undefined || run > sourceRunOrdinal || run < boundaryRun) continue; + boundaryRun = run; + boundary = item.ordinal; + } + return ( + items.find((item) => item.ordinal > boundary && isMoveReceiptBeforeFork(item, forkCreatedAt)) + ?.ordinal ?? boundary + ); +} + function isTurnItemAtOrBeforeRun(input: { readonly historyOrigin: OrchestrationV2ThreadProjection["thread"]["historyOrigin"]; - readonly itemRunId: OrchestrationV2TurnItem["runId"]; + readonly item: ForkHistoryItem; + readonly boundaryOrdinal: number; readonly runOrdinalById: ReadonlyMap, number>; readonly sourceRunOrdinal: number; + readonly forkCreatedAt: DateTime.Utc; }): boolean { - if (input.itemRunId === null) { - return input.historyOrigin === "v1_import"; + if (input.item.ordinal > input.boundaryOrdinal) return false; + if (input.item.runId === null) { + return isMoveReceipt(input.item) + ? isMoveReceiptBeforeFork(input.item, input.forkCreatedAt) + : input.historyOrigin === "v1_import"; } - const ordinal = input.runOrdinalById.get(input.itemRunId); + const ordinal = input.runOrdinalById.get(input.item.runId); return ordinal !== undefined && ordinal <= input.sourceRunOrdinal; } @@ -1543,6 +1586,7 @@ export function forkInheritedRunOrdinals( function visibleTurnItemsThroughRun(input: { readonly sourceProjection: OrchestrationV2ThreadProjection; readonly sourceRunId: NonNullable; + readonly boundaryOrdinal?: number | undefined; readonly forkCreatedAt: DateTime.Utc; }): Array> { const sourceRun = input.sourceProjection.runs.find((run) => run.id === input.sourceRunId); @@ -1556,6 +1600,15 @@ function visibleTurnItemsThroughRun(input: { attempts: input.sourceProjection.attempts, items: input.sourceProjection.turnItems, }); + const boundaryOrdinal = + input.boundaryOrdinal ?? + forkBoundaryOrdinal( + input.sourceProjection.turnItems, + runOrdinalById, + sourceRun.ordinal, + input.sourceProjection.thread.historyOrigin, + input.forkCreatedAt, + ); const inheritedPrefix = input.sourceProjection.visibleTurnItems .filter( (row) => row.item.threadId !== input.sourceProjection.thread.id || row.item.type === "fork", @@ -1572,9 +1625,11 @@ function visibleTurnItemsThroughRun(input: { isVisible(item) && isTurnItemAtOrBeforeRun({ historyOrigin: input.sourceProjection.thread.historyOrigin, - itemRunId: item.runId, + item, + boundaryOrdinal, runOrdinalById, sourceRunOrdinal: sourceRun.ordinal, + forkCreatedAt: input.forkCreatedAt, }), ), ); @@ -1604,6 +1659,7 @@ function buildVisibleTurnItems(input: { readonly projection: OrchestrationV2ThreadProjection; readonly sourceProjection: OrchestrationV2ThreadProjection | null; readonly inheritedRowCount?: number; + readonly boundaryOrdinal?: number | undefined; }): Array { const forkedFrom = inheritedRunFork(input.projection.thread); if ( @@ -1619,6 +1675,7 @@ function buildVisibleTurnItems(input: { : visibleTurnItemsThroughRun({ sourceProjection: input.sourceProjection, sourceRunId: forkedFrom.runId, + boundaryOrdinal: input.boundaryOrdinal, forkCreatedAt: input.projection.thread.createdAt, }); const markerItem = makeForkMarkerTurnItem({ @@ -2254,6 +2311,9 @@ export const layer: Layer.Layer = readonly attemptStatus?: string | null; readonly persistentOutput?: boolean; readonly deferredWork?: OrchestrationV2TurnItem["deferredWork"]; + /** Set, with `startedAt`, on a move receipt: a fork inherits it only from before the fork. */ + readonly previousFolder?: string; + readonly startedAt?: DateTime.Utc | null; }; type TimelineIndexRow = { readonly sourceThreadId: ThreadId; @@ -3234,6 +3294,14 @@ export const layer: Layer.Layer = AND json_extract(source_thread.payload_json, '$.historyOrigin') = 'v1_import' ) ) + -- A move receipt from before the fork; a later move is the source's alone. + OR (item.run_id IS NULL AND item.type = 'system_notice' + AND json_extract(item.payload_json, '$.previousFolder') IS NOT NULL + AND julianday(json_extract(item.payload_json, '$.startedAt')) <= julianday(${ + window.forkCreatedAt === undefined + ? null + : DateTime.formatIso(window.forkCreatedAt) + })) OR ( run.ordinal <= ( SELECT required_run.ordinal @@ -4460,6 +4528,13 @@ export const layer: Layer.Layer = FROM orchestration_v2_projection_runs AS run WHERE run.thread_id = ${forkedFrom.threadId} AND run.ordinal <= (SELECT ordinal FROM fork_run) + -- forkInheritedRunOrdinals drops the same runs from the JavaScript boundary. + AND NOT COALESCE( + run.status = 'rolled_back' + AND julianday(run.completed_at) + < julianday(${DateTime.formatIso(projection.thread.createdAt)}), + 0 + ) AND EXISTS ( SELECT 1 FROM orchestration_v2_projection_turn_items AS item @@ -4482,12 +4557,23 @@ export const layer: Layer.Layer = ), -1 ) AS ordinal + ), covering_receipt AS ( + SELECT COALESCE( + (SELECT MIN(ordinal) FROM orchestration_v2_projection_turn_items + WHERE thread_id = ${forkedFrom.threadId} AND run_id IS NULL + AND type = 'system_notice' + AND json_extract(payload_json, '$.previousFolder') IS NOT NULL + AND julianday(json_extract(payload_json, '$.startedAt')) + <= julianday(${DateTime.formatIso(projection.thread.createdAt)}) + AND ordinal > (SELECT ordinal FROM effective_boundary)), + (SELECT ordinal FROM effective_boundary) + ) AS ordinal ) SELECT turn_item_id FROM orchestration_v2_projection_turn_items WHERE thread_id = ${forkedFrom.threadId} AND ordinal <= COALESCE( - (SELECT ordinal FROM effective_boundary), + (SELECT ordinal FROM covering_receipt), -1 ) AND ( @@ -4497,7 +4583,7 @@ export const layer: Layer.Layer = WHERE thread_id = ${forkedFrom.threadId} AND turn_item_id = ${historyAnchor?.itemId ?? null} LIMIT 1), - (SELECT ordinal FROM effective_boundary), + (SELECT ordinal FROM covering_receipt), -1 ) ) @@ -4572,6 +4658,8 @@ export const layer: Layer.Layer = const visibleTurnItems = buildVisibleTurnItems({ projection, sourceProjection, + // A source window's SQL already stops at the fork boundary. + boundaryOrdinal: sourceWindow === undefined ? undefined : Number.POSITIVE_INFINITY, }); return { ...projection, @@ -6425,6 +6513,8 @@ export const layer: Layer.Layer = input_intent: NonNullable | null; created_by: "user" | "agent" | "system" | null; message_id: string | null; + previous_folder: string | null; + moved_at: string | null; status: OrchestrationV2TurnItem["status"]; ordinal: number; attempt_id: string | null; @@ -6434,7 +6524,9 @@ export const layer: Layer.Layer = ${includeAttempts ? sql`attempt.status` : sql`NULL`} AS attempt_status, CASE WHEN item.type = 'user_message' THEN json_extract(item.payload_json, '$.inputIntent') END AS input_intent, CASE WHEN item.type = 'user_message' THEN json_extract(item.payload_json, '$.createdBy') END AS created_by, - CASE WHEN item.type IN ('user_message', 'assistant_message') THEN json_extract(item.payload_json, '$.messageId') END AS message_id + CASE WHEN item.type IN ('user_message', 'assistant_message') THEN json_extract(item.payload_json, '$.messageId') END AS message_id, + CASE WHEN item.type = 'system_notice' THEN json_extract(item.payload_json, '$.previousFolder') END AS previous_folder, + CASE WHEN item.type = 'system_notice' THEN json_extract(item.payload_json, '$.startedAt') END AS moved_at FROM orchestration_v2_projection_turn_items AS item ${ includeAttempts @@ -6464,6 +6556,10 @@ export const layer: Layer.Layer = ...(row.input_intent === null ? {} : { inputIntent: row.input_intent }), ...(row.created_by === null ? {} : { createdBy: row.created_by }), ...(row.message_id === null ? {} : { messageId: MessageId.make(row.message_id) }), + ...(row.previous_folder === null ? {} : { previousFolder: row.previous_folder }), + ...(row.previous_folder === null || row.moved_at === null + ? {} + : { startedAt: DateTime.makeUnsafe(row.moved_at) }), })); const local: Array = items.map((item) => ({ sourceThreadId: threadId, @@ -6489,11 +6585,19 @@ export const layer: Layer.Layer = let inherited: Array = []; if (sourceRun !== undefined) { const ordinals = forkInheritedRunOrdinals(source.records.runs, records.thread.createdAt); + const sourceItems = source.local.map((row) => row.item); const isInheritedVisible = createInheritedTurnItemVisibility({ runs: source.records.runs, attempts: source.records.attempts, - items: source.local.map((row) => row.item), + items: sourceItems, }); + const boundaryOrdinal = forkBoundaryOrdinal( + sourceItems, + ordinals, + sourceRun.ordinal, + source.records.thread.historyOrigin, + records.thread.createdAt, + ); inherited = [ ...source.visible.filter( (row) => row.item.threadId !== fork.threadId || row.item.type === "fork", @@ -6503,9 +6607,11 @@ export const layer: Layer.Layer = isInheritedVisible(row.item) && isTurnItemAtOrBeforeRun({ historyOrigin: source.records.thread.historyOrigin, - itemRunId: row.item.runId, + item: row.item, + boundaryOrdinal, runOrdinalById: ordinals, sourceRunOrdinal: sourceRun.ordinal, + forkCreatedAt: records.thread.createdAt, }), ), ].map((row) => ({ ...row, visibility: "inherited" })); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 7458abda7f6d8..1704bb1b58e9a 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -6,6 +6,7 @@ import { CheckpointScopeId, ContextHandoffId, ContextTransferId, + EventId, GitCommandError, MessageId, NodeId, @@ -19,6 +20,7 @@ import { RunId, ThreadId, ProjectId, + TurnItemId, type OrchestrationV2ThreadProjection, OrchestrationV2DomainEvent, } from "@t3tools/contracts"; @@ -33,6 +35,8 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as ServerConfig from "../config.ts"; import { @@ -47,11 +51,14 @@ import { import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import { layerRealGit } from "./ProviderTurnStartService.testkit.ts"; import * as ProjectService from "../project/ProjectService.ts"; +import { moveReceiptTurnItem } from "../project/ThreadMoveReceipt.ts"; import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as ContextHandoffService from "./ContextHandoffService.ts"; import { withInlineContext } from "./ContextHandoffDelivery.ts"; +import * as EffectOutbox from "./EffectOutbox.ts"; import * as EventSink from "./EventSink.ts"; +import * as EventStore from "./EventStore.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -61,6 +68,7 @@ import * as ProviderTurnStart from "./ProviderTurnStartService.ts"; import * as RunExecutionService from "./RunExecutionService.ts"; import * as ProviderUsageAnalytics from "../telemetry/ProviderUsageAnalytics.ts"; import * as RuntimePolicy from "./RuntimePolicy.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import * as TurnItemPositionStore from "./TurnItemPositionStore.ts"; const isDomainEvent = Schema.is(OrchestrationV2DomainEvent); @@ -144,6 +152,8 @@ function startWithMissingWorktree( Layer.mergeAll( Layer.mock(ContextHandoffService.ContextHandoffServiceV2)({}), Layer.mock(EventSink.EventSinkV2)({ writeIfRunCurrent }), + Layer.mock(EventStore.EventStoreV2)({}), + Layer.mock(EffectOutbox.EffectOutboxV2)({}), IdAllocator.layer, Layer.mock(TurnItemPositionStore.TurnItemPositionStoreV2)({}), Layer.succeed(FileSystem.FileSystem, { @@ -395,6 +405,7 @@ function makeLocalCommandHarness(input: { readonly writeFailure?: unknown; readonly failReadsAfterRunning?: boolean; readonly recoveryReadFailure?: string; + readonly driver?: ProviderDriverKind; readonly previousTurn?: { readonly runStatus: "failed" | "completed"; readonly turnStatus: "running" | "completed" | "interrupted"; @@ -417,6 +428,9 @@ function makeLocalCommandHarness(input: { readonly nativeForkFrom?: "idle" | "busy"; readonly switchedProvider?: boolean; readonly threadRuntimeMode?: OrchestrationV2ThreadProjection["thread"]["runtimeMode"]; + readonly parentRunCwd?: string; + readonly relocation?: Deferred.Deferred; + readonly parentStorageFolder?: string; }; }) { const settledRuns: Array = []; @@ -452,9 +466,11 @@ function makeLocalCommandHarness(input: { }; const providerThread: OrchestrationV2ThreadProjection["providerThreads"][number] = { id: providerThreadId, - driver: ProviderDriverKind.make( - input.nativeGoals === undefined ? "codex" : (input.goalDriver ?? "opencode"), - ), + driver: + input.driver ?? + ProviderDriverKind.make( + input.nativeGoals === undefined ? "codex" : (input.goalDriver ?? "opencode"), + ), providerInstanceId: newInstanceId, providerSessionId, appThreadId: threadId, @@ -606,6 +622,9 @@ function makeLocalCommandHarness(input: { nativeId: "side-chat-parent-native", strength: "strong" as const, }, + ...(input.sideChat?.parentStorageFolder === undefined + ? {} + : { nativeStorageFolder: input.sideChat.parentStorageFolder }), }; const parentProviderTurn = (ordinal: number, attemptRunId: RunId) => ({ attempt: { @@ -613,6 +632,7 @@ function makeLocalCommandHarness(input: { id: RunAttemptId.make(`side-chat-parent-attempt-${ordinal}`), runId: attemptRunId, providerThreadId: parentProviderThread.id, + nativeThreadId: "side-chat-parent-native", status: "completed" as const, }, turn: { @@ -636,7 +656,7 @@ function makeLocalCommandHarness(input: { parentProviderTurn(1, parentRunId), parentProviderTurn(2, parentRolledBackRunId), ]; - const parentRecords = { + let parentRecords = { ...projection, thread: { ...projection.thread, id: parentThreadId }, runs: [ @@ -649,6 +669,7 @@ function makeLocalCommandHarness(input: { activeAttemptId: parentTurns[0]!.attempt.id, status: "completed" as const, completedAt: now, + ...(input.sideChat?.parentRunCwd === undefined ? {} : { cwd: input.sideChat.parentRunCwd }), }, { ...run, @@ -678,6 +699,64 @@ function makeLocalCommandHarness(input: { providerTurns: parentTurns.map(({ turn }) => turn), turnItems: [], }; + const parentScopeEvents = [ + [parentRunId, "/repo/side-chat-parent"] as const, + [parentRolledBackRunId, "/repo/side-chat-parent-later"] as const, + ].map(([scopeRunId, cwd], index) => ({ + sequence: index + 1, + commandId: null, + event: { + id: EventId.make(`side-chat-parent-scope-${index}`), + type: "checkpoint-scope.created" as const, + threadId: parentThreadId, + runId: scopeRunId, + occurredAt: now, + payload: { + ...projection.checkpointScopes[0]!, + id: CheckpointScopeId.make("side-chat-parent-root-scope"), + threadId: parentThreadId, + runId: scopeRunId, + providerThreadId: parentProviderThread.id, + cwd, + }, + }, + })); + const readEvents = vi.fn((request) => + Stream.fromIterable(request?.threadId === parentThreadId ? parentScopeEvents : []), + ); + const relocation = input.sideChat?.relocation; + const relocationAwaited = Deferred.makeUnsafe(); + // A second move, committed on the parent while the fork waits for the first relocation. + const relocationAgain = Deferred.makeUnsafe(); + const relocationAgainAwaited = Deferred.makeUnsafe(); + let pendingRelocations: ReadonlyArray = + relocation === undefined ? [] : ["relocate-parent"]; + const relocationsAtFork: Array> = []; + // The thread locks the service runs under, so a test can take the parent's lock itself. + let locks: ThreadCommandExecutor.ThreadCommandExecutor["Service"] | undefined; + const threadLocks = Effect.suspend(() => + locks === undefined + ? Effect.die("the service has not built its thread locks") + : Effect.succeed(locks), + ); + // A move commits under the source thread's lock and leaves a relocation to settle. + const commitMove = Effect.flatMap(threadLocks, (held) => + held.withLock( + parentThreadId, + Effect.sync(() => { + pendingRelocations = [...pendingRelocations, "relocate-parent-again"]; + }), + ), + ); + // Whether the service held the parent's lock at each relocation check and at the fork. + const parentLockedAt: Array = []; + const recordParentLock = (step: "check" | "fork") => + Effect.map( + Effect.flatMap(threadLocks, (held) => held.activeKeys), + (lockedThreads) => { + parentLockedAt.push([step, lockedThreads.includes(parentThreadId)]); + }, + ); if (input.sideChat !== undefined) { const earlierRunId = RunId.make("side-chat-earlier-run"); projection = { @@ -993,7 +1072,10 @@ function makeLocalCommandHarness(input: { }, }; const forkThread = vi.fn((_fork: ProviderAdapter.ProviderAdapterV2ForkThreadInput) => - Effect.succeed(sideChatThread), + Effect.map(recordParentLock("fork"), () => { + relocationsAtFork.push(pendingRelocations); + return sideChatThread; + }), ); const sideChatSession = { driver: providerThread.driver, @@ -1036,6 +1118,7 @@ function makeLocalCommandHarness(input: { providerSession, ensureThread: () => Effect.succeed(providerThread), resumeThread: () => Effect.succeed(projection.providerThreads[0]), + startTurn, } as never) : Effect.die("A local command must not open a native session."), ); @@ -1144,7 +1227,15 @@ function makeLocalCommandHarness(input: { ), }), ); - const layer = ProviderTurnStart.layer.pipe( + const layer = Layer.merge( + ProviderTurnStart.layer, + // The service provides itself this same layer, and one build memoizes it into one lock. + Layer.effectDiscard( + Effect.map(ThreadCommandExecutor.ThreadCommandExecutor, (shared) => { + locks = shared; + }), + ).pipe(Layer.provide(ThreadCommandExecutor.layer)), + ).pipe( Layer.provide( Layer.mergeAll( Layer.mock(ContextHandoffService.ContextHandoffServiceV2)({ @@ -1172,6 +1263,32 @@ function makeLocalCommandHarness(input: { return []; }), }), + Layer.mock(EventStore.EventStoreV2)({ read: readEvents }), + Layer.mock(EffectOutbox.EffectOutboxV2)({ + unsettledEffectIds: () => Effect.map(recordParentLock("check"), () => pendingRelocations), + awaitSettled: (effectId) => { + const settle = (relocated: Deferred.Deferred, nativeStorageFolder: string) => + Deferred.await(relocated).pipe( + Effect.map(() => { + pendingRelocations = pendingRelocations.filter((id) => id !== effectId); + parentRecords = { + ...parentRecords, + providerThreads: [{ ...parentProviderThread, nativeStorageFolder }], + }; + }), + ); + if (effectId === "relocate-parent-again") { + return Deferred.succeed(relocationAgainAwaited, undefined).pipe( + Effect.andThen(settle(relocationAgain, "/repo/side-chat-parent-moved-again")), + ); + } + return relocation === undefined + ? Effect.void + : Deferred.succeed(relocationAwaited, undefined).pipe( + Effect.andThen(settle(relocation, "/repo/side-chat-parent-moved")), + ); + }, + }), IdAllocator.layer, Layer.mock(TurnItemPositionStore.TurnItemPositionStoreV2)({}), layerWorktreeLocation(input.worktreesDir), @@ -1256,6 +1373,13 @@ function makeLocalCommandHarness(input: { forkBaseline, startTurn, forkThread, + readEvents, + relocationAwaited, + relocationAgain, + relocationAgainAwaited, + commitMove, + relocationsAtFork, + parentLockedAt, interruptTurn, tryHandlePromptCommand, events, @@ -1726,6 +1850,93 @@ effectIt.effect.each( }), ); +effectIt.effect.each([ + { folder: "/repo/side-chat-parent", eventReads: 1 }, + { sideChat: { parentRunCwd: "/repo/parent-run" }, folder: "/repo/parent-run" }, + { relocation: "settles", folder: "/repo/side-chat-parent-moved", recorded: true }, + { + relocation: "settles", + moveDuringWait: true, + folder: "/repo/side-chat-parent-moved-again", + recorded: true, + }, + { driver: "codex", relocation: "settles" }, + { + sideChat: { parentStorageFolder: "/repo/parent-recorded" }, + folder: "/repo/parent-recorded", + recorded: true, + }, + { relocation: "hangs", willRetry: true }, + { relocation: "hangs" }, +])("forks a Claude parent from its session's storage folder: %j", (row) => + Effect.gen(function* () { + const settled = yield* Deferred.make(); + const harness = makeLocalCommandHarness({ + text: "What is the main thread doing?", + driver: ProviderDriverKind.make(row.driver ?? "claudeAgent"), + sideChat: { + contextRun: "current", + nativeForkFrom: "idle", + ...row.sideChat, + ...(row.relocation === undefined ? {} : { relocation: settled }), + }, + }); + + const start = yield* Effect.forkChild( + Effect.exit(row.willRetry === true ? harness.startWithRetry : harness.start), + ); + if (row.relocation !== undefined) { + yield* Effect.race(Deferred.await(harness.relocationAwaited), Fiber.await(start)); + expect(harness.relocationsAtFork).toEqual( + row.driver === "codex" ? [["relocate-parent"]] : [], + ); + // The wait holds no lock, so a move on the source thread commits meanwhile. + if (row.moveDuringWait === true) yield* harness.commitMove; + yield* row.relocation === "settles" + ? Deferred.succeed(settled, undefined) + : TestClock.adjust("30 seconds"); + if (row.moveDuringWait === true) { + // Under the lock the fork finds the new move and waits for that one too. + yield* Deferred.await(harness.relocationAgainAwaited); + expect(harness.forkThread).not.toHaveBeenCalled(); + yield* Deferred.succeed(harness.relocationAgain, undefined); + } + } + + const hangs = row.relocation === "hangs"; + expect((yield* Fiber.join(start))._tag).toBe(row.willRetry === true ? "Failure" : "Success"); + expect( + harness.forkThread.mock.calls.map(([fork]) => [ + fork.sourceNativeStorageFolder, + fork.sourceProviderThread.nativeStorageFolder, + ]), + ).toEqual(hangs ? [] : [[row.folder, row.recorded === true ? row.folder : undefined]]); + expect(harness.relocationsAtFork.at(-1)).toEqual( + hangs ? undefined : row.driver === "codex" ? ["relocate-parent"] : [], + ); + if (!hangs && row.driver !== "codex") { + // Moves commit under the source's lock, so none lands between the last check and the fork. + expect(harness.parentLockedAt.slice(-2)).toEqual([ + ["check", true], + ["fork", true], + ]); + } + expect(harness.readEvents).toHaveBeenCalledTimes(row.eventReads ?? 0); + expect(harness.projection().runs.at(-1)?.status).toBe( + hangs ? (row.willRetry === true ? "starting" : "failed") : "running", + ); + expect( + harness + .projection() + .turnItems.flatMap((item) => (item.type === "error" ? [item.failure.message] : [])), + ).toEqual( + hangs && row.willRetry !== true + ? ["The source thread's Claude session is still being moved. Try again in a moment."] + : [], + ); + }), +); + effectIt.effect.each([ { runStatus: "failed", turnStatus: "completed" }, { runStatus: "completed", turnStatus: "completed" }, @@ -1743,6 +1954,68 @@ effectIt.effect.each([ }), ); +effectIt.effect.each([ + { text: "Continue", line: true }, + { text: "/goal ship it", line: false }, + { text: "Continue", restartContinuation: true, line: false }, + { text: "Continue", moved: true, line: true }, +])("tells the model the working folder changed: %j", ({ text, line, restartContinuation, moved }) => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text, + previousTurn: { runStatus: "completed", turnStatus: "completed" }, + }); + const projection = harness.projection(); + const [previous, current] = projection.runs; + harness.setProjection({ + ...projection, + runs: [ + moved + ? { ...previous!, cwd: "/tmp/folder-b", status: "rolled_back" } + : { ...previous!, cwd: "/tmp/folder-a" }, + restartContinuation ? { ...current!, restartContinuationOfRunId: previous!.id } : current!, + ], + attempts: [ + ...projection.attempts, + { ...projection.attempts[0]!, id: previous!.activeAttemptId!, runId: previous!.id }, + ], + }); + if (moved) { + const receipts = ["/tmp/folder-a", "/tmp/folder-b"].map((previousFolder, index) => + moveReceiptTurnItem({ + id: TurnItemId.make(`move-receipt-${index}`), + threadId: projection.thread.id, + ordinal: index + 1, + movedAt: DateTime.makeUnsafe(0), + previousFolder, + message: "Moved.", + }), + ); + harness.getThreadRecords.mockImplementation(() => + Effect.succeed({ ...harness.projection(), turnItems: receipts }), + ); + } + harness.tryHandlePromptCommand.mockReturnValue(Effect.succeed(false)); + + yield* harness.start; + const turn = harness.startRootRun.mock.calls[0]![0]; + yield* turn.session.startTurn(turn as never); + + expect( + harness.getThreadRecords.mock.calls.some(([, fields]) => fields.includes("turnItems")), + ).toBe(moved === true); + expect(harness.startTurn.mock.calls.map(([sent]) => sent.message.text)).toEqual([ + withInlineContext( + line + ? 'The working folder changed from "/tmp/folder-a" to "/tmp/native-account-command".' + : "", + text, + ), + ]); + expect(harness.projection().messages.at(-1)?.text).toBe(text); + }), +); + effectIt.effect("leaves delegated children to their own runs when the parent starts a turn", () => Effect.gen(function* () { const harness = makeLocalCommandHarness({ diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 672120494cf2e..fd4ecec1fc39a 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -33,15 +33,19 @@ import { import { withTurnStartReservation, withWorkspaceLease } from "../workspace/workspaceLease.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as ProjectService from "../project/ProjectService.ts"; +import { previousFolderAfter } from "../project/ThreadMoveReceipt.ts"; import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; import { findRepositoryRoot } from "../provider/Drivers/ClaudeSkills.ts"; import * as ServerSettings from "../serverSettings.ts"; import { makeRestoreMissingWorktree } from "../workspace/restoreMissingWorktree.ts"; +import * as EffectOutbox from "./EffectOutbox.ts"; import * as EventSink from "./EventSink.ts"; +import * as EventStore from "./EventStore.ts"; import * as ProviderUsageAnalytics from "../telemetry/ProviderUsageAnalytics.ts"; import * as ContextHandoffService from "./ContextHandoffService.ts"; import { rootCheckpointScopeName } from "./CheckpointService.ts"; import { isActiveRun } from "./CommandPolicy.ts"; +import { nativeStorageFolders } from "./NativeStorageFolder.ts"; import { DEFAULT_HANDOFF_TOKEN_CAP, handoffTokenCapConfig, @@ -53,11 +57,13 @@ import { latestNativeContextUsage, } from "@t3tools/provider-core/server/handoffBudget"; import { deliverContextHandoffs, withInlineContext } from "./ContextHandoffDelivery.ts"; +import { folderChangeNote, latestCountedRunFolder } from "./FolderChangeNotice.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; import { unsentManualContinuationSource } from "./manualContinuation.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import { ContextHandoffBackgroundWorkError, knownProviderFailureMessage, @@ -135,7 +141,9 @@ export class ProviderTurnStartServiceV2 extends Context.Service< export const layer: Layer.Layer< ProviderTurnStartServiceV2, never, + | EffectOutbox.EffectOutboxV2 | EventSink.EventSinkV2 + | EventStore.EventStoreV2 | ContextHandoffService.ContextHandoffServiceV2 | IdAllocator.IdAllocatorV2 | FileSystem.FileSystem @@ -153,7 +161,9 @@ export const layer: Layer.Layer< > = Layer.effect( ProviderTurnStartServiceV2, Effect.gen(function* () { + const outbox = yield* EffectOutbox.EffectOutboxV2; const eventSink = yield* EventSink.EventSinkV2; + const eventStore = yield* EventStore.EventStoreV2; const turnAnalytics = yield* ProviderUsageAnalytics.ProviderTurnAnalytics; const contextHandoffService = yield* ContextHandoffService.ContextHandoffServiceV2; const idAllocator = yield* IdAllocator.IdAllocatorV2; @@ -163,11 +173,33 @@ export const layer: Layer.Layer< const providerAuth = yield* ProviderAuthService.ProviderAuthService; const projectionStore = yield* ProjectionStore.ProjectionStoreV2; const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; + const threadLocks = yield* ThreadCommandExecutor.ThreadCommandExecutor; const runExecution = yield* RunExecutionService.RunExecutionServiceV2; const runtimePolicy = yield* RuntimePolicy.RuntimePolicyV2; const restoreMissingWorktree = yield* makeRestoreMissingWorktree; const turnItemPositions = yield* TurnItemPositionStore.TurnItemPositionStoreV2; + const readNativeStorageFolder = Effect.fn("ProviderTurnStartService.readNativeStorageFolder")( + function* ( + threadId: ThreadId, + providerThread: OrchestrationV2ProviderThread, + history: Pick[2], "runs" | "attempts">, + ) { + const folders = yield* nativeStorageFolders(eventStore, threadId, { + ...history, + providerThreads: [providerThread], + }).pipe( + Effect.catch((cause) => + Effect.logWarning("orchestration-v2.native-storage-folder-read-failed", { + threadId, + cause, + }).pipe(Effect.as(undefined)), + ), + ); + return folders?.get(providerThread.id); + }, + ); + // Build draining callbacks outside startup scope so they cannot retain full thread history. const makeRunControls = (input: { readonly threadId: ThreadId; @@ -824,6 +856,24 @@ export const layer: Layer.Layer< restartCancelledWork.length === 0 ? "" : restartCancelledBackgroundWorkNote(restartCancelledWork); + const folderNote = + resolvedRuntimePolicy.cwd === null || + run.restartContinuationOfRunId !== undefined || + isCompactionCommandMessage(message) || + goalCommand + ? "" + : yield* folderChangeNote( + fileSystem, + latestCountedRunFolder(projection) ?? + previousFolderAfter( + (yield* projectionStore.getThreadRecords(projection.thread.id, ["turnItems"], { + turnItemTypes: ["system_notice"], + turnItemRunIds: [null], + })).turnItems, + 0, + ), + resolvedRuntimePolicy.cwd, + ); const tokenCap = yield* handoffTokenCapConfig.pipe( Effect.orElseSucceed(() => DEFAULT_HANDOFF_TOKEN_CAP), ); @@ -1066,7 +1116,7 @@ export const layer: Layer.Layer< let resumedHandoffContext: ReturnType | undefined; let portableForkFallback: OrchestrationV2ContextHandoff | undefined; let recoveryTurnItem: OrchestrationV2TurnItem | undefined; - const loadedProviderThread = yield* Effect.gen(function* () { + const loadProviderThread = Effect.gen(function* () { if (nativeForkTransfer !== undefined) { const sourceProjection = yield* projectionStore.getThreadRecords( nativeForkTransfer.sourceThreadId, @@ -1110,9 +1160,15 @@ export const layer: Layer.Layer< ) .map((attempt) => attempt.id), ); + const sourceNativeStorageFolder = yield* readNativeStorageFolder( + nativeForkTransfer.sourceThreadId, + sourceProviderThread, + sourceProjection, + ); const forked = yield* Effect.result( session.forkThread({ sourceProviderThread, + ...(sourceNativeStorageFolder === undefined ? {} : { sourceNativeStorageFolder }), sourceProviderTurns: sourceProjection.providerTurns.filter( (turn) => turn.runAttemptId === null || !rolledBackAttemptIds.has(turn.runAttemptId), @@ -1467,6 +1523,49 @@ export const layer: Layer.Layer< recoveryTurnItem = handoffTurnItem; return replacement; }); + // A move relocates the source's Claude transcript before its row records the new folder. + // The wait holds no lock, so the source thread keeps taking commands meanwhile. Moves + // commit under the source's lock, so a recheck under it sees any move that landed during + // the wait, and none lands between that recheck and the fork. + const loadForkedProviderThread = Effect.fn( + "ProviderTurnStartService.loadForkedProviderThread", + )(function* (sourceThreadId: ThreadId) { + const unsettledRelocations = outbox.unsettledEffectIds({ + threadIds: [sourceThreadId], + effectType: "provider-session.relocate-storage", + }); + while (true) { + const settled = yield* Effect.forEach(yield* unsettledRelocations, outbox.awaitSettled, { + discard: true, + }).pipe(Effect.timeoutOption("30 seconds")); + if (Option.isNone(settled)) { + const error = new ProviderTurnStartError({ + runId, + cause: + "The source thread's Claude session is still being moved. Try again in a moment.", + }); + if (input.willRetry === true) return yield* error; + yield* settleStartFailure({ + signal: "provider-thread-load-failure", + title: "Provider turn failed to start", + error, + }); + return undefined; + } + const loaded = yield* threadLocks.withLock( + sourceThreadId, + Effect.gen(function* () { + if ((yield* unsettledRelocations).length > 0) return "relocating" as const; + return yield* loadProviderThread; + }), + ); + if (loaded !== "relocating") return loaded; + } + }); + const loadedProviderThread = yield* nativeForkTransfer === undefined || + session.driver !== "claudeAgent" + ? loadProviderThread + : loadForkedProviderThread(nativeForkTransfer.sourceThreadId); if (loadedProviderThread === undefined) return; if (!(yield* isCurrentAttemptInStatus("starting"))) { return; @@ -1714,7 +1813,7 @@ export const layer: Layer.Layer< sideChatHandoff === undefined || compact || goalCommand ? "" : sideChatHandoff.summaryText; - const context = [delivery.context, sideChatContext, restartNote] + const context = [delivery.context, sideChatContext, restartNote, folderNote] .filter((part) => part !== "") .join("\n\n"); const { restartContinuationOfRunId: _resumedRunId, ...promptedInput } = turnInput; @@ -1793,6 +1892,7 @@ export const layer: Layer.Layer< retryHandoff.length === 0 && sideChatHandoff === undefined && restartNote === "" && + folderNote === "" && !noteContinuation ? session : makeDeliverySession(session, startWithHandoffs); @@ -1833,6 +1933,13 @@ export const layer: Layer.Layer< hasUnpairedRunInterruptRequest: runControls.hasUnpairedRunInterruptRequest, loadRunInterruptReason: runControls.loadRunInterruptReason, hasWorkspaceMoved: runControls.hasWorkspaceMoved, + ...(runningProviderThread.nativeStorageFolder === undefined + ? { + readNativeStorageFolder: yield* Effect.cached( + readNativeStorageFolder(projection.thread.id, runningProviderThread, projection), + ), + } + : {}), message: { messageId: message.id, text: userText, @@ -1885,4 +1992,4 @@ export const layer: Layer.Layer< ), }); }), -); +).pipe(Layer.provide(ThreadCommandExecutor.layer)); diff --git a/apps/server/src/orchestration-v2/ResourceCleanupService.ts b/apps/server/src/orchestration-v2/ResourceCleanupService.ts index fd45ecac667ef..9026518b6f0af 100644 --- a/apps/server/src/orchestration-v2/ResourceCleanupService.ts +++ b/apps/server/src/orchestration-v2/ResourceCleanupService.ts @@ -1,4 +1,4 @@ -import { ThreadId } from "@t3tools/contracts"; +import { type ProjectId, ThreadId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -25,6 +25,10 @@ export class ResourceCleanupError extends Schema.TaggedError Effect.Effect; + readonly moveScheduledTasks: (input: { + readonly threadIds: ReadonlyArray; + readonly projectId: ProjectId; + }) => Effect.Effect; readonly cleanupTerminals: (threadId: string) => Effect.Effect; readonly cleanupPreviews: (threadId: string) => Effect.Effect; /** Releases the thread's device claims, as `device_close` does; the devices stay booted. */ @@ -35,6 +39,7 @@ export class ResourceCleanupService extends Context.Reference<{ }>("t3/orchestration-v2/ResourceCleanupService", { defaultValue: () => ({ cleanupScheduledTasks: () => Effect.void, + moveScheduledTasks: () => Effect.void, cleanupTerminals: () => Effect.void, cleanupPreviews: () => Effect.void, cleanupDevices: () => Effect.void, @@ -64,6 +69,12 @@ export const layer = Layer.effect( ), ); }), + moveScheduledTasks: Effect.fn("ResourceCleanupService.moveScheduledTasks")( + (input) => scheduledTasks.moveThreadTasks(input), + Effect.mapError( + (cause) => new ResourceCleanupError({ operation: "scheduled-tasks", cause }), + ), + ), cleanupTerminals: Effect.fn("ResourceCleanupService.cleanupTerminals")(function ( threadId: string, ) { diff --git a/apps/server/src/orchestration-v2/RunFinalizationQueue.test.ts b/apps/server/src/orchestration-v2/RunFinalizationQueue.test.ts index 3ca71f384eba1..995f097999eda 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationQueue.test.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationQueue.test.ts @@ -1,4 +1,6 @@ import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { CheckpointScopeId, CommandId, GitCommandError, RunId, ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -15,6 +17,7 @@ import * as CheckpointCapture from "./CheckpointCaptureService.ts"; import * as CheckpointRollback from "./CheckpointRollbackService.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; import * as EffectWorker from "./EffectWorker.ts"; +import * as EventSink from "./EventSink.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderSessions from "./ProviderSessionManager.ts"; import * as ProviderTurnControl from "./ProviderTurnControlService.ts"; @@ -97,6 +100,9 @@ it.effect.each([ Layer.mock(ThreadManagement.ThreadManagementService)({ recoverDelegatedTask: () => Effect.void, }), + Layer.mock(EventSink.EventSinkV2)({}), + IdAllocator.layer, + NodeServices.layer, Layer.mock(ProjectionStore.ProjectionStoreV2)({}), ServerSettings.layerTest(), Layer.mock(ProviderTurnStart.ProviderTurnStartServiceV2)({ diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index a6aad880cd88a..9885d0c91f51a 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -410,6 +410,7 @@ export interface ThreadManagementServiceShape { readonly itemId: TurnItemId; }) => Effect.Effect; readonly getThreadRecords: Orchestrator.OrchestratorV2["Service"]["getThreadRecords"]; + readonly threadHasBackgroundWork: Orchestrator.OrchestratorV2["Service"]["threadHasBackgroundWork"]; readonly getThreadProjection: ( threadId: ThreadId, ) => Effect.Effect; @@ -1721,6 +1722,7 @@ const make = Effect.gen(function* () { ); }, ), + threadHasBackgroundWork: orchestrator.threadHasBackgroundWork, getThreadProjection, getCheckpointContext, getThreadSnapshot, diff --git a/apps/server/src/orchestration-v2/TurnItemPositionStore.test.ts b/apps/server/src/orchestration-v2/TurnItemPositionStore.test.ts index ea3a70bdf22af..e9fcc1b03c582 100644 --- a/apps/server/src/orchestration-v2/TurnItemPositionStore.test.ts +++ b/apps/server/src/orchestration-v2/TurnItemPositionStore.test.ts @@ -54,6 +54,18 @@ it.effect("reuses an item's position without attempting another allocation inser yield* positions.allocate({ ...input, threadId: ThreadId.make("thread:other") }), 1, ); + + const inRun = yield* positions.allocate({ + ...input, + turnItemId: TurnItemId.make("item:in-run"), + runId: RunId.make("run:second"), + runOrdinal: 2, + }); + const afterRun = yield* positions.allocate({ + ...input, + turnItemId: TurnItemId.make("item:after-run"), + }); + assert.equal(afterRun, inRun + 1); }).pipe(Effect.provide(layerTest)), ); diff --git a/apps/server/src/orchestration-v2/TurnItemPositionStore.ts b/apps/server/src/orchestration-v2/TurnItemPositionStore.ts index 95eb9cdc2415f..207520abe5c1a 100644 --- a/apps/server/src/orchestration-v2/TurnItemPositionStore.ts +++ b/apps/server/src/orchestration-v2/TurnItemPositionStore.ts @@ -70,7 +70,8 @@ export const layer: Layer.Layer( layerThreadTitleRegenerationTest, layerServerSettings, layerThreadManagementProvided, + layerEventSinkProvided, + IdAllocator.layer, layerStores, ), ), diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 965e5bd44f2dc..579a2b7fc98c1 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -86,6 +86,7 @@ interface CapturedTurn { readonly nativeThreadId?: string | null; readonly providerTurnOrdinal?: number; readonly nativeThreadHasTurns?: boolean; + readonly nativeStorageFolder?: string; } function unimplemented(driver: ProviderDriverKind, detail: string) { @@ -276,6 +277,9 @@ function makeTestAdapter(input: { input.refusalCause ?? new ClaudeBackgroundWorkBlocksQueryReplacementError(), }); yield* Effect.yieldNow; + const nativeStorageFolder = yield* ( + turnInput.readNativeStorageFolder ?? Effect.undefined + ); yield* Ref.update(input.capturedTurns, (turns) => [ ...turns, { @@ -290,6 +294,7 @@ function makeTestAdapter(input: { ...(turnInput.nativeThreadHasTurns === undefined ? {} : { nativeThreadHasTurns: turnInput.nativeThreadHasTurns }), + ...(nativeStorageFolder === undefined ? {} : { nativeStorageFolder }), }, ]); const providerTurnId = ProviderTurnId.make( @@ -1374,6 +1379,8 @@ describe("orchestration v2 provider switching", () => { (attempts) => Effect.gen(function* () { const cwd = yield* checkpointWorkspace("resume-fallback-native-turns"); + const laterCwd = yield* checkpointWorkspace("resume-fallback-native-turns-later"); + const override = { cwd, approvalPolicy: "never", sandboxPolicy: { type: "readOnly" } }; const capturedTurns = yield* Ref.make>([]); const failResumeOnce = yield* Ref.make(false); const generation = yield* Ref.make(0); @@ -1464,6 +1471,7 @@ describe("orchestration v2 provider switching", () => { } yield* Ref.set(failResumeOnce, true); yield* runToCompletion(2); + override.cwd = laterCwd; yield* runToCompletion(3); const turns = yield* Ref.get(capturedTurns); @@ -1474,6 +1482,8 @@ describe("orchestration v2 provider switching", () => { assert.isFalse(turns[1]?.nativeThreadHasTurns); assert.equal(turns[2]?.nativeThreadId, turns[1]?.nativeThreadId); assert.isTrue(turns[2]?.nativeThreadHasTurns); + // Run 2 created the replacement session there; run 3 ran in the later folder. + assert.equal(turns[2]?.nativeStorageFolder, cwd); const projection = yield* orchestrator.getThreadProjection(threadId); const run2 = projection.runs[1]!; @@ -1491,14 +1501,7 @@ describe("orchestration v2 provider switching", () => { }).pipe( Effect.provide( ProviderReplayHarness.layerWithRegistry( - { - name: "resume-fallback-native-turns", - runtimePolicyOverride: { - cwd, - approvalPolicy: "never", - sandboxPolicy: { type: "readOnly" }, - }, - }, + { name: "resume-fallback-native-turns", runtimePolicyOverride: override }, layerRegistry, ), ), @@ -2373,7 +2376,11 @@ describe("orchestration v2 provider switching", () => { }), ); - it.live("resumes a queued account switch without requiring a portable handoff", () => + it.live.each([ + { folder: "the source's folder", source: "/repo/created-here", target: undefined }, + // The target row recorded a session of its own earlier; the resumed session's folder is unknown. + { folder: "no folder", source: undefined, target: "/repo/recorded-earlier" }, + ])("resumes a queued account switch without a handoff, carrying $folder", ({ source, target }) => Effect.gen(function* () { const cwd = yield* checkpointWorkspace("queued-account-switch"); const capturedTurns = yield* Ref.make>([]); @@ -2492,6 +2499,18 @@ describe("orchestration v2 provider switching", () => { const now = yield* DateTime.now; yield* eventSink.write({ events: [ + ...[source, target].map((nativeStorageFolder, index) => ({ + id: EventId.make(`event:queued-account-switch:storage-folder-${index}`), + type: "provider-thread.updated" as const, + threadId: queuedThreadId, + occurredAt: now, + payload: { + ...queued.providerThreads.find( + (providerThread) => providerThread.id === queued.runs[index]?.providerThreadId, + )!, + nativeStorageFolder, + }, + })), { id: EventId.make("event:queued-account-switch:first-complete"), type: "run.updated", @@ -2515,10 +2534,11 @@ describe("orchestration v2 provider switching", () => { ); yield* worker.drain(); const delivered = yield* orchestrator.getThreadProjection(queuedThreadId); - const targetNativeRef = delivered.providerThreads.find( + const targetProviderThread = delivered.providerThreads.find( (providerThread) => providerThread.id === delivered.runs[1]?.providerThreadId, - )?.nativeThreadRef; - assert.deepEqual(targetNativeRef, sourceNativeRef); + ); + assert.deepEqual(targetProviderThread?.nativeThreadRef, sourceNativeRef); + assert.equal(targetProviderThread?.nativeStorageFolder, source); return delivered; }).pipe( Effect.provide( diff --git a/apps/server/src/project/ProjectLock.ts b/apps/server/src/project/ProjectLock.ts new file mode 100644 index 0000000000000..111baaa4dcaf8 --- /dev/null +++ b/apps/server/src/project/ProjectLock.ts @@ -0,0 +1,10 @@ +import type { ProjectId } from "@t3tools/contracts"; +import * as KeyedLock from "@t3tools/shared/KeyedLock"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; + +export class ProjectLock extends Context.Service>()( + "t3/project/ProjectLock", +) {} + +export const layer = Layer.effect(ProjectLock, KeyedLock.make()); diff --git a/apps/server/src/project/ProjectService.deletion.test.ts b/apps/server/src/project/ProjectService.deletion.test.ts index 25e5ee54a2737..8938fe7a8a317 100644 --- a/apps/server/src/project/ProjectService.deletion.test.ts +++ b/apps/server/src/project/ProjectService.deletion.test.ts @@ -33,6 +33,7 @@ import * as ServerSettings from "../serverSettings.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; +import * as ProjectLock from "./ProjectLock.ts"; import * as ProjectService from "./ProjectService.ts"; import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; @@ -45,6 +46,7 @@ const layerServices = Layer.mergeAll( ProjectStore.layer, IdAllocator.layer, ThreadCommandExecutor.layer, + ProjectLock.layer, Layer.succeed(WorkspacePaths.WorkspacePaths, { normalizeWorkspaceRoot: (workspaceRoot) => Effect.succeed(workspaceRoot), resolveRelativePathWithinRoot: ({ workspaceRoot, relativePath }) => diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index 492379beecc26..a307bb3320ffa 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -36,6 +36,7 @@ import * as ServerSettings from "../serverSettings.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; +import * as ProjectLock from "./ProjectLock.ts"; import * as ProjectService from "./ProjectService.ts"; import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts"; @@ -201,6 +202,7 @@ const layerProjectServiceDependencies = Layer.mergeAll( ProjectionStore.layer, IdAllocator.layer, ThreadCommandExecutor.layer, + ProjectLock.layer, ).pipe( Layer.provideMerge(LegacyV1ThreadImporter.layer.pipe(Layer.provide(RuntimeLayer.layerEventSink))), Layer.provideMerge(ProjectEnrichmentService.layer), diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 4bdc8386438ca..80a8257fd3099 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -37,6 +37,7 @@ import { deletedProjectOverridePatch } from "../provider/ProjectSettingsOverride import { makeIsManagedWorktreePath } from "../worktreesDirectory.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; +import * as ProjectLock from "./ProjectLock.ts"; import { makeIsWorktreeContainer } from "./worktreeContainer.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -188,7 +189,7 @@ export const make = Effect.gen(function* () { const isWorktreeContainer = yield* makeIsWorktreeContainer.pipe( Effect.provide(ProcessRunner.layer), ); - const projectLocks = yield* KeyedLock.make(); + const projectLocks = yield* ProjectLock.ProjectLock; const workspaceLocks = yield* KeyedLock.make(); const toProject = ( @@ -304,14 +305,16 @@ export const make = Effect.gen(function* () { error: encodeProjectCommandRejection(planned.failure), }); }); - const receipt = yield* projectLocks - .withLock( - projectId, - workspaceRoot === undefined - ? planAndCommit - : workspaceLocks.withLock(workspaceRoot, planAndCommit), - ) - .pipe(Effect.mapError(dispatchError)); + const receipt = yield* ( + command.type === "project.delete" + ? planAndCommit + : projectLocks.withLock( + projectId, + workspaceRoot === undefined + ? planAndCommit + : workspaceLocks.withLock(workspaceRoot, planAndCommit), + ) + ).pipe(Effect.mapError(dispatchError)); if (receipt.projectId !== projectId || receipt.commandType !== command.type) { return yield* dispatchError( `Command ${command.commandId} was already used by ${receipt.commandType} for ${receipt.projectId}.`, @@ -578,10 +581,15 @@ export const make = Effect.gen(function* () { } const isFirstDelete = existing.value.deletedAt === null; - if (isFirstDelete) { - yield* deleteChildThreads(input); - } - yield* commit({ type: "project.delete", commandId: input.commandId, projectId }); + yield* projectLocks.withLock( + projectId, + Effect.gen(function* () { + if (isFirstDelete) { + yield* deleteChildThreads(input); + } + yield* commit({ type: "project.delete", commandId: input.commandId, projectId }); + }), + ); if (isFirstDelete) { yield* clearProjectSettingsOverrides(projectId); } @@ -677,4 +685,5 @@ export const make = Effect.gen(function* () { export const layer = Layer.effect(ProjectService, make).pipe( Layer.provide(ThreadCommandExecutor.layer), + Layer.provide(ProjectLock.layer), ); diff --git a/apps/server/src/project/ThreadMove.testkit.ts b/apps/server/src/project/ThreadMove.testkit.ts new file mode 100644 index 0000000000000..a1f68d84fecda --- /dev/null +++ b/apps/server/src/project/ThreadMove.testkit.ts @@ -0,0 +1,175 @@ +import { expect } from "@effect/vitest"; +import { + EventId, + MessageId, + type NodeId, + type OrchestrationV2AppThread, + type OrchestrationV2ProviderSession, + type OrchestrationV2ProviderThread, + type ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ProviderSessionId, + ProviderThreadId, + type RunId, + type ThreadId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; + +import { idleThreadProjection, liveThreadShell } from "../mcp/McpToolAccess.testkit.ts"; +import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; + +export const createdAt = DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"); +export const codexDriver = ProviderDriverKind.make("codex"); +export const claudeDriver = ProviderDriverKind.make("claudeAgent"); + +export function threadCreated(input: { + readonly threadId: ThreadId; + readonly projectId: ProjectId; + readonly worktreePath: string | null; + readonly providerInstanceId?: ProviderInstanceId | undefined; + readonly parent?: ThreadId | undefined; + readonly kind?: "fork" | "side" | "subagent" | undefined; + readonly branch?: string | null | undefined; + readonly branchPullRequest?: OrchestrationV2AppThread["branchPullRequest"]; +}) { + const { threadId } = input; + const providerInstanceId = input.providerInstanceId ?? ProviderInstanceId.make("codex"); + const payload: OrchestrationV2AppThread = { + ...idleThreadProjection(liveThreadShell(threadId)).thread, + projectId: input.projectId, + providerInstanceId, + modelSelection: { instanceId: providerInstanceId, model: "gpt-5" }, + branch: input.branch ?? null, + worktreePath: input.worktreePath, + ...(input.branchPullRequest === undefined + ? {} + : { branchPullRequest: input.branchPullRequest }), + lineage: { + parentThreadId: input.parent ?? null, + relationshipToParent: input.kind === "side" ? "fork" : (input.kind ?? null), + rootThreadId: input.parent ?? threadId, + ...(input.kind === "side" ? { sideChat: true } : {}), + }, + }; + return { + id: EventId.make(`created:${threadId}`), + type: "thread.created" as const, + threadId, + providerInstanceId, + occurredAt: createdAt, + payload, + }; +} + +/** An idle Claude provider thread holding the native session `nativeId`. */ +export function providerThread(input: { + readonly id: string; + readonly threadId: ThreadId; + readonly providerInstanceId: ProviderInstanceId; + readonly nativeId: string; + readonly nativeStorageFolder?: string | undefined; +}): OrchestrationV2ProviderThread { + return { + id: ProviderThreadId.make(input.id), + driver: claudeDriver, + providerInstanceId: input.providerInstanceId, + providerSessionId: null, + appThreadId: input.threadId, + ownerNodeId: null, + nativeThreadRef: { driver: claudeDriver, nativeId: input.nativeId, strength: "strong" }, + nativeConversationHeadRef: null, + ...(input.nativeStorageFolder === undefined + ? {} + : { nativeStorageFolder: input.nativeStorageFolder }), + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + pendingBackgroundTasks: [], + contextUsage: null, + nativeMetadata: null, + goal: null, + createdAt, + updatedAt: createdAt, + }; +} + +export function codexSession(input: { + readonly id: string; + readonly status: OrchestrationV2ProviderSession["status"]; + readonly cwd: string; +}): OrchestrationV2ProviderSession { + return { + id: ProviderSessionId.make(input.id), + driver: codexDriver, + providerInstanceId: ProviderInstanceId.make("codex"), + status: input.status, + cwd: input.cwd, + model: null, + capabilities: CodexProviderCapabilitiesV2, + createdAt, + updatedAt: createdAt, + lastError: null, + }; +} + +export function runCreated(input: { + readonly id: RunId; + readonly threadId: ThreadId; + readonly ordinal: number; + readonly providerInstanceId: ProviderInstanceId; + readonly providerThreadId?: ProviderThreadId | null | undefined; + readonly rootNodeId?: NodeId | null | undefined; + readonly cwd?: string | undefined; + readonly occurredAt?: DateTime.Utc | undefined; +}) { + const occurredAt = input.occurredAt ?? createdAt; + return { + id: EventId.make(`run-created:${input.id}`), + type: "run.created" as const, + threadId: input.threadId, + providerInstanceId: input.providerInstanceId, + occurredAt, + payload: { + id: input.id, + threadId: input.threadId, + ordinal: input.ordinal, + providerInstanceId: input.providerInstanceId, + modelSelection: { instanceId: input.providerInstanceId, model: "gpt-5" }, + providerThreadId: input.providerThreadId ?? null, + userMessageId: MessageId.make(`message:${input.id}`), + rootNodeId: input.rootNodeId ?? null, + activeAttemptId: null, + status: "completed" as const, + requestedAt: occurredAt, + startedAt: occurredAt, + completedAt: occurredAt, + checkpointId: null, + contextHandoffId: null, + ...(input.cwd === undefined ? {} : { cwd: input.cwd }), + }, + }; +} + +export const namedProject = (id: string) => ({ projectId: id, title: id }); +export const namedThread = (id: string) => ({ threadId: id, title: id }); +export const refusal = (reason?: string | RegExp, named?: Record) => ({ + _tag: "refused", + ...(typeof reason === "string" ? { reason: expect.stringContaining(reason) } : {}), + ...(reason instanceof RegExp ? { reason: expect.stringMatching(reason) } : {}), + ...named, +}); +export const member = (id: string, reason?: string | RegExp) => + refusal(reason, { thread: namedThread(id) }); +export const moved = (folder: string, members: ReadonlyArray = []) => ({ + _tag: "moved", + folder, + members: members.map(namedThread), +}); +export const needsProject = (folder: string, nestedIn?: string) => ({ + _tag: "needs-project", + folder, + ...(nestedIn === undefined ? {} : { nestedIn: namedProject(nestedIn) }), +}); diff --git a/apps/server/src/project/ThreadMoveDestination.test.ts b/apps/server/src/project/ThreadMoveDestination.test.ts new file mode 100644 index 0000000000000..efe1bdb7b3aa5 --- /dev/null +++ b/apps/server/src/project/ThreadMoveDestination.test.ts @@ -0,0 +1,529 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + type Project, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + ThreadMoveError, + type ThreadMoveInput, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; +import { + member, + moved, + namedProject, + namedThread, + needsProject, + refusal, + threadCreated, +} from "./ThreadMove.testkit.ts"; +import { + collectMoveSet, + decideThreadMove, + type ThreadMoveDecisionInput, + type ThreadMoveProbes, + type ThreadMoveProviderInstance, + type ThreadMoveShell, +} from "./ThreadMoveDestination.ts"; + +const project = (id: string, workspaceRoot: string, extra?: Partial): Project => ({ + id: ProjectId.make(id), + title: id, + workspaceRoot, + repositoryIdentity: null, + faviconPath: null, + projectIcon: null, + defaultModelSelection: null, + defaultThreadEnvMode: null, + autoPull: false, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + ...extra, +}); + +const thread = ( + id: string, + projectId: string, + worktreePath: string | null, + extra?: { + readonly parent?: string; + readonly kind?: "fork" | "side" | "subagent"; + readonly deleted?: boolean; + }, +): ThreadMoveShell => ({ + ...threadCreated({ + threadId: ThreadId.make(id), + projectId: ProjectId.make(projectId), + worktreePath, + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + parent: extra?.parent === undefined ? undefined : ThreadId.make(extra.parent), + kind: extra?.kind, + }).payload, + deletedAt: extra?.deleted === true ? DateTime.makeUnsafe(0) : null, +}); + +const provider = ( + driver: string, + version: string | null, + supportsFolderMove: boolean, + configDir?: string, +): ThreadMoveProviderInstance => ({ + driver: ProviderDriverKind.make(driver), + capabilities: { sessions: { ...CodexProviderCapabilitiesV2.sessions, supportsFolderMove } }, + version, + environment: configDir === undefined ? {} : { CLAUDE_CONFIG_DIR: configDir }, +}); +const claude = (version: string | null, configDir?: string) => + provider("claudeAgent", version, true, configDir); +const instances = ( + ...entries: ReadonlyArray +): ReadonlyMap => + new Map(entries.map(([id, entry]) => [ProviderInstanceId.make(id), entry])); +const claudeAt = (version: string | null, configDir?: string) => ({ + providerInstances: instances(["claudeAgent", claude(version, configDir)]), +}); + +const makeWorld = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.realPath( + yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-thread-move-" }), + ); + const at = (...segments: ReadonlyArray) => path.join(root, ...segments); + for (const folder of [ + "projA/nested projB dup archived deleted scratch/thread scratch/other scratch/promoted", + "worktree/sub worktree/proot jjws/.jj jjws/sub jjdefault/.jj/repo jjdefault/sub", + "main/.git/worktrees/wt main/.git/modules/worktrees/lib main/worktrees/lib srv/worktrees/main", + "separate locked-git locked-jj/.jj locked-root locked-parent/child managed/repo/branch/sub plain W PW/nested", + ].flatMap((group) => group.split(" "))) { + yield* fileSystem.makeDirectory(at(folder), { recursive: true }); + } + yield* fileSystem.writeFileString(at("worktree", ".git"), "gitdir: ../main/.git/worktrees/wt\n"); + yield* fileSystem.writeFileString(at("main", ".git", "worktrees", "wt", "commondir"), "../..\n"); + yield* fileSystem.writeFileString( + at("main", "worktrees", "lib", ".git"), + "gitdir: ../../.git/modules/worktrees/lib\n", + ); + yield* fileSystem.writeFileString( + at("separate", ".git"), + `gitdir: ${at("srv", "worktrees", "main")}\n`, + ); + yield* fileSystem.writeFileString(at("jjws", ".jj", "repo"), "/elsewhere/.jj/repo\n"); + yield* fileSystem.writeFileString(at("locked-git", ".git"), ""); + yield* fileSystem.chmod(at("locked-git", ".git"), 0o000); + yield* fileSystem.chmod(at("locked-jj", ".jj"), 0o000); + yield* fileSystem.chmod(at("locked-root"), 0o000); + yield* fileSystem.chmod(at("locked-parent"), 0o000); + // The temp folder removal needs to list locked-parent, so unlock it first. + yield* Effect.addFinalizer(() => Effect.ignore(fileSystem.chmod(at("locked-parent"), 0o700))); + yield* fileSystem.writeFileString(at("afile"), ""); + yield* fileSystem.symlink(at("projB"), at("link-B")); + yield* fileSystem.symlink(at("W"), at("link-W")); + yield* fileSystem.symlink(at("scratch"), at("link-scratch")); + const projects = [ + project("A", at("projA")), + project("B", at("link-B")), + project("No project", at("scratch")), + project("Promoted", at("scratch", "promoted")), + project("Archived", at("archived"), { archivedAt: "2026-01-02T00:00:00.000Z" }), + project("Deleted", at("deleted"), { deletedAt: "2026-01-02T00:00:00.000Z" }), + project("Dup1", at("dup")), + project("Dup2", at("dup")), + project("InWorktree", at("worktree", "proot")), + project("P", at("PW")), + project("Locked", at("locked-root")), + ]; + const threads = [ + thread("t1", "A", null), + thread("t1-side", "A", null, { parent: "t1", kind: "side" }), + thread("t1-sub", "A", null, { parent: "t1", kind: "subagent" }), + thread("t1-fork", "A", null, { parent: "t1", kind: "fork" }), + thread("t1-side-sub", "A", null, { parent: "t1-side", kind: "subagent" }), + thread("t2", "A", at("W")), + thread("t2-side", "A", at("W"), { parent: "t2", kind: "side" }), + thread("t2-fork", "A", at("W"), { parent: "t2", kind: "fork" }), + thread("picker", "B", at("W")), + thread("linked", "B", at("link-W")), + thread("gone", "B", at("W"), { deleted: true }), + thread("t3", "A", at("PW")), + thread("p-root", "P", null), + thread("tn", "No project", at("scratch", "thread")), + ]; + return { root, at, projects, threads }; +}); +type World = Effect.Success; +type Options = Partial & { + readonly probes?: Partial>; +}; +type Setup = Options | ((world: World) => Options); +type Records = Effect.Success>; + +const request = (threadId: string, destination: string | null) => ({ + threadId: ThreadId.make(threadId), + destination, +}); + +const idle = { runs: [], runtimeRequests: [] }; +const running = { runs: [{ status: "running" }], runtimeRequests: [] }; +const pending = { runs: [], runtimeRequests: [{ status: "pending" }] }; +const busy = Effect.succeed(true); +const probeFailed = Effect.fail(new ThreadMoveError({ message: "probe failed" })); + +const decide = (world: World, input: ThreadMoveInput, setup: Setup = {}) => + Effect.gen(function* () { + const { probes, ...overrides } = typeof setup === "function" ? setup(world) : setup; + return yield* decideThreadMove( + { + request: input, + threads: world.threads, + projects: world.projects, + providerInstances: instances( + ["claudeAgent", claude("2.1.223")], + ["codex", provider("codex", "1.0.0", true)], + ["cursor", provider("cursor", "1.0.0", false)], + ), + settings: { providerInstances: {}, projectSettingsOverrides: {} }, + scratchRoot: world.at("scratch"), + managedWorktreeDirectories: [world.at("managed")], + home: world.root, + ...overrides, + }, + { + fileSystem: yield* FileSystem.FileSystem, + path: yield* Path.Path, + threadRecords: () => Effect.succeed(idle), + hasBackgroundWork: () => Effect.succeed(false), + ...probes, + }, + ); + }); + +const onlyFor = + (threadId: string, value: Effect.Effect, otherwise: A) => + (id: ThreadId) => + id === threadId ? value : Effect.succeed(otherwise); +const records = (threadId: string, value: Records) => ({ + probes: { threadRecords: onlyFor(threadId, Effect.succeed(value), idle) }, +}); +const background = (threadId: string, value: Effect.Effect) => ({ + probes: { hasBackgroundWork: onlyFor(threadId, value, false) }, +}); +const patch = (world: World, threadId: string, fields: Partial) => ({ + threads: world.threads.map((shell) => (shell.id === threadId ? { ...shell, ...fields } : shell)), +}); + +const named = (...ids: ReadonlyArray) => ({ projects: ids.map(namedProject) }); +const worktree = refusal("inside a worktree"); +const movesTo = (projectId: string, tag = "project") => ({ + _tag: "move", + destination: { _tag: tag, project: expect.objectContaining({ id: projectId }) }, + leaving: null, +}); +const failure = (target: string) => ({ + _tag: "ThreadMoveError", + path: target, + message: expect.stringContaining(target), +}); + +it.layer(NodeServices.layer)("decideThreadMove", (it) => { + it.effect.each< + readonly [string, string | (() => string), object | ((folder: string) => object), string?] + >([ + ["rule 1: own folder moves with no members", "projA", moved], + ["rule 1: own worktree changes nothing, asks nothing", "W", moved, "t2"], + ["rule 1: own folder rooting project P", "PW", refusal("no folder change", named("P")), "t3"], + ["rule 2: the scratch root points to /no", "scratch", refusal("/no")], + ["rule 3: a project root", "projB", movesTo("B")], + ["rule 3: a relative path starts at the thread's folder", () => "../projB", movesTo("B")], + ["rule 3: ~ expands to the home folder", () => "~/projB", movesTo("B")], + ["rule 3: a symlink spelling of the root", "link-B", movesTo("B")], + ["rule 3: a promoted project", "scratch/promoted", movesTo("Promoted")], + ["rule 3: a root inside a worktree", "worktree/proot", movesTo("InWorktree")], + ["rule 3: two matches are named", "dup", refusal('"Dup1", "Dup2"', named("Dup1", "Dup2"))], + ["rule 3: archived match", "archived", refusal("Unarchive", named("Archived"))], + ["rule 3: an unreadable root is refused", "locked-root", refusal(/^Cannot read `.*`\.$/)], + ["rule 4: scratch folder, /no only", "scratch/other", refusal(/^(?!.*Turn into project)/)], + ["rule 4: from No project, Turn into too", "scratch/other", refusal("Turn into project"), "tn"], + ["rule 5: a folder in a linked git worktree", "worktree/sub", worktree], + ["rule 5: the linked worktree itself", "worktree", worktree], + ["rule 5: a folder in a jj workspace", "jjws/sub", worktree], + ["rule 5: the managed worktrees directory itself", "managed", worktree], + ["rule 5: under a managed worktrees directory", "managed/repo/branch/sub", worktree], + ["rule 5: unreadable .git fails, naming it", "locked-git", (f) => failure(`${f}/.git`)], + ["rule 5: unreadable .jj/repo fails, named", "locked-jj", (f) => failure(`${f}/.jj/repo`)], + ["rule 6: a folder with no project needs one", "plain", needsProject], + ["rule 6: a subfolder of A names A", "projA/nested", (folder) => needsProject(folder, "A")], + ["rule 6: a submodule under worktrees/ needs one", "main/worktrees/lib", needsProject], + ["rule 6: a checkout whose git dir is under worktrees/", "separate", needsProject], + ["rule 6: a deleted project's root needs a project", "deleted", needsProject], + ["rule 6: a folder in a default jj workspace needs one", "jjdefault/sub", needsProject], + ["a missing folder is refused", "missing", refusal(/^No folder at `.*missing`\.$/)], + [ + "a folder under an unreadable parent is refused, not reported missing", + "locked-parent/child", + refusal(/^Cannot read `.*locked-parent\/child`\.$/), + ], + ["a file is refused", "afile", refusal("No folder at `")], + ["a side chat is refused, naming its parent", "projB", member("t1", "side chat"), "t1-side"], + ["a subagent is refused, naming its parent", "projB", member("t1", "subagent"), "t1-sub"], + ])("/cd %s", ([, typed, expected, threadId = "t1"]) => + Effect.gen(function* () { + const world = yield* makeWorld; + const folder = typeof typed === "string" ? world.at(typed) : typed(); + const result = yield* Effect.catch(decide(world, request(threadId, folder)), Effect.succeed); + expect(result).toMatchObject(typeof expected === "function" ? expected(folder) : expected); + }), + ); + + it.effect("a project root that fails to resolve fails the decision", () => + Effect.gen(function* () { + const world = yield* makeWorld; + const error = yield* decide(world, request("t1", world.at("plain")), { + projects: [...world.projects, project("Unresolved", world.at("afile", "root"))], + }).pipe(Effect.flip); + expect(error).toMatchObject(failure(world.at("afile", "root"))); + }), + ); + + const competingScratch = (world: World) => ({ + projects: [...world.projects, project("No project 2", world.at("link-scratch"))], + }); + const scratchTwins = named("No project", "No project 2"); + + it.effect.each([ + ["refuses a No project thread", "tn", competingScratch, refusal("already in No project")], + ["refuses without scratch folders", "t1", { scratchRoot: undefined }, refusal("unavailable")], + ["names both scratch projects", "t1", competingScratch, refusal(undefined, scratchTwins)], + [ + "asks to unarchive an archived No project", + "t1", + (world) => ({ scratchRoot: world.at("archived") }), + refusal("Unarchive", named("Archived")), + ], + ["moves a root thread at once", "t1", {}, movesTo("No project", "scratch")], + ["asks leaving a worktree", "t2", {}, { _tag: "confirm", to: null, wayBack: "add-project" }], + ])("/no %s", ([, threadId, setup, expected]) => + Effect.gen(function* () { + const world = yield* makeWorld; + expect(yield* decide(world, request(threadId, null), setup)).toMatchObject(expected); + }), + ); + + it.effect("the move set holds members at any depth and stops at a listed fork", () => + Effect.gen(function* () { + const world = yield* makeWorld; + const members = collectMoveSet(ThreadId.make("t1"), world.threads).map(({ id }) => id); + expect(members.toSorted()).toEqual(["t1", "t1-side", "t1-side-sub", "t1-sub"]); + const result = yield* decide(world, request("t1", world.at("projB"))); + expect(result._tag === "move" ? result.members.map(({ id }) => id) : []).toEqual(members); + }), + ); + + type EligibilityCase = readonly [string, Setup, object]; + + it.effect.each([ + [ + "a running turn", + records("t1-sub", running), + member("t1-sub", /^"t1-sub" is still working\. Try again when the turn ends\.$/), + ], + [ + "the thread's own running turn, without its title", + records("t1", running), + member("t1", /^This thread is still working\. Try again when the turn ends\.$/), + ], + ["a pending request", records("t1-side", pending), member("t1-side")], + ["background work", background("t1-side", busy), member("t1-side", "background work")], + ["a failing probe", background("t1-side", probeFailed), member("t1-side", "background work")], + [ + "another folder", + (world) => patch(world, "t1-sub", { worktreePath: world.at("W") }), + member("t1-sub", "another folder"), + ], + [ + "a driver without supportsFolderMove", + (world) => patch(world, "t1-sub", { providerInstanceId: ProviderInstanceId.make("cursor") }), + member("t1-sub", "does not support a folder move"), + ], + [ + "a provider instance the destination project disables", + { + settings: { + providerInstances: {}, + projectSettingsOverrides: { + [ProjectId.make("B")]: { + providerInstances: { [ProviderInstanceId.make("claudeAgent")]: { enabled: false } }, + }, + }, + }, + }, + member("t1", "disables"), + ], + ...["", " ", " /config", ".claude-config"].map((configDir): EligibilityCase => [ + `CLAUDE_CONFIG_DIR ${JSON.stringify(configDir)}`, + claudeAt("2.1.223", configDir), + member("t1", `CLAUDE_CONFIG_DIR ("${configDir}") is not an absolute path`), + ]), + ["nothing when CLAUDE_CONFIG_DIR is absolute", claudeAt("2.1.223", "/config"), movesTo("B")], + [ + "Claude Code 2.1.222, naming it", + claudeAt("2.1.222"), + member("t1", /2\.1\.222.*Refresh the providers/), + ], + ["nothing for a newer Claude Code", claudeAt("2.2.0"), movesTo("B")], + ["an unparsed Claude version", claudeAt("garbage"), member("t1", "Claude Code garbage")], + ["no Claude version", claudeAt(null), member("t1", "unknown version")], + [ + "the member on the older of two Claude instances", + (world) => ({ + ...patch(world, "t1-side", { providerInstanceId: ProviderInstanceId.make("claude-two") }), + providerInstances: instances( + ["claudeAgent", claude("2.1.223")], + ["claude-two", claude("2.1.222")], + ), + settings: { + providerInstances: { + [ProviderInstanceId.make("claude-two")]: { + driver: ProviderDriverKind.make("claudeAgent"), + }, + }, + projectSettingsOverrides: {}, + }, + }), + member("t1-side", "2.1.222"), + ], + [ + "nothing for a Codex member", + (world) => patch(world, "t1-sub", { providerInstanceId: ProviderInstanceId.make("codex") }), + movesTo("B"), + ], + ])("eligibility refuses %s", ([, setup, expected]) => + Effect.gen(function* () { + const world = yield* makeWorld; + expect(yield* decide(world, request("t1", world.at("projB")), setup)).toMatchObject(expected); + }), + ); + + it.effect.each object, string?]>([ + [ + "leaving a worktree names the threads that stay in it, not the side chat that moves", + "t2", + (at) => ({ + _tag: "confirm", + from: at("W"), + to: at("link-B"), + resolvedFolders: { from: at("W"), to: at("projB") }, + staysOnDisk: `The folder ${at("W")} stays on disk with all its files until you remove it.`, + wayBack: "add-project", + otherThreads: ["t2-fork", "picker", "linked"].map(namedThread), + }), + ], + [ + "leaving a project's root worktree names the project, and /cd brings it back", + "t3", + (at) => ({ + _tag: "confirm", + from: at("PW"), + wayBack: "cd", + project: namedProject("P"), + otherThreads: [], + }), + ], + [ + "leaving a scratch folder has no way back", + "tn", + (at) => ({ _tag: "confirm", from: at("scratch/thread"), wayBack: "none", otherThreads: [] }), + ], + [ + "a removed folder no longer exists", + "t2", + () => ({ + _tag: "confirm", + staysOnDisk: "The folder no longer exists.", + otherThreads: ["t2-fork", "picker"].map(namedThread), + }), + "W", + ], + ])("confirm: %s", ([, threadId, expected, removed]) => + Effect.gen(function* () { + const world = yield* makeWorld; + const fileSystem = yield* FileSystem.FileSystem; + if (removed) yield* fileSystem.remove(world.at(removed), { recursive: true }); + const input = request(threadId, world.at("projB")); + const result = yield* decide(world, input); + expect(result).toMatchObject(expected(world.at)); + const { _tag, ...leaving } = result as Extract; + const confirmed = yield* decide(world, { ...input, confirm: true }); + expect(confirmed).toEqual(expect.objectContaining({ _tag: "move", leaving })); + }), + ); + + it.effect.each< + readonly [string, string, (at: World["at"]) => string, (world: World) => Options, string?] + >([ + [ + "the thread left the folder in the question", + "t3", + (at) => at("projB"), + (world) => patch(world, "t3", { worktreePath: world.at("W") }), + ], + [ + "the typed path resolves to another folder", + "p-root", + () => "nested", + (world) => patch(world, "p-root", { projectId: ProjectId.make("A") }), + ], + [ + "the thread changed project while the new-project question was open", + "p-root", + (at) => at("plain"), + (world) => patch(world, "p-root", { projectId: ProjectId.make("A") }), + ], + [ + "a symlink in the question changed its target", + "t3", + (at) => at("link-B"), + () => ({}), + "link-B", + ], + ])("an answered question is refused when %s", ([, threadId, typed, change, relinked]) => + Effect.gen(function* () { + const world = yield* makeWorld; + const fileSystem = yield* FileSystem.FileSystem; + const input = request(threadId, typed(world.at)); + const question = yield* decide(world, input); + const answer: ThreadMoveInput = + question._tag === "confirm" + ? { ...input, confirm: true, approved: question.resolvedFolders } + : question._tag === "needs-project" + ? { ...input, addProject: true, approved: question.resolvedFolders } + : input; + expect(yield* decide(world, answer)).toMatchObject({ _tag: "move" }); + if (relinked) { + yield* fileSystem.remove(world.at(relinked)); + yield* fileSystem.symlink(world.at("plain"), world.at(relinked)); + } + expect(yield* decide(world, answer, change)).toMatchObject( + refusal("changed while you were deciding"), + ); + }), + ); + + it.effect("from a worktree to a folder with no project, refuses a busy member first", () => + Effect.gen(function* () { + const world = yield* makeWorld; + const busyMember = records("t2-side", running); + const result = yield* decide(world, request("t2", world.at("plain")), busyMember); + expect(result).toMatchObject(member("t2-side")); + }), + ); +}); diff --git a/apps/server/src/project/ThreadMoveDestination.ts b/apps/server/src/project/ThreadMoveDestination.ts new file mode 100644 index 0000000000000..905bc4d68d789 --- /dev/null +++ b/apps/server/src/project/ThreadMoveDestination.ts @@ -0,0 +1,380 @@ +import { + isListedThreadLineage, + type OrchestrationV2ProviderCapabilities, + type OrchestrationV2ThreadShell, + type Project, + ProviderDriverKind, + type ProviderInstanceId, + resolveProjectProviderInstanceEnabled, + type ServerSettings, + type ThreadId, + ThreadMoveError, + type ThreadMoveInput, + type ThreadMoveResult, +} from "@t3tools/contracts"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; +import * as Effect from "effect/Effect"; +import type * as FileSystem from "effect/FileSystem"; +import type * as Path from "effect/Path"; +import type * as PlatformError from "effect/PlatformError"; + +import { claudeConfigDirRefusal } from "../orchestration-v2/ClaudeSessionRelocation.ts"; +import { threadHasUnfinishedWork } from "../orchestration-v2/Orchestrator.ts"; +import { isWithinRoot } from "../workspace/KnownWorkspaces.ts"; + +type Refused = Extract; +type Confirm = Extract; + +export type ThreadMoveShell = Pick< + OrchestrationV2ThreadShell, + "id" | "title" | "projectId" | "worktreePath" | "providerInstanceId" | "lineage" | "deletedAt" +>; + +export interface ThreadMoveProviderInstance { + readonly driver: ProviderDriverKind; + readonly capabilities: Pick; + readonly version: string | null; + readonly environment: { readonly CLAUDE_CONFIG_DIR?: string | undefined }; +} + +export interface ThreadMoveProbes { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly threadRecords: ( + threadId: ThreadId, + ) => Effect.Effect[0], ThreadMoveError>; + readonly hasBackgroundWork: (threadId: ThreadId) => Effect.Effect; +} + +export interface ThreadMoveDecisionInput { + readonly request: ThreadMoveInput; + readonly threads: ReadonlyArray; + readonly projects: ReadonlyArray; + readonly providerInstances: ReadonlyMap; + readonly settings: Pick; + readonly scratchRoot: string | undefined; + readonly managedWorktreeDirectories: ReadonlyArray; + readonly home: string; +} + +const CLAUDE_DRIVER = ProviderDriverKind.make("claudeAgent"); +// Claude Code resumes a session by id from any folder only from this version on. +const CLAUDE_MIN_VERSION = "2.1.223"; + +export const refused = (reason: string, named?: Pick): Refused => ({ + _tag: "refused", + reason, + ...named, +}); +const namedProject = (project: Project) => ({ projectId: project.id, title: project.title }); +const namedThread = (thread: ThreadMoveShell) => ({ threadId: thread.id, title: thread.title }); +const quote = (title: string) => `"${title}"`; +/** Backticks mark a path, so the client shows it as code on its own line. */ +const code = (folder: string) => `\`${folder}\``; + +const failure = (verb: string, target: string) => (cause: unknown) => + new ThreadMoveError({ + stage: "check", + path: target, + message: `Could not ${verb} ${target}.`, + cause, + }); +const notFound = (error: PlatformError.PlatformError) => error.reason._tag === "NotFound"; +const kindOf = (fileSystem: FileSystem.FileSystem, target: string) => + fileSystem.stat(target).pipe( + Effect.map((info) => info.type), + Effect.catchIf(notFound, () => Effect.succeed(null)), + Effect.mapError(failure("read", target)), + ); + +const matchProject = (folder: string, matches: ReadonlyArray) => { + if (matches.length > 1) { + return refused( + `More than one project has its root at ${code(folder)}: ${matches.map((project) => quote(project.title)).join(", ")}.`, + { projects: matches.map(namedProject) }, + ); + } + const match = matches[0]; + return match !== undefined && match.archivedAt != null + ? refused( + `The archived project ${quote(match.title)} has its root at ${code(folder)}. Unarchive it to move the thread there.`, + { projects: [namedProject(match)] }, + ) + : match; +}; + +export function collectMoveSet( + threadId: ThreadId, + threads: ReadonlyArray, +): ReadonlyArray { + const members = threads.filter((thread) => thread.id === threadId && thread.deletedAt === null); + for (const member of members) { + members.push( + ...threads.filter( + (child) => + child.deletedAt === null && + child.lineage.parentThreadId === member.id && + !isListedThreadLineage(child.lineage), + ), + ); + } + return members; +} + +export const decideThreadMove = Effect.fn("ThreadMoveDestination.decideThreadMove")(function* ( + input: ThreadMoveDecisionInput, + probes: ThreadMoveProbes, +) { + const { fileSystem, path } = probes; + const { request } = input; + const threads = input.threads.filter((thread) => thread.deletedAt === null); + const projects = input.projects.filter((project) => project.deletedAt === null); + const folderOf = (shell: ThreadMoveShell) => + shell.worktreePath ?? projects.find((project) => project.id === shell.projectId)?.workspaceRoot; + const realPaths = new Map(); + const resolve = Effect.fnUntraced(function* (target: string) { + const known = realPaths.get(target); + if (known !== undefined) return known; + const real = yield* fileSystem.realPath(target).pipe( + Effect.catchIf(notFound, () => Effect.succeed(path.resolve(target))), + Effect.mapError(failure("resolve", target)), + ); + realPaths.set(target, real); + return real; + }); + + const thread = threads.find((candidate) => candidate.id === request.threadId); + if (thread === undefined) return refused("Thread not found."); + if (!isListedThreadLineage(thread.lineage)) { + const parent = threads.find((candidate) => candidate.id === thread.lineage.parentThreadId); + return refused( + `This ${thread.lineage.sideChat === true ? "side chat" : "subagent"} moves with its parent${parent === undefined ? "" : ` ${quote(parent.title)}`}.`, + parent === undefined ? undefined : { thread: namedThread(parent) }, + ); + } + const currentFolder = folderOf(thread); + if (currentFolder === undefined) return refused("This thread's project no longer exists."); + const threadFolder = yield* resolve(currentFolder); + const scratchRoot = + input.scratchRoot === undefined ? undefined : yield* resolve(input.scratchRoot); + const roots = yield* Effect.forEach(projects, (project) => + resolve(project.workspaceRoot).pipe(Effect.map((root) => ({ project, root }))), + ); + const projectsAt = (folder: string) => + roots.filter((entry) => entry.root === folder).map((entry) => entry.project); + const inNoProject = + scratchRoot !== undefined && + projectsAt(scratchRoot).some((project) => project.id === thread.projectId); + + const destination = yield* Effect.gen(function* () { + if (request.destination === null) { + if (scratchRoot === undefined) { + return refused("Scratch folders are unavailable on this server."); + } + if (inNoProject) return refused("This thread is already in No project."); + // `project` is No project, undefined when it does not exist yet. + const project = matchProject(scratchRoot, projectsAt(scratchRoot)); + return project !== undefined && "_tag" in project + ? project + : { _tag: "scratch" as const, project }; + } + const target = path.resolve(currentFolder, expandHomePath(request.destination, input.home)); + // Only a missing path is "no folder"; an ancestor without search permission is "cannot read". + const folder = yield* fileSystem.realPath(target).pipe( + Effect.catchIf(notFound, () => Effect.succeed(null)), + Effect.orElseSucceed(() => undefined), + ); + if (folder === undefined) return refused(`Cannot read ${code(target)}.`); + if (folder === null || (yield* kindOf(fileSystem, folder)) !== "Directory") { + return refused(`No folder at ${code(target)}.`); + } + if (folder === threadFolder) { + const others = projectsAt(folder).filter( + (project) => project.id !== thread.projectId && project.archivedAt == null, + ); + return others.length > 0 + ? refused( + `This thread already works in ${code(folder)}. A move to another project with no folder change is not supported.`, + { projects: others.map(namedProject) }, + ) + : { _tag: "moved" as const, folder: currentFolder, members: [] }; + } + if (folder === scratchRoot) { + return refused( + `The scratch root is at ${code(folder)}. Use /no to move the thread to a new scratch folder.`, + ); + } + // Resolving `folder/.` needs search permission on the folder and the probe then needs read. + const readable = yield* fileSystem + .access(`${folder}${path.sep}.`, { readable: true }) + .pipe(Effect.isSuccess); + if (!readable) return refused(`Cannot read ${code(folder)}.`); + const project = matchProject(folder, projectsAt(folder)); + if (project !== undefined) { + return "_tag" in project ? project : { _tag: "project" as const, project }; + } + if (scratchRoot !== undefined && isWithinRoot(path, folder, scratchRoot)) { + return refused( + `The folder ${code(folder)} is a scratch folder. Use /no to move the thread to a new scratch folder${inNoProject ? ", or Turn into project to keep this one" : ""}.`, + ); + } + const worktreeRefusal = refused( + `The folder ${code(folder)} is inside a worktree. Only a worktree that is a project root can be a destination.`, + ); + for (const directory of input.managedWorktreeDirectories) { + if (isWithinRoot(path, folder, yield* resolve(directory))) return worktreeRefusal; + } + // A linked worktree's git dir holds `commondir`; a submodule's or a separate main git dir + // does not. `.jj/repo` is a folder in a default jj workspace and a file in the others. + for (let directory = folder; ; directory = path.dirname(directory)) { + const gitPath = path.join(directory, ".git"); + if ((yield* kindOf(fileSystem, gitPath)) === "File") { + const pointer = yield* fileSystem + .readFileString(gitPath) + .pipe(Effect.mapError(failure("read", gitPath))); + const gitdir = /^\s*gitdir:\s*(.+?)\s*$/m.exec(pointer)?.[1]; + if ( + gitdir !== undefined && + (yield* kindOf(fileSystem, path.join(path.resolve(directory, gitdir), "commondir"))) !== + null + ) { + return worktreeRefusal; + } + } + if ((yield* kindOf(fileSystem, path.join(directory, ".jj", "repo"))) === "File") { + return worktreeRefusal; + } + if (path.dirname(directory) === directory) break; + } + const nestedIn = roots + .filter((entry) => isWithinRoot(path, folder, entry.root)) + .toSorted((left, right) => right.root.length - left.root.length)[0]?.project; + return { _tag: "new-project" as const, folder, nestedIn }; + }); + if (destination._tag === "refused" || destination._tag === "moved") return destination; + + // An answer sends these back as `approved`. They are resolved because a symlink in a path the + // question shows can change its target while the question is open. + const resolvedFolders = { + from: threadFolder, + to: + destination._tag === "scratch" + ? null + : destination._tag === "project" + ? yield* resolve(destination.project.workspaceRoot) + : destination.folder, + }; + const { approved } = request; + if ( + approved !== undefined && + ((approved.from !== undefined && approved.from !== resolvedFolders.from) || + approved.to !== resolvedFolders.to) + ) { + return refused("The thread or its destination changed while you were deciding. Try again."); + } + + const members = collectMoveSet(thread.id, threads); + const destinationProjectId = + destination._tag === "new-project" ? null : (destination.project?.id ?? null); + for (const member of members) { + // The user is looking at the named thread, so only a subagent or a side chat needs its title. + const title = member.id === thread.id ? "This thread" : quote(member.title); + const memberFolder = folderOf(member); + const instance = input.providerInstances.get(member.providerInstanceId); + const isClaude = instance?.driver === CLAUDE_DRIVER; + const version = instance?.version ?? null; + const configDirRefusal = isClaude + ? claudeConfigDirRefusal(path, instance.environment) + : undefined; + const checks = [ + [ + () => Effect.map(probes.threadRecords(member.id), threadHasUnfinishedWork), + `${title} is still working. Try again when the turn ends.`, + ], + [ + () => + probes.hasBackgroundWork(member.id).pipe(Effect.catchCause(() => Effect.succeed(true))), + `${title} still has background work running.`, + ], + [ + () => + memberFolder === undefined + ? Effect.succeed(true) + : Effect.map(resolve(memberFolder), (folder) => folder !== threadFolder), + `${title} works in another folder${memberFolder === undefined ? "" : `, ${code(memberFolder)}`}.`, + ], + [ + instance?.capabilities.sessions.supportsFolderMove !== true, + `${title} uses ${instance?.driver ?? member.providerInstanceId}, which does not support a folder move.`, + ], + [ + isClaude && + (version === null || + parseSemver(version) === null || + compareSemverVersions(version, CLAUDE_MIN_VERSION) < 0), + `${title} uses Claude Code ${version ?? "of unknown version"}, and a folder move needs ${CLAUDE_MIN_VERSION} or newer. Refresh the providers to check again.`, + ], + [ + !resolveProjectProviderInstanceEnabled( + input.settings, + destinationProjectId, + member.providerInstanceId, + ), + `${title} uses ${member.providerInstanceId}, which the destination project disables.`, + ], + [configDirRefusal !== undefined, `${title} uses a Claude instance whose ${configDirRefusal}`], + ] as const; + for (const [when, reason] of checks) { + if (typeof when === "boolean" ? when : yield* when()) { + return refused(reason, { thread: namedThread(member) }); + } + } + } + if (destination._tag === "new-project" && request.addProject !== true) { + return { + _tag: "needs-project" as const, + folder: destination.folder, + ...(destination.nestedIn && { nestedIn: namedProject(destination.nestedIn) }), + resolvedFolders, + }; + } + const from = thread.worktreePath; + if (from === null) return { _tag: "move" as const, destination, members, leaving: null }; + const exists = yield* fileSystem.exists(from).pipe(Effect.orElseSucceed(() => true)); + const atFrom = projectsAt(threadFolder); + const project = atFrom.find((candidate) => candidate.archivedAt == null) ?? atFrom[0]; + const memberIds = new Set(members.map((member) => member.id)); + const otherThreads = (yield* Effect.filter(threads, (other) => { + const folder = folderOf(other); + return folder === undefined || + memberIds.has(other.id) || + (project !== undefined && other.worktreePath === null && other.projectId === project.id) + ? Effect.succeed(false) + : Effect.map(resolve(folder), (resolved) => resolved === threadFolder); + })).map(namedThread); + const leaving: Omit = { + from, + to: + destination._tag === "project" + ? destination.project.workspaceRoot + : destination._tag === "new-project" + ? destination.folder + : null, + staysOnDisk: exists + ? `The folder ${from} stays on disk with all its files until you remove it.` + : "The folder no longer exists.", + wayBack: + project !== undefined && project.archivedAt == null + ? "cd" + : scratchRoot !== undefined && isWithinRoot(path, threadFolder, scratchRoot) + ? "none" + : "add-project", + ...(project === undefined ? {} : { project: namedProject(project) }), + otherThreads, + resolvedFolders, + }; + return request.confirm === true + ? { _tag: "move" as const, destination, members, leaving } + : { _tag: "confirm" as const, ...leaving }; +}); diff --git a/apps/server/src/project/ThreadMovePlan.test.ts b/apps/server/src/project/ThreadMovePlan.test.ts new file mode 100644 index 0000000000000..180f08855e126 --- /dev/null +++ b/apps/server/src/project/ThreadMovePlan.test.ts @@ -0,0 +1,295 @@ +import { assert, expect, it } from "@effect/vitest"; +import { + CommandId, + type OrchestrationV2DomainEvent, + type OrchestrationV2ProviderSession, + type OrchestrationV2ThreadProjection, + ProjectId, + ProviderInstanceId, + ProviderThreadId, + RunId, + ThreadId, +} from "@t3tools/contracts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; + +import { applyToProjection, emptyProjection } from "../orchestration-v2/ProjectionStore.ts"; +import { codexSession, providerThread, runCreated, threadCreated } from "./ThreadMove.testkit.ts"; +import { planThreadMove, type ThreadMoveMember } from "./ThreadMovePlan.ts"; +import type { ThreadMoveReceiptInput } from "./ThreadMoveReceipt.ts"; + +const movedAt = DateTime.makeUnsafe("2026-09-04T00:00:00.000Z"); +const projectA = ProjectId.make("project:a"); +const projectB = ProjectId.make("project:b"); +const codex = ProviderInstanceId.make("codex"); +const claude = ProviderInstanceId.make("claudeAgent"); + +function projection(input: { + readonly id: string; + readonly worktreePath: string | null; + readonly branch?: string | null; + readonly pullRequest?: { readonly number: number; readonly url: string } | null; + readonly sessions?: ReadonlyArray; + readonly claudeSession?: string; + readonly runs?: ReadonlyArray<{ readonly id: string; readonly cwd?: string }>; +}): OrchestrationV2ThreadProjection { + const threadId = ThreadId.make(input.id); + return { + ...emptyProjection( + threadCreated({ + threadId, + projectId: projectA, + worktreePath: input.worktreePath, + providerInstanceId: input.claudeSession === undefined ? codex : claude, + branch: input.branch, + branchPullRequest: input.pullRequest + ? { projectId: projectA, repository: "o/r", ...input.pullRequest } + : undefined, + }), + ), + providerThreads: + input.claudeSession === undefined + ? [] + : [ + providerThread({ + id: `provider-thread:${input.id}`, + threadId, + providerInstanceId: claude, + nativeId: input.claudeSession, + }), + ], + providerSessions: (input.sessions ?? []).map((status) => + codexSession({ + id: `session:${input.id}:${status}`, + status, + cwd: input.worktreePath ?? "/work/a", + }), + ), + runs: (input.runs ?? []).map( + (run, index) => + runCreated({ + id: RunId.make(run.id), + threadId, + ordinal: index + 1, + providerInstanceId: codex, + cwd: run.cwd, + }).payload, + ), + }; +} + +const receipt: ThreadMoveReceiptInput = { + folder: "/work/b", + previousFolder: "/work/a", + project: "B", + previousProject: "A", + members: ["side"], + clearedBranch: null, + pullRequest: null, + leftFolder: null, +}; + +const plan = Effect.fn("ThreadMovePlanTest.plan")(function* (input: { + readonly members: ReadonlyArray; + readonly worktreePath?: string | null; + readonly folder?: string; + readonly folderChanged?: boolean; +}) { + return yield* planThreadMove({ + command: { + type: "thread.project.move", + commandId: CommandId.make("command:move"), + threadId: input.members[0]?.projection.thread.id ?? ThreadId.make("missing"), + projectId: projectB, + worktreePath: input.worktreePath ?? null, + }, + members: input.members, + folder: input.folder ?? "/work/b", + folderChanged: input.folderChanged ?? true, + receipt, + now: movedAt, + idAllocator: yield* IdAllocator.IdAllocatorV2, + }); +}); + +type EventOf = Event extends { readonly type: infer Type } + ? T extends Type + ? Event + : never + : never; +const ofType = ( + events: ReadonlyArray, + type: T, +) => events.filter((event): event is EventOf => event.type === type); +const noticeOf = (event: EventOf<"turn-item.updated">) => + event.payload.type === "system_notice" ? event.payload : assert.fail("Expected a system notice"); + +const member = ( + thread: OrchestrationV2ThreadProjection, + previousFolder = "/work/a", + nextOrdinal = 7, + storageFolders: ReadonlyMap = new Map(), +): ThreadMoveMember => ({ + projection: thread, + previousFolder, + scopeFolders: new Map(), + storageFolders, + nextOrdinal, +}); + +it.effect.each([ + { name: "root to root", worktreePath: null, from: "/work/a", to: "/work/b", scratch: null }, + { name: "worktree to root", worktreePath: "/wt", from: "/wt", to: "/work/b", scratch: null }, + { name: "/no", worktreePath: null, from: "/work/a", to: "/scratch/new", scratch: "/scratch/new" }, +])("moves a thread and its member from $name in one batch", ({ worktreePath, from, to, scratch }) => + Effect.gen(function* () { + const thread = projection({ id: "t", worktreePath, sessions: ["running", "stopped"] }); + const side = projection({ id: "side", worktreePath, sessions: ["ready"] }); + const { events, effects } = yield* plan({ + members: [member(thread, from, 7), member(side, from, 3)], + worktreePath: scratch, + folder: to, + }); + expect(ofType(events, "thread.metadata-updated")).toMatchObject([ + { payload: { id: "t", projectId: projectB, worktreePath: scratch, branch: null } }, + { payload: { id: "side", projectId: projectB, worktreePath: scratch, branch: null } }, + ]); + expect(ofType(events, "provider-session.detached")).toMatchObject([ + { threadId: "t", payload: { reason: "Workspace changed." } }, + { threadId: "side", payload: { reason: "Workspace changed." } }, + ]); + const notices = ofType(events, "turn-item.updated").map(noticeOf); + expect(notices).toMatchObject([ + { threadId: "t", ordinal: 7, runId: null, previousFolder: from, level: "info" }, + { threadId: "side", ordinal: 3, runId: null, previousFolder: from, level: "info" }, + ]); + assert.isTrue(notices.every((item) => item.startedAt === movedAt)); + assert.include(notices[0]?.message ?? "", `The next turn runs in \`${receipt.folder}\`.`); + assert.equal( + notices[1]?.message, + `Moved with t.\nThe next turn runs in \`${to}\`.\nThe receipt in t lists the details.`, + ); + expect(effects).toMatchObject([ + { threadId: "t", request: { type: "provider-session.detach" } }, + { threadId: "t", request: { type: "scheduled-tasks.move" } }, + { threadId: "side", request: { type: "provider-session.detach" } }, + { threadId: "side", request: { type: "scheduled-tasks.move" } }, + ]); + const after = events + .filter((event) => event.threadId === ThreadId.make("t")) + .reduce(applyToProjection, thread); + assert.equal(after.thread.projectId, projectB); + expect(after.providerSessions.map((session) => session.status)).toEqual(["stopped"]); + }).pipe(Effect.provide(IdAllocator.layer)), +); + +it.effect("a run that never recorded its folder keeps the one it ran in", () => + Effect.gen(function* () { + const thread = projection({ + id: "t", + worktreePath: null, + runs: [{ id: "legacy" }, { id: "recorded", cwd: "/wt" }], + }); + const { events } = yield* plan({ members: [member(thread)] }); + expect(ofType(events, "run.updated")).toMatchObject([ + { threadId: "t", payload: { id: "legacy", cwd: "/work/a" } }, + ]); + const after = events.reduce(applyToProjection, thread); + expect(after.runs.map((run) => run.cwd)).toEqual(["/work/a", "/wt"]); + }).pipe(Effect.provide(IdAllocator.layer)), +); + +it.effect("detaches nothing when the folder does not change", () => + Effect.gen(function* () { + const thread = projection({ + id: "t", + worktreePath: null, + sessions: ["running"], + runs: [{ id: "legacy" }], + }); + const { events, effects } = yield* plan({ members: [member(thread)], folderChanged: false }); + expect(events).toMatchObject([ + { type: "thread.metadata-updated" }, + { type: "turn-item.updated" }, + ]); + expect(effects.map(({ request }) => request.type)).toEqual(["scheduled-tasks.move"]); + }).pipe(Effect.provide(IdAllocator.layer)), +); + +it.effect("links the pull request a cleared branch tracked, once", () => + Effect.gen(function* () { + const pullRequest = { number: 42, url: "https://github.com/o/r/pull/42" }; + const thread = projection({ id: "t", worktreePath: null, branch: "feature", pullRequest }); + const { events } = yield* plan({ members: [member(thread)] }); + const [synced] = ofType(events, "thread.pull-request-synced"); + expect(synced?.payload).toMatchObject({ + branch: null, + branchPullRequest: null, + pullRequests: [{ ...pullRequest, host: "github.com", repository: "o/r", source: "manual" }], + }); + const after = events.reduce(applyToProjection, thread); + assert.lengthOf(after.thread.pullRequests ?? [], 1); + + const withLinks = (pullRequests: typeof after.thread.pullRequests) => ({ + ...thread, + thread: { ...thread.thread, pullRequests }, + }); + const again = yield* plan({ members: [member(withLinks(after.thread.pullRequests))] }); + assert.lengthOf(ofType(again.events, "thread.pull-request-synced"), 0); + const dismissedAt = "2026-08-01T00:00:00.000Z"; + const dismissed = withLinks( + after.thread.pullRequests?.map((link) => ({ + ...link, + source: "stack-dismissed" as const, + linkedAt: dismissedAt, + })), + ); + const relinked = yield* plan({ members: [member(dismissed)] }); + expect(ofType(relinked.events, "thread.pull-request-synced")).toMatchObject([ + { payload: { pullRequests: [{ number: 42, source: "manual", linkedAt: dismissedAt }] } }, + ]); + const unbranched = projection({ id: "t", worktreePath: null, branch: null, pullRequest }); + const none = yield* plan({ members: [member(unbranched)] }); + assert.lengthOf(ofType(none.events, "thread.pull-request-synced"), 0); + }).pipe(Effect.provide(IdAllocator.layer)), +); + +it.effect("relocates each Claude member's native session after its detach", () => + Effect.gen(function* () { + const thread = projection({ + id: "t", + worktreePath: null, + sessions: ["ready"], + claudeSession: "sess-1", + }); + const side = projection({ id: "side", worktreePath: null, claudeSession: "sess-2" }); + const codexOnly = projection({ id: "codex", worktreePath: null }); + const { effects } = yield* plan({ + members: [ + member( + thread, + "/work/a", + 7, + new Map([[ProviderThreadId.make("provider-thread:t"), "/old"]]), + ), + member(side), + member(codexOnly), + ], + }); + expect(effects).toMatchObject([ + { threadId: "t", request: { type: "provider-session.detach" } }, + { threadId: "t", request: { type: "provider-session.relocate-storage" } }, + { threadId: "t", request: { type: "scheduled-tasks.move" } }, + { threadId: "side", request: { type: "provider-session.relocate-storage" } }, + { threadId: "side", request: { type: "scheduled-tasks.move" } }, + { threadId: "codex", request: { type: "scheduled-tasks.move" } }, + ]); + expect( + effects.filter(({ request }) => request.type === "provider-session.relocate-storage"), + ).toMatchObject([ + { request: { nativeSessionId: "sess-1", oldFolder: "/old", newFolder: "/work/b" } }, + { request: { nativeSessionId: "sess-2", oldFolder: "/work/a", newFolder: "/work/b" } }, + ]); + }).pipe(Effect.provide(IdAllocator.layer)), +); diff --git a/apps/server/src/project/ThreadMovePlan.ts b/apps/server/src/project/ThreadMovePlan.ts new file mode 100644 index 0000000000000..d83efffc593a3 --- /dev/null +++ b/apps/server/src/project/ThreadMovePlan.ts @@ -0,0 +1,172 @@ +import { + type OrchestrationV2DomainEvent, + type OrchestrationV2ThreadProjectMoveCommand, + type OrchestrationV2ThreadProjection, + type ProviderThreadId, + type RunId, + TurnItemId, +} from "@t3tools/contracts"; +import type * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; + +import type { PendingOrchestrationEffectV2 } from "../orchestration-v2/EffectOutbox.ts"; +import { + legacyThreadPullRequestKey, + threadPullRequestKeysEqual, + threadPullRequestsOf, +} from "../sourceControl/threadPullRequests.ts"; +import { + moveReceiptTurnItem, + threadMoveMemberNotice, + threadMoveReceiptMessage, + type ThreadMoveReceiptInput, +} from "./ThreadMoveReceipt.ts"; + +export interface ThreadMoveMember { + readonly projection: Pick< + OrchestrationV2ThreadProjection, + "thread" | "providerSessions" | "providerThreads" | "runs" + >; + readonly previousFolder: string; + /** The folder each run's root checkpoint scope recorded. */ + readonly scopeFolders: ReadonlyMap; + readonly storageFolders: ReadonlyMap; + readonly nextOrdinal: number; +} + +const DETACH_REASON = "Workspace changed."; + +export const planThreadMove = Effect.fn("ThreadMovePlan.planThreadMove")(function* (input: { + readonly command: OrchestrationV2ThreadProjectMoveCommand; + /** The named thread first, then the members that move with it. */ + readonly members: ReadonlyArray; + readonly folder: string; + readonly folderChanged: boolean; + readonly receipt: ThreadMoveReceiptInput; + readonly now: DateTime.Utc; + readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; +}) { + const { command, now } = input; + const events: Array = []; + const effects: Array = []; + const emit = Effect.fn("ThreadMovePlan.emit")(function* < + Event extends OrchestrationV2DomainEvent, + >(event: Omit) { + const id = yield* input.idAllocator.allocate.event({ + threadId: event.threadId, + commandId: command.commandId, + }); + events.push({ ...event, id, occurredAt: now } as Event); + }); + const threadTitle = input.members[0]?.projection.thread.title ?? ""; + for (const [index, member] of input.members.entries()) { + const { thread, providerSessions, providerThreads, runs } = member.projection; + const { id: threadId, providerInstanceId } = thread; + const effect = (suffix: string, request: PendingOrchestrationEffectV2["request"]) => + effects.push({ + id: `effect:${command.commandId}:${request.type}:${suffix}`, + commandId: command.commandId, + threadId, + request, + }); + const moved = { + ...thread, + projectId: command.projectId, + worktreePath: command.worktreePath, + branch: null, + branchPullRequest: null, + updatedAt: now, + }; + yield* emit({ type: "thread.metadata-updated", threadId, providerInstanceId, payload: moved }); + const tracked = thread.branch === null ? null : thread.branchPullRequest; + if (tracked != null) { + const key = legacyThreadPullRequestKey(tracked); + const links = threadPullRequestsOf(moved); + const existing = links.find((link) => threadPullRequestKeysEqual(link, key)); + if (existing === undefined || existing.source === "stack-dismissed") { + const link = existing ?? { + ...key, + url: tracked.url, + linkedAt: DateTime.formatIso(now), + snapshot: null, + stack: null, + }; + yield* emit({ + type: "thread.pull-request-synced", + threadId, + providerInstanceId, + payload: { + ...moved, + pullRequests: [ + ...links.filter((other) => other !== existing), + { ...link, source: "manual" }, + ], + }, + }); + } + } + if (input.folderChanged) { + // A run from before runs recorded their folder would otherwise take the root scope's, + // which the next turn moves here. + for (const run of runs) { + if (run.cwd !== undefined) continue; + yield* emit({ + type: "run.updated", + threadId, + providerInstanceId: run.providerInstanceId, + payload: { ...run, cwd: member.scopeFolders.get(run.id) ?? member.previousFolder }, + }); + } + for (const session of providerSessions) { + if (session.status === "stopped" || session.status === "error") continue; + yield* emit({ + type: "provider-session.detached", + threadId, + driver: session.driver, + providerInstanceId: session.providerInstanceId, + payload: { providerSessionId: session.id, detachedAt: now, reason: DETACH_REASON }, + }); + effect(session.id, { + type: "provider-session.detach", + providerSessionId: session.id, + detail: DETACH_REASON, + }); + } + for (const providerThread of providerThreads) { + const nativeSessionId = providerThread.nativeThreadRef?.nativeId; + if (providerThread.driver !== "claudeAgent" || nativeSessionId == null) continue; + effect(providerThread.id, { + type: "provider-session.relocate-storage", + driver: providerThread.driver, + providerInstanceId: providerThread.providerInstanceId, + providerThreadId: providerThread.id, + nativeSessionId, + oldFolder: member.storageFolders.get(providerThread.id) ?? member.previousFolder, + newFolder: input.folder, + }); + } + } + yield* emit({ + type: "turn-item.updated", + threadId, + payload: moveReceiptTurnItem({ + id: TurnItemId.make(`${command.commandId}:receipt:${threadId}`), + threadId, + ordinal: member.nextOrdinal, + movedAt: now, + previousFolder: member.previousFolder, + message: + index === 0 + ? threadMoveReceiptMessage(input.receipt) + : threadMoveMemberNotice(threadTitle, input.folder), + }), + }); + effect(threadId, { + type: "scheduled-tasks.move", + threadIds: [threadId], + projectId: command.projectId, + }); + } + return { events, effects }; +}); diff --git a/apps/server/src/project/ThreadMoveReceipt.test.ts b/apps/server/src/project/ThreadMoveReceipt.test.ts new file mode 100644 index 0000000000000..3869c0a86af8a --- /dev/null +++ b/apps/server/src/project/ThreadMoveReceipt.test.ts @@ -0,0 +1,94 @@ +import { assert, it } from "@effect/vitest"; + +import { + previousFolderAfter, + threadMoveReceiptMessage, + type ThreadMoveReceiptInput, +} from "./ThreadMoveReceipt.ts"; + +const move = { + folder: "/repos/b", + previousFolder: "/repos/a", + project: "B", + previousProject: "A", + members: ["Side chat", "Subagent"], + clearedBranch: "feat/login", + pullRequest: { number: 12, url: "https://github.com/o/r/pull/12" }, + leftFolder: null, +}; + +const receipt = (change: Partial = {}) => + threadMoveReceiptMessage({ ...move, ...change }).split("\n"); +/** The indexes of the lines that differ between two receipts. */ +const changed = (left: ReadonlyArray, right: ReadonlyArray) => + left.flatMap((line, index) => (line === right[index] ? [] : [index])); + +it("writes the receipt for a move out of a project root, a worktree or a scratch folder", () => { + const full = receipt(); + // The client shows the first line as the heading, and renders a path in inline code as a link. + assert.include(full[0], "B"); + assert.include(full[1], '"A"'); + assert.include(full[1], "`/repos/a`"); + assert.include(full[2], "`/repos/b`"); + assert.include(receipt({ folder: "/repos/b`c" })[2], "`` /repos/b`c ``"); + + // The moved threads and the cleared branch each add one line, and no other line needs them. + const optional = full.filter( + (line) => line.includes('"Side chat", "Subagent"') || line.includes('"feat/login"'), + ); + assert.lengthOf(optional, 2); + assert.deepStrictEqual( + receipt({ members: [], clearedBranch: null, pullRequest: null }), + full.filter((line) => !optional.includes(line)), + ); + // The pull request is on the branch line, and only when the thread has one. + const unlinked = receipt({ pullRequest: null }); + assert.deepStrictEqual(changed(unlinked, full), [full.indexOf(optional[1]!)]); + assert.include(unlinked.join("\n"), '"feat/login"'); + for (const part of ["#12", "https://github.com/o/r/pull/12"]) { + assert.include(optional[1], part); + assert.notInclude(unlinked.join("\n"), part); + } + + // A worktree or scratch folder adds two lines: if it stays on disk, and who still works in it. + const worktree = receipt({ + previousFolder: "/worktrees/w", + leftFolder: { exists: true, project: "P", otherThreads: ["Fork", "Archived"] }, + }); + assert.include(worktree[1], "`/worktrees/w`"); + assert.include(worktree[4], '"P", "Fork", "Archived"'); + // Without those lines, only the line about the folder it came from differs from a root move. + assert.deepStrictEqual( + changed( + worktree.toSpliced(3, 2).map((line) => line.replaceAll("/worktrees/w", "/repos/a")), + full, + ), + [1], + ); + // A folder that is gone and empty changes those two lines and no other. + const gone = receipt({ + previousFolder: "/scratch/s", + leftFolder: { exists: false, project: null, otherThreads: [] }, + }); + assert.deepStrictEqual( + changed( + gone, + worktree.map((line) => line.replaceAll("/worktrees/w", "/scratch/s")), + ), + [3, 4], + ); +}); + +it("resolves a row against the first move receipt after it", () => { + const items = [ + { type: "user_message" as const, ordinal: 1 }, + { type: "system_notice" as const, ordinal: 2 }, + { type: "system_notice" as const, ordinal: 3, previousFolder: "/a" }, + { type: "user_message" as const, ordinal: 4 }, + { type: "system_notice" as const, ordinal: 5, previousFolder: "/b" }, + ]; + assert.strictEqual(previousFolderAfter(items, 1), "/a"); + assert.strictEqual(previousFolderAfter(items, 3), "/b"); + assert.strictEqual(previousFolderAfter(items, 4), "/b"); + assert.strictEqual(previousFolderAfter(items, 5), undefined); +}); diff --git a/apps/server/src/project/ThreadMoveReceipt.ts b/apps/server/src/project/ThreadMoveReceipt.ts new file mode 100644 index 0000000000000..7dd96ffba02ce --- /dev/null +++ b/apps/server/src/project/ThreadMoveReceipt.ts @@ -0,0 +1,113 @@ +import type { OrchestrationV2TurnItem, ThreadId, TurnItemId } from "@t3tools/contracts"; +import type * as DateTime from "effect/DateTime"; + +const quote = (title: string) => `"${title}"`; + +/** Inline code, so the client renders the path as a file link. */ +const receiptPath = (path: string) => (path.includes("`") ? `\`\` ${path} \`\`` : `\`${path}\``); + +export interface ThreadMoveReceiptInput { + readonly folder: string; + readonly previousFolder: string; + readonly project: string; + readonly previousProject: string; + readonly members: ReadonlyArray; + readonly clearedBranch: string | null; + readonly pullRequest: { readonly number: number; readonly url: string } | null; + /** The worktree or scratch folder the thread leaves; null for a move out of a project root. */ + readonly leftFolder: { + readonly exists: boolean; + /** The project rooted in the folder, named instead of its root threads. */ + readonly project: string | null; + readonly otherThreads: ReadonlyArray; + } | null; +} + +/** The first line is the heading the client shows; the lines after it are the details. */ +export function threadMoveReceiptMessage(input: ThreadMoveReceiptInput): string { + const left = input.leftFolder; + const working = + left === null + ? [] + : [ + ...(left.project === null ? [] : [`project ${quote(left.project)}`]), + ...left.otherThreads.map(quote), + ]; + const pullRequest = input.pullRequest; + const previousFolder = receiptPath(input.previousFolder); + return [ + `Moved to ${input.project}.`, + // A thread out of a project root names the root; a worktree or scratch folder is its own place. + left === null + ? `It came from project ${quote(input.previousProject)}, which has its root at ${previousFolder}.` + : `It came from project ${quote(input.previousProject)}, where it worked in ${previousFolder}.`, + `The next turn runs in ${receiptPath(input.folder)}.`, + left !== null && + (left.exists + ? `The folder ${previousFolder} stays on disk with all its files until you remove it.` + : "The folder no longer exists."), + left !== null && + (working.length === 0 + ? "No other thread works in it." + : `Still working in it: ${working.join(", ")}.`), + input.members.length > 0 && `Moved with it: ${input.members.map(quote).join(", ")}.`, + input.clearedBranch !== null && + `Cleared the branch ${quote(input.clearedBranch)}.${pullRequest === null ? "" : ` Its pull request #${pullRequest.number} (${pullRequest.url}) stays linked.`}`, + "Open terminals keep their folder.", + ] + .filter((line) => typeof line === "string") + .join("\n"); +} + +export function threadMoveMemberNotice(threadTitle: string, folder: string): string { + return [ + `Moved with ${threadTitle}.`, + `The next turn runs in ${receiptPath(folder)}.`, + `The receipt in ${threadTitle} lists the details.`, + ].join("\n"); +} + +export function moveReceiptTurnItem(input: { + readonly id: TurnItemId; + readonly threadId: ThreadId; + readonly ordinal: number; + readonly movedAt: DateTime.Utc; + readonly previousFolder: string; + readonly message: string; +}): OrchestrationV2TurnItem { + return { + id: input.id, + threadId: input.threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: input.ordinal, + status: "completed", + title: null, + startedAt: input.movedAt, + completedAt: input.movedAt, + updatedAt: input.movedAt, + type: "system_notice", + message: input.message, + previousFolder: input.previousFolder, + level: "info", + }; +} + +/** `items` sorted by ordinal, as the history read returns them. */ +export function previousFolderAfter( + items: ReadonlyArray< + Pick & { + readonly previousFolder?: string | undefined; + } + >, + ordinal: number, +): string | undefined { + return items.find( + (item) => + item.ordinal > ordinal && item.type === "system_notice" && item.previousFolder !== undefined, + )?.previousFolder; +} diff --git a/apps/server/src/project/ThreadMoveService.test.ts b/apps/server/src/project/ThreadMoveService.test.ts new file mode 100644 index 0000000000000..34805896e2998 --- /dev/null +++ b/apps/server/src/project/ThreadMoveService.test.ts @@ -0,0 +1,786 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, expect, it } from "@effect/vitest"; +import { + CheckpointScopeId, + CommandId, + EventId, + NodeId, + ProjectId, + ProviderInstanceId, + ProviderThreadId, + RunAttemptId, + RunId, + ServerSettingsError, + ThreadId, + type ThreadMoveInput, + TurnItemId, +} from "@t3tools/contracts"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as KeyedLock from "@t3tools/shared/KeyedLock"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as SqlClient from "effect/sql/SqlClient"; + +import * as ServerConfig from "../config.ts"; +import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; +import * as EffectOutbox from "../orchestration-v2/EffectOutbox.ts"; +import * as EventSink from "../orchestration-v2/EventSink.ts"; +import * as EventStore from "../orchestration-v2/EventStore.ts"; +import * as LegacyV1ThreadImporter from "../orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; +import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import * as ManagedProjectFolders from "./ManagedProjectFolders.ts"; +import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; +import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; +import * as ProjectLock from "./ProjectLock.ts"; +import * as ProjectService from "./ProjectService.ts"; +import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; +import { + claudeDriver, + codexDriver, + codexSession, + createdAt, + member, + moved, + namedThread, + needsProject, + providerThread, + refusal, + runCreated, + threadCreated, +} from "./ThreadMove.testkit.ts"; +import { moveReceiptTurnItem } from "./ThreadMoveReceipt.ts"; +import * as ThreadMoveService from "./ThreadMoveService.ts"; + +const codexId = ProviderInstanceId.make("codex"); +const SCRATCH_ID = ProjectId.make("scratch"); + +class Probe extends Context.Service< + Probe, + Ref.Ref<(threadId: ThreadId) => Effect.Effect> +>()("t3/project/ThreadMoveService.test/Probe") {} + +const codexInstance = { + instanceId: codexId, + driverKind: codexDriver, + snapshot: { getSnapshot: Effect.succeed({ version: null }) }, + orchestrationAdapter: { getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2) }, +} as unknown as ProviderInstance; + +const layerThreads = Layer.unwrap( + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const probe = yield* Probe; + return Layer.mock(ThreadManagementService.ThreadManagementService)({ + getShellSnapshot: (options) => projections.getShellSnapshot(options).pipe(Effect.orDie), + getThreadRecords: (threadId, fields, filter) => + projections.getThreadRecords(threadId, fields, filter).pipe(Effect.orDie), + threadHasBackgroundWork: (threadId) => + Ref.get(probe).pipe(Effect.flatMap((hasWork) => hasWork(threadId))), + }); + }), +); +const layerFolders = Layer.unwrap( + Effect.gen(function* () { + const projects = yield* ProjectService.ProjectService; + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scratchRoot = path.join(config.baseDir, "scratch"); + yield* fileSystem.makeDirectory(scratchRoot, { recursive: true }); + return Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: path.join(config.baseDir, "projects"), + scratchRoot: Effect.succeedSome(scratchRoot), + ensureScratchProject: projects + .bootstrap({ + commandId: CommandId.make("scratch:create"), + projectId: SCRATCH_ID, + title: "No project", + workspaceRoot: scratchRoot, + }) + .pipe(Effect.as({ projectId: SCRATCH_ID }), Effect.orDie), + folderForThread: ({ threadId }) => { + const folder = path.join(scratchRoot, `2026-01-01-${threadId.replace(/[^a-z0-9]/gi, "")}`); + return fileSystem.makeDirectory(folder).pipe(Effect.as(Option.some(folder)), Effect.orDie); + }, + }); + }), +); +const layerProviders = Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + getInstance: (instanceId) => Effect.succeed(instanceId === codexId ? codexInstance : undefined), +}); +const layerTest = Layer.effect(ThreadMoveService.ThreadMoveService, ThreadMoveService.make).pipe( + Layer.provideMerge(Layer.mergeAll(layerThreads, layerFolders, layerProviders)), + Layer.provideMerge(Layer.effect(ProjectService.ProjectService, ProjectService.make)), + Layer.provideMerge( + Layer.mergeAll( + LegacyV1ThreadImporter.layer.pipe( + Layer.provideMerge(EventSink.layer), + Layer.provideMerge(Layer.merge(EventStore.layer, ProjectionStore.layer)), + ), + EffectOutbox.layer, + ProjectStore.layer, + IdAllocator.layer, + ThreadCommandExecutor.layer, + ProjectLock.layer, + Layer.effect( + Probe, + Ref.make<(threadId: ThreadId) => Effect.Effect>(() => Effect.succeed(false)), + ), + Layer.succeed(WorkspacePaths.WorkspacePaths, { + normalizeWorkspaceRoot: (workspaceRoot) => Effect.succeed(workspaceRoot), + resolveRelativePathWithinRoot: ({ workspaceRoot, relativePath }) => + Effect.succeed({ absolutePath: `${workspaceRoot}/${relativePath}`, relativePath }), + }), + ), + ), + Layer.provideMerge(ProjectEnrichmentService.layer), + Layer.provide( + Layer.mergeAll( + Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: () => Effect.succeed(null), + }), + Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, + resolvePath: () => Effect.succeed(null), + }), + ), + ), + Layer.provideMerge(ServerSettings.layerTest()), + Layer.provideMerge(SqlitePersistence.layerMemory), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "thread-move-test-" })), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), +); + +const setup = Effect.fn("ThreadMoveServiceTest.setup")(function* (name: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projects = yield* ProjectService.ProjectService; + const sink = yield* EventSink.EventSinkV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const service = yield* ThreadMoveService.ThreadMoveService; + const probe = yield* Probe; + const root = yield* fileSystem.realPath( + yield* fileSystem.makeTempDirectoryScoped({ prefix: `t3-move-${name}-` }), + ); + const at = (...segments: ReadonlyArray) => path.join(root, ...segments); + for (const folder of ["a", "b", "w", "plain"]) yield* fileSystem.makeDirectory(at(folder)); + const project = (id: string, folder: string) => + projects.create({ + commandId: CommandId.make(`${name}:project:${id}`), + projectId: ProjectId.make(`${name}:${id}`), + title: id, + workspaceRoot: at(folder), + }); + const remove = (id: string, projectId: ProjectId, force = false) => + projects.delete({ commandId: CommandId.make(`${name}:${id}`), projectId, force }); + const a = yield* project("A", "a"); + const b = yield* project("B", "b"); + const thread = ( + id: string, + projectId: ProjectId, + worktreePath: string | null, + extra?: Omit[0], "threadId" | "projectId" | "worktreePath">, + ) => { + const threadId = ThreadId.make(`${name}:${id}`); + return sink + .write({ events: [threadCreated({ threadId, projectId, worktreePath, ...extra })] }) + .pipe(Effect.as(threadId)); + }; + const shell = (threadId: ThreadId) => + projections.getThreadProjection(threadId).pipe(Effect.map(({ thread }) => thread)); + return { at, a, b, thread, project, remove, shell, service, projects, projections, sink, probe }; +}); + +const outboxOf = (threadId: ThreadId) => + Effect.flatMap(SqlClient.SqlClient, (sql) => + Effect.map( + sql<{ + readonly effect_type: string; + }>`SELECT effect_type FROM orchestration_v2_effect_outbox WHERE thread_id = ${threadId} ORDER BY effect_id ASC`, + (rows) => rows.map((row) => row.effect_type), + ), + ); +const hookLock = ( + locks: KeyedLock.KeyedLock, + before: (key: Key) => Effect.Effect, +): KeyedLock.KeyedLock => ({ + ...locks, + withLock: (key, effect) => Effect.andThen(Effect.orDie(before(key)), locks.withLock(key, effect)), +}); +const hookedService = Effect.fn("ThreadMoveServiceTest.hookedService")(function* ( + before: (key: ProjectId) => Effect.Effect, +) { + const locks = yield* ProjectLock.ProjectLock; + return yield* ThreadMoveService.make.pipe( + Effect.provideService(ProjectLock.ProjectLock, hookLock(locks, before)), + ); +}); + +it.layer(layerTest)("ThreadMoveService", (it) => { + it.effect("a refused move or a failed environment read commits nothing", () => + Effect.gen(function* () { + const { at, a, thread, shell, service, probe } = yield* setup("refused"); + const threadId = yield* thread("t", a.id, null); + yield* Ref.set(probe, (candidate) => Effect.succeed(candidate === threadId)); + const result = yield* service.move({ threadId, destination: at("b") }); + expect(result).toMatchObject(member(threadId, "background work")); + assert.equal((yield* shell(threadId)).projectId, a.id); + assert.deepEqual(yield* outboxOf(threadId), []); + yield* Ref.set(probe, () => Effect.succeed(false)); + + // The decision reads the settings once itself; the environment resolver's read comes second. + const settings = yield* ServerSettings.ServerSettingsService; + let reads = 0; + const unresolved = yield* ThreadMoveService.make.pipe( + Effect.provideService(ServerSettings.ServerSettingsService, { + ...settings, + getSettings: Effect.suspend(() => + ++reads === 2 + ? new ServerSettingsError({ + settingsPath: at("settings.json"), + operation: "read-file", + }) + : settings.getSettings, + ), + }), + ); + const failure = yield* unresolved.move({ threadId, destination: at("b") }).pipe(Effect.flip); + assert.equal(failure.message, "Could not resolve the provider's environment."); + assert.equal((yield* shell(threadId)).projectId, a.id); + assert.deepEqual(yield* outboxOf(threadId), []); + }), + ); + + it.effect("only the call that carries every flag changes state", () => + Effect.gen(function* () { + const { at, a, thread, shell, service, projects, projections, sink } = yield* setup("flags"); + const threadId = yield* thread("t", a.id, at("w"), { branch: "feature" }); + const runId = RunId.make("flags:run"); + const earlier = moveReceiptTurnItem({ + id: TurnItemId.make("flags:earlier"), + threadId, + ordinal: 1, + movedAt: createdAt, + previousFolder: at("w"), + message: "earlier", + }); + yield* sink.write({ + events: [ + runCreated({ id: runId, threadId, ordinal: 1, providerInstanceId: codexId }), + { + id: EventId.make("flags:earlier"), + type: "turn-item.updated" as const, + threadId, + occurredAt: createdAt, + payload: { ...earlier, runId }, + }, + ], + }); + const sideId = yield* thread("side", a.id, at("w"), { parent: threadId, kind: "side" }); + const forkId = yield* thread("fork", a.id, at("w"), { parent: threadId, kind: "fork" }); + const added = () => + Effect.map(projects.getByWorkspaceRoot(at("plain")), Option.getOrUndefined); + const move = (flags?: Omit) => + service.move({ threadId, destination: at("plain"), ...flags }); + + expect(yield* move()).toMatchObject(needsProject(at("plain"))); + assert.isUndefined(yield* added()); + + // The question's resolved folders still match once the project exists at `to`. + const approved = { from: at("w"), to: at("plain") }; + expect(yield* move({ addProject: true })).toMatchObject({ + _tag: "confirm", + from: at("w"), + to: at("plain"), + wayBack: "add-project", + staysOnDisk: expect.stringContaining("stays on disk"), + otherThreads: [namedThread(forkId)], + resolvedFolders: approved, + }); + assert.isUndefined(yield* added()); + assert.equal((yield* shell(threadId)).projectId, a.id); + + expect(yield* move({ addProject: true, confirm: true, approved })).toMatchObject( + moved(at("plain"), [threadId, sideId]), + ); + const project = yield* added(); + assert.equal(project?.title, "plain"); + for (const id of [threadId, sideId]) { + expect(yield* shell(id)).toMatchObject({ + projectId: project?.id, + worktreePath: null, + branch: null, + }); + } + assert.equal((yield* shell(forkId)).projectId, a.id); + const { turnItems } = yield* projections.getThreadRecords(threadId, ["turnItems"]); + const receipt = turnItems.find((item) => item.id !== earlier.id); + // A reload reads this order: the receipt comes after the run's item. + const reloaded = yield* projections.getThreadProjection(threadId); + assert.deepEqual( + reloaded.visibleTurnItems.map((row) => row.item.id), + [earlier.id, receipt?.id], + ); + const message = receipt?.type === "system_notice" ? receipt.message : ""; + assert.equal(receipt?.type === "system_notice" ? receipt.previousFolder : null, at("w")); + assert.include(message, `The next turn runs in \`${at("plain")}\`.`); + assert.include(message, `"${forkId}"`); + assert.deepEqual(yield* outboxOf(threadId), ["scheduled-tasks.move"]); + }), + ); + + type Recheck = Effect.Success> & { + readonly key: ProjectId; + readonly threadId: ThreadId; + }; + type Hook = (scenario: Recheck) => Effect.Effect; + it.effect.each object]>([ + [ + "the re-check refuses a member", + ({ probe }) => Ref.set(probe, () => Effect.succeed(true)), + (threadId) => + member( + threadId, + `This thread still has background work running. The project "plain" was added and stays.`, + ), + ], + [ + "the thread enters a worktree meanwhile", + ({ sink, threadId, a, at }) => + sink.write({ + events: [ + { + ...threadCreated({ threadId, projectId: a.id, worktreePath: at("w") }), + id: EventId.make("recheck:enters-worktree"), + type: "thread.metadata-updated" as const, + }, + ], + }), + () => refusal(`The thread changed. Try again. The project "plain" was added and stays.`), + ], + [ + "a subagent child is created during the probes", + ({ probe, thread, threadId, a }) => + Ref.set(probe, () => + Ref.set(probe, () => Effect.succeed(false)).pipe( + Effect.andThen(thread("sub", a.id, null, { parent: threadId, kind: "subagent" })), + Effect.orDie, + Effect.as(false), + ), + ), + () => + refusal( + `The thread or its destination changed. Try again. The project "plain" was added and stays.`, + ), + ], + [ + "the added project is deleted before the lock", + ({ remove, key }) => remove(`del:${key}`, key, true), + () => refusal(`The destination project no longer exists. The project "plain" was added.`), + ], + ])("keeps and names the project it added when %s", ([name, hook, expected]) => + Effect.gen(function* () { + const scenario = yield* setup(name.replace(/\W+/g, "-")); + const { at, a, shell, projects, probe } = scenario; + const threadId = yield* scenario.thread("t", a.id, null); + const service = yield* hookedService((key) => hook({ ...scenario, key, threadId })); + const result = yield* service.move({ threadId, destination: at("plain"), addProject: true }); + const added = Option.getOrThrow( + yield* projects.getByWorkspaceRoot(at("plain"), { includeDeleted: true }), + ); + expect(result).toMatchObject({ + ...expected(threadId), + projects: [{ projectId: added.id, title: "plain" }], + }); + assert.equal((yield* shell(threadId)).projectId, a.id); + yield* Ref.set(probe, () => Effect.succeed(false)); + }), + ); + + it.effect("a move waiting on a deletion is refused; a forced deletion takes a moved thread", () => + Effect.gen(function* () { + const { at, a, b, thread, project, remove, shell, projects, projections } = + yield* setup("deletion"); + const threadId = yield* thread("t", a.id, null); + const deleteReadThreads = yield* Deferred.make(); + const releaseDelete = yield* Deferred.make(); + const gatedProjects = yield* ProjectService.make.pipe( + Effect.provideService(ProjectionStore.ProjectionStoreV2, { + ...projections, + getShellSnapshot: (options) => + options?.projectId === b.id + ? projections.getShellSnapshot(options).pipe( + Effect.tap(() => Deferred.succeed(deleteReadThreads, undefined)), + Effect.tap(() => Deferred.await(releaseDelete)), + ) + : projections.getShellSnapshot(options), + }), + ); + const deletion = yield* gatedProjects + .delete({ commandId: CommandId.make("deletion:delete-b"), projectId: b.id, force: true }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(deleteReadThreads); + const moveAtLock = yield* Deferred.make(); + const service = yield* hookedService(() => Deferred.succeed(moveAtLock, undefined)); + const releaser = yield* Deferred.await(moveAtLock).pipe( + Effect.andThen(Deferred.succeed(releaseDelete, undefined)), + Effect.forkChild({ startImmediately: true }), + ); + const moving = yield* service + .move({ threadId, destination: at("b") }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Fiber.join(releaser); + yield* Fiber.join(deletion); + assert.deepEqual(yield* Fiber.join(moving), { + _tag: "refused", + reason: "The destination project no longer exists.", + }); + assert.equal((yield* shell(threadId)).projectId, a.id); + assert.isTrue(Option.isNone(yield* projects.getById(b.id))); + + const c = yield* project("C", "plain"); + assert.equal((yield* service.move({ threadId, destination: at("plain") }))._tag, "moved"); + assert.equal((yield* Effect.flip(remove("delete-c", c.id)))._tag, "ProjectNotEmptyError"); + yield* remove("force-c", c.id, true); + assert.isNotNull((yield* shell(threadId)).deletedAt); + }), + ); + + it.effect("deletion holds the project lock from its thread read through its commit", () => + Effect.gen(function* () { + const { b, thread } = yield* setup("lock"); + const projectLocks = yield* ProjectLock.ProjectLock; + const deletionAtLock = yield* Deferred.make(); + const projects = yield* ProjectService.make.pipe( + Effect.provideService( + ProjectLock.ProjectLock, + hookLock(projectLocks, () => Deferred.succeed(deletionAtLock, undefined)), + ), + ); + const held = yield* Deferred.make(); + const release = yield* Deferred.make(); + const holder = yield* projectLocks + .withLock( + b.id, + Deferred.succeed(held, undefined).pipe(Effect.andThen(Deferred.await(release))), + ) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(held); + const deletion = yield* projects + .delete({ commandId: CommandId.make("lock:delete-b"), projectId: b.id }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(deletionAtLock); + yield* thread("late", b.id, null); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(holder); + assert.equal((yield* Fiber.join(deletion).pipe(Effect.flip))._tag, "ProjectNotEmptyError"); + assert.isTrue(Option.isSome(yield* projects.getById(b.id))); + }), + ); + + it.effect("/no claims one folder and removes it only while empty when the commit fails", () => + Effect.gen(function* () { + const { a, thread, shell, service, sink } = yield* setup("scratch"); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const managedFolders = yield* ManagedProjectFolders.ManagedProjectFolders; + const scratchRoot = Option.getOrThrow(yield* managedFolders.scratchRoot); + const claimedFor = (id: string) => + fileSystem + .readDirectory(scratchRoot) + .pipe(Effect.map((entries) => entries.filter((entry) => entry.includes(id)))); + const threadId = yield* thread("t", a.id, null); + + const result = yield* service.move({ threadId, destination: null }); + const { projectId, worktreePath } = yield* shell(threadId); + expect(result).toMatchObject(moved(worktreePath ?? "", [threadId])); + assert.equal(projectId, SCRATCH_ID); + assert.equal(path.dirname(worktreePath ?? ""), scratchRoot); + assert.lengthOf(yield* claimedFor("t"), 1); + assert.equal((yield* service.move({ threadId, destination: null }))._tag, "refused"); + + const failingSink = (fill: string | null) => + EventSink.EventSinkV2.of({ + ...sink, + commitCommand: ({ commandId, events }) => { + const moved = events.find((event) => event.type === "thread.metadata-updated"); + const folder = + moved?.type === "thread.metadata-updated" ? moved.payload.worktreePath : null; + const error = new EventSink.EventSinkWriteError({ + commandId, + eventCount: events.length, + cause: new Error("Injected commit failure"), + }); + const fillFolder = + folder === null || fill === null + ? Effect.void + : Effect.orDie(fileSystem.writeFileString(path.join(folder, fill), "keep")); + return Effect.andThen(fillFolder, error); + }, + }); + for (const [id, fill] of [ + ["empty", null], + ["full", "notes.md"], + ] as const) { + const memberId = yield* thread(id, a.id, null); + const failing = yield* ThreadMoveService.make.pipe( + Effect.provideService(EventSink.EventSinkV2, failingSink(fill)), + ); + const failure = yield* Effect.flip(failing.move({ threadId: memberId, destination: null })); + assert.equal(failure.message, "Could not commit the move."); + assert.equal((yield* shell(memberId)).projectId, a.id); + const kept = yield* claimedFor(id); + assert.lengthOf(kept, fill === null ? 0 : 1); + if (fill !== null) { + assert.isTrue(yield* fileSystem.exists(path.join(scratchRoot, kept[0]!, fill))); + } + } + }), + ); + + it.effect("/no keeps its folder when an interrupt arrives during the commit", () => + Effect.gen(function* () { + const { a, thread, shell, sink } = yield* setup("interrupted"); + const fileSystem = yield* FileSystem.FileSystem; + const committed = yield* Deferred.make(); + const release = yield* Deferred.make(); + // The sink does not let an interrupt in while it commits, so the interrupt waits for it. + const service = yield* ThreadMoveService.make.pipe( + Effect.provideService( + EventSink.EventSinkV2, + EventSink.EventSinkV2.of({ + ...sink, + commitCommand: (input) => + sink.commitCommand(input).pipe( + Effect.tap(() => Deferred.succeed(committed, undefined)), + Effect.tap(() => Deferred.await(release)), + Effect.uninterruptible, + ), + }), + ), + ); + const threadId = yield* thread("t", a.id, null); + const moving = yield* service + .move({ threadId, destination: null }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(committed); + const interrupt = yield* Fiber.interrupt(moving).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(interrupt); + const { projectId, worktreePath } = yield* shell(threadId); + assert.equal(projectId, SCRATCH_ID); + assert.isTrue(yield* fileSystem.exists(worktreePath ?? "")); + }), + ); + + it.effect("takes the project lock, then each member's lock from the parent down", () => + Effect.gen(function* () { + const { at, a, b, thread, shell } = yield* setup("order"); + const threadLocks = yield* ThreadCommandExecutor.ThreadCommandExecutor; + const projectLocks = yield* ProjectLock.ProjectLock; + const order = yield* Ref.make>([]); + const record = (key: string) => Ref.update(order, (keys) => [...keys, key]); + const threadId = yield* thread("t", a.id, null); + const sideId = yield* thread("side", a.id, null, { parent: threadId, kind: "side" }); + const subId = yield* thread("sub", a.id, null, { parent: sideId, kind: "subagent" }); + const forkId = yield* thread("fork", a.id, null, { parent: threadId, kind: "fork" }); + const lateSide = thread("late", a.id, null, { parent: threadId, kind: "side" }); + const lateId = ThreadId.make("order:late"); + const service = yield* ThreadMoveService.make.pipe( + Effect.provideService( + ThreadCommandExecutor.ThreadCommandExecutor, + hookLock(threadLocks, record), + ), + Effect.provideService( + ProjectLock.ProjectLock, + hookLock(projectLocks, (key) => record(`project:${key}`).pipe(Effect.andThen(lateSide))), + ), + ); + + const result = yield* service.move({ threadId, destination: at("b") }); + + assert.equal(result._tag, "moved"); + assert.deepEqual(yield* Ref.get(order), [`project:${b.id}`, threadId, lateId, sideId, subId]); + for (const id of [threadId, sideId, subId, lateId]) { + assert.equal((yield* shell(id)).projectId, b.id); + } + assert.equal((yield* shell(forkId)).projectId, a.id); + }), + ); + + it.effect("queues each member's task move behind that member's detach", () => + Effect.gen(function* () { + const { at, a, b, thread, service, sink } = yield* setup("queue"); + const outbox = yield* EffectOutbox.EffectOutboxV2; + const threadId = yield* thread("t", a.id, null); + const sideId = yield* thread("side", a.id, null, { parent: threadId, kind: "side" }); + yield* sink.write({ + events: [ + { + id: EventId.make("queue:attached"), + type: "provider-session.attached", + threadId: sideId, + driver: codexDriver, + providerInstanceId: codexId, + occurredAt: createdAt, + payload: codexSession({ id: "queue:session", status: "ready", cwd: at("a") }), + }, + ], + }); + const taskMove = (id: ThreadId) => ({ + type: "scheduled-tasks.move" as const, + threadIds: [id], + projectId: b.id, + }); + + yield* (yield* SqlClient.SqlClient)`UPDATE orchestration_v2_effect_outbox SET status = 'succeeded'`; + assert.equal((yield* service.move({ threadId, destination: at("b") }))._tag, "moved"); + const claim = outbox.claimNext({ workerId: "queue" }).pipe(Effect.map(Option.getOrUndefined)); + const detach = yield* claim; + expect([detach?.threadId, detach?.request.type]).toEqual([sideId, "provider-session.detach"]); + assert.deepEqual((yield* claim)?.request, taskMove(threadId)); + assert.isUndefined(yield* claim); + yield* outbox.succeed({ effectId: detach!.id, workerId: "queue" }); + assert.deepEqual((yield* claim)?.request, taskMove(sideId)); + }), + ); + + it.effect("relocates each Claude row from where its session is stored, one move at a time", () => + Effect.gen(function* () { + const { at, a, b, thread, shell, service, sink, projections } = yield* setup("storage"); + const sql = yield* SqlClient.SqlClient; + const threadId = yield* thread("t", a.id, null); + const providerInstanceId = ProviderInstanceId.make("claudeAgent"); + const base = { threadId, providerInstanceId, occurredAt: createdAt }; + const providerThreadId = (name: string) => ProviderThreadId.make(`storage:${name}`); + const row = (name: string, nativeId: string, nativeStorageFolder?: string) => ({ + ...base, + id: EventId.make(`storage:row:${name}:${nativeStorageFolder ?? "unknown"}`), + type: "provider-thread.updated" as const, + driver: claudeDriver, + payload: providerThread({ + id: `storage:${name}`, + threadId, + providerInstanceId, + nativeId, + nativeStorageFolder, + }), + }); + const run = (ordinal: number, name: string, cwd?: string) => + runCreated({ + id: RunId.make(`storage:run:${ordinal}`), + threadId, + ordinal, + providerInstanceId, + providerThreadId: providerThreadId(name), + cwd, + }); + const attempt = (ordinal: number, name: string, nativeThreadId?: string) => ({ + ...base, + id: EventId.make(`storage:attempt:${ordinal}`), + type: "run-attempt.created" as const, + payload: { + id: RunAttemptId.make(`storage:attempt:${ordinal}`), + ...(nativeThreadId === undefined ? {} : { nativeThreadId }), + runId: RunId.make(`storage:run:${ordinal}`), + attemptOrdinal: 1, + rootNodeId: NodeId.make(`storage:node:${ordinal}`), + providerInstanceId, + providerThreadId: providerThreadId(name), + providerTurnId: null, + reason: "initial" as const, + status: "completed" as const, + startedAt: createdAt, + completedAt: createdAt, + }, + }); + // "resumed" holds a session first run on another row; "legacy" predates run.cwd. + yield* sink.write({ + events: [ + row("resumed", "session-resumed"), + row("legacy", "session-legacy"), + run(1, "old-instance", "/old/storage"), + attempt(1, "old-instance", "session-resumed"), + run(2, "legacy"), + attempt(2, "legacy"), + { + ...base, + id: EventId.make("storage:scope"), + type: "checkpoint-scope.created", + payload: { + id: CheckpointScopeId.make("storage:scope"), + threadId, + runId: RunId.make("storage:run:2"), + nodeId: NodeId.make("storage:node:2"), + parentScopeId: null, + providerThreadId: providerThreadId("legacy"), + kind: "root_run", + ordinalWithinParent: 0, + advancesAppRunCount: true, + cwd: "/legacy/storage", + createdAt, + }, + }, + ], + }); + const pendingRelocations = sql<{ readonly payload_json: string }>` + SELECT payload_json FROM orchestration_v2_effect_outbox WHERE thread_id = ${threadId} + AND status = 'pending' AND effect_type = 'provider-session.relocate-storage' + ORDER BY effect_id ASC + `.pipe( + Effect.map((rows) => rows.map(({ payload_json }) => JSON.parse(payload_json))), + Effect.map((all) => all.map((r) => [r.providerThreadId, r.oldFolder, r.newFolder])), + ); + + assert.equal((yield* service.move({ threadId, destination: at("b") }))._tag, "moved"); + assert.deepEqual(yield* pendingRelocations, [ + ["storage:legacy", "/legacy/storage", at("b")], + ["storage:resumed", "/old/storage", at("b")], + ]); + const { runs } = yield* projections.getThreadRecords(threadId, ["runs"]); + assert.deepEqual( + runs.map((run) => run.cwd), + ["/old/storage", "/legacy/storage"], + ); + + assert.deepEqual(yield* service.move({ threadId, destination: at("a") }), { + _tag: "refused", + reason: "The last move is still relocating a Claude session. Try again in a moment.", + }); + assert.equal((yield* shell(threadId)).projectId, b.id); + assert.lengthOf(yield* pendingRelocations, 2); + + // What the relocation handler leaves behind when it succeeds. + yield* sink.write({ + events: [ + row("resumed", "session-resumed", at("b")), + row("legacy", "session-legacy", at("b")), + ], + }); + yield* sql`UPDATE orchestration_v2_effect_outbox SET status = 'succeeded' WHERE thread_id = ${threadId}`; + assert.equal((yield* service.move({ threadId, destination: at("a") }))._tag, "moved"); + assert.deepEqual(yield* pendingRelocations, [ + ["storage:legacy", at("b"), at("a")], + ["storage:resumed", at("b"), at("a")], + ]); + }), + ); +}); diff --git a/apps/server/src/project/ThreadMoveService.ts b/apps/server/src/project/ThreadMoveService.ts new file mode 100644 index 0000000000000..662b06d4c9dc3 --- /dev/null +++ b/apps/server/src/project/ThreadMoveService.ts @@ -0,0 +1,397 @@ +import { + CommandId, + ProjectId, + type ProviderInstanceId, + type ThreadId, + ThreadMoveError, + type ThreadMoveInput, + type ThreadMoveResult, + type ThreadMoveStage, +} from "@t3tools/contracts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Context from "effect/Context"; +import * as SecureRandom from "effect/crypto/SecureRandom"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; + +import * as ServerConfig from "../config.ts"; +import * as EffectOutbox from "../orchestration-v2/EffectOutbox.ts"; +import * as EventSink from "../orchestration-v2/EventSink.ts"; +import * as EventStore from "../orchestration-v2/EventStore.ts"; +import { nativeStorageFolders } from "../orchestration-v2/NativeStorageFolder.ts"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; +import { deriveProviderInstanceConfigMap } from "../provider/ProviderInstanceRegistryHydration.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { resolveProviderInstanceTerminalEnvironment } from "../terminal/Manager.ts"; +import { removeEmptyDirectory } from "../vcs/removeEmptyDirectory.ts"; +import { managedWorktreesDirectories } from "../worktreesDirectory.ts"; +import * as ManagedProjectFolders from "./ManagedProjectFolders.ts"; +import * as ProjectLock from "./ProjectLock.ts"; +import * as ProjectService from "./ProjectService.ts"; +import { + collectMoveSet, + decideThreadMove, + refused, + type ThreadMoveProbes, + type ThreadMoveProviderInstance, +} from "./ThreadMoveDestination.ts"; +import { planThreadMove, type ThreadMoveMember } from "./ThreadMovePlan.ts"; + +export class ThreadMoveService extends Context.Service< + ThreadMoveService, + { + readonly move: (input: ThreadMoveInput) => Effect.Effect; + } +>()("t3/project/ThreadMoveService") {} + +const destinationGone = refused("The destination project no longer exists."); + +export const make = Effect.gen(function* () { + const threads = yield* ThreadManagementService.ThreadManagementService; + const projects = yield* ProjectService.ProjectService; + const managedFolders = yield* ManagedProjectFolders.ManagedProjectFolders; + const threadLocks = yield* ThreadCommandExecutor.ThreadCommandExecutor; + const projectLocks = yield* ProjectLock.ProjectLock; + const eventSink = yield* EventSink.EventSinkV2; + const eventStore = yield* EventStore.EventStoreV2; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const providerInstances = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* HostProcess.HomeDirectory; + const hostEnvironment = yield* HostProcess.Environment; + const random = yield* SecureRandom.SecureRandom; + + const fail = (stage: ThreadMoveStage, message: string) => (cause: unknown) => + new ThreadMoveError({ stage, message, cause }); + const resolve = (folder: string) => + fileSystem.realPath(folder).pipe(Effect.orElseSucceed(() => path.resolve(folder))); + // Scopes in commit order; a run's latest scope wins. + const rootScopeFolders = (threadId: ThreadId) => + eventStore.read({ threadId, eventType: "checkpoint-scope.created" }).pipe( + Stream.runCollect, + Effect.map( + (events) => + new Map( + events.flatMap(({ event }) => + event.type === "checkpoint-scope.created" && + event.payload.kind === "root_run" && + event.payload.runId !== null + ? [[event.payload.runId, event.payload.cwd] as const] + : [], + ), + ), + ), + ); + + const probes: ThreadMoveProbes = { + fileSystem, + path, + threadRecords: (threadId) => + threads + .getThreadRecords(threadId, ["runs", "runtimeRequests"], { turnItemTypes: [] }) + .pipe(Effect.mapError(fail("check", "Could not read the thread."))), + hasBackgroundWork: (threadId) => + threads + .threadHasBackgroundWork(threadId) + .pipe(Effect.mapError(fail("check", "Could not probe the thread's background work."))), + }; + + const providerInstance = Effect.fn("ThreadMoveService.providerInstance")(function* ( + instanceId: ProviderInstanceId, + ) { + const instance = yield* providerInstances.getInstance(instanceId); + if (instance === undefined) return undefined; + const environment = yield* resolveProviderInstanceTerminalEnvironment({ + deriveProviderInstanceConfigMap, + serverSettings, + path, + rawProviderInstanceId: instanceId, + env: hostEnvironment as Record, + }).pipe(Effect.mapError(fail("check", "Could not resolve the provider's environment."))); + return { + driver: instance.driverKind, + capabilities: yield* instance.orchestrationAdapter + .getCapabilities() + .pipe(Effect.mapError(fail("check", "Could not read the provider's capabilities."))), + version: (yield* instance.snapshot.getSnapshot).version ?? null, + environment, + } satisfies ThreadMoveProviderInstance; + }); + + const readShells = threads.getShellSnapshot().pipe( + Effect.map((snapshot) => [...snapshot.threads, ...snapshot.archivedThreads]), + Effect.mapError(fail("check", "Could not read the threads.")), + ); + + const decide = Effect.fn("ThreadMoveService.decide")(function* (request: ThreadMoveInput) { + const shells = yield* readShells; + const { projects: projectList } = yield* projects.snapshot.pipe( + Effect.mapError(fail("check", "Could not read the projects.")), + ); + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError(fail("check", "Could not read the settings.")), + ); + const members = collectMoveSet(request.threadId, shells); + const instances = new Map(); + for (const id of new Set(members.map((member) => member.providerInstanceId))) { + const instance = yield* providerInstance(id); + if (instance !== undefined) instances.set(id, instance); + } + const decision = yield* decideThreadMove( + { + request, + threads: shells, + projects: projectList, + providerInstances: instances, + settings, + scratchRoot: Option.getOrUndefined(yield* managedFolders.scratchRoot), + managedWorktreeDirectories: managedWorktreesDirectories( + settings, + config.worktreesDir, + path, + home, + ), + home, + }, + probes, + ); + const relocating = + decision._tag !== "refused" && + decision._tag !== "moved" && + (yield* outbox + .hasUnsettled({ + threadIds: members.map((member) => member.id), + effectType: "provider-session.relocate-storage", + }) + .pipe(Effect.mapError(fail("check", "Could not read the thread's pending moves.")))); + return { + projects: projectList, + decision: relocating + ? refused("The last move is still relocating a Claude session. Try again in a moment.") + : decision, + }; + }); + + const addProject = Effect.fn("ThreadMoveService.addProject")( + function* (folder: string) { + const id = yield* random.uuidV4(); + return yield* projects + .create({ + commandId: CommandId.make(`thread-move-project:${id}`), + projectId: ProjectId.make(id), + title: path.basename(folder), + workspaceRoot: folder, + }) + .pipe( + Effect.catchTags({ + ProjectConflictError: (conflict) => + projects + .getById(conflict.conflictingProjectId) + .pipe(Effect.flatMap(Effect.fromOption)), + }), + ); + }, + Effect.mapError(fail("add-project", "Could not add the project.")), + ); + + const lockMembers = ( + threadId: ThreadId, + locked: Array, + body: Effect.Effect, + ): Effect.Effect => + threadLocks.withLock( + threadId, + Effect.flatMap(readShells, (shells) => { + locked.push(threadId); + return collectMoveSet(threadId, shells) + .filter((child) => child.lineage.parentThreadId === threadId) + .reduceRight((inner, child) => lockMembers(child.id, locked, inner), body); + }), + ); + + const apply = Effect.fn("ThreadMoveService.apply")(function* ( + input: ThreadMoveInput, + projectId: ProjectId, + lockedIds: ReadonlyArray, + ) { + const project = Option.getOrUndefined( + yield* projects + .getById(projectId) + .pipe(Effect.mapError(fail("check", "Could not read the destination project."))), + ); + if (project === undefined) return destinationGone; + if (project.archivedAt != null) + return refused(`The destination project "${project.title}" is archived.`); + const { projects: projectList, decision } = yield* decide(input); + if (decision._tag !== "move") return decision; + const target = decision.destination; + if ( + target._tag === "new-project" || + target.project?.id !== projectId || + collectMoveSet(input.threadId, yield* readShells).some(({ id }) => !lockedIds.includes(id)) + ) { + return refused("The thread or its destination changed. Try again."); + } + const [thread] = decision.members; + const previousProject = projectList.find((candidate) => candidate.id === thread?.projectId); + if (thread === undefined || previousProject === undefined) { + return refused("This thread's project no longer exists."); + } + const worktreePath = + target._tag !== "scratch" + ? null + : Option.getOrUndefined( + yield* managedFolders + .folderForThread({ projectId, threadId: thread.id, text: thread.title }) + .pipe(Effect.mapError(fail("claim-folder", "Could not claim a scratch folder."))), + ); + if (worktreePath === undefined) + return refused("Scratch folders are unavailable on this server."); + const folder = worktreePath ?? project.workspaceRoot; + const previousFolder = thread.worktreePath ?? previousProject.workspaceRoot; + const commit = Effect.gen(function* () { + const members: Array = []; + for (const member of decision.members) { + const unreadable = fail("plan", "Could not read a thread that moves along."); + const projection = yield* threads + .getThreadRecords( + member.id, + ["providerSessions", "providerThreads", "runs", "attempts"], + { turnItemTypes: [] }, + ) + .pipe(Effect.mapError(unreadable)); + members.push({ + projection, + previousFolder: member.worktreePath ?? previousProject.workspaceRoot, + scopeFolders: projection.runs.every((run) => run.cwd !== undefined) + ? new Map() + : yield* rootScopeFolders(member.id).pipe(Effect.mapError(unreadable)), + storageFolders: yield* nativeStorageFolders(eventStore, member.id, projection).pipe( + Effect.mapError(unreadable), + ), + nextOrdinal: yield* projections + .getNextTurnItemOrdinal(member.id) + .pipe(Effect.mapError(unreadable)), + }); + } + const moving = members[0]?.projection.thread; + const tracked = moving?.branch == null ? null : (moving.branchPullRequest ?? null); + const now = yield* DateTime.now; + const command = { + type: "thread.project.move" as const, + commandId: CommandId.make( + `thread-move:${yield* random.uuidV4().pipe(Effect.mapError(fail("plan", "Could not plan the move.")))}`, + ), + threadId: thread.id, + projectId, + worktreePath, + }; + const plan = yield* planThreadMove({ + command, + members, + folder, + folderChanged: (yield* resolve(previousFolder)) !== (yield* resolve(folder)), + receipt: { + folder, + previousFolder, + project: project.title, + previousProject: previousProject.title, + members: decision.members.slice(1).map((member) => member.title), + clearedBranch: moving?.branch ?? null, + pullRequest: tracked === null ? null : { number: tracked.number, url: tracked.url }, + leftFolder: + decision.leaving === null + ? null + : { + exists: yield* fileSystem + .exists(previousFolder) + .pipe(Effect.orElseSucceed(() => true)), + project: decision.leaving.project?.title ?? null, + otherThreads: decision.leaving.otherThreads.map((other) => other.title), + }, + }, + now, + idAllocator, + }).pipe(Effect.mapError(fail("plan", "Could not plan the move."))); + yield* eventSink + .commitCommand({ + commandId: command.commandId, + commandType: command.type, + threadId: thread.id, + acceptedAt: now, + events: plan.events, + effects: plan.effects, + }) + .pipe(Effect.mapError(fail("commit", "Could not commit the move."))); + return { + _tag: "moved" as const, + folder, + members: decision.members.map((member) => ({ threadId: member.id, title: member.title })), + }; + }); + // An interrupt during the commit takes effect after it, so a failure here can follow a + // stored move. The folder goes only while no thread works in it. + const releaseFolder = (claimed: string) => + readShells.pipe( + Effect.flatMap((shells) => + shells.some((shell) => shell.worktreePath === claimed) + ? Effect.void + : removeEmptyDirectory(claimed), + ), + Effect.ignore, + ); + return yield* worktreePath === null + ? commit + : commit.pipe(Effect.onError(() => releaseFolder(worktreePath))); + }); + + const move: ThreadMoveService["Service"]["move"] = Effect.fn("ThreadMoveService.move")( + function* (input) { + const first = yield* decide(input); + if (first.decision._tag !== "move") return first.decision; + const { destination } = first.decision; + const added = + destination._tag === "new-project" ? yield* addProject(destination.folder) : undefined; + const projectId = + (destination._tag === "new-project" ? added : destination.project)?.id ?? + (yield* managedFolders.ensureScratchProject.pipe( + Effect.mapError(fail("claim-folder", "Scratch folders are unavailable on this server.")), + )).projectId; + const locked: Array = []; + const result = yield* projectLocks.withLock( + projectId, + lockMembers(input.threadId, locked, apply(input, projectId, locked)), + ); + if (added === undefined || result._tag === "moved") return result; + const refusal = + result._tag === "refused" ? result : refused("The thread changed. Try again."); + const stays = result === destinationGone ? "" : " and stays"; + return { + ...refusal, + reason: `${refusal.reason} The project "${added.title}" was added${stays}.`, + projects: [...(refusal.projects ?? []), { projectId: added.id, title: added.title }], + }; + }, + ); + + return ThreadMoveService.of({ move }); +}); + +export const layer = Layer.effect(ThreadMoveService, make).pipe( + Layer.provide(ThreadCommandExecutor.layer), + Layer.provide(ProjectLock.layer), +); diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 10388e99fe7c5..ab5237d1b6463 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -210,6 +210,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( const threads = ThreadManagementService.ThreadManagementService.of({ getThreadShell: unused, getShellSnapshot: unused, + threadHasBackgroundWork: unused, getAgentAwarenessSnapshot: (threadId) => threadId === undefined ? Effect.sync(() => { diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 660548c1dd68b..42364f3e1a2d3 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -78,6 +78,7 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as ManagedProjectFolders from "./project/ManagedProjectFolders.ts"; import * as ScratchThreadPromotion from "./project/ScratchThreadPromotion.ts"; +import * as ThreadMoveService from "./project/ThreadMoveService.ts"; import * as McpAppRequests from "./mcpApps/McpAppRequests.ts"; import * as ModelManifest from "./provider/ModelManifest.ts"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; @@ -157,6 +158,7 @@ import * as OrchestrationEventStore from "./persistence/OrchestrationEventStore. import * as SqlitePersistence from "./persistence/Sqlite.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as ProjectEnrichment from "./project/ProjectEnrichmentService.ts"; +import * as ProjectLock from "./project/ProjectLock.ts"; import * as ProjectService from "./project/ProjectService.ts"; import { layerWsRpc, @@ -976,6 +978,7 @@ it.effect.each([ namedProjectsRoot: process.cwd(), }), Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({}), + Layer.mock(ThreadMoveService.ThreadMoveService)({}), Layer.mock(ServerEnvironment.ServerEnvironment)({ getDescriptor: Effect.succeed({ environmentId: EnvironmentId.make("config-test"), @@ -1200,6 +1203,7 @@ it.effect("access changes made while the snapshot loads reach the client after i namedProjectsRoot: "/unused", }), Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({}), + Layer.mock(ThreadMoveService.ThreadMoveService)({}), Layer.mock(ServerEnvironment.ServerEnvironment)({}), Layer.mock(EnvironmentAuth.EnvironmentAuth)({ listPairingLinks: () => Effect.succeed([]), @@ -1357,6 +1361,7 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( namedProjectsRoot: process.cwd(), }), Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({}), + Layer.mock(ThreadMoveService.ThreadMoveService)({}), Layer.mock(ServerEnvironment.ServerEnvironment)({ getDescriptor: Effect.succeed({ environmentId: EnvironmentId.make("config-test"), @@ -1724,6 +1729,7 @@ it.effect("a missing thread worktree gets its project's skills in the cwd snapsh IdAllocator.layer, Layer.mock(LegacyV1ThreadImporter.LegacyV1ThreadImporter)({}), Layer.mock(ThreadCommandExecutor.ThreadCommandExecutor)({}), + ProjectLock.layer, ), ), ), @@ -1738,6 +1744,7 @@ it.effect("a missing thread worktree gets its project's skills in the cwd snapsh Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({}), Layer.mock(ServerEnvironment.ServerEnvironment)({}), Layer.mock(EnvironmentAuth.EnvironmentAuth)({}), + Layer.mock(ThreadMoveService.ThreadMoveService)({}), ); const server = yield* RpcServer.makeNoSerialization( RpcGroup.make(WsServerRefreshProvidersRpc), @@ -1822,6 +1829,7 @@ it.effect("a git action that fails partway still refreshes the repository status Layer.mock(DirectEndpoints.DirectEndpoints)({}), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/unused" }), Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({}), + Layer.mock(ThreadMoveService.ThreadMoveService)({}), Layer.mock(ServerEnvironment.ServerEnvironment)({}), Layer.mock(EnvironmentAuth.EnvironmentAuth)({}), ); @@ -1902,6 +1910,7 @@ it.effect("a publish whose push fails still refreshes the new remote", () => Layer.mock(DirectEndpoints.DirectEndpoints)({}), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/unused" }), Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({}), + Layer.mock(ThreadMoveService.ThreadMoveService)({}), Layer.mock(ServerEnvironment.ServerEnvironment)({}), Layer.mock(EnvironmentAuth.EnvironmentAuth)({}), ); @@ -2362,6 +2371,7 @@ it.effect("ACP session RPCs list a thread missing its history and repair it on i Layer.mock(DirectEndpoints.DirectEndpoints)({}), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/unused" }), Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({}), + Layer.mock(ThreadMoveService.ThreadMoveService)({}), Layer.mock(ServerEnvironment.ServerEnvironment)({}), Layer.mock(EnvironmentAuth.EnvironmentAuth)({}), Layer.mock(ProjectService.ProjectService)({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index dfb96d3fc84f8..f37483440bdb0 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -77,6 +77,7 @@ import { ProjectSearchEntriesError, ProjectWriteFileError, ProjectMutationError, + ThreadMoveError, ProviderUploadFeedbackError, ProviderSetupError, RelayClientInstallFailedError, @@ -222,6 +223,7 @@ import * as ProjectService from "./project/ProjectService.ts"; import { threadWorkspaceRoot } from "./project/threadWorkspaceRoot.ts"; import * as ManagedProjectFolders from "./project/ManagedProjectFolders.ts"; import * as ScratchThreadPromotion from "./project/ScratchThreadPromotion.ts"; +import * as ThreadMoveService from "./project/ThreadMoveService.ts"; import { mutateProjectWithClone } from "./project/ProjectMutation.ts"; import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; @@ -1441,6 +1443,7 @@ export const layerWsRpc = ( const projectService = yield* ProjectService.ProjectService; const managedFolders = yield* ManagedProjectFolders.ManagedProjectFolders; const scratchThreadPromotion = yield* ScratchThreadPromotion.ScratchThreadPromotion; + const threadMove = yield* ThreadMoveService.ThreadMoveService; const eventLoopMonitor = yield* EventLoopMonitor.EventLoopMonitor; const threadSearch = yield* ThreadSearch.ThreadSearch; @@ -2991,6 +2994,14 @@ export const layerWsRpc = ( }), ), ), + [WS_METHODS.threadsMove]: (input) => + Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId }).pipe( + Effect.andThen(startup.enqueueCommand(threadMove.move(input))), + Effect.catchTags({ + ServerRuntimeStartupError: (cause) => + new ThreadMoveError({ message: "Failed to move the thread.", cause }), + }), + ), [WS_METHODS.shellOpenInEditor]: (input) => externalLauncher.launchEditor(input), [WS_METHODS.filesystemGetMetadata]: (input) => workspaceFileSystem.getMetadata(input), [WS_METHODS.filesystemBrowse]: (input) => diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index 718a9dda93c0e..3aa3258dfc2c3 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -196,7 +196,8 @@ describe("command permissions", () => { }), ); - it.effect.each([WS_METHODS.scheduledTasksDelete, ORCHESTRATION_V2_WS_METHODS.stopWorkflow])( + const { scheduledTasksDelete, threadsMove } = WS_METHODS; + it.effect.each([scheduledTasksDelete, threadsMove, ORCHESTRATION_V2_WS_METHODS.stopWorkflow])( "uses the target grant for both availability and execution of %s", (method) => Effect.gen(function* () { diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 8681c1ea8abeb..b98ed5b02592a 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -43,6 +43,7 @@ export const CLIENT_GUARDED_RPC_SCOPES = { // Writing a guide runs a model on this environment's providers, which reading one does not. [WS_METHODS.pullRequestsGenerateReviewGuide]: AuthOrchestrationOperateScope, [WS_METHODS.projectsCreateFromThread]: AuthOrchestrationOperateScope, + [WS_METHODS.threadsMove]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlCloneRepository]: AuthSourceControlWriteScope, [WS_METHODS.sourceControlPublishRepository]: AuthSourceControlWriteScope, [WS_METHODS.sourceControlStar]: AuthSourceControlWriteScope, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index ad1cfe30b0214..30e1b7d36196e 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -136,6 +136,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threadActiveReorder: Schema.optionalKey(Schema.Boolean), threadAutoSettleOptOut: Schema.optionalKey(Schema.Boolean), threadProjectFromScratch: Schema.optionalKey(Schema.Boolean), + threadWorkingFolderMove: Schema.optionalKey(Schema.Boolean), threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), threadVisitedTracking: Schema.optionalKey(Schema.Boolean), threadPullRequestLinking: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index 79cba2574118b..8a44c517350d9 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -109,6 +109,11 @@ describe("orchestration V2 contracts", () => { }); const known = item("item-known", "system_notice", { message: "Hello" }); const future = item("item-future", "hologram", { beam: "ref-1" }); + const notice = item("item-notice", "system_notice", { message: "Moved", previousFolder: "/a" }); + expect(decodeOrchestrationV2TurnItemJson(known)).not.toHaveProperty("previousFolder"); + expect(encodeOrchestrationV2TurnItemJson(decodeOrchestrationV2TurnItemJson(notice))).toEqual( + notice, + ); const projected = (position: number, turnItem: { readonly id: string }) => ({ position, visibility: "local", @@ -226,7 +231,7 @@ describe("orchestration V2 contracts", () => { ).toThrow(); }); - it("decodes persisted capability snapshots that predate runtimePolicy", () => { + it("decodes persisted capability snapshots that predate runtimePolicy and supportsFolderMove", () => { const legacyCapabilities = { sessions: { supportsMultipleProviderThreadsPerSession: false, @@ -323,9 +328,11 @@ describe("orchestration V2 contracts", () => { const explicit = decodeOrchestrationV2ProviderCapabilities({ ...legacyCapabilities, + sessions: { ...legacyCapabilities.sessions, supportsFolderMove: true }, runtimePolicy: { enforcement: "native" }, }); expect(explicit.runtimePolicy).toEqual({ enforcement: "native" }); + expect(explicit.sessions.supportsFolderMove).toBe(true); }); it.each([" indented prompt\n", "\n \t\n"])( diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index f78ff8270957c..5f47addfa5414 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -277,6 +277,7 @@ export const OrchestrationV2SessionCapabilities = Schema.Struct({ supportsProviderSwitchingViaHandoff: Schema.Boolean, supportsRuntimeModeSwitchInSession: Schema.Boolean, pendingRequestsSurviveRestart: Schema.Boolean, + supportsFolderMove: Schema.optional(Schema.Boolean), }); export type OrchestrationV2SessionCapabilities = typeof OrchestrationV2SessionCapabilities.Type; @@ -1841,6 +1842,7 @@ export const OrchestrationV2TurnItem = Schema.Union([ ...OrchestrationV2TurnItemBaseFields, type: Schema.Literal("system_notice"), message: Schema.String, + previousFolder: Schema.optional(Schema.String), level: Schema.optional(OrchestrationV2SystemNoticeLevel), }), Schema.Struct({ @@ -2727,6 +2729,7 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ ...OrchestrationV2TurnItemJsonBaseFields, type: Schema.Literal("system_notice"), message: Schema.String, + previousFolder: Schema.optional(Schema.String), level: Schema.optional(OrchestrationV2SystemNoticeLevel), }), Schema.Struct({ @@ -3821,6 +3824,17 @@ export type OrchestrationV2InternalCommand = typeof OrchestrationV2InternalComma export type OrchestrationV2ServerCommand = OrchestrationV2Command | OrchestrationV2InternalCommand; +// The move service records it through the event sink; the orchestrator never dispatches it. +export const OrchestrationV2ThreadProjectMoveCommand = Schema.Struct({ + type: Schema.Literal("thread.project.move"), + commandId: CommandId, + threadId: ThreadId, + projectId: ProjectId, + worktreePath: Schema.NullOr(TrimmedNonEmptyString), +}); +export type OrchestrationV2ThreadProjectMoveCommand = + typeof OrchestrationV2ThreadProjectMoveCommand.Type; + export const ORCHESTRATION_V2_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", getTurnDiff: "orchestration.getTurnDiff", diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index c3da9f2fe95cf..092b3ac8076bc 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; +import { ProjectId, ThreadId } from "./baseSchemas.ts"; import { OrchestrationProjectShell } from "./orchestrationProject.ts"; import { @@ -20,11 +21,17 @@ import { ProjectWriteFileError, ProjectWriteFileInput, ProjectWriteFileResult, + ThreadMoveInput, + ThreadMoveResult, } from "./project.ts"; const decodeProjectCreatePayload = Schema.decodeUnknownSync(ProjectCreatePayload); const decodeProjectUpdatePayload = Schema.decodeUnknownSync(ProjectUpdatePayload); const decodeProjectMutation = Schema.decodeUnknownSync(ProjectMutation); +const encodeThreadMoveInput = Schema.encodeSync(ThreadMoveInput); +const decodeThreadMoveInput = Schema.decodeUnknownSync(ThreadMoveInput); +const encodeThreadMoveResult = Schema.encodeSync(ThreadMoveResult); +const decodeThreadMoveResult = Schema.decodeUnknownSync(ThreadMoveResult); describe("project RPC errors", () => { it("keeps whitespace in file paths across the wire", () => { @@ -169,6 +176,42 @@ describe("shared project payloads", () => { decodeProjectMutation({ type: "project.update", ...envelope, title: undefined }), ).toHaveProperty("title", undefined); }); + + it("round-trips thread moves, keeping a quoted path's spaces, and each of their results", () => { + const threadId = ThreadId.make("thread"); + const thread = { threadId, title: "Fix links" }; + const project = { projectId: ProjectId.make("project"), title: "App" }; + const inputs: ReadonlyArray = [ + { threadId, destination: " ../my app ", addProject: true, confirm: true }, + { threadId, destination: null, confirm: true, approved: { from: "/w", to: null } }, + ]; + const results: ReadonlyArray = [ + { _tag: "moved", folder: "/repo/b", members: [thread] }, + { + _tag: "needs-project", + folder: "/repo/a/docs", + nestedIn: project, + resolvedFolders: { from: "/repo/a", to: "/repo/a/docs" }, + }, + { + _tag: "confirm", + from: "/w", + to: null, + staysOnDisk: "The folder /w stays on disk with all its files until you remove it.", + wayBack: "add-project", + project, + otherThreads: [thread], + resolvedFolders: { from: "/real/w", to: null }, + }, + { _tag: "refused", reason: "Move the parent instead.", thread, projects: [project] }, + ]; + for (const input of inputs) { + expect(decodeThreadMoveInput(encodeThreadMoveInput(input))).toEqual(input); + } + for (const result of results) { + expect(decodeThreadMoveResult(encodeThreadMoveResult(result))).toEqual(result); + } + }); }); const decodeFaviconPath = Schema.decodeUnknownEffect(ProjectFaviconPath); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 80848ba81f557..68169172ce76d 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -553,6 +553,87 @@ export const ProjectCreateFromThreadResult = Schema.Struct({ }); export type ProjectCreateFromThreadResult = typeof ProjectCreateFromThreadResult.Type; +const ThreadMoveThread = Schema.Struct({ threadId: ThreadId, title: TrimmedNonEmptyString }); +const ThreadMoveProject = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString }); + +export const ThreadMoveInput = Schema.Struct({ + threadId: ThreadId, + /** A path the server resolves against the thread's folder; null moves it to No project. */ + destination: Schema.NullOr(Schema.NonEmptyString), + addProject: Schema.optional(Schema.Boolean), + confirm: Schema.optional(Schema.Boolean), + /** + * The `resolvedFolders` of the question this call answers. The server refuses when the thread + * left `from` or the destination no longer resolves to `to`. + */ + approved: Schema.optional( + Schema.Struct({ + from: Schema.optional(Schema.NonEmptyString), + /** Null for a new scratch folder. */ + to: Schema.NullOr(Schema.NonEmptyString), + }), + ), +}); +export type ThreadMoveInput = typeof ThreadMoveInput.Type; + +/** + * The thread's folder and the destination of a question, with symlinks resolved. The dialog + * shows the paths the user knows, and the answer sends these back as `approved`. + */ +const ThreadMoveResolvedFolders = Schema.Struct({ + from: Schema.NonEmptyString, + /** Null for a new scratch folder. */ + to: Schema.NullOr(Schema.NonEmptyString), +}); + +export const ThreadMoveResult = Schema.Union([ + /** No members when the thread already works in `folder`. */ + Schema.TaggedStruct("moved", { + folder: Schema.NonEmptyString, + members: Schema.Array(ThreadMoveThread), + }), + Schema.TaggedStruct("needs-project", { + folder: Schema.NonEmptyString, + nestedIn: Schema.optional(ThreadMoveProject), + resolvedFolders: ThreadMoveResolvedFolders, + }), + Schema.TaggedStruct("confirm", { + from: Schema.NonEmptyString, + /** Null for a new scratch folder. */ + to: Schema.NullOr(Schema.NonEmptyString), + staysOnDisk: Schema.String, + wayBack: Schema.Literals(["cd", "add-project", "none"]), + /** Named instead of its root threads when `from` is its root. */ + project: Schema.optional(ThreadMoveProject), + otherThreads: Schema.Array(ThreadMoveThread), + resolvedFolders: ThreadMoveResolvedFolders, + }), + Schema.TaggedStruct("refused", { + reason: Schema.String, + thread: Schema.optional(ThreadMoveThread), + projects: Schema.optional(Schema.Array(ThreadMoveProject)), + }), +]); +export type ThreadMoveResult = typeof ThreadMoveResult.Type; + +export const ThreadMoveStage = Schema.Literals([ + "check", + "add-project", + "claim-folder", + "plan", + "commit", +]); +export type ThreadMoveStage = typeof ThreadMoveStage.Type; + +export class ThreadMoveError extends Schema.TaggedError()("ThreadMoveError", { + /** Absent when the server could not start the move. */ + stage: Schema.optional(ThreadMoveStage), + /** The file or folder that the failed step read. */ + path: Schema.optional(Schema.String), + message: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) {} + export class ProjectWriteFileError extends Schema.TaggedError()( "ProjectWriteFileError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 0c0aeda6e559a..6ccae4434e013 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -259,6 +259,9 @@ import { ProjectWriteFileError, ProjectWriteFileInput, ProjectWriteFileResult, + ThreadMoveError, + ThreadMoveInput, + ThreadMoveResult, } from "./project.ts"; import { TerminalAttachInput, @@ -425,6 +428,7 @@ export const WS_METHODS = { projectsEnsureScratch: "projects.ensureScratch", projectsCreateNew: "projects.createNew", projectsCreateFromThread: "projects.createFromThread", + threadsMove: "threads.move", shellOpenInEditor: "shell.openInEditor", @@ -1406,6 +1410,12 @@ const WsProjectsMutateRpc = Rpc.make(WS_METHODS.projectsMutate, { error: Schema.Union([ProjectMutationError, EnvironmentAuthorizationError]), }); +const WsThreadsMoveRpc = Rpc.make(WS_METHODS.threadsMove, { + payload: ThreadMoveInput, + success: ThreadMoveResult, + error: Schema.Union([ThreadMoveError, EnvironmentAuthorizationError]), +}); + const WsProjectsEnsureScratchRpc = Rpc.make(WS_METHODS.projectsEnsureScratch, { payload: Schema.Struct({}), success: ProjectEnsureScratchResult, @@ -2233,6 +2243,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsCreateFromThreadRpc, WsProjectsWriteFileRpc, WsProjectsMutateRpc, + WsThreadsMoveRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, WsFilesystemGetMetadataRpc, From 4e233095d3e88ef65cb303360aa612867dd42c6f Mon Sep 17 00:00:00 2001 From: "Claude Fable 5.2" <354270077+abominable-intelligence@users.noreply.github.com> Date: Sat, 14 Nov 2026 16:50:41 +0100 Subject: [PATCH 4/6] feat(web): /cd and /no move the thread from the composer `/cd ` moves the thread to another folder, and `/no` moves it to a new scratch folder outside any project. Both call `threads.move`, and both work when no provider is enabled. `/cd` with a name lists the matching projects. A typed path lists folders, and Tab completes. The folder listing now says when the server could not read a folder, and the menu does not offer that folder. When the server asks first, the existing confirm dialog shows the question. The timeline shows a move as a "Moved to" row that expands to the full folders. Co-Authored-By: Martin Zeman --- .../src/workspace/WorkspaceEntries.test.ts | 34 ++ apps/server/src/workspace/WorkspaceEntries.ts | 9 +- apps/web/src/components/ChatView.tsx | 9 +- apps/web/src/components/chat/ChatComposer.tsx | 140 +++++++- .../components/chat/ComposerCommandMenu.tsx | 61 +++- .../chat/MessagesTimeline.browser.test.tsx | 27 ++ .../chat/ThreadMoveReceiptDetails.tsx | 29 ++ .../components/chat/TimelineWorkEntryRow.tsx | 59 +++- .../chat/composerCommandKey.test.ts | 104 ++++++ .../src/components/chat/composerCommandKey.ts | 20 +- .../chat/composerMenuHighlight.test.ts | 21 ++ .../components/chat/composerMenuHighlight.ts | 4 +- .../chat/composerMenuItems.logic.test.ts | 26 ++ .../chat/composerMenuItems.logic.ts | 35 ++ .../chat/composerSend.logic.test.ts | 157 ++++++++- .../src/components/chat/composerSend.logic.ts | 143 ++++++++- .../chat/composerSlashCommandSearch.ts | 6 +- .../chat/composerSubmission.test.ts | 13 + .../src/components/chat/composerSubmission.ts | 5 +- .../chat/folderMoveDestinations.test.ts | 217 +++++++++++++ .../components/chat/folderMoveDestinations.ts | 303 ++++++++++++++++++ .../src/components/chat/threadMoveToast.tsx | 19 ++ .../components/chat/useComposerMenuItems.ts | 33 +- .../chat/useComposerSend.browser.test.tsx | 250 +++++++++++++++ .../src/components/chat/useComposerSend.ts | 117 ++++++- .../src/components/chat/useFolderMoveMenu.ts | 119 +++++++ apps/web/src/composer-logic.test.ts | 35 +- apps/web/src/composer-logic.ts | 24 +- apps/web/src/session-logic.test.ts | 49 +++ apps/web/src/session-logic.ts | 12 +- docs/user/composer.md | 9 + .../src/state/threadCommands.ts | 6 + packages/contracts/src/filesystem.ts | 2 + 33 files changed, 2058 insertions(+), 39 deletions(-) create mode 100644 apps/web/src/components/chat/ThreadMoveReceiptDetails.tsx create mode 100644 apps/web/src/components/chat/composerCommandKey.test.ts create mode 100644 apps/web/src/components/chat/composerMenuItems.logic.test.ts create mode 100644 apps/web/src/components/chat/composerMenuItems.logic.ts create mode 100644 apps/web/src/components/chat/folderMoveDestinations.test.ts create mode 100644 apps/web/src/components/chat/folderMoveDestinations.ts create mode 100644 apps/web/src/components/chat/threadMoveToast.tsx create mode 100644 apps/web/src/components/chat/useComposerSend.browser.test.tsx create mode 100644 apps/web/src/components/chat/useFolderMoveMenu.ts diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 1c50b94a7cbcc..556a667679fc0 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -1041,6 +1041,40 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); + it.effect("marks a folder it may not read as unreadable", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const path = yield* Path.Path; + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-unreadable-" }); + yield* writeTextFile(cwd, "locked/inner/file.txt", ""); + const locked = path.join(cwd, "locked"); + yield* Effect.promise(() => NodeFSP.chmod(locked, 0o000)); + const unlock = Effect.promise(() => NodeFSP.chmod(locked, 0o755)); + // Root reads a folder without the read permission, so the case does not apply. + const readsAnyway = yield* Effect.promise(() => + NodeFSP.readdir(locked).then( + () => true, + () => false, + ), + ); + if (readsAnyway) return yield* unlock; + + const lockedResult = yield* workspaceEntries + .browse({ partialPath: `${locked}${path.sep}` }) + .pipe(Effect.ensuring(unlock)); + const readableResult = yield* workspaceEntries.browse({ + partialPath: `${cwd}${path.sep}`, + }); + + expect(lockedResult).toEqual({ parentPath: locked, entries: [], unreadable: true }); + expect(readableResult).toEqual({ + parentPath: cwd, + entries: [{ name: "locked", fullPath: locked }], + }); + }), + ); + it.effect("marks only a missing directory as missing when browsing fails", () => Effect.gen(function* () { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 51b1720126536..cebbc90cd3225 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -212,6 +212,7 @@ export const make = Effect.gen(function* () { const parentPath = endsWithSeparator ? resolvedInputPath : path.dirname(resolvedInputPath); const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath); + let unreadable = false; const dirents = yield* Effect.tryPromise({ try: () => NodeFSP.readdir(parentPath, { withFileTypes: true }), catch: (cause) => @@ -227,7 +228,12 @@ export const make = Effect.gen(function* () { const code = (error.cause as NodeJS.ErrnoException | undefined)?.code; return code === "EACCES" || code === "EPERM"; }, - () => Effect.succeed([]), + // The listing stays empty, and the flag tells a client that the folder was not read. + () => + Effect.sync(() => { + unreadable = true; + return []; + }), ), ); @@ -250,6 +256,7 @@ export const make = Effect.gen(function* () { return { parentPath, entries: entries.toSorted((left, right) => left.name.localeCompare(right.name)), + ...(unreadable ? { unreadable: true } : {}), }; }, ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1110c730a722a..532a25c9c45e2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2924,7 +2924,7 @@ export default function ChatView(props: ChatViewProps) { scrollToEnd, setThreadError, }); - const { onSend } = useComposerSend({ + const { onSend, refuseFolderMove } = useComposerSend({ environmentId, routeThreadKey, currentRouteThreadKeyRef, @@ -3011,6 +3011,7 @@ export default function ChatView(props: ChatViewProps) { }; const onWorktreeSetupWorkLocallyStable = useStableCallback(onWorktreeSetupWorkLocally); const onComposerSendStable = useStableCallback(onSend); + const refuseFolderMoveStable = useStableCallback(refuseFolderMove); const sendPreviewAnnotation = ( annotation: PreviewAnnotationPayload, image: ComposerImageAttachment | null, @@ -3689,6 +3690,12 @@ export default function ChatView(props: ChatViewProps) { onToggleKeepFullHistory={toggleKeepFullHistoryStable} sideChatAvailable={sideChat.available} sideChatDisabledReason={sideChatDisabledReason} + folderMoveAvailable={ + serverConfig?.environment.capabilities.threadWorkingFolderMove + } + isLocalDraftThread={isLocalDraftThread} + folderMoveInNoProject={activeProjectIsScratch} + onFolderMoveRefused={refuseFolderMoveStable} onFreshSession={ freshSessionCommandAvailable ? armFreshSessionStable : undefined } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 9426a0b9b9b3e..de52b224dce51 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -78,6 +78,7 @@ import { composerSubmissionIntentForKey, composerStateAtPromptEnd, detectComposerTrigger, + parseFolderMoveCommand, expandCollapsedComposerCursor, formatAssistantCitationForComposer, isPathSuggestionCurrent, @@ -214,6 +215,8 @@ import { CollapsedComposerStopAction, ComposerPrimaryActions } from "./ComposerP import { ComposerFooterPrimaryActions } from "./ComposerFooterControls"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; +import { folderMoveMenuBlockReason } from "./composerSend.logic"; +import { folderMoveHighlightsFirstRow, folderMoveTabCompletion } from "./folderMoveDestinations"; import { buildPullRequestReferenceContext } from "../pullRequest/pullRequestDetail.logic"; import { getComposerPromptInjectionState, getComposerReasoningStep } from "./composerProviderState"; import { readPromptUltrathink } from "./TraitsPicker"; @@ -386,6 +389,13 @@ export interface ChatComposerProps { onUsageLimitsCommand?: (() => void) | undefined; sideChatAvailable?: boolean; sideChatDisabledReason?: string | null; + folderMoveAvailable?: boolean | undefined; + /** The server has not seen the thread yet, so `/cd` and `/no` cannot move it. */ + isLocalDraftThread?: boolean | undefined; + /** The thread is in No project, so the `/cd` pane does not offer it. */ + folderMoveInNoProject?: boolean | undefined; + /** Shows the "Thread not moved" toast for a `/cd` or `/no` that the composer blocks. */ + onFolderMoveRefused?: ((reason: string) => void) | undefined; /** Arms a fresh session for the next message; absent where `/clear` is unavailable. */ onFreshSession?: (() => void) | undefined; environmentUnavailable: { @@ -986,7 +996,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return collapseExpandedComposerCursor(value, value.length, isLiteralPendingAnswer); }); const { - trigger: composerTrigger, + trigger: detectedComposerTrigger, setTrigger: setComposerTrigger, resolveTrigger: resolveComposerTrigger, dismissTrigger: dismissComposerTrigger, @@ -994,6 +1004,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } = useComposerTriggerState(() => isLiteralPendingAnswer ? null : detectComposerTrigger(prompt, prompt.length), ); + // `/cd ` opens the destination pane only where the server can move threads. + const offeredComposerTrigger = (trigger: ComposerTrigger | null) => + trigger?.kind === "folder" && props.folderMoveAvailable !== true ? null : trigger; + const composerTrigger = offeredComposerTrigger(detectedComposerTrigger); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); const composerSuggestionId = useId(); const composerSuggestionListId = `${composerSuggestionId}-${encodeURIComponent(draftId ?? activeThreadId ?? "new")}-suggestions`; @@ -1115,10 +1129,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerPreviewAnnotations.length === 0 && composerReviewComments.length === 0; const linkedEnvironments = useLinkedEnvironments(environmentId); + const folderMoveDisabledReason = folderMoveMenuBlockReason({ + isLocalDraftThread: props.isLocalDraftThread === true, + editingQueuedMessage: isEditingQueuedMessage, + requestPending: activePendingProgress !== null || pendingApprovals.length > 0, + running: phase === "running", + }); const { items: composerMenuItems, isLoading: isComposerMenuLoading, emptyState: composerMenuEmptyState, + folderMove: folderMoveMenu, } = useComposerMenuItems({ composerTrigger, environmentId, @@ -1130,6 +1151,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) threadPullRequests, sideChatAvailable: props.sideChatAvailable === true, sideChatDisabledReason: props.sideChatDisabledReason ?? null, + folderMoveAvailable: props.folderMoveAvailable === true, + folderMoveDisabledReason, + folderMoveCurrentFolder: gitCwd, + folderMoveInNoProject: props.folderMoveInNoProject === true, freshSessionAvailable: props.onFreshSession !== undefined, compactSlashCommandAvailable, selectedProvider, @@ -1150,6 +1175,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) highlightedItemId: composerHighlightedItemId, currentSearchKey: composerMenuSearchKey, highlightedSearchKey: composerHighlightedSearchKey, + highlightFirst: + composerTrigger?.kind !== "folder" || + folderMoveHighlightsFirstRow({ + query: composerTrigger.query, + isBrowsing: folderMoveMenu.isBrowsing, + }), }); const activeComposerMenuItem = composerMenuItems.find((item) => item.id === activeComposerMenuItemId) ?? null; @@ -1847,20 +1878,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // A compact footer gives the dictation capsule the whole row beside the send button. const dictationTakesFooter = isComposerFooterCompact && speechInput.state.phase !== "idle"; const submissionTarget = activePendingProgress ? "pending-user-input" : "provider-turn"; - const submissionBlocked = isComposerSubmissionBlocked({ + const submissionBlockers = { submissionTarget, noProviderAvailable, sendDisabledReason, attachmentBlockReason, environmentUnavailable: environmentUnavailable !== null, - }); + } as const; + const submissionBlocked = isComposerSubmissionBlocked(submissionBlockers); // The pending Submit button follows the answer's own blockers, matching submitComposer. const primaryActionSendDisabledReason = (activePendingProgress ? attachmentBlockReason : sendDisabledReason) ?? (speechInput.preparing ? "Preparing voice input" : null); + // A typed `/cd` or `/no` needs no provider, so the send button stays enabled without one. + const promptLacksProvider = + noProviderAvailable && + !(props.folderMoveAvailable === true && parseFolderMoveCommand(prompt) !== null); const primaryActionEnvironmentUnavailable = environmentUnavailable !== null || - (!activePendingProgress && (noProviderAvailable || projectSelectionRequired)); + (!activePendingProgress && (promptLacksProvider || projectSelectionRequired)); const dictationShortcutLabel = useDictationShortcut({ keybindings, speech: speechInput, @@ -1879,7 +1915,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) snapshot, trigger: isLiteralPendingAnswer ? null - : resolveComposerTrigger(detectComposerTrigger(snapshot.value, snapshot.expandedCursor)), + : offeredComposerTrigger( + resolveComposerTrigger(detectComposerTrigger(snapshot.value, snapshot.expandedCursor)), + ), }; }; @@ -1930,17 +1968,49 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return; } - if (item.command === "side" || item.command === "btw") { - if (props.sideChatDisabledReason) return; + if (["side", "btw", "cd", "no"].includes(item.command)) { + const movesFolder = item.command === "cd" || item.command === "no"; + if (movesFolder && item.blockReason !== undefined) { + // The row says why; the toast repeats it, and the text and the menu stay. + props.onFolderMoveRefused?.(item.blockReason); + return; + } + if (!movesFolder && props.sideChatDisabledReason) return; + // `/no` takes no argument, so choosing it runs it. + const runsNow = item.command === "no"; + const applied = applyPromptReplacement( + trigger.rangeStart, + trigger.rangeEnd, + runsNow ? "/no" : `/${item.command} `, + { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd) }, + ); + if (applied) setComposerHighlightedItemId(null); + if (applied && runsNow) composerFormRef.current?.requestSubmit(); + return; + } + return; + } + if (item.type === "folder-destination") { + if (trigger.kind !== "folder") return; + if (item.action.kind === "open") { const applied = applyPromptReplacement( trigger.rangeStart, trigger.rangeEnd, - `/${item.command} `, + item.action.query, { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd) }, ); if (applied) setComposerHighlightedItemId(null); return; } + // The row stands for a command: write it, close the pane, and run it. + const command = item.action.kind === "no-project" ? "/no" : `/cd ${item.action.path}`; + const applied = applyPromptReplacement(0, snapshot.value.length, command, { + expectedText: snapshot.value, + }); + if (!applied) return; + setComposerHighlightedItemId(null); + dismissComposerTrigger(detectComposerTrigger(command, command.length)); + composerFormRef.current?.requestSubmit(); return; } if (item.type === "provider-slash-command") { @@ -2078,6 +2148,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; const onComposerMenuItemHighlightedStable = useStableCallback(onComposerMenuItemHighlighted); + /** Tab in the `/cd` pane: enter the highlighted folder, or extend the typed name. */ + const completeFolderMovePath = () => { + const { snapshot, trigger } = resolveActiveComposerTrigger(); + if (trigger?.kind !== "folder") return; + const completion = folderMoveTabCompletion({ + query: trigger.query, + platform: folderMoveMenu.platform, + highlighted: + activeComposerMenuItem?.type === "folder-destination" ? activeComposerMenuItem : null, + entries: folderMoveMenu.entries, + }); + if (completion === null) return; + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, completion, { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + }); + if (applied) setComposerHighlightedItemId(null); + }; + const nudgeComposerMenuHighlight = (key: "ArrowDown" | "ArrowUp") => { if (composerMenuItems.length === 0) return; const highlightedIndex = composerMenuItems.findIndex( @@ -2128,8 +2216,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) submitAfterDictation(() => submitComposerRef.current(dispatchMode, submissionIntent)); return false; } - if (submissionBlocked || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) { + const folderMove = + props.folderMoveAvailable === true && !activePendingProgress + ? parseFolderMoveCommand(promptRef.current) + : null; + if ( + isComposerSubmissionBlocked({ ...submissionBlockers, movesFolder: folderMove !== null }) || + !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) + ) { + event?.preventDefault(); + // A chosen row submits without the send button, so a blocked move says why here. + if (folderMove && sendDisabledReason) props.onFolderMoveRefused?.(sendDisabledReason); + return false; + } + // `/cd` with no destination opens the pane to choose one, also after Escape closed it. + // While a move is blocked, the pane gives the reason. + if (folderMove?.command === "cd" && folderMove.path === null) { event?.preventDefault(); + const command = "/cd "; + if (applyPromptReplacement(0, promptRef.current.length, command)) { + resetComposerTrigger(detectComposerTrigger(command, command.length)); + } return false; } // A send while a pasted image is still compressing would strand that image: @@ -2347,6 +2454,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) dismissComposerTrigger, nudgeComposerMenuHighlight, onSelectComposerItem, + completeFolderMovePath, resolveActiveComposerTrigger, readComposerSnapshot, applyPromptReplacement, @@ -3539,9 +3647,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? "Finding pull request..." : composerTriggerKind === "skill" || composerTriggerKind === "slash-command" ? "Searching workspace skills..." - : "Searching workspace files..." + : composerTriggerKind === "folder" + ? "Loading folders..." + : "Searching workspace files..." : composerMenuEmptyState - : ""} + : composerSuggestionsVisible + ? (folderMoveMenu.footer ?? "") + : ""} {composerSuggestionsVisible && ( @@ -3552,6 +3664,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isLoading={isComposerMenuLoading} triggerKind={composerTriggerKind} emptyStateText={composerMenuEmptyState} + header={ + composerTriggerKind === "folder" && gitCwd + ? `Working in ${gitCwd}` + : undefined + } + footer={folderMoveMenu.footer ?? undefined} activeItemId={activeComposerMenuItem?.id ?? null} onHighlightedItemChange={onComposerMenuItemHighlightedStable} onSelect={onSelectComposerItemStable} diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 7d1bbd6121f56..c745fca15e2db 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -17,7 +17,11 @@ import { } from "@t3tools/contracts"; import { BlocksIcon, + CornerLeftUpIcon, + FolderDotIcon, + FolderGit2Icon, FolderIcon, + FolderInputIcon, MessagesSquareIcon, PackageIcon, SettingsIcon, @@ -32,6 +36,7 @@ import { Badge } from "../ui/badge"; import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; +import type { FolderMoveRow } from "./folderMoveDestinations"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; import { ComposerBanner } from "./ComposerBanner"; @@ -53,6 +58,8 @@ export type ComposerCommandItem = command: ComposerSlashCommand; label: string; description: string; + /** Why the command cannot run now. The description shows it without its final period. */ + blockReason?: string | undefined; } | { id: string; @@ -103,7 +110,8 @@ export type ComposerCommandItem = acpRegistryIconUrl: string | undefined; label: string; description: string; - }; + } + | (FolderMoveRow & { type: "folder-destination" }); export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { listId: string; @@ -112,11 +120,16 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { isLoading: boolean; triggerKind: ComposerTriggerKind | null; emptyStateText?: string; + /** A line above the rows, such as the folder a thread works in. */ + header?: string | undefined; + /** A line below the rows, such as why a typed name matches none of them. */ + footer?: string | undefined; activeItemId: string | null; onHighlightedItemChange: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void; }) { const listRef = useRef(null); + const firstItem = props.items[0]; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls the highlighted menu item's DOM node into view useLayoutEffect(() => { @@ -142,6 +155,9 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { className="flex min-h-0 w-full flex-col overflow-hidden pb-(--chat-composer-attachment-overlap) **:data-[slot=command-item]:rounded-lg **:data-[slot=scroll-area-scrollbar]:data-[orientation=vertical]:my-4" data-composer-command-drawer="true" > + {props.header ? ( +

{props.header}

+ ) : null} {props.items.length > 0 ? ( + {/* The rows of the `/cd` pane share one group, which the first row names. */} + {firstItem?.type === "folder-destination" ? ( +
+ {firstItem.group} +
+ ) : null} {props.items.map((item) => ( )} + {props.footer && props.items.length > 0 ? ( +

+ {props.footer} +

+ ) : null} ); @@ -201,6 +233,10 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { props.triggerKind === "slash-command" && props.item.type === "skill" ? props.item.skill : null; const pullRequestPresentation = props.item.type === "pull-request" ? resolvePullRequestState(props.item.pullRequest) : null; + const FolderMoveIcon = + props.item.type === "folder-destination" ? FOLDER_MOVE_ICON[props.item.icon] : null; + // Not `disabled`: that drops pointer events, and a click must still show why the row is blocked. + const blocked = props.item.type === "slash-command" && props.item.blockReason !== undefined; const descriptionRef = useRef(null); const [descriptionTruncated, setDescriptionTruncated] = useState(false); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures the rendered description so only cut-off text gets a tooltip @@ -215,6 +251,8 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { } aria-selected={props.isActive} + aria-disabled={blocked || undefined} + className={blocked ? "opacity-64" : undefined} value={props.item.id} data-composer-item-id={props.item.id} active={props.isActive} @@ -262,6 +300,9 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { className={cn("size-4 shrink-0", pullRequestPresentation.toneClassName)} /> ) : null} + {FolderMoveIcon ? ( + ); @@ -308,6 +356,15 @@ const LISTBOX_LABEL_BY_TRIGGER: Record = { "pull-request": "Pull requests", "slash-command": "Commands", skill: "Skills", + folder: "Destinations", +}; + +const FOLDER_MOVE_ICON: Record = { + project: FolderGit2Icon, + folder: FolderIcon, + parent: CornerLeftUpIcon, + use: FolderInputIcon, + "no-project": FolderDotIcon, }; const SKILL_SOURCE_ICON_BY_KIND: Record = { diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 8ac12b54a4e50..1477d101e0b83 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -2305,3 +2305,30 @@ it("keeps concurrent approvals actionable outside collapsed work and restores se .element(screen.getByRole("button", { name: "Open the current tab", exact: true })) .toBeVisible(); }); + +it("shows the full folders in an expanded move receipt", async () => { + const detail = [ + 'It came from project "A", which has its root at `/repos/a`.', + "The next turn runs in `` /repos/b`c ``.", + "Open terminals keep their folder.", + ].join("\n"); + await using screen = await renderTimeline([ + work("move-receipt", { + itemType: "system_notice", + label: "Moved to B", + detail, + projectedItem: v2Item("system_notice", { + id: "move-receipt", + message: `Moved to B.\n${detail}`, + previousFolder: "/repos/a", + }), + }), + ]); + expect(screen.text()).not.toContain("/repos/a"); + await click(screen.button("Moved to B")); + await vi.waitFor(() => + expect(screen.text()).toContain('It came from project "A", which has its root at /repos/a.'), + ); + const paths = [...screen.container.querySelectorAll("code")].map((code) => code.textContent); + expect(paths).toEqual(["/repos/a", "/repos/b`c"]); +}); diff --git a/apps/web/src/components/chat/ThreadMoveReceiptDetails.tsx b/apps/web/src/components/chat/ThreadMoveReceiptDetails.tsx new file mode 100644 index 0000000000000..df71f78d0f727 --- /dev/null +++ b/apps/web/src/components/chat/ThreadMoveReceiptDetails.tsx @@ -0,0 +1,29 @@ +import { Fragment } from "react"; + +import { splitInlineCode } from "./composerSend.logic"; + +/** + * The lines of a move receipt under its heading, with each folder as its full path. Markdown + * would show a path as a file chip with only its last segment, which hides where the thread was. + */ +export function ThreadMoveReceiptDetails({ text }: { readonly text: string }) { + const lines: Array<{ readonly offset: number; readonly text: string }> = []; + for (const line of text.split("\n")) { + const previous = lines.at(-1); + const offset = previous === undefined ? 0 : previous.offset + previous.text.length + 1; + lines.push({ offset, text: line }); + } + return lines.map((line) => ( +

+ {splitInlineCode(line.text).map((part) => + part.code ? ( + + {part.text} + + ) : ( + {part.text} + ), + )} +

+ )); +} diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx index eecd3c50bdd7c..e6e4066311a6b 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx @@ -34,7 +34,13 @@ import ChatMarkdown from "../ChatMarkdown"; import { ChatMarkdownAssetImage } from "./ChatMarkdownMedia"; import type { ExpandedImagePreview } from "./ExpandedImagePreview"; import { workGroupImageGallery } from "./markdownImageGallery"; -import { ChevronRightIcon, CircleAlertIcon, XIcon, RotateCcwIcon } from "lucide-react"; +import { + ArrowRightLeftIcon, + ChevronRightIcon, + CircleAlertIcon, + XIcon, + RotateCcwIcon, +} from "lucide-react"; import { Button, InlineButton } from "../ui/button"; import { useAssetUrls } from "../../assets/assetUrls"; import { useThreadShell } from "../../state/entities"; @@ -49,6 +55,7 @@ import { cn } from "~/lib/utils"; import { formatUpcomingTimestamp } from "../../timestampFormat"; import { FetchedToolOutput, V2ItemInspector } from "./V2ItemInspector"; import { SubagentNotificationLink } from "./V2LifecycleRow"; +import { ThreadMoveReceiptDetails } from "./ThreadMoveReceiptDetails"; import { TimelineRowCtx, TimelineRowActivityCtx, @@ -310,6 +317,56 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { ); } + if (failureItem?.type === "system_notice" && failureItem.previousFolder !== undefined) { + const details = workEntry.detail; + return ( + ) => { + if (e.target === e.currentTarget && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + toggleExpanded(); + } + }, + } + : {})} + icon={} + label={{workEntry.label}} + trailing={ + <> + + {details ? ( + + ) : null} + + } + > + {expanded && details ? ( + + + + ) : null} + + ); + } const iconConfig = workToneIcon(workEntry.tone); const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; const showFailedIndicator = diff --git a/apps/web/src/components/chat/composerCommandKey.test.ts b/apps/web/src/components/chat/composerCommandKey.test.ts new file mode 100644 index 0000000000000..f2d544732aaca --- /dev/null +++ b/apps/web/src/components/chat/composerCommandKey.test.ts @@ -0,0 +1,104 @@ +import { expect, it, vi } from "vite-plus/test"; + +import type { ComposerTrigger } from "../../composer-logic"; +import type { ComposerCommandItem } from "./ComposerCommandMenu"; +import { composerCommandKeyHandler } from "./composerCommandKey"; + +type Input = Parameters[0]; + +const projectRow = { + id: "folder-move:project:p", + type: "folder-destination", + group: "Projects", + icon: "project", + label: "p", + description: "/repos/p", + isProject: true, + action: { kind: "move", path: "/repos/p" }, +} satisfies ComposerCommandItem; + +function setup( + trigger: ComposerTrigger, + active: ComposerCommandItem | null, + items: ComposerCommandItem[] = [projectRow], +) { + const spies = { + onSelectComposerItem: vi.fn(), + completeFolderMovePath: vi.fn(), + submitComposer: vi.fn(), + nudgeComposerMenuHighlight: vi.fn(), + }; + const onKey = composerCommandKeyHandler({ + keybindings: [], + sendShortcut: "enter", + routeKind: "server", + phase: "idle", + isCompacting: false, + followUpBehavior: "queue", + isSoftwareKeyboardOpen: false, + planModeUiEnabled: false, + isLiteralPendingAnswer: false, + pendingUserInputs: [], + composerDraftTarget: { environmentId: "env-1", threadId: "thread-1" }, + promptRef: { current: "/cd " }, + composerMenuOpenRef: { current: true }, + composerMenuItemsRef: { current: items }, + activeComposerMenuItemRef: { current: active }, + resolveActiveComposerTrigger: () => ({ trigger }), + ...spies, + } as unknown as Input); + const press = (key: string) => + onKey(key, { key, shiftKey: false, altKey: false, ctrlKey: false, metaKey: false } as never); + return { press, ...spies }; +} + +const folder: ComposerTrigger = { kind: "folder", query: "", rangeStart: 4, rangeEnd: 4 }; +const typedFolder: ComposerTrigger = { ...folder, query: "/repos/", rangeEnd: 11 }; + +it("in the /cd pane, Enter runs the typed destination unless a row is highlighted", () => { + const typed = setup(typedFolder, null); + expect(typed.press("Enter")).toBe(true); + expect(typed.onSelectComposerItem).not.toHaveBeenCalled(); + expect(typed.nudgeComposerMenuHighlight).not.toHaveBeenCalled(); + expect(typed.submitComposer).toHaveBeenCalledTimes(1); + + const picked = setup(folder, projectRow); + expect(picked.press("Enter")).toBe(true); + expect(picked.onSelectComposerItem).toHaveBeenCalledWith(projectRow); + expect(picked.submitComposer).not.toHaveBeenCalled(); +}); + +it("in the bare /cd pane, Enter picks the first row and moves nothing", () => { + const bare = setup(folder, null); + expect(bare.press("Enter")).toBe(true); + expect(bare.nudgeComposerMenuHighlight).toHaveBeenCalledWith("ArrowDown"); + expect(bare.onSelectComposerItem).not.toHaveBeenCalled(); + expect(bare.submitComposer).not.toHaveBeenCalled(); + + // A pair of quotes is still no destination. + const quoted = setup({ ...folder, query: '""', rangeEnd: 6 }, null); + expect(quoted.press("Enter")).toBe(true); + expect(quoted.nudgeComposerMenuHighlight).toHaveBeenCalledWith("ArrowDown"); + expect(quoted.submitComposer).not.toHaveBeenCalled(); + + // With no row to pick, Enter falls through to the composer, which keeps the pane open. + const empty = setup(folder, null, []); + expect(empty.press("Enter")).toBe(true); + expect(empty.nudgeComposerMenuHighlight).not.toHaveBeenCalled(); + expect(empty.submitComposer).toHaveBeenCalledTimes(1); +}); + +it("in the /cd pane, Tab completes the path and arrows move the highlight", () => { + const pane = setup(folder, projectRow); + expect(pane.press("Tab")).toBe(true); + expect(pane.completeFolderMovePath).toHaveBeenCalledTimes(1); + expect(pane.onSelectComposerItem).not.toHaveBeenCalled(); + expect(pane.press("ArrowDown")).toBe(true); + expect(pane.nudgeComposerMenuHighlight).toHaveBeenCalledWith("ArrowDown"); +}); + +it("in another menu, Enter still takes the first row", () => { + const slash = setup({ kind: "slash-command", query: "", rangeStart: 0, rangeEnd: 1 }, null); + expect(slash.press("Enter")).toBe(true); + expect(slash.onSelectComposerItem).toHaveBeenCalledWith(projectRow); +}); diff --git a/apps/web/src/components/chat/composerCommandKey.ts b/apps/web/src/components/chat/composerCommandKey.ts index 40650fe6c8952..d9ef8cd6c11ac 100644 --- a/apps/web/src/components/chat/composerCommandKey.ts +++ b/apps/web/src/components/chat/composerCommandKey.ts @@ -17,6 +17,7 @@ import { composerSubmissionIntentForKey, } from "../../composer-logic"; import { type PendingUserInput } from "../../session-logic"; +import { folderMoveQueryPath } from "./folderMoveDestinations"; import { type PreviewAnnotationPayload } from "@t3tools/contracts"; import { type ReviewCommentContext } from "../../reviewCommentContext"; import { composerTargetKey } from "../../composerDraftStore"; @@ -69,6 +70,7 @@ export function composerCommandKeyHandler(input: { readonly dismissComposerTrigger: (trigger: ComposerTrigger | null) => void; readonly nudgeComposerMenuHighlight: (key: "ArrowDown" | "ArrowUp") => void; readonly onSelectComposerItem: (item: ComposerCommandItem) => void; + readonly completeFolderMovePath: () => void; readonly resolveActiveComposerTrigger: () => { trigger: ComposerTrigger | null }; readonly readComposerSnapshot: () => { value: string }; readonly applyPromptReplacement: ( @@ -116,6 +118,7 @@ export function composerCommandKeyHandler(input: { dismissComposerTrigger, nudgeComposerMenuHighlight, onSelectComposerItem, + completeFolderMovePath, resolveActiveComposerTrigger, readComposerSnapshot, applyPromptReplacement, @@ -228,7 +231,14 @@ export function composerCommandKeyHandler(input: { } if (menuIsActive && (submissionIntent === null || submissionIntent === "foreground")) { const currentItems = composerMenuItemsRef.current; - const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; + // In the `/cd` pane Enter runs the typed destination unless the user picked a row. + const isFolderPane = trigger?.kind === "folder"; + const selectedItem = + activeComposerMenuItemRef.current ?? (isFolderPane ? undefined : currentItems[0]); + if (isFolderPane && key === "Tab") { + completeFolderMovePath(); + return true; + } if (key === "ArrowDown" && currentItems.length > 0) { nudgeComposerMenuHighlight("ArrowDown"); return true; @@ -237,6 +247,14 @@ export function composerCommandKeyHandler(input: { nudgeComposerMenuHighlight("ArrowUp"); return true; } + // A bare `/cd` has no destination to run, so Enter picks the first row instead; the next + // Enter moves there. + const bareFolderPane = + trigger?.kind === "folder" && folderMoveQueryPath(trigger.query) === ""; + if (key === "Enter" && bareFolderPane && !selectedItem && currentItems.length > 0) { + nudgeComposerMenuHighlight("ArrowDown"); + return true; + } if ((key === "Enter" || key === "Tab") && selectedItem) { onSelectComposerItem(selectedItem); return true; diff --git a/apps/web/src/components/chat/composerMenuHighlight.test.ts b/apps/web/src/components/chat/composerMenuHighlight.test.ts index 5f37a01d0ef58..3344a7b3d0301 100644 --- a/apps/web/src/components/chat/composerMenuHighlight.test.ts +++ b/apps/web/src/components/chat/composerMenuHighlight.test.ts @@ -19,4 +19,25 @@ describe("resolveComposerMenuActiveItemId", () => { }), ).toBe("new-result"); }); + + it("leaves a menu without an active row until the user highlights one", () => { + const menu = { + items: [{ id: "first" }, { id: "second" }], + currentSearchKey: "folder:", + highlightedSearchKey: "folder:", + highlightFirst: false, + }; + expect(resolveComposerMenuActiveItemId({ ...menu, highlightedItemId: null })).toBeNull(); + expect(resolveComposerMenuActiveItemId({ ...menu, highlightedItemId: "second" })).toBe( + "second", + ); + // A row picked for another query does not carry over. + expect( + resolveComposerMenuActiveItemId({ + ...menu, + highlightedItemId: "second", + highlightedSearchKey: "folder:s", + }), + ).toBeNull(); + }); }); diff --git a/apps/web/src/components/chat/composerMenuHighlight.ts b/apps/web/src/components/chat/composerMenuHighlight.ts index 3cc3d4324fc98..10cbf538bf343 100644 --- a/apps/web/src/components/chat/composerMenuHighlight.ts +++ b/apps/web/src/components/chat/composerMenuHighlight.ts @@ -3,6 +3,8 @@ export function resolveComposerMenuActiveItemId(input: { highlightedItemId: string | null; currentSearchKey: string | null; highlightedSearchKey: string | null; + /** False leaves the menu without an active row until the user highlights one. */ + highlightFirst?: boolean; }): string | null { if (input.items.length === 0) { return null; @@ -16,5 +18,5 @@ export function resolveComposerMenuActiveItemId(input: { return input.highlightedItemId; } - return input.items[0]?.id ?? null; + return input.highlightFirst === false ? null : (input.items[0]?.id ?? null); } diff --git a/apps/web/src/components/chat/composerMenuItems.logic.test.ts b/apps/web/src/components/chat/composerMenuItems.logic.test.ts new file mode 100644 index 0000000000000..fe7352abc6258 --- /dev/null +++ b/apps/web/src/components/chat/composerMenuItems.logic.test.ts @@ -0,0 +1,26 @@ +import { expect, it } from "vite-plus/test"; + +import { folderMoveSlashCommandItems } from "./composerMenuItems.logic"; + +it("folderMoveSlashCommandItems blocks a row with the reason that choosing it reports", () => { + const rows = (input: Parameters[0]) => + folderMoveSlashCommandItems(input).map(({ label, blockReason }) => ({ label, blockReason })); + const free = { available: true, disabledReason: null, inNoProject: false }; + const cd = { label: "/cd" }; + const no = { label: "/no" }; + + // An environment whose server cannot move threads offers neither command. + expect(rows({ ...free, available: false })).toEqual([]); + expect(rows(free)).toEqual([cd, no]); + // `/no` has nowhere to go from No project, and `/cd` stays usable. + expect(rows({ ...free, inNoProject: true })).toEqual([ + cd, + { ...no, blockReason: expect.any(String) }, + ]); + // What blocks every move wins over the `/no` reason. + const reason = "This thread is still working. Try again when the turn ends."; + expect(rows({ ...free, inNoProject: true, disabledReason: reason })).toEqual([ + { ...cd, blockReason: reason }, + { ...no, blockReason: reason }, + ]); +}); diff --git a/apps/web/src/components/chat/composerMenuItems.logic.ts b/apps/web/src/components/chat/composerMenuItems.logic.ts new file mode 100644 index 0000000000000..1c7b855d0a63b --- /dev/null +++ b/apps/web/src/components/chat/composerMenuItems.logic.ts @@ -0,0 +1,35 @@ +import type { ComposerCommandItem } from "./ComposerCommandMenu"; + +type SlashCommandItem = Extract; + +/** + * The `/cd` and `/no` rows of the slash menu. A blocked row says why in its description, and + * keeps the full sentence for the toast that choosing the row shows. + */ +export function folderMoveSlashCommandItems(input: { + readonly available: boolean; + readonly disabledReason: string | null; + readonly inNoProject: boolean; +}): ReadonlyArray { + if (!input.available) return []; + return (["cd", "no"] as const).map((command) => { + // `/no` has nowhere to go from No project. The server refuses it with the same sentence. + const blockReason = + input.disabledReason ?? + (command === "no" && input.inNoProject ? "This thread is already in No project." : null); + return { + id: `slash:${command}`, + type: "slash-command", + command, + label: `/${command}`, + // A slash menu row ends with no period, like the rows around it. + description: + blockReason !== null + ? blockReason.replace(/\.$/u, "") + : command === "cd" + ? "Move this thread to another folder" + : "Move to No project in a new scratch folder", + ...(blockReason === null ? {} : { blockReason }), + }; + }); +} diff --git a/apps/web/src/components/chat/composerSend.logic.test.ts b/apps/web/src/components/chat/composerSend.logic.test.ts index 13e36ea5659c9..e46e246d8904f 100644 --- a/apps/web/src/components/chat/composerSend.logic.test.ts +++ b/apps/web/src/components/chat/composerSend.logic.test.ts @@ -1,4 +1,10 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + EnvironmentId, + ProjectId, + ThreadId, + type ThreadMoveInput, + type ThreadMoveResult, +} from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { describe, expect, it } from "vite-plus/test"; import { formatInlineContextReference } from "../../lib/composerContextReferences"; @@ -6,9 +12,14 @@ import { threadContextReference } from "../../lib/composerContextRecords"; import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; import { approvalRedirectNotice, + folderMoveBlockReason, loadBalancingSendWarning, multipleModelSendBlock, referencedThreadContexts, + runThreadMove, + folderMoveMenuBlockReason, + splitInlineCode, + threadMoveConfirmMessage, } from "./composerSend.logic"; const local = EnvironmentId.make("local"); @@ -124,3 +135,147 @@ describe("approvalRedirectNotice", () => { expect(approvalRedirectNotice(settled)).toBeNull(); }); }); + +// The toast and the slash menu give a draft the same sentence. +const draftReason = + "Press Esc, then pick the project in the heading above the composer before the first message."; + +it("folderMoveBlockReason refuses locally in a local draft and with context", () => { + const ready = { + supported: true, + isLocalDraftThread: false, + editingQueuedMessage: false, + requestPending: false, + textOnly: true, + }; + expect(folderMoveBlockReason({ ...ready, isLocalDraftThread: true })).toBe(draftReason); + expect(folderMoveBlockReason({ ...ready, textOnly: false })).toMatch(/attachments/); +}); + +it("folderMoveMenuBlockReason names the first thing that blocks a move", () => { + const free = { + isLocalDraftThread: false, + editingQueuedMessage: false, + requestPending: false, + running: false, + }; + expect(folderMoveMenuBlockReason(free)).toBeNull(); + expect(folderMoveMenuBlockReason({ ...free, running: true })).toMatch(/still working/); + expect(folderMoveMenuBlockReason({ ...free, running: true, requestPending: true })).toMatch( + /pending question/, + ); + expect(folderMoveMenuBlockReason({ ...free, running: true, isLocalDraftThread: true })).toBe( + draftReason, + ); +}); + +it("splitInlineCode gives each path its own part and keeps the text around it", () => { + expect(splitInlineCode("Busy.")).toEqual([{ code: false, text: "Busy.", offset: 0 }]); + expect(splitInlineCode("No folder at `/a b/c`.")).toEqual([ + { code: false, text: "No folder at ", offset: 0 }, + { code: true, text: "/a b/c", offset: 13 }, + { code: false, text: ".", offset: 21 }, + ]); + expect(splitInlineCode("The folder `/s` is a scratch folder. Use /no.")).toEqual([ + { code: false, text: "The folder ", offset: 0 }, + { code: true, text: "/s", offset: 11 }, + { code: false, text: " is a scratch folder. Use /no.", offset: 15 }, + ]); + // The receipt writes a path with a backtick in double backticks. + expect(splitInlineCode("The next turn runs in `` /repos/b`c ``.")).toEqual([ + { code: false, text: "The next turn runs in ", offset: 0 }, + { code: true, text: "/repos/b`c", offset: 22 }, + { code: false, text: ".", offset: 38 }, + ]); +}); + +const threadId = ThreadId.make("thread-1"); +const request: ThreadMoveInput = { threadId, destination: "app" }; +const needsProject = { + _tag: "needs-project", + folder: "/repo/app", + resolvedFolders: { from: "/real/repo", to: "/real/app" }, +} as const; +const confirm = { + _tag: "confirm", + from: "/worktrees/w", + to: "/repo/app", + staysOnDisk: "The worktree stays on disk at /worktrees/w.", + wayBack: "add-project", + otherThreads: [{ threadId, title: "Fork" }], + resolvedFolders: { from: "/real/w", to: "/real/app" }, +} as const satisfies ThreadMoveResult; +const moved = { _tag: "moved", folder: "/repo/app", members: [] } as const; + +it("runThreadMove asks each question once, keeps every flag, sends back each question's resolved folders, and stops at a decline", async () => { + const calls: ThreadMoveInput[] = []; + const answers: ReadonlyArray = [needsProject, confirm, moved]; + const move = async (input: ThreadMoveInput) => answers[calls.push(input) - 1]!; + expect(await runThreadMove(request, move, async () => true)).toBe(moved); + expect(calls).toEqual([ + request, + { ...request, addProject: true, approved: needsProject.resolvedFolders }, + { ...request, addProject: true, confirm: true, approved: confirm.resolvedFolders }, + ]); + + calls.length = 0; + expect(await runThreadMove(request, move, async (q) => q._tag === "needs-project")).toBeNull(); + expect(calls).toHaveLength(2); + const refused = { _tag: "refused", reason: "A member is busy." } as const; + const ask = () => Promise.reject(new Error("asked")); + expect(await runThreadMove(request, async () => refused, ask)).toBe(refused); +}); + +// A confirm message is the title, a line for each folder, an empty line, then the notes. +const readConfirm = (message: string) => { + const [title = "", ...lines] = message.split("\n"); + const gap = lines.indexOf(""); + return { + title, + folders: lines.slice(0, gap).map((line) => line.slice(line.indexOf(": ") + 2)), + notes: lines.slice(gap + 1).join("\n"), + }; +}; + +// The confirm dialog takes its title from the first line, so the question leads each message. +it("threadMoveConfirmMessage names nesting, both folders once, the way back and threads", () => { + const nestedIn = { projectId: ProjectId.make("p"), title: "App" }; + const nested = readConfirm(threadMoveConfirmMessage({ ...needsProject, nestedIn }, "/repo")); + expect(nested.folders).toEqual(["/repo", "/repo/app"]); + expect(nested.notes).toContain("App"); + // Without a known current folder, only the destination has a line. Without nesting, no project. + expect(readConfirm(threadMoveConfirmMessage(needsProject, null))).toEqual({ + title: nested.title, + folders: ["/repo/app"], + notes: expect.not.stringContaining("App"), + }); + + const stays = "stays on disk with all its files until you remove it."; + const leaving = { ...confirm, staysOnDisk: `The folder /worktrees/w ${stays}` }; + const scratch = readConfirm( + threadMoveConfirmMessage({ ...leaving, to: null, wayBack: "none" }, null), + ); + const worktree = readConfirm(threadMoveConfirmMessage(leaving, null)); + const rooted = readConfirm(threadMoveConfirmMessage({ ...leaving, wayBack: "cd" }, null)); + // A new scratch folder has no path yet. + expect(scratch.folders).toEqual(["/worktrees/w", expect.not.stringContaining("/")]); + expect(worktree.folders).toEqual(["/worktrees/w", "/repo/app"]); + expect(rooted.folders).toEqual(worktree.folders); + for (const { notes } of [scratch, worktree, rooted]) { + // The server's sentence stays, without the path that the From line shows. + expect(notes).toContain(`The folder ${stays}`); + expect(notes).toContain("Fork"); + } + // Each way back has its own note, and only a folder with a way back names /cd. + expect(new Set([scratch.notes, worktree.notes, rooted.notes]).size).toBe(3); + expect(scratch.notes).not.toContain("/cd"); + expect(worktree.notes).toContain("/cd"); + expect(rooted.notes).toContain("/cd"); + // A new project, a scratch folder and a worktree each have their own question. + expect(rooted.title).toBe(worktree.title); + expect(new Set([nested.title, scratch.title, worktree.title]).size).toBe(3); + for (const { title, folders, notes } of [nested, scratch, worktree, rooted]) { + expect(title).toMatch(/^[^/]+\?$/); + for (const folder of folders) expect(notes).not.toContain(folder); + } +}); diff --git a/apps/web/src/components/chat/composerSend.logic.ts b/apps/web/src/components/chat/composerSend.logic.ts index e19512e6c90d4..c01f3a77b307e 100644 --- a/apps/web/src/components/chat/composerSend.logic.ts +++ b/apps/web/src/components/chat/composerSend.logic.ts @@ -1,4 +1,9 @@ -import type { EnvironmentId, ThreadContextRecord } from "@t3tools/contracts"; +import type { + EnvironmentId, + ThreadContextRecord, + ThreadMoveInput, + ThreadMoveResult, +} from "@t3tools/contracts"; import { collectInlineContextIds } from "../../lib/composerContextReferences"; /** Attached threads the prompt still links to; replacing linked text leaves stale records. */ @@ -79,3 +84,139 @@ export function approvalRedirectNotice(input: { } return null; } + +/* + * Why a move cannot happen, as full sentences: a toast, a pane hint, and a pane footer show + * them as they are. A slash menu row shows one without its final period. + */ +const DRAFT_CHOOSES_PROJECT = + "Press Esc, then pick the project in the heading above the composer before the first message."; +const QUEUED_EDIT_OPEN = "Save or cancel the queued message edit first."; +const REQUEST_PENDING = "Answer the pending question or approval first."; +const THREAD_STILL_WORKING = "This thread is still working. Try again when the turn ends."; + +export function folderMoveBlockReason(input: { + readonly supported: boolean; + readonly isLocalDraftThread: boolean; + readonly editingQueuedMessage: boolean; + readonly requestPending: boolean; + readonly textOnly: boolean; +}): string | null { + if (!input.supported) return "Update this environment's T3 Code server to move threads."; + if (input.isLocalDraftThread) return DRAFT_CHOOSES_PROJECT; + if (input.editingQueuedMessage) return QUEUED_EDIT_OPEN; + if (input.requestPending) return REQUEST_PENDING; + if (!input.textOnly) return "Remove the attachments and context, then send it again."; + return null; +} + +/** A path in backticks; a path with a backtick in it sits in double backticks with a space. */ +const INLINE_CODE = /``\s?(.+?)\s?``|`([^`]+)`/gu; + +/** + * Splits a refusal or a receipt at its backtick spans, which hold paths. The text around a path + * stays as written, its spaces and punctuation included. + */ +export function splitInlineCode(text: string): ReadonlyArray { + const parts: Array = []; + let end = 0; + for (const match of text.matchAll(INLINE_CODE)) { + if (match.index > end) { + parts.push({ code: false, text: text.slice(end, match.index), offset: end }); + } + parts.push({ code: true, text: match[1] ?? match[2] ?? "", offset: match.index }); + end = match.index + match[0].length; + } + if (end < text.length) parts.push({ code: false, text: text.slice(end), offset: end }); + return parts; +} + +/** `offset` is where the part starts in the text, so it identifies the part when rendering. */ +interface InlineCodePart { + readonly code: boolean; + readonly text: string; + readonly offset: number; +} + +/** Why the slash menu shows `/cd` and `/no` as blocked, before the user types the command. */ +export function folderMoveMenuBlockReason(input: { + readonly isLocalDraftThread: boolean; + readonly editingQueuedMessage: boolean; + readonly requestPending: boolean; + readonly running: boolean; +}): string | null { + if (input.isLocalDraftThread) return DRAFT_CHOOSES_PROJECT; + if (input.editingQueuedMessage) return QUEUED_EDIT_OPEN; + if (input.requestPending) return REQUEST_PENDING; + if (input.running) return THREAD_STILL_WORKING; + return null; +} + +type ThreadMoveQuestion = Extract; +type ThreadMoveOutcome = Extract; + +export async function runThreadMove( + input: ThreadMoveInput, + move: (input: ThreadMoveInput) => Promise, + ask: (question: ThreadMoveQuestion) => Promise | boolean, +): Promise { + const result = await move(input); + if (result._tag === "moved" || result._tag === "refused") return result; + if (!(await ask(result))) return null; + // The server resolves the path again, so the answer sends back the folders the question covers. + const answer = result._tag === "needs-project" ? { addProject: true } : { confirm: true }; + return runThreadMove({ ...input, ...answer, approved: result.resolvedFolders }, move, ask); +} + +/** + * The question for the confirm dialog, which takes its title from the first line. Each path + * appears once, on its From or To line, so no other line repeats one. `currentFolder` is null + * when the client does not know the thread's folder. + */ +export function threadMoveConfirmMessage( + question: ThreadMoveQuestion, + currentFolder: string | null, +): string { + const message = ( + title: string, + from: string | null, + to: string | null, + notes: ReadonlyArray, + ) => + [ + title, + ...(from === null ? [] : [`From: ${from}`]), + `To: ${to ?? "A new scratch folder"}`, + "", + ...notes, + ].join("\n"); + if (question._tag === "needs-project") { + return message("Create a project and move this thread?", currentFolder, question.folder, [ + question.nestedIn + ? `The new project is nested in project ${question.nestedIn.title}.` + : "This folder has no project yet.", + ]); + } + const wayBack = { + cd: "/cd to the From folder brings the thread back as a root thread.", + "add-project": "Add the From folder as a project first, and /cd to it brings the thread back.", + none: 'No command brings the thread back here. To keep a way back, cancel and choose "Turn into project" first.', + }[question.wayBack]; + return message( + question.wayBack === "none" ? "Leave this scratch folder?" : "Leave this worktree?", + question.from, + question.to, + [ + question.staysOnDisk.replace(`The folder ${question.from} `, "The folder "), + wayBack, + ...(question.project + ? [`The folder stays the root of project ${question.project.title}.`] + : []), + ...(question.otherThreads.length > 0 + ? [ + `Still working there: ${question.otherThreads.map((thread) => thread.title).join(", ")}.`, + ] + : []), + ], + ); +} diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.ts b/apps/web/src/components/chat/composerSlashCommandSearch.ts index ba1aacb83de31..087c53535f1e7 100644 --- a/apps/web/src/components/chat/composerSlashCommandSearch.ts +++ b/apps/web/src/components/chat/composerSlashCommandSearch.ts @@ -12,7 +12,7 @@ type SlashSearchItem = Extract< { type: "slash-command" | "provider-slash-command" | "skill" } >; -/** Provider commands, `/side`, `/btw` and `/clear` work only at the message start. */ +/** Provider commands, `/side`, `/btw`, `/clear`, `/cd` and `/no` work only at the message start. */ export function slashCommandItemsForPromptPosition( items: ReadonlyArray, isAtPromptStart: boolean, @@ -24,9 +24,7 @@ export function slashCommandItemsForPromptPosition( (item) => item.type === "skill" || (item.type === "slash-command" && - item.command !== "side" && - item.command !== "btw" && - item.command !== "clear"), + !["side", "btw", "clear", "cd", "no"].includes(item.command)), ); } diff --git a/apps/web/src/components/chat/composerSubmission.test.ts b/apps/web/src/components/chat/composerSubmission.test.ts index 23e85c9c644b6..3456ce4ef7135 100644 --- a/apps/web/src/components/chat/composerSubmission.test.ts +++ b/apps/web/src/components/chat/composerSubmission.test.ts @@ -234,4 +234,17 @@ describe("isComposerSubmissionBlocked", () => { ).toBe(true); expect(isComposerSubmissionBlocked({ ...answer, environmentUnavailable: true })).toBe(true); }); + + it("lets a folder move through without a provider, and blocks it on anything else", () => { + const move = { + ...sendBlocked, + sendDisabledReason: null, + submissionTarget: "provider-turn" as const, + movesFolder: true, + }; + expect(isComposerSubmissionBlocked(move)).toBe(false); + expect(isComposerSubmissionBlocked({ ...move, sendDisabledReason: "Messages loading" })).toBe( + true, + ); + }); }); diff --git a/apps/web/src/components/chat/composerSubmission.ts b/apps/web/src/components/chat/composerSubmission.ts index 2f9ffe0ddbd26..d44c912b4f58f 100644 --- a/apps/web/src/components/chat/composerSubmission.ts +++ b/apps/web/src/components/chat/composerSubmission.ts @@ -32,8 +32,10 @@ export function getComposerSubmissionValidationMessage( // Answering a question responds to the open request instead of starting a provider turn, so // provider and turn-send state must not block it. Its own attachments and connection still do. +// A folder move (`/cd`, `/no`) needs no provider. Every send-disabled reason still blocks it. export function isComposerSubmissionBlocked(input: { submissionTarget: ComposerSubmissionInput["submissionTarget"]; + movesFolder?: boolean; noProviderAvailable: boolean; sendDisabledReason: string | null; attachmentBlockReason: string | null; @@ -41,7 +43,8 @@ export function isComposerSubmissionBlocked(input: { }): boolean { return input.submissionTarget === "pending-user-input" ? input.attachmentBlockReason !== null || input.environmentUnavailable - : input.noProviderAvailable || input.sendDisabledReason !== null; + : (input.noProviderAvailable && input.movesFolder !== true) || + input.sendDisabledReason !== null; } export function submitComposerDraft( diff --git a/apps/web/src/components/chat/folderMoveDestinations.test.ts b/apps/web/src/components/chat/folderMoveDestinations.test.ts new file mode 100644 index 0000000000000..90084f67d1815 --- /dev/null +++ b/apps/web/src/components/chat/folderMoveDestinations.test.ts @@ -0,0 +1,217 @@ +import { expect, it } from "vite-plus/test"; + +import { + type FolderMoveRow, + folderMoveCannotRead, + folderMoveFolderRows, + folderMoveHighlightsFirstRow, + folderMoveNoMatchText, + folderMoveProjectRows, + folderMoveQueryPath, + folderMoveTabCompletion, +} from "./folderMoveDestinations"; + +const project = (id: string, workspaceRoot: string, extra: object = {}) => ({ + id, + title: id, + workspaceRoot, + updatedAt: "2026-01-01T00:00:00.000Z", + ...extra, +}); +const projects = [ + project("alpha", "/repos/alpha"), + project("beta", "/repos/beta"), + project("gamma", "/repos/gamma"), + project("old", "/repos/old", { archivedAt: "2026-01-02T00:00:00.000Z" }), + project("scratch", "/scratch", { isScratch: true }), +]; +const threads = [ + { projectId: "beta", updatedAt: "2026-03-01T00:00:00.000Z" }, + { projectId: "gamma", updatedAt: "2026-02-01T00:00:00.000Z" }, + { projectId: "gamma", updatedAt: "2026-04-01T00:00:00.000Z" }, +]; +const entries = [ + { name: "docs", fullPath: "/repos/docs" }, + { name: "beta", fullPath: "/repos/beta" }, + { name: "drafts", fullPath: "/repos/drafts" }, +]; +const labels = (rows: ReadonlyArray<{ label: string }>) => rows.map((row) => row.label); + +/** The project rows for an empty query from no known folder, with `input` on top. */ +const projectRows = (input: Partial[0]>) => + folderMoveProjectRows({ + projects, + threads, + currentFolder: null, + inNoProject: false, + query: "", + ...input, + }); + +/** The folder rows for `/repos/` and its entries from no known folder, with `input` on top. */ +const folderRows = (input: Partial[0]>) => + folderMoveFolderRows({ + projects, + currentFolder: null, + query: "/repos/", + platform: "MacIntel", + directoryFullPath: "/repos", + entries, + ...input, + }); + +it("reads the destination without one pair of quotes", () => { + expect(folderMoveQueryPath(' "/a b/c" ')).toBe("/a b/c"); + expect(folderMoveQueryPath("'/a b")).toBe("/a b"); + expect(folderMoveQueryPath("~/x")).toBe("~/x"); +}); + +it("lists the other projects by recent thread activity, then No project", () => { + const rows = projectRows({ currentFolder: "/repos/alpha" }); + expect(labels(rows)).toEqual(["gamma", "beta", "No project"]); + expect(rows[0]).toMatchObject({ + description: "/repos/gamma", + action: { kind: "move", path: "/repos/gamma" }, + }); + expect(rows.at(-1)?.action).toEqual({ kind: "no-project" }); + + // In a worktree of alpha the thread's folder is not alpha's root, so alpha stays. + const fromWorktree = projectRows({ currentFolder: "/worktrees/alpha-1", inNoProject: true }); + expect(labels(fromWorktree)).toEqual(["gamma", "beta", "alpha"]); +}); + +it("filters projects by title and root, with the exact title first", () => { + const named = [...projects, project("gam", "/repos/other")]; + expect(labels(projectRows({ projects: named, query: "gam" }))).toEqual(["gam", "gamma"]); + expect(labels(projectRows({ query: "repos/be" }))).toEqual(["beta"]); +}); + +it("makes the first row active for a typed project name only", () => { + expect(folderMoveHighlightsFirstRow({ query: "harn", isBrowsing: false })).toBe(true); + expect(folderMoveHighlightsFirstRow({ query: "", isBrowsing: false })).toBe(false); + expect(folderMoveHighlightsFirstRow({ query: ' "', isBrowsing: false })).toBe(false); + expect(folderMoveHighlightsFirstRow({ query: "./harn", isBrowsing: true })).toBe(false); +}); + +it("offers the browsed folder, its parent, project roots first, then folders", () => { + const rows = folderRows({ currentFolder: "/repos/alpha", query: "~/repos/" }); + expect(labels(rows)).toEqual(["Use this folder", "..", "beta", "docs", "drafts"]); + expect(rows.map((row) => row.action)).toEqual([ + { kind: "move", path: "/repos" }, + { kind: "open", query: "~/" }, + { kind: "move", path: "/repos/beta" }, + { kind: "open", query: "~/repos/docs/" }, + { kind: "open", query: "~/repos/drafts/" }, + ]); + expect(rows.map((row) => row.isProject)).toEqual([false, false, true, false, false]); + + // A typed name filters on the server's entries, and the folder itself is not offered. + const filtered = folderRows({ + query: '"/repos/d', + entries: entries.filter((entry) => entry.name.startsWith("d")), + }); + expect(labels(filtered)).toEqual(["..", "docs", "drafts"]); + + // A browsed folder that is a project root moves there at once. + const atRoot = folderRows({ + query: "/repos/beta/", + directoryFullPath: "/repos/beta", + entries: [], + }); + expect(atRoot[0]).toMatchObject({ label: "beta", isProject: true, action: { kind: "move" } }); +}); + +it("marks the thread's own folder as current, with the project icon on a project root", () => { + const browse = (currentFolder: string) => + folderRows({ + currentFolder, + entries: [{ name: "alpha", fullPath: "/repos/alpha" }, ...entries], + }); + // The current project root sorts with the projects, opens instead of moving, and is current. + const atRoot = browse("/repos/alpha"); + expect(labels(atRoot)).toEqual(["Use this folder", "..", "alpha", "beta", "docs", "drafts"]); + expect(atRoot[2]).toEqual({ + id: "folder-move:folder:/repos/alpha", + group: "Folders", + icon: "project", + label: "alpha", + description: "/repos/alpha", + isProject: true, + isCurrent: true, + action: { kind: "open", query: "/repos/alpha/" }, + }); + expect(atRoot.filter((row) => row.isCurrent === true)).toHaveLength(1); + + // A current folder that is no project root keeps the folder icon. + const inFolder = browse("/repos/docs/"); + expect(labels(inFolder)).toEqual(["Use this folder", "..", "alpha", "beta", "docs", "drafts"]); + expect(inFolder[4]).toMatchObject({ icon: "folder", isProject: false, isCurrent: true }); + expect(inFolder[2]).toMatchObject({ icon: "project", isProject: true, action: { kind: "move" } }); + expect(inFolder[2]).not.toHaveProperty("isCurrent"); +}); + +it("does not offer to use a folder the server may not read, and keeps the parent row", () => { + const locked = (folder: string) => + folderRows({ query: `${folder}/`, directoryFullPath: folder, entries: [], unreadable: true }); + expect(labels(locked("/repos/locked"))).toEqual([".."]); + // The server refuses a project root it may not read like any other folder. + expect(labels(locked("/repos/beta"))).toEqual([".."]); +}); + +it("stops waiting for a listing once the session may not read files", () => { + const cannotRead = (access: { canReadFiles: boolean; isPending: boolean; error: null }) => + folderMoveCannotRead({ unreadable: false, listingError: null, access }); + expect(cannotRead({ canReadFiles: true, isPending: false, error: null })).toBe(false); + expect(cannotRead({ canReadFiles: false, isPending: true, error: null })).toBe(false); + // A session without the file read permission settles with no error, and no listing follows. + expect(cannotRead({ canReadFiles: false, isPending: false, error: null })).toBe(true); +}); + +it("says when a typed path or project name matches nothing", () => { + const noMatch = ( + query: string, + shown: typeof entries, + rowCount: number, + input: Partial[0]> = {}, + ) => + folderMoveNoMatchText({ + query, + platform: "MacIntel", + directoryFullPath: "/repos", + entries: shown, + rowCount, + ...input, + }); + // The parent row is still listed, and it does not count as a match. + expect(noMatch("~/repos/zzz", [], 1)).toBe("No folder at /repos/zzz."); + // The sentence joins with the parent's own separator. + const windows = { platform: "Win32", directoryFullPath: "C:\\repos" }; + expect(noMatch("C:\\repos\\zzz", [], 1, windows)).toBe("No folder at C:\\repos\\zzz."); + expect(noMatch("~/repos/d", entries, 3)).toBeNull(); + // A folder with no subfolder is not a failed match. + expect(noMatch("~/repos/", [], 2)).toBeNull(); + expect(noMatch("harn", [], 0)).toBe( + 'No project matches "harn". Type a path to move to a folder.', + ); + expect(noMatch("no", [], 1)).toBeNull(); + expect(noMatch("", [], 0)).toBeNull(); + // Nothing is said before the server answers. + expect(noMatch("~/repos/zzz", [], 0, { directoryFullPath: null })).toBeNull(); +}); + +it("completes Tab to the highlighted folder or to the common prefix", () => { + const visible = entries.filter((entry) => entry.name.startsWith("d")); + const complete = (query: string, shown = visible, highlighted: FolderMoveRow | null = null) => + folderMoveTabCompletion({ query, platform: "MacIntel", highlighted, entries: shown }); + expect(complete("/repos/d", [{ name: "docs", fullPath: "/repos/docs" }])).toBe("/repos/docs/"); + expect(complete("/repos/d", [...visible, { name: "dry", fullPath: "/repos/dry" }])).toBeNull(); + expect( + complete("/repos/d", [ + { name: "draft-a", fullPath: "/repos/draft-a" }, + { name: "draft-b", fullPath: "/repos/draft-b" }, + ]), + ).toBe("/repos/draft-"); + expect(complete("alp")).toBeNull(); + const [row] = projectRows({ inNoProject: true, query: "beta" }); + expect(complete("beta", [], row ?? null)).toBe("/repos/beta/"); +}); diff --git a/apps/web/src/components/chat/folderMoveDestinations.ts b/apps/web/src/components/chat/folderMoveDestinations.ts new file mode 100644 index 0000000000000..2fd8d300358f2 --- /dev/null +++ b/apps/web/src/components/chat/folderMoveDestinations.ts @@ -0,0 +1,303 @@ +import { + getFilesystemBrowsePath, + getFilesystemBrowseTabCompletion, +} from "@t3tools/client-runtime/state/filesystem"; +import { + appendBrowsePathSegment, + ensureBrowseDirectoryPath, + findProjectByPath, + normalizeProjectPathForComparison, +} from "@t3tools/client-runtime/state/projects"; +import type { FilesystemBrowseEntry } from "@t3tools/contracts"; +import { scoreQueryMatch } from "@t3tools/shared/searchRanking"; + +export interface FolderMoveRow { + readonly id: string; + readonly group: "Projects" | "Folders"; + readonly icon: "project" | "folder" | "parent" | "use" | "no-project"; + readonly label: string; + readonly description: string; + /** A project root. It shows the project icon, and a badge among the folders. */ + readonly isProject: boolean; + /** The thread's own folder, which a badge marks. Enter opens it like any other folder. */ + readonly isCurrent?: true | undefined; + /** What Enter does on the row. */ + readonly action: + | { readonly kind: "move"; readonly path: string } + | { readonly kind: "no-project" } + | { readonly kind: "open"; readonly query: string }; +} + +interface DestinationProject { + readonly id: string; + readonly title: string; + readonly workspaceRoot: string; + readonly updatedAt: string; + readonly archivedAt?: string | null | undefined; + readonly isScratch?: true | undefined; +} + +/** The typed destination without the one pair of quotes that `/cd` strips. */ +export function folderMoveQueryPath(query: string): string { + const trimmed = query.trim(); + const quoted = /^(["'])([\s\S]*?)\1?$/.exec(trimmed); + return quoted ? (quoted[2] ?? "") : trimmed; +} + +/** + * A typed name picks the first matching project, so that row is active and Enter moves there. + * An empty query and a path have no active row: Enter runs the typed destination as it stands. + */ +export function folderMoveHighlightsFirstRow(input: { + readonly query: string; + readonly isBrowsing: boolean; +}): boolean { + return !input.isBrowsing && folderMoveQueryPath(input.query) !== ""; +} + +const samePath = (left: string, right: string) => + normalizeProjectPathForComparison(left) === normalizeProjectPathForComparison(right); + +/** Projects a thread can move to: not its own folder, not archived, not No project. */ +function destinationProjects( + projects: ReadonlyArray, + currentFolder: string | null, +): ReadonlyArray { + return projects.filter( + (project) => + project.isScratch !== true && + project.archivedAt == null && + (currentFolder === null || !samePath(project.workspaceRoot, currentFolder)), + ); +} + +const projectRow = (project: DestinationProject): FolderMoveRow => ({ + id: `folder-move:project:${project.id}`, + group: "Projects", + icon: "project", + label: project.title, + description: project.workspaceRoot, + isProject: true, + action: { kind: "move", path: project.workspaceRoot }, +}); + +/** + * The rows for an empty or a name query: the projects, most recent thread activity first, or + * best match first, then No project. + */ +export function folderMoveProjectRows(input: { + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray<{ readonly projectId: string; readonly updatedAt: string }>; + readonly currentFolder: string | null; + readonly inNoProject: boolean; + readonly query: string; +}): ReadonlyArray { + const lastActivity = new Map(); + for (const thread of input.threads) { + const known = lastActivity.get(thread.projectId); + if (known === undefined || thread.updatedAt > known) { + lastActivity.set(thread.projectId, thread.updatedAt); + } + } + const activity = (project: DestinationProject) => + lastActivity.get(project.id) ?? project.updatedAt; + const query = folderMoveQueryPath(input.query).toLowerCase(); + const score = (project: DestinationProject) => + query === "" + ? 0 + : Math.min( + scoreQueryMatch({ + value: project.title.toLowerCase(), + query, + exactBase: 0, + prefixBase: 2, + boundaryBase: 4, + includesBase: 6, + boundaryMarkers: ["-", "_", " ", "."], + }) ?? Number.POSITIVE_INFINITY, + scoreQueryMatch({ + value: project.workspaceRoot.toLowerCase(), + query, + exactBase: 10, + boundaryBase: 12, + includesBase: 14, + boundaryMarkers: ["/", "\\"], + }) ?? Number.POSITIVE_INFINITY, + ); + const rows = destinationProjects(input.projects, input.currentFolder) + .map((project) => ({ project, score: score(project) })) + .filter((entry) => Number.isFinite(entry.score)) + .toSorted( + (left, right) => + left.score - right.score || + activity(right.project).localeCompare(activity(left.project)) || + left.project.title.localeCompare(right.project.title), + ) + .map((entry) => projectRow(entry.project)); + const offersNoProject = !input.inNoProject && (query === "" || "no project".includes(query)); + return offersNoProject + ? [ + ...rows, + { + id: "folder-move:no-project", + group: "Projects", + icon: "no-project", + label: "No project", + description: "Move to a new scratch folder", + isProject: false, + action: { kind: "no-project" }, + }, + ] + : rows; +} + +/** + * The rows for a path query: the browsed folder itself, its parent, and its subfolders. A + * folder that is a project root moves the thread at once; another folder opens in the pane. + */ +export function folderMoveFolderRows(input: { + readonly projects: ReadonlyArray; + readonly currentFolder: string | null; + readonly query: string; + readonly platform: string; + /** The folder the server resolved the typed directory to. */ + readonly directoryFullPath: string; + readonly entries: ReadonlyArray; + /** The server may not read the folder, so the pane does not offer to move into it. */ + readonly unreadable?: boolean | undefined; +}): ReadonlyArray { + const typed = folderMoveQueryPath(input.query); + const browse = getFilesystemBrowsePath(typed, input.platform); + const projects = destinationProjects(input.projects, input.currentFolder); + const rows: Array = []; + if (browse.filterQuery === "" && input.unreadable !== true) { + const project = findProjectByPath(projects, input.directoryFullPath); + const isCurrent = + input.currentFolder !== null && samePath(input.directoryFullPath, input.currentFolder); + if (project !== undefined) { + rows.push({ ...projectRow(project), id: "folder-move:here", group: "Folders" }); + } else if (!isCurrent) { + rows.push({ + id: "folder-move:here", + group: "Folders", + icon: "use", + label: "Use this folder", + description: input.directoryFullPath, + isProject: false, + action: { kind: "move", path: input.directoryFullPath }, + }); + } + } + if (browse.canBrowseUp && browse.parentPath !== null) { + rows.push({ + id: "folder-move:parent", + group: "Folders", + icon: "parent", + label: "..", + description: "Parent folder", + isProject: false, + action: { kind: "open", query: browse.parentPath }, + }); + } + const folders = input.entries.map((entry): FolderMoveRow => { + const project = findProjectByPath(projects, entry.fullPath); + if (project !== undefined) { + return { + ...projectRow(project), + id: `folder-move:folder:${entry.fullPath}`, + group: "Folders", + }; + } + // The thread's own folder is no destination, so it opens; it keeps the project icon when it + // is a project root, which the destination projects leave out. + const isCurrent = input.currentFolder !== null && samePath(entry.fullPath, input.currentFolder); + const isRoot = isCurrent && findProjectByPath(input.projects, entry.fullPath) !== undefined; + return { + id: `folder-move:folder:${entry.fullPath}`, + group: "Folders", + icon: isRoot ? "project" : "folder", + label: entry.name, + description: entry.fullPath, + isProject: isRoot, + ...(isCurrent ? { isCurrent: true } : {}), + action: { + kind: "open", + query: appendBrowsePathSegment(browse.directoryPath, entry.name), + }, + }; + }); + return [ + ...rows, + ...folders.filter((row) => row.isProject), + ...folders.filter((row) => !row.isProject), + ]; +} + +/** + * True when the pane gets no listing of the typed folder: the server may not read the folder, + * the listing failed, or the session may not read files. A session without that permission + * settles with no error. A pending access check is no failure. + */ +export function folderMoveCannotRead(input: { + readonly unreadable: boolean; + readonly listingError: string | null; + readonly access: { readonly canReadFiles: boolean; readonly isPending: boolean }; +}): boolean { + return ( + input.unreadable || + input.listingError !== null || + (!input.access.isPending && !input.access.canReadFiles) + ); +} + +/** + * What the pane says when the typed text matches nothing, or null. A path counts only its + * matching folders: the parent row is always there and is not a match. A missing folder gets + * the sentence the server's refusal uses, so the pane and the toast agree. + */ +export function folderMoveNoMatchText(input: { + readonly query: string; + readonly platform: string; + /** The folder the server resolved the typed directory to, or null before it answers. */ + readonly directoryFullPath: string | null; + readonly entries: ReadonlyArray; + readonly rowCount: number; +}): string | null { + const typed = folderMoveQueryPath(input.query); + const browse = getFilesystemBrowsePath(typed, input.platform); + if (browse.isBrowsing) { + return input.directoryFullPath !== null && + browse.filterQuery !== "" && + input.entries.length === 0 + ? `No folder at ${ensureBrowseDirectoryPath(input.directoryFullPath)}${browse.filterQuery}.` + : null; + } + return typed !== "" && input.rowCount === 0 + ? `No project matches "${typed}". Type a path to move to a folder.` + : null; +} + +/** + * The query Tab completes to: the highlighted row's folder, or what the typed name has in + * common with the folders it matches. Null leaves the text as it is. + */ +export function folderMoveTabCompletion(input: { + readonly query: string; + readonly platform: string; + readonly highlighted: FolderMoveRow | null; + readonly entries: ReadonlyArray; +}): string | null { + const { highlighted } = input; + if (highlighted?.action.kind === "open") return highlighted.action.query; + if (highlighted?.action.kind === "move") { + return ensureBrowseDirectoryPath(highlighted.action.path); + } + if (highlighted !== null) return null; + const browse = getFilesystemBrowsePath(folderMoveQueryPath(input.query), input.platform); + if (!browse.isBrowsing) return null; + const completion = getFilesystemBrowseTabCompletion(input.entries, browse.filterQuery); + if (completion === null) return null; + return completion.isComplete + ? appendBrowsePathSegment(browse.directoryPath, completion.name) + : `${browse.directoryPath}${completion.name}`; +} diff --git a/apps/web/src/components/chat/threadMoveToast.tsx b/apps/web/src/components/chat/threadMoveToast.tsx new file mode 100644 index 0000000000000..8d5aee1741360 --- /dev/null +++ b/apps/web/src/components/chat/threadMoveToast.tsx @@ -0,0 +1,19 @@ +import { Fragment, type ReactNode } from "react"; + +import { splitInlineCode } from "./composerSend.logic"; + +/** A move toast's text, with each path as code at the start of its own line. */ +export function threadMoveToastDescription(text: string): ReactNode { + const parts = splitInlineCode(text); + if (parts.every((part) => !part.code)) return text; + return parts.map((part) => + part.code ? ( + + {part.offset > 0 ?
: null} + {part.text} +
+ ) : ( + {part.text} + ), + ); +} diff --git a/apps/web/src/components/chat/useComposerMenuItems.ts b/apps/web/src/components/chat/useComposerMenuItems.ts index 70ff7e8fd3f04..72c8d9228ca33 100644 --- a/apps/web/src/components/chat/useComposerMenuItems.ts +++ b/apps/web/src/components/chat/useComposerMenuItems.ts @@ -51,6 +51,8 @@ import type { ComposerCommandItem } from "./ComposerCommandMenu"; import { matchComposerModelItems } from "./composerModelMentions"; import type { ModelEsque } from "./providerIconUtils"; import type { ProviderInstanceEntry } from "../../providerInstances"; +import { folderMoveSlashCommandItems } from "./composerMenuItems.logic"; +import { useFolderMoveMenu } from "./useFolderMoveMenu"; import { searchSlashCommandItems, slashCommandItemsForPromptPosition, @@ -73,6 +75,10 @@ export function useComposerMenuItems({ threadPullRequests, sideChatAvailable, sideChatDisabledReason, + folderMoveAvailable, + folderMoveDisabledReason, + folderMoveCurrentFolder, + folderMoveInNoProject, freshSessionAvailable, compactSlashCommandAvailable, selectedProvider, @@ -93,6 +99,10 @@ export function useComposerMenuItems({ readonly threadPullRequests: ReadonlyArray | undefined; readonly sideChatAvailable: boolean; readonly sideChatDisabledReason: string | null; + readonly folderMoveAvailable: boolean; + readonly folderMoveDisabledReason: string | null; + readonly folderMoveCurrentFolder: string | null; + readonly folderMoveInNoProject: boolean; readonly freshSessionAvailable: boolean; readonly compactSlashCommandAvailable: boolean; readonly selectedProvider: ProviderDriverKind; @@ -197,8 +207,17 @@ export function useComposerMenuItems({ ? [] : composerPullRequestsFromLinks(threadPullRequests ?? [], pullRequestProjectId); + const folderMove = useFolderMoveMenu({ + query: composerTrigger?.kind === "folder" ? composerTrigger.query : null, + environmentId, + currentFolder: folderMoveCurrentFolder, + inNoProject: folderMoveInNoProject, + disabledReason: folderMoveDisabledReason, + }); + const items = ((): ComposerCommandItem[] => { if (!composerTrigger) return []; + if (composerTrigger.kind === "folder") return folderMove.items; if (composerTrigger.kind === "path") { return [ ...matchComposerThreadItems({ @@ -260,6 +279,11 @@ export function useComposerMenuItems({ }, ] as const) : []), + ...folderMoveSlashCommandItems({ + available: folderMoveAvailable, + disabledReason: folderMoveDisabledReason, + inNoProject: folderMoveInNoProject, + }), ...(freshSessionAvailable ? ([ { @@ -299,11 +323,12 @@ export function useComposerMenuItems({ skill.description ?? (skill.scope ? `${skill.scope} skill` : ""), })); - // T3's `/clear` replaces the provider's, which would clear a session T3 still treats as live. + // T3 handles `/clear` and `/cd`; the provider's would clear a live session or never run. const visibleProviderSlashCommandItems = providerSlashCommandItems.filter( (item) => (item.command.name !== "compact" || compactSlashCommandAvailable) && - (item.command.name !== "clear" || !freshSessionAvailable), + (item.command.name !== "clear" || !freshSessionAvailable) && + item.command.name !== "cd", ); const slashCommandItems = slashCommandItemsForPromptPosition( [...builtInSlashCommandItems, ...visibleProviderSlashCommandItems, ...skillItems], @@ -396,6 +421,7 @@ export function useComposerMenuItems({ return []; })(); const isLoading = + (composerTriggerKind === "folder" && folderMove.isLoading) || (composerTriggerKind === "path" && workspaceEntries.isPending) || ((composerTriggerKind === "skill" || composerTriggerKind === "slash-command") && workspaceSkillsScanning) || @@ -407,6 +433,7 @@ export function useComposerMenuItems({ pullRequestTriggerNumber !== debouncedPullRequestNumber || exactPullRequestLookup.isPending)); const emptyState = (() => { + if (composerTriggerKind === "folder") return folderMove.emptyState; if (composerTriggerKind === "skill") { return "No skills found. Try / to browse provider commands."; } @@ -429,5 +456,5 @@ export function useComposerMenuItems({ : "No matching command."; })(); - return { items, isLoading, emptyState }; + return { items, isLoading, emptyState, folderMove }; } diff --git a/apps/web/src/components/chat/useComposerSend.browser.test.tsx b/apps/web/src/components/chat/useComposerSend.browser.test.tsx new file mode 100644 index 0000000000000..17f9b61cd5bb9 --- /dev/null +++ b/apps/web/src/components/chat/useComposerSend.browser.test.tsx @@ -0,0 +1,250 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import { AsyncResult } from "effect/reactivity"; +import { useImperativeHandle, type RefObject } from "react"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; +import { useComposerDraftStore } from "../../composerDraftStore"; +import { __setClientSettingsForTests } from "../../hooks/useSettings"; +import { AppAtomRegistryProvider, appAtomRegistry } from "../../rpc/atomRegistry"; +import { toastManager } from "../ui/toast"; +import { useComposerSend } from "./useComposerSend"; + +const mocks = await vi.hoisted(async () => { + const { Atom } = await import("effect/reactivity"); + return { + move: vi.fn<(value: unknown) => Promise>(), + startTurn: vi.fn(), + serverConfigs: Atom.make>(new Map()).pipe(Atom.keepAlive), + }; +}); + +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => mocks.move })); +vi.mock(import("../../state/use-orchestration-command"), async (importOriginal) => ({ + ...(await importOriginal()), + useOrchestrationCommand: (() => mocks.startTurn) as never, +})); +vi.mock(import("../../state/session"), async (importOriginal) => ({ + ...(await importOriginal()), + readEnvironmentScope: () => true, +})); +vi.mock(import("../../state/server"), async (importOriginal) => ({ + ...(await importOriginal()), + environmentServerConfigsAtom: mocks.serverConfigs as never, +})); + +const ENV = EnvironmentId.make("env-1"); +const THREAD = ThreadId.make("thread-1"); +const ROUTE_KEY = `${ENV}:${THREAD}`; +const DRAFT_TARGET = scopeThreadRef(ENV, THREAD); +type Input = Parameters[0]; +type Api = ReturnType; + +function Harness(props: { readonly input: Input; readonly api: RefObject }) { + const api = useComposerSend(props.input); + useImperativeHandle(props.api, () => api); + return null; +} + +type Options = Partial< + Record<"folderMove" | "editingQueuedMessage" | "pendingQuestion", boolean> +> & { + /** What the composer holds besides the text. */ + readonly context?: "image" | "reply" | undefined; + readonly prompt?: string | undefined; +}; + +async function setup(options: Options) { + mocks.move.mockReset(); + mocks.startTurn.mockReset().mockResolvedValue(AsyncResult.success(undefined)); + __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); + const capabilities = { threadWorkingFolderMove: options.folderMove !== false }; + appAtomRegistry.set(mocks.serverConfigs, new Map([[ENV, { environment: { capabilities } }]])); + const saveEdit = vi.fn(); + const answer = vi.fn(); + const promptRef = { current: options.prompt ?? "/cd .." }; + const drafts = useComposerDraftStore.getState(); + drafts.setPrompt(DRAFT_TARGET, promptRef.current); + // The send reads only whether a reply target is set. + drafts.setReplyTo(DRAFT_TARGET, options.context === "reply" ? ({} as never) : null); + const sendContext = { + images: options.context === "image" ? [{ id: "image-1" }] : [], + files: [], + terminalContexts: [], + previewAnnotations: [], + reviewComments: [], + threadContexts: [], + environmentContexts: [], + providerAvailable: true, + multipleModelSelections: null, + shownPendingApprovalIds: [], + selectedProvider: "claude", + selectedModel: "claude-sonnet-4-5", + selectedProviderModels: [], + selectedPromptEffort: null, + selectedModelSelection: { instanceId: "claude", model: "claude-sonnet-4-5" }, + }; + const noop = () => {}; + const input = { + environmentId: ENV, + routeThreadKey: ROUTE_KEY, + currentRouteThreadKeyRef: { current: ROUTE_KEY }, + activeThread: { id: THREAD, environmentId: ENV, worktreePath: null }, + activeProject: { id: ProjectId.make("project-1"), environmentId: ENV, workspaceRoot: "/repo" }, + isLocalDraftThread: false, + resumeCompactionTokens: null, + composerRef: { + current: { + getSendContext: () => sendContext, + resetCursorState: noop, + focusAtEnd: noop, + validateProviderInput: () => true, + }, + }, + composerDraftTarget: DRAFT_TARGET, + promptRef, + feedbackUploadsInFlightRef: { current: new Set() }, + queuedRunEdit: { isEditing: options.editingQueuedMessage === true, saveEdit }, + readPendingEnvironmentChange: () => null, + refreshBeforeSend: () => null, + pendingApprovals: [], + respondingRequestIds: [], + activePendingProgress: options.pendingQuestion ? { questionIndex: 0 } : null, + onAdvanceActivePendingUserInput: answer, + sideChatDisabledReason: null, + beginLocalDispatch: noop, + beginWorktreeSetup: noop, + anchorNewTurn: noop, + optimisticBlobPreviews: { track: noop, release: noop }, + setOptimisticUserMessages: noop, + setThreadError: noop, + setKeepFullHistory: noop, + clearUsageLimitsFor: noop, + acknowledgeActiveThreadWoke: noop, + setSetupQueueSendThreadKey: noop, + } as unknown as Input; + const api: RefObject = { current: null }; + const screen = await render( + + + , + ); + const draftPrompt = () => useComposerDraftStore.getState().getComposerDraft(DRAFT_TARGET)?.prompt; + return { + screen, + send: () => api.current!.onSend(), + refuse: (reason: string) => api.current!.refuseFolderMove(reason), + saveEdit, + answer, + promptRef, + draftPrompt, + }; +} + +const TEXT_ONLY = "Remove the attachments and context, then send it again."; + +it.each([ + { + when: "a queued message edit is open", + reason: "Save or cancel the queued message edit first.", + editingQueuedMessage: true, + }, + { + when: "a question is pending", + reason: "Answer the pending question or approval first.", + pendingQuestion: true, + }, + { + when: "the server cannot move threads", + reason: "Update this environment's T3 Code server to move threads.", + folderMove: false, + }, + { when: "an image is attached", reason: TEXT_ONLY, context: "image" as const }, + { when: "a reply target is set", reason: TEXT_ONLY, context: "reply" as const, prompt: "/no" }, +])("a typed move refused while $when reaches no other send path", async (scenario) => { + const { screen, send, saveEdit, answer, promptRef, draftPrompt } = await setup(scenario); + await using _screen = screen; + using toast = vi.spyOn(toastManager, "add"); + const typed = promptRef.current; + + await act(send); + + const untouched = [saveEdit, answer, mocks.startTurn, mocks.move]; + for (const spy of untouched) expect(spy).not.toHaveBeenCalled(); + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ title: "Thread not moved", description: scenario.reason }), + ); + expect([promptRef.current, draftPrompt()]).toEqual([typed, typed]); + // With an attachment or a reply target, other text takes the ordinary send path. + if (scenario.context !== undefined) return; + + promptRef.current = "hello"; + await act(send); + expect(mocks.startTurn).toHaveBeenCalledTimes(scenario.folderMove === false ? 1 : 0); +}); + +it("ignores a second /cd until the first move has reported and cleared the draft", async () => { + const { screen, send, promptRef } = await setup({}); + await using _screen = screen; + let finish!: (result: unknown) => void; + mocks.move.mockReturnValueOnce(new Promise((resolve) => (finish = resolve))); + mocks.move.mockResolvedValue(AsyncResult.success({ _tag: "refused", reason: "Busy." })); + using _toast = vi.spyOn(toastManager, "add").mockImplementationOnce(() => { + void send(); + return ""; + }); + + const first = send(); + await act(send); + expect(mocks.move).toHaveBeenCalledTimes(1); + + finish(AsyncResult.success({ _tag: "moved", folder: "/repo", members: [] })); + await act(() => first); + expect(mocks.move).toHaveBeenCalledTimes(1); + expect(promptRef.current).toBe(""); + + promptRef.current = "/cd .."; + await act(send); + expect(mocks.move).toHaveBeenCalledTimes(2); +}); + +it("keeps one move toast on its thread, for a typed command and for a blocked menu row", async () => { + const { screen, send, refuse, promptRef, draftPrompt } = await setup({}); + await using _screen = screen; + mocks.move.mockResolvedValue(AsyncResult.success({ _tag: "refused", reason: "Busy." })); + using add = vi + .spyOn(toastManager, "add") + .mockReturnValueOnce("typed") + .mockReturnValueOnce("row") + .mockReturnValue("same-folder"); + using close = vi.spyOn(toastManager, "close"); + const refusal = (description: string) => + expect.objectContaining({ + type: "warning", + title: "Thread not moved", + description, + data: expect.objectContaining({ threadRef: DRAFT_TARGET }), + }); + + await act(send); + expect(add).toHaveBeenLastCalledWith(refusal("Busy.")); + expect(close).not.toHaveBeenCalled(); + + // A blocked menu row replaces the typed refusal with its own, sends nothing, and keeps the draft. + await act(() => refuse("This thread is already in No project.")); + expect(close).toHaveBeenLastCalledWith("typed"); + expect(add).toHaveBeenLastCalledWith(refusal("This thread is already in No project.")); + expect(mocks.move).toHaveBeenCalledTimes(1); + expect(mocks.startTurn).not.toHaveBeenCalled(); + expect([promptRef.current, draftPrompt()]).toEqual(["/cd ..", "/cd .."]); + + // The next attempt closes it. A move to the thread's own folder is no refusal. + mocks.move.mockResolvedValue( + AsyncResult.success({ _tag: "moved", folder: "/repo", members: [] }), + ); + await act(send); + expect(close).toHaveBeenLastCalledWith("row"); + expect(add.mock.lastCall?.[0]).toMatchObject({ type: "info", title: "Thread not moved" }); +}); diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 1afe53a944707..01a22e43cbfe5 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -55,7 +55,11 @@ import { import { type ComposerSubmissionIntent, collapseExpandedComposerCursor, + parseFolderMoveCommand, } from "../../composer-logic"; +import { requestConfirmDialog } from "../../confirmDialog"; +import { threadMoveToastDescription } from "./threadMoveToast"; +import { useAtomCommand } from "../../state/use-atom-command"; import { type ChatMessage, DEFAULT_INTERACTION_MODE } from "../../types"; import { recordPromptSent } from "../GitHubStarPrompt"; import { stackedThreadToast, toastManager } from "../ui/toast"; @@ -99,9 +103,12 @@ import { useStableCallback } from "../../hooks/useStableCallback"; import { environmentPresentations } from "../../state/presentation"; import { approvalRedirectNotice, + folderMoveBlockReason, loadBalancingSendWarning, multipleModelSendBlock, referencedThreadContexts, + runThreadMove, + threadMoveConfirmMessage, } from "./composerSend.logic"; import { useMultipleModelSend } from "./useMultipleModelSend"; import { type ComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch"; @@ -348,6 +355,7 @@ export function useComposerSend(input: ComposerSendInput) { const uploadThreadFeedback = useOrchestrationCommand(threadEnvironment.uploadFeedback, { reportFailure: false, }); + const moveThread = useAtomCommand(threadEnvironment.move, { reportFailure: false }); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const sendToMultipleModels = useMultipleModelSend({ @@ -366,6 +374,31 @@ export function useComposerSend(input: ComposerSendInput) { const { sendInFlight: sendInFlightRef } = draftSendState(routeThreadKey); const composerSendGenerationRef = useRef(0); const environmentUnavailableSendToastSlotRef = useRef(0); + const moveToastIdRef = useRef | null>(null); + // One move toast at a time: a new attempt or a question replaces the last answer. + const closeMoveToast = () => { + if (moveToastIdRef.current !== null) toastManager.close(moveToastIdRef.current); + moveToastIdRef.current = null; + }; + const notifyMove = (toast: Parameters[0]) => { + closeMoveToast(); + moveToastIdRef.current = toastManager.add( + stackedThreadToast({ + ...toast, + // A draft has no thread route to scope the toast to. + ...(isLocalDraftThread || activeThread === undefined + ? {} + : { data: { threadRef: scopeThreadRef(environmentId, activeThread.id) } }), + }), + ); + }; + /** The toast for a `/cd` or `/no` that did not move the thread, typed or chosen from the menu. */ + const refuseFolderMove = (reason: string) => + notifyMove({ + type: "warning", + title: "Thread not moved", + description: threadMoveToastDescription(reason), + }); // A send awaits uploads before it declines, so it reads the approvals pending by then. const pendingApprovalsRef = useRef(pendingApprovals); const respondingRequestIdsRef = useRef(respondingRequestIds); @@ -413,9 +446,10 @@ export function useComposerSend(input: ComposerSendInput) { Boolean(activePendingProgress) && sendCtx?.answeringPendingUserInput !== false; const startsSideChat = onSideChat !== undefined && sideChatCommand !== null && sideChatDisabledReason === null; + const folderMove = parseFolderMoveCommand(promptAtSend); // An answer responds to the open request rather than starting a turn, so the new-message // guards below (loading, busy, provider) must not swallow it. - if (answeringQuestion && !startsSideChat && !directAnnotation) { + if (answeringQuestion && !startsSideChat && !directAnnotation && !(sendCtx && folderMove)) { if (readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) && activeThread) { onAdvanceActivePendingUserInput(); } @@ -497,17 +531,18 @@ export function useComposerSend(input: ComposerSendInput) { ); return; } - if (onSideChat && sendCtx && sideChatCommand && sideChatDisabledReason === null) { - if ( - sendCtx.images.length > 0 || + const hasContext = + sendCtx !== undefined && + (sendCtx.images.length > 0 || sendCtx.files.length > 0 || filterTerminalContextsWithText(sendCtx.terminalContexts).length > 0 || sendCtx.previewAnnotations.length > 0 || sendCtx.reviewComments.length > 0 || sendCtx.threadContexts.length > 0 || sendCtx.environmentContexts.length > 0 || - useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.replyTo - ) { + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.replyTo); + if (onSideChat && sendCtx && sideChatCommand && sideChatDisabledReason === null) { + if (hasContext) { toastManager.add( stackedThreadToast({ type: "warning", @@ -530,6 +565,74 @@ export function useComposerSend(input: ComposerSendInput) { } return; } + if (sendCtx && folderMove) { + const config = appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId); + const blockReason = folderMoveBlockReason({ + supported: config?.environment.capabilities.threadWorkingFolderMove === true, + isLocalDraftThread, + editingQueuedMessage: queuedRunEdit.isEditing, + requestPending: activePendingProgress !== null || pendingApprovalsRef.current.length > 0, + textOnly: !directAnnotation && !hasContext, + }); + closeMoveToast(); + if (blockReason !== null) return refuseFolderMove(blockReason); + // The composer opens the destination pane for a bare `/cd`; this is what is left when it + // cannot, such as during a running turn. + if (folderMove.command === "cd" && folderMove.path === null) { + return notifyMove({ + type: "info", + title: "Choose a destination", + description: "Type a project name or a folder path after /cd.", + }); + } + if (folderMove.command === "no" && folderMove.path !== null) { + return refuseFolderMove("Usage: /no, with nothing after it."); + } + sendInFlightRef.current = true; + await runThreadMove( + { threadId: activeThread.id, destination: folderMove.path }, + async (request) => { + const result = await moveThread({ environmentId, input: request }); + if (result._tag === "Success") return result.value; + const error = squashAtomCommandFailure(result); + const reason = error instanceof Error ? error.message : "Could not move the thread."; + return { _tag: "refused", reason }; + }, + (question) => { + closeMoveToast(); + const currentFolder = activeThread.worktreePath ?? activeProject?.workspaceRoot ?? null; + // No mounted dialog host counts as a no. + return requestConfirmDialog(threadMoveConfirmMessage(question, currentFolder)) ?? false; + }, + ) + .then((outcome) => { + if (outcome === null) return; + if (outcome._tag === "refused") return refuseFolderMove(outcome.reason); + if (outcome.members.length === 0) { + notifyMove({ + type: "info", + title: "Thread not moved", + description: threadMoveToastDescription(`Already in \`${outcome.folder}\`.`), + }); + } + if ( + currentRouteThreadKeyRef.current === routeThreadKey && + promptRef.current === promptAtSend + ) { + promptRef.current = ""; + setComposerDraftPrompt(composerDraftTarget, ""); + composerRef.current?.resetCursorState(); + } + }) + .finally(() => { + sendInFlightRef.current = false; + // A dialog took the focus; the user goes on typing where they were. + if (currentRouteThreadKeyRef.current === routeThreadKey) { + composerRef.current?.focusAtEnd(); + } + }); + return; + } if (answeringQuestion) { if (directAnnotation) { notifyDirectAnnotationAttached(); @@ -1497,5 +1600,5 @@ export function useComposerSend(input: ComposerSendInput) { }); if (ready) return onSendLatest(...args); }; - return { onSend: onSendWithRouting }; + return { onSend: onSendWithRouting, refuseFolderMove }; } diff --git a/apps/web/src/components/chat/useFolderMoveMenu.ts b/apps/web/src/components/chat/useFolderMoveMenu.ts new file mode 100644 index 0000000000000..eaf78f06fa212 --- /dev/null +++ b/apps/web/src/components/chat/useFolderMoveMenu.ts @@ -0,0 +1,119 @@ +import { + filterFilesystemBrowseEntries, + getFilesystemBrowsePath, +} from "@t3tools/client-runtime/state/filesystem"; +import type { EnvironmentId, FilesystemBrowseEntry } from "@t3tools/contracts"; + +import { useProjects, useThreadShells } from "~/state/entities"; +import { filesystemEnvironment, useFilesystemReadAccess } from "~/state/filesystem"; +import { useEnvironmentPresentation } from "~/state/presentation"; +import { useEnvironmentQuery } from "~/state/query"; +import { getEnvironmentBrowsePlatform } from "../CommandPalette.logic"; +import type { ComposerCommandItem } from "./ComposerCommandMenu"; +import { + folderMoveCannotRead, + folderMoveFolderRows, + folderMoveNoMatchText, + folderMoveProjectRows, + folderMoveQueryPath, +} from "./folderMoveDestinations"; + +const NO_ENTRIES: ReadonlyArray = []; + +/** + * The `/cd` destination pane: the environment's projects for an empty or a name query, and the + * folders under the typed path for a path query. `query` is null while the pane is closed. + */ +export function useFolderMoveMenu(input: { + readonly query: string | null; + readonly environmentId: EnvironmentId; + /** The thread's working folder: the folder a relative path starts from. */ + readonly currentFolder: string | null; + readonly inNoProject: boolean; + readonly disabledReason: string | null; +}) { + const active = input.query !== null && input.disabledReason === null; + const projects = useProjects(); + const threads = useThreadShells(active); + const { presentation } = useEnvironmentPresentation(input.environmentId); + const platform = getEnvironmentBrowsePlatform( + presentation?.serverConfig?.environment.platform.os, + ); + const typed = folderMoveQueryPath(input.query ?? ""); + const browse = getFilesystemBrowsePath(typed, platform, active); + const access = useFilesystemReadAccess(browse.isBrowsing ? input.environmentId : null); + const listing = useEnvironmentQuery( + access.canReadFiles && browse.isBrowsing && browse.directoryPath.length > 0 + ? filesystemEnvironment.browse({ + environmentId: input.environmentId, + input: { + partialPath: browse.directoryPath, + ...(input.currentFolder ? { cwd: input.currentFolder } : {}), + }, + }) + : null, + ); + const entries = listing.data + ? filterFilesystemBrowseEntries(listing.data.entries, browse.filterQuery).visibleEntries + : NO_ENTRIES; + // An older server omits the flag and lists a folder it may not read as an empty one. + const unreadable = listing.data?.unreadable === true; + const environmentProjects = projects.filter( + (project) => project.environmentId === input.environmentId && !project.isScratch, + ); + const rows = !active + ? [] + : browse.isBrowsing + ? listing.data + ? folderMoveFolderRows({ + projects: environmentProjects, + currentFolder: input.currentFolder, + query: input.query ?? "", + platform, + directoryFullPath: listing.data.parentPath, + entries, + unreadable, + }) + : [] + : folderMoveProjectRows({ + projects: environmentProjects, + threads: threads.filter((thread) => thread.environmentId === input.environmentId), + currentFolder: input.currentFolder, + inNoProject: input.inNoProject, + query: input.query ?? "", + }); + const cannotRead = + browse.isBrowsing && folderMoveCannotRead({ unreadable, listingError: listing.error, access }); + const noMatch = active + ? folderMoveNoMatchText({ + query: input.query ?? "", + platform, + directoryFullPath: listing.data?.parentPath ?? null, + entries, + rowCount: rows.length, + }) + : null; + const problem = cannotRead + ? `Cannot read ${listing.data?.parentPath ?? browse.directoryPath}.` + : noMatch; + return { + items: rows.map((row): ComposerCommandItem => ({ + ...row, + type: "folder-destination" as const, + })), + isLoading: browse.isBrowsing && !cannotRead && listing.data === null && rows.length === 0, + emptyState: + input.disabledReason ?? + problem ?? + (browse.isBrowsing + ? "No matching folders." + : "No other project. Type a path to move to a folder."), + /** A message below the rows. The menu shows `emptyState` instead when it has no row. */ + footer: rows.length === 0 ? null : problem, + /** The folders the typed name matches, for Tab completion. */ + entries, + platform, + /** True for a path query, which lists folders. False for a project-name query. */ + isBrowsing: browse.isBrowsing, + }; +} diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index e7ec86822a547..c20b1969dad65 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -23,6 +23,7 @@ import { extendReplacementRangeForTrailingSpace, formatAssistantCitationForComposer, isPathSuggestionCurrent, + parseFolderMoveCommand, replaceTextRange, } from "./composer-logic"; import { slashCommandItemsForPromptPosition } from "./components/chat/composerSlashCommandSearch"; @@ -263,6 +264,22 @@ describe("detectComposerTrigger", () => { expect(detectComposerTrigger(text, cursor)).toBeNull(); }); + it("gives /cd its destination query, spaces and slashes included", () => { + const text = "/cd ~/My Projects/app"; + expect(detectComposerTrigger(text, text.length)).toEqual({ + kind: "folder", + query: "~/My Projects/app", + rangeStart: 4, + rangeEnd: text.length, + }); + expect(detectComposerTrigger("/cd ", 4)).toMatchObject({ kind: "folder", query: "" }); + // Before the space it is still the slash command; other text is not a destination. + expect(detectComposerTrigger("/cd", 3)).toMatchObject({ kind: "slash-command", query: "cd" }); + expect(detectComposerTrigger("/cd /a", 2)).toMatchObject({ kind: "slash-command" }); + expect(detectComposerTrigger("go /cd /tmp", 11)?.kind).not.toBe("folder"); + expect(detectComposerTrigger("/cd /tmp\nmore", 8)?.kind).not.toBe("folder"); + }); + it.each(["Use ", "Use\t", "Use\n "])("detects inline slash skills after %j", (prefix) => { const text = `${prefix}/skill:review later`; const cursor = `${prefix}/skill:review`.length; @@ -581,7 +598,7 @@ describe("extendReplacementRangeForTrailingSpace", () => { describe("side chat slash commands", () => { it("are not offered after the start of a message", () => { - const items = (["side", "btw", "clear", "model"] as const).map((command) => ({ + const items = (["side", "btw", "clear", "cd", "no", "model"] as const).map((command) => ({ id: `slash:${command}`, type: "slash-command" as const, command, @@ -593,3 +610,19 @@ describe("side chat slash commands", () => { ]); }); }); + +it.each([ + ["/cd", { command: "cd", path: null }], + ['/cd ""', { command: "cd", path: null }], + ["/CD ~/src/app ", { command: "cd", path: "~/src/app" }], + ["/cd My Projects/app", { command: "cd", path: "My Projects/app" }], + ['/cd "My Projects/app"', { command: "cd", path: "My Projects/app" }], + ["/cd ' a b '", { command: "cd", path: " a b " }], + ['/cd "a', { command: "cd", path: '"a' }], + ["/no", { command: "no", path: null }], + ["/no project", { command: "no", path: "project" }], + ["/node", null], + ["run /cd later", null], +])("parseFolderMoveCommand parses %j", (text, expected) => { + expect(parseFolderMoveCommand(text)).toEqual(expected); +}); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 37fef411897fc..63ac2f8576273 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -17,8 +17,8 @@ import { import { resolveShortcutCommand, type ShortcutEventLike } from "./keybindings"; -export type ComposerTriggerKind = "path" | "pull-request" | "slash-command" | "skill"; -export type ComposerSlashCommand = "model" | "side" | "btw" | "clear"; +export type ComposerTriggerKind = "path" | "pull-request" | "slash-command" | "skill" | "folder"; +export type ComposerSlashCommand = "model" | "side" | "btw" | "clear" | "cd" | "no"; export type ComposerSubmissionIntent = "foreground" | "background" | "alternate"; export interface ComposerTrigger { @@ -250,6 +250,16 @@ export function collapseExpandedComposerCursor( export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null { const cursor = clampCursor(text, cursorInput); + // `/cd `: the whole rest of the line is the query, so a path keeps its spaces. + const folderCommand = /^\/cd[ \t]/i.exec(text); + if (folderCommand && cursor >= folderCommand[0].length && !/[\r\n]/.test(text)) { + return { + kind: "folder", + query: text.slice(folderCommand[0].length), + rangeStart: folderCommand[0].length, + rangeEnd: text.length, + }; + } const tokenStart = tokenStartForCursor(text, cursor); const token = text.slice(tokenStart, cursor); if (token.startsWith("/")) { @@ -318,6 +328,16 @@ export function composerStateAtPromptEnd( }; } +type FolderMoveCommand = { readonly command: "cd" | "no"; readonly path: string | null }; + +export function parseFolderMoveCommand(text: string): FolderMoveCommand | null { + const match = /^\/(cd|no)(?:\s+([\s\S]*))?$/i.exec(text.trim()); + if (!match) return null; + const argument = match[2]?.trim() ?? ""; + if (match[1]?.toLowerCase() === "no") return { command: "no", path: argument || null }; + return { command: "cd", path: (/^(["'])([\s\S]*)\1$/.exec(argument)?.[2] ?? argument) || null }; +} + /** * A replacement ending in a space swallows one space already after the range, so inserting * `@file ` before existing text does not leave a double space. diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 53697f82d84bf..64e5079bd2200 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -91,6 +91,55 @@ describe("V2 session presentation", () => { ).toBe(false); }); + it("shows a move receipt as a heading with details, and other notices as warnings", () => { + const at = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); + const notice = (id: string, message: string, previousFolder?: string) => { + const item = { + id: TurnItemId.make(id), + threadId: ThreadId.make("thread-notices"), + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "completed" as const, + title: null, + startedAt: at, + completedAt: at, + updatedAt: at, + type: "system_notice" as const, + message, + ...(previousFolder === undefined ? {} : { previousFolder }), + }; + return { + position: 0, + visibility: "local" as const, + sourceThreadId: item.threadId, + sourceItemId: item.id, + item, + }; + }; + const entries = deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: [ + notice("receipt", "Moved to B.\nThe next turn runs in `/b`.\nMore.", "/a"), + notice("plain", "Provider restarted.\nDetails."), + ], + optimisticMessages: [], + }); + expect( + entries.map((entry) => + entry.kind === "work" + ? [entry.entry.label, entry.entry.detail, entry.entry.sourceActivityKind] + : null, + ), + ).toEqual([ + ["Moved to B", "The next turn runs in `/b`.\nMore.", undefined], + ["Provider restarted.\nDetails.", undefined, "runtime.warning"], + ]); + }); + it("offers Stop while a run is preparing/starting, not just once it's running (#13392)", () => { const runtimeWithStatus = ( status: ThreadRuntimeSummary["status"], diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index e73b250d27217..c7c3cf0758753 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -424,12 +424,22 @@ function projectedWorkEntry(row: OrchestrationV2ProjectedTurnItem): WorkLogEntry changedFiles: item.files.map((file) => file.path), toolData: item, }; - case "system_notice": + case "system_notice": { + // A move receipt records a successful move: its first line is the heading, the rest the details. + if (item.previousFolder !== undefined) { + const [heading = "", ...details] = item.message.split("\n"); + return { + ...common, + label: heading.replace(/\.$/u, ""), + ...(details.length > 0 ? { detail: details.join("\n") } : {}), + }; + } return { ...common, label: item.message, ...(item.level === "info" ? {} : { sourceActivityKind: "runtime.warning" }), }; + } case "error": { const presentation = providerErrorPresentation(item); return { diff --git a/docs/user/composer.md b/docs/user/composer.md index 89a96f120d4d0..25d85b239e35b 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -299,6 +299,15 @@ earlier messages, and the thread marks where it began with **New session**. Earlier messages and diffs stay in the thread. Dismiss the notice above the composer to keep the current session instead. +On web and desktop, send `/cd` followed by a folder path to move the thread to +that folder and its project. Send `/cd` on its own to pick one of your recent +projects, or type part of a path and press Tab to complete folders. The next +turn runs in the new folder and keeps the conversation. Send `/no` to move the +thread to **No project** in a new scratch folder. + +T3 Code asks before it leaves a worktree or a scratch folder, and before it +makes a plain folder a project. A thread cannot move while a turn is running. + On web and desktop, turn on **Context window** in **Settings → Appearance → Thread details** to see context usage in the thread details card. It appears once the provider reports usage. diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 865f6273b20ad..008deaaa863bc 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -421,6 +421,12 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + move: createEnvironmentRpcCommand(runtime, { + label: "environment-data:commands:thread:move", + tag: WS_METHODS.threadsMove, + scheduler, + concurrency, + }), }; const commands = { ...sent, diff --git a/packages/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts index a1f6c293e90b5..71904f2f6a562 100644 --- a/packages/contracts/src/filesystem.ts +++ b/packages/contracts/src/filesystem.ts @@ -41,6 +41,8 @@ export type FilesystemBrowseEntry = typeof FilesystemBrowseEntry.Type; export const FilesystemBrowseResult = Schema.Struct({ parentPath: TrimmedNonEmptyString, entries: Schema.Array(FilesystemBrowseEntry), + /** True when the server may not read the folder, so `entries` is empty. Older servers omit it. */ + unreadable: Schema.optionalKey(Schema.Boolean), }); export type FilesystemBrowseResult = typeof FilesystemBrowseResult.Type; From b46167ed662757d8377b06dcc939fda021487323 Mon Sep 17 00:00:00 2001 From: "Claude Fable 5.2" <354270077+abominable-intelligence@users.noreply.github.com> Date: Sat, 14 Nov 2026 16:50:49 +0100 Subject: [PATCH 5/6] fix(web): an old row opens its links in the folder its run worked in A row's relative file links, images, mentions and changed files resolved against the thread's current folder. After the thread moved, they pointed at files that are not there. Each row now resolves against the folder its run recorded. A row with no recorded run uses the folder that the next move receipt left. An older history page adds the folders of the runs it loads. Co-Authored-By: Martin Zeman --- .../components/ChatMarkdown.browser.test.tsx | 91 ++++++++- apps/web/src/components/ChatMarkdown.tsx | 79 +++++--- apps/web/src/components/ChatView.tsx | 1 + .../components/chat/ChatMarkdownMedia.test.ts | 24 +++ .../src/components/chat/ChatMarkdownMedia.tsx | 14 +- .../chat/MessagesTimeline.browser.test.tsx | 183 +++++++++++++++++- .../components/chat/MessagesTimeline.logic.ts | 32 ++- .../src/components/chat/MessagesTimeline.tsx | 24 ++- .../src/components/chat/ProposedPlanCard.tsx | 4 + .../components/chat/TimelineAssistantRows.tsx | 16 +- .../chat/TimelineUserMessageRow.tsx | 8 +- .../components/chat/TimelineWorkEntryRow.tsx | 10 +- .../components/chat/TimelineWorkGroupRows.tsx | 25 ++- .../chat/UserMessageContextChips.tsx | 17 +- .../src/components/chat/V2ItemInspector.tsx | 7 +- .../src/components/chat/timelineRowContext.ts | 10 +- apps/web/src/markdown-links.test.ts | 21 ++ apps/web/src/markdown-links.ts | 11 +- packages/client-runtime/package.json | 4 + .../src/state/threadHistoryMerge.test.ts | 15 +- .../src/state/threadHistoryMerge.ts | 5 + packages/client-runtime/src/state/threads.ts | 1 + .../src/timelineRowFolders.test.ts | 54 ++++++ .../client-runtime/src/timelineRowFolders.ts | 61 ++++++ 24 files changed, 652 insertions(+), 65 deletions(-) create mode 100644 apps/web/src/components/chat/ChatMarkdownMedia.test.ts create mode 100644 packages/client-runtime/src/timelineRowFolders.test.ts create mode 100644 packages/client-runtime/src/timelineRowFolders.ts diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index 66dc5c150a651..d11d717b21326 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -1,6 +1,6 @@ import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { AsyncResult } from "effect/reactivity"; +import { EnvironmentId, ProjectReadFileError, ThreadId } from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/reactivity"; import { type ReactElement } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; @@ -9,6 +9,7 @@ import { act, render } from "../../test/render"; import "../index.css"; import { filesystemEnvironment } from "../state/filesystem"; +import { projectEnvironment } from "../state/projects"; import { useRightPanelStore } from "../rightPanelStore"; import { remarkEnableOrderedListNumbers } from "@t3tools/shared/markdownPipeline"; import { setMarkdownTaskChecked } from "@t3tools/shared/markdownTaskList"; @@ -83,7 +84,10 @@ vi.mock(import("../state/filesystem"), async (original) => { useFilesystemReadAccess: ((environmentId: EnvironmentId | null) => environmentId === deniedEnvironmentId ? { canReadFiles: false, isPending: false, error: null } - : actual.useFilesystemReadAccess(environmentId)) as never, + : // The file panel test reads a file in this environment. + environmentId === "workspace" + ? { canReadFiles: true, isPending: false, error: null } + : actual.useFilesystemReadAccess(environmentId)) as never, }; }); vi.mock(import("../remoteOpen"), async (original) => ({ @@ -135,6 +139,7 @@ vi.mock(import("../hooks/useSettings"), async (original) => { import ChatMarkdown from "./ChatMarkdown"; import { chatMarkdownClipboardPayload } from "../markdown-clipboard"; import { AudioPreview } from "./files/AudioPreview"; +import FilePreviewPanel from "./files/FilePreviewPanel"; it("keeps compact audio playback loaded across signed URL renewals", async () => { const original = @@ -505,6 +510,86 @@ describe("ChatMarkdown workspace file opens", () => { expect(openFile).toHaveBeenCalledExactlyOnceWith(threadRef, "/elsewhere/b.md", 9); expect(workspaceSearch).toHaveBeenCalledOnce(); + + workspaceSearch.mockResolvedValue(result); + await screen.rerender( + , + ); + await act(async () => container.querySelector("a")!.click()); + expect(openFile).toHaveBeenLastCalledWith(threadRef, "notes/a.md", undefined); + await screen.rerender( + , + ); + // The row ran in another folder: its link is looked up there, and a miss opens it as written. + const recorded = container.querySelector("a")!; + const literal = "/school/old/physics/notes/a.md"; + expect(recorded.getAttribute("href")).toBe(literal); + await act(async () => recorded.click()); + expect(workspaceSearch).toHaveBeenLastCalledWith( + expect.objectContaining({ input: expect.objectContaining({ cwd: "/school/old" }) }), + ); + expect(openFile).toHaveBeenLastCalledWith(threadRef, literal, undefined); + workspaceSearch.mockResolvedValue( + AsyncResult.success({ + entries: [{ path: "term-1/physics/notes/a.md", kind: "file" }], + truncated: false, + }), + ); + await act(async () => recorded.click()); + expect(openFile).toHaveBeenLastCalledWith( + threadRef, + "/school/old/term-1/physics/notes/a.md", + undefined, + ); + using _read = vi.spyOn(projectEnvironment, "readFile").mockImplementation( + ({ input }) => + Atom.make( + AsyncResult.fail( + new ProjectReadFileError({ + ...input, + failure: "operation_failed", + resolvedPath: input.relativePath, + cause: { code: "ENOENT" }, + }), + ), + ) as never, + ); + await using panel = await render( + , + ); + await expect + .element(panel.getByRole("alert")) + .toHaveTextContent( + "The file could not be accessed or read. It may be missing or inaccessible.Attempted path/school/old/physics/notes/a.md", + ); }); }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index a146aa9743797..35c1fb346bc62 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -8,7 +8,11 @@ import { inlineCodeFilePathCandidate, } from "@t3tools/shared/markdownLinks"; import { taskListMarkerOffset } from "@t3tools/shared/markdownTaskList"; -import { isHostPath, resolveWorkspaceFilePath } from "@t3tools/shared/path"; +import { + isHostPath, + resolveWorkspaceFilePath, + workspaceRelativeFilePath, +} from "@t3tools/shared/path"; import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; import { AuthFilesystemReadScope, @@ -251,6 +255,8 @@ interface ChatMarkdownProps { onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; onRunShellCommand?: ((command: string) => void) | undefined; imageBaseDir?: string | undefined; + /** The row's run folder; relative file links resolve there and are looked up there by name. */ + fileLinkBaseDir?: string | undefined; workspaceMutationId?: string | null | undefined; onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; /** Keep one list per configuration (a module constant): it keys the shared parse cache. */ @@ -485,16 +491,17 @@ function resolveFileLinkSources( key: string, cwd: string | undefined, imageBaseDir: string | undefined, + lookupRoot: string | undefined, ) { const [hrefs, spans] = JSON.parse(key) as [string[], string[]]; const markdownFileLinkMetaByHref = new Map(); for (const href of hrefs) { - const meta = resolveMarkdownFileLinkMeta(href, cwd, imageBaseDir ?? cwd); + const meta = resolveMarkdownFileLinkMeta(href, cwd, imageBaseDir ?? cwd, lookupRoot); if (meta) markdownFileLinkMetaByHref.set(href, meta); } const inlineCodeFileLinkMetaByText = new Map(); for (const span of spans) { - const meta = resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd); + const meta = resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd, lookupRoot); if (meta) inlineCodeFileLinkMetaByText.set(span, meta); } const fileLinkParentSuffixByPath = buildFileLinkParentSuffixByPath([ @@ -517,7 +524,8 @@ function useChatMarkdownState({ skills = EMPTY_MARKDOWN_SKILLS, onUseArtifactTemplate, onRunShellCommand, - imageBaseDir, + imageBaseDir: documentBaseDir, + fileLinkBaseDir, workspaceMutationId, onImageExpand, renderContextReference, @@ -525,6 +533,9 @@ function useChatMarkdownState({ sourceControlMedia = false, sourceControlMediaRepository, }: ChatMarkdownProps) { + const imageBaseDir = fileLinkBaseDir ?? documentBaseDir; + // A row that ran in another folder has its relative links looked up there. + const lookupRoot = fileLinkBaseDir ?? cwd; const resolvedTheme = useRenderedAppearance(); const markdownRef = useRef(null); const mediaRequestId = useRef(0); @@ -704,7 +715,7 @@ function useChatMarkdownState({ // A template literal marks the key as a string, so the compiler memoizes the maps on its value. const fileLinksKey = `${fileLinkSourcesKey(text, cwd, imageBaseDir)}`; const { markdownFileLinkMetaByHref, inlineCodeFileLinkMetaByText, fileLinkParentSuffixByPath } = - resolveFileLinkSources(fileLinksKey, cwd, imageBaseDir); + resolveFileLinkSources(fileLinksKey, cwd, imageBaseDir, lookupRoot); const resolveThreadPullRequest = ( href: string, ): (ThreadPullRequestKey & { readonly url: string }) | null => { @@ -783,20 +794,20 @@ function useChatMarkdownState({ const workspaceRelativePath = fileLinkMeta.workspaceLookupPath; if ( !workspaceRelativePath || - !cwd || + !lookupRoot || environmentId === null || !readEnvironmentScope(environmentId, AuthFilesystemReadScope) ) { return null; } - const query = workspaceLinkLookupQuery(workspaceRelativePath, cwd); + const query = workspaceLinkLookupQuery(workspaceRelativePath, lookupRoot); if (query === null) return null; // Search can be truncated or still scanning, so it cannot rule out the literal // path. A path with a folder opens as written whenever it exists. if (!needsWorkspaceBasenameLookup(workspaceRelativePath)) { const metadata = await getFilesystemMetadata({ environmentId, - input: { paths: [resolveWorkspaceFilePath(workspaceRelativePath, cwd)] }, + input: { paths: [resolveWorkspaceFilePath(workspaceRelativePath, lookupRoot)] }, }); if (metadata._tag !== "Success" || metadata.value.entries[0] !== null) return null; } @@ -814,24 +825,28 @@ function useChatMarkdownState({ }); return { root, entries: result._tag === "Success" ? result.value.entries : [] }; }; - const own = await searchRoot(cwd); - const ownMatch = pickWorkspaceLinkMatch(workspaceRelativePath, cwd, own.entries); - if (ownMatch !== null) return ownMatch; + const own = await searchRoot(lookupRoot); + const ownMatch = pickWorkspaceLinkMatch(workspaceRelativePath, lookupRoot, own.entries); + // Callers read a relative match against the thread's folder. + if (ownMatch !== null) { + return lookupRoot === cwd ? ownMatch : resolveWorkspaceFilePath(ownMatch, lookupRoot); + } const threadProjectId = threadRef ? readThreadShell(threadRef)?.projectId : undefined; const otherRoots = projects.flatMap((project) => project.environmentId === environmentId && - project.id !== threadProjectId && - project.workspaceRoot !== cwd + // A row that ran in another folder is not in the thread's project. + (lookupRoot !== cwd || project.id !== threadProjectId) && + project.workspaceRoot !== lookupRoot ? [project.workspaceRoot] : [], ); - const agentRoots = (agentDirectories?.() ?? []).filter((root) => root !== cwd); + const agentRoots = (agentDirectories?.() ?? []).filter((root) => root !== lookupRoot); if (otherRoots.length === 0 && agentRoots.length === 0) return null; // Another project's file never replaces a thread-local file the search missed. if (needsWorkspaceBasenameLookup(workspaceRelativePath)) { const metadata = await getFilesystemMetadata({ environmentId, - input: { paths: [resolveWorkspaceFilePath(workspaceRelativePath, cwd)] }, + input: { paths: [resolveWorkspaceFilePath(workspaceRelativePath, lookupRoot)] }, }); if (metadata._tag !== "Success" || metadata.value.entries[0] !== null) return null; } @@ -858,10 +873,13 @@ function useChatMarkdownState({ const isLatestLookup = claimWorkspaceBasenameLookup(); const openAt = (path: string) => useRightPanelStore.getState().openFile(threadRef, path, fileLinkMeta.line); + // A row that ran in another folder has an absolute panel path. The lookup reads it from its root. + const rootPath = workspaceRelativeFilePath(fileLinkMeta.filePath, lookupRoot); if ( fileLinkMeta.workspaceLookupPath === null || - !cwd || - workspaceLinkLookupQuery(panelPath, cwd) === null + !lookupRoot || + rootPath === null || + workspaceLinkLookupQuery(rootPath, lookupRoot) === null ) { openAt(panelPath); return; @@ -880,7 +898,7 @@ function useChatMarkdownState({ const resolveHostPath = () => resolveMarkdownFileHostPath({ meta: fileLinkMeta, - cwd, + cwd: lookupRoot, findWorkspaceMatch: () => findWorkspaceFileMatch(fileLinkMeta), }); const isAppBundle = isMacOS && fileLinkMeta.filePath.endsWith(".app"); @@ -980,6 +998,7 @@ function useChatMarkdownState({ renderContextReference, headingLevelOffset, imageBaseDir, + lookupRoot, workspaceMutationId, inlineCodeFileLinkMetaByText, inline, @@ -1157,6 +1176,7 @@ const CHAT_MARKDOWN_COMPONENTS = { cwd, environmentId, imageBaseDir, + lookupRoot, markdownFileLinkMetaByHref, threadRef, openMarkdownMedia, @@ -1204,7 +1224,7 @@ const CHAT_MARKDOWN_COMPONENTS = { const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; const fileLinkMeta = normalizedHref ? (markdownFileLinkMetaByHref.get(normalizedHref) ?? - resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd)) + resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd, lookupRoot)) : null; if (!fileLinkMeta) { const faviconHost = resolveExternalWebLinkHost(href); @@ -1432,8 +1452,15 @@ const CHAT_MARKDOWN_COMPONENTS = { ); }, code: function MarkdownCode({ node, children, className, ...props }) { - const { cwd, imageBaseDir, inlineCodeFileLinkMetaByText, fileLinkChip, mathEnabled, inline } = - use(ChatMarkdownRendererContext); + const { + cwd, + imageBaseDir, + lookupRoot, + inlineCodeFileLinkMetaByText, + fileLinkChip, + mathEnabled, + inline, + } = use(ChatMarkdownRendererContext); const math = mathEnabled ? mathKind(className) : null; if (math) { return ( @@ -1448,7 +1475,7 @@ const CHAT_MARKDOWN_COMPONENTS = { const codeText = nodeToPlainText(children); const fileLinkMeta = inlineCodeFileLinkMetaByText.get(codeText.trim()) ?? - resolveInlineCodeFileLinkMeta(codeText, cwd, imageBaseDir ?? cwd); + resolveInlineCodeFileLinkMeta(codeText, cwd, imageBaseDir ?? cwd, lookupRoot); if (fileLinkMeta) { return fileLinkChip( fileLinkMeta, @@ -1476,6 +1503,7 @@ const CHAT_MARKDOWN_COMPONENTS = { sourceControlMedia, sourceControlMediaRepository, imageBaseDir, + lookupRoot, threadRef, workspaceMutationId, renderContextReference, @@ -1541,7 +1569,12 @@ const CHAT_MARKDOWN_COMPONENTS = { ); } if (mediaKind === null && imageSource._tag === "WorkspaceFile") { - const fileLinkMeta = resolveMarkdownFileLinkMeta(srcString, cwd, imageBaseDir ?? cwd); + const fileLinkMeta = resolveMarkdownFileLinkMeta( + srcString, + cwd, + imageBaseDir ?? cwd, + lookupRoot, + ); if (fileLinkMeta) return fileLinkChip(fileLinkMeta, copyMarkdown, srcString); } const kind = mediaKind ?? "image"; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 532a25c9c45e2..d0e1001110153 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3477,6 +3477,7 @@ export default function ChatView(props: ChatViewProps) { footer={timelineFooter} listRef={legendListRef} runs={serverProjection?.runs ?? []} + runFolders={serverProjection?.runFolders} latestRun={activeActivityRun} announcedRun={announcedRun(activeActivityRun, serverProjection?.runs ?? [])} detailBehindShell={isDetailBehindShell( diff --git a/apps/web/src/components/chat/ChatMarkdownMedia.test.ts b/apps/web/src/components/chat/ChatMarkdownMedia.test.ts new file mode 100644 index 0000000000000..3c462facdb25a --- /dev/null +++ b/apps/web/src/components/chat/ChatMarkdownMedia.test.ts @@ -0,0 +1,24 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; + +import { useRightPanelStore } from "../../rightPanelStore"; +import { assetMediaActionsSource } from "./ChatMarkdownMedia"; + +it.each([ + { run: "the thread's folder", root: "/repo-w/app", path: "/repo-w/app/a.png", opens: ["a.png"] }, + { run: "another folder", root: "/repo-a", path: "/repo-a/a.png", opens: ["/repo-a/a.png"] }, + { run: "a parent folder", root: "/repo-w", path: "/repo-w/app/a.png", opens: ["a.png"] }, + { run: "another folder, file outside it", root: "/repo-a", path: "/tmp/a.png", opens: [] }, +])("opens a media file of a run in $run by a path the panel reads", ({ root, path, opens }) => { + using openFile = vi.spyOn(useRightPanelStore.getState(), "openFile").mockImplementation(() => {}); + assetMediaActionsSource({ + environmentId: EnvironmentId.make("environment-1"), + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path }, + kind: "image", + name: "image", + src: null, + workspaceRoot: root, + threadWorkspaceRoot: "/repo-w/app", + }).onOpenFile?.(); + expect(openFile.mock.calls.map((call) => call[1])).toEqual(opens); +}); diff --git a/apps/web/src/components/chat/ChatMarkdownMedia.tsx b/apps/web/src/components/chat/ChatMarkdownMedia.tsx index 580de34b9d95b..10435caad53f6 100644 --- a/apps/web/src/components/chat/ChatMarkdownMedia.tsx +++ b/apps/web/src/components/chat/ChatMarkdownMedia.tsx @@ -347,6 +347,8 @@ export function assetMediaActionsSource(input: { readonly name: string; readonly src: string | null; readonly workspaceRoot?: string | undefined; + /** The thread's folder, when `workspaceRoot` is the folder of a run that started elsewhere. */ + readonly threadWorkspaceRoot?: string | undefined; readonly originalUrl?: string | undefined; readonly downloadable?: boolean | undefined; }): MediaActionSource { @@ -363,18 +365,24 @@ export function assetMediaActionsSource(input: { ? mediaUrlReference(input.originalUrl) : undefined; const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; + // The panel reads a relative path in the thread's folder, so a file of a run in another folder + // opens by absolute path. + const panelPath = + path && input.threadWorkspaceRoot + ? (mediaFileReference(path, input.threadWorkspaceRoot).relativePath ?? (relativePath && path)) + : relativePath; return { kind: input.kind, name: input.name, src: input.src, ...(input.downloadable === false ? {} : { asset: { environmentId, resource } }), ...(reference ? { reference } : {}), - ...(relativePath && (resource._tag === "media-file" || resource._tag === "workspace-file") + ...(panelPath && (resource._tag === "media-file" || resource._tag === "workspace-file") ? { onOpenFile: () => useRightPanelStore .getState() - .openFile({ environmentId, threadId: resource.threadId }, relativePath), + .openFile({ environmentId, threadId: resource.threadId }, panelPath), } : {}), }; @@ -398,6 +406,7 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props readonly framed?: boolean | undefined; readonly fallbackSrc?: string | undefined; readonly workspaceRoot?: string | undefined; + readonly threadWorkspaceRoot?: string | undefined; readonly workspaceMutationId?: string | null | undefined; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; }) { @@ -442,6 +451,7 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props name: props.alt || (props.kind ?? "image"), src, workspaceRoot: props.workspaceRoot, + threadWorkspaceRoot: props.threadWorkspaceRoot, originalUrl: props.originalUrl, downloadable: fallbackSrc === undefined, }); diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 1477d101e0b83..09ba1f931b302 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -18,6 +18,7 @@ import { ThreadId, type OrchestrationV2Subagent, } from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { createRef, @@ -33,7 +34,8 @@ import { describe, expect, it, onTestFinished, vi } from "vite-plus/test"; import "~/index.css"; -import { environmentThreadDetails } from "../../state/threads"; +import { environmentProjects } from "../../state/projects"; +import { environmentThreadDetails, environmentThreadShells } from "../../state/threads"; import { act, fakeTimers, render, stubGlobal } from "../../../test/render"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { MessagesTimeline } from "./MessagesTimeline"; @@ -51,6 +53,8 @@ import { TimelineRowCtx } from "./timelineRowContext"; import { parseMarkdownWithMath } from "@t3tools/shared/markdownMath"; import { collectThreadFindRanges } from "./threadFindHighlights"; import { toastManager } from "../ui/toast"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; +import { useRightPanelStore } from "~/rightPanelStore"; import type { CitedHistory } from "./citationProcedure"; import type { TimelineNavigation } from "./timelineViewport"; import { rememberTimelinePosition } from "./timelineScrollAnchoring"; @@ -58,7 +62,11 @@ import { COMPOSER_CONTEXT_CLIPBOARD_MIME, decodeComposerContextFragment, } from "@t3tools/shared/composerContextClipboard"; -import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import { + collectComposerContextReferences, + formatComposerContextReference, +} from "@t3tools/shared/composerContextReferences"; +import { resolveFileContextMenuAbsolutePath, type FileContextMenuTarget } from "~/fileContextMenu"; const environmentQuery = vi.hoisted(() => ({ error: "Pull request not found" as string | null, @@ -77,6 +85,12 @@ vi.mock(import("~/state/query"), async (original) => ({ })) as never, })); +const fileMenu = vi.hoisted(() => ({ targets: [] as Array })); +vi.mock(import("~/fileContextMenu"), async (original) => ({ + ...(await original()), + useFileContextMenuHandler: () => (target: FileContextMenuTarget) => fileMenu.targets.push(target), +})); + const stubbedAsset = vi.hoisted(() => ({ url: "" })); vi.mock(import("~/assets/assetUrls"), async (original) => { const actual = await original(); @@ -92,6 +106,8 @@ vi.mock(import("~/assets/assetUrls"), async (original) => { : actual.useAssetUrls(...args)) as typeof actual.useAssetUrls, }; }); +const PIXEL_PNG = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; const ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const CREATED_AT = "2026-03-17T19:12:28.000Z"; @@ -223,6 +239,7 @@ async function click(element: HTMLElement) { function setup({ scheduledTasks = [] as ReadonlyArray<{ id: string; title: string }> } = {}) { environmentQuery.error = "Pull request not found"; environmentQuery.data = { tasks: scheduledTasks }; + fileMenu.targets.length = 0; } const TASK_ID = ScheduledTaskId.make("task-1"); @@ -1679,8 +1696,7 @@ describe("MessagesTimeline attachments", () => { }); it("opens a viewed image as a gallery of the images beside it", async () => { - stubbedAsset.url = - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + stubbedAsset.url = PIXEL_PNG; onTestFinished(() => { stubbedAsset.url = ""; }); @@ -1701,6 +1717,7 @@ describe("MessagesTimeline attachments", () => { id: "viewed", createdAt: CREATED_AT, paths: ["/repo/first.png", "/repo/second.png", "/repo/third.png"], + source: { id: "viewed" }, }} /> , @@ -1717,8 +1734,7 @@ describe("MessagesTimeline attachments", () => { }); it("opens an image from an expanded tool row as a gallery of the turn's viewed images", async () => { - stubbedAsset.url = - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + stubbedAsset.url = PIXEL_PNG; onTestFinished(() => { stubbedAsset.url = ""; }); @@ -2332,3 +2348,158 @@ it("shows the full folders in an expanded move receipt", async () => { const paths = [...screen.container.querySelectorAll("code")].map((code) => code.textContent); expect(paths).toEqual(["/repos/a", "/repos/b`c"]); }); + +it("opens a row's relative links under its run's folder", async () => { + setup(); + stubbedAsset.url = PIXEL_PNG; + onTestFinished(() => { + stubbedAsset.url = ""; + }); + const onImageExpand = vi.fn(); + const run = (id: string, folder: object = {}) => ({ + id: RunId.make(id), + ordinal: 1, + providerInstanceId: ProviderInstanceId.make("codex"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + ...folder, + }); + const mention = { + version: 1 as const, + kind: "mention" as const, + contextId: ComposerContextId.make("mention-notes"), + label: "notes.md", + path: "notes.md", + }; + const entries = [ + message("See [notes](notes.txt).", {}, "imported"), + { + id: "plan", + kind: "proposed-plan", + createdAt: CREATED_AT, + proposedPlan: { id: "plan", runId: null, planMarkdown: "[plan](plan.txt)", status: "active" }, + } as Entry, + work("move-receipt", { + itemType: "system_notice", + label: "Moved.", + structuredPayload: { type: "system_notice", previousFolder: "/old" }, + }), + message("See [a](a.ts).", { runId: RunId.make("run-1") }, "in-run"), + ...["diagram.png", "chart.png"].map((name) => + work(`read-${name}`, { + label: `Read ${name}`, + toolTitle: "Read", + tone: "tool", + itemType: "dynamic_tool", + toolLifecycleStatus: "completed", + viewedImagePath: name, + runId: RunId.make("run-1"), + }), + ), + message("See [b](b.ts).", {}, "runless"), + message("See [c](c.ts).", { runId: RunId.make("run-paged") }, "paged"), + message( + `Read ${formatComposerContextReference(mention)}`, + { runId: RunId.make("run-1"), context: { version: 1, records: [mention] } }, + "mention", + ), + message("Done", { role: "assistant", runId: RunId.make("run-1") }, "changed"), + ]; + const ui = (props: Partial) => ( + + ); + using _threadShell = vi + .spyOn(environmentThreadShells, "quietThreadShellAtom") + .mockReturnValue(Atom.make({ environmentId: ENVIRONMENT_ID, projectId: "project-w" }) as never); + using _project = vi + .spyOn(environmentProjects, "projectAtom") + .mockReturnValue(Atom.make({ repositoryIdentity: { rootPath: "/repo-w" } }) as never); + await using screen = await renderElement(ui({ runs: [run("run-1")] })); + using openFile = vi.spyOn(useRightPanelStore.getState(), "openFile").mockImplementation(() => {}); + const opened = async () => { + openFile.mockClear(); + const links = [...screen.container.querySelectorAll("a.chat-markdown-file-link")]; + for (const link of links) await click(link); + await click(screen.button("Preview notes.md")); + return openFile.mock.calls.map((call) => call[1]); + }; + const noFolders = ["/old/notes.txt", "/old/plan.txt", "a.ts", "b.ts", "c.ts", "notes.md"]; + await vi.waitFor(async () => expect(await opened()).toEqual(noFolders)); + + await screen.rerender( + ui({ + runs: [run("run-1", { cwd: "/repo-a/app", repositoryRoot: "/repo-a" })], + runFolders: [{ id: RunId.make("run-paged"), cwd: "/repo-old" }], + }), + ); + await vi.waitFor(async () => + expect(await opened()).toEqual([ + "/old/notes.txt", + "/old/plan.txt", + "/repo-a/app/a.ts", + "b.ts", + "/repo-old/c.ts", + "/repo-a/app/notes.md", + ]), + ); + + const gallery = expect.objectContaining({ + images: ["diagram.png", "chart.png"].map((name) => + expect.objectContaining({ + actionsSource: expect.objectContaining({ + asset: expect.objectContaining({ + resource: expect.objectContaining({ path: `/repo-a/app/${name}` }), + }), + }), + }), + ), + }); + const thumbnails = () => screen.buttons("Preview diagram.png"); + await click(screen.button(/^Worked for/)); + await vi.waitFor(() => expect(thumbnails()).toHaveLength(1)); + await click(thumbnails()[0]!); + await click(screen.button(/^Read 2 files/)); + await vi.waitFor(() => expect(screen.buttons("Read")).toHaveLength(2)); + await click(screen.buttons("Read")[0]!); + await vi.waitFor(() => expect(thumbnails()).toHaveLength(2)); + await click(thumbnails().find((image) => !image.closest("[data-image-gallery]"))!); + expect(onImageExpand.mock.calls).toEqual([[gallery], [gallery]]); + + await click(screen.button(/^app/)); + for (const [folder, target] of [ + [{ cwd: "/repo-a/app", repositoryRoot: "/repo-a" }, "/repo-a/app/kept.txt"], + [{ cwd: "/repo-w/app" }, "/repo-w/app/kept.txt"], + [{ cwd: "/repo-old" }, "/repo-old/app/kept.txt"], + ] as const) { + await screen.rerender(ui({ runs: [run("run-1", folder)] })); + await vi.waitFor(() => { + screen.button(/^kept\.txt/).dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })); + expect(resolveFileContextMenuAbsolutePath(fileMenu.targets.at(-1)!)).toEqual(target); + }); + } + + expect(screen.text()).toContain("Moved."); + onTestFinished(() => __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS)); + __setClientSettingsForTests({ ...DEFAULT_CLIENT_SETTINGS, toolCallVisibility: "zen" }); + await screen.rerender(ui({ runs: [run("run-1")] })); + await vi.waitFor(() => expect(screen.text()).not.toContain("Moved.")); + expect(await opened()).toEqual(noFolders); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 9208e409ba205..07e75a3685bbf 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,5 +1,9 @@ import * as DateTime from "effect/DateTime"; import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; +import { + timelineRowFolder as rowFolder, + type TimelineRowFolders, +} from "@t3tools/client-runtime/timeline-row-folders"; import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; import { isLiveSubagentTurnItem, @@ -560,6 +564,8 @@ type MessagesTimelineRowContent = id: string; createdAt: string; paths: ReadonlyArray; + /** The group's first entry, as on its work row: it names the folder the paths resolve in. */ + source: Pick; } | { kind: "turn-fold"; @@ -686,9 +692,10 @@ function viewedImagesRows( groupedEntries: ReadonlyArray, ): Array> { const paths = workEntriesViewedImagePaths(groupedEntries); - return paths.length === 0 + const [source] = groupedEntries; + return source === undefined || paths.length === 0 ? [] - : [{ kind: "viewed-images", id: `${groupId}:viewed-images`, createdAt, paths }]; + : [{ kind: "viewed-images", id: `${groupId}:viewed-images`, createdAt, paths, source }]; } export function resolveAssistantMessageCopyState({ @@ -2756,6 +2763,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean const bv = b as typeof a; return ( a.createdAt === bv.createdAt && + a.source.id === bv.source.id && + a.source.runId === bv.source.runId && a.paths.length === bv.paths.length && a.paths.every((path, index) => path === bv.paths[index]) ); @@ -2844,6 +2853,25 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean } } +export function timelineEntryPreviousFolder(entry: TimelineEntry) { + const item = entry.kind === "work" ? entry.entry.structuredPayload : undefined; + return item?.type === "system_notice" ? item.previousFolder : undefined; +} + +export function timelineRowFolder(row: MessagesTimelineRow, folders: TimelineRowFolders) { + const entry = + row.kind === "work" + ? row.groupedEntries[0] + : row.kind === "work-live" + ? row.entry + : row.kind === "viewed-images" + ? row.source + : undefined; + const source = + row.kind === "message" ? row.message : row.kind === "proposed-plan" ? row.proposedPlan : entry; + return rowFolder(folders, source?.runId, entry?.id ?? row.id); +} + export function handoffRunsSignature(runs: ReadonlyArray): string { return runs .map( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 83a616a0e8609..98dd289865c9a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -13,6 +13,8 @@ import { type AssistantCitation, type EnvironmentId, type MessageId, + type OrchestrationV2Run, + type OrchestrationV2ThreadProjection, type OrchestrationV2TurnItem, type RunAttemptId, type ServerProviderSkill, @@ -76,6 +78,11 @@ import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection import { type AssistantCitationRequest } from "./AssistantCitationSource"; import { useAssistantCitationTarget } from "./useAssistantCitationTarget"; import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; +import { + receiptFoldersSignature, + runFoldersSignature, + timelineRowFolders, +} from "@t3tools/client-runtime/timeline-row-folders"; import { resolveCitationPhase, type CitedHistory } from "./citationProcedure"; import { resolveTimelineIsAtEnd, @@ -87,6 +94,8 @@ import { TIMELINE_MINIMAP_TARGET_OFFSET, timelineTurnFoldRunIdsByEntryId, liveAnswerPreview, + timelineEntryPreviousFolder, + timelineRowFolder, type MessagesTimelineRow, type TimelineLatestRun, } from "./MessagesTimeline.logic"; @@ -108,6 +117,7 @@ import { type TimelineRowActivityState, TimelineRowCtx, TimelineRowActivityCtx, + TimelineRowFolderCtx, type WorkGroupViewState, type TimelineRow, } from "./timelineRowContext"; @@ -304,7 +314,8 @@ interface MessagesTimelineProps { timestampFormat: TimestampFormat; workspaceRoot: string | undefined; skills?: ReadonlyArray; - runs: ReadonlyArray; + runs: ReadonlyArray>; + runFolders?: OrchestrationV2ThreadProjection["runFolders"]; anchorMessageId: MessageId | null; onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; onAnchorSizeChanged: (messageId: MessageId, size: number) => void; @@ -406,6 +417,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ workspaceRoot, skills = EMPTY_TIMELINE_SKILLS, runs: runsProp, + runFolders, anchorMessageId, onAnchorReady, onAnchorSizeChanged, @@ -1343,6 +1355,10 @@ const ConversationTimeline = memo(function ConversationTimeline({ return next; }); }); + const folders = timelineRowFolders( + `${runFoldersSignature(runsProp, runFolders)}`, + `${receiptFoldersSignature(timelineEntries, timelineEntryPreviousFolder)}`, + ); const renderItem = ({ item }: { item: MessagesTimelineRow }) => (
- + + +
); - const listExtraData = { identity: listIdentityKey, rowCount: rows.length, sharedState }; + const listExtraData = { identity: listIdentityKey, rowCount: rows.length, sharedState, folders }; const timelineListFooter = ( }) { @@ -65,6 +70,7 @@ export function AssistantTimelineRow({ row }: { row: Extract @@ -438,6 +445,7 @@ function AssistantChangedFilesSectionInner({ onOpenTurnDiff: (runId: RunId, filePath?: string) => void; }) { const ctx = use(TimelineRowCtx); + const rowFolder = use(TimelineRowFolderCtx); const persistedExpanded = useUiStateStore( (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.runId], ); @@ -468,11 +476,13 @@ function AssistantChangedFilesSectionInner({ { environmentId: ctx.activeThreadEnvironmentId, filePath, - workspaceRoot: ctx.workspaceRoot, + workspaceRoot: rowFolder?.cwd ?? ctx.workspaceRoot, repositoryRoot: + rowFolder?.repositoryRoot ?? + ((rowFolder === undefined || rowFolder.cwd === ctx.workspaceRoot) && thread?.worktreePath == null ? activeProject?.repositoryIdentity?.rootPath - : undefined, + : undefined), }, event, ) diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index 3fee2134c3f58..fdabae35aad8b 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -85,7 +85,12 @@ import { useMediaQuery } from "~/hooks/useMediaQuery"; import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat"; import { WorkflowOpeningBlock } from "./WorkflowOpeningBlock"; import { useTallEnoughToFold } from "./useTallEnoughToFold"; -import { TimelineRowCtx, TimelineRowActivityCtx, type TimelineRow } from "./timelineRowContext"; +import { + TimelineRowCtx, + TimelineRowActivityCtx, + TimelineRowFolderCtx, + type TimelineRow, +} from "./timelineRowContext"; import { MESSAGE_HEADING_LEVEL, MessageAuthorHeading, @@ -1224,6 +1229,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { void }) => e.stopPropagation( export function ReasoningTraceContent({ entries }: { entries: ReadonlyArray }) { const ctx = use(TimelineRowCtx); + const rowFolder = use(TimelineRowFolderCtx)?.cwd; const { isWorking, latestRunId } = use(TimelineRowActivityCtx); const isStreaming = (entry: TimelineWorkEntry) => isWorking && entry.runId === latestRunId && entry.toolLifecycleStatus === "inProgress"; @@ -140,6 +143,7 @@ export function ReasoningTraceContent({ entries }: { entries: ReadonlyArray