From 713731f8481bb6ce8b31d1d6543493e1df391735 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 09:30:51 -0800 Subject: [PATCH 001/409] fix(server): a timed-out preview call no longer evicts the shared server browser (#36241) --- .../src/mcp/PreviewAutomationBroker.test.ts | 50 +++++++++++++++++++ .../server/src/mcp/PreviewAutomationBroker.ts | 6 ++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index e800570b8ba57..dac5153acee6b 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -18,8 +18,10 @@ import { import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; +import * as Queue from "effect/Queue"; import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { PreviewBrowserRootError } from "../preview/PreviewBrowserHost.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; @@ -950,6 +952,54 @@ it.effect("fails requests assigned to the stream that is replaced", () => }), ); +it.effect( + "keeps another thread's request alive when one request times out on the shared server browser", + () => + Effect.gen(function* () { + const broker = yield* makeBroker; + const clientId = SERVER_BROWSER_AUTOMATION_CLIENT_ID; + const delivered = yield* Queue.unbounded(); + const requests = requestsFrom( + yield* broker.connect(makeHost({ clientId }), { preferred: true }), + ); + yield* Stream.runForEach(requests, (request) => Queue.offer(delivered, request)).pipe( + Effect.forkScoped, + ); + yield* Effect.yieldNow; + const otherScope = { + ...scope, + requestNamespace: "provider-session-2", + thread: { + threadId: ThreadId.make("thread-2"), + providerSessionId: "provider-session-2", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + }; + + const otherWait = yield* broker + .invoke({ scope: otherScope, operation: "waitFor", input: {}, timeoutMs: 40_000 }) + .pipe(Effect.result, Effect.forkScoped); + const otherRequest = yield* Queue.take(delivered); + // The browser never answers this one, as when it waits behind the thread's own running action. + const stalled = yield* broker + .invoke({ scope, operation: "evaluate", input: {} }) + .pipe(Effect.flip, Effect.forkScoped); + yield* Queue.take(delivered); + + yield* TestClock.adjust("16 seconds"); + expect((yield* Fiber.join(stalled))._tag).toBe("PreviewAutomationTimeoutError"); + + yield* broker.respond({ + clientId, + connectionId: otherRequest.connectionId, + requestId: otherRequest.requestId, + ok: true, + result: "ready", + }); + expect(yield* Fiber.join(otherWait)).toEqual(Result.succeed("ready")); + }), +); + it.effect("accepts responses only from the host that received the request", () => Effect.gen(function* () { const broker = yield* makeBroker; diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 85646d387160d..073cecced051b 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -662,7 +662,11 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { // The enclosing operation owns the span for this inline handler. onNone: Effect.fnUntraced(function* () { // Never replay possibly applied actions; slow metadata reads must not evict the host. - if (input.updateCurrentTab !== false) { + // The in-process server browser is shared by every thread and cannot hang its stream. + if ( + input.updateCurrentTab !== false && + connection.clientId !== SERVER_BROWSER_AUTOMATION_CLIENT_ID + ) { yield* disconnect(connection.clientId, connection.queue, true); } return yield* new PreviewAutomationTimeoutError(requestContext); From 5aea99a308f805e932b6557ffa9a593a548f2b10 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 09:31:08 -0800 Subject: [PATCH 002/409] fix(server): turns recreate a worktree folder that lost its checkout (#34894) --- .../ProviderTurnStartService.test.ts | 30 ++++++++++++++++- apps/server/src/storageCleanup.test.ts | 18 ++++++++++ apps/server/src/storageCleanup.ts | 6 ++-- .../src/workspace/restoreMissingWorktree.ts | 33 +++++++++++++++++-- 4 files changed, 82 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 62f095b6fd913..efb12a224e015 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -1127,6 +1127,8 @@ effectIt.effect( const expectedTips = { retained: { tip: threadTip, file: "thread.txt", upstream: null, base: "" }, + // A failed removal can leave the folder behind with only dependencies inside. + leftover: { tip: threadTip, file: "thread.txt", upstream: null, base: "" }, deleted: { tip: projectTip, file: "project.txt", upstream: null, base: "project-current" }, pushed: { tip: threadTip, @@ -1171,7 +1173,12 @@ effectIt.effect( yield* git(workspaceRoot, ["remote", "remove", "origin"]); } yield* fileSystem.remove(worktreePath, { recursive: true }); - if (scenario !== "retained") { + if (scenario === "leftover") { + yield* fileSystem.makeDirectory(path.join(worktreePath, "node_modules"), { + recursive: true, + }); + } + if (scenario !== "retained" && scenario !== "leftover") { yield* git(workspaceRoot, ["worktree", "prune"]); yield* git(workspaceRoot, ["branch", "-D", "feature/restore"]); } @@ -1220,6 +1227,27 @@ effectIt.effect( }).pipe(Effect.scoped, Effect.provide(layerRealGit)), ); +effectIt.effect("leaves a managed worktree folder without .git that holds user files", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const worktreesDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-leftover-" }); + const worktreePath = path.join(worktreesDir, "project", "restore"); + yield* fileSystem.makeDirectory(path.join(worktreePath, "node_modules"), { recursive: true }); + yield* fileSystem.writeFileString(path.join(worktreePath, "notes.md"), "keep me"); + const harness = makeLocalCommandHarness({ + text: "Continue", + worktreePath, + worktreesDir, + fileSystem, + failReadsAfterRunning: true, + }); + yield* harness.start; + expect(harness.createWorktree).not.toHaveBeenCalled(); + expect(yield* fileSystem.readFileString(path.join(worktreePath, "notes.md"))).toBe("keep me"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + effectIt.effect( "preserves recreation interrupts and retries, then fails the final attempt before provider start", () => diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 28d66e50bb0bb..b0641a20428ec 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -2332,6 +2332,24 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.effect("keeps a thread's worktree folder that lost its .git link", () => + runCleanupTest( + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture(); + fixture.setThreads([ + shell({ branch: "feature", worktreePath: fixture.worktree, archivedAt: at(-DAY_MS) }), + ]); + yield* fixture.fs.remove(`${fixture.worktree}/.git`); + yield* fixture.fs.makeDirectory(`${fixture.worktree}/node_modules`); + const report = yield* fixture.service.runNow; + expect(report.entries).toEqual([ + expect.objectContaining({ path: fixture.worktree, outcome: "kept" }), + ]); + expect(yield* fixture.fs.exists(`${fixture.worktree}/node_modules`)).toBe(true); + }), + ), + ); it.live("skips a stored worktree path whose parent is a regular file", () => runCleanupTest( Effect.gen(function* () { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 0cf5eca0b80d0..99097501d49d7 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -1010,8 +1010,10 @@ const make = Effect.gen(function* () { return "worktree is a symbolic link"; if (!roots.some((root) => inside(root, realPath))) return "outside the managed worktree folder"; - if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File") - return "not a linked worktree"; + const gitLink = yield* fs + .stat(path.join(worktreePath, ".git")) + .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined)); + if (gitLink?.type !== "File") return "not a linked worktree"; return null; }); const deletedRows = yield* sql<{ payload_json: string; workspaceRoot: string }>` diff --git a/apps/server/src/workspace/restoreMissingWorktree.ts b/apps/server/src/workspace/restoreMissingWorktree.ts index ee423c47d06ab..4bc1ac8f533c8 100644 --- a/apps/server/src/workspace/restoreMissingWorktree.ts +++ b/apps/server/src/workspace/restoreMissingWorktree.ts @@ -33,6 +33,9 @@ class WorktreeRecreateNoBaseError extends Schema.TaggedError + fileSystem.exists(path.join(worktreePath, ".git")).pipe( + Effect.flatMap((hasGit) => + hasGit + ? Effect.succeed(false) + : fileSystem.readDirectory(worktreePath).pipe( + Effect.flatMap((entries) => + Effect.forEach(entries, (entry) => + LEFTOVER_WORKTREE_ENTRIES.has(entry) + ? fileSystem + .stat(path.join(worktreePath, entry)) + .pipe(Effect.map((info) => info.type === "Directory")) + : Effect.succeed(false), + ), + ), + Effect.map((leftovers) => leftovers.every(Boolean)), + ), + ), + Effect.orElseSucceed(() => false), + ); + const resolveRecreateBase = Effect.fn("restoreMissingWorktree.resolveRecreateBase")(function* ( cwd: string, remoteName: string | null, @@ -116,7 +142,8 @@ export const makeRestoreMissingWorktree = Effect.gen(function* () { const { worktreePath, branch } = thread; if (worktreePath === null || branch === null) return; const exists = yield* fileSystem.exists(worktreePath).pipe(Effect.orElseSucceed(() => true)); - if (exists || !(yield* isManagedWorktreePath(worktreePath))) return; + const leftover = exists && (yield* isLeftoverFolder(worktreePath)); + if ((exists && !leftover) || !(yield* isManagedWorktreePath(worktreePath))) return; const project = yield* projects.getById(thread.projectId).pipe( Effect.map(Option.getOrUndefined), Effect.orElseSucceed(() => undefined), @@ -127,7 +154,9 @@ export const makeRestoreMissingWorktree = Effect.gen(function* () { worktreePath, branch, }); - yield* recreateMissingWorktree({ cwd: project.workspaceRoot, worktreePath, branch }).pipe( + // `git worktree add` refuses a non-empty target. + yield* (leftover ? fileSystem.remove(worktreePath, { recursive: true }) : Effect.void).pipe( + Effect.andThen(recreateMissingWorktree({ cwd: project.workspaceRoot, worktreePath, branch })), Effect.mapError((cause) => cause._tag === "WorktreeRecreateNoBaseError" ? cause From beb528ce4d67aff726de1f5ac2f707f5f977aedd Mon Sep 17 00:00:00 2001 From: Ali-Al1 <11404482+Ali-Al1@users.noreply.github.com> Date: Fri, 13 Nov 2026 17:34:17 +0000 Subject: [PATCH 003/409] fix(server): queued scheduled deliveries follow the thread's current model (#34149) Co-authored-by: Ali Alwan Co-authored-by: Julius Marminge --- .../src/orchestration-v2/Orchestrator.ts | 43 ++- .../SteeringCompletion.integration.test.ts | 191 +++++++++++- .../src/orchestration-v2/runtimeLayer.test.ts | 281 ++++++++++++++++++ packages/contracts/src/orchestrationV2.ts | 2 + 4 files changed, 506 insertions(+), 11 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index e78b473e5c4f2..3fa5a6bf4955c 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -909,16 +909,23 @@ function nextQueuedRun( return queuedRunsInDeliveryOrder(projection).find((run) => run.queueHeld !== true); } +// Delegated completions, monitor notifications and unpinned scheduled deliveries never choose a +// model, so a queued one runs on the thread's current pick rather than the one frozen at queue time. function followsCurrentConversation( message: | Pick< OrchestrationV2ConversationMessage, - "createdBy" | "creationSource" | "notification" | "delegatedCompletion" + | "createdBy" + | "creationSource" + | "notification" + | "delegatedCompletion" + | "followsThreadSelection" > | undefined, ): boolean { return ( message?.delegatedCompletion !== undefined || + message?.followsThreadSelection === true || (message?.notification?.source.kind === "monitor" && !isBufferedProviderNotification(message)) ); } @@ -1490,7 +1497,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio /** The driver when this provider runs `/goal` natively, else undefined. */ const goalDriverFor = Effect.fn("Orchestrator.goalDriverFor")(function* ( - command: OrchestrationV2ServerCommand, + command: Pick, providerInstanceId: ProviderInstanceId, ) { const adapter = yield* providerAdapters.get(providerInstanceId).pipe(mapDispatchError(command)); @@ -1505,7 +1512,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio /** Unsupported goal controls can become new objectives in native providers. */ const ensureGoalCommandSupported = Effect.fn("Orchestrator.ensureGoalCommandSupported")( function* ( - command: OrchestrationV2ServerCommand, + command: Pick, message: { readonly text: string; readonly attachments: ReadonlyArray }, providerInstanceId: ProviderInstanceId, ) { @@ -1531,7 +1538,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); const mapDispatchError = - (command: OrchestrationV2ServerCommand) => + (command: Pick) => (effect: Effect.Effect): Effect.Effect => effect.pipe( Effect.mapError( @@ -2452,6 +2459,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Queued run ${queuedRun.id} is missing projection state.`, }); } + // A scheduled delivery starts on the thread's current pick, so /goal must suit that provider. + yield* ensureGoalCommandSupported( + { commandId, type: "message.dispatch" }, + queuedMessage, + queuedRun.providerInstanceId, + ); const now = yield* DateTime.now; const targetAdapter = yield* providerAdapters.get(queuedRun.providerInstanceId).pipe( @@ -2478,7 +2491,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? activeProviderThread : rootProviderThreadsForProvider(projection, queuedRun.providerInstanceId)[0] : freshContext && - (storedProviderThread.firstRunOrdinal !== null || + // The reserved row belongs to the instance the run queued on, which may have changed. + (storedProviderThread.providerInstanceId !== queuedRun.providerInstanceId || + storedProviderThread.firstRunOrdinal !== null || storedProviderThread.nativeThreadRef !== null) ? undefined : storedProviderThread; @@ -7439,6 +7454,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), ...(command.linkedSender === undefined ? {} : { linkedSender: command.linkedSender }), ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), + ...(command.scheduledTaskId !== undefined && command.modelSelection === undefined + ? { followsThreadSelection: true as const } + : {}), id: command.messageId, threadId: command.threadId, runId, @@ -7851,6 +7869,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), ...(command.linkedSender === undefined ? {} : { linkedSender: command.linkedSender }), ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), + ...(command.scheduledTaskId !== undefined && command.modelSelection === undefined + ? { followsThreadSelection: true as const } + : {}), id: command.messageId, threadId: command.threadId, runId, @@ -10965,10 +10986,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const now = yield* DateTime.now; const emitEvent = emit(events, command); + // A scheduled delivery steers on the thread's current pick, so /goal must suit that provider. + const modelSelection = queuedRunModelSelection(projection, queuedRun); const parsedGoalCommand = parseGoalCommand(queuedMessage); const goalCommand = parsedGoalCommand === null || - (yield* goalDriverFor(command, queuedRun.providerInstanceId)) === undefined + (yield* goalDriverFor(command, modelSelection.instanceId)) === undefined ? null : parsedGoalCommand; const target = projection.runs.find((run) => run.id === command.targetRunId); @@ -10982,7 +11005,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio isCompactionCommandMessage(targetMessage) ) { if (goalCommand !== null) { - yield* ensureGoalCommandSupported(command, queuedMessage, queuedRun.providerInstanceId); + yield* ensureGoalCommandSupported(command, queuedMessage, modelSelection.instanceId); } yield* emitEvent({ type: "run.updated", @@ -11015,7 +11038,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } if (goalCommand !== null) { - yield* ensureGoalCommandSupported(command, queuedMessage, queuedRun.providerInstanceId); + yield* ensureGoalCommandSupported(command, queuedMessage, modelSelection.instanceId); const goal = projection.providerThreads.find( (candidate) => candidate.id === target?.providerThreadId, )?.goal; @@ -11124,7 +11147,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio events, effects, projection, - modelSelection: queuedRun.modelSelection, + modelSelection, targetRunId: command.targetRunId, messageId: queuedMessage.id, text: queuedMessage.text, @@ -11348,7 +11371,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio yield* ensureGoalCommandSupported( command, { text: command.text, attachments: command.attachments ?? queuedMessage.attachments }, - queuedRun.providerInstanceId, + queuedRunModelSelection(projection, queuedRun).instanceId, ); const queuedTurnItem = projection.turnItems.find( (candidate) => candidate.type === "user_message" && candidate.messageId === queuedMessage.id, diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index c0c8cfae5a804..acfd20af78749 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -11,6 +11,7 @@ import { ProviderInstanceId, ProviderThreadId, ProviderTurnId, + ScheduledTaskId, ThreadId, TurnItemId, type ModelSelection, @@ -511,6 +512,7 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* onSteer: ( turn: ProviderAdapter.ProviderAdapterV2SteerInput, ) => Effect.Effect = () => Effect.void, + extraAdapters: ReadonlyArray = [], ) { const driver = ProviderDriverKind.make(nativeGoals === undefined ? "codex" : "opencode"); const cwd = yield* checkpointWorkspace(name); @@ -647,7 +649,7 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* }; const layer = ProviderReplayHarness.layerWithRegistry( { name }, - ProviderAdapterRegistry.layerSingle(adapter), + ProviderAdapterRegistry.layerFromAdapters([adapter, ...extraAdapters]), { runEffectWorker: false }, ); const startFirstTurn = Effect.gen(function* () { @@ -2158,6 +2160,193 @@ it.effect("steers a promoted queued message with the selection it was queued wit }), ); +it.effect("steers a promoted scheduled delivery with the thread's current selection", () => + Effect.scoped( + Effect.gen(function* () { + const { started, steered, layer, startFirstTurn } = yield* nextTurnSelectionHarness( + "promote-scheduled-selection", + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + // Bound scheduled tasks send like ScheduledTaskService: no model of their own. + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("scheduled"), + threadId, + messageId: MessageId.make("message:scheduled"), + scheduledTaskId: ScheduledTaskId.make("task:scheduled"), + text: "scheduled", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queuedRun = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.status === "queued", + )!; + assert.deepEqual(queuedRun.modelSelection, runSelection); + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make("select-composer"), + threadId, + modelSelection: composerSelection, + }); + yield* orchestrator.dispatch({ + type: "queued-message.promote-to-steer", + commandId: CommandId.make("promote"), + threadId, + queuedRunId: queuedRun.id, + targetRunId: started[0]!.runId, + }); + yield* worker.drain(); + + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(steered, ["scheduled"]); + assert.deepEqual(projection.thread.modelSelection, composerSelection); + }).pipe(Effect.provide(layer)); + }), + ), +); + +const claudeSelection = { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-test", +}; +const claudeAdapter: ProviderAdapter.ProviderAdapterV2["Service"] = { + instanceId: claudeSelection.instanceId, + driver: ProviderDriverKind.make("claudeAgent"), + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), + openSession: () => Effect.die("the Claude instance never starts a turn here"), +}; + +// Queues a scheduled delivery behind the Codex turn, stops that turn (Stop holds the queue), then +// switches the thread to Claude: a thread changes instance only while no run is active. +const scheduledDeliveryAfterClaudeSwitch = (name: string, text: string) => + Effect.gen(function* () { + const harness = yield* nextTurnSelectionHarness( + name, + false, + true, + Effect.void, + true, + undefined, + undefined, + [claudeAdapter], + ); + const queueAndSwitch = Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = yield* harness.startFirstTurn; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("scheduled"), + threadId, + messageId: MessageId.make("message:scheduled"), + scheduledTaskId: ScheduledTaskId.make("task:scheduled"), + text, + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queuedRun = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.status === "queued", + )!; + assert.equal(queuedRun.providerInstanceId, instanceId); + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const first = harness.started[0]!; + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("stop"), + threadId, + runId: first.runId, + holdQueue: true, + }); + yield* worker.drain(); + const stopped = (yield* orchestrator.getThreadProjection(threadId)).runs; + assert.equal(stopped.find((run) => run.id === first.runId)?.status, "interrupted"); + const held = stopped.find((run) => run.id === queuedRun.id); + assert.equal(held?.status, "queued"); + assert.isTrue(held?.queueHeld); + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make("select-claude"), + threadId, + modelSelection: claudeSelection, + }); + return { threadId, queuedRun }; + }); + return { ...harness, queueAndSwitch }; + }); + +it.effect("checks an edited scheduled /goal against the thread's current provider", () => + Effect.scoped( + Effect.gen(function* () { + const { layer, queueAndSwitch } = yield* scheduledDeliveryAfterClaudeSwitch( + "edit-scheduled-goal", + "scheduled", + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { threadId, queuedRun } = yield* queueAndSwitch; + const editError = yield* orchestrator + .dispatch({ + type: "queued-run.edit", + commandId: CommandId.make("edit"), + threadId, + runId: queuedRun.id, + text: "/goal pause", + }) + .pipe(Effect.flip); + + assert.match(String(editError.cause), /Claude goals cannot be paused or resumed/); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).messages.find( + (message) => message.id === queuedRun.userMessageId, + )?.text, + "scheduled", + ); + }).pipe(Effect.provide(layer)); + }), + ), +); + +it.effect("checks a queued scheduled /goal against the thread's current provider at start", () => + Effect.scoped( + Effect.gen(function* () { + const { started, layer, queueAndSwitch } = yield* scheduledDeliveryAfterClaudeSwitch( + "start-scheduled-goal", + "/goal pause", + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const { threadId, queuedRun } = yield* queueAndSwitch; + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make("resume"), + threadId, + }); + yield* orchestrator.resumeQueuedRuns; + yield* worker.drain(); + + const projection = yield* orchestrator.getThreadProjection(threadId); + const failure = projection.turnItems.find( + (item) => item.type === "error" && item.runId === queuedRun.id, + ); + assert.equal(started.length, 1); + assert.equal(projection.runs.find((run) => run.id === queuedRun.id)?.status, "failed"); + assert.equal( + failure?.type === "error" ? failure.failure.code : null, + "queued_start_failed", + ); + }).pipe(Effect.provide(layer)); + }), + ), +); + it.effect("marks the model switch on a promoted message that restarts the turn", () => Effect.gen(function* () { const { started, layer, startFirstTurn } = yield* nextTurnSelectionHarness( diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index e6b7ec2e944c8..8ecf4a6ac3ee7 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -8479,6 +8479,287 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect.each([ + { ...modelSelection, model: "gpt-5.5", options: [{ id: "reasoningEffort", value: "high" }] }, + { instanceId: alternateInstanceId, model: "gpt-5.5" }, + ])( + "starts a queued scheduled delivery on a selection set while it waited: %j", + (newerSelection) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make( + `runtime-layer-queued-scheduled-selection-${newerSelection.instanceId}-${"options" in newerSelection}`, + ); + const scheduledMessageId = MessageId.make(`${threadId}:scheduled`); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + createdBy: "user", + creationSource: "web", + title: "Scheduled delivery selection", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:active`), + threadId, + messageId: MessageId.make(`${threadId}:active`), + text: "Busy", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const active = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + // Bound scheduled tasks send like ScheduledTaskService: no model of their own. + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:scheduled`), + threadId, + messageId: scheduledMessageId, + scheduledTaskId: ScheduledTaskId.make(`${threadId}:task`), + text: "Scheduled prompt", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queued = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.userMessageId === scheduledMessageId, + )!; + assert.equal(queued.status, "queued"); + assert.deepEqual(queued.modelSelection, modelSelection); + // Stop holds the queue; a thread changes instance only while no run is active. + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make(`${threadId}:stop`), + threadId, + runId: active.id, + holdQueue: true, + }); + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make(`${threadId}:select-newer`), + threadId, + modelSelection: newerSelection, + }); + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make(`${threadId}:resume`), + threadId, + }); + yield* orchestrator.resumeQueuedRuns; + const after = yield* orchestrator.getThreadProjection(threadId); + const delivered = after.runs.find((run) => run.userMessageId === scheduledMessageId)!; + assert.equal(delivered.status, "starting"); + assert.deepEqual(delivered.modelSelection, newerSelection); + assert.equal( + after.attempts.find((attempt) => attempt.id === delivered.activeAttemptId) + ?.providerInstanceId, + newerSelection.instanceId, + ); + assert.deepEqual(after.thread.modelSelection, newerSelection); + assert.equal(after.thread.providerInstanceId, newerSelection.instanceId); + }), + ); + + it.effect( + "starts a queued scheduled delivery that pinned a model on its pin after the thread moved", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-layer-queued-scheduled-pinned-selection"); + const scheduledMessageId = MessageId.make(`${threadId}:scheduled`); + const pinnedSelection = { instanceId: modelSelection.instanceId, model: "gpt-5.5" }; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + createdBy: "user", + creationSource: "web", + title: "Pinned scheduled selection", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:active`), + threadId, + messageId: MessageId.make(`${threadId}:active`), + text: "Busy", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const active = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + // A task that pins a model names it on the delivery, as a fresh-thread launch seeds it. + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:scheduled`), + threadId, + messageId: scheduledMessageId, + scheduledTaskId: ScheduledTaskId.make(`${threadId}:task`), + modelSelection: pinnedSelection, + text: "Pinned scheduled prompt", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queued = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.userMessageId === scheduledMessageId, + )!; + assert.equal(queued.status, "queued"); + assert.deepEqual(queued.modelSelection, pinnedSelection); + // Stop holds the queue; a thread changes instance only while no run is active. + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make(`${threadId}:stop`), + threadId, + runId: active.id, + holdQueue: true, + }); + // The user moves the thread off the pinned model while the delivery is held. + const movedSelection = { instanceId: alternateInstanceId, model: "gpt-5.5" }; + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make(`${threadId}:select-moved`), + threadId, + modelSelection: movedSelection, + }); + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make(`${threadId}:resume`), + threadId, + }); + yield* orchestrator.resumeQueuedRuns; + const after = yield* orchestrator.getThreadProjection(threadId); + const delivered = after.runs.find((run) => run.userMessageId === scheduledMessageId)!; + assert.equal(delivered.status, "starting"); + // The pin wins: the run starts on the selection the delivery named, not the thread's later pick. + assert.deepEqual(delivered.modelSelection, pinnedSelection); + assert.equal( + after.attempts.find((attempt) => attempt.id === delivered.activeAttemptId) + ?.providerInstanceId, + pinnedSelection.instanceId, + ); + // Pinned tasks keep today's behaviour: the thread follows the run's selection back. + assert.deepEqual(after.thread.modelSelection, pinnedSelection); + }), + ); + + it.effect( + "starts a fresh scheduled delivery on a new provider thread after a provider switch", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-layer-fresh-scheduled-provider-thread"); + const switchedSelection = { instanceId: alternateInstanceId, model: "gpt-5.5" }; + const scheduledMessageId = MessageId.make(`${threadId}:scheduled`); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + createdBy: "user", + creationSource: "web", + title: "Fresh scheduled provider thread", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:active`), + threadId, + messageId: MessageId.make(`${threadId}:active`), + text: "Busy", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const active = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:scheduled`), + threadId, + messageId: scheduledMessageId, + scheduledTaskId: ScheduledTaskId.make(`${threadId}:task`), + text: "Scheduled prompt", + attachments: [], + freshContext: true, + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queuedProjection = yield* orchestrator.getThreadProjection(threadId); + const queued = queuedProjection.runs.find( + (run) => run.userMessageId === scheduledMessageId, + )!; + // The queue reserved an unused provider thread on the original instance. + const reserved = queuedProjection.providerThreads.find( + (providerThread) => providerThread.id === queued.providerThreadId, + )!; + assert.equal(reserved.providerInstanceId, modelSelection.instanceId); + assert.isNull(reserved.firstRunOrdinal); + assert.isNull(reserved.nativeThreadRef); + // Stop holds the queue; a thread changes instance only while no run is active. + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make(`${threadId}:stop`), + threadId, + runId: active.id, + holdQueue: true, + }); + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make(`${threadId}:select-switched`), + threadId, + modelSelection: switchedSelection, + }); + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make(`${threadId}:resume`), + threadId, + }); + yield* orchestrator.resumeQueuedRuns; + + const after = yield* orchestrator.getThreadProjection(threadId); + const delivered = after.runs.find((run) => run.userMessageId === scheduledMessageId)!; + assert.equal(delivered.status, "starting"); + assert.isTrue(delivered.freshContext === true); + assert.notEqual(delivered.providerThreadId, reserved.id); + assert.equal( + after.providerThreads.find( + (providerThread) => providerThread.id === delivered.providerThreadId, + )?.providerInstanceId, + switchedSelection.instanceId, + ); + assert.equal( + after.providerThreads.find((providerThread) => providerThread.id === reserved.id) + ?.providerInstanceId, + modelSelection.instanceId, + ); + }), + ); + it.effect("keeps the queue after a user interrupts the active run", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 42868af7da957..bf6b53666f4d7 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1370,6 +1370,8 @@ export const OrchestrationV2ConversationMessage = Schema.Struct({ notification: Schema.optional(OrchestrationV2Notification), ...OrchestrationV2CreationFields, scheduledTaskId: Schema.optional(ScheduledTaskId), + /** A scheduled delivery that named no model; it runs on the thread's selection at start. */ + followsThreadSelection: Schema.optional(Schema.Literal(true)), senderThreadId: Schema.optional(ThreadId), linkedSender: Schema.optional(OrchestrationV2LinkedSender), idleCompaction: Schema.optional(OrchestrationV2IdleCompaction), From 350753aface93da09325ddcafbda0ecffb9ad3f0 Mon Sep 17 00:00:00 2001 From: Nei Cardoso de Oliveira Neto Date: Fri, 13 Nov 2026 14:35:01 -0300 Subject: [PATCH 004/409] fix(usage): Claude accounts with a $0 monthly cap no longer leave a blank cell (#35341) Co-authored-by: Claude Fable 5.2 --- .../src/provider/claudeUsageLimits.test.ts | 16 ++++++++++++++++ apps/server/src/provider/claudeUsageLimits.ts | 6 +++++- .../src/components/usage/UsageLimitsPooled.tsx | 12 ++++++++++++ packages/contracts/src/providerUsageLimits.ts | 2 ++ .../provider-core/src/server/usageLimits.test.ts | 5 +++-- packages/provider-core/src/server/usageLimits.ts | 1 + 6 files changed, 39 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/claudeUsageLimits.test.ts b/apps/server/src/provider/claudeUsageLimits.test.ts index f20d8b0543636..b683c80f4f5e8 100644 --- a/apps/server/src/provider/claudeUsageLimits.test.ts +++ b/apps/server/src/provider/claudeUsageLimits.test.ts @@ -199,6 +199,22 @@ describe("claudeUsageResponseToLimits", () => { ).toEqual([monthlyWindow]); }); + it("flags a $0 cap instead of reporting a monthly window", () => { + const response = { + rate_limits_available: true, + rate_limits: { + five_hour: null, + seven_day: null, + extra_usage: { is_enabled: true, used_credits: 0, monthly_limit: 0, utilization: null }, + }, + }; + expect(claudeUsageResponseToLimits({ checkedAt, response }).limits).toEqual({ + checkedAt, + windows: [], + zeroSpendCap: true, + }); + }); + it("omits disabled budgets and refuses invalid money without an enabled fallback", () => { const rateLimits = enterpriseResponse.rate_limits; const response = { diff --git a/apps/server/src/provider/claudeUsageLimits.ts b/apps/server/src/provider/claudeUsageLimits.ts index d0acf43bb4968..8f81f2e7c7ef3 100644 --- a/apps/server/src/provider/claudeUsageLimits.ts +++ b/apps/server/src/provider/claudeUsageLimits.ts @@ -202,6 +202,7 @@ export function claudeUsageResponseToLimits(input: { overageIncluded ??= entry.display_name; } const spendPercent = readSpendPercent(response.rate_limits); + const extra = response.rate_limits.extra_usage; if (spendPercent !== undefined) { windows.push({ id: "monthly_spend", @@ -211,7 +212,10 @@ export function claudeUsageResponseToLimits(input: { }); } return { - limits: makeUsageLimits({ checkedAt, windows }), + limits: { + ...makeUsageLimits({ checkedAt, windows }), + ...(extra?.is_enabled && extra.monthly_limit === 0 ? { zeroSpendCap: true } : {}), + }, names: { overageIncluded }, }; } diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index d5f46398fa31c..8ec1c7a02afcd 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -523,6 +523,18 @@ function PoolBar({ index={position + 1} showAccountName={pool.columns.length > 1} /> + ) : pool.kind === "monthly" && member.account.limits.zeroSpendCap ? ( +
+ + $0 cap +
) : null, )} diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 851de4c72ffe3..ead95f54af7b8 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -47,6 +47,8 @@ export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type; export const ServerProviderUsageLimits = Schema.Struct({ checkedAt: IsoDateTime, windows: ForwardCompatibleArray(ServerProviderUsageWindow), + /** A $0 monthly spend cap has no percentage, so it cannot be a window. */ + zeroSpendCap: Schema.optional(Schema.Boolean), credentialFingerprint: Schema.optional(TrimmedNonEmptyString), resetCredits: Schema.optional(ServerProviderResetCredits), externalUsage: Schema.optional( diff --git a/packages/provider-core/src/server/usageLimits.test.ts b/packages/provider-core/src/server/usageLimits.test.ts index 48bf7a76e85fa..2acdb0ba15d79 100644 --- a/packages/provider-core/src/server/usageLimits.test.ts +++ b/packages/provider-core/src/server/usageLimits.test.ts @@ -89,10 +89,10 @@ describe("applyUsageLimitsUpdate", () => { ).toEqual({ checkedAt, windows: [weekly], unavailable: { reason: "probeFailed" } }); }); - it("preserves reset credits when a streamed window update changes usage", () => { + it("preserves reset credits and a $0 cap when a streamed window update changes usage", () => { const resetCredits = { availableCount: 2, nextExpiresAt: "2026-10-01T00:00:00.000Z" }; const next = applyUsageLimitsUpdate({ - previous: { ...published, resetCredits }, + previous: { ...published, resetCredits, zeroSpendCap: true }, checkedAt: "2026-09-03T12:00:05.000Z", update: { windows: [{ ...session, usedPercent: 55 }] }, }); @@ -101,6 +101,7 @@ describe("applyUsageLimitsUpdate", () => { checkedAt: "2026-09-03T12:00:05.000Z", windows: [{ ...session, usedPercent: 55 }, weekly], resetCredits, + zeroSpendCap: true, }); }); }); diff --git a/packages/provider-core/src/server/usageLimits.ts b/packages/provider-core/src/server/usageLimits.ts index f9b8def9d65e6..7d1f6e6a910e9 100644 --- a/packages/provider-core/src/server/usageLimits.ts +++ b/packages/provider-core/src/server/usageLimits.ts @@ -84,6 +84,7 @@ export function applyUsageLimitsUpdate(input: { return { ...makeUsageLimits({ checkedAt: input.checkedAt, windows: merged.values() }), ...(previous?.resetCredits !== undefined ? { resetCredits: previous.resetCredits } : {}), + ...(previous?.zeroSpendCap ? { zeroSpendCap: true } : {}), ...(unavailable !== undefined ? { unavailable } : {}), }; } From 93aaad5b4562540a0f6906b5e24a9d9923028b68 Mon Sep 17 00:00:00 2001 From: Dhruv Khara <66153057+BeLazy167@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:35:39 -0600 Subject: [PATCH 005/409] fix(web): PR conversation headers wrap instead of overflowing (#36211) Co-authored-by: Julius Marminge --- .../src/components/pullRequest/PullRequestReviewAnnotation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx index 212da0d088fe3..1b95a1a563aab 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx @@ -305,7 +305,7 @@ export function ReviewThreadCard({ contentEditable={false} onPointerDown={(event) => event.stopPropagation()} > -
+
{thread.isResolved ? ( ) : ( From c5dfb8ffa395b720c3d140330e231eb2057d6f36 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 09:36:37 -0800 Subject: [PATCH 006/409] fix(mobile): new thread starts in the thread list's filtered project (#36234) --- .../src/features/home/HomeRouteScreen.tsx | 41 +++++--------- .../src/features/home/homeThreadList.ts | 24 +++++++- .../layout/AdaptiveWorkspaceLayout.tsx | 31 ++++++++++- .../threads/ThreadNavigationSidebar.tsx | 17 +----- .../new-task-project-selection.test.ts | 55 ++++++++++++++++++- .../threads/new-task-project-selection.ts | 28 +++++++++- .../state/use-remote-environment-registry.ts | 2 +- docs/user/thread-sidebar.md | 9 +-- 8 files changed, 157 insertions(+), 50 deletions(-) diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index df5142c15e243..cc66e114d21be 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -9,10 +9,7 @@ import { useMemo, useState } from "react"; import { Platform } from "react-native"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { - pickableProjectGroups, - resolveScratchProjectScopeKey, -} from "@t3tools/client-runtime/state/project-grouping"; +import { pickableProjectGroups } from "@t3tools/client-runtime/state/project-grouping"; import { useProjects } from "../../state/entities"; import { steadyNavigationThreadShellsAtom } from "../../state/threads"; @@ -31,7 +28,7 @@ import { useAtomValueWhileVisible, useHomeRouteVisible, } from "./home-route-visibility"; -import { buildHomeProjectScopes } from "./homeThreadList"; +import { buildHomeProjectScopes, resolveHomeProjectFilterScope } from "./homeThreadList"; import { usePendingTaskListActions } from "./usePendingTaskListActions"; import { useThreadListActions } from "./useThreadListActions"; import { useWorkspaceTabs } from "../layout/workspace/WorkspaceTabsContext"; @@ -130,35 +127,25 @@ export function HomeRouteScreen() { setSelectedProjectKey, } = useHomeListOptions(availableEnvironmentIds); const selectedEnvironmentId = listOptions.selectedEnvironmentId; - // A legacy per-environment scratch key resolves here on every render; the stored - // value is only rewritten when the user picks a filter. - const resolvedProjectKey = resolveScratchProjectScopeKey( - listOptions.selectedProjectKey, - projects, - { - sidebarProjectGroupingMode: listOptions.projectGroupingMode, - sidebarProjectGroupingOverrides: {}, - }, + const projectScopes = useMemo( + () => + buildHomeProjectScopes({ + projects, + environmentId: selectedEnvironmentId, + projectGroupingMode: listOptions.projectGroupingMode, + }), + [listOptions.projectGroupingMode, projects, selectedEnvironmentId], ); const projectFilterOptions = useMemo( () => - pickableProjectGroups( - buildHomeProjectScopes({ - projects, - environmentId: selectedEnvironmentId, - projectGroupingMode: listOptions.projectGroupingMode, - }), - ).map((scope) => ({ + pickableProjectGroups(projectScopes).map((scope) => ({ key: scope.key, label: scope.title, })), - [listOptions.projectGroupingMode, projects, selectedEnvironmentId], + [projectScopes], ); - const selectedProjectKey = projectFilterOptions.some( - (project) => project.key === resolvedProjectKey, - ) - ? resolvedProjectKey - : null; + const selectedProjectKey = + resolveHomeProjectFilterScope(projectScopes, projects, listOptions)?.key ?? null; if (layout.usesSplitView && !nativePrimaryColumn) { return ( diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 1b2e1a8c541f7..7c0392497645b 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -1,4 +1,8 @@ -import { buildProjectGroups } from "@t3tools/client-runtime/state/project-grouping"; +import { + buildProjectGroups, + pickableProjectGroups, + resolveScratchProjectScopeKey, +} from "@t3tools/client-runtime/state/project-grouping"; import type { EnvironmentProject, EnvironmentThreadShell, @@ -70,6 +74,24 @@ export function buildHomeProjectScopes(input: { }); } +/** The scope the thread list's project filter shows; an archived or unknown key shows all projects. */ +export function resolveHomeProjectFilterScope( + scopes: ReadonlyArray, + projects: ReadonlyArray, + options: { + readonly selectedProjectKey: string | null; + readonly projectGroupingMode: SidebarProjectGroupingMode; + }, +): HomeProjectScope | null { + // A legacy per-environment scratch key resolves here; the stored value is only + // rewritten when the user picks a filter. + const key = resolveScratchProjectScopeKey(options.selectedProjectKey, projects, { + sidebarProjectGroupingMode: options.projectGroupingMode, + sidebarProjectGroupingOverrides: {}, + }); + return pickableProjectGroups(scopes).find((scope) => scope.key === key) ?? null; +} + export function findHomeProjectScope( scopes: ReadonlyArray, key: string | null, diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 3a32e33523611..a90ba87eedda2 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -59,6 +59,8 @@ import { useProjects } from "../../state/entities"; import { environmentThreadShells } from "../../state/threads"; import { withKnownPullRequestSummary } from "../../state/use-thread-pr"; import { mobilePreferencesAtom } from "../../state/preferences"; +import { savedConnectionsByIdAtom } from "../../state/use-remote-environment-registry"; +import { workspaceConnections } from "../../state/workspace"; import { DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS, resolveMobileProjectGroupingSettings, @@ -83,7 +85,8 @@ import { import { startOpeningDetail } from "./workspace/useSelectWorkspaceDetail"; import { useWorkspaceTabs } from "./workspace/WorkspaceTabsContext"; import { HomeListOptionsProvider } from "../home/home-list-options"; -import { newTaskDestination } from "../threads/new-task-project-selection"; +import { buildHomeProjectScopes, resolveHomeProjectFilterScope } from "../home/homeThreadList"; +import { newTaskDestination, newTaskFilteredProject } from "../threads/new-task-project-selection"; import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; import { PaneContext } from "../../components/ui/Pane"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; @@ -606,12 +609,38 @@ function AdaptiveWorkspaceLayoutContent( : undefined; const preferences = appAtomRegistry.get(mobilePreferencesAtom); const saved = AsyncResult.isSuccess(preferences) ? preferences.value : undefined; + // Resolve the thread list's filter as useHomeListOptions does: a removed connection's filter reads as all. + const listFilters = saved?.threadListFilters; + const listEnvironmentId = + listFilters?.environmentId != null && + appAtomRegistry.get(savedConnectionsByIdAtom)[listFilters.environmentId] !== undefined + ? listFilters.environmentId + : null; + const filteredScope = resolveHomeProjectFilterScope( + buildHomeProjectScopes({ projects, environmentId: listEnvironmentId, projectGroupingMode }), + projects, + { selectedProjectKey: listFilters?.projectKey ?? null, projectGroupingMode }, + ); + const connectedEnvironmentIds = new Set( + appAtomRegistry + .get(workspaceConnections.environmentsAtom) + .filter((environment) => environment.connectionState === "connected") + .map((environment) => environment.environmentId), + ); handleSelectThreadRoute( newTaskDestination({ projects, requestId: uuidv4(), useLastProject: saved?.useLastProjectForNewThreads, currentProject: environmentId && projectId ? { environmentId, projectId } : undefined, + filteredProject: filteredScope + ? newTaskFilteredProject( + filteredScope, + listEnvironmentId, + connectedEnvironmentIds, + saved?.lastThreadProject, + ) + : undefined, lastProject: saved?.lastThreadProject, }), ); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index e1c51b7f524b5..fb0db83a4dab7 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1,10 +1,7 @@ import { useActiveNewTask } from "../../state/active-new-task"; import { ThreadUndoNotice } from "../home/ThreadUndoNotice"; import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; -import { - pickableProjectGroups, - resolveScratchProjectScopeKey, -} from "@t3tools/client-runtime/state/project-grouping"; +import { pickableProjectGroups } from "@t3tools/client-runtime/state/project-grouping"; import { useAppearancePreferences, useAndroidFloatingTabs, @@ -48,7 +45,7 @@ import { requestThreadMessageJump } from "./threadMessageJump"; import { useThreadListSearchMatches } from "./useThreadListSearchMatches"; import { useHomeListOptions } from "../home/home-list-options"; import { buildHomeListFilterMenuItems } from "../home/home-list-filter-menu"; -import { buildHomeProjectScopes } from "../home/homeThreadList"; +import { buildHomeProjectScopes, resolveHomeProjectFilterScope } from "../home/homeThreadList"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; import { useThreadListActions } from "../home/useThreadListActions"; @@ -172,12 +169,6 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) { ) ? [options.selectedEnvironmentId] : []; - // A legacy per-environment scratch key resolves here on every render; the stored - // value is only rewritten when the user picks a filter. - const resolvedProjectKey = resolveScratchProjectScopeKey(options.selectedProjectKey, projects, { - sidebarProjectGroupingMode: options.projectGroupingMode, - sidebarProjectGroupingOverrides: {}, - }); const projectScopes = useMemo( () => buildHomeProjectScopes({ @@ -206,9 +197,7 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) { ), [projectScopes], ); - // An archived project's filter reads as all projects, matching the filter menu. - const selectedProjectScope = - pickableProjectGroups(projectScopes).find((scope) => scope.key === resolvedProjectKey) ?? null; + const selectedProjectScope = resolveHomeProjectFilterScope(projectScopes, projects, options); const threadSearch = useThreadSearch( searchEnvironmentIds, props.searchQuery, diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index 99cf9e7d37ef4..0fcd9d9d6d745 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -6,6 +6,7 @@ import type { HomeProjectScope } from "../home/homeThreadList"; import { getProjectScopeSelectionTarget, newTaskDestination, + newTaskFilteredProject, resolveDraftProjectSelection, resolveEnvironmentProjectMatch, resolveNewTaskScratchOption, @@ -77,6 +78,37 @@ describe("getProjectScopeSelectionTarget", () => { }); }); +describe("newTaskFilteredProject", () => { + it("never starts in an archived copy of the filtered project", () => { + const mac = { ...makeProject("t3code", "mac"), archivedAt: "2026-01-01T00:00:00.000Z" }; + const server = makeProject("t3code", "server"); + expect( + newTaskFilteredProject( + makeScope([mac, server]), + null, + new Set([mac.environmentId, server.environmentId]), + { environmentId: "mac", projectId: "t3code" }, + ), + ).toEqual({ environmentId: "server", projectId: "t3code" }); + }); + + it("prefers the remembered copy of the filtered project", () => { + const mac = makeProject("t3code", "mac"); + const server = makeProject("t3code", "server"); + const connected = new Set([mac.environmentId, server.environmentId]); + const filtered = (last?: { readonly environmentId: string; readonly projectId: string }) => + newTaskFilteredProject(makeScope([mac, server]), null, connected, last); + expect(filtered({ environmentId: "server", projectId: "t3code" })).toEqual({ + environmentId: "server", + projectId: "t3code", + }); + expect(filtered({ environmentId: "server", projectId: "other" })).toEqual({ + environmentId: "mac", + projectId: "t3code", + }); + }); +}); + describe("resolveEnvironmentProjectMatch", () => { it("follows the same repository onto the target machine", () => { const selected = makeProject("t3code", "mac", { repositoryKey: "github.com/t3tools/t3code" }); @@ -173,6 +205,7 @@ describe("newTaskDestination", () => { readonly useLastProject: boolean | undefined; readonly projects: ReadonlyArray; readonly current?: EnvironmentProject; + readonly filtered?: EnvironmentProject; readonly last?: { readonly environmentId: string; readonly projectId: string }; }) { return newTaskDestination({ @@ -183,17 +216,22 @@ describe("newTaskDestination", () => { environmentId: options.current.environmentId, projectId: options.current.id, }, + filteredProject: options.filtered && { + environmentId: options.filtered.environmentId, + projectId: options.filtered.id, + }, lastProject: options.last, }); } - it("starts in the current thread's project ahead of the remembered one", () => { + it("starts in the current thread's project ahead of the filtered and remembered ones", () => { const projects = [makeProject("remembered"), makeProject("current", "other-environment")]; expect( destination({ useLastProject: true, projects, current: projects[1], + filtered: projects[0], last: { environmentId: "environment", projectId: "remembered" }, }), ).toEqual({ @@ -201,6 +239,20 @@ describe("newTaskDestination", () => { }); }); + it("starts in the thread list's filtered project ahead of the remembered one", () => { + const projects = [makeProject("bravo"), makeProject("alpha")]; + expect( + destination({ + useLastProject: true, + projects, + filtered: projects[1], + last: { environmentId: "environment", projectId: "bravo" }, + }), + ).toEqual({ + draft: { requestId: "request", environmentId: "environment", projectId: "alpha" }, + }); + }); + it("opens the picker for an archived last project, but keeps an archived current one", () => { const archived = { ...makeProject("archived"), archivedAt: "2026-01-01T00:00:00.000Z" }; expect( @@ -262,6 +314,7 @@ describe("newTaskDestination", () => { useLastProject: false, projects: [only], current: only, + filtered: only, last: { environmentId: "environment", projectId: "only" }, }), ).toEqual(picker); diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts index 93c8f1d5f51d6..88f30171419f0 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -147,11 +147,35 @@ export function resolveDraftProjectSelection( type ProjectRef = { readonly environmentId: string; readonly projectId: string }; +/** The live copy of the thread list's filtered project a new thread starts in: the remembered one if it is a copy. */ +export function newTaskFilteredProject( + scope: HomeProjectScope, + preferredEnvironmentId: EnvironmentId | null, + connectedEnvironmentIds: ReadonlySet, + lastProject: ProjectRef | undefined, +): ProjectRef | undefined { + const live = scope.projects.filter((project) => project.archivedAt == null); + if (live.length === 0) return undefined; + const project = + live.find( + (candidate) => + candidate.environmentId === lastProject?.environmentId && + candidate.id === lastProject.projectId, + ) ?? + getProjectScopeSelectionTarget( + { ...scope, projects: live }, + preferredEnvironmentId, + connectedEnvironmentIds, + ); + return { environmentId: project.environmentId, projectId: project.id }; +} + export function newTaskDestination(options: { readonly projects: ReadonlyArray; readonly requestId: string; readonly useLastProject: boolean | undefined; readonly currentProject: ProjectRef | undefined; + readonly filteredProject: ProjectRef | undefined; readonly lastProject: ProjectRef | undefined; }) { const find = (ref: ProjectRef | undefined) => @@ -164,7 +188,9 @@ export function newTaskDestination(options: { const project = options.useLastProject === false ? undefined - : (find(options.currentProject) ?? (last?.archivedAt == null ? last : undefined)); + : (find(options.currentProject) ?? + find(options.filteredProject) ?? + (last?.archivedAt == null ? last : undefined)); return project ? { draft: { diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index c75456461bf64..a1488220d6952 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -100,7 +100,7 @@ const EMPTY_RUNTIME_STATE_ATOM = Atom.make(null) Atom.withLabel("mobile:environment-runtime-state:empty"), ); -const savedConnectionsByIdAtom = Atom.make((get) => { +export const savedConnectionsByIdAtom = Atom.make((get) => { const catalog = get(environmentCatalog.catalogValueAtom); return Object.fromEntries( [...catalog.entries.keys()].flatMap((environmentId) => { diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 5dd5a4bf59205..e5fe950e140a2 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -13,10 +13,11 @@ Its branch and workspace mode come from your configured defaults. To continue in an existing worktree, use **New thread in this worktree** from the branch toolbar. On mobile, **New thread** starts in the current thread's project, or else in the -last project you opened or chose on that device. If neither is available, the -project picker opens. Turn off **Settings → General → Use last project** to -choose a project every time. When you pick a project that is on more than one -machine, the thread starts on a connected one if any is connected. +project the thread list is filtered to, or else in the last project you opened +or chose on that device. If none is available, the project picker opens. Turn +off **Settings → General → Use last project** to choose a project every time. +When you pick a project that is on more than one machine, the thread starts on +a connected one if any is connected. When you change a new thread's project, T3 Code stays in the current environment if that project exists there. Otherwise it selects an environment that has it. From a877d40b74797f006d102684d0a8106432be2a5a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 09:41:32 -0800 Subject: [PATCH 007/409] fix(opencode): models load once OpenCode 2 finishes activating its plugins (#30263) --- .../src/server/openCode2Catalog.test.ts | 80 +++++++++++++------ .../src/server/openCode2Catalog.ts | 8 +- .../provider-opencode/src/server/status.ts | 31 +++++-- 3 files changed, 81 insertions(+), 38 deletions(-) diff --git a/packages/provider-opencode/src/server/openCode2Catalog.test.ts b/packages/provider-opencode/src/server/openCode2Catalog.test.ts index f3fab03fb1c40..1186cf813bc44 100644 --- a/packages/provider-opencode/src/server/openCode2Catalog.test.ts +++ b/packages/provider-opencode/src/server/openCode2Catalog.test.ts @@ -1,4 +1,5 @@ import { assert, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -7,40 +8,48 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import { describe } from "vite-plus/test"; import { loadOpenCode2Catalog } from "./openCode2Catalog.ts"; +import { makeOpenCode2ModelLoader } from "./status.ts"; import * as OpenCode2Client from "./v2/OpenCode2Client.ts"; const MODELS_BODY = `{"location":{"directory":"/project"},"data":[{"id":"step-5-preview-free","modelID":"step-5-preview-free","providerID":"opencode-go","name":"Step 5 Preview Free","compatibility":{"reasoningField":"reasoning_content"},"package":"@opencode/ai/providers/openai-compatible","settings":{"apiKey":"sk-demo-not-real","baseURL":"http://127.0.0.1:48990/v1","provider":"opencode-go"},"capabilities":{"tools":true,"input":["text","image","video"],"output":["text"]},"variants":[{"id":"low","settings":{"reasoningEffort":"low"}},{"id":"medium","settings":{"reasoningEffort":"medium"}},{"id":"high","settings":{"reasoningEffort":"high"}}],"time":{"released":1789516800000},"cost":[{"input":0,"output":0,"cache":{"read":0,"write":0}}],"status":"active","enabled":true,"limit":{"context":1000000,"input":1000000,"output":65536}},{"id":"step-5-preview-free","modelID":"step-5-preview-free","providerID":"opencode","name":"Step 5 Preview Free","compatibility":{"reasoningField":"reasoning_content"},"package":"@opencode/ai/providers/openai-compatible","settings":{"apiKey":"public","baseURL":"https://opencode.ai/zen/v1","provider":"opencode"},"capabilities":{"tools":true,"input":["text","image","video"],"output":["text"]},"variants":[{"id":"low","settings":{"reasoningEffort":"low"}},{"id":"medium","settings":{"reasoningEffort":"medium"}},{"id":"high","settings":{"reasoningEffort":"high"}}],"time":{"released":1789516800000},"cost":[{"input":0,"output":0,"cache":{"read":0,"write":0}}],"status":"active","enabled":true,"limit":{"context":1000000,"input":1000000,"output":65536}}]}`; const PROVIDERS_BODY = `{"location":{"directory":"/project"},"data":[{"id":"opencode-go","integrationID":"opencode-go","name":"OpenCode Go","activation":"enabled","package":"@opencode/ai/providers/openai-compatible","settings":{"apiKey":"sk-demo-not-real","baseURL":"http://127.0.0.1:48990/v1","provider":"opencode-go"}},{"id":"opencode","integrationID":"opencode","name":"OpenCode Zen","activation":"enabled","package":"@opencode/ai/providers/openai-compatible","settings":{"apiKey":"public","baseURL":"https://opencode.ai/zen/v1"}}]}`; +const PLUGINS_ACTIVATING_BODY = + '{"_tag":"ServiceUnavailableError","message":"Location plugins did not finish activating","service":"plugin"}'; + +type Reply = string | { readonly status: number; readonly body: string }; + const layerServerReplying = ( - bodies: Readonly>, + bodies: Readonly>>, hanging: ReadonlyArray = [], ) => Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => - Effect.suspend(() => { + Effect.gen(function* () { const path = new URL(request.url).pathname; if (hanging.includes(path)) { - return Effect.never; + return yield* Effect.never; } - const body = bodies[path]; - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(body ?? '{"_tag":"NotFoundError","message":"not recorded"}', { - status: body === undefined ? 404 : 200, - headers: { "content-type": "application/json" }, - }), - ), + const recorded = bodies[path]; + const reply = Effect.isEffect(recorded) ? yield* recorded : recorded; + const { status, body } = + reply === undefined + ? { status: 404, body: '{"_tag":"NotFoundError","message":"not recorded"}' } + : typeof reply === "string" + ? { status: 200, body: reply } + : reply; + return HttpClientResponse.fromWeb( + request, + new Response(body, { status, headers: { "content-type": "application/json" } }), ); }), ), ); const readCatalog = ( - bodies: Readonly>, + bodies: Readonly>>, hanging: ReadonlyArray = [], ) => Effect.gen(function* () { @@ -86,17 +95,6 @@ describe("loadOpenCode2Catalog", () => { }), ); - it.effect("lists no models yet when the model list does not answer", () => - Effect.gen(function* () { - const fiber = yield* Effect.forkChild( - readCatalog({ "/api/provider": PROVIDERS_BODY }, ["/api/model"]), - ); - yield* TestClock.adjust("5 seconds"); - - assert.deepStrictEqual(yield* Fiber.join(fiber), []); - }), - ); - // regression: #17424 only the provider list is optional; a model-list failure fails the read it.effect("fails the read when the model list fails", () => Effect.gen(function* () { @@ -109,4 +107,38 @@ describe("loadOpenCode2Catalog", () => { assert.match(error.detail, /could not list its models/i); }), ); + + // regression: #30251 a busy cold OpenCode 2 holds each model read past 5 seconds, then 503s + it.effect("lists models once OpenCode 2 finishes activating its plugins", () => + Effect.gen(function* () { + const activatedAt = (yield* Clock.currentTimeMillis) + 60_000; + // Timings measured from OpenCode 2.2.7 on a CPU-throttled cold start. + const listModels = Effect.gen(function* () { + if ((yield* Clock.currentTimeMillis) >= activatedAt) { + return yield* Effect.as(Effect.sleep("9 seconds"), MODELS_BODY); + } + yield* Effect.sleep("14 seconds"); + return { status: 503, body: PLUGINS_ACTIVATING_BODY }; + }); + const load = yield* makeOpenCode2ModelLoader( + readCatalog({ "/api/model": listModels, "/api/provider": PROVIDERS_BODY }), + ); + const fiber = yield* Effect.forkChild(load); + yield* TestClock.adjust("2 minutes"); + + assert.strictEqual((yield* Fiber.join(fiber)).length, 2); + }), + ); + + it.effect("lists no models once a model list that never answers reaches the deadline", () => + Effect.gen(function* () { + const load = yield* makeOpenCode2ModelLoader( + readCatalog({ "/api/provider": PROVIDERS_BODY }, ["/api/model"]), + ); + const fiber = yield* Effect.forkChild(load); + yield* TestClock.adjust("2 minutes"); + + assert.deepStrictEqual(yield* Fiber.join(fiber), []); + }), + ); }); diff --git a/packages/provider-opencode/src/server/openCode2Catalog.ts b/packages/provider-opencode/src/server/openCode2Catalog.ts index 91e1241f9ddc2..a767b59fe9173 100644 --- a/packages/provider-opencode/src/server/openCode2Catalog.ts +++ b/packages/provider-opencode/src/server/openCode2Catalog.ts @@ -6,8 +6,6 @@ import type { OpenCode2Model } from "./status.ts"; // Cold catalogs take seconds; model reads already retry empty catalogs. const PROVIDER_NAME_TIMEOUT = "3 seconds"; -// Bounds the request alone (server start has its own bounds); a slow list reads as not listed yet. -const MODEL_LIST_TIMEOUT = "5 seconds"; const listProviders = (client: OpenCodeClient, directory: string) => client.provider.list({ location: { directory } }).pipe( @@ -22,10 +20,8 @@ const readCatalog = ( ): Effect.Effect, OpenCodeRuntime.OpenCodeRuntimeError> => Effect.all( { - models: client.model.list({ location: { directory } }).pipe( - Effect.timeoutOption(MODEL_LIST_TIMEOUT), - Effect.map((list) => (list._tag === "Some" ? list.value.data : [])), - ), + // Callers bound the read: a busy host can hold it past OpenCode's own activation wait. + models: client.model.list({ location: { directory } }).pipe(Effect.map((list) => list.data)), providers, }, { concurrency: "unbounded" }, diff --git a/packages/provider-opencode/src/server/status.ts b/packages/provider-opencode/src/server/status.ts index 0f90b9df28940..e9776bcefc1d1 100644 --- a/packages/provider-opencode/src/server/status.ts +++ b/packages/provider-opencode/src/server/status.ts @@ -26,6 +26,7 @@ import { import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; import type { ProbedOpenCode } from "./versionProbe.ts"; import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; +import { ServiceUnavailableError } from "@opencode/protocol/errors"; import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; const OPENCODE_PRESENTATION = { @@ -498,6 +499,15 @@ const catalogFingerprint = (models: ReadonlyArray) => .toSorted() .join("\n"); +// A cold OpenCode 2.2.5 503s model reads for about a minute while its location plugins activate. +const PLUGIN_ACTIVATION_DEADLINE = "2 minutes"; + +const isServiceUnavailable = Schema.is(ServiceUnavailableError); +const isActivatingPlugins = (error: unknown): boolean => + OpenCodeRuntime.isOpenCodeRuntimeError(error) + ? isActivatingPlugins(error.cause) + : isServiceUnavailable(error) && error.service === "plugin"; + /** A cold server can list a partial catalog; two agreeing reads reduce that startup race. */ const settleOpenCode2Catalog = ( list: Effect.Effect, E>, @@ -536,17 +546,22 @@ export const makeOpenCode2ModelLoader = ( ), ); // The first read can include a cold server start, so only settling shares the budget. + // Each retry borrows the managed server, so retries well inside its idle TTL keep it alive. return tracked.pipe( + Effect.retry({ while: isActivatingPlugins, schedule: Schedule.spaced("1 second") }), + Effect.timeoutOption(PLUGIN_ACTIVATION_DEADLINE), Effect.flatMap((first) => - settleOpenCode2Catalog(tracked, first).pipe( - Effect.timeoutOption( - resolveProviderProbeTimeoutMs( - "opencode", - OPENCODE_2_CATALOG_SETTLE_TIMEOUT_MS, - environment, + Option.isNone(first) + ? Effect.succeedNone + : settleOpenCode2Catalog(tracked, first.value).pipe( + Effect.timeoutOption( + resolveProviderProbeTimeoutMs( + "opencode", + OPENCODE_2_CATALOG_SETTLE_TIMEOUT_MS, + environment, + ), + ), ), - ), - ), ), Effect.catch((error) => latest.length > 0 ? Effect.succeedSome(latest) : Effect.fail(error), From 497aac20f766fcfa704b93a5dfd9da8f8263c34b Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 13 Nov 2026 11:42:02 -0600 Subject: [PATCH 008/409] fix(server): include Cursor team usage in account history (#36237) --- .../src/server/CursorAccountReader.ts | 7 +- .../src/server/accountCache.ts | 4 +- .../src/server/accountUsage.test.ts | 281 +++++++-- .../src/server/accountUsage.ts | 573 ++++++++++-------- 4 files changed, 587 insertions(+), 278 deletions(-) diff --git a/packages/provider-cursor/src/server/CursorAccountReader.ts b/packages/provider-cursor/src/server/CursorAccountReader.ts index f6e9b384a40d8..8db2fc42d2d66 100644 --- a/packages/provider-cursor/src/server/CursorAccountReader.ts +++ b/packages/provider-cursor/src/server/CursorAccountReader.ts @@ -5,7 +5,7 @@ import * as HttpClient from "effect/http/HttpClient"; import * as Layer from "effect/Layer"; import type { CursorCredentialSource } from "./accountCache.ts"; -import { readCursorAccountUsage, type CursorAccountUsageReadResult } from "./accountUsage.ts"; +import { makeCursorAccountUsageReader, type CursorAccountUsageReadResult } from "./accountUsage.ts"; export class CursorAccountReader extends Context.Service< CursorAccountReader, @@ -22,11 +22,10 @@ export const layer = Layer.effect( CursorAccountReader, Effect.gen(function* () { const context = yield* Effect.context(); + const read = yield* makeCursorAccountUsageReader; return CursorAccountReader.of({ read: Effect.fn("CursorAccountReader.read")(function (credentialSource, sinceMs, untilMs) { - return readCursorAccountUsage(credentialSource, sinceMs, untilMs).pipe( - Effect.provideContext(context), - ); + return read(credentialSource, sinceMs, untilMs).pipe(Effect.provideContext(context)); }), }); }), diff --git a/packages/provider-cursor/src/server/accountCache.ts b/packages/provider-cursor/src/server/accountCache.ts index 637d2b96d1ec4..c5a68592423df 100644 --- a/packages/provider-cursor/src/server/accountCache.ts +++ b/packages/provider-cursor/src/server/accountCache.ts @@ -82,8 +82,8 @@ export function mergeCursorFetch( } /** Versioned files isolate caches for different server versions sharing a state directory. */ -export const CURSOR_ACCOUNT_CACHE_FILE_NAME = "usage-cursor-account-cache-v1.json"; -const CURSOR_ACCOUNT_CACHE_VERSION = 1; +export const CURSOR_ACCOUNT_CACHE_FILE_NAME = "usage-cursor-account-cache-v2.json"; +const CURSOR_ACCOUNT_CACHE_VERSION = 2; type SerializedRecord = readonly [ timestampMs: number, diff --git a/packages/provider-cursor/src/server/accountUsage.test.ts b/packages/provider-cursor/src/server/accountUsage.test.ts index 94c65d546b957..e97239357a10f 100644 --- a/packages/provider-cursor/src/server/accountUsage.test.ts +++ b/packages/provider-cursor/src/server/accountUsage.test.ts @@ -2,6 +2,8 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Layer from "effect/Layer"; import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Queue from "effect/Queue"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -11,7 +13,10 @@ import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as TestClock from "effect/testing/TestClock"; -import { readCursorAccountUsage } from "./accountUsage.ts"; +import * as CursorAccountReader from "./CursorAccountReader.ts"; + +import { makeCursorAccountUsageReader } from "./accountUsage.ts"; +import type { CursorCredentialSource } from "./accountCache.ts"; interface DashboardRequest { readonly url: string; @@ -20,7 +25,10 @@ interface DashboardRequest { readonly headers: Headers; } -const dashboard = (answer: (request: DashboardRequest) => Response | Promise) => +const dashboard = ( + answer: (request: DashboardRequest) => Response | Promise, + membership = { userId: 1, teamIds: [] as number[] }, +) => HttpClient.make((request, url, signal, fiber) => Effect.tryPromise({ try: async () => { @@ -36,6 +44,15 @@ const dashboard = (answer: (request: DashboardRequest) => Response | Promise ({ id })) }), + ); + } const response = await answer({ url: url.toString(), init: { ...init, signal, body }, @@ -52,13 +69,15 @@ const dashboard = (answer: (request: DashboardRequest) => Response | Promise[0], + credentialSource: CursorCredentialSource, sinceMs: number, endDate: number, answer: (request: DashboardRequest) => Response | Promise, + membership?: { userId: number; teamIds: number[] }, ) => - readCursorAccountUsage(credentialSource, sinceMs, endDate).pipe( - Effect.provideService(HttpClient.HttpClient, dashboard(answer)), + makeCursorAccountUsageReader.pipe( + Effect.flatMap((read) => read(credentialSource, sinceMs, endDate)), + Effect.provideService(HttpClient.HttpClient, dashboard(answer, membership)), ); describe("Cursor account history", () => { @@ -123,6 +142,157 @@ describe("Cursor account history", () => { }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); + it.effect("includes each team's own-user history alongside personal history", () => + Effect.gen(function* () { + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + const scopes: unknown[] = []; + const result = yield* readAccountUsage( + { kind: "apiKey", apiKey: accessToken }, + 0, + 1781000000000, + async ({ init }) => { + const body = JSON.parse(String(init.body)); + scopes.push([body.teamId, body.userId]); + const team = body.teamId !== undefined; + return Response.json({ + totalUsageEventsCount: 1, + usageEventsDisplay: [ + { + timestamp: "1780000000000", + model: "gpt-5", + conversationId: "same-conversation", + tokenUsage: { cacheReadTokens: team ? 5_000_000 : 10 }, + }, + ], + }); + }, + { userId: 42, teamIds: [7, 8, 7] }, + ); + assert.isNull(result.error); + assert.deepStrictEqual(scopes, [ + [undefined, undefined], + [7, 42], + [8, 42], + ]); + assert.strictEqual( + result.records.reduce((sum, row) => sum + row.totals.cachedInputTokens, 0), + 10_000_010, + ); + assert.strictEqual(new Set(result.records.map((row) => row.dedupeKey)).size, 3); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); + + it.effect("rejects unscoped or incomplete team history without keeping personal totals", () => + Effect.gen(function* () { + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + for (const userId of [0, 42]) { + const scopes: unknown[] = []; + const result = yield* readAccountUsage( + { kind: "apiKey", apiKey: accessToken }, + 0, + 1781000000000, + async ({ init }) => { + const body = JSON.parse(String(init.body)); + scopes.push(body.teamId); + return body.teamId === undefined + ? Response.json({ + totalUsageEventsCount: 1, + usageEventsDisplay: [ + { + timestamp: "1780000000000", + model: "gpt-5", + tokenUsage: { inputTokens: 10 }, + }, + ], + }) + : new Response(null, { status: 403 }); + }, + { userId, teamIds: [7] }, + ); + assert.isNotNull(result.error); + assert.deepStrictEqual(result.records, []); + assert.deepStrictEqual(scopes, userId === 0 ? [] : [undefined, 7]); + } + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); + + it.effect.each([ + { exp: undefined, lifetime: "5 minutes" }, + { exp: 90, lifetime: "1 minute" }, + ] as const)( + "reuses discovery for $lifetime, invalidates rejected auth, and isolates API keys", + ({ exp, lifetime }) => + Effect.gen(function* () { + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp })).toString("base64url")}.signature`; + const requests: string[] = []; + let rejected = false; + const client = dashboard( + () => (rejected ? new Response(null, { status: 401 }) : Response.json({})), + { userId: 42, teamIds: [7] }, + ).pipe( + HttpClient.mapRequest((request) => { + requests.push(request.url.split("/").at(-1)!); + return request; + }), + ); + yield* Effect.gen(function* () { + const reader = yield* CursorAccountReader.CursorAccountReader; + const credential = { kind: "apiKey", apiKey: accessToken } as const; + assert.isNull((yield* reader.read(credential, 0, 1781000000000)).error); + requests.length = 0; + assert.isNull((yield* reader.read(credential, 0, 1781000000000)).error); + assert.deepStrictEqual(requests, ["GetFilteredUsageEvents", "GetFilteredUsageEvents"]); + rejected = true; + assert.isNotNull((yield* reader.read(credential, 0, 1781000000000)).error); + rejected = false; + requests.length = 0; + assert.isNull((yield* reader.read(credential, 0, 1781000000000)).error); + assert.include(requests, "GetTeams"); + requests.length = 0; + yield* TestClock.adjust(lifetime); + yield* reader.read(credential, 0, 1781000000000); + assert.include(requests, "GetTeams"); + requests.length = 0; + yield* reader.read({ ...credential, apiKey: accessToken + "other" }, 0, 1781000000000); + assert.include(requests, "GetTeams"); + }).pipe( + Effect.provide( + CursorAccountReader.layer.pipe( + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ), + ), + ); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); + + it.effect("starts personal and team reads together without waiting for either response", () => + Effect.gen(function* () { + const personal = yield* Deferred.make(); + const team = yield* Deferred.make(); + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + const client = dashboard(() => Response.json({}), { userId: 42, teamIds: [7] }).pipe( + HttpClient.transform((response, request) => { + if (!request.url.endsWith("/GetFilteredUsageEvents")) return response; + const body = + request.body._tag === "Uint8Array" + ? JSON.parse(new TextDecoder().decode(request.body.body)) + : {}; + const isPersonal = body.teamId === undefined; + return Effect.gen(function* () { + yield* Deferred.succeed(isPersonal ? personal : team, undefined); + yield* Deferred.await(isPersonal ? team : personal); + return yield* response; + }); + }), + ); + const result = yield* makeCursorAccountUsageReader.pipe( + Effect.flatMap((read) => read({ kind: "apiKey", apiKey: accessToken }, 0, 1781000000000)), + Effect.provideService(HttpClient.HttpClient, client), + ); + assert.isNull(result.error); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); + it.effect("reads Cursor account history beyond 100 pages", () => Effect.gen(function* () { const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; @@ -341,37 +511,76 @@ describe("readCursorAccountUsage", () => { }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); - it.effect("reads the pages behind the first together and keeps them in page order", () => - Effect.gen(function* () { - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - let inFlight = 0; - let mostInFlight = 0; - const result = yield* readAccountUsage( - { kind: "apiKey", apiKey: accessToken }, - 0, - 1781000000000, - async ({ page }) => { - inFlight++; - mostInFlight = Math.max(mostInFlight, inFlight); - for (let turn = page; turn < 9; turn++) await Promise.resolve(); - inFlight--; - return Response.json({ - totalUsageEventsCount: 8001, - usageEventsDisplay: Array.from({ length: page === 9 ? 1 : 1000 }, (_, index) => ({ - timestamp: String(1780000000000 + (page - 1) * 1000 + index), - model: "gpt-5", - tokenUsage: { inputTokens: 1 }, - })), - }); - }, - ); - assert.isNull(result.error); - assert.strictEqual(mostInFlight, 6); - assert.deepStrictEqual( - result.records.map((record) => record.timestampMs), - Array.from({ length: 8001 }, (_, index) => 1780000000000 + index), - ); - }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + it.effect.each([{ queued: false }, { queued: true }])( + "reads concurrent pages in order with a full request deadline after queuing: $queued", + ({ queued }) => + Effect.gen(function* () { + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + const started = yield* Queue.unbounded(); + const firstBatch = yield* Deferred.make(); + const secondBatch = yield* Deferred.make(); + let delayedRequests = 0; + let inFlight = 0; + let mostInFlight = 0; + const client = dashboard( + async ({ page }) => { + inFlight++; + mostInFlight = Math.max(mostInFlight, inFlight); + for (let turn = page; turn < 9; turn++) await Promise.resolve(); + inFlight--; + return Response.json({ + totalUsageEventsCount: 8001, + usageEventsDisplay: Array.from({ length: page === 9 ? 1 : 1000 }, (_, index) => ({ + timestamp: String(1780000000000 + (page - 1) * 1000 + index), + model: "gpt-5", + tokenUsage: { inputTokens: 1 }, + })), + }); + }, + { userId: 42, teamIds: queued ? [7] : [] }, + ).pipe( + HttpClient.transform((response, request) => { + if (!queued || !request.url.endsWith("/GetFilteredUsageEvents")) return response; + const body = + request.body._tag === "Uint8Array" + ? JSON.parse(new TextDecoder().decode(request.body.body)) + : {}; + if (body.page === 1) return response; + return Effect.gen(function* () { + const batch = delayedRequests++ < 6 ? firstBatch : secondBatch; + yield* Queue.offer(started, undefined); + yield* Deferred.await(batch); + return yield* response; + }); + }), + ); + const read = yield* Effect.forkChild( + makeCursorAccountUsageReader.pipe( + Effect.flatMap((reader) => + reader({ kind: "apiKey", apiKey: accessToken }, 0, 1781000000000), + ), + Effect.provideService(HttpClient.HttpClient, client), + ), + ); + if (queued) { + yield* Effect.all(Array.from({ length: 6 }, () => Queue.take(started))); + yield* TestClock.adjust("6 seconds"); + yield* Deferred.succeed(firstBatch, undefined); + yield* Effect.all(Array.from({ length: 6 }, () => Queue.take(started))); + yield* TestClock.adjust("6 seconds"); + yield* Deferred.succeed(secondBatch, undefined); + } + const result = yield* Fiber.join(read); + assert.isNull(result.error); + assert.strictEqual(mostInFlight, 6); + assert.deepStrictEqual( + result.records.map((record) => record.timestampMs), + Array.from( + { length: queued ? 16002 : 8001 }, + (_, index) => 1780000000000 + (index % 8001), + ), + ); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("ends like a page-by-page read when a page fails, leaving no request open", () => diff --git a/packages/provider-cursor/src/server/accountUsage.ts b/packages/provider-cursor/src/server/accountUsage.ts index dff8f3d877e24..7ebc87489ccd7 100644 --- a/packages/provider-cursor/src/server/accountUsage.ts +++ b/packages/provider-cursor/src/server/accountUsage.ts @@ -1,5 +1,9 @@ import type { UsageRecord } from "@t3tools/provider-core/server/usage"; import * as Digest from "effect/crypto/Digest"; +import * as Cache from "effect/Cache"; +import * as Clock from "effect/Clock"; +import * as Exit from "effect/Exit"; +import * as Semaphore from "effect/Semaphore"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Base64Url from "effect/encoding/Base64Url"; @@ -47,8 +51,26 @@ const invalid = (reason: string) => Effect.fail(new CursorAccountUsageInvalidErr const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); -const pageRequestBody = (page: number, pageSize: number, sinceMs: number, endDate: number) => - JSON.stringify({ page, pageSize, startDate: String(sinceMs), endDate: String(endDate) }); +const decodeTeams = Schema.decodeUnknownEffect( + Schema.Struct({ teams: Schema.optionalKey(Schema.Array(Schema.Struct({ id: Schema.Number }))) }), +); +const decodeUser = Schema.decodeUnknownEffect(Schema.Struct({ userId: Schema.Number })); + +const pageRequestBody = ( + page: number, + pageSize: number, + sinceMs: number, + endDate: number, + teamId?: number, + userId?: number, +) => + JSON.stringify({ + page, + pageSize, + startDate: String(sinceMs), + endDate: String(endDate), + ...(teamId === undefined ? {} : { teamId, userId }), + }); const billedEventIdentity = (...fields: readonly unknown[]) => JSON.stringify(fields); @@ -80,257 +102,336 @@ function boundaryOverlap(previous: readonly string[], current: readonly string[] /** Never forward the access token through a redirect. */ const NO_REDIRECT: RequestInit = { redirect: "error" }; -export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(function* ( - credentialSource: CursorCredentialSource, - sinceMs: number, - endDate: number, -): Effect.fn.Return { +export const makeCursorAccountUsageReader = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const digest = yield* Digest.Digest; // Per-event hashes share the scan span and use the native hex encoder. const accountHash = (value: string) => digest.digestHex(Digest.sha256, textEncoder.encode(value)); - const credential = yield* Effect.result(exchangeCursorApiKey(credentialSource.apiKey)); - if (Result.isFailure(credential)) { - return { - accountKey: null, - records: [], - missing: false, - error: "Cursor account usage could not be read.", - }; - } - const accessToken = credential.success; - let accountKey: string | null = null; - const read = Effect.gen(function* () { + const readSession = Effect.fn("CursorAccountUsage.readSession")(function* (apiKey: string) { + const accessToken = yield* exchangeCursorApiKey(apiKey); const payload = Base64Url.decodeString(accessToken.split(".")[1] ?? ""); if (Result.isFailure(payload)) return yield* invalid("Invalid authentication"); - const subject = object( + const authentication = object( yield* decodeJson(payload.success).pipe( Effect.catch(() => invalid("Invalid authentication")), ), - ).sub; + ); + const subject = authentication.sub; if (typeof subject !== "string" || !subject) return yield* invalid("Invalid authentication"); - accountKey = yield* accountHash(subject); - if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate) - return yield* invalid("Invalid date window"); - const pageRequest = HttpClientRequest.post( - `${DEFAULT_CURSOR_API_ENDPOINT}/aiserver.v1.DashboardService/GetFilteredUsageEvents`, - ).pipe( - HttpClientRequest.bearerToken(accessToken), - HttpClientRequest.setHeader("connect-protocol-version", "1"), + const accountKey = yield* accountHash(subject); + const membershipRequest = Effect.fn("CursorAccountUsage.membershipRequest")(function* ( + method: string, + ) { + const response = yield* httpClient + .execute( + HttpClientRequest.post( + `${DEFAULT_CURSOR_API_ENDPOINT}/aiserver.v1.DashboardService/${method}`, + ).pipe( + HttpClientRequest.bearerToken(accessToken), + HttpClientRequest.setHeader("connect-protocol-version", "1"), + HttpClientRequest.bodyText("{}", "application/json"), + ), + ) + .pipe(Effect.provideService(FetchHttpClient.RequestInit, NO_REDIRECT)); + if (response.status < 200 || response.status >= 300) + return yield* invalid("Account membership request failed"); + const body = yield* response.json; + const fields = object(body); + if ("error" in fields || "message" in fields || "code" in fields) + return yield* invalid("Account membership error response"); + return body; + }, Effect.timeout("10 seconds")); + const [teams, me] = yield* Effect.all( + [ + membershipRequest("GetTeams").pipe(Effect.flatMap(decodeTeams)), + membershipRequest("GetMe").pipe(Effect.flatMap(decodeUser)), + ], + { concurrency: 2 }, ); - const records: UsageRecord[] = []; - const occurrences = new Map(); - const normalized: Array<(Omit & { dedupeKey: string }) | null> = []; - const pages: Array<{ start: number; end: number; overlap: number }> = []; - let previousEvents: unknown[] = []; - const pageSize = 1000; - let total: number | undefined; - const readPage = Effect.fn("CursorAccountUsage.readPage")( - function* (page: number) { - // A count can include overlapping page boundaries. Allow room to - // reconcile them without imposing a fixed account-size limit. - if (page > (total === undefined ? 1000 : Math.ceil(total / pageSize) * 2 + 1)) { - return yield* invalid("Account usage page limit exceeded"); - } - const response = yield* httpClient - .execute( - pageRequest.pipe( - HttpClientRequest.bodyText( - pageRequestBody(page, pageSize, sinceMs, endDate), - "application/json", + const teamIds = [...new Set((teams.teams ?? []).map((team) => team.id))]; + if (teamIds.some((id) => !Number.isSafeInteger(id) || id <= 0)) + return yield* invalid("Invalid account teams"); + const userId = me.userId; + if (!Number.isSafeInteger(userId) || userId <= 0) return yield* invalid("Invalid account user"); + + const nowMs = yield* Clock.currentTimeMillis; + const ttlMs = + typeof authentication.exp === "number" + ? Math.max(0, Math.min(5 * 60_000, authentication.exp * 1000 - nowMs - 30_000)) + : 5 * 60_000; + return { accessToken, accountKey, teamIds, userId, ttlMs }; + }); + const sessions = yield* Cache.makeWith(readSession, { + capacity: 16, + timeToLive: (exit) => + Exit.isSuccess(exit) ? Duration.millis(exit.value.ttlMs) : Duration.zero, + }); + return Effect.fn("readCursorAccountUsage")(function* ( + credentialSource: CursorCredentialSource, + sinceMs: number, + endDate: number, + ) { + let accountKey: string | null = null; + const read = Effect.gen(function* () { + if ( + !Number.isFinite(sinceMs) || + !Number.isFinite(endDate) || + sinceMs < 0 || + sinceMs > endDate + ) + return yield* invalid("Invalid date window"); + const session = yield* Cache.get(sessions, credentialSource.apiKey); + const { accessToken, teamIds, userId } = session; + accountKey = session.accountKey; + const pageSlots = yield* Semaphore.make(6); + // Omitting teamId returns personal history; team history also needs the current user filter. + const readScope = Effect.fn("CursorAccountUsage.readScope")(function* (teamId?: number) { + const pageRequest = HttpClientRequest.post( + `${DEFAULT_CURSOR_API_ENDPOINT}/aiserver.v1.DashboardService/GetFilteredUsageEvents`, + ).pipe( + HttpClientRequest.bearerToken(accessToken), + HttpClientRequest.setHeader("connect-protocol-version", "1"), + ); + const records: UsageRecord[] = []; + const occurrences = new Map(); + const normalized: Array<(Omit & { dedupeKey: string }) | null> = + []; + const pages: Array<{ start: number; end: number; overlap: number }> = []; + let previousEvents: unknown[] = []; + const pageSize = 1000; + let total: number | undefined; + const readPage = Effect.fn("CursorAccountUsage.readPage")( + function* (page: number) { + // A count can include overlapping page boundaries. Allow room to + // reconcile them without imposing a fixed account-size limit. + if (page > (total === undefined ? 1000 : Math.ceil(total / pageSize) * 2 + 1)) { + return yield* invalid("Account usage page limit exceeded"); + } + const response = yield* httpClient + .execute( + pageRequest.pipe( + HttpClientRequest.bodyText( + pageRequestBody(page, pageSize, sinceMs, endDate, teamId, userId), + "application/json", + ), + ), + ) + .pipe( + Effect.provideService(FetchHttpClient.RequestInit, NO_REDIRECT), + Effect.mapError( + () => + new CursorAccountUsageInvalidError({ reason: "Account usage request failed" }), + ), + ); + if (response.status === 401 || response.status === 403) { + yield* Cache.invalidate(sessions, credentialSource.apiKey); + return { + accountKey, + records: [], + missing: false, + error: "Sign in to Cursor again to read account usage.", + } satisfies CursorAccountUsageReadResult; + } + if (response.status < 200 || response.status >= 300) + return yield* invalid("Account usage request failed"); + const parsed: unknown = yield* response.json.pipe( + Effect.mapError( + () => new CursorAccountUsageInvalidError({ reason: "Invalid account usage page" }), ), - ), - ) - .pipe( - Effect.provideService(FetchHttpClient.RequestInit, NO_REDIRECT), - Effect.mapError( - () => new CursorAccountUsageInvalidError({ reason: "Account usage request failed" }), - ), - ); - if (response.status === 401 || response.status === 403) { - return { - accountKey, - records: [], - missing: false, - error: "Sign in to Cursor again to read account usage.", - } satisfies CursorAccountUsageReadResult; - } - if (response.status < 200 || response.status >= 300) - return yield* invalid("Account usage request failed"); - const parsed: unknown = yield* response.json.pipe( - Effect.mapError( - () => new CursorAccountUsageInvalidError({ reason: "Invalid account usage page" }), - ), + ); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return yield* invalid("Invalid account usage page"); + } + const body = object(parsed); + const keys = Object.keys(body); + if ("error" in body || "message" in body || "code" in body) + return yield* invalid("Account usage error response"); + const count = keys.length === 0 ? 0 : body.totalUsageEventsCount; + const events = + keys.length === 0 || (keys.length === 1 && keys[0] === "totalUsageEventsCount") + ? [] + : body.usageEventsDisplay; + if ( + (count !== undefined && + (typeof count !== "number" || + !Number.isSafeInteger(count) || + count < 0 || + (total !== undefined && count !== total))) || + !Array.isArray(events) || + events.length > pageSize || + (count === undefined && !Array.isArray(body.usageEventsDisplay)) + ) { + return yield* invalid("Inconsistent account usage page"); + } + if (typeof count === "number") total = count; + return events as unknown[]; + }, + Effect.timeoutOrElse({ + duration: Duration.seconds(10), + orElse: () => invalid("Account usage request timed out"), + }), + pageSlots.withPermit, ); - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - return yield* invalid("Invalid account usage page"); - } - const body = object(parsed); - const keys = Object.keys(body); - if ("error" in body || "message" in body || "code" in body) - return yield* invalid("Account usage error response"); - const count = keys.length === 0 ? 0 : body.totalUsageEventsCount; - const events = - keys.length === 0 || (keys.length === 1 && keys[0] === "totalUsageEventsCount") - ? [] - : body.usageEventsDisplay; - if ( - (count !== undefined && - (typeof count !== "number" || - !Number.isSafeInteger(count) || - count < 0 || - (total !== undefined && count !== total))) || - !Array.isArray(events) || - events.length > pageSize || - (count === undefined && !Array.isArray(body.usageEventsDisplay)) - ) { - return yield* invalid("Inconsistent account usage page"); - } - if (typeof count === "number") total = count; - return events as unknown[]; - }, - Effect.timeoutOrElse({ - duration: Duration.seconds(10), - orElse: () => invalid("Account usage request timed out"), - }), - ); - // Cursor caps a page at 1,000 events and takes about a second to answer one, so the pages - // the first one's count implies are requested up to six ahead and consumed in page order. - const ahead: Fiber.Fiber< - Effect.Success>, - CursorAccountUsageInvalidError - >[] = []; - for (let page = 1; ; page++) { - while (ahead.length < Math.min(6, Math.ceil((total ?? 0) / pageSize) - page + 1)) { - ahead.push(yield* Effect.forkScoped(readPage(page + ahead.length))); - } - const next = ahead.shift(); - const events = yield* next === undefined ? readPage(page) : Fiber.join(next); - if (!Array.isArray(events)) return events; - const start = normalized.length; - const firstTimestamp = object(events[0]).timestamp; - // Equal raw events must have equal timestamps; avoid canonicalizing disjoint pages. - const mayOverlap = - (typeof firstTimestamp !== "string" && typeof firstTimestamp !== "number") || - previousEvents.some((event) => object(event).timestamp === firstTimestamp); - const overlap = mayOverlap - ? boundaryOverlap(previousEvents.map(canonicalJson), events.map(canonicalJson)) - : 0; - previousEvents = events; - for (const raw of events) { - const event = object(raw); - const usage = object(event.tokenUsage); - if (event.tokenUsage === undefined || event.tokenUsage === null) { - normalized.push(null); - continue; - } - for (const key of [ - "inputTokens", - "outputTokens", - "cacheReadTokens", - "cacheWriteTokens", - "totalCents", - ]) { - const value = usage[key]; - if ( - value !== undefined && - (typeof value !== "number" || !Number.isFinite(value) || value < 0) - ) { - return yield* invalid("Invalid account usage totals"); + // Cursor caps a page at 1,000 events and takes about a second to answer one, so the pages + // the first one's count implies are requested up to six ahead and consumed in page order. + const ahead: Fiber.Fiber< + Effect.Success>, + CursorAccountUsageInvalidError + >[] = []; + for (let page = 1; ; page++) { + while (ahead.length < Math.min(6, Math.ceil((total ?? 0) / pageSize) - page + 1)) { + ahead.push(yield* Effect.forkScoped(readPage(page + ahead.length))); + } + const next = ahead.shift(); + const events = yield* next === undefined ? readPage(page) : Fiber.join(next); + if (!Array.isArray(events)) return events; + const start = normalized.length; + const firstTimestamp = object(events[0]).timestamp; + // Equal raw events must have equal timestamps; avoid canonicalizing disjoint pages. + const mayOverlap = + (typeof firstTimestamp !== "string" && typeof firstTimestamp !== "number") || + previousEvents.some((event) => object(event).timestamp === firstTimestamp); + const overlap = mayOverlap + ? boundaryOverlap(previousEvents.map(canonicalJson), events.map(canonicalJson)) + : 0; + previousEvents = events; + for (const raw of events) { + const event = object(raw); + const usage = object(event.tokenUsage); + if (event.tokenUsage === undefined || event.tokenUsage === null) { + normalized.push(null); + continue; + } + for (const key of [ + "inputTokens", + "outputTokens", + "cacheReadTokens", + "cacheWriteTokens", + "totalCents", + ]) { + const value = usage[key]; + if ( + value !== undefined && + (typeof value !== "number" || !Number.isFinite(value) || value < 0) + ) { + return yield* invalid("Invalid account usage totals"); + } + } + const timestampMs = + typeof event.timestamp === "string" && event.timestamp.trim() !== "" + ? Number(event.timestamp) + : event.timestamp; + if ( + typeof timestampMs !== "number" || + !Number.isFinite(timestampMs) || + typeof event.model !== "string" || + !event.model + ) + return yield* invalid("Invalid account usage event"); + if (timestampMs < sinceMs || timestampMs > endDate) { + normalized.push(null); + continue; + } + const totals = { + uncachedInputTokens: tokens(usage.inputTokens), + cachedInputTokens: tokens(usage.cacheReadTokens), + cacheCreationTokens: tokens(usage.cacheWriteTokens), + outputTokens: tokens(usage.outputTokens), + reasoningTokens: 0, + }; + const reportedCostUsd = + typeof usage.totalCents === "number" ? usage.totalCents / 100 : null; + const sessionId = typeof event.conversationId === "string" ? event.conversationId : ""; + // No event ID is provided. Preserve identical billed rows with an occurrence index. + const key = yield* accountHash( + billedEventIdentity( + ...(teamId === undefined ? [] : [teamId]), + timestampMs, + event.model, + sessionId, + totals, + reportedCostUsd, + ), + ); + normalized.push({ + provider: "cursor", + timestampMs, + model: event.model, + rateModel: cursorRateModel(event.model), + sessionId, + totals, + reportedCostUsd, + speed: "standard", + dedupeKey: key, + }); } + pages.push({ start, end: normalized.length, overlap }); + if (events.length < pageSize) break; + yield* Effect.yieldNow; } - const timestampMs = - typeof event.timestamp === "string" && event.timestamp.trim() !== "" - ? Number(event.timestamp) - : event.timestamp; - if ( - typeof timestampMs !== "number" || - !Number.isFinite(timestampMs) || - typeof event.model !== "string" || - !event.model - ) - return yield* invalid("Invalid account usage event"); - if (timestampMs < sinceMs || timestampMs > endDate) { - normalized.push(null); - continue; + const rawCount = normalized.length; + if (total !== undefined && rawCount < total) + return yield* invalid("Incomplete account usage pages"); + let removalsRemaining = total === undefined ? 0 : rawCount - total; + // The final count proves which boundary copies to remove, including non-token events. + for (const page of pages) { + const removalCount = Math.min(removalsRemaining, page.overlap); + removalsRemaining -= removalCount; + for (let index = page.start; index < page.end; index++) { + const record = normalized[index]; + normalized[index] = null; + if (index < page.start + removalCount || record == null) continue; + const key = record.dedupeKey; + const occurrence = occurrences.get(key) ?? 0; + occurrences.set(key, occurrence + 1); + record.dedupeKey = `cursor-account:${accountKey}:${key}:${occurrence}`; + records.push(record); + } + yield* Effect.yieldNow; } - const totals = { - uncachedInputTokens: tokens(usage.inputTokens), - cachedInputTokens: tokens(usage.cacheReadTokens), - cacheCreationTokens: tokens(usage.cacheWriteTokens), - outputTokens: tokens(usage.outputTokens), - reasoningTokens: 0, - }; - const reportedCostUsd = - typeof usage.totalCents === "number" ? usage.totalCents / 100 : null; - const sessionId = typeof event.conversationId === "string" ? event.conversationId : ""; - // No event ID is provided. Preserve identical billed rows with an occurrence index. - const key = yield* accountHash( - billedEventIdentity(timestampMs, event.model, sessionId, totals, reportedCostUsd), - ); - normalized.push({ - provider: "cursor", - timestampMs, - model: event.model, - rateModel: cursorRateModel(event.model), - sessionId, - totals, - reportedCostUsd, - speed: "standard", - dedupeKey: key, + if (removalsRemaining !== 0) return yield* invalid("Inconsistent account usage boundaries"); + yield* Effect.annotateCurrentSpan({ + "cursor.usage.pages": pages.length, + "cursor.usage.events": rawCount, + "cursor.usage.records": records.length, }); - } - pages.push({ start, end: normalized.length, overlap }); - if (events.length < pageSize) break; - yield* Effect.yieldNow; - } - const rawCount = normalized.length; - if (total !== undefined && rawCount < total) - return yield* invalid("Incomplete account usage pages"); - let removalsRemaining = total === undefined ? 0 : rawCount - total; - // The final count proves which boundary copies to remove, including non-token events. - for (const page of pages) { - const removalCount = Math.min(removalsRemaining, page.overlap); - removalsRemaining -= removalCount; - for (let index = page.start; index < page.end; index++) { - const record = normalized[index]; - normalized[index] = null; - if (index < page.start + removalCount || record == null) continue; - const key = record.dedupeKey; - const occurrence = occurrences.get(key) ?? 0; - occurrences.set(key, occurrence + 1); - record.dedupeKey = `cursor-account:${accountKey}:${key}:${occurrence}`; - records.push(record); - } - yield* Effect.yieldNow; - } - if (removalsRemaining !== 0) return yield* invalid("Inconsistent account usage boundaries"); - yield* Effect.annotateCurrentSpan({ - "cursor.usage.pages": pages.length, - "cursor.usage.events": rawCount, - "cursor.usage.records": records.length, - }); - return { - accountKey, - records, - missing: false, - error: null, - } satisfies CursorAccountUsageReadResult; - }); - return yield* read.pipe( - Effect.scoped, - Effect.timeoutOrElse({ - duration: Duration.seconds(60), - orElse: () => invalid("Account usage read timed out"), - }), - Effect.orElseSucceed( - () => - ({ + return { accountKey, - records: [], + records, missing: false, - error: "Cursor account usage could not be read.", - }) satisfies CursorAccountUsageReadResult, - ), - ); + error: null, + } satisfies CursorAccountUsageReadResult; + }); + + const results = yield* Effect.forEach( + [undefined, ...teamIds], + (teamId) => readScope(teamId).pipe(Effect.scoped), + { concurrency: 2 }, + ); + const failure = results.find((result) => result.error !== null); + if (failure !== undefined) return failure; + return { + accountKey, + records: results.flatMap((result) => result.records), + missing: false, + error: null, + } satisfies CursorAccountUsageReadResult; + }); + return yield* read.pipe( + Effect.scoped, + Effect.timeoutOrElse({ + duration: Duration.seconds(60), + orElse: () => invalid("Account usage read timed out"), + }), + Effect.orElseSucceed( + () => + ({ + accountKey, + records: [], + missing: false, + error: "Cursor account usage could not be read.", + }) satisfies CursorAccountUsageReadResult, + ), + ); + }); }); From 764c0475b5d98e60bdd01f5cd341c5cfbd55a2da Mon Sep 17 00:00:00 2001 From: Simon Maggini Date: Fri, 13 Nov 2026 18:42:14 +0100 Subject: [PATCH 009/409] fix(web): terminal TypeScript links preserve error locations (#36174) --- apps/web/src/terminal-links.test.ts | 21 +++++++++++++++++++++ apps/web/src/terminal-links.ts | 9 ++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/web/src/terminal-links.test.ts b/apps/web/src/terminal-links.test.ts index 4dcd52961425c..242df9ddcccd3 100644 --- a/apps/web/src/terminal-links.test.ts +++ b/apps/web/src/terminal-links.test.ts @@ -299,6 +299,27 @@ describe("resolvePathLinkTarget", () => { ); }); + it.each([ + ["src/main.ts", "/repo/src/main.ts"], + ["/repo/src/main.ts", "/repo/src/main.ts"], + [String.raw`C:\repo\src\main.ts`, String.raw`C:\repo\src\main.ts`], + ["app/(auth)/page.tsx", "/repo/app/(auth)/page.tsx"], + ])("preserves a TypeScript compiler location on %s", (path, target) => { + const text = `${path}(42,7)`; + const links = extractTerminalLinks(`${text}: error TS2322: Type mismatch`); + expect(links).toEqual([{ kind: "path", text: `${path}:42:7`, start: 0, end: text.length }]); + expect(resolveTerminalLinkTarget(links[0]?.text ?? "", "/repo")).toBe(`${target}:42:7`); + }); + + it.each([ + [String.raw`PS C:\dev\folder (42,7)> `, String.raw`C:\dev\folder (42,7)`], + ["/tmp/report(42,7)", "/tmp/report(42,7)"], + ["src/Program.cs(3,10): error CS1002: x", "/repo/src/Program.cs"], + ])("preserves existing path targets in %s", (line, target) => { + const [link] = extractTerminalLinks(line); + expect(resolveTerminalLinkTarget(link?.text ?? "", "/repo")).toBe(target); + }); + // regression: #36005 OSC 8 file URIs were joined onto the cwd as relative paths it("does not resolve an OSC 8 file URI target against the cwd", () => { expect(resolveTerminalLinkTarget("file:///var/tmp/report.txt", "/workspace/project")).toBe( diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index 43ad505337a02..1d4366cc42748 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -40,9 +40,9 @@ const POWERSHELL_PROMPT_PATH_PATTERN = /^PS ((?:[A-Za-z]:[\\/]|\\\\|\/)[^>\r\n]* // Bare paths start only at a segment boundary: hover detection runs this on // every frame, so separator-free output must not be rescanned at every suffix. // For the same reason `]` needs its `[`: trimming a long run of `]` is quadratic. -// Parentheses count only before a slash, so `src/foo.ts(3,10)` stays a compiler location. +// Parenthesized path segments need a following slash to distinguish compiler locations. const FILE_PATH_PATTERN = - /(?:~[\\/]|\.{1,2}[\\/]|\/|[A-Za-z]:[\\/]|\\\\)[^\s"'`<>]+|(?]+|(?(?:~[\\/]|\.{1,2}[\\/]|\/|[A-Za-z]:[\\/]|\\\\|[\p{L}\p{M}\p{N}._ -]+[\\/])[^\r\n]*?|[\p{L}\p{M}\p{N}._ -]+\.[\p{L}\p{M}\p{N}_-]+(?::\d+){0,2})\1(?(?::\d+){0,2})/gu; const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/; @@ -106,7 +106,10 @@ function collectMatches( const candidate: TerminalLinkMatch = { kind, - text: trimmed, + text: + kind === "path" && /^:\s+(?:error|warning) TS\d+:/u.test(line.slice(start + trimmed.length)) + ? trimmed.replace(/\((\d+),(\d+)\)$/, ":$1:$2") + : trimmed, start, // A position outside quotes spans the closing quote in the terminal buffer. end: start + trimmed.length + (rawMatch.groups?.position ? 1 : 0), From 79dd6d78d10115e4145ea6a6e764d9b3e21c218d Mon Sep 17 00:00:00 2001 From: Simon Maggini Date: Fri, 13 Nov 2026 18:45:16 +0100 Subject: [PATCH 010/409] fix(web): terminal TypeScript links preserve error locations (#36174) From daed1dd327bbb72b1e0991bcf101a96011db5b06 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 09:48:57 -0800 Subject: [PATCH 011/409] fix(server): a follow-up delegated task waits for the subagents its child starts (#34958) --- apps/server/src/mcp/OrchestratorMcpService.ts | 25 +- .../DelegatedCompletionDelivery.test.ts | 245 ++++++++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 19 +- .../orchestration-v2/RestartBackgroundNote.ts | 53 +++- .../src/server/subagentProjection.ts | 23 +- 5 files changed, 338 insertions(+), 27 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index c52716f2ba2d4..e94168b7e06ff 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -101,7 +101,12 @@ import type { ProjectionRecordField, ProjectionRecordFilter, } from "../orchestration-v2/ProjectionStore.ts"; -import { delegatedTaskResultRun, runOutcome } from "../orchestration-v2/RestartBackgroundNote.ts"; +import { + delegatedTaskAwaitsSubagents, + delegatedTaskResultRun, + delegatedTaskRuns, + runOutcome, +} from "../orchestration-v2/RestartBackgroundNote.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; @@ -1625,16 +1630,18 @@ const make = Effect.gen(function* () { const childRun = delegatedTaskRun(childControls, task); const terminalRun = latestTerminalResultRun(childControls, childRun); const progress = delegatedTaskProgress(childControls); + const followupRuns = + childRun?.delegatedTaskId === task.id ? delegatedTaskRuns(childControls, task.id) : undefined; const taskResultRun = - childRun?.delegatedTaskId === task.id - ? delegatedTaskResultRun(childControls.runs, task.id) - : progress.resultRun; + followupRuns === undefined ? progress.resultRun : delegatedTaskResultRun(followupRuns); const taskWorkState = - childRun?.delegatedTaskId === task.id - ? isTerminalTaskStatus(taskStatusForRun(taskResultRun)) - ? "result_available" - : "working" - : progress.state; + followupRuns === undefined + ? progress.state + : !isTerminalTaskStatus(taskStatusForRun(taskResultRun)) + ? "working" + : delegatedTaskAwaitsSubagents(followupRuns, childControls.subagents) + ? "waiting_for_children" + : "result_available"; const resultRunIds = [ ...new Set( [taskResultRun?.id, terminalRun?.id].filter((id): id is RunId => id !== undefined), diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index 426da22121e36..1daeb72e7ecee 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -2937,6 +2937,251 @@ it.layer(Layer.merge(layerTest, ProviderContinuationRequests.layer))( }; }); + it.effect("holds a follow-up open while the child's own subagent still runs", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const parentThreadId = ThreadId.make("thread:followup-grandchild-parent"); + const projectId = ProjectId.make("project:followup-grandchild"); + const parentRunId = RunId.make("run:followup-grandchild-parent"); + const rootNodeId = NodeId.make("node:followup-grandchild-parent"); + yield* seedParentWithTerminalTask({ + threadId: parentThreadId, + projectId, + runId: parentRunId, + rootNodeId, + taskId: NodeId.make("node:followup-grandchild-seed"), + deliveryState: "acknowledged", + now, + }); + const child = yield* seedRestartCancelledChild({ + parentThreadId, + projectId, + parentRunId, + rootNodeId, + name: "followup-grandchild-child", + completionWake: "always", + continuationPending: false, + runStatus: "completed", + now, + }); + yield* orchestrator.recoverDelegatedTasks; + assert.equal( + (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( + (task) => task.id === child.taskId, + )?.status, + "completed", + ); + + const followupMessageId = MessageId.make("message:followup-grandchild-send"); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "agent", + creationSource: "mcp", + commandId: CommandId.make("command:followup-grandchild-send"), + threadId: child.childThreadId, + senderThreadId: parentThreadId, + messageId: followupMessageId, + text: "Now delegate the next step.", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + }); + const followupRun = (yield* orchestrator.getThreadProjection( + child.childThreadId, + )).runs.find((run) => run.userMessageId === followupMessageId)!; + assert.isDefined(followupRun.delegatedTaskId); + const followupTaskId = followupRun.delegatedTaskId!; + + const grandchildId = NodeId.make("node:followup-grandchild-grandchild"); + const grandchild = { + id: grandchildId, + threadId: child.childThreadId, + runId: followupRun.id, + parentNodeId: NodeId.make("node:followup-grandchild-child-root"), + origin: "app_owned" as const, + createdBy: "agent" as const, + driver, + providerInstanceId: modelSelection.instanceId, + providerThreadId: null, + childThreadId: ThreadId.make("thread:followup-grandchild-grandchild"), + nativeTaskRef: null, + prompt: "Do the next step.", + title: null, + model: null, + completionWake: "always" as const, + status: "running" as const, + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }; + yield* eventSink.write({ + commandId: reconcileCommandId("followup-grandchild-turn-ended"), + events: [ + { + id: EventId.make("event:followup-grandchild-started"), + type: "subagent.updated", + threadId: child.childThreadId, + runId: followupRun.id, + nodeId: grandchildId, + driver, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: grandchild, + }, + { + id: EventId.make("event:followup-grandchild-run-completed"), + type: "run.updated", + threadId: child.childThreadId, + runId: followupRun.id, + occurredAt: now, + payload: { ...followupRun, status: "completed", startedAt: now, completedAt: now }, + }, + ], + }); + yield* orchestrator.recoverDelegatedTasks; + + const waiting = yield* orchestrator.getThreadProjection(parentThreadId); + const heldFollowup = waiting.subagents.find((task) => task.id === followupTaskId); + assert.isNull( + heldFollowup?.result ?? null, + "the follow-up closed while the child's own subagent was still running", + ); + assert.equal(heldFollowup?.status, "running"); + assert.equal( + waiting.contextTransfers.filter( + (transfer) => + transfer.type === "subagent_result" && + transfer.sourceThreadId === child.childThreadId, + ).length, + 1, + ); + + // Another turn in the child does not release the hold while the grandchild runs. + for (const status of ["running", "completed"] as const) { + yield* eventSink.write({ + commandId: reconcileCommandId(`followup-grandchild-user-${status}`), + events: [ + runEvent({ + threadId: child.childThreadId, + runId: RunId.make("run:followup-grandchild-user"), + ordinal: followupRun.ordinal + 1, + status, + now, + }), + ], + }); + yield* orchestrator.recoverDelegatedTasks; + assert.isNull( + (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( + (task) => task.id === followupTaskId, + )?.result ?? null, + `the follow-up closed while another child run was ${status}`, + ); + } + + // The grandchild's completion wakes the child in a run without a delegatedTaskId. + const wakeRunId = RunId.make("run:followup-grandchild-wake"); + const wakeRun = (status: "running" | "completed") => + runEvent({ + threadId: child.childThreadId, + runId: wakeRunId, + ordinal: followupRun.ordinal + 2, + status, + now, + }); + yield* eventSink.write({ + commandId: reconcileCommandId("followup-grandchild-settled"), + events: [ + { + id: EventId.make("event:followup-grandchild-completed"), + type: "subagent.updated", + threadId: child.childThreadId, + runId: followupRun.id, + nodeId: grandchildId, + driver, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + ...grandchild, + status: "completed", + result: "grandchild finished", + completionDelivery: { state: "acknowledged", observedByRunId: wakeRunId }, + completedAt: now, + }, + }, + { + id: EventId.make("event:followup-grandchild-wake-message"), + type: "message.updated", + threadId: child.childThreadId, + runId: wakeRunId, + occurredAt: now, + payload: { + id: MessageId.make(`message:${wakeRunId}`), + threadId: child.childThreadId, + runId: wakeRunId, + nodeId: null, + role: "user", + text: "Delegated task finished.", + attachments: [], + streaming: false, + createdBy: "agent", + creationSource: "server", + delegatedCompletion: { + parentRunId: followupRun.id, + generation: 1, + taskIds: [grandchildId], + }, + createdAt: now, + updatedAt: now, + }, + }, + wakeRun("running"), + ], + }); + yield* orchestrator.recoverDelegatedTasks; + const waking = (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( + (task) => task.id === followupTaskId, + ); + assert.isNull(waking?.result ?? null, "the follow-up closed before the child's wake ended"); + + yield* eventSink.write({ + commandId: reconcileCommandId("followup-grandchild-wake-finished"), + events: [ + { + id: EventId.make("event:followup-grandchild-wake-result"), + type: "message.updated", + threadId: child.childThreadId, + runId: wakeRunId, + occurredAt: now, + payload: { + id: MessageId.make("message:followup-grandchild-wake-result"), + threadId: child.childThreadId, + runId: wakeRunId, + nodeId: null, + role: "assistant", + text: "The grandchild finished the next step.", + attachments: [], + streaming: false, + createdBy: "agent", + creationSource: "server", + createdAt: now, + updatedAt: now, + }, + }, + wakeRun("completed"), + ], + }); + yield* orchestrator.recoverDelegatedTasks; + const settled = (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( + (task) => task.id === followupTaskId, + ); + assert.equal(settled?.status, "completed"); + assert.equal(settled?.result, "The grandchild finished the next step."); + }), + ); + it.effect("waits for a run the follow-up's child queued on its own thread", () => Effect.gen(function* () { const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 3fa5a6bf4955c..879dad7809cc2 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -128,7 +128,9 @@ import { cancelledBeforeStart, continuationFailedBeforePrompt, continuationStartRetryDelayMs, + delegatedTaskAwaitsSubagents, delegatedTaskResultRun, + delegatedTaskRuns, isRestartContinuationSource, runOutcome, } from "./RestartBackgroundNote.ts"; @@ -13939,13 +13941,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ) { return; } - const taskRuns = childControls.runs.filter((run) => run.delegatedTaskId === options.task.id); + const taskRuns = delegatedTaskRuns(childControls, options.task.id); const childRun = taskRuns.length === 0 ? options.task.id === forkedFrom.nodeId ? options.resultRun : undefined - : delegatedTaskResultRun(taskRuns, options.task.id); + : delegatedTaskResultRun(taskRuns); if (childRun === undefined) return; let outcome = runOutcome(childRun); let terminalStatus = delegatedTaskTerminalStatus(outcome); @@ -14005,6 +14007,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ]); return; } + // A follow-up ends with the subagents its turns started, as the original task does. + if (delegatedTaskAwaitsSubagents(taskRuns, childControls.subagents)) return; if ( yield* childAwaitsRestartContinuation( taskRuns.length === 0 ? childControls.runs : taskRuns, @@ -15576,9 +15580,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio { messageRoles: ["user"] }, ); if (taskId !== undefined) { - const runs = child.runs.filter((run) => run.delegatedTaskId === taskId); - const run = delegatedTaskResultRun(runs, taskId); - if (run === undefined || delegatedTaskTerminalStatus(run.status) === null) return true; + const runs = delegatedTaskRuns(child, taskId); + const run = delegatedTaskResultRun(runs); + if ( + run === undefined || + delegatedTaskTerminalStatus(run.status) === null || + delegatedTaskAwaitsSubagents(runs, child.subagents) + ) + return true; return yield* childAwaitsRestartContinuation(runs, run); } const progress = delegatedTaskProgress(child); diff --git a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts index 48029bdc75fd1..3de10f4e944ad 100644 --- a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts +++ b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts @@ -1,12 +1,15 @@ import type { NodeId, + OrchestrationV2ConversationMessage, OrchestrationV2PendingBackgroundTask, OrchestrationV2ProviderTurn, OrchestrationV2RestartCancelledBackgroundWork, OrchestrationV2Run, OrchestrationV2RunAttempt, + OrchestrationV2Subagent, OrchestrationV2TurnItem, } from "@t3tools/contracts"; +import { subagentHoldsCompletion } from "@t3tools/provider-core/server/subagentProjection"; import { latestRootProviderFailure, runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; type Work = OrchestrationV2RestartCancelledBackgroundWork; @@ -187,14 +190,56 @@ export function cancelledBeforeStart( return run.status === "cancelled" && neverStarted(run); } +/** A follow-up task's runs: those tagged with it, and the untagged wakes for subagents they started. */ +export function delegatedTaskRuns( + projection: { + readonly runs: ReadonlyArray; + readonly messages: ReadonlyArray< + Pick + >; + }, + taskId: NodeId, +): ReadonlyArray { + const owned = new Set( + projection.runs.filter((run) => run.delegatedTaskId === taskId).map((run) => run.id), + ); + const untagged = new Set( + projection.runs.filter((run) => run.delegatedTaskId === undefined).map((run) => run.id), + ); + for (let grew = owned.size > 0; grew;) { + grew = false; + for (const { runId, delegatedCompletion } of projection.messages) { + if ( + runId === null || + !untagged.has(runId) || + owned.has(runId) || + delegatedCompletion === undefined || + !owned.has(delegatedCompletion.parentRunId) + ) + continue; + owned.add(runId); + grew = true; + } + } + return projection.runs.filter((run) => owned.has(run.id)); +} + +/** A follow-up task's runs still owe a subagent they started its result. */ +export function delegatedTaskAwaitsSubagents( + runs: ReadonlyArray, + subagents: ReadonlyArray< + Pick + >, +): boolean { + const runIds = new Set(runs.map((run) => run.id)); + return subagents.some((task) => runIds.has(task.runId) && subagentHoldsCompletion(task)); +} + /** A follow-up task's latest run, passing over queued work it no longer waits on, as the original task does. */ export function delegatedTaskResultRun( runs: ReadonlyArray, - taskId: NodeId, ): OrchestrationV2Run | undefined { - const taskRuns = runs - .filter((run) => run.delegatedTaskId === taskId) - .toSorted((a, b) => b.ordinal - a.ordinal); + const taskRuns = runs.toSorted((a, b) => b.ordinal - a.ordinal); const awaited = taskRuns.filter( (run) => !cancelledBeforeStart(run) && !(run.status === "queued" && run.queueHeld === true), ); diff --git a/packages/provider-core/src/server/subagentProjection.ts b/packages/provider-core/src/server/subagentProjection.ts index 699067abb3e6f..f476bdc70ff80 100644 --- a/packages/provider-core/src/server/subagentProjection.ts +++ b/packages/provider-core/src/server/subagentProjection.ts @@ -256,6 +256,19 @@ export function subagentResultForRun( }; } +export function subagentHoldsCompletion( + task: Pick, +): boolean { + return ( + isOrchestrationV2WorkActive(task.status) || + // Publishing a child's result precedes scheduling its parent's wake. + // The parent still owes that follow-up even between those transactions. + (task.status !== "idle" && + (task.completionDelivery?.state === "pending" || + task.completionDelivery?.state === "claimed")) + ); +} + export function delegatedTaskProgress(projection: { readonly runs: OrchestrationV2ThreadProjection["runs"]; readonly messages: ReadonlyArray< @@ -282,15 +295,7 @@ export function delegatedTaskProgress(projection: { (run) => !terminal(run.status) && !(run.status === "queued" && run.queueHeld === true), ); const children = - projection.subagents.some( - (task) => - isOrchestrationV2WorkActive(task.status) || - // Publishing a child's result precedes scheduling its parent's wake. - // The parent still owes that follow-up even between those transactions. - (task.status !== "idle" && - (task.completionDelivery?.state === "pending" || - task.completionDelivery?.state === "claimed")), - ) || + projection.subagents.some(subagentHoldsCompletion) || projection.providerThreads.some((thread) => backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []), ); From 9b5d9979c85a3ed8ab6ef6241cc876559597c316 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 09:49:36 -0800 Subject: [PATCH 012/409] fix(opencode): native forks no longer multiply usage (#23120) --- .../src/server/usage.fixture.ts | 176 ++++++++++++++++++ .../src/server/usage.test.ts | 146 +++++++++++++++ .../provider-opencode/src/server/usage.ts | 90 ++++++++- 3 files changed, 404 insertions(+), 8 deletions(-) create mode 100644 packages/provider-opencode/src/server/usage.fixture.ts diff --git a/packages/provider-opencode/src/server/usage.fixture.ts b/packages/provider-opencode/src/server/usage.fixture.ts new file mode 100644 index 0000000000000..27cc8f567125b --- /dev/null +++ b/packages/provider-opencode/src/server/usage.fixture.ts @@ -0,0 +1,176 @@ +/** + * Rows recorded from an isolated `@opencode/cli@2.1.2` data directory: one imported + * session with a single assistant reply, a fork of it, and a fork of that fork + * moved to another directory. No model request was made after the import, so + * every assistant row here describes the same provider request. + */ +export const openCodeForkSchema = [ + // Recorded verbatim, except the project foreign key, whose table the reader never reads. + `CREATE TABLE \`session_v2\` ( + \`id\` text PRIMARY KEY, + \`project_id\` text NOT NULL, + \`workspace_id\` text, + \`parent_id\` text, + \`fork_session_id\` text, + \`fork_boundary\` text, + \`slug\` text NOT NULL, + \`directory\` text NOT NULL, + \`path\` text, + \`title\` text, + \`version\` text NOT NULL, + \`share_url\` text, + \`summary_additions\` integer, + \`summary_deletions\` integer, + \`summary_files\` integer, + \`summary_diffs\` text, + \`metadata\` text, + \`cost\` real DEFAULT 0 NOT NULL, + \`tokens_input\` integer DEFAULT 0 NOT NULL, + \`tokens_output\` integer DEFAULT 0 NOT NULL, + \`tokens_reasoning\` integer DEFAULT 0 NOT NULL, + \`tokens_cache_read\` integer DEFAULT 0 NOT NULL, + \`tokens_cache_write\` integer DEFAULT 0 NOT NULL, + \`revert\` text, + \`permission\` text, + \`agent\` text, + \`model\` text, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + \`time_idle\` integer, + \`time_viewed\` integer, + \`idle_outcome\` text, + \`time_compacting\` integer, + \`time_archived\` integer, + \`time_suspended\` integer, + \`resume_attempts\` integer DEFAULT 0 NOT NULL + )`, + `CREATE TABLE \`session_message\` ( + \`id\` text PRIMARY KEY, + \`session_id\` text NOT NULL, + \`type\` text NOT NULL, + \`seq\` integer NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + \`data\` text NOT NULL, + CONSTRAINT \`fk_session_message_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE + )`, +] as const; + +export const openCodeForkSourceSessionId = "ses_e9f85baeeffec3vOG8D972DJeK"; +export const openCodeForkSourceAssistantId = "msg_usageproofassistant0000001"; +export const openCodeForkContinuationSessionId = "ses_e9f847594ffetYJnuLwILjFktF"; + +export const openCodeForkSessions = [ + { + id: openCodeForkSourceSessionId, + project_id: "66c815350f7a94ca31c5664e5e983b8692210bdc", + fork_session_id: null, + fork_boundary: null, + slug: "mighty-engine", + directory: "/home/ubuntu/wt/issue-23104", + version: "2.1.2", + time_created: 1792619988283, + time_updated: 1792620016405, + }, + { + id: "ses_e9f854c73ffezSCt9xY4yA99bP", + project_id: "66c815350f7a94ca31c5664e5e983b8692210bdc", + fork_session_id: openCodeForkSourceSessionId, + fork_boundary: `{"type":"through","messageID":"${openCodeForkSourceAssistantId}"}`, + slug: "silent-meadow", + directory: "/home/ubuntu/wt/issue-23104", + version: "2.1.2", + time_created: 1792620016526, + time_updated: 1792620016526, + }, + { + id: openCodeForkContinuationSessionId, + project_id: "98021609e205cae99a3b3545f6e3643a33b807b5", + fork_session_id: "ses_e9f854c73ffezSCt9xY4yA99bP", + fork_boundary: '{"type":"through","messageID":"msg_1607ab38e001GxURO0NU4GY4Az_2"}', + slug: "playful-star", + directory: "/tmp/triage-23104", + version: "2.1.2", + time_created: 1792620071533, + time_updated: 1792620071833, + }, +] as const; + +const userData = '{"time":{"created":1792619988283},"text":"Say usage proof"}'; +const assistantData = + '{"time":{"created":1792619988284,"completed":1792619988285},"agent":"build","model":{"id":"claude-sonnet-4-5","providerID":"anthropic"},"content":[{"type":"text","text":"usage proof"}],"finish":"stop","cost":0.01,"tokens":{"input":1000,"output":200,"reasoning":0,"cache":{"read":300,"write":0}}}'; + +export const openCodeForkMessages = [ + { + id: "msg_usageproofuser0000000000001", + session_id: openCodeForkSourceSessionId, + type: "user", + seq: 1, + time_created: 1792619988283, + time_updated: 1792620016408, + data: userData, + }, + { + id: openCodeForkSourceAssistantId, + session_id: openCodeForkSourceSessionId, + type: "assistant", + seq: 2, + time_created: 1792619988284, + time_updated: 1792620016408, + data: assistantData, + }, + { + id: "msg_1607ab38e001GxURO0NU4GY4Az_1", + session_id: "ses_e9f854c73ffezSCt9xY4yA99bP", + type: "user", + seq: 1, + time_created: 1792619988283, + time_updated: 1792620016408, + data: userData, + }, + { + id: "msg_1607ab38e001GxURO0NU4GY4Az_2", + session_id: "ses_e9f854c73ffezSCt9xY4yA99bP", + type: "assistant", + seq: 2, + time_created: 1792619988284, + time_updated: 1792620016408, + data: assistantData, + }, + { + id: "msg_1607b8a6d001MA7a5GgmyUywMf_1", + session_id: openCodeForkContinuationSessionId, + type: "user", + seq: 1, + time_created: 1792619988283, + time_updated: 1792620016408, + data: userData, + }, + { + id: "msg_1607b8a6d001MA7a5GgmyUywMf_2", + session_id: openCodeForkContinuationSessionId, + type: "assistant", + seq: 2, + time_created: 1792619988284, + time_updated: 1792620016408, + data: assistantData, + }, + { + id: "msg_1607b8b990025w0634bKiaIXmD", + session_id: openCodeForkContinuationSessionId, + type: "location-switched", + seq: 6, + time_created: 1792620071833, + time_updated: 1792620071838, + data: '{"time":{"created":1792620071833},"location":{"directory":"/tmp/triage-23104"},"projectID":"98021609e205cae99a3b3545f6e3643a33b807b5","subpath":"","previous":{"location":{"directory":"/home/ubuntu/wt/issue-23104"},"projectID":"66c815350f7a94ca31c5664e5e983b8692210bdc","subpath":""}}', + }, + { + id: "msg_1607b8ba5001oCiDSgDjIcR3Nc", + session_id: openCodeForkContinuationSessionId, + type: "idle", + seq: 7, + time_created: 1792620071845, + time_updated: 1792620071846, + data: '{"time":{"created":1792620071845},"outcome":"succeeded"}', + }, +] as const; diff --git a/packages/provider-opencode/src/server/usage.test.ts b/packages/provider-opencode/src/server/usage.test.ts index 02d1e5dc8cc28..9be19daf97d9a 100644 --- a/packages/provider-opencode/src/server/usage.test.ts +++ b/packages/provider-opencode/src/server/usage.test.ts @@ -12,6 +12,14 @@ import * as Path from "effect/Path"; import * as SqlClient from "effect/sql/SqlClient"; import { vi } from "vite-plus/test"; +import { + openCodeForkContinuationSessionId, + openCodeForkMessages, + openCodeForkSchema, + openCodeForkSessions, + openCodeForkSourceAssistantId, + openCodeForkSourceSessionId, +} from "./usage.fixture.ts"; import { openCodeUsageReader, readOpenCodeUsage } from "./usage.ts"; const readerWorkers = vi.hoisted(() => ({ started: 0, enabled: false })); @@ -40,6 +48,57 @@ const openDatabase = (filename: string) => Effect.map(Context.get(SqlClient.SqlClient)), ); +const recordedForkDatabase = Effect.fn(function* ( + sessions: ReadonlyArray<{ + readonly id: string; + readonly project_id: string; + readonly fork_session_id: string | null; + readonly fork_boundary: string | null; + readonly slug: string; + readonly directory: string; + readonly version: string; + readonly time_created: number; + readonly time_updated: number; + }>, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "usage-reader-fork-test-" }); + const db = yield* openDatabase(path.join(dir, "opencode.db")); + for (const statement of openCodeForkSchema) yield* db.unsafe(statement); + for (const session of sessions) { + yield* db.unsafe( + "INSERT INTO session_v2 (id, project_id, fork_session_id, fork_boundary, slug, directory, version, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + session.id, + session.project_id, + session.fork_session_id, + session.fork_boundary, + session.slug, + session.directory, + session.version, + session.time_created, + session.time_updated, + ], + ); + } + for (const message of openCodeForkMessages) { + yield* db.unsafe( + "INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?, ?)", + [ + message.id, + message.session_id, + message.type, + message.seq, + message.time_created, + message.time_updated, + message.data, + ], + ); + } + return { dir, db }; +}); + describe("readOpenCodeUsage", () => { it.effect("bounds raw history allocations while counting every eligible message once", () => Effect.gen(function* () { @@ -308,6 +367,93 @@ describe("readOpenCodeUsage", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("counts an OpenCode reply once across the sessions forked from it", () => + Effect.gen(function* () { + const { dir, db } = yield* recordedForkDatabase(openCodeForkSessions); + // A reply sent after the second fork is a new provider request. + yield* db.unsafe( + "INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?, ?)", + [ + "msg_continuationassistant00001", + openCodeForkContinuationSessionId, + "assistant", + 8, + 1792620090000, + 1792620090500, + JSON.stringify({ + time: { created: 1792620090000, completed: 1792620090500 }, + agent: "build", + model: { id: "claude-sonnet-4-5", providerID: "anthropic" }, + content: [{ type: "text", text: "continued" }], + finish: "stop", + cost: 0.02, + tokens: { input: 50, output: 10, reasoning: 0, cache: { read: 1500, write: 0 } }, + }), + ], + ); + + const result = yield* readOpenCodeUsage(dir, 0); + assert.isFalse(result.error); + const records = result.files.flatMap((file) => file.records); + assert.deepStrictEqual( + records.map((record) => [record.dedupeKey, record.sessionId]), + [ + [`opencode:${openCodeForkSourceAssistantId}`, openCodeForkSourceSessionId], + ["opencode:msg_continuationassistant00001", openCodeForkContinuationSessionId], + ], + ); + assert.deepStrictEqual(records[0]?.totals, { + uncachedInputTokens: 1000, + cachedInputTokens: 300, + cacheCreationTokens: 0, + outputTokens: 200, + reasoningTokens: 0, + }); + assert.strictEqual(records[0]?.reportedCostUsd, 0.01); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("counts each OpenCode fork's own replies after its boundary", () => + Effect.gen(function* () { + const [source, fork] = openCodeForkSessions; + const sibling = { ...fork, id: "ses_siblingfork00000000000000", slug: "quiet-river" }; + const { dir, db } = yield* recordedForkDatabase([...openCodeForkSessions, sibling]); + const insert = (id: string, sessionId: string, seq: number, created: number, data: string) => + db.unsafe( + "INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES (?, ?, 'assistant', ?, ?, ?, ?)", + [id, sessionId, seq, created, created, data], + ); + const copied = openCodeForkMessages.find( + (message) => message.id === openCodeForkSourceAssistantId, + )!; + yield* insert("msg_siblingcopy_2", sibling.id, copied.seq, copied.time_created, copied.data); + // Sibling forks send identical replies at the same seq and millisecond. + const created = 1792620090000; + const reply = JSON.stringify({ ...JSON.parse(copied.data), time: { created } }); + yield* insert("msg_forkreply000000000000003", fork.id, 3, created, reply); + yield* insert("msg_siblingreply0000000000003", sibling.id, 3, created, reply); + + const records = (yield* readOpenCodeUsage(dir, 0)).files.flatMap((file) => file.records); + assert.deepStrictEqual( + records.map((record) => record.sessionId), + [source.id, fork.id, sibling.id], + ); + + // Deleting the source leaves the first fork holding the only copy. + yield* db.unsafe("DELETE FROM session_message WHERE session_id = ?", [source.id]); + yield* db.unsafe("DELETE FROM session_v2 WHERE id = ?", [source.id]); + const remaining = (yield* readOpenCodeUsage(dir, 0)).files.flatMap((file) => file.records); + assert.includeMembers( + remaining.map((record) => record.dedupeKey), + ["opencode:msg_1607ab38e001GxURO0NU4GY4Az_2", "opencode:msg_forkreply000000000000003"], + ); + assert.notInclude( + remaining.map((record) => record.dedupeKey), + "opencode:msg_1607b8a6d001MA7a5GgmyUywMf_2", + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect( "projects both SQLite formats without changing malformed or optional field handling", () => diff --git a/packages/provider-opencode/src/server/usage.ts b/packages/provider-opencode/src/server/usage.ts index 8d9bb28c70b34..041d05ee6e35e 100644 --- a/packages/provider-opencode/src/server/usage.ts +++ b/packages/provider-opencode/src/server/usage.ts @@ -204,15 +204,75 @@ export const readOpenCodeUsage = Effect.fn("readOpenCodeUsage")(function* ( )).map((row) => row.name), ); if (!tables.has("message") && !tables.has("session_message")) error = true; + const columnsOf = (table: string) => + sql + .unsafe<{ readonly name: unknown }>(`PRAGMA table_info(${table})`) + .pipe(Effect.map((rows) => new Set(rows.map((row) => row.name)))); + // A fork copies its parent's messages through the boundary under new ids, keeping + // seq, creation time and data. Resolve each fork to its last copied seq so a copy + // names its original as (session, seq). A fork whose boundary is gone keeps its usage. + const forks = new Map< + string, + { readonly parent: string; readonly lastCopiedSeq: number; readonly created: number } + >(); + if ( + tables.has("session_v2") && + tables.has("session_message") && + (yield* columnsOf("session_message")).has("seq") && + (yield* columnsOf("session_v2")).has("fork_boundary") + ) { + const resolved = yield* sql.unsafe<{ + readonly id: unknown; + readonly parent: unknown; + readonly created: unknown; + readonly last_copied_seq: unknown; + }>( + `SELECT fork.id, fork.fork_session_id AS parent, fork.time_created AS created, + (SELECT MAX(copied.seq) FROM session_message AS copied + WHERE copied.session_id = fork.fork_session_id + AND CASE json_extract(fork.fork_boundary, '$.type') + WHEN 'through' THEN copied.seq <= boundary.seq + WHEN 'before' THEN copied.seq < boundary.seq + END) AS last_copied_seq + FROM session_v2 AS fork + JOIN session_message AS boundary + ON boundary.session_id = fork.fork_session_id + AND boundary.id = json_extract(fork.fork_boundary, '$.messageID') + WHERE fork.fork_session_id IS NOT NULL AND json_valid(fork.fork_boundary)`, + ); + for (const row of resolved) { + if (typeof row.last_copied_seq !== "number" || typeof row.created !== "number") continue; + forks.set(text(row.id), { + parent: text(row.parent), + lastCopiedSeq: row.last_copied_seq, + created: row.created, + }); + } + } + const original = (sessionId: string, seq: number, created: number) => { + const visited = new Set(); + let session = sessionId; + for ( + let fork = forks.get(session); + fork !== undefined && + seq <= fork.lastCopiedSeq && + created < fork.created && + !visited.has(session); + fork = forks.get(session) + ) { + visited.add(session); + session = fork.parent; + } + return `${session}:${seq}`; + }; + const originals = new Set(); for (const table of ["message", "session_message"] as const) { if (!tables.has(table)) continue; - const columns = new Set( - (yield* sql.unsafe<{ readonly name: unknown }>(`PRAGMA table_info(${table})`)).map( - (row) => row.name, - ), - ); + const columns = yield* columnsOf(table); const timestamp = columns.has("time_created") ? "time_created" : "NULL"; const updated = columns.has("time_updated") ? "time_updated" : "NULL"; + const lineage = + table === "session_message" && forks.size > 0 && columns.has("seq") ? "seq" : "NULL"; // Without time_updated an in-place edit can keep the length, so any database write counts. const version = updated === "NULL" ? `${identity}/${stamp(wal!)}` : identity; const predicates = table === "session_message" ? ["type = 'assistant'"] : []; @@ -224,9 +284,10 @@ export const readOpenCodeUsage = Effect.fn("readOpenCodeUsage")(function* ( readonly updated: unknown; readonly size: unknown; readonly created: unknown; + readonly seq: unknown; }>( `SELECT rowid AS row_id, ${updated} AS updated, octet_length(data) AS size, - ${timestamp} AS created + ${timestamp} AS created, ${lineage} AS seq FROM ${table} ${predicates.length ? `WHERE ${predicates.join(" AND ")}` : ""}`, timestamp === "NULL" ? [] : [sinceMs], @@ -283,11 +344,24 @@ export const readOpenCodeUsage = Effect.fn("readOpenCodeUsage")(function* ( } // Rows the SQL filter dropped are cached as empty so they are not read again either. for (const row of page) { - cached.set(row.row_id, { ...row, record: parsed.get(row.row_id) ?? null }); + cached.set(row.row_id, { + updated: row.updated, + size: row.size, + created: row.created, + record: parsed.get(row.row_id) ?? null, + }); } yield* Effect.yieldNow; } - for (const row of listed) append(file.records, cached.get(row.row_id)!.record); + for (const row of listed) { + const record = cached.get(row.row_id)!.record; + if (record !== null && typeof row.seq === "number") { + const copyOf = original(record.sessionId, row.seq, record.timestampMs); + if (originals.has(copyOf)) continue; + originals.add(copyOf); + } + append(file.records, record); + } // Rows before this window were not listed; keep them for a wider window until retention. for (const [rowId, entry] of reusable ?? []) { const created = entry.created; From d70bfe6e97158cd47435d3f173e7d5bc35e4e562 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 09:54:57 -0800 Subject: [PATCH 013/409] fix(server): a send to a thread whose worktree can't be recreated now fails instead of reporting started (#36251) --- apps/server/src/mcp/OrchestratorMcpService.ts | 1 + .../ProviderTurnStartService.test.ts | 48 +++++++++ .../ThreadManagementService.test.ts | 100 ++++++++++++++++++ .../ThreadManagementService.ts | 44 ++++++++ .../orchestration-v2/ThreadMessageIntake.ts | 1 + .../src/orchestration-v2/runtimeLayer.ts | 12 +-- .../src/workspace/restoreMissingWorktree.ts | 30 +++++- 7 files changed, 229 insertions(+), 7 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index e94168b7e06ff..e3cbb4aca9417 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -251,6 +251,7 @@ function threadManagementFailure(error: unknown): OrchestratorMcpFailure { return failure("run_not_found", error.message); case "ThreadManagementThreadArchivedError": case "ThreadManagementNoSteerableRunError": + case "ThreadManagementWorktreeRestoreError": return failure("thread_not_sendable", error.message); case "ThreadManagementThreadNotInterruptibleError": return failure("thread_not_interruptible", error.message); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index efb12a224e015..29b3e51ecbbff 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -40,6 +40,10 @@ import { withWorkspaceLease, } from "../workspace/workspaceLease.ts"; import * as GitWorkflow from "../git/GitWorkflowService.ts"; +import { + layerThreadWorktreeRestore, + ThreadWorktreeRestore, +} from "../workspace/restoreMissingWorktree.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import { layerRealGit } from "./ProviderTurnStartService.testkit.ts"; import * as ProjectService from "../project/ProjectService.ts"; @@ -272,6 +276,50 @@ it.each([ expect(harness.createWorktree).toHaveBeenCalledTimes(input.recreated ? 1 : 0); }); +const restoreLeaseExists = vi.fn(() => Effect.succeed(false)); +const restoreLeaseCreateWorktree = vi.fn(() => Effect.succeed({} as never)); +const layerRestoreLeaseTest = layerThreadWorktreeRestore.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(FileSystem.FileSystem, { + exists: restoreLeaseExists, + realPath: (target: string) => Effect.succeed(normalize(target)), + } as never), + layerWorktreeLocation(), + Layer.mock(GitWorkflow.GitWorkflowService)({ + pruneWorktrees: () => Effect.void, + createWorktree: restoreLeaseCreateWorktree, + listLocalBranchNames: () => Effect.succeed(["feature/restore"]), + }), + Layer.mock(ProjectService.ProjectService)({ + getById: () => + Effect.succeedSome({ workspaceRoot: "/tmp/provider-turn-start-project" } as never), + }), + ), + ), +); + +effectIt.effect("restores a missing worktree before a send only under the workspace lease", () => + Effect.gen(function* () { + const worktreePath = `${managedWorktreesDir}/project/feature-restore`; + const { restore } = yield* ThreadWorktreeRestore; + yield* withWorkspaceLease( + worktreePath, + Effect.gen(function* () { + const restoring = yield* restore({ + id: ThreadId.make("thread_send_restore_lease"), + projectId: ProjectId.make("project_send_restore_lease"), + worktreePath, + branch: "feature/restore", + }).pipe(Effect.forkChild({ startImmediately: true })); + expect(restoreLeaseExists).not.toHaveBeenCalled(); + return restoring; + }), + ).pipe(Effect.flatMap(Fiber.join)); + expect(restoreLeaseCreateWorktree).toHaveBeenCalledTimes(1); + }).pipe(Effect.provide(layerRestoreLeaseTest)), +); + function makeLocalCommandHarness(input: { readonly text: string; readonly nativeGoals?: boolean; diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 7056e07303c31..c2502902ef8e3 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -38,6 +38,11 @@ import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; +import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; +import { + ThreadWorktreeRestore, + WorktreeRecreateError, +} from "../workspace/restoreMissingWorktree.ts"; it("stamps authoritative provenance on commands that create threads or messages", () => { const command: OrchestrationV2Command = { @@ -199,6 +204,101 @@ it.effect( }, ); +it.effect.each([ + { label: "fails a send that starts a turn", running: false }, + { label: "still steers a running turn", running: true }, +])("sendToThread with a missing worktree that cannot be recreated $label", ({ running }) => { + const projectId = ProjectId.make("project:thread-management:worktree-gone"); + const threadId = ThreadId.make("thread:thread-management:worktree-gone"); + const dispatched: Array = []; + const layerTest = ThreadManagementService.layerWithLegacyImporter.pipe( + Layer.provide( + Layer.succeed(ThreadWorktreeRestore, { + restore: () => + Effect.fail( + new WorktreeRecreateError({ + worktreePath: "/worktrees/gone", + branch: "t3/gone", + cause: new Error("branch_checked_out_in_worktree"), + }), + ), + }), + ), + Layer.provide( + Layer.mock(LegacyV1ThreadImporter.LegacyV1ThreadImporter)({ + ensureTranscript: () => Effect.succeed({ importedThreadCount: 0, importedMessageCount: 0 }), + }), + ), + Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), + Layer.provide( + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadRecords: () => + Effect.succeed({ + thread: { + id: threadId, + projectId, + deletedAt: null, + archivedAt: null, + worktreePath: "/worktrees/gone", + branch: "t3/gone", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "test" }, + }, + runs: running + ? [ + { + id: RunId.make("run:thread-management:worktree-gone"), + status: "running", + activeAttemptId: "attempt:thread-management:worktree-gone", + }, + ] + : [], + providerTurns: running + ? [ + { + runAttemptId: "attempt:thread-management:worktree-gone", + status: "running", + }, + ] + : [], + messages: [], + turnItems: [], + } as unknown as OrchestrationV2ThreadProjection), + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command.type); + return { sequence: 1, storedEvents: [] } as never; + }), + }), + ), + Layer.provide(ServerSettings.layerTest()), + Layer.provideMerge(layerReceiptsTest), + ); + + return Effect.gen(function* () { + const threads = yield* ThreadManagementService.ThreadManagementService; + const error = yield* Effect.flip( + threads.sendToThread({ + projectId, + threadId, + commandId: CommandId.make("command:thread-management:worktree-gone"), + messageId: MessageId.make("message:thread-management:worktree-gone"), + text: "Reply with ok.", + attachments: [], + mode: "auto", + createdBy: "agent", + creationSource: "mcp", + }), + ); + // The mock records no message, so a dispatched send ends in the projection read. + expect(error._tag).toBe( + running + ? "ThreadManagementDurableRunProjectionError" + : "ThreadManagementWorktreeRestoreError", + ); + expect(dispatched).toEqual(running ? ["message.dispatch"] : []); + }).pipe(Effect.provide(layerTest)); +}); + it.effect("waitForThread follows a restart continuation instead of the cut run", () => Effect.gen(function* () { const projectId = ProjectId.make("project:thread-management:wait-restart"); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index e821096f6c1d7..00e81061f7a13 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -69,6 +69,7 @@ import { DispatchModeLimit, exceededDispatchModeLimit } from "./DispatchModeLimi import { projectTurnItemForDetail } from "./WireProjection.ts"; import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; import * as ServerSettings from "../serverSettings.ts"; +import { ThreadWorktreeRestore } from "../workspace/restoreMissingWorktree.ts"; export type ThreadManagementSendMode = "auto" | "queue" | "steer" | "restart"; @@ -316,6 +317,19 @@ export class ThreadManagementDurableRunProjectionError extends Schema.TaggedErro } } +export class ThreadManagementWorktreeRestoreError extends Schema.TaggedError()( + "ThreadManagementWorktreeRestoreError", + { + threadId: ThreadId, + cause: Schema.Defect(), + }, +) { + override get message(): string { + const detail = this.cause instanceof Error ? ` ${this.cause.message}` : ""; + return `Couldn't recreate the missing workspace of thread ${this.threadId}, so the message was not sent.${detail}`; + } +} + export class ThreadManagementProviderInstanceDisabledError extends Schema.TaggedError()( "ThreadManagementProviderInstanceDisabledError", { @@ -360,6 +374,7 @@ export const ThreadManagementError = Schema.Union([ ThreadManagementProjectThreadsListError, ThreadManagementDurableRunProjectionError, ThreadManagementProviderInstanceDisabledError, + ThreadManagementWorktreeRestoreError, ]); export type ThreadManagementError = typeof ThreadManagementError.Type; @@ -592,6 +607,7 @@ const make = Effect.gen(function* () { const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; const serverSettings = yield* ServerSettings.ServerSettingsService; const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2; + const worktreeRestore = yield* ThreadWorktreeRestore; const assertProviderInstanceEnabled: ThreadManagementServiceShape["assertProviderInstanceEnabled"] = (input) => @@ -873,6 +889,26 @@ const make = Effect.gen(function* () { }; } + // Turn start would fail on its own after the send reported success; a replay keeps its answer. + // A live steer starts no turn, so it needs no workspace. + if (dispatchMode.type !== "steer_active") { + yield* worktreeRestore + .restore(target.thread) + .pipe( + Effect.catch((cause) => + hasReceipt(input.commandId).pipe( + Effect.flatMap((replay) => + replay + ? Effect.void + : Effect.fail( + new ThreadManagementWorktreeRestoreError({ threadId: input.threadId, cause }), + ), + ), + ), + ), + ); + } + const dispatched = yield* dispatch({ type: "message.dispatch", commandId: input.commandId, @@ -1719,6 +1755,12 @@ const make = Effect.gen(function* () { }); }); +const layerThreadWorktreeRestoreNoop = Layer.succeed(ThreadWorktreeRestore, { + restore: Effect.fn("ThreadWorktreeRestore.restore")(function () { + return Effect.void; + }), +}); + const layerLegacyV1ThreadImporterNoop = Layer.succeed( LegacyV1ThreadImporter.LegacyV1ThreadImporter, LegacyV1ThreadImporter.LegacyV1ThreadImporter.of({ @@ -1740,6 +1782,7 @@ export const layer: Layer.Layer< | CommandReceiptStore.CommandReceiptStoreV2 > = Layer.effect(ThreadManagementService, make).pipe( Layer.provide(layerLegacyV1ThreadImporterNoop), + Layer.provide(layerThreadWorktreeRestoreNoop), Layer.provide(ThreadCommandExecutor.layer), ); @@ -1747,6 +1790,7 @@ export const layerWithLegacyImporter: Layer.Layer< ThreadManagementService, never, | LegacyV1ThreadImporter.LegacyV1ThreadImporter + | ThreadWorktreeRestore | Orchestrator.OrchestratorV2 | ProviderSessionManager.ProviderSessionManagerV2 | ServerSettings.ServerSettingsService diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts index a48cc3ca7cd90..c6a6cdbf7ac1e 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts @@ -27,6 +27,7 @@ function dispatchWasNotAccepted( case "OrchestratorProviderSelectionBusyError": case "OrchestratorRuntimeModeAboveCeilingError": case "ThreadManagementProviderInstanceDisabledError": + case "ThreadManagementWorktreeRestoreError": return true; default: return false; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 0cfa599ed5486..6866633397168 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -47,6 +47,7 @@ import * as ProviderRuntimeRecoveryService from "./ProviderRuntimeRecoveryServic import * as ProviderSwitchService from "./ProviderSwitchService.ts"; import * as ProviderTurnControlService from "./ProviderTurnControlService.ts"; import * as ProviderTurnStartService from "./ProviderTurnStartService.ts"; +import { layerThreadWorktreeRestore } from "../workspace/restoreMissingWorktree.ts"; import * as RunExecutionService from "./RunExecutionService.ts"; import * as RunFinalizationService from "./RunFinalizationService.ts"; import * as ResourceCleanupService from "./ResourceCleanupService.ts"; @@ -267,6 +268,7 @@ const layerThreadManagementProvided = ThreadManagementService.layerWithLegacyImp layerLegacyV1ThreadImporterProvided, layerCommandReceiptStoreProvided, layerProviderSessionManagerProvided, + layerThreadWorktreeRestore, ), ), ); @@ -281,14 +283,13 @@ const layerManagedProjectFoldersProvided = ManagedProjectFolders.layer.pipe( const layerThreadLaunchProvided = ThreadLaunchService.layer.pipe( Layer.provide( Layer.mergeAll( - layerProjectService, layerProjectSetupScriptRunner, layerManagedProjectFoldersProvided, layerThreadManagementProvided, layerCommandReceiptStoreProvided, layerEventSink, IdAllocator.layer, - ), + ).pipe(Layer.provideMerge(layerProjectService)), ), ); const layerSecretRequestsProvided = SecretRequests.layer.pipe( @@ -307,11 +308,10 @@ const layerScheduledTaskProvided = ScheduledTaskService.layer.pipe( const layerScratchThreadPromotionProvided = ScratchThreadPromotion.layer.pipe( Layer.provide( Layer.mergeAll( - layerProjectService, layerManagedProjectFoldersProvided, layerThreadManagementProvided, layerScheduledTaskProvided, - ), + ).pipe(Layer.provideMerge(layerProjectService)), ), ); const layerProviderContinuationWorkerProvided = ProviderContinuationService.layer.pipe( @@ -389,8 +389,7 @@ export const layerProduction = Layer.mergeAll( WorktreeBranchSync.layer.pipe( Layer.provide(Layer.merge(layerThreadManagementProvided, ProjectionStore.layer)), ), - layer.pipe(Layer.provide(layerProjectService)), - layerProjectService, + layer, layerManagedProjectFoldersProvided, layerThreadLaunchProvided, layerScheduledTaskProvided, @@ -414,6 +413,7 @@ export const layerProduction = Layer.mergeAll( EffectOutbox.layerPruneWorker.pipe(Layer.provide(EffectOutbox.layer)), ).pipe( Layer.provide(ResourceCleanupService.layer.pipe(Layer.provide(layerScheduledTaskProvided))), + Layer.provideMerge(layerProjectService), Layer.provide(Scheduler.layer), Layer.provideMerge(layerEventInfrastructure), ); diff --git a/apps/server/src/workspace/restoreMissingWorktree.ts b/apps/server/src/workspace/restoreMissingWorktree.ts index 4bc1ac8f533c8..21b5e0e422267 100644 --- a/apps/server/src/workspace/restoreMissingWorktree.ts +++ b/apps/server/src/workspace/restoreMissingWorktree.ts @@ -1,6 +1,8 @@ import { ThreadId, type ProjectId, type TerminalCwdNotFoundError } from "@t3tools/contracts"; +import * as Context from "effect/Context"; 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 Schema from "effect/Schema"; @@ -9,9 +11,10 @@ import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import { makeIsManagedWorktreePath } from "../worktreesDirectory.ts"; import { isWithinRoot } from "./KnownWorkspaces.ts"; +import { withWorkspaceLease } from "./workspaceLease.ts"; import { TerminalCwdRecovery } from "../terminal/Manager.ts"; -class WorktreeRecreateError extends Schema.TaggedError()( +export class WorktreeRecreateError extends Schema.TaggedError()( "WorktreeRecreateError", { worktreePath: Schema.String, @@ -166,6 +169,31 @@ export const makeRestoreMissingWorktree = Effect.gen(function* () { }); }); +export class ThreadWorktreeRestore extends Context.Service< + ThreadWorktreeRestore, + { + readonly restore: ( + thread: WorktreeThread, + ) => Effect.Effect; + } +>()("t3/workspace/restoreMissingWorktree/ThreadWorktreeRestore") {} + +// Turn start restores under the same lease, so concurrent restores see each other's worktree. +export const layerThreadWorktreeRestore = Layer.effect( + ThreadWorktreeRestore, + Effect.gen(function* () { + const path = yield* Path.Path; + const restore = yield* makeRestoreMissingWorktree; + return { + restore: Effect.fn("ThreadWorktreeRestore.restore")(function* (thread: WorktreeThread) { + return yield* thread.worktreePath === null + ? restore(thread) + : withWorkspaceLease(path.resolve(thread.worktreePath), restore(thread)); + }), + }; + }), +); + // Worktree setup uses terminals, so recovery must stay outside TerminalManager. export const makePrepareTerminalLaunch = Effect.fn( "restoreMissingWorktree.makePrepareTerminalLaunch", From 1ea523608b4fdcf3794fdcdf01c376faac21f594 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 09:55:26 -0800 Subject: [PATCH 014/409] fix(mobile): HTML file previews play video inline on iPhone (#32343) --- apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx b/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx index 0c40fc4e088b4..b962d716b992a 100644 --- a/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx @@ -63,6 +63,7 @@ function DocumentWebPreview(props: { readonly uri: string }) { originWhitelist={["*"]} allowsBackForwardNavigationGestures allowsFullscreenVideo + allowsInlineMediaPlayback setSupportMultipleWindows={false} onShouldStartLoadWithRequest={(request) => { if (request.isTopFrame !== false && isPdfPreviewUri(request.url)) { From 2953fda660c2e5e327a1f5e52f742a59980af618 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 09:57:27 -0800 Subject: [PATCH 015/409] fix(server): Antigravity skill bodies no longer exhaust the skill scan budget (#32182) --- .../Drivers/AntigravitySkills.test.ts | 40 ++++++++++++--- .../src/provider/Drivers/AntigravitySkills.ts | 49 ++++++++++++++----- 2 files changed, 71 insertions(+), 18 deletions(-) diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts index 5060d8a24e5ed..c81b38a00dbcb 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts @@ -273,15 +273,43 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { }), ); - it.effect("rejects an oversized skill instead of returning an incomplete catalog", () => + it.effect("does not spend the scan budget on skill bodies", () => Effect.gen(function* () { const path = yield* Path.Path; const input = yield* makeWorkspace(); - const skillPath = yield* writeSkill( - path.join(input.cwd, ".agents", "skills", "oversized"), - `---\nname: oversized\ndescription: Read a large skill.\n---\n${"x".repeat(1_000_000)}`, + const root = path.join(input.userHome, ".gemini", "config", "skills"); + const expected = []; + for (let index = 0; index < 9; index += 1) { + const name = `large-${index}`; + const description = `Large skill ${index}. ${"y".repeat(2_000)}`; + const skillPath = yield* writeSkill( + path.join(root, name), + `---\nname: ${name}\ndescription: ${description}\n---\n${"x".repeat(950_000)}`, + ); + expected.push({ name, description, path: skillPath, scope: "user", enabled: true }); + } + + assert.deepEqual(yield* discoverAntigravitySkills(input), expected); + }), + ); + + it.effect("rejects frontmatter that does not close within the per-skill limit", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const root = path.join(input.cwd, ".agents", "skills"); + const largeBodyPath = yield* writeSkill( + path.join(root, "large-body"), + `---\nname: large-body\n---\n${"x".repeat(2_000_000)}`, ); + assert.deepEqual(yield* discoverAntigravitySkills(input), [ + { name: "large-body", path: largeBodyPath, scope: "project", enabled: true }, + ]); + const skillPath = yield* writeSkill( + path.join(root, "oversized"), + `---\nname: oversized\ndescription: ${"x".repeat(1_000_000)}\n---\n`, + ); const result = yield* discoverAntigravitySkills(input).pipe(Effect.result); assert.equal(result._tag, "Failure"); if (result._tag === "Failure") { @@ -291,7 +319,7 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { }), ); - it.effect("bounds the total read size across skills", () => + it.effect("bounds the total frontmatter read size across skills", () => Effect.gen(function* () { const path = yield* Path.Path; const input = yield* makeWorkspace(); @@ -299,7 +327,7 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { for (let index = 0; index < 9; index += 1) { yield* writeSkill( path.join(root, `large-${index}`), - `---\nname: large-${index}\n---\n${"x".repeat(900_000)}`, + `---\nname: large-${index}\ndescription: ${"x".repeat(900_000)}\n---\n`, ); } diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.ts b/apps/server/src/provider/Drivers/AntigravitySkills.ts index 19e9d8d293f48..96e1fe61109f4 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.ts @@ -37,6 +37,8 @@ export function antigravityUserSkillDirectories( const MAX_SKILL_BYTES = 1_000_000; const MAX_SCAN_BYTES = 8_000_000; const MAX_SCAN_ENTRIES = 10_000; +// Typical frontmatter fits in one chunk, so most skills cost about this much of the scan budget. +const SKILL_READ_CHUNK_BYTES = 1024; const SkillFrontmatter = Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)), @@ -106,7 +108,29 @@ function skillPathSortKey(entry: string) { ); } -const readSkill = Effect.fn("readAntigravitySkill")(function* ( +const DASH = 0x2d; + +/** Finds the byte offset after the second `---`, as parseSkillFrontmatter's indexOf calls do. */ +function makeFrontmatterEndFinder() { + let bytesSeen = 0; + let dashes = 0; + let markers = 0; + return (chunk: Uint8Array) => { + for (const byte of chunk) { + bytesSeen += 1; + dashes = byte === DASH ? dashes + 1 : 0; + if (dashes === 3) { + dashes = 0; + markers += 1; + if (markers === 2) return bytesSeen; + } + } + return undefined; + }; +} + +/** Reads through the frontmatter only, so skill bodies do not spend the scan budget. */ +const readSkillFrontmatter = Effect.fn("readAntigravitySkill")(function* ( skillPath: string, budget: ScanBudget, ) { @@ -115,26 +139,27 @@ const readSkill = Effect.fn("readAntigravitySkill")(function* ( if (info?.type !== "File") return undefined; const byteLimit = Math.min(MAX_SKILL_BYTES, budget.remainingBytes); - if (info.size > BigInt(byteLimit)) { - return yield* new AntigravitySkillsProbeError({ - reason: "scan-budget-exhausted", - path: skillPath, - }); - } + const findFrontmatterEnd = makeFrontmatterEndFinder(); + let frontmatterEnd: number | undefined; const chunks = yield* readIfPresent( - fileSystem.stream(skillPath, { bytesToRead: byteLimit + 1 }).pipe(Stream.runCollect), + fileSystem + .stream(skillPath, { bytesToRead: byteLimit + 1, chunkSize: SKILL_READ_CHUNK_BYTES }) + .pipe( + Stream.takeUntil((chunk) => (frontmatterEnd = findFrontmatterEnd(chunk)) !== undefined), + Stream.runCollect, + ), skillPath, ); if (chunks === undefined) return undefined; const bytes = Buffer.concat(chunks); - if (bytes.byteLength > byteLimit) { + budget.remainingBytes -= bytes.byteLength; + if ((frontmatterEnd ?? bytes.byteLength) > byteLimit) { return yield* new AntigravitySkillsProbeError({ reason: "scan-budget-exhausted", path: skillPath, }); } - budget.remainingBytes -= bytes.byteLength; - return bytes.toString("utf8"); + return bytes.toString("utf8", 0, frontmatterEnd); }); /** Keep read errors typed so workspace snapshots cannot cache partial results. */ @@ -187,7 +212,7 @@ export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")( if (skillFileName !== undefined) { if (!skillFileName.endsWith(".md")) return; const skillPath = path.join(directory, skillFileName); - const contents = yield* readSkill(skillPath, budget); + const contents = yield* readSkillFrontmatter(skillPath, budget); if (contents === undefined) return; const skill = parseSkillFrontmatter(contents, skillFileName); if (!skill || skillsByName.has(skill.name)) return; From 76d0fff5fd030eb654ef8316e84c5bf3c3616578 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 10:04:02 -0800 Subject: [PATCH 016/409] fix(server): include symlinks in workspace search (#24578) --- .../src/workspace/IgnorePatterns.test.ts | 56 +++ apps/server/src/workspace/IgnorePatterns.ts | 170 ++++++++ .../src/workspace/WorkspaceEntries.test.ts | 127 ++++++ .../workspace/WorkspaceSearchIndex.test.ts | 248 +++++++++++ .../src/workspace/WorkspaceSearchIndex.ts | 394 +++++++++++++++++- 5 files changed, 989 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/workspace/IgnorePatterns.test.ts create mode 100644 apps/server/src/workspace/IgnorePatterns.ts diff --git a/apps/server/src/workspace/IgnorePatterns.test.ts b/apps/server/src/workspace/IgnorePatterns.test.ts new file mode 100644 index 0000000000000..f10345566eca5 --- /dev/null +++ b/apps/server/src/workspace/IgnorePatterns.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { parseIgnoreFile } from "./IgnorePatterns.ts"; + +const decide = (contents: string, path: string, isFolder = false) => + parseIgnoreFile(contents).decide(path, isFolder); + +describe("parseIgnoreFile", () => { + it("lets the last matching pattern decide, including negations", () => { + expect(decide("*keep*\n!*keep*\n", "sub/keep-link.ts")).toBe("included"); + expect(decide("!*keep*\n*keep*\n", "sub/keep-link.ts")).toBe("ignored"); + expect(decide("# comment\n\nother\n", "keep.ts")).toBeUndefined(); + }); + + it("anchors patterns that contain a slash and matches bare names at any depth", () => { + expect(decide("link", "a/b/link")).toBe("ignored"); + expect(decide("/link", "a/link")).toBeUndefined(); + expect(decide("/link", "link")).toBe("ignored"); + expect(decide("a/link", "x/a/link")).toBeUndefined(); + expect(decide("a/*.ts", "a/b/c.ts")).toBeUndefined(); + expect(decide("link", "blink")).toBeUndefined(); + expect(decide("link", "a/blink")).toBeUndefined(); + }); + + it("supports globstars, classes, escapes, and folder-only patterns", () => { + expect(decide("**/gen/*.ts", "gen/a.ts")).toBe("ignored"); + expect(decide("a/**/b", "a/x/y/b")).toBe("ignored"); + expect(decide("a/**/b", "a/b")).toBe("ignored"); + expect(decide("foo/**/bar", "foo/xbar")).toBeUndefined(); + expect(decide("foo/**/bar", "foo/x/ybar")).toBeUndefined(); + expect(decide("out/**", "out/x/y")).toBe("ignored"); + expect(decide("link-[0-9]", "link-7")).toBe("ignored"); + expect(decide("link-[!0-9]", "link-7")).toBeUndefined(); + expect(decide("link-?", "link-/")).toBeUndefined(); + expect(decide("\\!bang\n\\#hash\nstar\\*", "!bang")).toBe("ignored"); + expect(decide("\\!bang\n\\#hash\nstar\\*", "#hash")).toBe("ignored"); + expect(decide("\\!bang\n\\#hash\nstar\\*", "starx")).toBeUndefined(); + expect(decide("dist/", "dist")).toBeUndefined(); + expect(decide("dist/", "dist", true)).toBe("ignored"); + }); + + it("matches adversarial patterns in linear time", () => { + // A backtracking regex takes seconds to reject this pair; the vitest timeout catches that. + const pattern = `${"a*".repeat(20)}b\n`; + expect(decide(pattern, `${"a".repeat(200)}c`)).toBeUndefined(); + expect(decide(pattern.repeat(50), `${"a".repeat(1_000)}c`)).toBeUndefined(); + expect(decide(pattern, `${"a".repeat(200)}b`)).toBe("ignored"); + }); + + it("parses unclosed classes in linear time", () => { + // Rescanning the rest of the line for each `[` stalls for over a minute here; linear takes ms. + expect(decide("[".repeat(30_000), "[".repeat(30_000))).toBe("ignored"); + expect(decide(`${"[".repeat(30_000)}]`, "x")).toBeUndefined(); + expect(decide("[a][", "a[")).toBe("ignored"); + }); +}); diff --git a/apps/server/src/workspace/IgnorePatterns.ts b/apps/server/src/workspace/IgnorePatterns.ts new file mode 100644 index 0000000000000..1180b02316ef9 --- /dev/null +++ b/apps/server/src/workspace/IgnorePatterns.ts @@ -0,0 +1,170 @@ +/** + * Gitignore-syntax matching for `.ignore` files, which git cannot evaluate for us. Patterns come + * from the repository, so each runs as an NFA over the path (O(pattern × path)): a backtracking + * regex stalls for seconds on `a*a*a*…b`. POSIX `[:class:]` names are not supported. + */ + +type Token = + | { readonly type: "char"; readonly char: string } + | { readonly type: "one" } + | { readonly type: "class"; readonly negated: boolean; readonly ranges: ReadonlyArray } + | { readonly type: "star" } + | { readonly type: "any" } + /** A leading or inner `**` segment: nothing, or anything ending in `/`. */ + | { readonly type: "folders" }; + +interface Pattern { + readonly tokens: ReadonlyArray; + readonly negated: boolean; + readonly folderOnly: boolean; +} + +export type IgnoreDecision = "ignored" | "included"; + +function parseClass(chars: ReadonlyArray, start: number) { + let index = start + 1; + const negated = chars[index] === "!" || chars[index] === "^"; + if (negated) index += 1; + const ranges: string[] = []; + for (let first = true; index < chars.length; first = false) { + let char = chars[index]!; + if (char === "]" && !first) + return { token: { type: "class", negated, ranges } as const, end: index }; + if (char === "\\" && index + 1 < chars.length) char = chars[++index]!; + if (chars[index + 1] === "-" && index + 2 < chars.length && chars[index + 2] !== "]") { + let last = chars[index + 2]!; + index += 2; + if (last === "\\" && index + 1 < chars.length) last = chars[++index]!; + ranges.push(char, last); + } else { + ranges.push(char, char); + } + index += 1; + } + return undefined; +} + +function parseLine(rawLine: string): Pattern | undefined { + let line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + // Trailing spaces are dropped unless escaped. + while (line.endsWith(" ") && !line.endsWith("\\ ")) line = line.slice(0, -1); + if (line === "" || line.startsWith("#")) return undefined; + const negated = line.startsWith("!"); + if (negated) line = line.slice(1); + else if (line.startsWith("\\!") || line.startsWith("\\#")) line = line.slice(1); + const folderOnly = line.endsWith("/") && !line.endsWith("\\/"); + if (folderOnly) line = line.slice(0, -1); + if (line === "") return undefined; + const anchored = line.slice(0, -1).includes("/"); + if (line.startsWith("/")) line = line.slice(1); + + const chars = Array.from(line); + const tokens: Token[] = anchored ? [] : [{ type: "folders" }]; + // A `[` that never closes leaves every later `[` unclosed too, so one failed scan settles them + // all; rescanning the suffix for each would make `[[[[…` quadratic. + let classesClose = true; + for (let index = 0; index < chars.length; index += 1) { + const char = chars[index]!; + if (char === "*" && chars[index + 1] === "*") { + const atSegmentStart = index === 0 || chars[index - 1] === "/"; + const next = chars[index + 2]; + if (atSegmentStart && next === "/") { + tokens.push({ type: "folders" }); + index += 2; + continue; + } + if (atSegmentStart && next === undefined) { + tokens.push({ type: "any" }); + index += 1; + continue; + } + } + if (char === "*") tokens.push({ type: "star" }); + else if (char === "?") tokens.push({ type: "one" }); + else if (char === "[") { + const parsed = classesClose ? parseClass(chars, index) : undefined; + if (!parsed) classesClose = false; + if (parsed) { + tokens.push(parsed.token); + index = parsed.end; + } else tokens.push({ type: "char", char }); + } else if (char === "\\" && index + 1 < chars.length) { + tokens.push({ type: "char", char: chars[++index]! }); + } else tokens.push({ type: "char", char }); + } + return { tokens, negated, folderOnly }; +} + +/** + * States are token positions. A `folders` token that has consumed part of a segment is `~position` + * instead: it may only finish after the next `/`, so an inner globstar never matches `xbar` as `bar`. + */ +function matches(tokens: ReadonlyArray, path: ReadonlyArray): boolean { + let states = new Set(); + const add = (set: Set, position: number) => { + // Wildcards also match nothing, so reaching one reaches the token after it. + for (; position <= tokens.length && !set.has(position); position += 1) { + set.add(position); + const type = tokens[position]?.type; + if (type !== "star" && type !== "any" && type !== "folders") return; + } + }; + add(states, 0); + for (const char of path) { + const next = new Set(); + for (const state of states) { + const position = state < 0 ? ~state : state; + const token = tokens[position]; + if (!token) continue; + switch (token.type) { + case "char": + if (token.char === char) add(next, position + 1); + break; + case "one": + if (char !== "/") add(next, position + 1); + break; + case "class": { + let inRange = false; + for (let index = 0; index < token.ranges.length && !inRange; index += 2) { + inRange = token.ranges[index]! <= char && char <= token.ranges[index + 1]!; + } + if (char !== "/" && inRange !== token.negated) add(next, position + 1); + break; + } + case "star": + if (char !== "/") add(next, position); + break; + case "any": + add(next, position); + break; + case "folders": + if (char === "/") add(next, position); + else next.add(~position); + break; + } + } + if (next.size === 0) return false; + states = next; + } + return states.has(tokens.length); +} + +export interface IgnoreFile { + /** The last pattern that matches decides; `undefined` when none does. */ + readonly decide: (relativePath: string, isFolder: boolean) => IgnoreDecision | undefined; +} + +export function parseIgnoreFile(contents: string): IgnoreFile { + const patterns = contents.split("\n").flatMap((line) => parseLine(line) ?? []); + return { + decide: (relativePath, isFolder) => { + const path = Array.from(relativePath); + for (let index = patterns.length - 1; index >= 0; index -= 1) { + const pattern = patterns[index]!; + if (pattern.folderOnly && !isFolder) continue; + if (matches(pattern.tokens, path)) return pattern.negated ? "included" : "ignored"; + } + return undefined; + }, + }; +} diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index d20e378bae053..35b4c5410e960 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -546,6 +546,133 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); + it.effect("indexes symlinked files and directories inside the workspace", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-symlinks-" }); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, "src/index.ts", "export {};"); + yield* fileSystem.symlink("src", path.join(cwd, "src-link")); + yield* fileSystem.symlink("src/index.ts", path.join(cwd, "index-link.ts")); + const symlinkEntries = [ + { kind: "directory", path: "src-link" }, + { kind: "file", path: "index-link.ts" }, + ]; + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const searched = yield* searchWorkspaceEntries({ cwd, query: "link", limit: 100 }); + const listed = yield* workspaceEntries.list({ cwd }); + + for (const entry of symlinkEntries) { + expect(searched.entries).toContainEqual(expect.objectContaining(entry)); + expect(listed.entries).toContainEqual(expect.objectContaining(entry)); + } + }), + ); + it.effect("skips symlinks that ignore rules hide or that leave the workspace", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-symlinks-", git: true }); + const outside = yield* makeTempDir({ prefix: "t3code-workspace-symlinks-outside-" }); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, "src/index.ts", "export {};"); + yield* writeTextFile(cwd, ".ignore", "custom-link\n"); + yield* fileSystem.symlink("src", path.join(cwd, "custom-link")); + yield* fileSystem.symlink("src/index.ts", path.join(cwd, "tracked-link.ts")); + yield* fileSystem.symlink("src", path.join(cwd, "ignored-link")); + yield* fileSystem.symlink("src", path.join(cwd, "kept-link")); + yield* fileSystem.symlink(outside, path.join(cwd, "outside-link")); + yield* git(cwd, ["add", "tracked-link.ts"]); + yield* writeTextFile(cwd, ".gitignore", "tracked-link.ts\nignored-link\n"); + // A submodule path makes `git check-ignore` refuse the whole batch without --no-index. + const submodule = path.join(cwd, "vendor/sub"); + yield* writeTextFile(submodule, "a.ts", "export {};"); + yield* git(submodule, ["init"]); + yield* git(submodule, ["add", "a.ts"]); + yield* git(submodule, [ + "-c", + "user.name=t", + "-c", + "user.email=t@t", + "commit", + "-m", + "init", + ]); + yield* git(cwd, ["add", "vendor/sub"]); + yield* fileSystem.symlink("a.ts", path.join(submodule, "sublink.ts")); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const searched = yield* searchWorkspaceEntries({ cwd, query: "link", limit: 100 }); + const listed = yield* workspaceEntries.list({ cwd }); + + for (const entries of [searched.entries, listed.entries]) { + const paths = entries.map((entry) => entry.path); + expect(paths).toContain("kept-link"); + expect(paths).toContain("vendor/sub/sublink.ts"); + expect(paths).not.toContain("custom-link"); + expect(paths).not.toContain("tracked-link.ts"); + expect(paths).not.toContain("ignored-link"); + expect(paths).not.toContain("outside-link"); + } + }), + ); + it.effect("lets the nearest .ignore rule decide a symlink, and git's rules still hide it", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-symlinks-", git: true }); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, ".gitignore", "*keep*\n"); + yield* writeTextFile(cwd, ".ignore", "!*keep*\n*hide*\n"); + yield* writeTextFile(cwd, "sub/.ignore", "!*hide*\n"); + yield* writeTextFile(cwd, "keep-real.ts", "export {};"); + yield* writeTextFile(cwd, "sub/hide-real.ts", "export {};"); + yield* fileSystem.symlink("keep-real.ts", path.join(cwd, "keep-link.ts")); + yield* fileSystem.symlink("hide-real.ts", path.join(cwd, "sub/hide-link.ts")); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const searched = yield* searchWorkspaceEntries({ cwd, query: "link", limit: 100 }); + const listed = yield* workspaceEntries.list({ cwd }); + + for (const entries of [searched.entries, listed.entries]) { + const paths = entries.map((entry) => entry.path); + expect(paths).toContain("sub/hide-link.ts"); + expect(paths).not.toContain("keep-link.ts"); + } + // The real files are shown or hidden by the same rules. + const listedPaths = listed.entries.map((entry) => entry.path); + expect(listedPaths).toContain("sub/hide-real.ts"); + expect(listedPaths).not.toContain("keep-real.ts"); + }), + ); + it.effect("matches .ignore rules against whole names and treats folder links as links", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-symlinks-", git: true }); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, "src/index.ts", "export {};"); + yield* writeTextFile(cwd, ".gitignore", "blink\n"); + // `!link` must not re-include `blink`, and `folder-link/` does not apply to a link. + yield* writeTextFile(cwd, ".ignore", "!link\nfolder-link/\n"); + yield* fileSystem.symlink("src", path.join(cwd, "blink")); + yield* fileSystem.symlink("src", path.join(cwd, "folder-link")); + yield* writeTextFile(cwd, "sub/blink", "regular"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const searched = yield* searchWorkspaceEntries({ cwd, query: "link", limit: 100 }); + const listed = yield* workspaceEntries.list({ cwd }); + + for (const entries of [searched.entries, listed.entries]) { + const paths = entries.map((entry) => entry.path); + expect(paths).not.toContain("blink"); + expect(paths).not.toContain("sub/blink"); + expect(paths).toContain("folder-link"); + } + }), + ); it.effect("finds a path inside a gitignored nested git worktree only by its exact path", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-worktree-", git: true }); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index c01600842f2f6..25d1b912b1495 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -4,14 +4,18 @@ import { type GrepCursor, type GrepOptions, type GrepResult, + type WatchEvent, } from "@ff-labs/fff-node"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { afterEach, expect, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as TestClock from "effect/testing/TestClock"; import { vi } from "vite-plus/test"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -55,6 +59,8 @@ it.effect("filters image searches before applying the result limit", () => const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), + isScanning: () => false, + watch: () => ({ ok: true as const, value: () => undefined }), fileSearch, } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); @@ -101,6 +107,8 @@ it.effect("fetches past a first page of Git-ignored results", () => const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), + isScanning: () => false, + watch: () => ({ ok: true as const, value: () => undefined }), fileSearch, } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); @@ -123,6 +131,8 @@ it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), + isScanning: () => false, + watch: () => ({ ok: true as const, value: () => undefined }), fileSearch: vi.fn(() => ({ ok: true as const, value: { items, scores: [], totalMatched: 1, totalFiles: 1 }, @@ -185,6 +195,8 @@ it.effect("continues whole-word searches after a filtered grep page", () => const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), + isScanning: () => false, + watch: () => ({ ok: true as const, value: () => undefined }), grep, } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); @@ -215,6 +227,242 @@ it.effect("continues whole-word searches after a filtered grep page", () => ).pipe(Effect.provide(testLayer)), ); +function fakeSymlinkFinder( + directories: ReadonlyArray, + options: { + readonly indexed?: ReadonlyArray<{ + readonly path: string; + readonly kind: "file" | "directory"; + }>; + readonly watchFailures?: number; + } = {}, +) { + const watchers: Array<(events: ReadonlyArray) => void> = []; + let watchFailures = options.watchFailures ?? 0; + // fff's pageIndex is an offset into the results, not a page number. + const directorySearch = vi.fn( + (_query: string, searchOptions?: { pageIndex?: number; pageSize?: number }) => { + const offset = searchOptions?.pageIndex ?? 0; + const items = directories.slice(offset, offset + (searchOptions?.pageSize ?? 100)); + return { + ok: true as const, + value: { + items: items.map((relativePath) => ({ relativePath })), + totalMatched: directories.length, + }, + }; + }, + ); + const indexedItems = (options.indexed ?? []).map((entry) => ({ + type: entry.kind, + item: { relativePath: entry.path, fileName: entry.path.split("/").at(-1) }, + })); + const finder = { + destroy: vi.fn(), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), + isScanning: () => false, + watch: (callback: (events: ReadonlyArray) => void) => { + if (watchFailures > 0) { + watchFailures -= 1; + throw new Error("File system watcher is not ready"); + } + watchers.push(callback); + return { ok: true as const, value: () => undefined }; + }, + directorySearch, + fileSearch: () => { + const items = indexedItems.flatMap((entry) => (entry.type === "file" ? [entry.item] : [])); + return { ok: true as const, value: { items, scores: [], totalMatched: items.length } }; + }, + mixedSearch: () => ({ + ok: true as const, + value: { items: indexedItems, totalMatched: indexedItems.length }, + }), + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + return { + directorySearch, + notifyWatchers: (events: ReadonlyArray) => + watchers.forEach((watcher) => watcher(events)), + }; +} + +const listedPaths = (searchIndex: WorkspaceSearchIndex.WorkspaceSearchIndex["Service"]) => + searchIndex.list().pipe(Effect.map((result) => result.entries.map((entry) => entry.path))); + +it.effect("reads symlinks again after the watcher reports a change", () => + Effect.scoped( + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-symlink-watch-" }); + const outside = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-outside-" }); + yield* fileSystem.writeFileString(path.join(cwd, "a.ts"), ""); + yield* fileSystem.symlink("a.ts", path.join(cwd, "old-link.ts")); + const { directorySearch, notifyWatchers } = fakeSymlinkFinder([]); + + const searchIndex = yield* WorkspaceSearchIndex.make(cwd); + const concurrent = yield* Effect.all( + Array.from({ length: 8 }, () => listedPaths(searchIndex)), + { concurrency: "unbounded" }, + ); + yield* fileSystem.remove(path.join(cwd, "old-link.ts")); + yield* fileSystem.symlink("a.ts", path.join(cwd, "new-link.ts")); + const beforeNotice = yield* listedPaths(searchIndex); + // Editing a file cannot move a link, so it does not cost a rescan. + notifyWatchers([{ kind: "modified", path: path.join(cwd, "a.ts") }]); + const afterEdit = yield* listedPaths(searchIndex); + notifyWatchers([{ kind: "created", path: path.join(cwd, "new-link.ts") }]); + const afterNotice = yield* listedPaths(searchIndex); + // fff reports a link replaced in place as modified. + yield* fileSystem.remove(path.join(cwd, "new-link.ts")); + yield* fileSystem.symlink(outside, path.join(cwd, "new-link.ts")); + notifyWatchers([{ kind: "modified", path: path.join(cwd, "new-link.ts") }]); + const afterReplace = yield* listedPaths(searchIndex); + + expect(concurrent).toEqual(Array.from({ length: 8 }, () => ["old-link.ts"])); + expect(beforeNotice).toEqual(["old-link.ts"]); + expect(afterEdit).toEqual(["old-link.ts"]); + expect(afterNotice).toEqual(["new-link.ts"]); + expect(afterReplace).toEqual([]); + expect(directorySearch).toHaveBeenCalledTimes(3); + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect("answers from the last symlink scan while a slow rescan runs", () => + Effect.scoped( + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-symlink-stale-" }); + yield* fileSystem.writeFileString(path.join(cwd, "a.ts"), ""); + yield* fileSystem.symlink("a.ts", path.join(cwd, "old-link.ts")); + const { notifyWatchers } = fakeSymlinkFinder([]); + // Holding git's ignore check open keeps the rescan from finishing. + const gate = yield* Deferred.make(); + let holdIgnoreChecks = false; + const vcsProcess = yield* VcsProcess.VcsProcess; + const searchIndex = yield* WorkspaceSearchIndex.make(cwd).pipe( + Effect.provideService(VcsProcess.VcsProcess, { + ...vcsProcess, + run: (input) => + holdIgnoreChecks && input.operation === "WorkspaceSearchIndex.listGitIgnored" + ? Deferred.await(gate).pipe(Effect.andThen(vcsProcess.run(input))) + : vcsProcess.run(input), + }), + ); + expect(yield* listedPaths(searchIndex)).toEqual(["old-link.ts"]); + + holdIgnoreChecks = true; + yield* fileSystem.symlink("a.ts", path.join(cwd, "new-link.ts")); + notifyWatchers([{ kind: "created", path: path.join(cwd, "new-link.ts") }]); + const duringRescan = yield* Effect.forkChild(listedPaths(searchIndex)); + yield* TestClock.adjust("1 second"); + expect(yield* Fiber.join(duringRescan)).toEqual(["old-link.ts"]); + + yield* Deferred.succeed(gate, undefined); + expect(yield* listedPaths(searchIndex)).toEqual(["new-link.ts", "old-link.ts"]); + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect("keeps symlinks once the watcher subscribes after starting late", () => + Effect.scoped( + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-symlink-watch-" }); + yield* fileSystem.writeFileString(path.join(cwd, "a.ts"), ""); + yield* fileSystem.symlink("a.ts", path.join(cwd, "link.ts")); + const { directorySearch } = fakeSymlinkFinder([], { watchFailures: 2 }); + + const searchIndex = yield* WorkspaceSearchIndex.make(cwd); + const scansAfterRound: number[] = []; + for (let round = 0; round < 5; round += 1) { + expect(yield* listedPaths(searchIndex)).toEqual(["link.ts"]); + expect(yield* listedPaths(searchIndex)).toEqual(["link.ts"]); + scansAfterRound.push(directorySearch.mock.calls.length); + yield* TestClock.adjust("1 minute"); + } + + // An unwatched scan serves reads only briefly; the third has a watcher and is kept. + expect(scansAfterRound).toEqual([1, 2, 3, 3, 3]); + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect( + "lists a symlink the watcher indexed once, with the link's kind, and drops rejected ones", + () => + Effect.scoped( + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-symlink-dupe-" }); + const outside = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-outside-" }); + yield* fileSystem.makeDirectory(path.join(cwd, "src")); + yield* fileSystem.symlink("src", path.join(cwd, "src-link")); + // A link the scan rejects stays out even when the watcher indexed it. + yield* fileSystem.symlink(outside, path.join(cwd, "outside-link")); + fakeSymlinkFinder([], { + indexed: [ + { path: "outside-link", kind: "file" }, + { path: "src-link", kind: "file" }, + { path: "link-file.ts", kind: "file" }, + ], + }); + + const searchIndex = yield* WorkspaceSearchIndex.make(cwd); + const searched = yield* searchIndex.search("link", 10); + const files = yield* searchIndex.search("link", 1, "file"); + const listed = yield* searchIndex.list(); + + expect(searched.entries).toEqual([ + { path: "src-link", kind: "directory" }, + { path: "link-file.ts", kind: "file" }, + ]); + // The dropped link does not take the only slot. + expect(files).toEqual({ + entries: [{ path: "link-file.ts", kind: "file" }], + truncated: false, + }); + expect(listed.entries).toEqual([ + { path: "link-file.ts", kind: "file" }, + { path: "src-link", kind: "directory" }, + ]); + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect("finds symlinks in folders past the first page of the index", () => + Effect.scoped( + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-symlink-pages-" }); + yield* fileSystem.makeDirectory(path.join(cwd, "late")); + yield* fileSystem.writeFileString(path.join(cwd, "late/a.ts"), ""); + yield* fileSystem.symlink("a.ts", path.join(cwd, "late/link.ts")); + // Folders fff lists but that are gone by the time they are read are skipped. + const firstPage = Array.from({ length: 1_000 }, (_, index) => `missing-${index}`); + const { directorySearch } = fakeSymlinkFinder([...firstPage, "late"]); + + const searchIndex = yield* WorkspaceSearchIndex.make(cwd); + + expect(yield* listedPaths(searchIndex)).toEqual(["late", "late/link.ts"]); + expect(directorySearch.mock.calls.map(([, options]) => options?.pageIndex)).toEqual([ + 0, 1_000, + ]); + }), + ).pipe(Effect.provide(testLayer)), +); + // fff reads HOME on Linux and macOS but the user profile on Windows, so a temp HOME cannot stand in there. it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "refuses to index a workspace rooted at the home directory", diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 5d5d39bf55612..50a89b01ce869 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics nodeBuiltinImport:off - readdir with file types avoids an lstat per child +import * as NodeFSP from "node:fs/promises"; import * as NodeModule from "node:module"; import type { @@ -7,11 +9,18 @@ import type { GrepCursor, MixedItem, Result, + WatchEvent, + WatchUnsubscribe, } from "@ff-labs/fff-node"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as LayerMap from "effect/LayerMap"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import type { @@ -24,8 +33,10 @@ import type { } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { isProjectFaviconPath } from "@t3tools/shared/projectFavicon"; +import { scoreSubsequenceMatch } from "@t3tools/shared/searchRanking"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { type IgnoreDecision, type IgnoreFile, parseIgnoreFile } from "./IgnorePatterns.ts"; // fff-node's native library stays external; single-executables require disk packages instead of importing them. const requireForFff = NodeModule.createRequire(import.meta.url); @@ -38,6 +49,13 @@ const WORKSPACE_INDEX_SCAN_TIMEOUT_MS = 15_000; const WORKSPACE_INDEX_IDLE_TTL = "15 minutes"; const CONTENT_SEARCH_TIME_BUDGET_MS = 250; const CONTENT_SEARCH_MAX_MATCHES_PER_FILE = 100; +const SYMLINK_SCAN_CONCURRENCY = 16; +// A scan no watcher can invalidate is reused this long, so typing does not rescan every folder. +const UNWATCHED_SYMLINK_SCAN_TTL_MS = 2_000; +// A rescan that outlasts this leaves the read on the previous scan. +const SYMLINK_RESCAN_WAIT = "5 millis"; +// Each page is a synchronous native call; small pages let searches run between them. +const SYMLINK_DIRECTORY_PAGE_SIZE = 1_000; export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedError()( "WorkspaceSearchIndexCreateFailed", @@ -290,6 +308,135 @@ function withDirectoryAncestors(entries: ReadonlyArray): ProjectEn return [...entryByPath.values()]; } +interface ScannedDirectory { + readonly directory: string; + readonly links: ReadonlyArray; + /** Every symlink in the folder, including ones dropped for leaving the workspace. */ + readonly linkPaths: ReadonlyArray; + /** A nested repository (or the workspace's own), whose ignore rules own what is below it. */ + readonly isRepository: boolean; + readonly ignoreFile: string | undefined; +} + +const joinEntryPath = (directory: string, name: string) => + directory ? `${directory}/${name}` : name; + +/** + * fff skips symlinks, and `followSymlinks` drops file links, dedupes folder links, and walks out + * of the workspace. So links are read from the folders fff already indexed (ignore rules applied), + * without descending into them, and kept only when they resolve inside the workspace. + */ +const scanDirectory = Effect.fn("WorkspaceSearchIndex.scanDirectory")(function* ( + fileSystem: FileSystem.FileSystem, + path: Path.Path, + cwd: string, + root: string, + directory: string, +) { + // Effect's readDirectory returns bare names; one lstat per child would cost far more. + const children = yield* Effect.promise(() => + NodeFSP.readdir(path.join(cwd, directory), { withFileTypes: true }).catch(() => []), + ); + const links: ProjectEntry[] = []; + const linkPaths: string[] = []; + let isRepository = false; + let ignoreFile: string | undefined; + for (const child of children) { + if (child.name === ".git") isRepository = true; + if (child.name === ".ignore" && child.isFile()) { + ignoreFile = yield* fileSystem + .readFileString(path.join(cwd, directory, child.name)) + .pipe(Effect.orElseSucceed(() => undefined)); + } + if (!child.isSymbolicLink()) continue; + const entryPath = joinEntryPath(directory, child.name); + linkPaths.push(entryPath); + const absolutePath = path.join(cwd, entryPath); + const target = yield* Effect.all([ + fileSystem.realPath(absolutePath), + fileSystem.stat(absolutePath), + ]).pipe(Effect.orElseSucceed(() => undefined)); + if (!target) continue; + const relative = path.relative(root, target[0]); + if ( + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) || + relative.split(path.sep).includes(".git") || + (target[1].type !== "Directory" && target[1].type !== "File") + ) { + continue; + } + links.push({ path: entryPath, kind: target[1].type === "Directory" ? "directory" : "file" }); + } + return { directory, links, linkPaths, isRepository, ignoreFile } satisfies ScannedDirectory; +}); + +/** Whether `entryPath` is `directory` itself or below it. */ +const isWithin = (entryPath: string, directory: string) => + directory === "" || entryPath.startsWith(`${directory}/`); + +/** + * fff reads `.ignore` files alongside git's rules, and like ripgrep the nearest one that matches + * decides, ahead of any `.gitignore`. `ignoreFiles` is ordered deepest folder first. + */ +function decideByIgnoreFiles( + entry: ProjectEntry, + ignoreFiles: ReadonlyArray<{ readonly directory: string; readonly file: IgnoreFile }>, +): IgnoreDecision | undefined { + for (const { directory, file } of ignoreFiles) { + if (!isWithin(entry.path, directory) || entry.path === directory) continue; + const relative = directory ? entry.path.slice(directory.length + 1) : entry.path; + // Like git and ripgrep, a link is never a folder to a folder-only rule, whatever it targets. + const decision = file.decide(relative, false); + if (decision) return decision; + } + return undefined; +} + +/** Places each symlink before the first index result that matches the query less closely. */ +function mergeSymlinkMatches( + indexed: ProjectSearchEntriesResult, + linkPaths: ReadonlySet, + symlinks: ReadonlyArray, + query: string, + limit: number, +): ProjectSearchEntriesResult { + if (linkPaths.size === 0) return indexed; + // fff's watcher indexes links created after its scan, under a kind of its own choosing, so a + // path it already returned takes the link's kind (or drops out of a kind-filtered search). + const symlinkByPath = new Map(symlinks.map((entry) => [entry.path, entry])); + const score = (entry: ProjectEntry) => + scoreSubsequenceMatch(entry.path.toLowerCase(), query) ?? Number.POSITIVE_INFINITY; + const indexedPaths = new Set(indexed.entries.map((entry) => entry.path)); + const pending = symlinks + .flatMap((entry) => { + const entryScore = score(entry); + return Number.isFinite(entryScore) && !indexedPaths.has(entry.path) + ? [{ entry, score: entryScore }] + : []; + }) + .toSorted((left, right) => left.score - right.score); + const entries: ProjectEntry[] = []; + let nextPending = 0; + for (const indexedEntry of indexed.entries) { + const entry = linkPaths.has(indexedEntry.path) + ? symlinkByPath.get(indexedEntry.path) + : indexedEntry; + if (!entry) continue; + const entryScore = score(entry); + while (nextPending < pending.length && pending[nextPending]!.score < entryScore) { + entries.push(pending[nextPending++]!.entry); + } + entries.push(entry); + } + for (; nextPending < pending.length; nextPending += 1) entries.push(pending[nextPending]!.entry); + return { + entries: entries.slice(0, limit), + truncated: indexed.truncated || entries.length > limit, + }; +} + // fff applies .gitignore files only; Git also ignores paths from .git/info/exclude and the global excludes file. const listGitIgnoredPaths = Effect.fn("WorkspaceSearchIndex.listGitIgnoredPaths")(function* ( vcsProcess: VcsProcess.VcsProcess["Service"], @@ -401,6 +548,8 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( variant: WorkspaceSearchIndexVariant = "paths", ) { const hostIsWindows = yield* HostProcess.isWindows; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const finder = yield* Effect.acquireRelease(createFinder(cwd, variant), (finder) => Effect.try({ try: () => finder.destroy(), @@ -460,6 +609,225 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( return result.value; }); + const root = yield* fileSystem.realPath(cwd).pipe(Effect.orElseSucceed(() => cwd)); + + const listIndexedDirectories = Effect.fn("WorkspaceSearchIndex.listIndexedDirectories")( + function* () { + const directories: string[] = []; + // fff's pageIndex is an offset into the results, not a page number. + for (let pageIndex = 0; ; pageIndex += SYMLINK_DIRECTORY_PAGE_SIZE) { + const result = yield* Effect.sync(() => { + try { + return finder.directorySearch("", { pageIndex, pageSize: SYMLINK_DIRECTORY_PAGE_SIZE }); + } catch { + return undefined; + } + }); + if (!result?.ok) return directories; + for (const item of result.value.items) { + const directory = trimDirectorySeparator(toPosixPath(item.relativePath, hostIsWindows)); + if (directory) directories.push(directory); + } + if (result.value.items.length < SYMLINK_DIRECTORY_PAGE_SIZE) return directories; + yield* Effect.yieldNow; + } + }, + ); + + /** Ignore rules belong to the repository that owns a path, so each one answers for its own. */ + const listGitIgnored = Effect.fn("WorkspaceSearchIndex.listGitIgnored")(function* ( + repository: string, + entries: ReadonlyArray, + ) { + const relativePaths = entries.map((entry) => + repository ? entry.path.slice(repository.length + 1) : entry.path, + ); + const repositoryCwd = path.join(cwd, repository); + const result = yield* vcsProcess + .run({ + operation: "WorkspaceSearchIndex.listGitIgnored", + command: "git", + // fff applies ignore rules to tracked paths too, so the index is not consulted. + args: ["-c", "core.fsmonitor=false", "check-ignore", "--no-index", "-z", "--stdin"], + cwd: repositoryCwd, + stdin: `${relativePaths.join("\0")}\0`, + allowNonZeroExit: true, + timeoutMs: 10_000, + maxOutputBytes: 16 * 1024 * 1024, + }) + .pipe(Effect.orElseSucceed(() => undefined)); + // Exit 1 means nothing matched. + if (result?.exitCode === 1) return []; + if (result?.exitCode !== 0 || result.stdoutTruncated) { + // Outside git no rules apply (git exits before reading stdin, so the cause is lost). + // Any other failure hides the repository's links rather than leaking ones it ignores. + const insideRepository = yield* vcsProcess + .run({ + operation: "WorkspaceSearchIndex.listGitIgnored", + command: "git", + args: ["rev-parse", "--git-dir"], + cwd: repositoryCwd, + timeoutMs: 10_000, + }) + .pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + return insideRepository ? entries.map((entry) => entry.path) : []; + } + return result.stdout + .split("\0") + .filter(Boolean) + .map((relativePath) => (repository ? `${repository}/${relativePath}` : relativePath)); + }); + + const scanSymlinkEntries = Effect.fn("WorkspaceSearchIndex.scanSymlinkEntries")(function* () { + const indexComplete = !finder.isScanning(); + const directories = yield* listIndexedDirectories(); + const scanned = yield* Effect.forEach( + ["", ...directories], + (directory) => scanDirectory(fileSystem, path, cwd, root, directory), + { concurrency: SYMLINK_SCAN_CONCURRENCY }, + ); + const candidates = scanned.flatMap((directory) => directory.links); + const linkPaths = new Set(scanned.flatMap((directory) => directory.linkPaths)); + if (candidates.length === 0) return { entries: candidates, linkPaths, indexComplete }; + + const ignoreFiles = scanned + .flatMap(({ directory, ignoreFile }) => + ignoreFile === undefined ? [] : [{ directory, file: parseIgnoreFile(ignoreFile) }], + ) + .toSorted((left, right) => right.directory.length - left.directory.length); + const decisions = new Map( + candidates.map((entry) => [entry.path, decideByIgnoreFiles(entry, ignoreFiles)]), + ); + const repositories = [ + "", + ...scanned.flatMap(({ directory, isRepository }) => + isRepository && directory ? [directory] : [], + ), + ].toSorted((left, right) => right.length - left.length); + const entriesByRepository = Map.groupBy( + candidates.filter((entry) => decisions.get(entry.path) === undefined), + (entry) => + repositories.find( + (repository) => isWithin(entry.path, repository) && entry.path !== repository, + ) ?? "", + ); + const ignored = new Set( + (yield* Effect.forEach( + entriesByRepository, + ([repository, entries]) => listGitIgnored(repository, entries), + { concurrency: 4 }, + )).flat(), + ); + return { + entries: candidates.filter((entry) => + decisions.get(entry.path) === undefined + ? !ignored.has(entry.path) + : decisions.get(entry.path) === "included", + ), + linkPaths, + indexComplete, + }; + }); + + // One scan runs at a time and outlives the reads that started it. Only the first read waits for + // it in full: after that a change or expiry marks the result stale, and reads wait briefly for + // the rescan before answering from the last finished one, so file churn cannot stall search. + type SymlinkScan = Effect.Success>; + let lastSymlinkScan: SymlinkScan | undefined; + let lastSymlinkScanExpiresAt: number | undefined; + let pendingSymlinkScan: Deferred.Deferred | undefined; + let symlinkScanStale = false; + let knownLinkPaths: ReadonlySet = new Set(); + const invalidateSymlinkScan = () => { + symlinkScanStale = true; + }; + const isKnownLink = (absolutePath: string) => + [cwd, root].some((base) => + knownLinkPaths.has(toPosixPath(path.relative(base, absolutePath), hostIsWindows)), + ); + const onWatchBatch = (events: ReadonlyArray) => { + // fff reports a link replaced in place as `modified`, so edits to known links count too. + const editsOnly = events.every( + (event) => + event.kind === "modified" && + !/(^|[\\/])\.(git)?ignore$/.test(event.path) && + !isKnownLink(event.path), + ); + if (!editsOnly) invalidateSymlinkScan(); + }; + // fff refuses subscriptions until its watcher is up, which can trail the initial scan, so + // each read retries until one is in place. + let unwatch: WatchUnsubscribe | undefined; + yield* Effect.addFinalizer(() => Effect.sync(() => unwatch?.())); + const ensureWatching = () => { + if (unwatch) return true; + try { + const result = finder.watch(onWatchBatch); + if (result.ok) unwatch = result.value; + } catch { + // Not ready yet; the next read tries again. + } + return unwatch !== undefined; + }; + const indexScope = yield* Effect.scope; + const startSymlinkScan = Effect.fn("WorkspaceSearchIndex.startSymlinkScan")(function* () { + symlinkScanStale = false; + // Subscribing before the scan starts means no change during it goes unseen. + const watched = ensureWatching(); + const done = yield* Deferred.make(); + pendingSymlinkScan = done; + yield* scanSymlinkEntries().pipe( + Effect.tap((result) => + Effect.gen(function* () { + lastSymlinkScan = result; + knownLinkPaths = result.linkPaths; + // Without a watcher, or before fff has seen every folder, the result is kept only briefly. + lastSymlinkScanExpiresAt = + watched && result.indexComplete + ? undefined + : (yield* Clock.currentTimeMillis) + UNWATCHED_SYMLINK_SCAN_TTL_MS; + }), + ), + Effect.onExit((exit) => + Effect.sync(() => { + pendingSymlinkScan = undefined; + }).pipe(Effect.andThen(Deferred.done(done, exit))), + ), + Effect.forkIn(indexScope), + ); + return done; + }); + const listSymlinkEntries = Effect.fn("WorkspaceSearchIndex.listSymlinkEntries")(function* () { + if ( + lastSymlinkScanExpiresAt !== undefined && + (yield* Clock.currentTimeMillis) >= lastSymlinkScanExpiresAt + ) { + symlinkScanStale = true; + } + const pending = + pendingSymlinkScan ?? + (lastSymlinkScan === undefined || symlinkScanStale ? yield* startSymlinkScan() : undefined); + let result = lastSymlinkScan; + if (pending && result === undefined) { + result = yield* Deferred.await(pending); + } else if (pending) { + const fresh = yield* Deferred.await(pending).pipe(Effect.timeoutOption(SYMLINK_RESCAN_WAIT)); + if (Option.isSome(fresh)) result = fresh.value; + } + if (result === undefined) + return yield* Effect.die("The symlink scan finished without a result."); + return { + // Git's ignore rules hide a link the same way searchVisible hides the files it indexes. + entries: result.entries.filter( + (entry) => !isIgnoredPath(ignoredPaths, entry.path, hostIsWindows), + ), + linkPaths: result.linkPaths, + }; + }); + // Grows the first page until hidden entries can no longer crowd visible ones out of it. const searchVisible = Effect.fn("WorkspaceSearchIndex.searchVisible")(function* ( query: string, @@ -522,6 +890,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( }), ).pipe(Effect.catchTags({ WorkspaceSearchIndexScanTimedOut: () => Effect.void })); ignoredPaths = yield* listGitIgnoredPaths(vcsProcess, cwd, hostIsWindows); + invalidateSymlinkScan(); }); const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( @@ -535,8 +904,11 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( mixedItemPath, ); const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES, hostIsWindows); - const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) => - left.path.localeCompare(right.path), + const symlinks = yield* listSymlinkEntries(); + // fff's watcher can index a link the scan rejected; only accepted links are listed. + const indexed = mapped.entries.filter((entry) => !symlinks.linkPaths.has(entry.path)); + const sortedEntries = withDirectoryAncestors([...indexed, ...symlinks.entries]).toSorted( + (left, right) => left.path.localeCompare(right.path), ); const entries = sortedEntries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES); return { @@ -553,7 +925,17 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( kind: ProjectEntryKind | undefined, imageOnly: boolean | undefined, ) { - const pageSize = imageOnly ? WORKSPACE_INDEX_PAGE_SIZE : Math.max(1, limit + 1); + const { entries: allSymlinks, linkPaths } = yield* listSymlinkEntries(); + const symlinks = allSymlinks.filter((entry) => + imageOnly + ? entry.kind === "file" && isProjectFaviconPath(entry.path) + : kind === undefined || entry.kind === kind, + ); + // Index results that are filtered-out links get dropped in the merge, so fetch past them. + const indexLimit = limit + linkPaths.size - symlinks.length; + const pageSize = imageOnly ? WORKSPACE_INDEX_PAGE_SIZE : Math.max(1, indexLimit + 1); + const merge = (indexed: ProjectSearchEntriesResult) => + mergeSymlinkMatches(indexed, linkPaths, symlinks, query, limit); if (kind === "file" || imageOnly) { const result = yield* searchVisible( query, @@ -562,7 +944,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( (size) => finder.fileSearch(query, { pageSize: size }), (item) => item.relativePath, ); - return mapFileSearchResult(result, limit, hostIsWindows, imageOnly); + return merge(mapFileSearchResult(result, indexLimit, hostIsWindows, imageOnly)); } if (kind === "directory") { const result = yield* searchVisible( @@ -572,7 +954,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( (size) => finder.directorySearch(query, { pageSize: size }), (item) => item.relativePath, ); - return mapDirectorySearchResult(result, limit, hostIsWindows); + return merge(mapDirectorySearchResult(result, indexLimit, hostIsWindows)); } const result = yield* searchVisible( query, @@ -581,7 +963,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( (size) => finder.mixedSearch(query, { pageSize: size }), mixedItemPath, ); - return mapMixedSearchResult(result, limit, hostIsWindows); + return merge(mapMixedSearchResult(result, indexLimit, hostIsWindows)); }); const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( From 7ab4650030acb5b43d4314eda215b97b4fe9c775 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 10:09:10 -0800 Subject: [PATCH 017/409] fix(mobile): Work on PR keeps its worktree when switching machines (#27658) --- .../threads/new-task-flow-provider.tsx | 16 +++++++++++---- .../new-task-project-selection.test.ts | 4 ++++ .../threads/new-task-project-selection.ts | 13 ++++++++++++ .../src/state/use-composer-drafts.test.ts | 20 +++++++++++++++++-- apps/mobile/src/state/use-composer-drafts.ts | 9 ++++++++- 5 files changed, 55 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index d78dd73b18402..5852206095a8f 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -89,7 +89,7 @@ import { resolveNewTaskBranchWorktreePath, resolveNewTaskLocalWorkspaceSelection, } from "./new-task-context-presentation"; -import { resolveEnvironmentProjectMatch } from "./new-task-project-selection"; +import { isSameRepository, resolveEnvironmentProjectMatch } from "./new-task-project-selection"; import { useNewTaskProjectDefaults } from "./use-new-task-project-defaults"; import { resolveNewThreadRuntimeMode } from "./thread-settings-options"; import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation"; @@ -560,11 +560,14 @@ export function NewTaskFlowProvider( replaceComposerDraftAttachments(selectedProjectDraftKey, []); }; - const carryDraftContentTo = (project: EnvironmentProject): string => { + const carryDraftContentTo = ( + project: EnvironmentProject, + options?: Parameters[2], + ): string => { const target = { environmentId: project.environmentId, projectId: project.id }; const current = readFlow().draftKey; if (current !== null && isNewTaskDraftKey(current)) { - retargetNewTaskDraft(current, target); + retargetNewTaskDraft(current, target, options); return current; } return createNewTaskDraft(target); @@ -584,7 +587,12 @@ export function NewTaskFlowProvider( selectedProject, ); updateFlow({ - draftKey: match ? carryDraftContentTo(match) : readFlow().draftKey, + // A name or title match may be another repository, which must not receive the PR checkout. + draftKey: match + ? carryDraftContentTo(match, { + keepPullRequestWorkspace: isSameRepository(match, selectedProject), + }) + : readFlow().draftKey, environmentId, projectKey: match ? scopedProjectKey(match.environmentId, match.id) : null, }); diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index 0fcd9d9d6d745..82533a3c91e74 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -5,6 +5,7 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import type { HomeProjectScope } from "../home/homeThreadList"; import { getProjectScopeSelectionTarget, + isSameRepository, newTaskDestination, newTaskFilteredProject, resolveDraftProjectSelection, @@ -117,6 +118,7 @@ describe("resolveEnvironmentProjectMatch", () => { makeProject("t3code-clone", "server", { repositoryKey: "github.com/t3tools/t3code" }), ]; expect(resolveEnvironmentProjectMatch(target, selected)).toBe(target[1]); + expect(isSameRepository(target[1]!, selected)).toBe(true); }); it("falls back to workspace basename, then title, for unindexed projects", () => { @@ -126,6 +128,7 @@ describe("resolveEnvironmentProjectMatch", () => { makeProject("srv", "server", { workspaceRoot: "/home/me/t3code" }), ]; expect(resolveEnvironmentProjectMatch(byBasename, selected)).toBe(byBasename[1]); + expect(isSameRepository(byBasename[1]!, selected)).toBe(false); const byTitle = [ makeProject("other", "server"), @@ -147,6 +150,7 @@ describe("resolveEnvironmentProjectMatch", () => { const unindexed = makeProject("unindexed", "server", { workspaceRoot: "/srv/t3code" }); expect(resolveEnvironmentProjectMatch([fork, unindexed], selected)).toBe(unindexed); expect(resolveEnvironmentProjectMatch([fork], selected)).toBe(fork); + expect(isSameRepository(fork, selected)).toBe(false); }); }); diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts index 88f30171419f0..2fa21eba1e234 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -117,6 +117,19 @@ export function resolveEnvironmentProjectMatch( ); } +/** Both projects have a known repository identity, and it is the same one. */ +export function isSameRepository( + left: EnvironmentProject | null, + right: EnvironmentProject | null, +): boolean { + return ( + left?.repositoryIdentity != null && + right?.repositoryIdentity != null && + repositoryGroupingKeyOf(left.repositoryIdentity) === + repositoryGroupingKeyOf(right.repositoryIdentity) + ); +} + export function resolveDraftProjectSelection( selectedProjectKey: string | null, projects: ReadonlyArray, diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index f7fe9ebbcf57c..f25037f40f0b4 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -1901,7 +1901,7 @@ describe("mobile composer drafts", () => { expect(getComposerDraftSnapshot(second).text).toBe("second idea"); }); - it("retargets a new-task draft to another project without losing its text", () => { + it("retargets a new-task draft to another project, keeping only a pull request workspace", () => { const from = { environmentId: EnvironmentId.make("environment-1"), projectId: ProjectId.make("project-1"), @@ -1922,7 +1922,7 @@ describe("mobile composer drafts", () => { }); const createdAt = getComposerDraftSnapshot(key).project?.createdAt; - retargetNewTaskDraft(key, to); + retargetNewTaskDraft(key, to, { keepPullRequestWorkspace: true }); const moved = getComposerDraftSnapshot(key); expect(moved.text).toBe("moving house"); @@ -1932,6 +1932,22 @@ describe("mobile composer drafts", () => { expect(moved.project).toEqual({ ...to, createdAt }); expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), from)).toEqual([]); expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), to)).toEqual([key]); + + const pullRequestSelection = { + mode: "worktree" as const, + branch: "feature/pr-head", + worktreePath: "/old-machine/worktrees/pr-42", + startFromOrigin: false, + pullRequestReference: "https://github.com/acme/repo/pull/42", + }; + updateComposerDraftSettings(key, { workspaceSelection: pullRequestSelection }); + retargetNewTaskDraft(key, from, { keepPullRequestWorkspace: true }); + expect(getComposerDraftSnapshot(key).workspaceSelection).toEqual({ + ...pullRequestSelection, + worktreePath: null, + }); + retargetNewTaskDraft(key, to); + expect(getComposerDraftSnapshot(key).workspaceSelection).toBeUndefined(); }); it("retargets a saved new-task draft only after drafts load", async () => { diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 2e4e53ef6dd45..7fb44be817f32 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1965,6 +1965,7 @@ export function createNewTaskDraft(project: { export function retargetNewTaskDraft( draftKey: string, project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId }, + options?: { readonly keepPullRequestWorkspace?: boolean }, ): void { updateComposerDrafts((current) => { const existing = current[draftKey]; @@ -1976,7 +1977,12 @@ export function retargetNewTaskDraft( ) { return current; } - const { workspaceSelection: _workspaceSelection, ...retained } = normalizeDraft(existing); + const { workspaceSelection, ...retained } = normalizeDraft(existing); + // The PR checkout is recreated on the target machine, so only the old local path is dropped. + const keptWorkspaceSelection = + options?.keepPullRequestWorkspace && workspaceSelection?.pullRequestReference !== undefined + ? { ...workspaceSelection, worktreePath: null } + : undefined; // Keep bytes across environment moves but drop server-specific upload stamps. const attachments = retained.attachments.map((attachment) => attachment.uploadEnvironmentId !== undefined && @@ -1988,6 +1994,7 @@ export function retargetNewTaskDraft( ...current, [draftKey]: { ...retained, + ...(keptWorkspaceSelection ? { workspaceSelection: keptWorkspaceSelection } : {}), attachments, project: { environmentId: project.environmentId, From a09e54a3632c4b75c5b340de0a2c7fc34d46025d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 10:10:19 -0800 Subject: [PATCH 018/409] fix(server): proxied usage limits take their reset time from the CLIProxyAPI hub (#36238) --- .../src/orchestration-v2/Orchestrator.ts | 17 +- .../ProviderEventIngestor.test.ts | 32 +++- .../orchestration-v2/ProviderEventIngestor.ts | 34 +++- .../src/orchestration-v2/runtimeLayer.test.ts | 62 ++++++- apps/server/src/provider/ClaudeProvider.ts | 5 +- .../server/src/provider/CodexProvider.test.ts | 9 ++ apps/server/src/provider/CodexProvider.ts | 33 +++- apps/server/src/provider/claudeUsageLimits.ts | 14 +- apps/server/src/server.ts | 1 + .../src/usage/UsageLimitSources.test.ts | 152 +++++++++++++++++- apps/server/src/usage/UsageLimitSources.ts | 72 ++++++++- packages/contracts/src/providerUsageLimits.ts | 2 + packages/contracts/src/server.ts | 2 + 13 files changed, 410 insertions(+), 25 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 879dad7809cc2..a6f412e7ceddd 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4455,7 +4455,22 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ) .map((message) => message.id), ); - if (queuedRuns.some((run) => !automaticMessageIds.has(run.userMessageId))) { + const userRuns = queuedRuns.filter((run) => !automaticMessageIds.has(run.userMessageId)); + // User work waiting behind a usage limit cannot start until the reset or the user acts. + const latest = latestExecutedRun(projection.runs); + const limitRecords = + userRuns.length === 0 || latest?.status !== "failed" + ? null + : yield* loadProjectionForCommand(command, ["turnItems", "providerSessions"], { + turnItemTypes: ["error"], + turnItemRunIds: [latest.id], + }); + const heldBehindLimit = + limitRecords !== null && + userRuns.every((run) => + usageLimitHoldsQueue({ ...limitRecords, runs: projection.runs }, run.providerInstanceId), + ); + if (userRuns.length > 0 && !heldBehindLimit) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index f69444d235712..ebe5fba1efb0e 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -49,8 +49,10 @@ import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import * as ProviderUsageAnalytics from "../telemetry/ProviderUsageAnalytics.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; +import { UsageLimitResetFallback } from "../usage/UsageLimitSources.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; +import type { ServerProvider } from "@t3tools/contracts"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { makeProviderEventRoutingState, @@ -230,7 +232,7 @@ function threadCreatedEvent( const layer = it.layer(layerTest); -it.effect("refreshes the failing instance snapshot after a committed usage_limit terminal", () => +it.effect("takes a hub's reset for an unreset usage_limit, then refreshes the snapshot", () => Effect.gen(function* () { const refreshed = yield* Deferred.make(); const adapter = yield* ProviderAdapter.ProviderAdapterV2.pipe( @@ -261,7 +263,9 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit }, snapshot: { refreshUsage: Deferred.succeed(refreshed, undefined).pipe(Effect.asVoid), - getSnapshot: Effect.die("unused"), + getSnapshot: Effect.succeed({ + auth: { status: "authenticated", endpoint: "http://hub.test:8317" }, + } as ServerProvider), refresh: Effect.die("unused"), resolveMaintenance: () => Effect.die("unused"), applyUsageLimits: () => Effect.die("unused"), @@ -269,6 +273,7 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit }, }; const lookups: ProviderInstanceId[] = []; + const hubLookups: Array<[string, string, string | null]> = []; const instances = Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ getInstance: (id) => Effect.sync(() => { @@ -317,10 +322,29 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit }); assert.equal(stored.length, 1); if (failureClass === "validation_error") assert.deepEqual(lookups, []); + else { + const event = stored[0]?.event; + assert.equal( + event?.type === "turn-item.updated" && event.payload.type === "error" + ? event.payload.failure.resetAt + : undefined, + "2026-11-13T18:00:00.000Z", + ); + } } - assert.deepEqual(lookups, [instance.instanceId]); + assert.deepEqual(lookups, [instance.instanceId, instance.instanceId]); + assert.deepEqual(hubLookups, [["cursor", "http://hub.test:8317", "gpt-5.4"]]); yield* Deferred.await(refreshed); - }).pipe(Effect.provide(makeLayerTest(layerTestEventSink, instances))); + }).pipe( + Effect.provide(makeLayerTest(layerTestEventSink, instances)), + Effect.provideService(UsageLimitResetFallback, { + resetAt: (driver, endpoint, model) => + Effect.sync(() => { + hubLookups.push([driver, endpoint, model]); + return "2026-11-13T18:00:00.000Z"; + }), + }), + ); }), ); diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 6fd6e7bc3129c..fddffcd6ee19d 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -32,6 +32,7 @@ import { } from "../telemetry/ProviderUsageAnalytics.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; +import { UsageLimitResetFallback } from "../usage/UsageLimitSources.ts"; import * as EventSink from "./EventSink.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -254,6 +255,7 @@ export const layer: Layer.Layer< const analytics = yield* ProviderTurnAnalytics; const agentScope = yield* AgentScope; const instances = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; + const usageLimitResets = yield* UsageLimitResetFallback; const scope = yield* Effect.scope; const completedTurnAnalytics = new Set(); @@ -656,9 +658,39 @@ export const layer: Layer.Layer< return dismissed; } const occurredAt = yield* DateTime.now; - const failure = (yield* agentScope.oomKilled(input.threadId)) + const reported = (yield* agentScope.oomKilled(input.threadId)) ? { ...input.event.failure, message: "Killed: out of memory.", code: "oom_kill" } : input.event.failure; + // A proxied CLI can drop the reset time; the hub still knows when its pool refills. + const endpoint = + reported.class === "usage_limit" && reported.resetAt == null + ? (yield* Effect.flatMap( + instances.getInstance(input.providerInstanceId), + (instance) => instance?.snapshot.getSnapshot ?? Effect.undefined, + ))?.auth.endpoint + : undefined; + const hubResetAt = + endpoint === undefined + ? null + : yield* Effect.gen(function* () { + // The failing run's model, which may differ from a selection changed mid-turn. + const { runs } = + input.runId === undefined + ? { runs: [] } + : yield* projections.getThreadRecords(input.threadId, ["runs"], { + runIds: [input.runId], + }); + return ( + runs[0]?.modelSelection ?? + (yield* projections.getThread(input.threadId)).modelSelection + ).model; + }).pipe( + Effect.orElseSucceed(() => null), + Effect.flatMap((model) => + usageLimitResets.resetAt(input.event.driver, endpoint, model), + ), + ); + const failure = hubResetAt === null ? reported : { ...reported, resetAt: hubResetAt }; return [ ...dismissed, yield* makeDomainEvent(input, { diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 8ecf4a6ac3ee7..c7cc43ee5ddea 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -9557,8 +9557,8 @@ it.layer(layerSharedApplicationDataPlaneTest)("pending provider interruption", ( }); it.layer(layerTest)("snooze queued runs", (it) => { - it.effect.each(["notification", "delegated-completion", "user"] as const)( - "only user messages block snooze with a held %s run", + it.effect.each(["notification", "delegated-completion", "user", "limited-user"] as const)( + "only user messages block snooze with a queued %s run, unless a usage limit holds them", (kind) => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -9627,8 +9627,8 @@ it.layer(layerTest)("snooze queued runs", (it) => { attachments: [], modelSelection, dispatchMode: { type: "queue_after_active" }, - createdBy: kind === "user" ? "user" : "agent", - creationSource: kind === "user" ? "web" : "server", + createdBy: kind === "user" || kind === "limited-user" ? "user" : "agent", + creationSource: kind === "user" || kind === "limited-user" ? "web" : "server", ...(kind === "notification" ? { notification: { @@ -9647,6 +9647,43 @@ it.layer(layerTest)("snooze queued runs", (it) => { } : {}), }); + if (kind === "limited-user") { + yield* eventSink.write({ + events: [ + { + id: EventId.make(`limited:${threadId}`), + type: "turn-item.updated", + threadId, + occurredAt: now, + payload: { + id: TurnItemId.make(`limited:${threadId}`), + type: "error", + threadId, + runId: activeRun.id, + nodeId: activeRun.rootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 2, + status: "failed", + title: "Usage limit reached", + startedAt: now, + completedAt: now, + updatedAt: now, + // A proxied provider can report no reset time. + failure: { + class: "usage_limit", + message: "Claude API rate limit reached. Try again later.", + code: "api_error_429", + retryable: true, + resetAt: null, + }, + }, + }, + ], + }); + } const beforeRecovery = yield* orchestrator.getThreadProjection(threadId); yield* eventSink.commitCommand({ commandId: CommandId.make(`recovery:${threadId}`), @@ -9661,13 +9698,24 @@ it.layer(layerTest)("snooze queued runs", (it) => { occurredAt: now, payload: run.status === "queued" - ? { ...run, queueHeld: true } - : { ...run, status: "cancelled" as const, completedAt: now }, + ? // A usage limit stops the queue from starting without marking it held. + kind === "limited-user" + ? run + : { ...run, queueHeld: true } + : { + ...run, + status: kind === "limited-user" ? ("failed" as const) : ("cancelled" as const), + completedAt: now, + }, })), effects: [], }); const beforeSnooze = yield* orchestrator.getThreadProjection(threadId); - assert.isTrue(beforeSnooze.runs.some((run) => run.status === "queued" && run.queueHeld)); + assert.isTrue( + beforeSnooze.runs.some( + (run) => run.status === "queued" && (kind === "limited-user" || run.queueHeld), + ), + ); const snooze = orchestrator.dispatch({ type: "thread.snooze", commandId: CommandId.make(`snooze:${threadId}`), diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index f969544fad064..18885f0417ebb 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -776,6 +776,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( (yield* readClaudeSettingsEnv(claudeSettings, cwd, resolvedEnvironment)).ANTHROPIC_BASE_URL ?? resolvedEnvironment.ANTHROPIC_BASE_URL; const customApiEndpoint = customClaudeApiEndpointHost(baseUrl) !== undefined; + const endpoint = customApiEndpoint ? URL.parse(baseUrl?.trim() ?? "")?.origin : undefined; if (!customApiEndpoint && claudeAuthStatus(capabilities) === "unauthenticated") { const path = yield* Path.Path; const configDir = (yield* makeClaudeEnvironment(claudeSettings, resolvedEnvironment)) @@ -804,7 +805,9 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( // A proxy's ANTHROPIC_AUTH_TOKEN reads as an API key; the endpoint is what the token is for. const authMetadata = apiProviderAuthMetadata(capabilities.apiProvider) ?? - (customApiEndpoint ? { type: "customEndpoint", label: "Custom API endpoint" } : undefined) ?? + (customApiEndpoint + ? { type: "customEndpoint", label: "Custom API endpoint", ...(endpoint ? { endpoint } : {}) } + : undefined) ?? claudeAuthMetadata({ subscriptionType: capabilities.subscriptionType, authMethod: capabilities.tokenSource, diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts index 7eadf550eab28..ad15bf0896eb2 100644 --- a/apps/server/src/provider/CodexProvider.test.ts +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; import { applyPreferredCodexDefaultModel, + codexCustomEndpoint, codexRoutesSpendNativeQuota, mapCodexModelCapabilities, } from "./CodexProvider.ts"; @@ -192,4 +193,12 @@ it("reports native quota only for an OpenAI login on a native route", () => { name, ); } + // The custom endpoint is what a usage-limit hub is matched against. + assert.strictEqual(codexCustomEndpoint(hub), "https://hub.example.com"); + assert.strictEqual( + codexCustomEndpoint({ openai_base_url: "http://127.0.0.1:8317/v1" }), + "http://127.0.0.1:8317", + ); + assert.strictEqual(codexCustomEndpoint({ openai_base_url: "https://chatgpt.com/x" }), undefined); + assert.strictEqual(codexCustomEndpoint({}), undefined); }); diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index f3bbf514a83e1..df105d3ba20fe 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -94,17 +94,21 @@ const CodexCyberAccessConfig = Schema.Struct({ }); const decodeCodexCyberAccessConfig = Schema.decodeUnknownOption(CodexCyberAccessConfig); -function routesToNativeOpenai(config: unknown): boolean { +// Null is Codex's auth-dependent OpenAI default; undefined is a route that cannot be read. +function codexRouteBaseUrl(config: unknown): string | null | undefined { const decoded = decodeCodexRoutingConfig(config); - if (Option.isNone(decoded)) return false; + if (Option.isNone(decoded)) return undefined; const routing = decoded.value; const providerId = routing.model_provider ?? "openai"; + if (providerId === "openai") return routing.openai_base_url || null; const provider = routing.model_providers?.[providerId]; - if (providerId !== "openai" && !provider) return false; - const baseUrl = - providerId === "openai" ? routing.openai_base_url || undefined : provider?.base_url; - // A provider without an override uses Codex's auth-dependent OpenAI default. - if (baseUrl == null) return true; + return provider ? (provider.base_url ?? null) : undefined; +} + +function routesToNativeOpenai(config: unknown): boolean { + const baseUrl = codexRouteBaseUrl(config); + if (baseUrl === undefined) return false; + if (baseUrl === null) return true; if (!URL.canParse(baseUrl)) return false; const url = new URL(baseUrl); return ( @@ -113,6 +117,15 @@ function routesToNativeOpenai(config: unknown): boolean { ); } +/** The origin of the custom endpoint Codex sends model requests to, if it uses one. */ +export function codexCustomEndpoint(config: unknown): string | undefined { + const baseUrl = codexRouteBaseUrl(config); + const url = baseUrl ? URL.parse(baseUrl) : null; + return url && /^https?:$/.test(url.protocol) && !routesToNativeOpenai(config) + ? url.origin + : undefined; +} + // A stored login does not mean this provider's traffic spends that login's quota. export const codexRoutesSpendNativeQuota = ( account: CodexSchema.V2GetAccountResponse, @@ -140,6 +153,7 @@ const CODEX_PRESENTATION = { export interface CodexAppServerProviderSnapshot { readonly account: CodexSchema.V2GetAccountResponse; + readonly endpoint?: string | undefined; readonly nativeUsage?: boolean; readonly rateLimits?: CodexRateLimitsProbe; readonly version: string | undefined; @@ -757,6 +771,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun const cyberAccess = Option.getOrUndefined(decodeCodexCyberAccessConfig(config)); return { account: accountResponse, + endpoint: config ? codexCustomEndpoint(config) : undefined, nativeUsage, ...(rateLimits ? { rateLimits } : {}), version, @@ -1075,7 +1090,9 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu installed: true, version: snapshot.version ?? null, status: snapshot.models === undefined ? "warning" : accountStatus.status, - auth: accountStatus.auth, + auth: snapshot.endpoint + ? { ...accountStatus.auth, endpoint: snapshot.endpoint } + : accountStatus.auth, ...(accountStatus.message ? { message: accountStatus.message } : snapshot.models === undefined diff --git a/apps/server/src/provider/claudeUsageLimits.ts b/apps/server/src/provider/claudeUsageLimits.ts index 8f81f2e7c7ef3..ba1263ef704bb 100644 --- a/apps/server/src/provider/claudeUsageLimits.ts +++ b/apps/server/src/provider/claudeUsageLimits.ts @@ -47,8 +47,20 @@ export const makeClaudeScopedLimitNames = Ref.make({ overageIncluded: undefined, }); +const SCOPED_WINDOW_PREFIX = "seven_day_"; +const scopeToken = (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, "_"); + function scopedWindowId(displayName: string): string { - return `seven_day_${displayName.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`; + return `${SCOPED_WINDOW_PREFIX}${scopeToken(displayName)}`; +} + +/** A model-scoped weekly allowance blocks only its own model; an unknown model is not blocked. */ +export function windowLimitsModel( + window: Pick, + model: string | null, +): boolean { + if (!window.id.startsWith(SCOPED_WINDOW_PREFIX)) return true; + return model !== null && scopeToken(model).includes(window.id.slice(SCOPED_WINDOW_PREFIX.length)); } function scopedWindow( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index dae95787afe82..9353fa2f508a8 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -574,6 +574,7 @@ const layerScheduledTaskWebhookOrigin = Layer.effect( ); const layerOrchestrationV2Runtime = RuntimeLayer.layerProduction.pipe( + Layer.provide(UsageLimitSources.layerResetFallback), Layer.provide(layerScheduledTaskWebhookOrigin), Layer.provide(ProviderUsageAnalytics.layer), Layer.provide(layerCheckpointStore), diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index ae3e468d099e4..d7e73a1da96c9 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -1,11 +1,21 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { expect, it } from "@effect/vitest"; -import { SecretProviderId, ServerConfigStreamEvent, UsageLimitSourceId } from "@t3tools/contracts"; +import { + ProviderDriverKind, + SecretProviderId, + ServerConfigStreamEvent, + UsageLimitSourceId, + type ServerProviderUsageWindow, + type UsageLimitSourceSnapshot, +} from "@t3tools/contracts"; import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; @@ -26,6 +36,14 @@ it.effect("keeps malformed source URLs encodable for config subscribers", () => "hub.test:8317", "Usage limit source", ]); + // Only an http(s) hub URL names an endpoint an agent can be sent to. + expect(snapshots.map((source) => source.origin)).toEqual([ + undefined, + undefined, + undefined, + "https://hub.test:8317", + undefined, + ]); expect(() => encodeConfigEvent({ version: 1, @@ -155,3 +173,135 @@ it.effect("reads a management key from its secret provider", () => { ]), ); }); + +const NOW = Date.parse("2026-11-13T12:00:00.000Z"); +const at = (hours: number) => DateTime.formatIso(DateTime.makeUnsafe(NOW + hours * 3_600_000)); +const spent = (id: string, hours: number): ServerProviderUsageWindow => ({ + id, + kind: "weekly", + label: id, + usedPercent: 100, + resetsAt: at(hours), +}); +const hub = ( + accounts: ReadonlyArray<{ + driver?: string; + windows?: ReadonlyArray; + unavailable?: true; + }>, +): UsageLimitSourceSnapshot => ({ + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy", + label: "hub", + checkedAt: at(0), + accounts: accounts.map((account, index) => ({ + id: `account-${index}`, + driver: ProviderDriverKind.make(account.driver ?? "claudeAgent"), + usageLimits: { + checkedAt: at(0), + windows: account.windows ?? [], + ...(account.unavailable ? { unavailable: { reason: "probeFailed" as const } } : {}), + }, + })), +}); + +it.each([ + { + name: "earliest refill when every account is spent, each at its latest spent window", + accounts: [ + { windows: [spent("five_hour", 1), spent("seven_day", 3)] }, + { windows: [spent("five_hour", 2)] }, + { driver: "codex", windows: [] }, + ], + model: "claude-haiku-5-5", + expected: at(2), + }, + { + name: "unknown while an account still has room", + accounts: [{ windows: [spent("five_hour", 1)] }, { windows: [] }], + model: "claude-haiku-5-5", + expected: null, + }, + { + name: "unknown while an account could not be read", + accounts: [{ windows: [spent("five_hour", 1)] }, { unavailable: true as const }], + model: "claude-haiku-5-5", + expected: null, + }, + { + name: "unknown when a spent window has no reset or already reset", + accounts: [{ windows: [{ ...spent("five_hour", 1), resetsAt: undefined }] }], + model: "claude-haiku-5-5", + expected: null, + }, + { + name: "a model-scoped weekly window blocks only its own model", + accounts: [{ windows: [spent("seven_day_fable", 5)] }], + model: "claude-haiku-5-5", + expected: null, + }, + { + name: "a model-scoped weekly window blocks its own model", + accounts: [{ windows: [spent("seven_day_fable", 5)] }], + model: "claude-fable-5-2", + expected: at(5), + }, + { + name: "no account of the driver", + accounts: [{ driver: "codex", windows: [spent("five_hour", 1)] }], + model: "claude-haiku-5-5", + expected: null, + }, +])("hub pool reset: $name", ({ accounts, model, expected }) => { + expect( + UsageLimitSources.hubPoolResetAt( + [hub(accounts)], + ProviderDriverKind.make("claudeAgent"), + model, + NOW, + ), + ).toBe(expected); +}); + +it.effect("the reset fallback refreshes its hub, and a stalled hub cannot hold the failure", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const roomLeft = { ...hub([{ windows: [spent("five_hour", 2)] }]), origin: "http://hub.test" }; + const cached = { + ...roomLeft, + accounts: roomLeft.accounts.map((account) => ({ + ...account, + usageLimits: { + ...account.usageLimits, + windows: account.usageLimits.windows.map((window) => ({ ...window, usedPercent: 99 })), + }, + })), + }; + let current: ReadonlyArray = [cached]; + let refresh: Effect.Effect = Effect.sync(() => { + current = [roomLeft]; + }); + const lookup = UsageLimitSources.UsageLimitResetFallback.pipe( + Effect.flatMap((fallback) => + fallback.resetAt(ProviderDriverKind.make("claudeAgent"), "http://hub.test", null), + ), + Effect.provide( + UsageLimitSources.layerResetFallback.pipe( + Layer.provide( + Layer.mock(UsageLimitSources.UsageLimitSources)({ + current: Effect.sync(() => current), + refresh: Effect.suspend(() => refresh), + }), + ), + ), + ), + ); + // The last poll saw room; the account was spent after it. + expect(yield* lookup).toBe(at(2)); + current = [cached]; + refresh = Effect.never; + const stalled = yield* Effect.forkChild(lookup); + yield* TestClock.adjust("5 seconds"); + expect(yield* Fiber.join(stalled)).toBeNull(); + }), +); diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 7d40b0fb9ecea..c591931843411 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -3,6 +3,7 @@ import { UsageLimitSourceError, type UsageLimitSourceConsumeResetCreditInput, type ProviderConsumeResetCreditResult, + type ProviderDriverKind, type ServerSettings, UsageLimitSourceConfig, settingsSecretLiteral, @@ -26,6 +27,7 @@ import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretReso import * as SecretResolverLayer from "../secrets/SecretResolver.ts"; import { resolveSettingsSecrets } from "../secrets/settingsSecretValue.ts"; import * as Settings from "../serverSettings.ts"; +import { windowLimitsModel } from "../provider/claudeUsageLimits.ts"; import { makeCliproxyApi } from "./cliproxyApi.ts"; export class UsageLimitSources extends Context.Service< @@ -79,7 +81,14 @@ export const make = Effect.gen(function* () { config: UsageLimitSourceConfig, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); - const base = { id, kind: config.kind, label: sourceLabel(id, config), checkedAt } as const; + const url = URL.parse(config.url); + const base = { + id, + kind: config.kind, + label: sourceLabel(id, config), + ...(url && /^https?:$/.test(url.protocol) ? { origin: url.origin } : {}), + checkedAt, + } as const; const managementKey = yield* readManagementKey(config); if (managementKey.length === 0) { return { ...base, accounts: [], error: missingManagementKey(config) }; @@ -198,3 +207,64 @@ export const make = Effect.gen(function* () { export const layer = Layer.effect(UsageLimitSources, make).pipe( Layer.provide(SecretResolverLayer.layer), ); + +/** A hub answers with a usage limit only when every account it routes the driver to is spent. */ +export function hubPoolResetAt( + sources: ReadonlyArray, + driver: ProviderDriverKind, + model: string | null, + now: number, +): string | null { + let earliest: number | null = null; + for (const source of sources) { + for (const account of source.accounts) { + if (account.driver !== driver) continue; + // An unread account may be the one still blocked, so its refill is unknown. + if (account.usageLimits.unavailable) return null; + const spent = account.usageLimits.windows.filter( + (window) => window.usedPercent >= 100 && windowLimitsModel(window, model), + ); + if (spent.length === 0) return null; + const resets = spent.map((window) => Date.parse(window.resetsAt ?? "")); + if (resets.some((reset) => !Number.isFinite(reset))) return null; + const recoversAt = Math.max(...resets); + if (recoversAt <= now) return null; + earliest = earliest === null ? recoversAt : Math.min(earliest, recoversAt); + } + } + return earliest === null ? null : DateTime.formatIso(DateTime.makeUnsafe(earliest)); +} + +export class UsageLimitResetFallback extends Context.Reference<{ + readonly resetAt: ( + driver: ProviderDriverKind, + endpoint: string, + model: string | null, + ) => Effect.Effect; +}>("t3/usage/UsageLimitResetFallback", { + defaultValue: () => ({ resetAt: () => Effect.succeed(null) }), +}) {} + +export const layerResetFallback = Layer.effect( + UsageLimitResetFallback, + Effect.gen(function* () { + const sources = yield* UsageLimitSources; + return { + resetAt: Effect.fn("UsageLimitResetFallback.resetAt")(function* (driver, endpoint, model) { + // Only the hub at the instance's endpoint serves its turns. + const atEndpoint = Effect.map(sources.current, (current) => + current.filter((source) => source.origin === endpoint), + ); + if ((yield* atEndpoint).length === 0) return null; + // The pool usually ran out after the last poll, so the cached accounts still show room. + yield* sources.refresh.pipe(Effect.timeout("5 seconds"), Effect.ignore); + return hubPoolResetAt( + yield* atEndpoint, + driver, + model, + DateTime.toEpochMillis(yield* DateTime.now), + ); + }), + }; + }), +); diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index ead95f54af7b8..7a356ba4e2d11 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -86,6 +86,8 @@ export const UsageLimitSourceSnapshot = Schema.Struct({ id: UsageLimitSourceId, kind: Schema.Literal("cliproxy"), label: TrimmedNonEmptyString, + /** Origin of the hub, which also serves the agents sent through it. */ + origin: Schema.optional(TrimmedNonEmptyString), checkedAt: IsoDateTime, accounts: ForwardCompatibleArray(UsageLimitSourceAccount), error: Schema.optional(TrimmedNonEmptyString), diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index a983e7c0826ff..e8bcda3a76f5d 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -70,6 +70,8 @@ export const ServerProviderAuth = Schema.Struct({ subscriptionSharing: Schema.optional(Schema.Boolean), profileId: Schema.optional(TrimmedNonEmptyString), workspaceId: Schema.optional(TrimmedNonEmptyString), + /** Origin of the custom endpoint the instance sends its requests to. */ + endpoint: Schema.optional(TrimmedNonEmptyString), }); export type ServerProviderAuth = typeof ServerProviderAuth.Type; From 2d5e0ae2bd60d27422596cbd3d5d84d569b0ad51 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 10:10:52 -0800 Subject: [PATCH 019/409] fix(server): session import brings in the linked-worktree sessions the Welcome page counts (#36242) --- .../src/project/AgentSessionImporter.test.ts | 9 +- .../src/project/AgentSessionImporter.ts | 6 +- .../src/project/AgentSessionScanner.test.ts | 64 ++++++ .../server/src/project/AgentSessionScanner.ts | 185 ++++++++++++------ 4 files changed, 195 insertions(+), 69 deletions(-) diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index b05a0a0504cdd..4378a7fde8470 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -92,6 +92,7 @@ function candidate( { role: "assistant" as const, text: "Fixed", createdAt: timestamp }, ], }, + worktree: null as { path: string; branch: string | null } | null, } satisfies AgentSessionScanner.AgentSessionRecentThread; } @@ -304,6 +305,7 @@ it.effect( ); const unowned = candidate(CODEX_MODEL_SELECTION, "unowned"); unowned.thread.omittedMessageCount = 1; + unowned.worktree = { path: "/workspace/project/.claude/worktrees/feat", branch: "feat" }; const unownedPi = candidate(PI_MODEL_SELECTION, "unowned-pi", "pi"); const importer = yield* makeImporter([ active, @@ -356,6 +358,8 @@ it.effect( expect(notices).toEqual([]); } expect(records.thread.historyOrigin).toBe("v1_import"); + expect(records.thread.worktreePath).toBe(source.worktree?.path ?? null); + expect(records.thread.branch).toBe(source.worktree?.branch ?? null); expect(records.thread.activeProviderThreadId).toBe(records.providerThreads[0]?.id); expect(records.providerThreads[0]?.nativeThreadRef?.nativeId).toBe(nativeId(source)); expect(Option.getOrThrow(yield* runtimes.getByThreadId({ threadId }))).toMatchObject({ @@ -364,7 +368,10 @@ it.effect( source.source.provider === "claudeAgent" ? { threadId, resume: source.source.providerSessionId } : { threadId: nativeId(source) }, - runtimePayload: { importedTranscripts: [source.source] }, + runtimePayload: { + cwd: source.worktree?.path ?? "/workspace/project", + importedTranscripts: [source.source], + }, }); } }).pipe(Effect.scoped, Effect.provide(persistence)), diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index 9c80ebf1049bf..6200fc11188fb 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -309,7 +309,7 @@ const make = Effect.gen(function* () { thread.source === "claudeAgent" ? { threadId, resume: thread.providerSessionId } : { threadId: nativeThreadId }, - runtimePayload: { cwd: project.workspaceRoot }, + runtimePayload: { cwd: outcome.worktree?.path ?? project.workspaceRoot }, }, { onConflict: "ignore" }, ); @@ -362,8 +362,8 @@ const make = Effect.gen(function* () { modelSelection: { instanceId: thread.providerInstanceId, model }, runtimeMode, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - branch: null, - worktreePath: null, + branch: outcome.worktree?.branch ?? null, + worktreePath: outcome.worktree?.path ?? null, linkedPullRequest: null, branchPullRequest: null, activeProviderThreadId: providerThreadId, diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 48b52d97ad132..6dac5a1b0b6c9 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -1104,6 +1104,70 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); + it.effect("imports a checkout's sessions from the linked worktrees the scan counts", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const repo = yield* makeTempDir("t3code-workspace-repo-"); + yield* fileSystem.makeDirectory(path.join(repo, ".git")); + yield* fileSystem.writeFileString(path.join(repo, ".git", "config"), "[core]\n"); + const worktreeGitDir = path.join(repo, ".git", "worktrees", "feature"); + yield* fileSystem.makeDirectory(worktreeGitDir, { recursive: true }); + yield* fileSystem.writeFileString(path.join(worktreeGitDir, "commondir"), "../..\n"); + yield* fileSystem.writeFileString( + path.join(worktreeGitDir, "HEAD"), + "ref: refs/heads/feature\n", + ); + const worktree = path.join(repo, ".claude", "worktrees", "feature"); + yield* fileSystem.makeDirectory(worktree, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(worktree, ".git"), + `gitdir: ${worktreeGitDir}\n`, + ); + + for (const [index, [cwd, sessionId]] of [ + [repo, "at-root"], + [worktree, "in-worktree"], + ].entries()) { + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, `${sessionId}.jsonl`), + contents: `${JSON.stringify({ + type: "user", + cwd, + sessionId, + message: { role: "user", content: "Fix the project" }, + })}\n${JSON.stringify({ + type: "assistant", + sessionId, + message: { role: "assistant", content: [{ type: "text", text: "Done" }] }, + })}\n`, + mtimeMs: nowMs - (index + 1) * 60 * 60 * 1000, + }); + } + + const input = { claudeHomePath, codexHomePath }; + const { candidates } = yield* runScan(input); + expect(candidates.map(({ path, threadCount }) => ({ path, threadCount }))).toEqual([ + { path: repo, threadCount: 2 }, + ]); + const outcomes = yield* runRecentThreadOutcomes({ ...input, workspaceRoot: repo }); + expect( + outcomes.flatMap((outcome) => + outcome._tag === "Importable" + ? [{ sessionId: outcome.thread.providerSessionId, worktree: outcome.worktree }] + : [], + ), + ).toEqual([ + { sessionId: "at-root", worktree: null }, + { sessionId: "in-worktree", worktree: { path: worktree, branch: "feature" } }, + ]); + }), + ); + it.effect("excludes sandboxes under the configured worktrees dir without .t3 in the path", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index b47cef441c925..0de2e84b1f6e3 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -223,6 +223,8 @@ export type AgentSessionRecentThread = readonly _tag: "Importable"; readonly thread: AgentSessionThread; readonly source: AgentSessionImportSource; + /** The root's linked worktree the session ran in, or null when it ran in the root. */ + readonly worktree: { readonly path: string; readonly branch: string | null } | null; } | { readonly _tag: "AlreadyImported"; readonly source: AgentSessionImportSource } | { readonly _tag: "Duplicate"; readonly source: AgentSessionImportSource } @@ -979,19 +981,24 @@ export const make = Effect.gen(function* () { | { readonly _tag: "Repository"; readonly git: AgentSessionProjectGit | null } | { readonly _tag: "Worktree"; + readonly gitDir: string; readonly mainCheckout: string | null; readonly git: AgentSessionProjectGit | null; } | { readonly _tag: "NotGit" }; const identities = new Map(); - const repositories: Array<{ directory: string; gitDir: string; linkedWorktree: boolean }> = []; + const repositories: Array<{ + directory: string; + gitDir: string; + worktreeGitDir: string | null; + }> = []; const pointers: Array<{ directory: string; gitPath: string }> = []; for (const directory of directories) { const gitPath = path.join(directory, ".git"); const gitStats = yield* statOption(gitPath); if (Option.isNone(gitStats)) identities.set(directory, { _tag: "NotGit" }); else if (gitStats.value.type === "Directory") { - repositories.push({ directory, gitDir: gitPath, linkedWorktree: false }); + repositories.push({ directory, gitDir: gitPath, worktreeGitDir: null }); } else pointers.push({ directory, gitPath }); } @@ -1006,7 +1013,7 @@ export const make = Effect.gen(function* () { const gitDir = path.resolve(directory, target); if (/[\\/]worktrees[\\/][^\\/]+[\\/]?$/.test(gitDir)) { linkedWorktrees.push({ directory, gitDir }); - } else repositories.push({ directory, gitDir, linkedWorktree: false }); + } else repositories.push({ directory, gitDir, worktreeGitDir: null }); } const commonDirTexts = yield* readGitFiles( @@ -1015,19 +1022,19 @@ export const make = Effect.gen(function* () { for (const { directory, gitDir } of linkedWorktrees) { const commonDir = (commonDirTexts.get(path.join(gitDir, "commondir")) ?? "").trim(); if (commonDir.length === 0) { - identities.set(directory, { _tag: "Worktree", mainCheckout: null, git: null }); + identities.set(directory, { _tag: "Worktree", gitDir, mainCheckout: null, git: null }); } else repositories.push({ directory, gitDir: path.resolve(gitDir, commonDir), - linkedWorktree: true, + worktreeGitDir: gitDir, }); } const configTexts = yield* readGitFiles( repositories.map(({ gitDir }) => path.join(gitDir, "config")), ); - for (const { directory, gitDir, linkedWorktree } of repositories) { + for (const { directory, gitDir, worktreeGitDir } of repositories) { const configText = configTexts.get(path.join(gitDir, "config")) ?? ""; const originUrl = parseOriginUrlFromGitConfig(configText); const git: AgentSessionProjectGit = { @@ -1036,9 +1043,10 @@ export const make = Effect.gen(function* () { repository: repositoryPathFromRemoteUrl(originUrl), }; // Bare repositories have no main checkout, so offer their linked worktrees independently. - if (linkedWorktree && !isBareGitConfig(configText)) { + if (worktreeGitDir !== null && !isBareGitConfig(configText)) { identities.set(directory, { _tag: "Worktree", + gitDir: worktreeGitDir, // Only a common dir named .git sits inside its main checkout, and an // unreadable config can't rule out a bare repository. mainCheckout: @@ -1052,6 +1060,61 @@ export const make = Effect.gen(function* () { return identities; }); + /** Maps each directory to the project it counts toward: a linked worktree counts toward its main checkout. */ + const resolveDirectoryProjects = Effect.fn("AgentSessionScanner.resolveDirectoryProjects")( + function* (resolvedDirectories: ReadonlyArray) { + // Stat every directory first so one batch reads all git identities. + const directories = new Map>(); + for (const resolved of resolvedDirectories) { + if (directories.has(resolved)) continue; + directories.set(resolved, yield* projectDirectoryStats(resolved)); + } + const directoryGitIdentities = yield* readGitIdentities( + Array.from(directories).flatMap(([directory, stats]) => + Option.isSome(stats) ? [directory] : [], + ), + ); + const projects = new Map< + string, + { + readonly key: string; + readonly path: string; + readonly git: AgentSessionProjectGit | null; + readonly worktreeGitDir: string | null; + } | null + >(); + for (const [resolved, stats] of directories) { + const gitIdentity = directoryGitIdentities.get(resolved); + if (Option.isNone(stats) || gitIdentity === undefined) { + projects.set(resolved, null); + } else if (gitIdentity._tag === "Worktree") { + const { mainCheckout } = gitIdentity; + const mainStats = + mainCheckout === null ? Option.none() : yield* projectDirectoryStats(mainCheckout); + projects.set( + resolved, + mainCheckout !== null && Option.isSome(mainStats) + ? { + key: yield* directoryIdentity(mainCheckout, mainStats.value), + path: mainCheckout, + git: gitIdentity.git, + worktreeGitDir: gitIdentity.gitDir, + } + : null, + ); + } else { + projects.set(resolved, { + key: yield* directoryIdentity(resolved, stats.value), + path: resolved, + git: gitIdentity._tag === "Repository" ? gitIdentity.git : null, + worktreeGitDir: null, + }); + } + } + return projects; + }, + ); + const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* ( source: AgentSessionSource, transcript: TranscriptCandidate, @@ -1769,64 +1832,18 @@ export const make = Effect.gen(function* () { git: AgentSessionProjectGit | null; } >(); - // Resolve every candidate directory first so one batch reads all git identities. - const directories = new Map>(); - for (const candidate of raw) { - const expanded = expandHomePath(candidate.cwd.trim(), homeDir); - if (!path.isAbsolute(expanded)) continue; - const resolved = path.resolve(expanded); - if (directories.has(resolved)) continue; - directories.set(resolved, yield* projectDirectoryStats(resolved)); - } - const directoryGitIdentities = yield* readGitIdentities( - Array.from(directories).flatMap(([directory, stats]) => - Option.isSome(stats) ? [directory] : [], - ), + const directoryProjects = yield* resolveDirectoryProjects( + raw.flatMap((candidate) => { + const expanded = expandHomePath(candidate.cwd.trim(), homeDir); + return path.isAbsolute(expanded) ? [path.resolve(expanded)] : []; + }), ); - const directoryProjects = new Map< - string, - { - readonly key: string; - readonly path: string; - readonly git: AgentSessionProjectGit | null; - } | null - >(); - for (const candidate of raw) { const expanded = expandHomePath(candidate.cwd.trim(), homeDir); if (!path.isAbsolute(expanded)) continue; - const resolved = path.resolve(expanded); - const stats = directories.get(resolved); - if (stats === undefined || Option.isNone(stats)) continue; - let project = directoryProjects.get(resolved); - if (project === undefined) { - const gitIdentity = directoryGitIdentities.get(resolved); - if (gitIdentity === undefined) { - project = null; - } else if (gitIdentity._tag === "Worktree") { - // A linked worktree's sessions count toward its main checkout's project. - const { mainCheckout } = gitIdentity; - const mainStats = - mainCheckout === null ? Option.none() : yield* projectDirectoryStats(mainCheckout); - project = - mainCheckout !== null && Option.isSome(mainStats) - ? { - key: yield* directoryIdentity(mainCheckout, mainStats.value), - path: mainCheckout, - git: gitIdentity.git, - } - : null; - } else { - project = { - key: yield* directoryIdentity(resolved, stats.value), - path: resolved, - git: gitIdentity._tag === "Repository" ? gitIdentity.git : null, - }; - } - directoryProjects.set(resolved, project); - } - if (project === null) continue; + const project = directoryProjects.get(path.resolve(expanded)); + if (project === undefined || project === null) continue; const existing = merged.get(project.key); if (!existing) { @@ -1916,6 +1933,45 @@ export const make = Effect.gen(function* () { } const { candidates } = cachedCandidates; + // The scan counts a linked worktree's sessions toward its main checkout, so import them with it. + const candidateDirectories = candidates.flatMap((candidate) => { + const expanded = expandHomePath(candidate.cwd.trim(), homeDir); + return path.isAbsolute(expanded) ? [path.resolve(expanded)] : []; + }); + const directoryProjects = yield* resolveDirectoryProjects(candidateDirectories); + const belongsToRoot = Effect.fn("AgentSessionScanner.belongsToRoot")(function* ( + resolved: string, + ) { + if ((yield* directoryIdentity(resolved)) === rootIdentity) return true; + if (!directoryProjects.has(resolved)) { + const project = (yield* resolveDirectoryProjects([resolved])).get(resolved) ?? null; + directoryProjects.set(resolved, project); + } + return directoryProjects.get(resolved)?.key === rootIdentity; + }); + const worktreesByDirectory = new Map(); + const rootWorktree = Effect.fn("AgentSessionScanner.rootWorktree")(function* ( + resolved: string, + ) { + const known = worktreesByDirectory.get(resolved); + if (known !== undefined) return known; + const gitDir = + (yield* directoryIdentity(resolved)) === rootIdentity + ? null + : (directoryProjects.get(resolved)?.worktreeGitDir ?? null); + let worktree: { path: string; branch: string | null } | null = null; + if (gitDir !== null) { + const headPath = path.join(gitDir, "HEAD"); + const head = (yield* readGitFiles([headPath])).get(headPath) ?? ""; + worktree = { + path: resolved, + branch: /^ref: refs\/heads\/(.+)$/.exec(head.trim())?.[1] ?? null, + }; + } + worktreesByDirectory.set(resolved, worktree); + return worktree; + }); + const eligibleTranscripts: Array<{ readonly candidate: RawCandidate; readonly transcript: RawCandidate["transcripts"][number] & { readonly mtimeMs: number }; @@ -1924,7 +1980,7 @@ export const make = Effect.gen(function* () { const expanded = expandHomePath(candidate.cwd.trim(), homeDir); if (!path.isAbsolute(expanded)) continue; const resolved = path.resolve(expanded); - if ((yield* directoryIdentity(resolved)) !== rootIdentity) continue; + if (!(yield* belongsToRoot(resolved))) continue; for (const transcript of candidate.transcripts) { if ( @@ -2055,12 +2111,10 @@ export const make = Effect.gen(function* () { projectCwd(snapshot.cwd.value, transcript.projectRoots).trim(), homeDir, ); - if ( - !path.isAbsolute(expandedCwd) || - (yield* directoryIdentity(path.resolve(expandedCwd))) !== rootIdentity - ) { + if (!path.isAbsolute(expandedCwd) || !(yield* belongsToRoot(path.resolve(expandedCwd)))) { return Option.some({ _tag: "Skipped" }); } + const worktree = yield* rootWorktree(path.resolve(expandedCwd)); const inheritedRecords = candidate.source === "codex" @@ -2116,6 +2170,7 @@ export const make = Effect.gen(function* () { thread: savedTitle === undefined ? parsedThread : { ...parsedThread, title: savedTitle }, source, + worktree, }); }).pipe(importReadLock.withPermits(1)), ), From 571b319643611fb21db2c6ab7a0c38697b8cffd9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 10:19:56 -0800 Subject: [PATCH 020/409] fix(server): a remote delegated task no longer stays running when its follower hits a defect (#32331) --- apps/server/src/peer/RemoteDelegation.test.ts | 199 ++++++++++++++++++ apps/server/src/peer/RemoteDelegation.ts | 73 +++++-- 2 files changed, 255 insertions(+), 17 deletions(-) diff --git a/apps/server/src/peer/RemoteDelegation.test.ts b/apps/server/src/peer/RemoteDelegation.test.ts index 6d09b6b1b3a68..af569e902c027 100644 --- a/apps/server/src/peer/RemoteDelegation.test.ts +++ b/apps/server/src/peer/RemoteDelegation.test.ts @@ -25,6 +25,7 @@ import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; @@ -644,6 +645,8 @@ const makeLaptop = ( >, /** The laptop's links from before a restart, which keep their sessions there. */ kept?: Context.Context>>, + /** Runs as each follow attempt here starts, on its follower's fiber. */ + beforeFollow: Effect.Effect = Effect.void, ) => Effect.gen(function* () { const linking = kept ?? (yield* layerLinkingEnvironment(laptop).pipe(Layer.build)); @@ -672,6 +675,19 @@ const makeLaptop = ( Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), Layer.provideMerge( RemoteDelegation.layer.pipe( + Layer.provide( + Layer.effect( + ThreadManagement.ThreadManagementService, + Effect.gen(function* () { + const threads = yield* ThreadManagement.ThreadManagementService; + return { + ...threads, + getThreadEventSequence: (threadId: ThreadId) => + beforeFollow.pipe(Effect.andThen(threads.getThreadEventSequence(threadId))), + }; + }), + ), + ), Layer.provideMerge(PeerForwarding.layer), Layer.provide( Layer.mock(ProjectService.ProjectService)({ @@ -1784,6 +1800,108 @@ const steppedRetries = Effect.gen(function* () { return { failed, retry, schedule }; }); +it.effect("a follower that keeps hitting defects fails the task and wakes the parent", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const retries = yield* steppedRetries; + const attempts = yield* Ref.make(0); + // The defect carries internal detail that the task's result must not. + const beforeFollow = Ref.update(attempts, (count) => count + 1).pipe( + Effect.andThen(Effect.die(new Error("A follow attempt hit a bug: defect-detail-4f1c9e"))), + ); + const a = yield* makeLaptop(database, offers, undefined, beforeFollow).pipe( + Effect.provideService(RemoteDelegation.FollowRetrySchedule, retries.schedule), + ); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + yield* Queue.take(retries.failed).pipe( + Effect.andThen(Queue.offer(retries.retry, undefined)), + Effect.forever, + Effect.forkScoped, + ); + + const delegated = yield* delegateToBox(a); + const task = yield* taskResult(a, delegated.taskId); + expect(yield* Ref.get(attempts)).toBe(3); + expect(task?.status).toBe("failed"); + expect(task?.result).toContain(delegated.remoteChild.threadId); + expect(task?.result).not.toContain("defect-detail-4f1c9e"); + const offered = yield* SubscriptionRef.changes(offers).pipe( + Stream.filter((seen) => seen.some((offer) => offer.threadId === parentThreadId)), + Stream.runHead, + ); + expect(Option.isSome(offered)).toBe(true); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect.each(["a still-running wait", "a failed attempt"] as const)( + "defects separated by %s do not add up to giving up", + (between) => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const retries = yield* steppedRetries; + const attempts = yield* Ref.make(0); + const defect = Effect.die(new Error("A follow attempt hit a bug.")); + const script = [ + defect, + between === "a still-running wait" + ? Effect.void + : Effect.fail( + new Orchestrator.OrchestratorProjectionError({ threadId: parentThreadId }), + ), + defect, + between === "a still-running wait" + ? Effect.void + : Effect.fail( + new Orchestrator.OrchestratorProjectionError({ threadId: parentThreadId }), + ), + defect, + ]; + // Past the script, the run there has ended, so the next attempt completes the task. + const beforeFollow = Ref.getAndUpdate(attempts, (count) => count + 1).pipe( + Effect.flatMap( + (count) => + script[count] ?? + Ref.set(thread.reply, "All green.").pipe( + Effect.andThen(Ref.set(thread.status, "completed")), + ), + ), + ); + // Each wait there answers at once that its run is still running. + yield* Deferred.succeed(thread.finished, undefined); + const a = yield* makeLaptop(database, offers, undefined, beforeFollow).pipe( + Effect.provideService(RemoteDelegation.FollowRetrySchedule, retries.schedule), + ); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + yield* Queue.take(retries.failed).pipe( + Effect.andThen(Queue.offer(retries.retry, undefined)), + Effect.forever, + Effect.forkScoped, + ); + + const delegated = yield* delegateToBox(a); + expect(yield* taskResult(a, delegated.taskId)).toMatchObject({ + status: "completed", + result: "All green.", + }); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + /** Waits until the task here records that the `stopCount`th Stop after its result reached there. */ const stoppedThere = ( laptopEnv: Effect.Success>, @@ -1813,6 +1931,87 @@ const stoppedThere = ( ), ); +it.effect("a follower that keeps hitting defects leaves a stop it owes owed", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const retries = yield* steppedRetries; + // Follow attempts hit a defect only once the task has its result. + const broken = yield* Ref.make(false); + const follower = yield* Deferred.make>(); + const beforeFollow = Ref.get(broken).pipe( + Effect.flatMap((isBroken) => + isBroken + ? Effect.withFiber((fiber) => Deferred.succeed(follower, fiber)).pipe( + Effect.andThen(Effect.die(new Error("A follow attempt hit a bug."))), + ) + : Effect.void, + ), + ); + const a = yield* makeLaptop(database, offers, undefined, beforeFollow).pipe( + Effect.provideService(RemoteDelegation.FollowRetrySchedule, retries.schedule), + ); + yield* a.remote.start(); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + const delegated = yield* delegateToBox(a); + yield* Ref.set(thread.reply, "All green."); + yield* Ref.set(thread.status, "completed"); + yield* Deferred.succeed(thread.finished, undefined); + yield* taskResult(a, delegated.taskId); + yield* Ref.set(broken, true); + yield* Queue.take(retries.failed).pipe( + Effect.andThen(Queue.offer(retries.retry, undefined)), + Effect.forever, + Effect.forkScoped, + ); + + // The Stop owes one stop there, which every attempt to send fails on. + yield* a.threads.stopDelegatedTasks({ + threadId: parentThreadId, + commandId: CommandId.make("command:stop:laptop-parent-defective-follower"), + }); + const owedAgain = a.orchestrator + .streamStoredEventsFrom({ + threadId: parentThreadId, + afterSequence: 0, + eventType: "subagent.updated", + }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "subagent.updated" && + stored.event.payload.id === delegated.taskId && + (stored.event.payload.remoteChild?.stopCount ?? 0) > 1, + ), + Stream.runHead, + Effect.flatMap((owed) => + Option.isSome(owed) ? Effect.succeed("owed another stop" as const) : Effect.never, + ), + ); + const ended = yield* Deferred.await(follower).pipe( + Effect.flatMap(Fiber.await), + Effect.as("follower ended" as const), + Effect.raceFirst(owedAgain), + ); + expect(ended).toBe("follower ended"); + const { subagents } = yield* a.threads.getThreadRecords(parentThreadId, ["subagents"], { + subagentIds: [delegated.taskId], + }); + expect(subagents[0]).toMatchObject({ + status: "completed", + result: "All green.", + remoteChild: { stoppedThere: false, stopCount: 1 }, + }); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.effect("stopping the parent stops follow-up work on a task there that has its result", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/peer/RemoteDelegation.ts b/apps/server/src/peer/RemoteDelegation.ts index 3b01c48b01db0..c9e38b4bc73d4 100644 --- a/apps/server/src/peer/RemoteDelegation.ts +++ b/apps/server/src/peer/RemoteDelegation.ts @@ -19,6 +19,7 @@ import { type RunId, type ThreadId, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; @@ -53,6 +54,8 @@ import * as PeerLinks from "./PeerLinks.ts"; const FOLLOW_WAIT_MS = 10 * 60 * 1_000; /** A linked environment that stops answering is retried, backing off to this. */ const FOLLOW_MAX_BACKOFF = Duration.minutes(5); +/** A follower that hits this many defects in a row fails its task instead of retrying. */ +const FOLLOW_MAX_DEFECTS = 3; /** How a follower retries; tests step it by hand. */ export const FollowRetrySchedule = Context.Reference>( "t3/peer/RemoteDelegation/FollowRetrySchedule", @@ -673,26 +676,54 @@ const make = Effect.gen(function* () { /** * Waits for the task's run there to end, completes the task here, and stops it there if it * ended here first. A revoked, expired or forgotten link, or a thread or run gone there, - * fails the task with nothing left to stop; anything else (the other machine asleep, a - * dropped route) is retried with back-off, and so is completing. + * fails the task with nothing left to stop; repeated defects here fail it too. Anything else + * (the other machine asleep, a dropped route) is retried with back-off, and so is completing. */ const follow = (parentThreadId: ThreadId, taskId: NodeId): Effect.Effect => - followOnce(parentThreadId, taskId).pipe( - // `false` means it is still running there: follow again straight away. - Effect.repeat({ while: (done) => !done }), - Effect.catchTags({ - OrchestratorMcpFailure: Effect.fn("RemoteDelegation.followFailed")(function* (error) { - const reason = yield* finalReason(parentThreadId, taskId, error); - if (reason === undefined) return yield* error; - // Gone there, or no link to reach it by: nothing left to stop there. - const { task } = yield* readTask(parentThreadId, taskId); - const stopCount = task?.remoteChild?.stopCount; - yield* complete(parentThreadId, taskId, "failed", reason, "stopped", stopCount); + Effect.suspend(() => { + let defects = 0; + const noDefect = Effect.sync(() => { + defects = 0; + }); + return followOnce(parentThreadId, taskId).pipe( + Effect.tap(noDefect), + // `false` means it is still running there: follow again straight away. + Effect.repeat({ while: (done) => !done }), + Effect.catchTags({ + OrchestratorMcpFailure: Effect.fn("RemoteDelegation.followFailed")(function* (error) { + const reason = yield* finalReason(parentThreadId, taskId, error); + if (reason === undefined) return yield* error; + // Gone there, or no link to reach it by: nothing left to stop there. + const { task } = yield* readTask(parentThreadId, taskId); + const stopCount = task?.remoteChild?.stopCount; + yield* complete(parentThreadId, taskId, "failed", reason, "stopped", stopCount); + }), }), - }), - Effect.retry(followRetry), - Effect.ignoreCause({ log: true }), - ); + Effect.tapError(() => noDefect), + Effect.tapDefect(() => + Effect.sync(() => { + defects += 1; + }), + ), + failOnDefect, + Effect.retry({ schedule: followRetry, while: () => defects < FOLLOW_MAX_DEFECTS }), + Effect.catch( + Effect.fn("RemoteDelegation.followGaveUp")( + function* () { + const { task } = yield* readTask(parentThreadId, taskId); + const remote = task?.remoteChild; + // A task with its result keeps it; completing it again would owe another stop there. + if (remote === undefined || task?.result !== null) return; + const result = `Following the thread ${remote.threadId} in ${remote.label} failed repeatedly, so it may still run there (task_cancel stops it).`; + yield* complete(parentThreadId, taskId, "failed", result, undefined); + }, + failOnDefect, + Effect.retry(followRetry), + ), + ), + Effect.ignoreCause({ log: true }), + ); + }); /** * One wait on the task's thread there; true once the task here is done. @@ -976,6 +1007,14 @@ const make = Effect.gen(function* () { return RemoteDelegation.of({ delegate, followUp, cancel, start }); }); +/** A defect, logged, as a failure, so a retry covers it. */ +const failOnDefect = (effect: Effect.Effect) => + Effect.catchDefect(effect, (defect) => + Effect.logWarning("A remote task's follower hit a defect", Cause.die(defect)).pipe( + Effect.andThen(Effect.fail(new Cause.UnknownError(defect, "A follower hit a defect."))), + ), + ); + const isTerminal = (status: string) => status === "completed" || status === "failed" || From 445d32c2c3abdfac0a8aa070273529168efaac95 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 10:21:46 -0800 Subject: [PATCH 021/409] fix(server): preview clicks fail within their timeout and say why (#34922) --- apps/server/src/preview/ServerBrowser.ts | 6 ++- .../src/preview/ServerBrowserPage.test.ts | 14 ++++++- apps/server/src/preview/ServerBrowserPage.ts | 39 +++++++++++++------ 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index ad9e20ef11b3c..6537576c89a73 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -2626,7 +2626,11 @@ const make = Effect.gen(function* () { return loadError ? { ...snapshot, url: loadError.url, loadError } : snapshot; } case "click": { - const clickInput = input as PreviewAutomationClickInput; + // Time spent queued behind other work on this tab counts against the broker's deadline. + const clickInput = { + ...(input as PreviewAutomationClickInput), + timeoutMs: Math.max(1, Math.floor(deadlineMs - performance.now())), + }; await recordAction(tab, "click", () => ServerBrowserPage.click(tab.page, clickInput, pointerFor(tab)), ); diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 74da1e3825716..550943312c2f1 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -173,10 +173,22 @@ describe("server browser element refs", () => { const hidden = await failure("button"); expect(hidden.tag).toBe("PreviewAutomationTimeoutError"); expect(hidden.message).toBe( - 'The click may have happened; check the page before retrying. "button" still matches an element after the timeout.', + 'The click may have happened; check the page before retrying. "button" still matches an element after the timeout. Playwright last saw: element is not visible.', ); }); + it("gives a late target only the click's remaining time, not a fresh timeout", async () => { + await page.setContent(``); + await expect( + ServerBrowserPage.click(page, { selector: "#save", timeoutMs: 2_000 }), + ).rejects.toThrow(); + expect(await page.locator("body").getAttribute("data-clicked")).toBeNull(); + }); + it("does not retarget a removed ref to a replacement node", async () => { await page.setContent(""); const locator = buttonLocator((await takeSnapshot()).accessibilityTree, "original"); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 20df4457643eb..3354554f1dd90 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -22,6 +22,7 @@ import { constVoid } from "effect/Function"; import * as Schema from "effect/Schema"; import type { CDPSession, ConsoleMessage, Frame, Locator, Page } from "playwright-core"; import * as NodeCrypto from "node:crypto"; +import * as NodeUtil from "node:util"; import { BrowserControlInterrupted } from "./SessionControl.ts"; const isBrowserControlInterrupted = Schema.is(BrowserControlInterrupted); @@ -62,7 +63,7 @@ export const toOperationError = ( if (cause instanceof Error && cause.name === "TimeoutError") { return new ServerBrowserOperationError({ tag: "PreviewAutomationTimeoutError", - message: firstLine, + message: withActionabilityReason(firstLine, cause), detail: timeoutMs === undefined ? undefined : { timeoutMs }, }); } @@ -84,6 +85,17 @@ export const toOperationError = ( }); }; +/** Playwright puts why an element was not actionable in its call log, below the first line. */ +const withActionabilityReason = (message: string, cause: Error) => { + // Playwright dims call-log lines with ANSI escapes when colour is on. + const reason = NodeUtil.stripVTControlCharacters(cause.message) + .split("\n") + .map((line) => line.trim()) + .findLast((line) => line.startsWith("- element is ")) + ?.slice(2); + return reason === undefined ? message : `${message} Playwright last saw: ${reason}.`; +}; + const DEFAULT_TIMEOUT_MS = 15_000; const TARGET_COUNT_MS = 250; const MAX_TARGET_CHARS = 200; @@ -168,11 +180,11 @@ const targetPoint = async ( page: Page, locator: Locator | null, input: { readonly x?: number | undefined; readonly y?: number | undefined }, - timeout: number, + timeout: () => number, ) => { if (locator === null) return { x: input.x ?? 0, y: input.y ?? 0 }; - await scrollIntoView(locator, timeout); - const box = await locator.boundingBox({ timeout }); + await scrollIntoView(locator, timeout()); + const box = await locator.boundingBox({ timeout: timeout() }); if (box) return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; const viewport = page.viewportSize(); return { x: (viewport?.width ?? 0) / 2, y: (viewport?.height ?? 0) / 2 }; @@ -711,10 +723,12 @@ export const click = async ( input: PreviewAutomationClickInput, pointer: PointerReporter = noPointer, ): Promise<{ readonly x: number; readonly y: number }> => { - const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + // One deadline across steps keeps the reply inside the broker's grace; Playwright reads 0 as none. + const deadline = Date.now() + (input.timeoutMs ?? DEFAULT_TIMEOUT_MS); + const remaining = () => Math.max(1, deadline - Date.now()); const locator = targetLocator(page, input); try { - return await clickTarget(page, locator, input, timeout, pointer); + return await clickTarget(page, locator, input, remaining, pointer); } catch (cause) { throw locator === null ? cause : await namedTargetTimeout(locator, input, cause); } @@ -750,10 +764,11 @@ const namedTargetTimeout = async ( : matches === 0 ? `The click may have happened and removed its target or navigated; check the page before retrying. No element matches ${target} after the timeout; take a fresh snapshot and use its locator.` : `The click may have happened; check the page before retrying. ${target} still matches an element after the timeout.`; + const message = withActionabilityReason(reason, cause); return new ServerBrowserOperationError({ tag: "PreviewAutomationTimeoutError", - message: reason, - detail: { reason }, + message, + detail: { reason: message }, cause, }); }; @@ -762,7 +777,7 @@ const clickTarget = async ( page: Page, locator: Locator | null, input: PreviewAutomationClickInput, - timeout: number, + timeout: () => number, pointer: PointerReporter, ) => { const point = await targetPoint(page, locator, input, timeout); @@ -771,7 +786,7 @@ const clickTarget = async ( const clicked = locator === null ? page.mouse.click(point.x, point.y, options) - : locator.click({ ...options, timeout }); + : locator.click({ ...options, timeout: timeout() }); let onDialog = constVoid; const dialogOpened = new Promise<"dialog">((resolve) => { onDialog = () => resolve("dialog"); @@ -852,7 +867,7 @@ export const hover = async ( ) => { const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; const locator = targetLocator(page, input); - await pointer(await targetPoint(page, locator, input, timeout), "move"); + await pointer(await targetPoint(page, locator, input, () => timeout), "move"); if (locator === null) { await page.mouse.move(input.x ?? 0, input.y ?? 0); return; @@ -903,7 +918,7 @@ export const drag = async ( const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; const source = targetLocator(page, { locator: input.source })!; const target = targetLocator(page, { locator: input.target })!; - await pointer(await targetPoint(page, source, {}, timeout), "move"); + await pointer(await targetPoint(page, source, {}, () => timeout), "move"); // Observe drag rejection immediately; an unhandled rejection during cursor movement exits the server. // Chromium delivers a single move before dragstart; steps give the page mousemoves during the drag. const dragFailure = source.dragTo(target, { timeout, steps: 5 }).then( From daed91f26464a280796e38911b7b97455226ef9e Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 13 Nov 2026 12:25:18 -0600 Subject: [PATCH 022/409] fix(shared): batch SQLite worker result transfers (#36067) --- packages/shared/src/nodeSqliteClient.test.ts | 66 ++++++++++++++++++++ packages/shared/src/nodeSqliteClient.ts | 56 +++++++++++++++-- 2 files changed, 116 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/nodeSqliteClient.test.ts b/packages/shared/src/nodeSqliteClient.test.ts index 92ae7134017e9..ac65e7f12daa7 100644 --- a/packages/shared/src/nodeSqliteClient.test.ts +++ b/packages/shared/src/nodeSqliteClient.test.ts @@ -174,6 +174,72 @@ const makeTempDatabase = Effect.gen(function* () { return { filename, other }; }); +it.effect.each([false, true])("batches worker results without losing rows (reader=%s)", (reader) => + Effect.gen(function* () { + const { filename } = yield* makeTempDatabase; + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const route = (effect: Effect.Effect) => + reader ? effect.pipe(SqliteClient.readOnly) : effect; + yield* route(sql`SELECT 1`); + const worker = startedWorkers + .filter((candidate) => !checkpointWorkers.includes(candidate)) + .at(-1)!; + const batches: Array> = []; + const record = (reply: { rows?: ReadonlyArray }) => { + if (Array.isArray(reply.rows) && reply.rows.length > 0) batches.push(reply.rows); + }; + worker.prependListener("message", record); + yield* Effect.gen(function* () { + const text = "x".repeat(16 * 1024); + const query = sql<{ n: number; text: string }>` + WITH RECURSIVE sequence(n) AS ( + SELECT 1 UNION ALL SELECT n + 1 FROM sequence WHERE n < 300 + ) SELECT n, ${text} AS text FROM sequence + `; + const rows = yield* reader ? route(query.values) : query; + assert.equal(rows.length, 300); + for (const [index, row] of rows.entries()) { + assert.deepEqual(row, reader ? [index + 1, text] : { n: index + 1, text }); + } + assert.isAbove(batches.length, 1); + for (const batch of batches) { + assert.isAtMost(batch.length, 16_384); + assert.isAtMost(batch.length * text.length * 2, 8 * 1024 * 1024); + } + batches.length = 0; + const narrow = sql<{ n: number }>` + WITH RECURSIVE sequence(n) AS ( + SELECT 1 UNION ALL SELECT n + 1 FROM sequence WHERE n < 10000 + ) SELECT n FROM sequence + `; + const narrowRows = yield* reader ? route(narrow.values) : narrow; + assert.equal(narrowRows.length, 10_000); + assert.deepEqual(narrowRows.at(-1), reader ? [10_000] : { n: 10_000 }); + assert.equal(batches.length, 1); + }).pipe(Effect.ensuring(Effect.sync(() => worker.off("message", record)))); + }).pipe( + Effect.provide(SqliteClient.layer({ filename, writerWorker: true, readerWorker: true })), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("reuses a worker query after an iterator constraint failure", () => + Effect.gen(function* () { + const { filename } = yield* makeTempDatabase; + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`CREATE TABLE unique_entries(name TEXT UNIQUE)`; + const insert = (name: string) => + sql<{ name: string }>`INSERT INTO unique_entries VALUES (${name}) RETURNING name`; + yield* insert("first"); + const error = yield* insert("first").pipe(Effect.flip); + assert.equal(error.reason._tag, "UniqueViolation"); + assert.deepEqual(yield* insert("second"), [{ name: "second" }]); + }).pipe(Effect.provide(SqliteClient.layer({ filename, writerWorker: true }))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect("keeps another connection from committing between a transaction's read and write", () => Effect.gen(function* () { const { filename, other } = yield* makeTempDatabase; diff --git a/packages/shared/src/nodeSqliteClient.ts b/packages/shared/src/nodeSqliteClient.ts index 73872a8805b0f..fda878a114d9a 100644 --- a/packages/shared/src/nodeSqliteClient.ts +++ b/packages/shared/src/nodeSqliteClient.ts @@ -242,7 +242,7 @@ parentPort.on("message", (request) => { parentPort.close(); return; } - const { id, sql, params, cached, values, raw, safeIntegers } = request; + const { id, sql, params, cached, values, raw, safeIntegers, received } = request; let operation = "open"; try { if (db === undefined) throw new Error("database connection is unavailable"); @@ -252,12 +252,37 @@ parentPort.on("message", (request) => { statement.setReadBigInts(safeIntegers); if (statement.columns().length > 0) { statement.setReturnArrays(values); - parentPort.postMessage({ id, rows: statement.all(...params) }); + let rows = []; + let bytes = 0; + let pendingBatch = false; + for (const row of statement.iterate(...params)) { + let rowBytes = 0; + for (const key in row) { + const value = row[key]; + rowBytes += typeof value === "string" ? value.length * 2 + : ArrayBuffer.isView(value) ? value.byteLength : 8; + } + if (rows.length > 0 && (rows.length >= 16384 || bytes + rowBytes > 8 * 1024 * 1024)) { + // Build the next batch during receipt, but allow only one unacknowledged message. + if (pendingBatch) Atomics.wait(received, 0, 0); + Atomics.store(received, 0, 0); + parentPort.postMessage({ id, rows, more: true }); + rows = []; + bytes = 0; + pendingBatch = true; + } + rows.push(row); + bytes += rowBytes; + } + if (pendingBatch) Atomics.wait(received, 0, 0); + parentPort.postMessage({ id, rows }); } else { const result = statement.run(...params); parentPort.postMessage({ id, rows: raw ? result : [] }); } } catch (error) { + // A failed iterator can report its previous SQLite error when the statement is reused. + statements.delete(sql); parentPort.postMessage({ id, error: { operation, ...describe(error) } }); } }); @@ -283,7 +308,7 @@ type WorkerStartup = | ({ readonly type: "unavailable" } & WorkerFailure); type WorkerReply = - | { readonly id: number; readonly rows: ReadonlyArray } + | { readonly id: number; readonly rows: ReadonlyArray; readonly more?: boolean } | { readonly id: number; readonly error: WorkerFailure & { readonly operation: "open" | "prepare" | "execute" }; @@ -353,7 +378,12 @@ const makeWorkerConnection = Effect.fnUntraced(function* (options: { let stoppedMessage: string | undefined; const pending = new Map< number, - { readonly endsTransaction: boolean; readonly resume: (reply: WorkerReply) => void } + { + readonly endsTransaction: boolean; + readonly received: Int32Array; + readonly rows: Array; + readonly resume: (reply: WorkerReply) => void; + } >(); const stop = (message: string) => { stoppedMessage = message; @@ -377,10 +407,21 @@ const makeWorkerConnection = Effect.fnUntraced(function* (options: { } const request = pending.get(reply.id); if (request === undefined) return; + if ("rows" in reply && reply.more) { + for (const row of reply.rows) request.rows.push(row); + Atomics.store(request.received, 0, 1); + Atomics.notify(request.received, 0); + return; + } pending.delete(reply.id); if (pending.size === 0) worker.unref(); options.onReply?.(); - request.resume(reply); + if ("rows" in reply && request.rows.length > 0) { + for (const row of reply.rows) request.rows.push(row); + request.resume({ id: reply.id, rows: request.rows }); + } else { + request.resume(reply); + } }); worker.on("error", (cause) => stop(`${label} failed: ${cause.message}`)); worker.on("exit", (code) => stop(`${label} exited with code ${code}`)); @@ -399,8 +440,11 @@ const makeWorkerConnection = Effect.fnUntraced(function* (options: { const safeIntegers = Boolean(Context.get(fiber.context, Client.SafeIntegers)); const reply = Effect.callback, SqlError>((resume) => { const id = nextRequestId++; + const received = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)); pending.set(id, { endsTransaction, + received, + rows: [], resume: (reply) => resume( "rows" in reply @@ -411,7 +455,7 @@ const makeWorkerConnection = Effect.fnUntraced(function* (options: { if (pending.size === 1) worker.ref(); const cached = input.cached && !hasLargeBinding(input.params); // oxlint-disable-next-line unicorn/require-post-message-target-origin -- Node workers do not accept a target origin. - worker.postMessage({ id, safeIntegers, ...input, cached }); + worker.postMessage({ id, safeIntegers, ...input, cached, received }); }); // A posted write runs to the end, as it did on the event loop; the caller and its lock // must not move on while the worker is still applying it. Reads stay cancellable. From 78b654f998f651a9d7f00ecaf93499c5ed0bc5ad Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 10:37:52 -0800 Subject: [PATCH 023/409] fix(server): Stop clears a finished delegated child's row held by a stale native subagent (#32307) --- .../src/orchestration-v2/Orchestrator.ts | 200 +++++++++++------- .../src/orchestration-v2/ThreadStop.test.ts | 148 +++++++++++++ 2 files changed, 273 insertions(+), 75 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index a6f412e7ceddd..00f9dfae8d9e1 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1450,6 +1450,21 @@ function lastDeliveredRunForProviderThread( ); } +type DyingNativeSubagent = { + readonly projection: Pick; + readonly subagent: Pick< + Extract, + "subagentId" | "origin" | "childThreadId" | "providerInstanceId" + >; +}; + +const nativeSubagentRef = (subagent: OrchestrationV2Subagent): DyingNativeSubagent["subagent"] => ({ + subagentId: subagent.id, + origin: subagent.origin, + childThreadId: subagent.childThreadId, + providerInstanceId: subagent.providerInstanceId, +}); + const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(function* () { const checkpointService = yield* CheckpointServiceV2; const commandPolicy = yield* CommandPolicyV2; @@ -11809,6 +11824,78 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } }); + // Ends each native subagent's record, its own thread's runless root turns, and the + // subagents it started there, which a provider can record on that thread rather than its parent's. + const endNativeSubagentTrees = Effect.fn("Orchestrator.endNativeSubagentTrees")( + function* (input: { + readonly command: Extract< + OrchestrationV2ServerCommand, + { readonly type: "run.interrupt" | "thread.background-work.settle" | "thread.stop" } + >; + readonly events: Ref.Ref>; + readonly dying: ReadonlyArray; + readonly childThreadIds: Set; + readonly now: DateTime.Utc; + }) { + const allocateEventId = () => + idAllocator.allocate.event({ + threadId: input.command.threadId, + commandId: input.command.commandId, + }); + const dying = [...input.dying]; + const { childThreadIds } = input; + for (const { projection, subagent } of dying) { + const ended = yield* endOrphanedNativeSubagent({ + projection, + subagentId: subagent.subagentId, + status: "interrupted", + now: input.now, + emitted: yield* Ref.get(input.events), + allocateEventId, + }).pipe(mapDispatchError(input.command)); + yield* Ref.update(input.events, (events) => [...events, ...ended]); + const childThreadId = subagent.childThreadId; + if ( + subagent.origin !== "provider_native" || + childThreadId === null || + childThreadIds.has(childThreadId) + ) { + continue; + } + childThreadIds.add(childThreadId); + const child = yield* projectionStore + .getThreadRecords(childThreadId, ["nodes", "providerTurns", "subagents", "turnItems"], { + // Runless items are the subagent's; any run there is the user's own. + turnItemRunIds: [null], + turnItemStatuses: ["pending", "running", "waiting"], + }) + .pipe( + Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }), + mapDispatchError(input.command), + ); + if (child === null) continue; + const childEnded = yield* endRunlessRootTurns({ + threadId: childThreadId, + providerInstanceId: subagent.providerInstanceId, + projection: child, + status: "interrupted", + now: input.now, + emitted: yield* Ref.get(input.events), + allocateEventId, + }).pipe(mapDispatchError(input.command)); + yield* Ref.update(input.events, (events) => [...events, ...childEnded]); + for (const nested of child.turnItems) { + if (nested.type === "subagent") dying.push({ projection: child, subagent: nested }); + } + for (const nested of child.subagents) { + if (nested.origin === "provider_native" && nested.runId === null) { + dying.push({ projection: child, subagent: nativeSubagentRef(nested) }); + } + } + } + }, + ); + const settleBackgroundWork = Effect.fn("Orchestrator.settleBackgroundWork")(function* (input: { readonly command: Extract< OrchestrationV2ServerCommand, @@ -11825,11 +11912,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }) { const emitEvent = emit(input.events, input.command); const runOrdinals = new Map(input.projection.runs.map((run) => [run.id, run.ordinal])); - const allocateEventId = () => - idAllocator.allocate.event({ - threadId: input.command.threadId, - commandId: input.command.commandId, - }); const childThreadIds = new Set(); const liveness = new Map(); const hasLiveSession = Effect.fn("Orchestrator.hasLiveSession")(function* ( @@ -11894,76 +11976,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, }); if (item.type !== "subagent") continue; - // A native subagent died with the process too: end its record, its own - // thread's runless root turn, and the subagents it started there, which - // a provider can record on that thread rather than this one. - const dying: Array<{ - readonly projection: Pick< - OrchestrationV2ThreadProjection, - "nodes" | "subagents" | "turnItems" - >; - readonly subagent: Pick< - Extract, - "subagentId" | "origin" | "childThreadId" | "providerInstanceId" - >; - }> = [{ projection: input.projection, subagent: item }]; - for (const { projection, subagent } of dying) { - const ended = yield* endOrphanedNativeSubagent({ - projection, - subagentId: subagent.subagentId, - status: "interrupted", - now: input.now, - emitted: yield* Ref.get(input.events), - allocateEventId, - }).pipe(mapDispatchError(input.command)); - yield* Ref.update(input.events, (events) => [...events, ...ended]); - const childThreadId = subagent.childThreadId; - if ( - subagent.origin !== "provider_native" || - childThreadId === null || - childThreadIds.has(childThreadId) - ) { - continue; - } - childThreadIds.add(childThreadId); - const child = yield* projectionStore - .getThreadRecords(childThreadId, ["nodes", "providerTurns", "subagents", "turnItems"], { - // Runless items are the subagent's; any run there is the user's own. - turnItemRunIds: [null], - turnItemStatuses: ["pending", "running", "waiting"], - }) - .pipe( - Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }), - mapDispatchError(input.command), - ); - if (child === null) continue; - const childEnded = yield* endRunlessRootTurns({ - threadId: childThreadId, - providerInstanceId: subagent.providerInstanceId, - projection: child, - status: "interrupted", - now: input.now, - emitted: yield* Ref.get(input.events), - allocateEventId, - }).pipe(mapDispatchError(input.command)); - yield* Ref.update(input.events, (events) => [...events, ...childEnded]); - for (const nested of child.turnItems) { - if (nested.type === "subagent") dying.push({ projection: child, subagent: nested }); - } - for (const nested of child.subagents) { - if (nested.origin === "provider_native" && nested.runId === null) { - dying.push({ - projection: child, - subagent: { - subagentId: nested.id, - origin: nested.origin, - childThreadId: nested.childThreadId, - providerInstanceId: nested.providerInstanceId, - }, - }); - } - } - } + // A native subagent died with the process too. + yield* endNativeSubagentTrees({ + command: input.command, + events: input.events, + dying: [{ projection: input.projection, subagent: item }], + childThreadIds, + now: input.now, + }); } for (const providerThread of input.projection.providerThreads) { if ((providerThread.pendingBackgroundTasks?.length ?? 0) === 0) continue; @@ -13439,6 +13459,36 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio for (const run of reached.values()) { yield* markStoppedRun({ command, events, thread: projection.thread, run, now }); } + // Record and item are written together, so an active record without a live item lost its end. + const staleNative = (target === undefined ? projection.subagents : []).filter( + (subagent) => + subagent.origin === "provider_native" && + subagent.runId !== null && + isOrchestrationV2WorkActive(subagent.status) && + !projection.turnItems.some( + (item) => item.type === "subagent" && item.subagentId === subagent.id, + ), + ); + if (staleNative.length > 0) { + const { nodes } = yield* projectionStore + .getThreadRecords(command.threadId, ["nodes"]) + .pipe(mapDispatchError(command)); + const threadProjection = { + nodes, + subagents: projection.subagents, + turnItems: projection.turnItems, + }; + yield* endNativeSubagentTrees({ + command, + events, + dying: staleNative.map((subagent) => ({ + projection: threadProjection, + subagent: nativeSubagentRef(subagent), + })), + childThreadIds: new Set(), + now, + }); + } yield* holdStoppedThread({ command, events, projection, cohortRunIds: [], now }); yield* cancelIdleOrphans({ command, events, projection }); yield* stopDelegatedTasks; diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index aa847174ac96b..fc52191d32b29 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -1839,6 +1839,154 @@ it.effect.each(["Stop", "Force stop"] as const)( }).pipe(Effect.provide(layerTest)), ); +// A finished delegated child whose native subagent the provider never reported ending, +// with no open turn item, and a nested native subagent on that agent's own thread. +it.effect( + "Stop settles a delegated row whose finished child still records a running native subagent", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const parentThreadId = ThreadId.make("thread:held-row"); + yield* createWatchingThread(parentThreadId, 30); + yield* send(parentThreadId, "parent", "start_immediately"); + const childThreadId = yield* delegate(parentThreadId, "held child"); + const child = yield* orchestrator.getThreadProjection(childThreadId); + const childRun = child.runs[0]!; + const task = (yield* orchestrator.getThreadProjection(parentThreadId)).subagents[0]!; + const now = yield* DateTime.now; + const nativeThreadId = ThreadId.make("thread:held-row-native"); + const nestedThreadId = ThreadId.make("thread:held-row-nested"); + yield* createWatchingThread(nativeThreadId, 31); + yield* createWatchingThread(nestedThreadId, 32); + const nativeProviderThreadId = ProviderThreadId.make("provider-thread:held-row-native"); + const runningRoot = (threadId: ThreadId, id: NodeId) => ({ + ...child.nodes.find((node) => node.id === childRun.rootNodeId)!, + id, + rootNodeId: id, + threadId, + runId: null, + parentNodeId: null, + status: "running" as const, + countsForRun: false, + completedAt: null, + }); + const nativeRoot = runningRoot(nativeThreadId, NodeId.make("node:held-row-native-root")); + const nestedRoot = runningRoot(nestedThreadId, NodeId.make("node:held-row-nested-root")); + for (const root of [nativeRoot, nestedRoot]) { + yield* projections.apply({ + id: EventId.make(`event:held-row:${root.id}`), + type: "node.updated", + threadId: root.threadId, + nodeId: root.id, + occurredAt: now, + payload: root, + }); + } + yield* projections.apply({ + id: EventId.make("event:held-row:native-turn"), + type: "provider-turn.updated", + threadId: nativeThreadId, + nodeId: nativeRoot.id, + occurredAt: now, + payload: { + id: ProviderTurnId.make("turn:held-row-native"), + providerThreadId: nativeProviderThreadId, + nodeId: nativeRoot.id, + runAttemptId: null, + nativeTurnRef: null, + ordinal: 1, + status: "running", + startedAt: now, + completedAt: null, + }, + }); + const native = { + ...task, + id: NodeId.make("node:held-row-native"), + threadId: childThreadId, + runId: childRun.id, + parentNodeId: childRun.rootNodeId!, + origin: "provider_native" as const, + providerThreadId: nativeProviderThreadId, + childThreadId: nativeThreadId, + status: "running" as const, + completionDelivery: undefined, + }; + yield* projections.apply({ + id: EventId.make("event:held-row:native"), + type: "subagent.updated", + threadId: childThreadId, + runId: childRun.id, + occurredAt: now, + payload: native, + }); + yield* projections.apply({ + id: EventId.make("event:held-row:nested"), + type: "subagent.updated", + threadId: nativeThreadId, + occurredAt: now, + payload: { + ...native, + id: NodeId.make("node:held-row-nested"), + threadId: nativeThreadId, + runId: null, + parentNodeId: nativeRoot.id, + childThreadId: nestedThreadId, + }, + }); + yield* projections.apply({ + id: EventId.make("event:held-row:child-completed"), + type: "run.updated", + threadId: childThreadId, + runId: childRun.id, + occurredAt: now, + payload: { ...childRun, status: "completed", startedAt: now, completedAt: now }, + }); + yield* orchestrator.recoverDelegatedTasks; + assert.equal( + (yield* orchestrator.getThreadProjection(parentThreadId)).subagents[0]?.status, + "running", + ); + const statuses = Effect.gen(function* () { + const nativeAfter = yield* orchestrator.getThreadProjection(nativeThreadId); + const nestedAfter = yield* orchestrator.getThreadProjection(nestedThreadId); + return { + row: (yield* orchestrator.getThreadProjection(parentThreadId)).subagents[0]?.status, + native: (yield* orchestrator.getThreadProjection(childThreadId)).subagents.find( + (subagent) => subagent.id === native.id, + )?.status, + nativeRoot: nativeAfter.nodes.find((node) => node.id === nativeRoot.id)?.status, + nested: nativeAfter.subagents[0]?.status, + nestedRoot: nestedAfter.nodes.find((node) => node.id === nestedRoot.id)?.status, + }; + }); + + // The row's Stop: the parent's task, then the child thread. + yield* orchestrator.dispatch({ + type: "subagent.stop", + commandId: CommandId.make("held-row:stop-task"), + threadId: parentThreadId, + runId: task.runId!, + subagentId: task.id, + }); + yield* orchestrator.dispatch({ + type: "thread.stop", + commandId: CommandId.make("held-row:stop-child"), + threadId: childThreadId, + }); + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + yield* worker.drain(); + assert.deepEqual(yield* statuses, { + row: "completed", + native: "interrupted", + nativeRoot: "interrupted", + nested: "interrupted", + nestedRoot: "interrupted", + }); + }).pipe(Effect.provide(layerTest)), +); + it.effect.each([ { followUp: "started its turn", startedBeforeRelease: true }, { followUp: "was promoted", startedBeforeRelease: false }, From ff30b16af4f9be1cbb96ebf6630e82edc50ee17f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 10:43:03 -0800 Subject: [PATCH 024/409] fix(desktop): clicking above the Settings or Usage modal closes it again (#36266) --- apps/web/src/components/AppRouteModal.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/AppRouteModal.tsx b/apps/web/src/components/AppRouteModal.tsx index 79e5510265f8e..bfce6b50d4cac 100644 --- a/apps/web/src/components/AppRouteModal.tsx +++ b/apps/web/src/components/AppRouteModal.tsx @@ -53,7 +53,9 @@ export function AppRouteModal({ label, children }: { label: string; children: Re "--app-modal-sm-top": `max(1.5rem, ${nativeTitlebarHeight}px)`, } as CSSProperties } - className="fixed inset-0 z-50 flex items-center justify-center sm:p-6 sm:pt-(--app-modal-sm-top) lg:p-10" + // Electron hit-tests drag regions under the backdrop regardless of z-index; without + // this, presses above the modal move the window instead of dismissing it. + className="fixed inset-0 z-50 flex items-center justify-center [-webkit-app-region:no-drag] sm:p-6 sm:pt-(--app-modal-sm-top) lg:p-10" onPointerDown={(event) => { backdropPressRef.current = event.target === event.currentTarget; }} From 065aca4889fb422c536bbf673d2f2beba50df3cb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 10:45:18 -0800 Subject: [PATCH 025/409] test(web): code block highlight test waits for the highlighter, not a 1s poll (#36269) --- apps/web/src/components/ChatMarkdown.browser.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index d24b1c9299ac7..30f3bcdd32543 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -12,6 +12,7 @@ import { useRightPanelStore } from "../rightPanelStore"; import { remarkEnableOrderedListNumbers } from "@t3tools/shared/markdownPipeline"; import { setMarkdownTaskChecked } from "@t3tools/shared/markdownTaskList"; import { HostImageUrlContext } from "./chat/hostImageUrl"; +import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting"; const { workspaceSearch, filesystemMetadata } = vi.hoisted(() => ({ workspaceSearch: vi.fn(), @@ -283,6 +284,8 @@ it("highlights a code block's own source when an earlier block of the same lengt // These two lines share a 32-bit FNV-1a hash and a length. const first = 'const commitSha = "f36a6d8fe0aa";'; const second = 'const commitSha = "cb06f8d135b3";'; + // The block suspends until the shared highlighter loads, which can outlast vi.waitFor on a busy runner. + await getSyntaxHighlighterPromise("ts"); { await using screen = await renderText(`\`\`\`ts\n${first}\n\`\`\``); await vi.waitFor(() => expect(highlightedText(screen.container)).toBe(first)); From be5f805964858307864b599f63b215e62d36b3d7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 11:05:20 -0800 Subject: [PATCH 026/409] fix(web): new desktop browser tabs open at the default zoom and appearance (#34943) --- apps/server/src/preview/Manager.test.ts | 12 ++++++++++ apps/server/src/preview/Manager.ts | 2 ++ apps/web/src/browser/browserDefaults.ts | 5 ++-- apps/web/src/browser/openFileInPreview.ts | 4 +++- .../preview/openPreviewSession.test.ts | 24 +++++++++++++++++++ .../components/preview/openPreviewSession.ts | 4 +++- .../preview/openTerminalLinkInPreview.test.ts | 24 +++++++++++++++++++ .../preview/openTerminalLinkInPreview.ts | 4 +++- packages/contracts/src/preview.ts | 2 ++ 9 files changed, 76 insertions(+), 5 deletions(-) diff --git a/apps/server/src/preview/Manager.test.ts b/apps/server/src/preview/Manager.test.ts index 1c16992123206..28598cfbdfe15 100644 --- a/apps/server/src/preview/Manager.test.ts +++ b/apps/server/src/preview/Manager.test.ts @@ -79,6 +79,18 @@ it.layer(layer)("PreviewManager", (it) => { }), ); + it.effect("opens a server tab with the requested zoom and appearance", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const rendering = { zoomFactor: 0.8, colorScheme: "dark" } as const; + + const opened = yield* manager.open({ threadId, runtime: "server", ...rendering }); + + expect(opened).toMatchObject(rendering); + }), + ); + it.effect("reissues presentation requests without replaying them on navigation", () => Effect.gen(function* () { const threadId = freshThreadId(); diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index ff8442306f2ff..0899866f80b64 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -193,6 +193,8 @@ export const make = Effect.gen(function* PreviewManagerMake() { viewport: input.viewport ?? FILL_PREVIEW_VIEWPORT, ...(input.profileId === undefined ? {} : { profileId: input.profileId }), ...(runtime === undefined ? {} : { runtime }), + ...(input.colorScheme === undefined ? {} : { colorScheme: input.colorScheme }), + ...(input.zoomFactor === undefined ? {} : { zoomFactor: input.zoomFactor }), ...(runtime === "server" && input.automationOwner !== undefined ? { automationOwner: input.automationOwner } : {}), diff --git a/apps/web/src/browser/browserDefaults.ts b/apps/web/src/browser/browserDefaults.ts index bf1c2da40db87..5cc74bb1b7f0d 100644 --- a/apps/web/src/browser/browserDefaults.ts +++ b/apps/web/src/browser/browserDefaults.ts @@ -5,6 +5,7 @@ import { type DesktopPreviewTabDefaults, type PreviewAppearancePreference, type PreviewViewportSetting, + type PreviewZoomFactor, } from "@t3tools/contracts"; import { @@ -17,7 +18,7 @@ import { resolveResponsiveBrowserViewportSize } from "./browserViewportLayout"; export interface BrowserDefaults { readonly viewport: PreviewViewportSetting; - readonly zoomFactor: number; + readonly zoomFactor: PreviewZoomFactor; readonly appearance: PreviewAppearancePreference; readonly autoShowFloatingPreview: boolean; readonly profiles: ReadonlyArray; @@ -26,7 +27,7 @@ export interface BrowserDefaults { const toBrowserDefaults = (settings: { readonly browserDefaultViewport: PreviewViewportSetting; - readonly browserDefaultZoomFactor: number; + readonly browserDefaultZoomFactor: PreviewZoomFactor; readonly browserDefaultAppearance: PreviewAppearancePreference; readonly browserAutoShowFloatingPreview: boolean; readonly browserProfiles: ReadonlyArray; diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index a997ee2b552e4..db1a58e98dfb9 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -85,7 +85,9 @@ export async function openUrlInPreview(input: { url: input.url, viewport: defaults.viewport, profileId: input.profileId ?? defaults.profileId, - ...(runtime === undefined ? {} : { runtime }), + ...(runtime === undefined + ? {} + : { runtime, zoomFactor: defaults.zoomFactor, colorScheme: defaults.appearance }), }, }); return mapAtomCommandResult(result, (snapshot) => { diff --git a/apps/web/src/components/preview/openPreviewSession.test.ts b/apps/web/src/components/preview/openPreviewSession.test.ts index 005d44d9468c7..9acfd0eb6c050 100644 --- a/apps/web/src/components/preview/openPreviewSession.test.ts +++ b/apps/web/src/components/preview/openPreviewSession.test.ts @@ -97,6 +97,30 @@ describe("openPreviewSession", () => { }); }, ); + + it.each(["new tab", "link"] as const)( + "opens a server tab from a %s with the default zoom and appearance", + async (entryPoint) => { + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultZoomFactor: 0.8, + browserDefaultAppearance: "dark", + }); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + + if (entryPoint === "link") { + vi.spyOn(previewRuntime, "previewRuntimeFor").mockReturnValue("server"); + await openUrlInPreview({ openPreview, threadRef, url: "https://t3.chat/" }); + } else { + await openPreviewSession({ openPreview, threadRef, runtime: "server" }); + } + + expect(openPreview).toHaveBeenCalledExactlyOnceWith({ + environmentId: threadRef.environmentId, + input: expect.objectContaining({ runtime: "server", zoomFactor: 0.8, colorScheme: "dark" }), + }); + }, + ); }); describe("openUrlInPreview from a link", () => { diff --git a/apps/web/src/components/preview/openPreviewSession.ts b/apps/web/src/components/preview/openPreviewSession.ts index 99dc6c3e572c4..6c372888de5a2 100644 --- a/apps/web/src/components/preview/openPreviewSession.ts +++ b/apps/web/src/components/preview/openPreviewSession.ts @@ -45,7 +45,9 @@ export async function openPreviewSession( ...(input.url === undefined ? {} : { url: input.url }), viewport: input.viewport ?? defaults.viewport, profileId: input.profileId ?? defaults.profileId, - ...(runtime === undefined || runtime === "desktop" ? {} : { runtime }), + ...(runtime === undefined || runtime === "desktop" + ? {} + : { runtime, zoomFactor: defaults.zoomFactor, colorScheme: defaults.appearance }), }, }); if (result._tag === "Failure") { diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts index 069a5782e7fdb..ab11752a66fef 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts @@ -3,6 +3,8 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import * as previewRuntime from "~/browser/previewRuntime"; import { openTerminalLinkInPreview } from "./openTerminalLinkInPreview"; vi.mock("~/previewStateStore", () => ({ @@ -36,6 +38,8 @@ vi.mock("~/browser/browserLinkTarget", () => ({ const hydratedDefaults = { viewport: { _tag: "fixed", width: 1280, height: 720 } as const, profileId: "work", + zoomFactor: 0.8, + appearance: "dark", }; const threadRef = { @@ -69,4 +73,24 @@ describe("openTerminalLinkInPreview", () => { expect(reportError).not.toHaveBeenCalled(); expect(fallbackToBrowser).not.toHaveBeenCalled(); }); + + it("opens a server tab with the default zoom and appearance", async () => { + vi.spyOn(previewRuntime, "previewRuntimeFor").mockReturnValue("server"); + const openPreview = vi.fn(async () => + AsyncResult.failure(Cause.interrupt()), + ); + + await openTerminalLinkInPreview({ + url: "https://t3.chat/", + threadRef, + openPreview, + fallbackToBrowser: vi.fn(), + forceBrowser: false, + }); + + expect(openPreview).toHaveBeenCalledExactlyOnceWith({ + environmentId: threadRef.environmentId, + input: expect.objectContaining({ runtime: "server", zoomFactor: 0.8, colorScheme: "dark" }), + }); + }); }); diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts index d160c1ee7ea61..867cc528f9ae8 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -64,7 +64,9 @@ export async function openTerminalLinkInPreview( url: input.url, viewport: defaults.viewport, profileId: defaults.profileId, - ...(runtime === undefined ? {} : { runtime }), + ...(runtime === undefined + ? {} + : { runtime, zoomFactor: defaults.zoomFactor, colorScheme: defaults.appearance }), }, }); if (result._tag === "Failure") { diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index 1790a4797db29..9216899834b33 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -175,6 +175,8 @@ export const PreviewOpenInput = Schema.Struct({ viewport: Schema.optional(PreviewViewportSetting), profileId: Schema.optional(BrowserProfileId), runtime: Schema.optional(PreviewRuntime), + colorScheme: Schema.optional(PreviewAppearancePreference), + zoomFactor: Schema.optional(PreviewZoomFactor), reveal: Schema.optional(Schema.Boolean), }); export type PreviewOpenInput = typeof PreviewOpenInput.Type; From 8cd8a3ca5ab36c5ce79d166f5a226f7aa644e093 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 11:08:10 -0800 Subject: [PATCH 027/409] fix(server): file search no longer freezes the server while fff's watcher reads a large file (#36273) --- .../src/workspace/WorkspaceEntries.test.ts | 5 +- .../workspace/WorkspaceSearchIndex.test.ts | 51 ++-- .../src/workspace/WorkspaceSearchIndex.ts | 44 ++-- patches/@ff-labs__fff-node@0.11.0.patch | 231 ++++++++++++++++-- pnpm-lock.yaml | 6 +- 5 files changed, 274 insertions(+), 63 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 35b4c5410e960..521e127779984 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -963,10 +963,13 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { if (created.ok) { const finder = created.value; finders += 1; - // The synchronous rescan waits on fff's picker lock on the event loop. + // The synchronous rescan and grep wait on fff's picker lock on the event loop. finder.scanFiles = () => { throw new Error("refresh must not rescan on the JS thread"); }; + finder.grep = () => { + throw new Error("search must not grep on the JS thread"); + }; const scanFilesAsync = finder.scanFilesAsync.bind(finder); finder.scanFilesAsync = () => { rescans += 1; diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 25d1b912b1495..45102e4c1cfea 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -47,7 +47,7 @@ it.effect("filters image searches before applying the result limit", () => ...Array.from({ length: 200 }, (_, index) => fileItem(`src/file-${index}.ts`)), fileItem("public/icon.svg"), ]; - const fileSearch = vi.fn(() => ({ + const fileSearch = vi.fn(async () => ({ ok: true as const, value: { items, @@ -59,9 +59,12 @@ it.effect("filters image searches before applying the result limit", () => const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - isScanning: () => false, + getScanProgressAsync: async () => ({ + ok: true as const, + value: { isScanning: false }, + }), watch: () => ({ ok: true as const, value: () => undefined }), - fileSearch, + fileSearchAsync: fileSearch, } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); @@ -95,7 +98,7 @@ it.effect("fetches past a first page of Git-ignored results", () => fileItem("hidden/c.ts"), fileItem("src/a.ts"), ]; - const fileSearch = vi.fn((_query: string, options?: { pageSize?: number }) => ({ + const fileSearch = vi.fn(async (_query: string, options?: { pageSize?: number }) => ({ ok: true as const, value: { items: items.slice(0, options?.pageSize), @@ -107,9 +110,12 @@ it.effect("fetches past a first page of Git-ignored results", () => const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - isScanning: () => false, + getScanProgressAsync: async () => ({ + ok: true as const, + value: { isScanning: false }, + }), watch: () => ({ ok: true as const, value: () => undefined }), - fileSearch, + fileSearchAsync: fileSearch, } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); @@ -131,9 +137,12 @@ it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - isScanning: () => false, + getScanProgressAsync: async () => ({ + ok: true as const, + value: { isScanning: false }, + }), watch: () => ({ ok: true as const, value: () => undefined }), - fileSearch: vi.fn(() => ({ + fileSearchAsync: vi.fn(async () => ({ ok: true as const, value: { items, scores: [], totalMatched: 1, totalFiles: 1 }, })), @@ -184,7 +193,7 @@ it.effect("continues whole-word searches after a filtered grep page", () => filteredFileCount: 1, nextCursor: cursor, }); - const grep = vi.fn((_query: string, options?: GrepOptions) => + const grep = vi.fn(async (_query: string, options?: GrepOptions) => options?.cursor ? { ok: true as const, value: grepResult("needle", [[0, 6]], null) } : { @@ -195,9 +204,12 @@ it.effect("continues whole-word searches after a filtered grep page", () => const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - isScanning: () => false, + getScanProgressAsync: async () => ({ + ok: true as const, + value: { isScanning: false }, + }), watch: () => ({ ok: true as const, value: () => undefined }), - grep, + grepAsync: grep, } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); @@ -241,7 +253,7 @@ function fakeSymlinkFinder( let watchFailures = options.watchFailures ?? 0; // fff's pageIndex is an offset into the results, not a page number. const directorySearch = vi.fn( - (_query: string, searchOptions?: { pageIndex?: number; pageSize?: number }) => { + async (_query: string, searchOptions?: { pageIndex?: number; pageSize?: number }) => { const offset = searchOptions?.pageIndex ?? 0; const items = directories.slice(offset, offset + (searchOptions?.pageSize ?? 100)); return { @@ -260,7 +272,14 @@ function fakeSymlinkFinder( const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - isScanning: () => false, + // fff's isScanning waits on the picker lock on the JS thread. + isScanning: () => { + throw new Error("isScanning must not run on the JS thread"); + }, + getScanProgressAsync: async () => ({ + ok: true as const, + value: { isScanning: false }, + }), watch: (callback: (events: ReadonlyArray) => void) => { if (watchFailures > 0) { watchFailures -= 1; @@ -269,12 +288,12 @@ function fakeSymlinkFinder( watchers.push(callback); return { ok: true as const, value: () => undefined }; }, - directorySearch, - fileSearch: () => { + directorySearchAsync: directorySearch, + fileSearchAsync: async () => { const items = indexedItems.flatMap((entry) => (entry.type === "file" ? [entry.item] : [])); return { ok: true as const, value: { items, scores: [], totalMatched: items.length } }; }, - mixedSearch: () => ({ + mixedSearchAsync: async () => ({ ok: true as const, value: { items: indexedItems, totalMatched: indexedItems.length }, }), diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 50a89b01ce869..22e307c182616 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -579,22 +579,32 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( }), ); - const hasIncompleteInitialScan = () => initialScanTimedOut && finder.isScanning(); + // fff's isScanning takes the picker lock too, so progress is read off the JS thread. + const isScanning = Effect.promise(async () => { + try { + const progress = await finder.getScanProgressAsync(); + return !progress.ok || progress.value.isScanning; + } catch { + return true; + } + }); + const hasIncompleteInitialScan = () => (initialScanTimedOut ? isScanning : Effect.succeed(false)); const runSearch = Effect.fn("WorkspaceSearchIndex.runSearch")(function* ( query: string, pageSize: number, operation: "directorySearch" | "fileSearch" | "grep" | "mixedSearch", - execute: () => Result, + execute: () => Promise>, ): Effect.fn.Return { - const result = yield* Effect.try({ + // fff's searches wait on its picker lock, which its watcher can hold for minutes. + const result = yield* Effect.tryPromise({ try: execute, catch: (cause) => new WorkspaceSearchIndexSearchFailed({ cwd, queryLength: query.length, pageSize, - reason: `FileFinder.${operation} threw unexpectedly.`, + reason: `FileFinder.${operation} rejected unexpectedly.`, cause, }), }); @@ -616,9 +626,12 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const directories: string[] = []; // fff's pageIndex is an offset into the results, not a page number. for (let pageIndex = 0; ; pageIndex += SYMLINK_DIRECTORY_PAGE_SIZE) { - const result = yield* Effect.sync(() => { + const result = yield* Effect.promise(async () => { try { - return finder.directorySearch("", { pageIndex, pageSize: SYMLINK_DIRECTORY_PAGE_SIZE }); + return await finder.directorySearchAsync("", { + pageIndex, + pageSize: SYMLINK_DIRECTORY_PAGE_SIZE, + }); } catch { return undefined; } @@ -629,7 +642,6 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( if (directory) directories.push(directory); } if (result.value.items.length < SYMLINK_DIRECTORY_PAGE_SIZE) return directories; - yield* Effect.yieldNow; } }, ); @@ -682,7 +694,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( }); const scanSymlinkEntries = Effect.fn("WorkspaceSearchIndex.scanSymlinkEntries")(function* () { - const indexComplete = !finder.isScanning(); + const indexComplete = !(yield* isScanning); const directories = yield* listIndexedDirectories(); const scanned = yield* Effect.forEach( ["", ...directories], @@ -833,7 +845,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( query: string, pageSize: number, operation: "directorySearch" | "fileSearch" | "mixedSearch", - execute: (pageSize: number) => Result>, + execute: (pageSize: number) => Promise>>, pathOf: (item: I) => string, ): Effect.fn.Return, WorkspaceSearchIndexSearchFailed> { let size = pageSize; @@ -895,12 +907,12 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( function* () { - const incompleteBeforeQuery = hasIncompleteInitialScan(); + const incompleteBeforeQuery = yield* hasIncompleteInitialScan(); const result = yield* searchVisible( "", WORKSPACE_INDEX_PAGE_SIZE, "mixedSearch", - (pageSize) => finder.mixedSearch("", { pageSize }), + (pageSize) => finder.mixedSearchAsync("", { pageSize }), mixedItemPath, ); const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES, hostIsWindows); @@ -941,7 +953,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( query, pageSize, "fileSearch", - (size) => finder.fileSearch(query, { pageSize: size }), + (size) => finder.fileSearchAsync(query, { pageSize: size }), (item) => item.relativePath, ); return merge(mapFileSearchResult(result, indexLimit, hostIsWindows, imageOnly)); @@ -951,7 +963,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( query, pageSize, "directorySearch", - (size) => finder.directorySearch(query, { pageSize: size }), + (size) => finder.directorySearchAsync(query, { pageSize: size }), (item) => item.relativePath, ); return merge(mapDirectorySearchResult(result, indexLimit, hostIsWindows)); @@ -960,7 +972,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( query, pageSize, "mixedSearch", - (size) => finder.mixedSearch(query, { pageSize: size }), + (size) => finder.mixedSearchAsync(query, { pageSize: size }), mixedItemPath, ); return merge(mapMixedSearchResult(result, indexLimit, hostIsWindows)); @@ -973,7 +985,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( // matching is subsequence-based, so dropping them still matches "[id]" and "a{b}" paths. // "{" is dropped only when the query finds nothing as a glob, so "*.{ts,tsx}" keeps working. const query = rawQuery.replaceAll("[", ""); - const incompleteBeforeQuery = hasIncompleteInitialScan(); + const incompleteBeforeQuery = yield* hasIncompleteInitialScan(); let mapped = yield* searchNames(query, limit, kind, imageOnly); if (mapped.entries.length === 0 && query.includes("{")) { mapped = yield* searchNames(query.replaceAll("{", ""), limit, kind, imageOnly); @@ -998,7 +1010,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( do { const remainingTimeBudgetMs = Math.max(1, Math.ceil(deadline - performance.now())); const result = yield* runSearch(input.query, input.limit, "grep", () => - finder.grep(searchQuery, { + finder.grepAsync(searchQuery, { mode: regexMode ? "regex" : "plain", smartCase: !input.caseSensitive && !regexMode, maxMatchesPerFile: Math.min(CONTENT_SEARCH_MAX_MATCHES_PER_FILE, rawPageSize), diff --git a/patches/@ff-labs__fff-node@0.11.0.patch b/patches/@ff-labs__fff-node@0.11.0.patch index 8468783d2a6a5..198ac430e0d18 100644 --- a/patches/@ff-labs__fff-node@0.11.0.patch +++ b/patches/@ff-labs__fff-node@0.11.0.patch @@ -30,10 +30,10 @@ index 0f90eedca20655ccd30b8f28e433b2094341fbdf..c2ef86927d6e3ab0e8584330fa0ac3fd /** Total number of items (files + dirs) that matched */ totalMatched: number; diff --git a/dist/finder.d.ts b/dist/finder.d.ts -index 452102d720cc19b0560cfc9e2f1a320687047c92..3d3211c70a7de4b97792edb32cc9466c8ba89547 100644 +index 452102d720cc19b0560cfc9e2f1a320687047c92..490dba5277c8d751b53ed239c0ce77f3e11f9c43 100644 --- a/dist/finder.d.ts +++ b/dist/finder.d.ts -@@ -242,6 +242,10 @@ export declare class FileFinder implements FileFinderApi { +@@ -242,6 +242,18 @@ export declare class FileFinder implements FileFinderApi { * background watcher might have missed. */ scanFiles(): Result; @@ -41,10 +41,18 @@ index 452102d720cc19b0560cfc9e2f1a320687047c92..3d3211c70a7de4b97792edb32cc9466c + * Trigger a rescan without blocking the JS thread. Patched in T3 Code. + */ + scanFilesAsync(): Promise>; ++ /** `fileSearch` without blocking the JS thread. Patched in T3 Code. */ ++ fileSearchAsync(query: string, options?: SearchOptions): Promise>; ++ /** `directorySearch` without blocking the JS thread. Patched in T3 Code. */ ++ directorySearchAsync(query: string, options?: DirSearchOptions): Promise>; ++ /** `mixedSearch` without blocking the JS thread. Patched in T3 Code. */ ++ mixedSearchAsync(query: string, options?: SearchOptions): Promise>; ++ /** `grep` without blocking the JS thread. Patched in T3 Code. */ ++ grepAsync(query: string, options?: GrepOptions): Promise>; /** * Check if a scan is currently in progress. */ -@@ -254,6 +258,10 @@ export declare class FileFinder implements FileFinderApi { +@@ -254,6 +266,10 @@ export declare class FileFinder implements FileFinderApi { * Get the current scan progress. */ getScanProgress(): Result; @@ -56,7 +64,7 @@ index 452102d720cc19b0560cfc9e2f1a320687047c92..3d3211c70a7de4b97792edb32cc9466c * Wait for the initial file scan to complete. * Non-blocking: polls `isScanning` and yields to the event loop between checks. diff --git a/dist/index.js b/dist/index.js -index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588ac7f86cc9 100644 +index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..5d9fbf45095bd2b2ff1c321c03b85414498bde57 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,7 +1,7 @@ @@ -140,7 +148,20 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a if ("ok" in res) return res; freeResult(res.rawPtr); return { ok: true, value: void 0 }; -@@ -426,9 +459,9 @@ function ffiDestroy(handle) { +@@ -417,18 +450,20 @@ function ffiCreate(basePath, frecencyDbPath, historyDbPath, _useUnsafeNoLock, en + } + function ffiDestroy(handle) { + loadLibrary(); +- load({ ++ return load({ + library: LIBRARY_KEY, + funcName: "fff_destroy", + retType: DataType.Void, + paramsType: [DataType.External], +- paramsValue: [handle] ++ paramsValue: [handle], ++ runInNewThread: true ++ }).catch(() => { }); } var FFF_FILE_ITEM_STRUCT = { @@ -153,7 +174,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a size: DataType.U64, modified: DataType.U64, access_frecency_score: DataType.I64, -@@ -445,8 +478,9 @@ var FFF_SCORE_STRUCT = { +@@ -445,8 +480,9 @@ var FFF_SCORE_STRUCT = { distance_penalty: DataType.I32, current_file_penalty: DataType.I32, combo_match_boost: DataType.I32, @@ -164,7 +185,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a }; var FFF_SEARCH_RESULT_STRUCT = { items: DataType.External, -@@ -462,8 +496,8 @@ var FFF_SEARCH_RESULT_STRUCT = { +@@ -462,8 +498,8 @@ var FFF_SEARCH_RESULT_STRUCT = { location_end_col: DataType.I32 }; var FFF_DIR_ITEM_STRUCT = { @@ -175,7 +196,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a max_access_frecency: DataType.I32 }; var FFF_DIR_SEARCH_RESULT_STRUCT = { -@@ -475,9 +509,9 @@ var FFF_DIR_SEARCH_RESULT_STRUCT = { +@@ -475,9 +511,9 @@ var FFF_DIR_SEARCH_RESULT_STRUCT = { }; var FFF_MIXED_ITEM_STRUCT = { item_type: DataType.U8, @@ -188,7 +209,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a size: DataType.U64, modified: DataType.U64, access_frecency_score: DataType.I64, -@@ -500,10 +534,10 @@ var FFF_MIXED_SEARCH_RESULT_STRUCT = { +@@ -500,10 +536,10 @@ var FFF_MIXED_SEARCH_RESULT_STRUCT = { location_end_col: DataType.I32 }; var FFF_GREP_MATCH_STRUCT = { @@ -203,7 +224,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a match_ranges: DataType.External, context_before: DataType.External, context_after: DataType.External, -@@ -598,18 +632,13 @@ function readMixedItemFromRaw(raw) { +@@ -598,18 +634,13 @@ function readMixedItemFromRaw(raw) { } function callAccessor(funcName, resultPtr, index, structDef) { loadLibrary(); @@ -224,7 +245,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a } function ptrOffset(base, bytes) { return load({ -@@ -766,6 +795,7 @@ function parseSearchResult(rawPtr) { +@@ -766,6 +797,7 @@ function parseSearchResult(rawPtr) { } const items = []; const scores = []; @@ -232,7 +253,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a for (let i = 0; i < count; i++) { const rawItem = callAccessor( "fff_search_result_get_item", -@@ -774,13 +804,15 @@ function parseSearchResult(rawPtr) { +@@ -774,13 +806,15 @@ function parseSearchResult(rawPtr) { FFF_FILE_ITEM_STRUCT ); items.push(readFileItemFromRaw(rawItem)); @@ -255,7 +276,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a } load({ library: LIBRARY_KEY, -@@ -824,6 +856,7 @@ function parseDirSearchResult(rawPtr) { +@@ -824,6 +858,7 @@ function parseDirSearchResult(rawPtr) { const count = sr.count; const items = []; const scores = []; @@ -263,7 +284,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a for (let i = 0; i < count; i++) { const rawItem = callAccessor( "fff_dir_search_result_get_item", -@@ -832,13 +865,15 @@ function parseDirSearchResult(rawPtr) { +@@ -832,13 +867,15 @@ function parseDirSearchResult(rawPtr) { FFF_DIR_ITEM_STRUCT ); items.push(readDirItemFromRaw(rawItem)); @@ -286,7 +307,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a } load({ library: LIBRARY_KEY, -@@ -897,6 +932,7 @@ function parseMixedSearchResult(rawPtr) { +@@ -897,6 +934,7 @@ function parseMixedSearchResult(rawPtr) { } const items = []; const scores = []; @@ -294,7 +315,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a for (let i = 0; i < count; i++) { const rawItem = callAccessor( "fff_mixed_search_result_get_item", -@@ -905,13 +941,15 @@ function parseMixedSearchResult(rawPtr) { +@@ -905,13 +943,15 @@ function parseMixedSearchResult(rawPtr) { FFF_MIXED_ITEM_STRUCT ); items.push(readMixedItemFromRaw(rawItem)); @@ -317,7 +338,83 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a } load({ library: LIBRARY_KEY, -@@ -1188,7 +1226,11 @@ var FFF_SCAN_PROGRESS_STRUCT = { +@@ -932,7 +972,7 @@ function parseMixedSearchResult(rawPtr) { + } + return { ok: true, value: result }; + } +-function ffiSearch(handle, query, currentFile, maxThreads, pageIndex, pageSize, comboBoostMultiplier, minComboCount) { ++function ffiSearch(handle, query, currentFile, maxThreads, pageIndex, pageSize, comboBoostMultiplier, minComboCount, onNativeThread = false) { + loadLibrary(); + const rawPtr = load({ + library: LIBRARY_KEY, +@@ -966,9 +1006,10 @@ function ffiSearch(handle, query, currentFile, maxThreads, pageIndex, pageSize, + comboBoostMultiplier, + minComboCount + ], +- freeResultMemory: false ++ freeResultMemory: false, ++ runInNewThread: onNativeThread + }); +- return parseSearchResult(rawPtr); ++ return onNativeThread ? rawPtr.then(parseSearchResult) : parseSearchResult(rawPtr); + } + function ffiGlob(handle, pattern, currentFile, maxThreads, pageIndex, pageSize) { + loadLibrary(); +@@ -995,7 +1036,7 @@ function ffiGlob(handle, pattern, currentFile, maxThreads, pageIndex, pageSize) + }); + return parseSearchResult(rawPtr); + } +-function ffiSearchDirectories(handle, query, currentFile, maxThreads, pageIndex, pageSize) { ++function ffiSearchDirectories(handle, query, currentFile, maxThreads, pageIndex, pageSize, onNativeThread = false) { + loadLibrary(); + const rawPtr = load({ + library: LIBRARY_KEY, +@@ -1016,11 +1057,12 @@ function ffiSearchDirectories(handle, query, currentFile, maxThreads, pageIndex, + // page_size + ], + paramsValue: [handle, query, currentFile ?? "", maxThreads, pageIndex, pageSize], +- freeResultMemory: false ++ freeResultMemory: false, ++ runInNewThread: onNativeThread + }); +- return parseDirSearchResult(rawPtr); ++ return onNativeThread ? rawPtr.then(parseDirSearchResult) : parseDirSearchResult(rawPtr); + } +-function ffiSearchMixed(handle, query, currentFile, maxThreads, pageIndex, pageSize, comboBoostMultiplier, minComboCount) { ++function ffiSearchMixed(handle, query, currentFile, maxThreads, pageIndex, pageSize, comboBoostMultiplier, minComboCount, onNativeThread = false) { + loadLibrary(); + const rawPtr = load({ + library: LIBRARY_KEY, +@@ -1054,11 +1096,12 @@ function ffiSearchMixed(handle, query, currentFile, maxThreads, pageIndex, pageS + comboBoostMultiplier, + minComboCount + ], +- freeResultMemory: false ++ freeResultMemory: false, ++ runInNewThread: onNativeThread + }); +- return parseMixedSearchResult(rawPtr); ++ return onNativeThread ? rawPtr.then(parseMixedSearchResult) : parseMixedSearchResult(rawPtr); + } +-function ffiLiveGrep(handle, query, mode, maxFileSize, maxMatchesPerFile, smartCase, fileOffset, pageLimit, timeBudgetMs, enforceTimeBudget, beforeContext, afterContext, classifyDefinitions) { ++function ffiLiveGrep(handle, query, mode, maxFileSize, maxMatchesPerFile, smartCase, fileOffset, pageLimit, timeBudgetMs, enforceTimeBudget, beforeContext, afterContext, classifyDefinitions, onNativeThread = false) { + loadLibrary(); + const rawPtr = load({ + library: LIBRARY_KEY, +@@ -1107,9 +1150,10 @@ function ffiLiveGrep(handle, query, mode, maxFileSize, maxMatchesPerFile, smartC + afterContext, + classifyDefinitions + ], +- freeResultMemory: false ++ freeResultMemory: false, ++ runInNewThread: onNativeThread + }); +- return parseGrepResult(rawPtr); ++ return onNativeThread ? rawPtr.then(parseGrepResult) : parseGrepResult(rawPtr); + } + function ffiMultiGrep(handle, patternsJoined, constraints, maxFileSize, maxMatchesPerFile, smartCase, fileOffset, pageLimit, timeBudgetMs, enforceTimeBudget, beforeContext, afterContext, classifyDefinitions) { + loadLibrary(); +@@ -1188,7 +1232,11 @@ var FFF_SCAN_PROGRESS_STRUCT = { }; function ffiGetScanProgress(handle) { loadLibrary(); @@ -330,16 +427,18 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a if ("ok" in res) return res; const handlePtr = res.struct.handle; freeResult(res.rawPtr); -@@ -1429,6 +1471,8 @@ var FileFinder = class _FileFinder { +@@ -1429,6 +1477,10 @@ var FileFinder = class _FileFinder { handle; /** Native ids of this instance's active watch subscriptions. */ watchers = /* @__PURE__ */ new Set(); + /** Patched in T3 Code: in-flight native-thread calls by function name. */ + nativeThreadCalls = /* @__PURE__ */ new Map(); ++ /** Patched in T3 Code: the last search queued on a native thread. */ ++ nativeSearchTail = null; constructor(handle) { this.handle = handle; } -@@ -1485,12 +1529,37 @@ var FileFinder = class _FileFinder { +@@ -1485,12 +1537,115 @@ var FileFinder = class _FileFinder { destroy() { if (this.handle !== null) { const handle = this.handle; @@ -349,13 +448,15 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a - ffiWatchCleanupAfterDestroy(handle, this.watchers); - this.watchers.clear(); + this.watchers = /* @__PURE__ */ new Set(); -+ const release = () => { -+ ffiDestroy(handle); ++ // Patched in T3 Code: freeing the handle waits on the picker lock, so it runs on a ++ // native thread, after any native-thread call still using the handle. ++ const release = () => ffiDestroy(handle).finally(() => { + ffiWatchCleanupAfterDestroy(handle, watchers); -+ }; -+ // Patched in T3 Code: a native-thread call may still be using the handle. -+ if (this.nativeThreadCalls.size > 0) { -+ Promise.allSettled(this.nativeThreadCalls.values()).then(release); ++ }); ++ const pending = [...this.nativeThreadCalls.values()]; ++ if (this.nativeSearchTail) pending.push(this.nativeSearchTail); ++ if (pending.length > 0) { ++ Promise.allSettled(pending).then(release); + } else { + release(); + } @@ -376,11 +477,87 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a + }); + this.nativeThreadCalls.set(funcName, call); + return call; ++ } ++ /** ++ * Patched in T3 Code: runs a search on a libuv pool thread. Searches on one finder run one at ++ * a time, so while fff's watcher holds the picker lock a finder holds at most one pool thread. ++ */ ++ searchOnNativeThread(search) { ++ const guard = this.ensureAlive(); ++ if (!guard.ok) return Promise.resolve(guard); ++ const call = (this.nativeSearchTail ?? Promise.resolve()).then(() => { ++ const alive = this.ensureAlive(); ++ return alive.ok ? search(alive.value) : alive; ++ }).catch((cause) => err(String(cause))); ++ this.nativeSearchTail = call; ++ call.finally(() => { ++ if (this.nativeSearchTail === call) this.nativeSearchTail = null; ++ }); ++ return call; ++ } ++ /** Patched in T3 Code: `fileSearch` without blocking the JS thread. */ ++ fileSearchAsync(query, options) { ++ return this.searchOnNativeThread((handle) => ffiSearch( ++ handle, ++ query, ++ options?.currentFile ?? "", ++ options?.maxThreads ?? 0, ++ options?.pageIndex ?? 0, ++ options?.pageSize ?? 0, ++ options?.comboBoostMultiplier ?? 0, ++ options?.minComboCount ?? 0, ++ true ++ )); ++ } ++ /** Patched in T3 Code: `directorySearch` without blocking the JS thread. */ ++ directorySearchAsync(query, options) { ++ return this.searchOnNativeThread((handle) => ffiSearchDirectories( ++ handle, ++ query, ++ options?.currentFile ?? null, ++ options?.maxThreads ?? 0, ++ options?.pageIndex ?? 0, ++ options?.pageSize ?? 0, ++ true ++ )); ++ } ++ /** Patched in T3 Code: `mixedSearch` without blocking the JS thread. */ ++ mixedSearchAsync(query, options) { ++ return this.searchOnNativeThread((handle) => ffiSearchMixed( ++ handle, ++ query, ++ options?.currentFile ?? "", ++ options?.maxThreads ?? 0, ++ options?.pageIndex ?? 0, ++ options?.pageSize ?? 0, ++ options?.comboBoostMultiplier ?? 0, ++ options?.minComboCount ?? 0, ++ true ++ )); ++ } ++ /** Patched in T3 Code: `grep` without blocking the JS thread. */ ++ grepAsync(query, options) { ++ return this.searchOnNativeThread((handle) => ffiLiveGrep( ++ handle, ++ query, ++ options?.mode ?? "plain", ++ options?.maxFileSize ?? 0, ++ options?.maxMatchesPerFile ?? 0, ++ options?.smartCase ?? true, ++ options?.cursor?._offset ?? 0, ++ options?.pageSize ?? 0, ++ options?.timeBudgetMs ?? 0, ++ options?.enforceTimeBudget ?? false, ++ options?.beforeContext ?? 0, ++ options?.afterContext ?? 0, ++ options?.classifyDefinitions ?? false, ++ true ++ )); + } /** * Check if this instance has been destroyed. */ -@@ -1757,6 +1826,18 @@ var FileFinder = class _FileFinder { +@@ -1757,6 +1912,18 @@ var FileFinder = class _FileFinder { if (!guard.ok) return guard; return ffiScanFiles(guard.value); } @@ -399,7 +576,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a /** * Check if a scan is currently in progress. */ -@@ -1838,7 +1919,8 @@ var FileFinder = class _FileFinder { +@@ -1838,7 +2005,8 @@ var FileFinder = class _FileFinder { if (!guard.ok) return guard; const deadline = Date.now() + timeoutMs; while (true) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 744838ebefa9b..bbca8110a7837 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -364,7 +364,7 @@ patchedDependencies: '@cursor/sdk@1.2.0': 57f502128bf6accd976963b126e227c86a45d815a53a8dc450461beae3bed4a4 '@effect/vitest@4.1.2': 299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7 '@expo/ui@58.0.27': 9f2547678939f4ec78fe45163b6455aad63d3c80353d7e0cc783f6a61a7ccc2f - '@ff-labs/fff-node@0.11.0': a71dc3a5544fc47afacf0fe9da04dccec3018e625a76c96087ebfdf35e293768 + '@ff-labs/fff-node@0.11.0': 3be7107340e5f388114da259637828cd47f2b32eb8ae572440715c4e38cffe6e '@legendapp/list@3.7.2': 3c68607b8bb7a531873bcbe0b197d163ef1fdb4d4cf52a4ddf9d2b3804671658 '@muse-code/sdk@1.4.6': 7b495b607fc5d76b444c7c4d58821e839726a30e6f3c53564f0d59f2d5b42101 '@pierre/diffs@1.7.0-beta.3': 4a64f63a0def94c5add9d86d70560d26d6857e1f5150133862e42e1fd77bc4e8 @@ -857,7 +857,7 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.3)(utf-8-validate@6.0.6) '@ff-labs/fff-node': specifier: 0.11.0 - version: 0.11.0(patch_hash=a71dc3a5544fc47afacf0fe9da04dccec3018e625a76c96087ebfdf35e293768) + version: 0.11.0(patch_hash=3be7107340e5f388114da259637828cd47f2b32eb8ae572440715c4e38cffe6e) '@muse-code/sdk': specifier: 1.4.6 version: 1.4.6(patch_hash=7b495b607fc5d76b444c7c4d58821e839726a30e6f3c53564f0d59f2d5b42101)(@types/node@24.12.4) @@ -15148,7 +15148,7 @@ snapshots: '@ff-labs/fff-bin-win32-x64@0.11.0': optional: true - '@ff-labs/fff-node@0.11.0(patch_hash=a71dc3a5544fc47afacf0fe9da04dccec3018e625a76c96087ebfdf35e293768)': + '@ff-labs/fff-node@0.11.0(patch_hash=3be7107340e5f388114da259637828cd47f2b32eb8ae572440715c4e38cffe6e)': dependencies: ffi-rs: 1.3.2 optionalDependencies: From 8171cd09d59ea0f49dd80c31260c8c9f9b8fc675 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 11:14:25 -0800 Subject: [PATCH 028/409] fix(desktop): mouse back and forward stay inside the browser tab (#33276) --- apps/desktop/src/preview/GuestProtocol.ts | 2 +- apps/desktop/src/preview/Manager.test.ts | 63 +++++++++++++++++++++ apps/desktop/src/preview/Manager.ts | 32 +++++++---- apps/desktop/src/preview/PickPreload.ts | 52 ++++++++--------- apps/desktop/src/preview/RecordingCursor.ts | 11 ++-- 5 files changed, 116 insertions(+), 44 deletions(-) diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index 0a9f4cb85c008..1d24079d1947f 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -5,7 +5,6 @@ export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; export const ANNOTATION_SEND_ENABLED_CHANNEL = "preview:annotation-send-enabled"; export const HUMAN_INPUT_CHANNEL = "preview:human-input"; -export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate"; // Sent to the hosting element (ipc-message). The web app mirrors this // literal in HostedBrowserWebview because it cannot import from the desktop app. export const POINTER_DOWN_HOST_CHANNEL = "preview:pointer-down"; @@ -15,6 +14,7 @@ export const RECORDING_POINTER_CHANNEL = "preview:recording-pointer"; export const RECORDING_KEY_CHANNEL = "preview:recording-key"; export const RECORDING_INPUT_CHANNEL = "preview:recording-input"; export const RECORDING_CONTROLLER_CHANNEL = "preview:recording-controller"; +export const THUMB_PRESS_CHANNEL = "preview:thumb-press"; export const PASSKEY_CREATE_CHANNEL = "preview:passkey-create"; export const PASSKEY_GET_CHANNEL = "preview:passkey-get"; export const PASSKEY_BRIDGE_ARGUMENT = "--t3code-preview-passkey-bridge"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index b344b1ac93b2b..530a1200378b1 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -745,6 +745,69 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("keeps mouse back and forward buttons inside the preview tab", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + const goBack = vi.fn(); + const goForward = vi.fn(); + const send = vi.fn(); + let canGoBack = false; + Object.assign(preview.webContents, { + navigationHistory: { + canGoBack: () => canGoBack, + canGoForward: () => true, + goBack, + goForward, + }, + getZoomFactor: () => 2, + send, + }); + fromId.mockReturnValue(preview.webContents); + yield* manager.createTab("tab_mouse_navigation"); + yield* manager.registerWebview("tab_mouse_navigation", 42); + + // Electron reports thumb buttons as "back"/"forward" for every frame of the guest, iframes included. + const beforeMouse = preview.listeners.get("before-mouse-event"); + expect(beforeMouse, "guest has no before-mouse-event listener").toBeDefined(); + const press = (type: "mouseDown" | "mouseUp", button: string) => { + const preventDefault = vi.fn(); + beforeMouse!({ preventDefault } as never, { type, button, x: 10, y: 10 } as never); + return preventDefault; + }; + + // At the start of tab history the press is still swallowed, or the host app goes back instead. + expect(press("mouseDown", "back")).toHaveBeenCalledOnce(); + expect(press("mouseUp", "back")).toHaveBeenCalledOnce(); + expect(goBack).not.toHaveBeenCalled(); + + canGoBack = true; + expect(press("mouseDown", "back")).toHaveBeenCalledOnce(); + expect(goBack).not.toHaveBeenCalled(); + expect(press("mouseUp", "back")).toHaveBeenCalledOnce(); + expect(goBack).toHaveBeenCalledOnce(); + expect(press("mouseDown", "forward")).toHaveBeenCalledOnce(); + expect(press("mouseUp", "forward")).toHaveBeenCalledOnce(); + expect(goForward).toHaveBeenCalledOnce(); + for (const button of ["left", "right"]) { + expect(press("mouseDown", button)).not.toHaveBeenCalled(); + expect(press("mouseUp", button)).not.toHaveBeenCalled(); + } + expect(goBack).toHaveBeenCalledOnce(); + expect(goForward).toHaveBeenCalledOnce(); + // The guest replays dropped presses for human input and recordings, in CSS px. + expect(send.mock.calls.filter(([channel]) => channel === "preview:thumb-press")).toEqual([ + ["preview:thumb-press", "down", 5, 5, 3], + ["preview:thumb-press", "up", 5, 5, 3], + ["preview:thumb-press", "down", 5, 5, 3], + ["preview:thumb-press", "up", 5, 5, 3], + ["preview:thumb-press", "down", 5, 5, 4], + ["preview:thumb-press", "up", 5, 5, 4], + ]); + }), + ), + ); + effectIt.effect("keeps editing and forwarded shortcuts in whichever tab holds focus", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 0fc48165ffccf..49b61c73316f3 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -81,13 +81,13 @@ import { CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, - MOUSE_NAVIGATE_CHANNEL, PAGE_UNHANDLED_SHORTCUT_CHANNEL, RECORDING_CURSOR_CHANNEL, RECORDING_INPUT_CHANNEL, RECORDING_CONTROLLER_CHANNEL, RECORDING_POINTER_CHANNEL, START_PICK_CHANNEL, + THUMB_PRESS_CHANNEL, } from "./GuestProtocol.ts"; import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts"; @@ -1490,15 +1490,27 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const humanInput = (_event: unknown, rawSignal?: unknown): void => { runFork(handleHumanInput(rawSignal)); }; - const mouseNavigate = (_event: unknown, payload?: unknown): void => { - const direction = - typeof payload === "object" && payload !== null && "direction" in payload - ? (payload as { direction?: unknown }).direction - : undefined; - if (direction !== "back" && direction !== "forward") return; + // Chromium navigates the outermost WebContents (the host app) on thumb buttons, even from guest iframes. + const beforeMouse = (event: Electron.Event, mouse: Electron.MouseInputEvent): void => { + // Electron reports thumb buttons as "back" and "forward", which its typings omit. + const button: string | undefined = mouse.button; + if (button !== "back" && button !== "forward") return; + if (mouse.type !== "mouseDown" && mouse.type !== "mouseUp") return; + event.preventDefault(); + // The page never sees the press, so the guest replays it; these coordinates are DIPs, not CSS px. + const zoom = wc.getZoomFactor(); + const phase = mouse.type === "mouseDown" ? "down" : "up"; + wc.send( + THUMB_PRESS_CHANNEL, + phase, + mouse.x / zoom, + mouse.y / zoom, + button === "back" ? 3 : 4, + ); + if (phase !== "up") return; runFork( attempt({ operation: "mouseNavigate", tabId, webContentsId: wc.id }, () => { - if (direction === "back") { + if (button === "back") { if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); } else if (wc.navigationHistory.canGoForward()) { wc.navigationHistory.goForward(); @@ -1608,9 +1620,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.off("found-in-page", foundInPage as never); wc.off("did-create-window", windowCreated); wc.off("before-input-event", beforeInput); + wc.off("before-mouse-event", beforeMouse); wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.off(RECORDING_INPUT_CHANNEL, recordingInput); - wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); wc.ipc.off(PAGE_UNHANDLED_SHORTCUT_CHANNEL, pageUnhandledShortcut); detachPasskeys(); }).pipe(Effect.ignore), @@ -1631,7 +1643,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.on("found-in-page", foundInPage as never); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput); - wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); wc.ipc.on(PAGE_UNHANDLED_SHORTCUT_CHANNEL, pageUnhandledShortcut); detachPasskeys = passkeys.attachGuest(wc); wc.setWindowOpenHandler((details) => { @@ -1659,6 +1670,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); wc.on("did-create-window", windowCreated); wc.on("before-input-event", beforeInput); + wc.on("before-mouse-event", beforeMouse); }); yield* Ref.update(attachedRef, (attached) => replaceMap(attached, (copy) => { diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index cbaf873e49332..42867b9d9cb44 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -39,7 +39,6 @@ import { CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, - MOUSE_NAVIGATE_CHANNEL, POINTER_DOWN_HOST_CHANNEL, PAGE_UNHANDLED_SHORTCUT_CHANNEL, RECORDING_CURSOR_CHANNEL, @@ -48,6 +47,7 @@ import { RECORDING_INPUT_CHANNEL, RECORDING_CONTROLLER_CHANNEL, START_PICK_CHANNEL, + THUMB_PRESS_CHANNEL, } from "./GuestProtocol.ts"; const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; const INPUT_LAYER_ATTRIBUTE = "data-t3code-annotation-input"; @@ -249,34 +249,28 @@ const isolateOverlayKey = (event: KeyboardEvent): void => { for (const type of ["keydown", "keypress", "keyup"] as const) window.addEventListener(type, isolateOverlayKey, true); -const MOUSE_BUTTON_BACK = 3; -const MOUSE_BUTTON_FORWARD = 4; - -const navigationDirectionForButton = (button: number): "back" | "forward" | null => { - if (button === MOUSE_BUTTON_BACK) return "back"; - if (button === MOUSE_BUTTON_FORWARD) return "forward"; - return null; -}; - -// Chromium routes thumb-button navigation to the focused WebContents; navigate this hovered guest explicitly. -const suppressNavigationButton = (event: MouseEvent): void => { - if (!event.isTrusted || navigationDirectionForButton(event.button) === null) return; - event.preventDefault(); - event.stopImmediatePropagation(); -}; - -const requestNavigationForButton = (event: MouseEvent): void => { - if (!event.isTrusted) return; - const direction = navigationDirectionForButton(event.button); - if (direction === null) return; - event.preventDefault(); - event.stopImmediatePropagation(); - ipcRenderer.send(MOUSE_NAVIGATE_CHANNEL, { direction }); -}; - -window.addEventListener("mousedown", suppressNavigationButton, true); -window.addEventListener("mouseup", requestNavigationForButton, true); -window.addEventListener("auxclick", suppressNavigationButton, true); +let replayedThumbPress: { readonly x: number; readonly y: number } | null = null; +ipcRenderer.on( + THUMB_PRESS_CHANNEL, + (_event, phase: unknown, x: unknown, y: unknown, button: unknown) => { + if (phase !== "down" && phase !== "up") return; + if (typeof x !== "number" || typeof y !== "number" || typeof button !== "number") return; + // Main reports presses inside a frame in that frame's coordinates. + const inFrame = document.querySelector("iframe:hover, frame:hover, object:hover, embed:hover"); + if (phase === "up") { + const pressed = replayedThumbPress; + replayedThumbPress = null; + // A replayed press is always released, at its own point if the release lands in a frame. + if (pressed) recordingCursor?.press(inFrame ? pressed : { x, y }, "up"); + return; + } + if (inFrame) return; + replayedThumbPress = { x, y }; + ipcRenderer.sendToHost(POINTER_DOWN_HOST_CHANNEL); + ipcRenderer.send(HUMAN_INPUT_CHANNEL, { kind: "pointer", x, y, button }); + recordingCursor?.press({ x, y }, "down"); + }, +); const nextId = createAnnotationIdSource(); diff --git a/apps/desktop/src/preview/RecordingCursor.ts b/apps/desktop/src/preview/RecordingCursor.ts index 29f1865de291f..ab74c361b68e0 100644 --- a/apps/desktop/src/preview/RecordingCursor.ts +++ b/apps/desktop/src/preview/RecordingCursor.ts @@ -156,13 +156,15 @@ export function installRecordingCursor( moveHuman(point); pointer(point, "move"); }; + const press = (point: { readonly x: number; readonly y: number }, phase: "down" | "up") => { + if (phase === "down") moveHuman(point); + pointer(point, phase); + }; const pointerDown = (event: PointerEvent) => { - if (event.pointerType === "touch") return; - moveHuman({ x: event.clientX, y: event.clientY }); - pointer({ x: event.clientX, y: event.clientY }, "down"); + if (event.pointerType !== "touch") press({ x: event.clientX, y: event.clientY }, "down"); }; const pointerUp = (event: PointerEvent) => { - if (event.pointerType !== "touch") pointer({ x: event.clientX, y: event.clientY }, "up"); + if (event.pointerType !== "touch") press({ x: event.clientX, y: event.clientY }, "up"); }; const keyDown = (event: KeyboardEvent) => { if (event.isComposing || event.repeat) return; @@ -196,6 +198,7 @@ export function installRecordingCursor( window.addEventListener("blur", hide); return { move, + press, keyPress, setController, setTheme, From fcd06873e8df977a882ba025e706aba4f160acb0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 11:18:45 -0800 Subject: [PATCH 029/409] fix(web): removing a checkout moves its open draft to a remaining checkout (#36276) --- .../ProjectSettingsPanel.browser.test.tsx | 34 ++++++++++++++++++- .../settings/ProjectSettingsPanel.tsx | 15 +++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx index 9576820832323..0d505c3250f11 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx @@ -132,7 +132,8 @@ vi.mock(import("../files/projectFilesQueryState"), async (original) => ({ getProjectFileQueryAtom: () => missingProjectFile, })); -import { useComposerDraftStore } from "../../composerDraftStore"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; import { appAtomRegistry } from "../../rpc/atomRegistry"; import { assetEnvironment } from "../../state/assets"; import { buildSidebarProjectSnapshots } from "../../sidebarProjectGrouping"; @@ -418,6 +419,37 @@ describe("project settings permissions", () => { expect(screen.router.state.location.href).toBe(initialHref); }); + it("moves a draft on the removed checkout to a remaining checkout of the group", async () => { + setup(); + const draftId = DraftId.make("draft-handed-off"); + const [primaryProject, remoteProject] = state.projects; + const drafts = useComposerDraftStore.getState(); + drafts.setLogicalProjectDraftThreadId( + "github.com/example/shared-repo", + scopeProjectRef(primaryId, primaryProject!.id), + draftId, + ); + // "Add to another machine" hands the open draft to the new checkout this way. + drafts.setDraftThreadContext(draftId, { + projectRef: scopeProjectRef(remoteId, remoteProject!.id), + environmentSelection: "manual", + loadBalancedEnvironmentId: null, + }); + drafts.setPrompt(draftId, "keep this prompt"); + await using screen = await mountPanel(); + + await userEvent.click(removeCheckout(screen, "/work/remote")); + await vi.waitFor(() => expect(state.releaseUploads).toHaveBeenCalledOnce()); + + expect(useComposerDraftStore.getState().getDraftSession(draftId)).toMatchObject({ + environmentId: primaryId, + projectId: primaryProject!.id, + }); + expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.prompt).toBe( + "keep this prompt", + ); + }); + it("rechecks every removal target after confirmation before deleting or clearing drafts", async () => { setup(); const confirmed = deferred(); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index c1e15267234b3..3f370ce7108d3 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -362,6 +362,13 @@ function ProjectDetail({ if (checkProjectAccess(members, "Failed to remove project")) return; const draftStore = useComposerDraftStore.getState(); + const remainingMember = allMembers.find( + (candidate) => + !members.some( + (member) => + member.environmentId === candidate.environmentId && member.id === candidate.id, + ), + ); for (const member of members) { if (checkProjectAccess([member], "Failed to remove project")) return; const memberThreads = projectThreads.filter( @@ -388,7 +395,13 @@ function ProjectDetail({ memberThreads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)), ); const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); - if (projectDraftThread) { + if (projectDraftThread && remainingMember) { + draftStore.setDraftThreadContext(projectDraftThread.draftId, { + projectRef: scopeProjectRef(remainingMember.environmentId, remainingMember.id), + environmentSelection: "manual", + loadBalancedEnvironmentId: null, + }); + } else if (projectDraftThread) { draftStore.clearDraftThread(projectDraftThread.draftId); } draftStore.clearProjectDraftThreadId(projectRef); From 9efd960925f0899c0a6d045d128557c5d82818e8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 11:21:49 -0800 Subject: [PATCH 030/409] fix(mobile): a Settings link sent to the running app opens the Settings tab (#36262) --- apps/mobile/src/App.tsx | 23 ++++++----- .../workspace-tab-navigation.test.ts | 39 ++++++++++++++++++- .../features/settings/settings-navigation.ts | 18 +++++++++ 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index fc4dfdf3a417a..b92c0ca9d42c5 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -83,7 +83,10 @@ import { splashCanHide, useLaunchSplashHomeRowsPending, } from "./features/home/home-launch-splash"; -import { restoreSettingsLinkState } from "./features/settings/settings-navigation"; +import { + restoreSettingsLinkState, + settingsLinkAction, +} from "./features/settings/settings-navigation"; import { hydrateWorkspaceSelections, launchWorkspaceURL, @@ -143,14 +146,16 @@ const appLinking = { getActionFromState: ( state: Parameters[0], options: Parameters[1], - ): ReturnType => - // Our router handles the selection; React Navigation's type only lists its own actions. - (workspaceDetailLinkAction(state) as ReturnType) ?? - pairingFormLinkAction( - state, - rootNavigationRef.isReady() ? rootNavigationRef.getRootState() : undefined, - ) ?? - getActionFromState(state, options), + ): ReturnType => { + const current = rootNavigationRef.isReady() ? rootNavigationRef.getRootState() : undefined; + return ( + // Our router handles the selection; React Navigation's type only lists its own actions. + (workspaceDetailLinkAction(state) as ReturnType) ?? + pairingFormLinkAction(state, current) ?? + settingsLinkAction(state, current) ?? + getActionFromState(state, options) + ); + }, getStateFromPath: getAppStateFromPath, }; diff --git a/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts index 3f217fd70c8bc..3cd3fe6d0978f 100644 --- a/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts @@ -1,6 +1,7 @@ +import * as NodeModule from "node:module"; import { describe, expect, it } from "vite-plus/test"; -import { restoreSettingsLinkState } from "../../settings/settings-navigation"; +import { restoreSettingsLinkState, settingsLinkAction } from "../../settings/settings-navigation"; import { workspaceSelectionsIn, workspaceTabColumns, @@ -19,6 +20,17 @@ const pullRequest = { params: { environmentId: "env", projectId: "project", repository: "o/r", number: 7 }, }; const settings = { key: "settings", name: "Settings" }; +const requireFromNative = NodeModule.createRequire( + NodeModule.createRequire(import.meta.url).resolve("@react-navigation/native/package.json"), +); +const { getStateFromPath } = requireFromNative("@react-navigation/core") as { + readonly getStateFromPath: typeof import("@react-navigation/native").getStateFromPath; +}; +const { StackRouter } = NodeModule.createRequire( + requireFromNative.resolve("@react-navigation/core/package.json"), +)("@react-navigation/routers") as { + readonly StackRouter: typeof import("@react-navigation/native").StackRouter; +}; const available = { thread: () => true, pullRequest: () => true }; const names = (state: { routes: readonly { name: string }[] }) => state.routes.map((route) => route.name); @@ -218,6 +230,31 @@ describe("Settings links", () => { expect(restoreSettingsLinkState(bare, undefined)).toBe(bare); }); + it("opens a link sent to the running app on the Settings tab, as its button does", () => { + const screens = { + Home: "", + Settings: "settings", + SettingsAbout: "settings/about", + SettingsAppearance: "settings/appearance", + }; + const router = StackRouter({}); + const options = { routeNames: Object.keys(screens), routeParamList: {}, routeGetIdList: {} }; + const open = router.getRehydratedState( + { index: 2, routes: [{ name: "Home" }, { name: "Settings" }, { name: "SettingsAbout" }] }, + options, + ); + const follow = (path: string) => { + const link = getStateFromPath(path, { initialRouteName: "Home", screens })!; + const action = settingsLinkAction(link, open)!; + return router.getStateForAction(open, action, options)!; + }; + + expect(names(follow("settings"))).toEqual(["Home", "Settings"]); + const appearance = follow("settings/appearance"); + expect(names(appearance)).toEqual(["Home", "Settings", "SettingsAppearance"]); + expect(appearance.routes[1]!.key).toBe(open.routes[1]!.key); + }); + it("opens the sign-in link as a modal over the workspace", () => { const state = { routes: [{ name: "Home" }, { name: "SettingsAuth" }] }; expect(restoreSettingsLinkState(state, "SettingsAppearance")).toBe(state); diff --git a/apps/mobile/src/features/settings/settings-navigation.ts b/apps/mobile/src/features/settings/settings-navigation.ts index b97bc948c0a2b..a414d1014a695 100644 --- a/apps/mobile/src/features/settings/settings-navigation.ts +++ b/apps/mobile/src/features/settings/settings-navigation.ts @@ -91,6 +91,24 @@ export function settingsNavigationState, + current: NavigationState | undefined, +) { + const top = link.routes.at(-1); + if (!current || !top || top.state || !isSettingsRoute(top.name)) return undefined; + const page = top.name === "Settings" ? undefined : (top.name as SettingsPageName); + return { + type: "RESET" as const, + payload: settingsNavigationState(current, page, top.params), + target: current.key, + }; +} + /** Opens one Settings page in the Settings tab, from anywhere outside it. */ export function openSettings( navigation: RootStackNavigation, From c83e78fe073db40b17cd6d478a88fe575125bb96 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 11:44:41 -0800 Subject: [PATCH 031/409] fix(web): Shift+Return after closed inline code or bold starts a plain line (#36285) --- ...rPromptEditorTiptap.input.browser.test.tsx | 25 +++++++++++++++++++ apps/web/src/composer-markdown-source.ts | 14 ++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index cd01804f8efe3..a614f4823f289 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -599,6 +599,31 @@ describe("Markdown markers around styled text", () => { expected: "**bo**\n**ld** after", marks: { bold: ["bo", "ld"] }, }, + { + name: "a line break after typed inline code leaves the next line plain", + value: "", + caret: 1, + keys: "`Test`{Shift>}{Enter}{/Shift}f", + expected: "`Test`\nf", + marks: { code: ["Test"] }, + }, + { + name: "a line break after typed bold leaves the next line plain", + value: "", + caret: 1, + keys: "**Test**{Shift>}{Enter}{/Shift}f", + expected: "**Test**\nf", + marks: { bold: ["Test"] }, + }, + { + name: "bold turned on after typed bold carries across a line break", + value: "", + caret: 1, + keys: `**Test**${isMacPlatform(navigator.platform) ? "{Meta>}b{/Meta}" : "{Control>}b{/Control}"}{Shift>}{Enter}{/Shift}f`, + expected: "**Test**\n**f**", + // The caret beside f shows it as source. + marks: { bold: ["Test"] }, + }, { name: "splitting a list item inside bold keeps both halves bold", value: "- **bold** after", diff --git a/apps/web/src/composer-markdown-source.ts b/apps/web/src/composer-markdown-source.ts index c4119b981b18c..c57985f12e345 100644 --- a/apps/web/src/composer-markdown-source.ts +++ b/apps/web/src/composer-markdown-source.ts @@ -221,9 +221,9 @@ function collapseRegion(tr: Transaction, region: TextRange, type: MarkType): voi function collapseMarkdownSource( tr: Transaction, keep: (region: TextRange) => boolean = () => false, -): void { +): TextRange[] { const type = tr.doc.type.schema.marks[MARKDOWN_SOURCE_MARK]; - if (!type) return; + if (!type) return []; const regions: TextRange[] = []; for (const region of sourceRegions(tr.doc, type)) { const extended = withAdjacentDelimiters(tr.doc, region); @@ -235,6 +235,7 @@ function collapseMarkdownSource( if (keep(region)) tr.addMark(region.from, region.to, type.create()); else collapseRegion(tr, region, type); } + return regions; } function mapSelection(tr: Transaction, selection: Selection, map: (pos: number) => number) { @@ -248,7 +249,14 @@ function mapSelection(tr: Transaction, selection: Selection, map: (pos: number) /** Parses all shown source first, so a command that splits it keeps its styles. */ export const collapseShownMarkdownSource: Command = ({ tr }) => { - collapseMarkdownSource(tr); + const { selection } = tr; + const type = tr.doc.type.schema.marks[MARKDOWN_SOURCE_MARK]; + const marks = tr.storedMarks ?? selection.$from.marks(); + const regions = collapseMarkdownSource(tr); + // A caret after the closing markers is outside the styles, though parsed text there is inclusive. + if (type && selection.empty && regions.some(({ to }) => to === selection.from)) { + tr.setStoredMarks(type.removeFromSet(marks)); + } return true; }; From e94db17ead0e52813b38c0b67537843b44634d44 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 11:57:35 -0800 Subject: [PATCH 032/409] fix(mobile): adding a folder that is already a project opens it (#34942) --- .../projects/AddProjectScreen.test.ts | 79 +++++++++- .../features/projects/AddProjectScreen.tsx | 143 +++++++++++++----- 2 files changed, 178 insertions(+), 44 deletions(-) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.test.ts b/apps/mobile/src/features/projects/AddProjectScreen.test.ts index ab331d0149e15..4718133f7a5a5 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.test.ts +++ b/apps/mobile/src/features/projects/AddProjectScreen.test.ts @@ -6,7 +6,11 @@ import * as NodeModule from "node:module"; import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import { workspaceDetailRouter } from "../layout/workspace/workspace-detail-selection"; import type { NavigationState } from "@react-navigation/native"; -import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts"; +import { + AuthFilesystemReadScope, + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { act, createElement, isValidElement, type ReactNode } from "react"; @@ -22,7 +26,9 @@ const state = vi.hoisted(() => ({ id: string; title: string; workspaceRoot: string; + repositoryIdentity?: { canonicalKey: string }; }[], + cloneDestinations: [] as string[], createResult: null as Promise | null, createNewResult: null as Promise | null, startCloneResult: null as Promise | null, @@ -134,7 +140,15 @@ vi.mock("../../state/use-remote-environment-registry", () => ({ }), })); vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); -vi.mock("../../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => () => {} })); +// Only filesystem.browse of `~` answers, with the host's home as its parentPath. +vi.mock("../../state/use-atom-query-runner", () => ({ + useAtomQueryRunner: + () => + async ({ input }: { input?: { partialPath?: string } }) => + input?.partialPath === "~" + ? AsyncResult.success({ parentPath: "/home/tester", entries: [] }) + : undefined, +})); vi.mock("../../state/query", () => ({ useEnvironmentQuery: () => ({ data: null }) })); vi.mock("../../state/presentation", () => ({ useEnvironmentPresentation: () => ({ isReady: true, presentation: null }), @@ -144,6 +158,7 @@ vi.mock("../../state/sourceControl", () => ({ sourceControlEnvironment: { discovery: (input: unknown) => input, startProjectClone: async ({ input }: { input: { destinationPath: string } }) => { + state.cloneDestinations.push(input.destinationPath); if (state.startCloneResult) return state.startCloneResult; NodeFS.mkdirSync(input.destinationPath); return AsyncResult.success(undefined); @@ -230,7 +245,7 @@ function cloneAction() { renderScreen(() => AddProjectDestinationScreen({ environmentId: "environment", - remoteUrl: "https://example.com/repo.git", + remoteUrl: "https://example.com/owner/repo.git", repositoryName: "repo", }), ), @@ -274,6 +289,38 @@ describe("clone project permissions", () => { expect(state.projects).toEqual([]); }, ); + + it.each([ + [ + "opens the existing project of that repository instead of cloning into its folder", + "example.com/owner/repo", + false, + ], + ["leaves a folder holding another repository to the clone", "example.com/owner/other", true], + ])("%s", async (_name, canonicalKey, clones) => { + state.scopes.add(AuthOrchestrationOperateScope); + state.cloneDestinations = []; + state.startCloneResult = Promise.resolve( + AsyncResult.failure(Cause.fail(new Error("Destination is not empty"))), + ); + state.existingProjects = [ + { + environmentId: "environment", + id: "existing", + title: "repo", + workspaceRoot: NodePath.join(state.baseDirectory, "repo"), + repositoryIdentity: { canonicalKey }, + }, + ]; + try { + await cloneAction()(); + + expect(state.cloneDestinations.length > 0).toBe(clones); + } finally { + state.existingProjects = []; + state.startCloneResult = null; + } + }); }); function mountProjectFlow( @@ -488,10 +535,10 @@ describe("add project source environment", () => { }); describe("local project navigation", () => { - function setup() { + function setup(baseDirectory = "/tmp/t3-mobile-audit-fixture") { state.navigationState = undefined; state.scopes = new Set([AuthOrchestrationOperateScope]); - state.baseDirectory = "/tmp/t3-mobile-audit-fixture"; + state.baseDirectory = baseDirectory; state.projects = []; state.dispatch.mockReset(); state.alert.mockReset(); @@ -523,6 +570,28 @@ describe("local project navigation", () => { ); }); + it("opens an existing project typed as a home-relative path instead of creating it", async () => { + state.existingProjects = [ + { + environmentId: "environment", + id: "existing", + title: "bike-documents", + workspaceRoot: "/home/tester/projects/bike-documents", + }, + ]; + try { + const { created, projected, submit } = setup("~/projects/bike-documents/"); + state.scopes.add(AuthFilesystemReadScope); + created.resolve(AsyncResult.success(undefined)); + projected.resolve(null); + await submit(); + + expect(state.projects).toEqual([]); + } finally { + state.existingProjects = []; + } + }); + it("does not replace a reopened flow when the previous submission finishes", async () => { const { created, projected, submit } = setup(); const navigation = mountProjectFlow(false); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 33b7de17c3914..56518cd071eac 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -387,7 +387,26 @@ function useBrowsePathInput( [browseNavigation], ); - return { isBrowseNavigating, pathInput, setPathInput, navigateToBrowsePath }; + // Registered roots are absolute and `~` expands on the host, so ask the host for its home. + const resolveHomePath = async (path: string): Promise => { + if ( + !/^~(?:[\\/]|$)/.test(path) || + !environment || + !readEnvironmentScope(environment.environmentId, AuthFilesystemReadScope) || + !canPreloadBrowsePath(environmentRuntime?.connectionState) + ) { + return path; + } + const home = await loadBrowsePath({ + environmentId: environment.environmentId, + input: { partialPath: "~" }, + }); + return AsyncResult.isSuccess(home) && home.value + ? `${home.value.parentPath.replace(/[\\/]+$/, "")}${path.slice(1)}` + : path; + }; + + return { isBrowseNavigating, pathInput, setPathInput, navigateToBrowsePath, resolveHomePath }; } function useEnvironmentOptions( @@ -730,16 +749,76 @@ async function draftWithPrompt( return draftId; } -function useCreateProject(environment: EnvironmentOption | null) { +function useOpenExistingProject(environment: EnvironmentOption | null) { const openNewTaskDraft = useOpenNewTaskDraft(); - const navigation = useNavigation(); - const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); const updateProject = useAtomCommand(projectEnvironment.update, { label: "project unarchive", }); const projects = useProjects(); - return async (workspaceRoot: string, originRoute = getActiveRoute(navigation)) => { + return async ( + path: string, + originRoute: ReturnType, + clone?: { readonly remoteUrl: string; readonly prompt: string | null }, + ): Promise => { + if (!environment) return false; + const existing = findExistingAddProject({ + projects, + environmentId: environment.environmentId, + path, + }); + if (!existing) return false; + // A folder holding another repository is not this clone; let the clone report it occupied. + if ( + clone && + existing.repositoryIdentity?.canonicalKey !== + sourceControlClients.repositoryKeyFromRemote(clone.remoteUrl) + ) { + return false; + } + const prompt = clone?.prompt; + // Adding an archived project's folder again brings the project back. + const unarchived = + existing.archivedAt == null || + ( + await updateProject({ + environmentId: existing.environmentId, + input: { projectId: existing.id, archived: false }, + }) + )._tag === "Success"; + Dialog.alert( + "Project already exists", + unarchived ? existing.title : `${existing.title} is still archived. Try again later.`, + ); + openNewTaskDraft( + { + environmentId: existing.environmentId, + projectId: existing.id, + title: existing.title, + ...(prompt + ? await draftWithPrompt(existing.environmentId, existing.id, prompt).then( + (draftId) => ({ draftId }), + () => ({}), + ) + : {}), + }, + originRoute, + ); + return true; + }; +} + +function useCreateProject(environment: EnvironmentOption | null) { + const openNewTaskDraft = useOpenNewTaskDraft(); + const navigation = useNavigation(); + const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); + const openExistingProject = useOpenExistingProject(environment); + + return async ( + workspaceRoot: string, + originRoute = getActiveRoute(navigation), + lookupPath = workspaceRoot, + ) => { if ( !environment || !canCreateProjectInEnvironment(environment.connectionState) || @@ -752,33 +831,7 @@ function useCreateProject(environment: EnvironmentOption | null) { return; } - const existing = findExistingAddProject({ - projects, - environmentId: environment.environmentId, - path: workspaceRoot, - }); - if (existing) { - // Adding an archived project's folder again brings the project back. - const unarchived = - existing.archivedAt == null || - ( - await updateProject({ - environmentId: existing.environmentId, - input: { projectId: existing.id, archived: false }, - }) - )._tag === "Success"; - Dialog.alert( - "Project already exists", - unarchived ? existing.title : `${existing.title} is still archived. Try again later.`, - ); - openNewTaskDraft( - { - environmentId: existing.environmentId, - projectId: existing.id, - title: existing.title, - }, - originRoute, - ); + if (await openExistingProject(lookupPath, originRoute)) { return; } @@ -1313,8 +1366,9 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st environment?.environmentId ?? null, AuthOrchestrationOperateScope, ); + const navigation = useNavigation(); const createProject = useCreateProject(environment); - const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } = + const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput, resolveHomePath } = useBrowsePathInput(environment); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); @@ -1333,7 +1387,11 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st } setIsSubmitting(true); - const result = await createProject(resolved.path); + const originRoute = getActiveRoute(navigation); + const lookupPath = resolved.path.startsWith("~") + ? await resolveHomePath(resolved.path) + : resolved.path; + const result = await createProject(resolved.path, originRoute, lookupPath); if (result && AsyncResult.isFailure(result)) { setError(errorMessage(Cause.squash(result.cause))); } @@ -1404,11 +1462,9 @@ export function AddProjectDestinationScreen(props: { ? getCloneDirectoryPath(remoteUrl, true, repositoryName) : null; const cloneDirectoryName = ghqDirectoryPath?.split("/").at(-1) ?? repositoryName; - const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } = useBrowsePathInput( - environment, - cloneDirectoryName, - ghqDirectoryPath ?? repositoryName, - ); + const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput, resolveHomePath } = + useBrowsePathInput(environment, cloneDirectoryName, ghqDirectoryPath ?? repositoryName); + const openExistingProject = useOpenExistingProject(environment); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); @@ -1436,6 +1492,15 @@ export function AddProjectDestinationScreen(props: { const originRoute = getActiveRoute(navigation); setIsSubmitting(true); + if ( + await openExistingProject(await resolveHomePath(resolved.path), originRoute, { + remoteUrl, + prompt: prompt ?? null, + }) + ) { + setIsSubmitting(false); + return; + } const title = inferProjectTitleFromPath(resolved.path); const startResult = await startProjectClone({ environmentId: environment.environmentId, From 41e69ffa06574e6528c64f2b607b001966ce5ead Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 12:03:05 -0800 Subject: [PATCH 033/409] fix(contracts): agents keep working after starting an async delegate (#34924) --- evals/mcp-tool-use/cases/orchestration.ndjson | 1 + .../cases/threads-vs-tasks.ndjson | 20 +++++++++---------- .../contracts/src/orchestratorMcp.test.ts | 15 ++++++++++++++ packages/contracts/src/orchestratorMcp.ts | 5 +++-- 4 files changed, 29 insertions(+), 12 deletions(-) diff --git a/evals/mcp-tool-use/cases/orchestration.ndjson b/evals/mcp-tool-use/cases/orchestration.ndjson index ead48236bd922..df13d9cd6313b 100644 --- a/evals/mcp-tool-use/cases/orchestration.ndjson +++ b/evals/mcp-tool-use/cases/orchestration.ndjson @@ -44,3 +44,4 @@ {"id": "settle-after-delayed-merge-waits", "area": "orchestration", "kind": "guard", "pattern": "settle before delayed work finishes", "experience": "Asked to merge a PR once its checks pass and then settle this thread, an agent following the #26749 settle bullet can settle in the first turn; a settled thread refuses watch_pull_request and drops PR-watch wakes, so the merge never happens.", "rule": "Asked to settle ... a thread, including this one: call t3_thread_organize. (the request is to settle after the merge, not now)", "privacy": "commit-safe", "notes": "From the CodeRabbit finding on #26765. Settling this thread applies at turn end, and Orchestrator.ts rejects watch wakes on a settled thread. Good: watch #123 and end the turn without settling. gh pr merge --auto plus a settle would still lose the wake that confirms the merge.", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Okay this looks good to me. Any risk merging it?"}, {"role": "assistant", "text": "Low risk. [#123](https://github.com/acme/app/pull/123) only adds an accessible title to the HTML preview frame, and the new test covers it. Two checks are still running: Check and Test Web.", "toolCalls": [{"tool": "Bash", "input": {"command": "gh pr view 123 --json title,files --jq '.title, .files[].path'"}, "result": "fix(web): HTML preview frame gets an accessible title\napps/web/src/HtmlPreview.tsx\napps/web/src/HtmlPreview.test.tsx"}, {"tool": "Bash", "input": {"command": "gh pr checks 123"}, "result": "Lint\tpass\t41s\nTypecheck\tpass\t1m2s\nCheck\tpending\t0\nTest Web\tpending\t0"}, {"tool": "link_pull_request", "input": {"url": "https://github.com/acme/app/pull/123"}, "result": {"host": "github.com", "repository": "acme/app", "number": 123, "url": "https://github.com/acme/app/pull/123", "alreadyLinked": false}}]}], "mocks": {"watch_pull_request": "default", "link_pull_request": "default", "t3_thread_organize": {"settlesWhenTurnEnds": true}}, "world": {"branch": "fix/html-preview-title", "commits": [{"sha": "abc1234", "subject": "fix(web): HTML preview frame gets an accessible title"}], "diff": "diff --git a/apps/web/src/HtmlPreview.tsx b/apps/web/src/HtmlPreview.tsx\n--- a/apps/web/src/HtmlPreview.tsx\n+++ b/apps/web/src/HtmlPreview.tsx\n@@ -1,3 +1,3 @@\n export function HtmlPreview({ html }: { html: string }) {\n- return