diff --git a/apps/server/src/coderEnvironment.ts b/apps/server/src/coderEnvironment.ts index fc3a6ee2af5ab..92196c3c90769 100644 --- a/apps/server/src/coderEnvironment.ts +++ b/apps/server/src/coderEnvironment.ts @@ -57,6 +57,7 @@ export const CODER_ENVIRONMENT_CAPABILITIES = { projectSettingsOverrides: true, projectProviderInstanceOverrides: true, threadSnooze: true, + threadForceStop: true, environmentThemes: true, providerWorkspaceSnapshotsOnDemand: true, threadPinning: true, diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index 4b566c44a69d1..018acac5c715f 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -61,6 +61,7 @@ const ThreadOrganizeTool = Tool.make("t3_thread_organize", { ], }) .annotate(Tool.Title, "Organize a thread") + .annotate(Tool.Strict, true) .annotate(Tool.Destructive, true); const commandTool = { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index b07f0927a3827..55d55745c0c2f 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -9035,6 +9035,172 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + // regression: #17531 a mod that holds a tool behind a focused pane must fail the turn, not hang it + it.effect.each([ + { case: "fails a turn whose mod requests focus for a pane T3 cannot show", focus: true }, + { case: "keeps a turn running beside a passive mod pane", focus: false }, + ] as const)("$case", ({ focus }) => + Effect.scoped( + Effect.gen(function* () { + const closed = yield* Deferred.make(); + const harness = yield* makeWakeHarnessWithOptions({ + close: () => Deferred.succeed(closed, undefined), + }); + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-mod-pane-focus"), + text: "Run rm -r build", + attachments: [], + }), + ); + // Captured from Claude 2.1.303 with the blast-radius mod holding a Bash + // call: the mod waits for keyboard input in its pane and nothing else + // arrives until it gives up. + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "ui_panes", + panes: [ + { id: "blast-radius", title: "Blast Radius", plugin: "blast-radius", rows: 10 }, + ], + shown_id: "blast-radius", + focused_id: null, + focus_requested_id: focus ? "blast-radius" : null, + uuid: "00000000-0000-4000-8000-000000000795", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "ui_invalidate", + event: "ui.render", + uuid: "00000000-0000-4000-8000-000000000796", + session_id: WAKE_NATIVE_SESSION, + }), + ); + // Frames are handled in order, so a later notice proves the pane frame + // was handled without the turn ending. + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "informational", + content: "Processing barrier.", + level: "warning", + uuid: "00000000-0000-4000-8000-000000000797", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* harness.until( + () => + harness.terminalEvents().length > 0 || + harness.events.some( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "system_notice", + ), + ); + const [terminal] = harness.terminalEvents(); + if (!focus) { + assert.equal(terminal, undefined); + return; + } + assert.equal(terminal?.status, "failed"); + if (terminal?.status !== "failed") return; + assert.include(terminal.failure.message, "blast-radius"); + // The held tool call is abandoned by closing the query, never answered. + yield* Deferred.await(closed); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + // regression: #17531 a follow-up sent while the failed mod turn's process closes gets a new process + it.effect("runs a follow-up in a fresh query while the mod-blocked query is closing", () => + Effect.scoped( + Effect.gen(function* () { + const releaseClose = yield* Deferred.make(); + const replacementClosedOld = yield* Deferred.make(); + let oldCloses = 0; + const harness = yield* makeWakeHarnessWithOptions({ + // The held CLI never answers an interrupt; closure must not need one. + interrupt: Effect.never, + close: (sdkMessages) => + Effect.suspend(() => + ++oldCloses === 2 ? Deferred.succeed(replacementClosedOld, undefined) : Effect.void, + ).pipe( + Effect.andThen(Deferred.await(releaseClose)), + Effect.andThen(Queue.shutdown(sdkMessages)), + ), + freshQueueOnReopen: true, + }); + // Added after the harness so it runs first: a failed assertion must not + // leave teardown waiting on the held close. + yield* Effect.addFinalizer(() => Deferred.succeed(releaseClose, undefined)); + const now = yield* DateTime.now; + const turnInput = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-mod-pane-blocked"), + text: "Run rm -r build", + attachments: [], + }); + yield* harness.runtime.startTurn(turnInput); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "ui_panes", + panes: [ + { id: "blast-radius", title: "Blast Radius", plugin: "blast-radius", rows: 10 }, + ], + shown_id: "blast-radius", + focused_id: null, + focus_requested_id: "blast-radius", + uuid: "00000000-0000-4000-8000-000000000798", + session_id: WAKE_NATIVE_SESSION, + }), + ); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "failed"); + + const followUp = yield* harness.runtime + .startTurn({ + ...turnInput, + attemptId: RunAttemptId.make("attempt-claude-after-mod-pane"), + providerTurnOrdinal: 2, + }) + .pipe(Effect.forkScoped); + // Reusing the closing process would offer the prompt to it instead. + yield* Effect.raceFirst( + Deferred.await(replacementClosedOld), + harness.until(() => harness.offeredMessages.length >= 2), + ); + assert.isTrue(yield* Deferred.isDone(replacementClosedOld)); + yield* Deferred.succeed(releaseClose, undefined); + yield* Fiber.join(followUp); + assert.equal(harness.processQueues.length, 2); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "after-mod-pane-result", result: "Done." }), + ); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + // regression: #16897 a prompt whose uuid the session already holds must not hang or end early it.effect("keeps a started prompt running until its result", () => Effect.scoped( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 55ba6a47fedd1..26e684c8ca96d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -315,6 +315,8 @@ const WORKFLOW_MEMBER_TRANSCRIPT_FLUSH_MS = 500; // A steer waits at most this long, from its request, for its foreground // subagents to move to the background before it is sent. const BACKGROUND_MOVE_TIMEOUT_MS = 5_000; +// How long a Stop waits for the CLI to exit before the user is offered Force stop. +const CLAUDE_STOP_CONFIRM_TIMEOUT_MS = 10_000; const CLAUDE_GROUP_WATCH_SCRIPT = [ "{ trap '' TERM; read -r _ <&3; kill -TERM 0; sleep 5; kill -KILL 0; } /dev/null 2>&1 &", @@ -4090,6 +4092,20 @@ function claudeTaskString(value: unknown, key: string): string | undefined { return typeof field === "string" && field.trim().length > 0 ? field.trim() : undefined; } +// A Claude Code mod can hold a tool call until the user acts in its terminal +// pane. T3 cannot provide the requested pane interaction. +// Passive panes never request focus and keep working. +function claudeFocusRequestedPanePlugin(message: SDKMessage): string | null { + if (message.type !== "system" || claudeTaskField(message, "subtype") !== "ui_panes") return null; + const paneId = claudeTaskString(message, "focus_requested_id"); + if (paneId === undefined) return null; + const panes = claudeTaskField(message, "panes"); + const pane = Array.isArray(panes) + ? panes.find((entry) => claudeTaskField(entry, "id") === paneId) + : undefined; + return claudeTaskString(pane, "plugin") ?? paneId; +} + function claudeTaskStatus(value: unknown): OrchestrationV2PlanStep["status"] | undefined { switch (value) { case "pending": @@ -8069,6 +8085,28 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } return; } + const focusedPlugin = claudeFocusRequestedPanePlugin(message); + if (focusedPlugin !== null && context === (yield* Ref.get(activeTurn))) { + yield* Effect.logWarning("orchestration-v2.claude-mod-pane-focus-unsupported", { + providerTurnId: context.providerTurnId, + plugin: focusedPlugin, + }); + liveQuery.stopping = true; + yield* finalizeActiveTurn({ + context, + status: "failed", + completedAt: yield* DateTime.now, + failure: makeProviderFailure({ + class: "provider_error", + code: "claude_mod_ui_unsupported", + message: `The Claude Code mod "${focusedPlugin}" is waiting for input in its terminal pane, which T3 Code cannot show. Disable the mod for sessions run from T3 Code, or run the task in the Claude Code terminal.`, + }), + }); + // Closing ends the CLI process and abandons the held tool call + // without answering it. Not interrupt: the held CLI may never ack it. + yield* liveQuery.query.close.pipe(Effect.ignore); + return; + } yield* trackClaudeGoal({ nativeThreadId: liveQuery.nativeThreadId, context, message }); if (message.type === "stream_event" && !message.parent_tool_use_id) { @@ -9704,6 +9742,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if ( existing !== null && existing.nativeThreadId === nativeThreadId && + !existing.stopping && isClaudeProviderContinuationTurn(turnInput) ) { return existing; @@ -9735,6 +9774,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( existing !== null && existing.nativeThreadId === nativeThreadId && existing.queryPolicyKey === queryPolicyKey && + !existing.stopping && existing.selection.queryIdentity === compiledSelection.queryIdentity ) { // A denied ExitPlanMode leaves Claude in plan mode. Restore only from plan: @@ -10320,44 +10360,36 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* existing.query.interrupt; yield* existing.query.close.pipe(Effect.ignore); const closed = yield* Deferred.await(existing.closed).pipe( - Effect.timeoutOption("10 seconds"), + Effect.timeoutOption(CLAUDE_STOP_CONFIRM_TIMEOUT_MS), ); if (Option.isSome(closed)) { return; } - - const completedAt = yield* DateTime.now; + // The turn stays open: the CLI may still be working, and the user decides + // whether to Force stop it. yield* Effect.logWarning("orchestration-v2.claude-query-interrupt-timeout", { providerSessionId: input.providerSessionId, providerThreadId: turnInput.providerThread.id, providerTurnId: turnInput.providerTurnId, }); - const ownedLiveQuery = yield* Ref.modify(queryContext, (current) => - current?.query === existing.query ? [true, null] : [false, current], - ); - if (ownedLiveQuery) { - existing.stopping = true; - yield* finalizeActiveTurnAfterQueryExit(existing); - yield* endSubagentCallsOfClosedQuery(existing.nativeThreadId, "interrupted"); - } else { - yield* finalizeActiveTurn({ - context: currentTurn, - status: "interrupted", - completedAt, - }); - } - yield* Deferred.succeed(existing.closed, undefined); + return yield* new ProviderAdapter.ProviderAdapterInterruptTimeoutError({ + driver: CLAUDE_PROVIDER, + providerThreadId: turnInput.providerThread.id, + providerTurnId: turnInput.providerTurnId, + timeoutMs: CLAUDE_STOP_CONFIRM_TIMEOUT_MS, + }); }, (effect, turnInput) => effect.pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterInterruptError({ - driver: CLAUDE_PROVIDER, - providerThreadId: turnInput.providerThread.id, - providerTurnId: turnInput.providerTurnId, - cause, - }), + Effect.mapError((cause) => + cause._tag === "ProviderAdapterInterruptTimeoutError" + ? cause + : new ProviderAdapter.ProviderAdapterInterruptError({ + driver: CLAUDE_PROVIDER, + providerThreadId: turnInput.providerThread.id, + providerTurnId: turnInput.providerTurnId, + cause, + }), ), ), ); diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts index c894442409921..49eb6c9b3b999 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts @@ -2,7 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderReplayEntry, ProviderInstanceId, - type ProviderInstanceEnvironment, + type ResolvedProviderInstanceEnvironment, type ProviderReplayTranscript, type ProviderReplayEntry as ProviderReplayEntryType, } from "@t3tools/contracts"; @@ -363,7 +363,7 @@ export function layer(input: { readonly spawner: Layer.Layer; readonly binaryPath: string; readonly launchArgs: string; - readonly environment?: ProviderInstanceEnvironment; + readonly environment?: ResolvedProviderInstanceEnvironment; }) { return ProviderAdapterRegistry.layerFromAdaptersEffect( PiAdapterV2Driver.create({ diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index 77e4cd11ba071..ad2bc395d083f 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -2448,6 +2448,65 @@ it.layer(layerTest)("delegated tasks across a server restart", (it) => { const task = settled.subagents.find((row) => row.id === child.taskId); assert.equal(task?.status, "cancelled"); assert.equal(task?.completionDelivery?.state, "claimed"); + + // The parent's Stop committed, but the restart cut the effect that stops its children. + const stoppedChild = yield* seedRestartCancelledChild({ + parentThreadId: threadId, + projectId, + parentRunId: runId, + rootNodeId, + name: "restart-parent-stopped-child", + completionWake: "always", + continuationPending: true, + now, + }); + yield* (yield* EventSink.EventSinkV2).write({ + commandId: CommandId.make("command:restart-declined-parent:stop"), + events: [ + { + id: EventId.make("event:restart-declined-parent:interrupt-request"), + type: "turn-item.updated", + threadId, + runId, + nodeId: rootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + id: TurnItemId.make("turn-item:restart-declined-parent:interrupt-request"), + type: "run_interrupt_request", + threadId, + runId, + nodeId: rootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 90, + status: "completed", + title: "Interrupt requested", + startedAt: now, + completedAt: now, + updatedAt: now, + message: "Stopped by user", + }, + }, + ], + }); + yield* continueRestartedRun({ + threadId: stoppedChild.childThreadId, + sourceRunId: stoppedChild.childRunId, + }).pipe(Effect.provide(ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }))); + const afterStop = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + afterStop.subagents.find((row) => row.id === stoppedChild.taskId)?.status, + "cancelled", + ); + assert.deepEqual( + (yield* orchestrator.getThreadProjection(stoppedChild.childThreadId)).runs.map( + (run) => run.id, + ), + [stoppedChild.childRunId], + ); }), ); diff --git a/apps/server/src/orchestration-v2/EffectWorker.test.ts b/apps/server/src/orchestration-v2/EffectWorker.test.ts index cb130930372c8..2329f2aa20cf0 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.test.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.test.ts @@ -111,6 +111,8 @@ function layerExecutorFor(input: { closeInstance: () => Effect.void, release: () => record("release"), detach: () => record("detach"), + otherWorkingThreads: () => Effect.succeed([]), + releaseUnlessBusy: () => Effect.succeed(true), }), ), Layer.succeed( @@ -167,7 +169,18 @@ function layerExecutorFor(input: { ); } -it.effect("settles a stopped run when its adapter has already lost the native turn", () => +it.effect.each([ + { + name: "settles a stopped run when its adapter has already lost the native turn", + unresponsive: false, + dispatched: ["thread.background-work.settle"], + }, + { + name: "leaves the run running and flags the Stop when the provider never confirms it", + unresponsive: true, + dispatched: ["thread.interrupt.unresponsive"], + }, +])("$name", ({ unresponsive, dispatched }) => Effect.gen(function* () { const now = yield* DateTime.now; const events = yield* Ref.make>([]); @@ -178,7 +191,8 @@ it.effect("settles a stopped run when its adapter has already lost the native tu threadId, operation: "interrupt", providerTurnId, - cause: "Provider turn is not active.", + ...(unresponsive ? { unresponsive } : {}), + cause: unresponsive ? "Timed out." : "Provider turn is not active.", }), threads: { dispatch: (command) => @@ -199,7 +213,7 @@ it.effect("settles a stopped run when its adapter has already lost the native tu }, }); }).pipe(Effect.provide(layer)); - assert.deepEqual(yield* Ref.get(events), ["thread.background-work.settle"]); + assert.deepEqual(yield* Ref.get(events), dispatched); }), ); diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index c4432e7749737..6a951e52852a8 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -170,6 +170,13 @@ export const layerExecutor: Layer.Layer< cause, }), ), + Effect.map((outcome) => ({ unresponsive: false, outcome }) as const), + // Settling here would end the run while the agent may still be running; the + // user decides with Force stop instead. + Effect.catchIf( + (cause) => request.subagent === undefined && cause.unresponsive === true, + () => Effect.succeed({ unresponsive: true, outcome: undefined } as const), + ), Effect.catchIf( (cause) => request.subagent === undefined && @@ -177,27 +184,35 @@ export const layerExecutor: Layer.Layer< request.type, Cause.pretty(Cause.fail(cause)), ), - () => Effect.void, + () => Effect.succeed({ unresponsive: false, outcome: undefined } as const), ), - Effect.andThen((outcome) => - request.subagent !== undefined && outcome === undefined - ? Effect.void - : threads.dispatch({ - type: "thread.background-work.settle", - commandId: CommandId.make(`${effect.id}:background-work-settled`), - ...(request.subagent === undefined || outcome === undefined - ? {} - : { - subagent: { - id: request.subagent.id, - stoppedAt: DateTime.makeUnsafe(effect.createdAt), - outcome, - }, - }), + Effect.andThen(({ unresponsive, outcome }) => + unresponsive + ? threads.dispatch({ + type: "thread.interrupt.unresponsive", + commandId: CommandId.make(`${effect.id}:unresponsive`), threadId: effect.threadId, providerThreadId: request.providerThreadId, providerTurnId: request.providerTurnId, - }), + }) + : request.subagent !== undefined && outcome === undefined + ? Effect.void + : threads.dispatch({ + type: "thread.background-work.settle", + commandId: CommandId.make(`${effect.id}:background-work-settled`), + ...(request.subagent === undefined || outcome === undefined + ? {} + : { + subagent: { + id: request.subagent.id, + stoppedAt: DateTime.makeUnsafe(effect.createdAt), + outcome, + }, + }), + threadId: effect.threadId, + providerThreadId: request.providerThreadId, + providerTurnId: request.providerTurnId, + }), ), Effect.mapError( (cause) => diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 47f822b70b126..7364c8f4b645f 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -107,6 +107,8 @@ import { ContextHandoffServiceV2 } from "./ContextHandoffService.ts"; import { notificationTurnItem } from "@t3tools/provider-core/server/notification"; import { cancelledBeforeStart, + continuationFailedBeforePrompt, + continuationStartRetryDelayMs, isRestartContinuationSource, runOutcome, } from "./RestartBackgroundNote.ts"; @@ -533,6 +535,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "checkpoint.rollback": case "checkpoint.rollback.fail": case "thread.background-work.settle": + case "thread.interrupt.unresponsive": case "thread.stop": case "provider.switch": return command.threadId; @@ -740,6 +743,20 @@ function delegatedTaskTerminalStatus( } } +/** Failed worktree setup keeps the queue held so queued messages cannot start in the project checkout. */ +function worktreeSetupPending( + projection: Pick, +): boolean { + return ( + projection.thread.worktreePath === null && + projection.runs.some( + (run) => + (run.status === "failed" || run.status === "interrupted") && + run.workspacePreparation?.type === "worktree", + ) + ); +} + function nextQueuedRun( projection: Pick, ): OrchestrationV2Run | undefined { @@ -2194,6 +2211,81 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); + // Under load a resumed provider can time out before taking the prompt; the restart still owes the turn. + const retryContinuationStart = Effect.fn("Orchestrator.retryContinuationStart")(function* ( + threadId: ThreadId, + runId: RunId, + ) { + const projection = yield* projectionStore.getThreadRecords( + threadId, + ["runs", "providerTurns", "turnItems"], + { turnItemTypes: ["error"], turnItemRunIds: [runId] }, + ); + const run = projection.runs.find((candidate) => candidate.id === runId); + if (run === undefined) return; + const delayMs = continuationStartRetryDelayMs(run, projection); + if (delayMs === undefined) return; + const commandId = CommandId.make(`command:restart-continuation-retry:${run.id}`); + yield* writeSystemEvents( + [], + [ + { + id: `effect:restart-continuation:${run.id}`, + commandId, + threadId, + request: { type: "provider-runtime.continue", sourceRunId: run.id }, + availableAt: DateTime.add(yield* DateTime.now, { milliseconds: delayMs }), + }, + ], + ); + }); + + // A hold waits for a person to review what they queued. Nobody reviews messages from agents, + // the server or scheduled tasks, so a later completed run releases those unless it was stopped. + const releaseUnattendedHolds = Effect.fn("Orchestrator.releaseUnattendedHolds")(function* ( + threadId: ThreadId, + completedRunId: RunId, + ) { + const held = yield* projectionStore.getHeldQueuedRuns(threadId); + if (held.length === 0) return; + const projection = yield* projectionStore.getThreadRecords( + threadId, + ["runs", "messages", "turnItems"], + { + messageIds: held.map((run) => run.userMessageId), + turnItemTypes: ["run_interrupt_request"], + turnItemRunIds: [completedRunId], + }, + ); + // Only the newest run completing shows the thread moved past the hold; a late or + // bookkeeping update to an older run, overtaken by a failure or Stop, does not. + if ( + latestExecutedRun(projection.runs)?.id !== completedRunId || + projection.turnItems.length > 0 || + worktreeSetupPending(projection) + ) + return; + const { messages } = projection; + const unattended = new Set( + messages + .filter((message) => message.createdBy !== "user" || message.scheduledTaskId !== undefined) + .map((message) => message.id), + ); + const now = yield* DateTime.now; + const released = held.filter((run) => unattended.has(run.userMessageId)); + if (released.length === 0) return; + yield* writeSystemEvents( + released.map((run) => ({ + type: "run.updated" as const, + threadId, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...run, queueHeld: false }, + })), + ); + }); + const resumeQueuedRuns = Effect.gen(function* () { const threadIds = yield* projectionStore.getRecoveryThreadIds("queued-runs"); let resumed = 0; @@ -5127,7 +5219,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const source = projection.runs.find((run) => run.id === command.restartContinuationOfRunId); if ( !source || - !isRestartContinuationSource(source, projection.providerTurns) || + !( + isRestartContinuationSource(source, projection.providerTurns) || + continuationFailedBeforePrompt(source, projection.providerTurns) + ) || projection.thread.archivedAt !== null || projection.thread.deletedAt !== null || projection.thread.providerInstanceId !== source.providerInstanceId || @@ -5538,15 +5633,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio dispatchMode.type === "queue_after_active"; const shouldQueue = queueBehindRun !== undefined && startsOrQueuesRun; // Client messages resume held queues; server continuations and scheduled tasks do not. - // Provider failure or usage limits re-hold the queue. Failed worktree setup keeps it held - // until a worktree exists, so queued messages cannot start in the project checkout. - const worktreeSetupUnresolved = - projection.thread.worktreePath === null && - projection.runs.some( - (run) => - (run.status === "failed" || run.status === "interrupted") && - run.workspacePreparation?.type === "worktree", - ); + // Provider failure or usage limits re-hold the queue. + const worktreeSetupUnresolved = worktreeSetupPending(projection); const sentByUserFromClient = command.createdBy === "user" && (command.creationSource === "web" || command.creationSource === "mobile") && @@ -10190,7 +10278,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio run, rootNode, providerThread, - reason: yield* runInterruptReason(input.command, input.events, run.id), + reason: + (input.command.type === "thread.background-work.settle" + ? input.command.reason + : undefined) ?? (yield* runInterruptReason(input.command, input.events, run.id)), completedAt: input.now, }), }); @@ -10365,7 +10456,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // A resumed queue can have a lower ordinal than a pending continuation source. // Stop must reach those sources even when interrupting the resumed run succeeds. if ( - run.status === "cancelled" && + (run.status === "cancelled" || + (run.status === "failed" && run.restartContinuationOfRunId !== undefined)) && (yield* awaitsRestartContinuation(run).pipe(mapDispatchError(input.command))) ) { yield* markStoppedRun({ ...input, thread, run }); @@ -10436,6 +10528,55 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio yield* closePendingLinkRequests({ ...input, threadId: thread.id }); }); + const dispatchInterruptUnresponsive = Effect.fn("Orchestrator.dispatchInterruptUnresponsive")( + function* ( + command: Extract< + OrchestrationV2InternalCommand, + { readonly type: "thread.interrupt.unresponsive" } + >, + events: Ref.Ref>, + ) { + const { providerTurn } = yield* projectionStore + .getProviderControlContext(command.threadId, { + providerThreadId: command.providerThreadId, + providerTurnId: command.providerTurnId, + }) + .pipe(mapDispatchError(command)); + const { runs } = yield* projectionStore + .getThreadRecords(command.threadId, ["runs"]) + .pipe(mapDispatchError(command)); + const run = runs.find( + (candidate) => + providerTurn !== undefined && candidate.activeAttemptId === providerTurn.runAttemptId, + ); + // A settled run still counts: Stop may have targeted its background work. + if (run === undefined) return; + const { turnItems } = yield* projectionStore + .getThreadRecords(command.threadId, ["turnItems"], { + turnItemTypes: ["run_interrupt_request", "run_interrupt_result"], + turnItemRunIds: [run.id], + }) + .pipe(mapDispatchError(command)); + // A provider that stopped late already wrote the result; it needs no Force stop. + if (turnItems.some((item) => item.type === "run_interrupt_result")) return; + const request = turnItems.find((item) => item.type === "run_interrupt_request"); + if (request?.type !== "run_interrupt_request" || request.unresponsiveAt !== undefined) return; + const now = yield* DateTime.now; + yield* emit( + events, + command, + )({ + type: "turn-item.updated", + threadId: command.threadId, + runId: run.id, + ...(request.nodeId === null ? {} : { nodeId: request.nodeId }), + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...request, unresponsiveAt: now, updatedAt: now }, + }); + }, + ); + const dispatchBackgroundWorkSettle = Effect.fn("Orchestrator.dispatchBackgroundWorkSettle")( function* ( command: Extract< @@ -10973,6 +11114,45 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio occurredAt: now, payload: { ...run, status: "interrupted", completedAt: now }, }); + // Stop cancels a pending restart, so it must end the turn that restart could not stop. + const supersededTurn = projection.providerTurns.findLast( + (candidate) => + candidate.status === "running" && + projection.attempts.some( + (attempt) => attempt.id === candidate.runAttemptId && attempt.runId === run.id, + ), + ); + const supersededOwner = + supersededTurn === undefined + ? undefined + : projection.providerThreads.find( + (candidate) => candidate.id === supersededTurn.providerThreadId, + ); + if ( + supersededTurn !== undefined && + supersededOwner !== undefined && + supersededOwner.providerSessionId !== null && + Option.isSome( + yield* providerSessions + .get(supersededOwner.providerSessionId) + .pipe(Effect.orElseSucceed(() => Option.none())), + ) + ) { + const id = `effect:${command.commandId}:provider-turn.interrupt:${supersededTurn.id}`; + if (!otherProviderInterrupts.some((effect) => effect.id === id)) { + otherProviderInterrupts.push({ + id, + commandId: command.commandId, + threadId: command.threadId, + request: { + type: "provider-turn.interrupt", + providerSessionId: supersededOwner.providerSessionId, + providerThreadId: supersededOwner.id, + providerTurnId: supersededTurn.id, + }, + }); + } + } yield* stopRemainingWork(); yield* Ref.update(effects, (existing) => [...existing, ...otherProviderInterrupts]); return { @@ -12774,6 +12954,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.background-work.settle": yield* dispatchBackgroundWorkSettle(command, events, effects); break; + case "thread.interrupt.unresponsive": + yield* dispatchInterruptUnresponsive(command, events); + break; case "thread.fork": yield* dispatchThreadFork(command, events); break; @@ -12904,6 +13087,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio (planned) => planned.events.length > 0 || command.type === "thread.background-work.settle" || + command.type === "thread.interrupt.unresponsive" || command.type === "thread.stop" || command.type === "subagent.stop" || // Closing a share again, or after it ended, is not an error. @@ -13076,6 +13260,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // while writing child events, so nesting parent inside child would invert the order. // The keyed semaphores are neither reentrant nor deadlock-aware. if (delegatedTaskTerminalStatus(run.status) !== null) { + // Before the parent hears of the failure: a pending continuation keeps the task open. + if (run.status === "failed" && run.restartContinuationOfRunId !== undefined) { + yield* threadDispatch.withLock(threadId, retryContinuationStart(threadId, run.id)).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to schedule a restart continuation retry", { + threadId, + runId: run.id, + cause, + }), + ), + ); + } yield* threadDispatch.withLock( threadId, finalizeDelegatedCompletionDelivery(threadId, run.id), @@ -13083,10 +13279,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio yield* threadDispatch .withLock( threadId, - startNextQueuedRun(threadId, { - endedRunId: run.id, - ...(run.status === "failed" ? { failedRunId: run.id } : {}), - }), + (run.status === "completed" + ? releaseUnattendedHolds(threadId, run.id) + : Effect.void + ).pipe( + Effect.andThen( + startNextQueuedRun(threadId, { + endedRunId: run.id, + ...(run.status === "failed" ? { failedRunId: run.id } : {}), + }), + ), + ), ) .pipe( Effect.catchCause((cause) => diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 8cdcdc072ce21..bd2eff55bbc53 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -612,6 +612,9 @@ export interface ProjectionStoreV2Shape { readonly canStartQueuedRun: ( threadId: ThreadId, ) => Effect.Effect; + readonly getHeldQueuedRuns: ( + threadId: ThreadId, + ) => Effect.Effect, ProjectionStoreV2Error>; readonly getRecoveryThreadIds: ( kind: ProjectionRecoveryKind, ) => Effect.Effect, ProjectionStoreV2Error>; @@ -5182,6 +5185,17 @@ export const layer: Layer.Layer = ) .pipe(Effect.mapError(controlReadError(threadId))); + const getHeldQueuedRuns: ProjectionStoreV2Shape["getHeldQueuedRuns"] = (threadId) => + sql` + SELECT payload_json FROM orchestration_v2_projection_runs + WHERE thread_id = ${threadId} AND status = 'queued' + AND json_extract(payload_json, '$.queueHeld') IS 1 + ORDER BY ordinal ASC + `.pipe( + Effect.flatMap(decodeRows(decodeRunPayload, threadId)), + Effect.mapError(controlReadError(threadId)), + ); + const getMessageCount: ProjectionStoreV2Shape["getMessageCount"] = (threadId) => sql<{ count: number; @@ -6898,6 +6912,7 @@ export const layer: Layer.Layer = getCheckpointRollbackContext: readOnReader(getCheckpointRollbackContext), getRunMessage: readOnReader(getRunMessage), canStartQueuedRun: readOnReader(canStartQueuedRun), + getHeldQueuedRuns: readOnReader(getHeldQueuedRuns), getPendingNativeUserInputs: readOnReader(getPendingNativeUserInputs), hasUnpairedRunInterruptRequest: readOnReader(hasUnpairedRunInterruptRequest), isNodeStored: readOnReader(isNodeStored), diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index f9f98cbc67be5..73b2a81e95869 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -339,6 +339,7 @@ const makeProviderAdapter = Effect.fnUntraced(function* ( readonly backgroundTasksEndWithProcess?: boolean; readonly hangSessionScopeClose?: boolean; readonly startTurn?: Effect.Effect; + readonly interruptTurn?: Effect.Effect; readonly beforeUnload?: Effect.Effect; readonly spawnBeforeOpen?: boolean; readonly scopeCloseReached?: Deferred.Deferred; @@ -438,7 +439,7 @@ const makeProviderAdapter = Effect.fnUntraced(function* ( Ref.update(state, (current) => ({ ...current, interruptCount: current.interruptCount + 1, - })), + })).pipe(Effect.andThen(options.interruptTurn ?? Effect.void)), unloadThread: ({ providerThread }) => (options.beforeUnload ?? Effect.void).pipe( Effect.andThen( @@ -486,6 +487,7 @@ function layerTest(input: { readonly backgroundTasksEndWithProcess?: boolean; readonly hangSessionScopeClose?: boolean; readonly startTurn?: Effect.Effect; + readonly interruptTurn?: Effect.Effect; readonly beforeUnload?: Effect.Effect; readonly spawnBeforeOpen?: boolean; readonly scopeCloseReached?: Deferred.Deferred; @@ -521,6 +523,7 @@ function layerTest(input: { ? {} : { hangSessionScopeClose: input.hangSessionScopeClose }), ...(input.startTurn === undefined ? {} : { startTurn: input.startTurn }), + ...(input.interruptTurn === undefined ? {} : { interruptTurn: input.interruptTurn }), ...(input.beforeUnload === undefined ? {} : { beforeUnload: input.beforeUnload }), ...(input.spawnBeforeOpen === undefined ? {} : { spawnBeforeOpen: input.spawnBeforeOpen }), ...(input.scopeCloseReached === undefined @@ -1379,6 +1382,159 @@ it.effect( }), ); +it.effect("ProviderSessionManagerV2 gives up on a provider that never confirms a Stop", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-unresponsive-stop"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const runtime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const stopping = yield* runtime + .interruptTurn({ + providerThread: makeProviderThread({ idAllocator, threadId, providerSessionId, now }), + providerTurnId: idAllocator.derive.providerTurn({ + driver: CODEX_DRIVER, + nativeTurnId: "native-turn-unresponsive", + }), + }) + .pipe(Effect.flip, Effect.forkChild); + yield* TestClock.adjust(ProviderSessionManager.PROVIDER_INTERRUPT_TIMEOUT_MS); + assert.equal((yield* Fiber.join(stopping))._tag, "ProviderAdapterInterruptTimeoutError"); + }).pipe( + Effect.provide(layerTest({ state, idleTimeoutMs: 60_000, interruptTurn: Effect.never })), + ); + }), +); + +it.effect( + "ProviderSessionManagerV2 keeps a shared session a sibling is busy on when releasing", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const stopping = ThreadId.make("thread-provider-session-manager-release-stopping"); + const sibling = ThreadId.make("thread-provider-session-manager-release-sibling"); + const providerSessionId = idAllocator.derive.providerSession({ + providerInstanceId: modelSelection.instanceId, + }); + yield* eventSink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator, threadId: stopping, now }), + yield* makeThreadCreatedEvent({ idAllocator, threadId: sibling, now }), + ], + }); + const runtime = yield* manager.open({ + threadId: stopping, + providerSessionId, + modelSelection, + runtimePolicy, + }); + yield* manager.open({ + threadId: sibling, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const providerThreadOf = (threadId: ThreadId) => + makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, + nativeThreadId: `native-${threadId}`, + }); + const runId = idAllocator.derive.run({ threadId: sibling, ordinal: 1 }); + yield* runtime.startTurn({ + appThread: (yield* projectionStore.getThreadProjection(sibling)).thread, + threadId: sibling, + runId, + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }), + rootNodeId: idAllocator.derive.rootNode({ runId }), + providerThread: providerThreadOf(sibling), + message: { + createdBy: "user", + creationSource: "web", + messageId: yield* idAllocator.allocate.message({ threadId: sibling, ordinal: 1 }), + text: "turn", + attachments: [], + }, + modelSelection, + runtimePolicy, + }); + const release = (own: ThreadId) => + manager.releaseUnlessBusy({ + providerSessionId, + ownProviderThreadIds: [providerThreadOf(own).id], + reason: "manual_shutdown", + }); + + assert.isFalse(yield* release(stopping)); + assert.isTrue(yield* manager.isLive(providerSessionId)); + assert.isTrue(yield* release(sibling)); + assert.isFalse(yield* manager.isLive(providerSessionId)); + + // A turn that starts on a runtime released meanwhile fails instead of running on it. + const startOnReleasedRuntime = Effect.gen(function* () { + const lateRunId = idAllocator.derive.run({ threadId: stopping, ordinal: 1 }); + return yield* Effect.flip( + runtime.startTurn({ + appThread: (yield* projectionStore.getThreadProjection(stopping)).thread, + threadId: stopping, + runId: lateRunId, + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: idAllocator.derive.runAttempt({ runId: lateRunId, attemptOrdinal: 1 }), + rootNodeId: idAllocator.derive.rootNode({ runId: lateRunId }), + providerThread: providerThreadOf(stopping), + message: { + createdBy: "user", + creationSource: "web", + messageId: yield* idAllocator.allocate.message({ threadId: stopping, ordinal: 1 }), + text: "turn", + attachments: [], + }, + modelSelection, + runtimePolicy, + }), + ); + }); + assert.equal((yield* startOnReleasedRuntime)._tag, "ProviderAdapterTurnStartError"); + + // Nor may it leave its turn busy on a replacement opened under the same id. + yield* manager.open({ + threadId: sibling, + providerSessionId, + modelSelection, + runtimePolicy, + }); + assert.equal((yield* startOnReleasedRuntime)._tag, "ProviderAdapterTurnStartError"); + assert.isTrue(yield* release(sibling)); + }).pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 60 * 60_000 }))); + }), +); + it.effect("ProviderSessionManagerV2 stops a session still opening when its layer shuts down", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); @@ -2572,6 +2728,10 @@ it.effect("ProviderSessionManagerV2 releases sessions when provider event stream assert.isTrue(Option.isNone(liveSession)); assert.equal(runtimeState.closeCount, 1); assert.equal(projection.providerSessions.at(-1)?.status, "error"); + assert.equal( + projection.providerSessions.at(-1)?.lastError, + "The provider event stream closed unexpectedly. Retry the turn; if it keeps failing, check the provider and server logs.", + ); }); yield* effect.pipe( diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index f714c8cdea3e1..271ce3810cc0b 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -43,17 +43,26 @@ import * as CommandOutputHub from "./CommandOutputHub.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000; const DEFAULT_MAX_IDLE_PIN_MS = 4 * 60 * 60 * 1000; const RELEASE_SCOPE_CLOSE_TIMEOUT_MS = 30 * 1000; +/** Past adapters' own 10 s stop fallbacks, so they settle first when the provider answers at all. */ +export const PROVIDER_INTERRUPT_TIMEOUT_MS = 15 * 1000; const busyTurnPrefix = (providerThreadId: ProviderThreadId) => `${providerThreadId}#`; const busyTurnKey = (providerThreadId: ProviderThreadId, runOrdinal: number) => `${busyTurnPrefix(providerThreadId)}${runOrdinal}`; const UNLOAD_THREAD_TIMEOUT_MS = 10 * 1000; +const WORKING_RUN_STATUSES: ReadonlySet = new Set([ + "preparing", + "starting", + "running", + "waiting", +]); export const ProviderSessionReleaseReason = Schema.Literals([ "idle_timeout", @@ -173,6 +182,25 @@ export interface ProviderSessionManagerV2Shape { readonly detail?: string; readonly revokeMcpCredential?: boolean; }) => Effect.Effect; + /** + * Releases the session unless, at the moment of removal, a turn of a provider thread outside + * `ownProviderThreadIds` is busy on it. Checked atomically with turn starts. Succeeds with + * whether it was released. + */ + readonly releaseUnlessBusy: (input: { + readonly providerSessionId: ProviderSessionId; + readonly ownProviderThreadIds: ReadonlyArray; + readonly reason: ProviderSessionReleaseReason; + readonly detail?: string; + }) => Effect.Effect; + /** + * Other threads with a run or background work on this session's shared process; empty when + * it serves one thread. + */ + readonly otherWorkingThreads: (input: { + readonly providerSessionId: ProviderSessionId; + readonly threadId: ThreadId; + }) => Effect.Effect>; } export class ProviderSessionManagerV2 extends Context.Service< @@ -887,6 +915,7 @@ export const layerWithOptions = ( function* (input: { readonly providerSessionId: ProviderSessionId; readonly onlyIfIdleGeneration?: number; + readonly keepIfBusyOtherThan?: ReadonlyArray; }): Effect.fn.Return, DateTime.Utc]> { const key = sessionKey(input.providerSessionId); const candidate = (yield* Ref.get(sessions)).get(key); @@ -906,6 +935,15 @@ export const layerWithOptions = ( ) { return ["kept", current] as const; } + const own = input.keepIfBusyOtherThan; + if ( + own !== undefined && + [...existing.busyTurns].some( + (turnKey) => !own.some((id) => turnKey.startsWith(busyTurnPrefix(id))), + ) + ) { + return ["kept", current] as const; + } const updated = new Map(current); updated.delete(key); return ["removed", updated] as const; @@ -964,6 +1002,7 @@ export const layerWithOptions = ( readonly detachedThreadId?: ThreadId; readonly cancelIdleFiber?: boolean; readonly onlyIfIdleGeneration?: number; + readonly keepIfBusyOtherThan?: ReadonlyArray; readonly gracefulSubscribers?: boolean; }) => Effect.acquireUseRelease( @@ -1363,15 +1402,21 @@ export const layerWithOptions = ( .pipe(Effect.ensuring(Effect.sync(dropReservation))); }); - const markBusy = (providerSessionId: ProviderSessionId, turnKey: string) => + // Marks only the entry this runtime belongs to: a released or replaced session must not + // gain a busy turn that never ends. Succeeds with whether the turn was marked. + const markBusy = ( + providerSessionId: ProviderSessionId, + turnKey: string, + runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime, + ) => withActivityError( providerSessionId, Effect.gen(function* () { const key = sessionKey(providerSessionId); - const idleFiber = yield* Ref.modify(sessions, (current) => { + const marked = yield* Ref.modify(sessions, (current) => { const entry = current.get(key); - if (entry === undefined) { - return [null, current] as const; + if (entry === undefined || entry.runtime !== runtime) { + return [undefined, current] as const; } const updated = new Map(current); updated.set(key, { @@ -1380,9 +1425,11 @@ export const layerWithOptions = ( idleFiber: null, pinnedSinceMs: null, }); - return [entry.idleFiber, updated] as const; + return [{ idleFiber: entry.idleFiber }, updated] as const; }); - yield* cancelIdleFiber(idleFiber); + if (marked === undefined) return false; + yield* cancelIdleFiber(marked.idleFiber); + return true; }), ); @@ -1758,11 +1805,31 @@ export const layerWithOptions = ( ), Effect.andThen( Effect.acquireUseRelease( - observeActivity( + markBusy( providerSessionId, - markBusy( - providerSessionId, - busyTurnKey(input.providerThread.id, input.runOrdinal), + busyTurnKey(input.providerThread.id, input.runOrdinal), + runtime, + ).pipe( + Effect.catchCause((cause) => + Effect.logWarning("orchestration-v2.driver-session.activity-failed", { + providerSessionId, + cause, + }).pipe(Effect.as(true)), + ), + // A release between attach and markBusy leaves nothing to mark: fail the + // start instead of running it on a runtime being torn down. + Effect.flatMap((marked) => + marked + ? Effect.void + : Effect.fail( + new ProviderAdapter.ProviderAdapterTurnStartError({ + driver: runtime.driver, + threadId: input.threadId, + providerThreadId: input.providerThread.id, + runId: input.runId, + cause: "The provider session was released while the turn started.", + }), + ), ), ), () => runtime.startTurn(input), @@ -1780,9 +1847,25 @@ export const layerWithOptions = ( observeActivity(providerSessionId, touchActivity(providerSessionId)).pipe( Effect.andThen(runtime.steerTurn(input)), ), + // A provider starved of memory can stop answering, so no caller may wait on it unbounded. interruptTurn: (input) => observeActivity(providerSessionId, touchActivity(providerSessionId)).pipe( - Effect.andThen(runtime.interruptTurn(input)), + Effect.andThen( + runtime.interruptTurn(input).pipe( + Effect.timeoutOrElse({ + duration: PROVIDER_INTERRUPT_TIMEOUT_MS, + orElse: () => + Effect.fail( + new ProviderAdapter.ProviderAdapterInterruptTimeoutError({ + driver: runtime.driver, + providerThreadId: input.providerThread.id, + providerTurnId: input.providerTurnId, + timeoutMs: PROVIDER_INTERRUPT_TIMEOUT_MS, + }), + ), + }), + ), + ), ), respondToRuntimeRequest: (input) => observeActivity(providerSessionId, touchActivity(providerSessionId)).pipe( @@ -1956,10 +2039,16 @@ export const layerWithOptions = ( cause, }); yield* Ref.set(entry.eventSubscribers, new Map()); + // lastError is the thread banner, so it gets the same bounded + // message as a failed turn; the raw cause survives only in the log. + yield* Effect.logWarning("orchestration-v2.provider-session-runtime-failed", { + providerSessionId: entry.runtime.providerSessionId, + cause, + }); yield* releaseEntry({ providerSessionId: entry.runtime.providerSessionId, reason: "runtime_error", - detail: Cause.pretty(cause), + detail: makeProviderFailure({ cause }).message, }).pipe(logReleaseFailure(entry.runtime.providerSessionId)); }), ), @@ -2234,6 +2323,40 @@ export const layerWithOptions = ( ), ), release: releaseEntry, + releaseUnlessBusy: Effect.fn("ProviderSessionManager.releaseUnlessBusy")(function* (input) { + const key = sessionKey(input.providerSessionId); + const before = (yield* Ref.get(sessions)).get(key); + yield* releaseEntry({ + providerSessionId: input.providerSessionId, + reason: input.reason, + ...(input.detail === undefined ? {} : { detail: input.detail }), + keepIfBusyOtherThan: input.ownProviderThreadIds, + }); + // Identity, not presence: open can install a replacement under the same id meanwhile. + return before !== undefined && (yield* Ref.get(sessions)).get(key) !== before; + }), + otherWorkingThreads: Effect.fn("ProviderSessionManager.otherWorkingThreads")( + function* (input) { + const entry = (yield* Ref.get(sessions)).get(sessionKey(input.providerSessionId)); + if (entry === undefined || !entry.supportsMultipleProviderThreads) return []; + return yield* Effect.filter( + [...entry.attachedThreadIds].filter((threadId) => threadId !== input.threadId), + (threadId) => + // Runs, not the shell's activeRunId: the shell omits silent runs such as idle + // compaction. Background work outlives its run and still needs the process. + Effect.zipWith( + projectionStore.getThreadRecords(threadId, ["runs"]), + projectionStore.getThreadShell(threadId), + ({ runs }, shell) => + runs.some((run) => WORKING_RUN_STATUSES.has(run.status)) || + (shell?.pendingBackgroundTasks?.length ?? 0) > 0, + ).pipe( + // An unreadable thread may still be working; never kill it unseen. + Effect.orElseSucceed(() => true), + ), + ); + }, + ), detach: Effect.fn("ProviderSessionManager.detach")( function* (input) { const key = sessionKey(input.providerSessionId); diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 1d79e87eb9b91..65da8b23676b0 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -263,6 +263,7 @@ it.effect( getRunMessage: () => Effect.die("not used"), isNodeStored: () => Effect.die("not used"), canStartQueuedRun: () => Effect.die("not used"), + getHeldQueuedRuns: () => Effect.die("not used"), getRecoveryThreadIds: () => Effect.die("unused getRecoveryThreadIds"), getUnreadableThreadIds: () => Effect.die("unused getUnreadableThreadIds"), getThreadSnapshot: () => Effect.die("unused getThreadSnapshot"), @@ -283,6 +284,8 @@ it.effect( closeInstance: () => Effect.void, release: () => Effect.void, detach: () => Effect.void, + otherWorkingThreads: () => Effect.succeed([]), + releaseUnlessBusy: () => Effect.succeed(true), }), ); const layerControl = ProviderTurnControlService.layer.pipe( diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts index e1dbac21bc52e..bd225bb41773b 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts @@ -38,6 +38,8 @@ export class ProviderTurnControlError extends Schema.TaggedError + Effect.fail( + new ProviderTurnControlError({ + threadId: input.threadId, + operation: "interrupt", + providerTurnId: input.providerTurnId, + unresponsive: true, + cause, + }), + ), + }), + ); // Use real time; ingestion fibers do not advance TestClock and would leave Stop blocked. yield* Effect.gen(function* () { const deadline = (yield* Clock.currentTimeMillis) + 2_000; diff --git a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts index ba3830f65453d..24afd61d6186a 100644 --- a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts +++ b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts @@ -6,7 +6,7 @@ import type { OrchestrationV2RunAttempt, OrchestrationV2TurnItem, } from "@t3tools/contracts"; -import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; +import { latestRootProviderFailure, runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; type Work = OrchestrationV2RestartCancelledBackgroundWork; type Attempt = Pick; @@ -181,6 +181,50 @@ export function cancelledBeforeStart( return run.status === "cancelled" && neverStarted(run); } +/** The provider never accepted this continuation's prompt, so starting it again repeats nothing. */ +export function continuationFailedBeforePrompt( + run: Pick, + providerTurns: ReadonlyArray>, +): boolean { + return ( + run.status === "failed" && + run.restartContinuationOfRunId !== undefined && + !providerTurns.some((turn) => turn.runAttemptId === run.activeAttemptId) + ); +} + +const CONTINUATION_START_RETRY_DELAYS_MS = [60_000, 180_000]; + +/** Backoff before restarting a continuation that failed to start, or undefined to let it fail. */ +export function continuationStartRetryDelayMs( + run: OrchestrationV2Run, + projection: { + readonly runs: ReadonlyArray; + readonly providerTurns: ReadonlyArray>; + readonly turnItems: Parameters[1]; + }, +): number | undefined { + if (!continuationFailedBeforePrompt(run, projection.providerTurns)) return; + // These fail the same way however often they start. + const failureClass = latestRootProviderFailure(run, projection.turnItems)?.class; + if ( + failureClass === "usage_limit" || + failureClass === "permission_error" || + failureClass === "validation_error" + ) + return; + let failedStarts = 0; + for ( + let current: OrchestrationV2Run | undefined = run; + current !== undefined && continuationFailedBeforePrompt(current, projection.providerTurns); + current = projection.runs.find( + (candidate) => candidate.id === current?.restartContinuationOfRunId, + ) + ) + failedStarts += 1; + return CONTINUATION_START_RETRY_DELAYS_MS[failedStarts - 1]; +} + export function isRestartContinuationSource( source: Pick, providerTurns: ReadonlyArray>, diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index 061fc54a7f112..d2224720b89f1 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -20,6 +20,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerSettings from "../serverSettings.ts"; import { restartContinuationRun, continueRestartedRun } from "./RestartContinuation.ts"; +import { continuationStartRetryDelayMs } from "./RestartBackgroundNote.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ProviderRuntimeRecovery from "./ProviderRuntimeRecoveryService.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -46,6 +47,8 @@ function makeProjection() { providerInstanceId: instanceId, archivedAt: null, deletedAt: null, + lineage: { parentThreadId: null, relationshipToParent: null }, + forkedFrom: null, }, runs: [ { @@ -839,10 +842,13 @@ it.effect("does not cancel or resume a run that completes while shutdown intent }), ); -const continuationDispatches = (projection: OrchestrationV2ThreadProjection) => +const continuationDispatches = ( + projection: OrchestrationV2ThreadProjection, + sourceRunId: RunId = runId, +) => Effect.gen(function* () { const dispatches: Array> = []; - yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe( + yield* continueRestartedRun({ threadId, sourceRunId }).pipe( Effect.provide( Layer.merge( Layer.mock(ThreadManagementService.ThreadManagementService)({ @@ -943,6 +949,118 @@ it.effect("continues a resumed queued run that ran after an earlier continuation }), ); +it("backs off twice from a continuation the provider never took, then lets it fail", () => { + const base = makeProjection(); + const failedStart = (id: string, of: RunId) => ({ + ...base.runs[0]!, + id: RunId.make(id), + activeAttemptId: RunAttemptId.make(`attempt:${id}`), + status: "failed" as const, + restartContinuationOfRunId: of, + }); + const first = failedStart("run:retry-1", runId); + const second = failedStart("run:retry-2", first.id); + const third = failedStart("run:retry-3", second.id); + const projection = (failureClass = "provider_error", providerTurns = base.providerTurns) => + ({ + runs: [{ ...base.runs[0]!, status: "cancelled" }, first, second, third], + providerTurns, + turnItems: [first, second, third].map((run) => ({ + id: `error:${run.id}`, + type: "error", + status: "failed", + runId: run.id, + nodeId: run.rootNodeId, + ordinal: 1, + updatedAt: now, + failure: { class: failureClass, message: "Failed.", code: null, retryable: null }, + })), + }) as unknown as Parameters[1]; + assert.deepEqual( + [first, second, third].map((run) => continuationStartRetryDelayMs(run, projection())), + [60_000, 180_000, undefined], + ); + for (const failureClass of ["usage_limit", "permission_error", "validation_error"]) + assert.isUndefined(continuationStartRetryDelayMs(first, projection(failureClass))); + // Once the provider took the prompt, starting again would repeat the turn. + assert.isUndefined( + continuationStartRetryDelayMs( + first, + projection("provider_error", [ + { ...base.providerTurns[0]!, runAttemptId: first.activeAttemptId, status: "failed" }, + ] as never), + ), + ); +}); + +it.effect("continues again after a continuation fails before its prompt", () => + Effect.gen(function* () { + const cut = cutMidTurn(); + const failedStart = { + ...cut.runs[0]!, + id: RunId.make("run:failed-start"), + ordinal: 2, + activeAttemptId: RunAttemptId.make("attempt:failed-start"), + status: "failed", + restartContinuationOfRunId: runId, + }; + const dispatches = yield* continuationDispatches( + { ...cut, runs: [...cut.runs, failedStart] } as unknown as OrchestrationV2ThreadProjection, + failedStart.id, + ); + assert.deepEqual( + dispatches.map((command) => command.restartContinuationOfRunId), + [failedStart.id], + ); + }), +); + +it.effect.each([ + ["running", null, 0], + ["completed", "Done.", 1], +] as const)( + "a parent's Stop on the task's run blocks the child only while the task is %s", + ([status, result, expected]) => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:parent"); + const parentRunId = RunId.make("run:parent"); + const taskId = NodeId.make("node:task"); + const cut = cutMidTurn(); + const child = { + ...cut, + thread: { + ...cut.thread, + lineage: { parentThreadId, relationshipToParent: "subagent" }, + forkedFrom: { type: "node", nodeId: taskId }, + }, + } as unknown as OrchestrationV2ThreadProjection; + const parent = { + thread: { id: parentThreadId }, + subagents: [{ id: taskId, runId: parentRunId, origin: "app_owned", status, result }], + turnItems: [{ id: "turn-item:stop", runId: parentRunId, type: "run_interrupt_request" }], + }; + let dispatched = 0; + yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe( + Effect.provide( + Layer.merge( + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (id) => + Effect.succeed((id === parentThreadId ? parent : child) as never), + recoverDelegatedTask: () => Effect.void, + dispatch: () => + Effect.sync(() => { + dispatched += 1; + return {} as never; + }), + }), + ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }), + ), + ), + ); + assert.equal(dispatched, expected); + }), +); + it.effect("does not continue a run the user asked to stop before the restart", () => Effect.gen(function* () { const texts = yield* continuationTexts({ diff --git a/apps/server/src/orchestration-v2/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts index c81faa6cf47cb..e4d7ac2b7f900 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.ts @@ -2,6 +2,7 @@ import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { CommandId, + isOrchestrationV2WorkActive, latestProviderTurnForAttempt, MessageId, type OrchestrationV2Run, @@ -19,6 +20,7 @@ import { awaitingProviderThreadId, cancelledRosterTaskWork, cancelledTurnItemWork, + continuationFailedBeforePrompt, isAwaitedWork, isRestartContinuationSource, cancelledBeforeStart, @@ -200,7 +202,10 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta const source = projection.runs.find((run) => run.id === input.sourceRunId); if ( !source || - !isRestartContinuationSource(source, projection.providerTurns) || + !( + isRestartContinuationSource(source, projection.providerTurns) || + continuationFailedBeforePrompt(source, projection.providerTurns) + ) || restartContinuationBlocked( projection, source, @@ -255,6 +260,32 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta ) ) return; + // A parent's Stop reaches its open delegated tasks through an effect a restart can cut short. + const { lineage, forkedFrom } = projection.thread; + const taskId = + source.delegatedTaskId ?? (forkedFrom?.type === "node" ? forkedFrom.nodeId : undefined); + if ( + lineage.relationshipToParent === "subagent" && + lineage.parentThreadId !== null && + taskId !== undefined + ) { + const task = (yield* threads.getThreadRecords(lineage.parentThreadId, ["subagents"], { + subagentIds: [taskId], + })).subagents.find((candidate) => candidate.id === taskId); + // A settled task no longer owns later work in its thread. + const parentRunId = + task !== undefined && task.result === null && isOrchestrationV2WorkActive(task.status) + ? task.runId + : null; + if ( + parentRunId !== null && + (yield* threads.getThreadRecords(lineage.parentThreadId, ["turnItems"], { + turnItemTypes: ["run_interrupt_request"], + turnItemRunIds: [parentRunId], + })).turnItems.length > 0 + ) + return; + } const sourceMessage = sourceRecords.messages.find( (message) => message.id === source.userMessageId, ); diff --git a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts index 03cdf5e85a642..d059c4c20e9d7 100644 --- a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts +++ b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts @@ -438,6 +438,118 @@ it.live("retries a failed selection restart session open before completing", () ), ); +it.live("Stop during a restart interrupts the turn the restart could not stop", () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("selection-restart-stop"); + const threadId = ThreadId.make("thread:selection-restart-stop"); + const state = yield* Ref.make({ + activeTurn: null, + opened: [], + started: [], + closedSessionCount: 0, + failedReplacementOpen: false, + }); + const base = makeRestartAdapter(state); + // A wedged provider ignores the restart's soft cancel; only Stop ends its turn. + const adapter: ProviderAdapter.ProviderAdapterV2["Service"] = { + ...base, + openSession: (input) => + base.openSession(input).pipe( + Effect.map((session) => ({ + ...session, + interruptTurn: (interrupt: ProviderAdapter.ProviderAdapterV2InterruptInput) => + interrupt.requestRuntimeRestart === true + ? session.interruptTurn(interrupt) + : Effect.never, + })), + ), + }; + + const result = yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const running = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => event.type === "provider-turn.updated" && event.payload.status === "running", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped({ startImmediately: true }), + ); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:selection-restart-stop:create"), + threadId, + projectId: ProjectId.make("project:selection-restart-stop"), + title: "Stop during restart", + modelSelection: initialSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:selection-restart-stop:first"), + threadId, + messageId: MessageId.make("message:selection-restart-stop:first"), + text: "first", + attachments: [], + modelSelection: initialSelection, + dispatchMode: { type: "start_immediately" }, + }); + yield* worker.drain(); + yield* Fiber.join(running); + const runId = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!.id; + + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:selection-restart-stop:steer"), + threadId, + messageId: MessageId.make("message:selection-restart-stop:steer"), + text: "Are you still going?", + attachments: [], + modelSelection: initialSelection, + dispatchMode: { type: "restart_active", targetRunId: runId }, + }); + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("command:selection-restart-stop:stop"), + threadId, + runId, + }); + yield* worker.drain(); + return { + projection: yield* orchestrator.getThreadProjection(threadId), + captured: yield* Ref.get(state), + }; + }).pipe( + Effect.provide( + ProviderReplayHarness.layerWithRegistry( + { name: "selection-restart-stop" }, + ProviderAdapterRegistry.layerSingle(adapter), + ), + ), + ); + + assert.isNull(result.captured.activeTurn); + assert.lengthOf(result.captured.started, 1); + assert.equal(result.projection.runs[0]?.status, "interrupted"); + assert.deepEqual( + result.projection.providerTurns.map((turn) => turn.status), + ["interrupted"], + ); + }), + ), +); + it.live( "restarts the live session on a model change when a newer stopped session record exists", () => diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index b2daad62681e9..9285f72d46f56 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -27,6 +27,8 @@ import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; +import { AgentScope } from "@t3tools/shared/AgentScope"; + import * as ServerSettings from "../serverSettings.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; @@ -596,3 +598,174 @@ it.effect.each([ expect(dispatched).toEqual(settles ? ["thread.settle"] : []); }), ); + +it.effect.each([ + { + name: "kills the thread's own agent and ends its turn", + others: [] as ReadonlyArray, + killed: true, + expected: { outcome: "killed", sharedWithThreadIds: [] }, + teardown: ["release"], + }, + { + name: "keeps a process another working thread shares and only ends this turn", + others: ["thread:thread-management:force-stop:other"], + killed: false, + expected: { + outcome: "shared", + sharedWithThreadIds: ["thread:thread-management:force-stop:other"], + }, + teardown: ["detach"], + }, + { + name: "still settles a finished run's background work in a shared process", + others: ["thread:thread-management:force-stop:other"], + killed: false, + settledRun: true, + expected: { + outcome: "shared", + sharedWithThreadIds: ["thread:thread-management:force-stop:other"], + }, + teardown: ["detach"], + }, + { + name: "detaches when a sibling starts a turn just before the session is released", + others: [] as ReadonlyArray, + killed: false, + siblingBusy: true, + expected: { outcome: "shared", sharedWithThreadIds: [] }, + teardown: ["release", "detach"], + }, + { + name: "reports a teardown that has not finished in time as still releasing", + others: [] as ReadonlyArray, + killed: false, + releaseHangs: true, + expected: { outcome: "releasing", sharedWithThreadIds: [] }, + teardown: ["release"], + }, +])("Force stop $name", ({ others, killed, expected, teardown, ...row }) => + Effect.gen(function* () { + const threadId = ThreadId.make("thread:thread-management:force-stop"); + const providerSessionId = ProviderSessionId.make("provider-session:force-stop"); + const providerThreadId = ProviderThreadId.make("provider-thread:force-stop"); + const runId = RunId.make("run:thread-management:force-stop"); + const dispatched: Array = []; + const teardowns: Array = []; + const tornDown = yield* Deferred.make(); + const layerTest = ThreadManagementService.layer.pipe( + Layer.provide( + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ + isLive: () => Effect.succeed(true), + otherWorkingThreads: () => Effect.succeed(others.map((id) => ThreadId.make(id))), + releaseUnlessBusy: () => + Effect.sync(() => teardowns.push("release")).pipe( + Effect.andThen( + "releaseHangs" in row ? Effect.never : Effect.succeed(!("siblingBusy" in row)), + ), + ), + detach: () => + Effect.sync(() => teardowns.push("detach")).pipe( + Effect.andThen(Deferred.succeed(tornDown, undefined)), + Effect.asVoid, + ), + }), + ), + Layer.provide( + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadRecords: () => + Effect.succeed({ + thread: { id: threadId, deletedAt: null }, + runs: [ + { + id: runId, + ordinal: 1, + status: "settledRun" in row ? "completed" : "running", + activeAttemptId: "attempt", + }, + // A queued follow-up neither supersedes the request nor becomes the target. + { id: RunId.make("run:queued-follow-up"), ordinal: 2, status: "queued" }, + ], + providerThreads: [{ id: providerThreadId, appThreadId: threadId, providerSessionId }], + providerTurns: [ + { + id: "provider-turn:force-stop", + providerThreadId, + runAttemptId: "attempt", + status: "settledRun" in row ? "completed" : "running", + }, + ], + } as unknown as OrchestrationV2ThreadProjection), + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command); + return { sequence: 1, storedEvents: [] } as never; + }), + }), + ), + Layer.provide( + Layer.succeed(AgentScope, { + ...AgentScope.defaultValue(), + kill: () => Effect.succeed(killed), + }), + ), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(layerReceiptsTest), + ); + yield* Effect.gen(function* () { + const service = yield* ThreadManagementService.ThreadManagementService; + const stopping = yield* service.forceStopThread({ threadId, runId }).pipe(Effect.forkChild); + yield* TestClock.adjust("10 seconds"); + expect(yield* Fiber.join(stopping)).toEqual(expected); + expect(dispatched).toMatchObject([ + { + type: "thread.background-work.settle", + providerThreadId, + reason: "Force stopped by user", + }, + ]); + if (teardown.at(-1) === "detach") yield* Deferred.await(tornDown); + expect(teardowns).toEqual(teardown); + }).pipe(Effect.provide(layerTest)); + }), +); + +it.effect("Force stop refuses once a newer run has started, before killing anything", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread:thread-management:force-stop-superseded"); + let killed = false; + const layerTest = ThreadManagementService.layer.pipe( + Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), + Layer.provide( + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadRecords: () => + Effect.succeed({ + thread: { id: threadId, deletedAt: null }, + runs: [ + { id: RunId.make("run:stopped"), ordinal: 1, status: "running" }, + { id: RunId.make("run:newer"), ordinal: 2, status: "running" }, + ], + providerThreads: [], + providerTurns: [], + } as unknown as OrchestrationV2ThreadProjection), + }), + ), + Layer.provide( + Layer.succeed(AgentScope, { + ...AgentScope.defaultValue(), + kill: () => Effect.sync(() => (killed = true)), + }), + ), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(layerReceiptsTest), + ); + const error = yield* Effect.gen(function* () { + const service = yield* ThreadManagementService.ThreadManagementService; + return yield* Effect.flip( + service.forceStopThread({ threadId, runId: RunId.make("run:stopped") }), + ); + }).pipe(Effect.provide(layerTest)); + expect(error.reason).toBe("superseded"); + expect(killed).toBe(false); + }), +); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 664002ed619d6..9bbdf74b47a4c 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -14,6 +14,9 @@ import { MessageId, OrchestrationV2StopWorkflowError, type OrchestrationV2StopWorkflowInput, + OrchestrationV2ForceStopThreadError, + type OrchestrationV2ForceStopThreadInput, + type OrchestrationV2ForceStopThreadResult, type ModelSelection, type OrchestrationV2Actor, type OrchestrationV2Command, @@ -30,6 +33,7 @@ import { ProjectId, PROVIDER_SEND_TURN_MAX_FILE_BYTES, ProviderInstanceId, + type ProviderSessionId, resolveProjectProviderInstanceEnabled, RunId, type ScheduledTaskId, @@ -38,10 +42,13 @@ import { } from "@t3tools/contracts"; import { delegatedTaskProgress } from "@t3tools/provider-core/server/subagentProjection"; import { threadTranscriptHeader } from "@t3tools/shared/threadTranscript"; +import { AgentScope } from "@t3tools/shared/AgentScope"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -455,6 +462,13 @@ export interface ThreadManagementServiceShape { readonly stopWorkflow: ( input: OrchestrationV2StopWorkflowInput, ) => Effect.Effect; + /** + * Kills the thread's agent for a provider that no longer responds, then ends + * its turn as interrupted. Never kills a process other working threads share. + */ + readonly forceStopThread: ( + input: OrchestrationV2ForceStopThreadInput, + ) => Effect.Effect; readonly awaitBackgroundTasks: (input: { readonly threadId: ThreadId; readonly taskIds: ReadonlyArray; @@ -548,12 +562,16 @@ function latestSteerableRun( } const SETTLE_AFTER_RUN_WAIT_MS = 24 * 60 * 60 * 1_000; +const FORCE_STOP_REASON = "Force stopped by user"; +const FORCE_STOP_RELEASE_WAIT = "10 seconds"; +const isForceStopThreadError = Schema.is(OrchestrationV2ForceStopThreadError); const make = Effect.gen(function* () { const threadDispatch = yield* ThreadCommandExecutor.ThreadCommandExecutor; const orchestrator = yield* Orchestrator.OrchestratorV2; const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2; const layerScope = yield* Effect.scope; + const agentScope = yield* AgentScope; const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; const serverSettings = yield* ServerSettings.ServerSettingsService; const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2; @@ -1073,6 +1091,153 @@ const make = Effect.gen(function* () { return { type: "interrupt_requested", run: interruptibleRun, dispatch } as const; }); + const forceStopThread: ThreadManagementServiceShape["forceStopThread"] = Effect.fn( + "ThreadManagementService.forceStopThread", + )( + function* (input) { + // Under the thread lock no new run can be created between the check and the kill. The + // settlement below dispatches, which takes that lock itself, so it runs after release. + const { records, target, killed } = yield* threadDispatch.withLock( + input.threadId, + Effect.gen(function* () { + const records = yield* orchestrator.getThreadRecords(input.threadId, [ + "runs", + "providerThreads", + "providerTurns", + ]); + // The run Force stop acts on: the active one, else the latest that started. Queued + // follow-ups never ran, so they neither count as targets nor supersede the request. + const target = + latestActiveRun(records) ?? + records.runs + .filter((run) => run.status !== "queued") + .reduce( + (current, run) => + current === undefined || run.ordinal > current.ordinal ? run : current, + undefined, + ); + if (input.runId !== undefined && input.runId !== target?.id) { + return yield* new OrchestrationV2ForceStopThreadError({ + threadId: input.threadId, + reason: "superseded", + }); + } + // Shared scopes are never tracked per thread, so this only kills the thread's own agent. + const killed = yield* agentScope.kill(input.threadId); + return { records, target, killed }; + }), + ); + const sessionIds = new Set( + records.providerThreads.flatMap((providerThread) => + providerThread.appThreadId === input.threadId && providerThread.providerSessionId !== null + ? [providerThread.providerSessionId] + : [], + ), + ); + // A run that already ended may still hold background work; settling it is a no-op otherwise. + const run = target; + if (run !== undefined) { + const active = isActiveRun(run); + const providerTurn = records.providerTurns.findLast( + (turn) => + turn.runAttemptId === run.activeAttemptId && (!active || turn.status === "running"), + ); + const commandId = CommandId.make( + `server:force-stop:${run.id}:${yield* Clock.currentTimeMillis}`, + ); + yield* providerTurn === undefined && !active + ? Effect.void + : providerTurn === undefined + ? orchestrator.dispatch({ + type: "run.interrupt", + commandId, + threadId: input.threadId, + runId: run.id, + reason: FORCE_STOP_REASON, + }) + : orchestrator.dispatch({ + type: "thread.background-work.settle", + commandId, + threadId: input.threadId, + providerThreadId: providerTurn.providerThreadId, + providerTurnId: providerTurn.id, + reason: FORCE_STOP_REASON, + }); + } + const ownProviderThreadIds = records.providerThreads.flatMap((providerThread) => + providerThread.appThreadId === input.threadId ? [providerThread.id] : [], + ); + const detach = (providerSessionId: ProviderSessionId) => + sessions + .detach({ providerSessionId, threadId: input.threadId, detail: FORCE_STOP_REASON }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("orchestration-v2.force-stop-detach-failed", { + threadId: input.threadId, + cause, + }), + ), + Effect.forkIn(layerScope), + ); + let shared = false; + const sharedWith = new Set(); + const releases: Array<{ + readonly providerSessionId: ProviderSessionId; + readonly fiber: Fiber.Fiber; + }> = []; + for (const providerSessionId of sessionIds) { + if (!(yield* sessions.isLive(providerSessionId))) continue; + // Decided just before teardown, so the reply matches what happens to the process. + const others = yield* sessions.otherWorkingThreads({ + providerSessionId, + threadId: input.threadId, + }); + for (const threadId of others) sharedWith.add(threadId); + if (others.length > 0) { + shared = true; + yield* detach(providerSessionId); + continue; + } + // The session manager re-checks atomically with turn starts and keeps a session a + // sibling began using in the meantime. + const fiber = yield* sessions + .releaseUnlessBusy({ + providerSessionId, + ownProviderThreadIds, + reason: "manual_shutdown", + detail: FORCE_STOP_REASON, + }) + .pipe(Effect.forkIn(layerScope)); + releases.push({ providerSessionId, fiber }); + } + // Teardown waits on the provider's own exit paths; past this bound it keeps going on its own. + let releasing = false; + for (const release of releases) { + const released = yield* Fiber.join(release.fiber).pipe( + Effect.timeoutOption(FORCE_STOP_RELEASE_WAIT), + ); + if (Option.isNone(released)) { + releasing = true; + } else if (!released.value) { + shared = true; + yield* detach(release.providerSessionId); + } + } + return { + outcome: killed ? "killed" : shared ? "shared" : releasing ? "releasing" : "released", + sharedWithThreadIds: [...sharedWith], + } as const; + }, + (effect, input) => + effect.pipe( + Effect.mapError((cause) => + isForceStopThreadError(cause) + ? cause + : new OrchestrationV2ForceStopThreadError({ threadId: input.threadId, cause }), + ), + ), + ); + const stopWorkflow: ThreadManagementServiceShape["stopWorkflow"] = Effect.fn( "ThreadManagementService.stopWorkflow", )( @@ -1355,6 +1520,7 @@ const make = Effect.gen(function* () { interruptThread, stopDelegatedTasks, stopWorkflow, + forceStopThread, awaitBackgroundTasks, getThreadEventSequence: orchestrator.getThreadEventSequence, recoverDelegatedTask: orchestrator.recoverDelegatedTask, diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index 23a52f39d33d3..16dcedaf8ac9c 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -1552,3 +1552,115 @@ it.effect.each(["missing session", "completed", "failed", "cancelled"] as const) ); }).pipe(Effect.provide(layerTest)), ); + +it.effect("an unanswered Stop is flagged until Force stop ends the run with its own reason", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread:force-stop"); + yield* createWatchingThread(threadId, 6); + yield* send(threadId, "work", "start_immediately"); + const now = yield* DateTime.now; + const started = yield* orchestrator.getThreadProjection(threadId); + const run = started.runs[0]!; + const providerThreadId = run.providerThreadId!; + const providerTurnId = ProviderTurnId.make("provider-turn:force-stop"); + const apply = (event: Omit) => + projections.apply({ + ...event, + id: EventId.make(`event:force-stop:${event.type}`), + threadId, + } as OrchestrationV2DomainEvent); + const attempt = started.attempts[0]!; + const attemptId = attempt.id; + yield* apply({ + type: "run.updated", + runId: run.id, + occurredAt: now, + payload: { ...run, status: "running", startedAt: now, activeAttemptId: attemptId }, + }); + yield* apply({ + type: "run-attempt.updated", + occurredAt: now, + payload: { ...attempt, status: "running", startedAt: now }, + }); + yield* apply({ + type: "provider-turn.updated", + occurredAt: now, + payload: { + id: providerTurnId, + providerThreadId, + nodeId: run.rootNodeId!, + runAttemptId: attemptId, + nativeTurnRef: null, + ordinal: 1, + status: "running", + startedAt: now, + completedAt: null, + }, + }); + yield* apply({ + type: "turn-item.updated", + runId: run.id, + occurredAt: now, + payload: { + id: TurnItemId.make("turn-item:force-stop:interrupt-request"), + threadId, + runId: run.id, + nodeId: run.rootNodeId!, + providerThreadId, + providerTurnId, + nativeItemRef: null, + parentItemId: null, + ordinal: 1_000, + status: "completed", + title: "Interrupt requested", + startedAt: now, + completedAt: now, + updatedAt: now, + type: "run_interrupt_request", + message: "Stopped by user", + }, + }); + const target = { threadId, providerThreadId, providerTurnId }; + const request = Effect.map(orchestrator.getThreadProjection(threadId), (projection) => + projection.turnItems.find((item) => item.type === "run_interrupt_request"), + ); + + yield* orchestrator.dispatch({ + ...target, + type: "thread.interrupt.unresponsive", + commandId: CommandId.make("force-stop:unresponsive"), + }); + const flagged = yield* request; + assert.isTrue( + flagged?.type === "run_interrupt_request" && flagged.unresponsiveAt !== undefined, + ); + assert.deepEqual((yield* threadState(threadId)).runs, ["running"]); + + yield* orchestrator.dispatch({ + ...target, + type: "thread.background-work.settle", + commandId: CommandId.make("force-stop:settle"), + reason: "Force stopped by user", + }); + const settled = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual( + settled.runs.map((candidate) => candidate.status), + ["interrupted"], + ); + const result = settled.turnItems.find((item) => item.type === "run_interrupt_result"); + assert.equal( + result?.type === "run_interrupt_result" ? result.message : undefined, + "Force stopped by user", + ); + + // A provider confirming late changes nothing once the run has ended. + const late = yield* orchestrator.dispatch({ + ...target, + type: "thread.interrupt.unresponsive", + commandId: CommandId.make("force-stop:late-unresponsive"), + }); + assert.lengthOf(late.storedEvents, 0); + }).pipe(Effect.provide(layerTest)), +); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index cdf15026b11e4..1f3b961e8e804 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -4745,6 +4745,265 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + // A thread fed by agent messages, with helpers to end its runs as a provider would. + const queueTestThread = (threadId: ThreadId) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + title: "Dispatcher", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + const send = (key: string, createdBy: "user" | "agent", start: boolean) => + orchestrator.dispatch({ + type: "message.dispatch", + createdBy, + creationSource: createdBy === "user" ? "web" : "server", + commandId: CommandId.make(`${threadId}:message:${key}`), + threadId, + messageId: MessageId.make(`${threadId}:message:${key}`), + text: key, + attachments: [], + modelSelection, + dispatchMode: { type: start ? "start_immediately" : "queue_after_active" }, + }); + const runFor = (key: string) => + Effect.map(orchestrator.getThreadProjection(threadId), ({ runs }) => { + const run = runs.find( + (candidate) => candidate.userMessageId === MessageId.make(`${threadId}:message:${key}`), + ); + assert.isDefined(run); + return run; + }); + const startedRunIds = yield* Queue.unbounded(); + // Run updates in commit order; the run-update listener handles them in this order too. + const runUpdates: Array = []; + yield* eventSink + .stream({ threadId, afterSequence: yield* orchestrator.getThreadEventSequence(threadId) }) + .pipe( + Stream.runForEach((stored) => { + if (stored.event.type !== "run.updated") return Effect.void; + runUpdates.push(stored.event.payload); + return stored.event.payload.status === "starting" + ? Queue.offer(startedRunIds, stored.event.payload.id) + : Effect.void; + }), + Effect.forkScoped, + ); + yield* Effect.yieldNow; + const releasedAfter = (key: string, completedKey: string) => + Effect.gen(function* () { + const [run, completed] = [yield* runFor(key), yield* runFor(completedKey)]; + const released = runUpdates.findIndex( + (update) => update.id === run.id && update.queueHeld === false, + ); + const completion = runUpdates.findIndex( + (update) => update.id === completed.id && update.status === "completed", + ); + assert.isAbove(released, completion, `${key} was released before ${completedKey} ended`); + }); + const end = ( + key: string, + status: "completed" | "failed", + revision = "", + patch: Partial = {}, + ) => + Effect.gen(function* () { + const run = yield* runFor(key); + assert.isNotNull(run.rootNodeId); + const now = yield* DateTime.now; + yield* eventSink.write({ + events: [ + ...(status === "failed" + ? [ + { + id: EventId.make(`${threadId}:${key}:error`), + type: "turn-item.updated" as const, + threadId, + runId: run.id, + nodeId: run.rootNodeId, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { + id: TurnItemId.make(`${threadId}:${key}:error`), + type: "error" as const, + threadId, + runId: run.id, + nodeId: run.rootNodeId, + providerThreadId: run.providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 2, + status: "failed" as const, + title: "Provider failure", + startedAt: now, + completedAt: now, + updatedAt: now, + failure: { + class: "provider_error" as const, + message: "Provider failed.", + code: "provider_failed", + retryable: null, + }, + }, + }, + ] + : []), + { + id: EventId.make(`${threadId}:${key}:${status}${revision}`), + type: "run.updated", + threadId, + runId: run.id, + nodeId: run.rootNodeId, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...run, ...patch, status, startedAt: now, completedAt: now }, + }, + ], + }); + }); + + // The run-update listener places failure holds after the terminal event commits. + const awaitHeld = (key: string) => + Effect.gen(function* () { + const run = yield* runFor(key); + yield* eventSink.stream({ threadId, afterSequence: 0, eventType: "run.updated" }).pipe( + Stream.filter( + (stored) => + stored.event.type === "run.updated" && + stored.event.payload.id === run.id && + stored.event.payload.queueHeld === true, + ), + Stream.runHead, + ); + }); + return { send, runFor, end, startedRunIds, releasedAfter, awaitHeld }; + }); + + it.effect("releases held agent messages once a later run completes, not the user's", () => + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make("runtime-layer-failed-queue-agent-messages"); + const { send, runFor, end, startedRunIds, releasedAfter, awaitHeld } = + yield* queueTestThread(threadId); + yield* send("earlier", "agent", true); + yield* end("earlier", "completed"); + yield* send("active", "agent", true); + yield* send("issue", "agent", false); + yield* send("user", "user", false); + yield* end("active", "failed"); + yield* awaitHeld("issue"); + assert.isTrue((yield* runFor("issue")).queueHeld); + // Delivery bookkeeping rewrites an older completed run; the thread has not moved on. + yield* end("earlier", "completed", ":bookkeeping"); + // A scheduled tick finds the thread idle and starts past the held queue. + yield* send("tick", "agent", true); + assert.equal((yield* runFor("tick")).status, "starting"); + yield* end("tick", "completed"); + assert.equal(yield* Queue.take(startedRunIds), (yield* runFor("issue")).id); + yield* releasedAfter("issue", "tick"); + const user = yield* runFor("user"); + assert.equal(user.status, "queued"); + assert.isTrue(user.queueHeld); + + // A run that finishes despite a stop request does not release what the stop held. + yield* send("after-stop", "agent", false); + assert.isTrue((yield* runFor("after-stop")).queueHeld); + const issue = yield* runFor("issue"); + assert.isNotNull(issue.rootNodeId); + const now = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${threadId}:issue:interrupt-request`), + type: "turn-item.updated", + threadId, + runId: issue.id, + nodeId: issue.rootNodeId, + providerInstanceId: issue.providerInstanceId, + occurredAt: now, + payload: { + id: TurnItemId.make(`${threadId}:issue:interrupt-request`), + type: "run_interrupt_request", + threadId, + runId: issue.id, + nodeId: issue.rootNodeId, + providerThreadId: issue.providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 2, + status: "completed", + title: "Interrupt requested", + startedAt: now, + completedAt: now, + updatedAt: now, + message: "Stopped by user", + }, + }, + ], + }); + yield* end("issue", "completed"); + // The next completed run releases it, which also shows the listener handled the stopped one. + yield* send("probe", "agent", true); + assert.equal((yield* runFor("probe")).status, "starting"); + yield* end("probe", "completed"); + assert.equal(yield* Queue.take(startedRunIds), (yield* runFor("after-stop")).id); + yield* releasedAfter("after-stop", "probe"); + }), + ); + + it.effect("keeps held agent messages while the thread's worktree setup is unresolved", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const dispatcherId = ThreadId.make("runtime-layer-held-queue-worktree"); + const dispatcher = yield* queueTestThread(dispatcherId); + const witness = yield* queueTestThread(ThreadId.make("runtime-layer-held-queue-witness")); + yield* dispatcher.send("setup", "agent", true); + yield* dispatcher.send("issue", "agent", false); + yield* dispatcher.end("setup", "failed", "", { + workspacePreparation: { type: "worktree", baseRef: "main" }, + }); + yield* dispatcher.awaitHeld("issue"); + assert.isTrue((yield* dispatcher.runFor("issue")).queueHeld); + yield* dispatcher.send("tick", "agent", true); + assert.equal((yield* dispatcher.runFor("tick")).status, "starting"); + yield* witness.send("active", "agent", true); + yield* witness.send("queued", "agent", false); + // The failed setup never produced the thread's worktree. + const { thread } = yield* orchestrator.getThreadProjection(dispatcherId); + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${dispatcherId}:worktree-missing`), + type: "thread.metadata-updated", + threadId: dispatcherId, + occurredAt: yield* DateTime.now, + payload: { ...thread, worktreePath: null }, + }, + ], + }); + yield* dispatcher.end("tick", "completed"); + // One listener handles every thread's completions in order, so the witness starting + // means the tick's completion was handled too. + yield* witness.end("active", "completed"); + assert.equal(yield* Queue.take(witness.startedRunIds), (yield* witness.runFor("queued")).id); + assert.isTrue((yield* dispatcher.runFor("issue")).queueHeld); + }), + ); + it.effect("keeps queued user selections and pauses only the limited provider", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -5183,6 +5442,165 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect.each([false, true])( + "restarts a continuation that failed before its prompt, unless stopped (%s)", + (stopped) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = ThreadId.make(`runtime-layer-continuation-start-retry-${stopped}`); + const messageId = (key: string) => MessageId.make(`${threadId}:${key}`); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + title: "Continuation start retry", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + const runFor = (key: string) => + Effect.map(orchestrator.getThreadProjection(threadId), ({ runs }) => + runs.find((candidate) => candidate.userMessageId === messageId(key)), + ); + const write = ( + key: string, + payload: OrchestrationV2Run, + extra: Parameters[0]["events"] = [], + ) => + Effect.gen(function* () { + const now = yield* DateTime.now; + yield* eventSink.write({ + events: [ + ...extra, + { + id: EventId.make(`${threadId}:${key}`), + type: "run.updated", + threadId, + runId: payload.id, + providerInstanceId: payload.providerInstanceId, + occurredAt: now, + payload: { ...payload, startedAt: now, completedAt: now }, + }, + ], + }); + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}:message:cut`), + threadId, + messageId: messageId("cut"), + text: "Work that the restart cut", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + const cut = (yield* runFor("cut"))!; + yield* write("cut:cancelled", { ...cut, status: "cancelled" }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "agent", + creationSource: "server", + commandId: CommandId.make(`${threadId}:message:continuation`), + threadId, + messageId: messageId("continuation"), + text: "Continue where you left off.", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + restartContinuationOfRunId: cut.id, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}:message:queued`), + threadId, + messageId: messageId("queued"), + text: "Queued behind the continuation", + attachments: [], + modelSelection, + dispatchMode: { type: "queue_after_active" }, + }); + const continuation = (yield* runFor("continuation"))!; + assert.isNotNull(continuation.rootNodeId); + const queued = (yield* runFor("queued"))!; + // The failure holds the queue after the reactor scheduled any retry. + const held = yield* eventSink + .stream({ threadId, afterSequence: yield* orchestrator.getThreadEventSequence(threadId) }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "run.updated" && + stored.event.payload.id === queued.id && + stored.event.payload.queueHeld === true, + ), + Stream.runHead, + Effect.forkScoped, + ); + yield* Effect.yieldNow; + const now = yield* DateTime.now; + yield* write("continuation:failed", { ...continuation, status: "failed" }, [ + { + id: EventId.make(`${threadId}:continuation:error`), + type: "turn-item.updated", + threadId, + runId: continuation.id, + nodeId: continuation.rootNodeId, + providerInstanceId: continuation.providerInstanceId, + occurredAt: now, + payload: { + id: TurnItemId.make(`${threadId}:continuation:error`), + type: "error", + threadId, + runId: continuation.id, + nodeId: continuation.rootNodeId, + providerThreadId: continuation.providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 2, + status: "failed", + title: "Provider failure", + startedAt: now, + completedAt: now, + updatedAt: now, + failure: { + class: "provider_error", + message: "Claude timed out while registering its MCP tools.", + code: null, + retryable: null, + }, + }, + }, + ]); + yield* Fiber.join(held); + const retriesOf = Effect.map(orchestrator.getThreadProjection(threadId), ({ runs }) => + runs.filter((run) => run.restartContinuationOfRunId === continuation.id), + ); + yield* worker.drain(); + assert.deepEqual(yield* retriesOf, [], "the retry waits out its backoff"); + if (stopped) { + yield* orchestrator.dispatch({ + type: "thread.stop", + commandId: CommandId.make(`${threadId}:stop`), + threadId, + }); + } + yield* TestClock.adjust("61 seconds"); + yield* worker.drain(); + assert.lengthOf(yield* retriesOf, stopped ? 0 : 1); + }), + ); + it.effect.each(["startup", "shutdown"] as const)( "preserves and holds queued messages across %s until explicitly resumed", (trigger) => @@ -5349,9 +5767,10 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { after.runs.find((run) => run.id === held.id), held, ); + // Nobody will resume a server message, so the completed run released it behind the wake. assert.deepEqual( after.runs.find((run) => run.id === textContinuation.id), - textContinuation, + { ...textContinuation, queueHeld: false }, ); }), ); @@ -5691,7 +6110,7 @@ it.layer(layerTest)("snooze queued runs", (it) => { payload: run.status === "queued" ? { ...run, queueHeld: true } - : { ...run, status: "completed" as const, completedAt: now }, + : { ...run, status: "cancelled" as const, completedAt: now }, })), effects: [], }); diff --git a/apps/server/src/process/agentScope.test.ts b/apps/server/src/process/agentScope.test.ts index 94ca314ac1f33..2a9998f4cfbf6 100644 --- a/apps/server/src/process/agentScope.test.ts +++ b/apps/server/src/process/agentScope.test.ts @@ -110,6 +110,8 @@ const withFakeSystemd = ( ...fileSystem, readFileString: (path, encoding) => fileSystem.readFileString(path.replace("/sys/fs/cgroup", cgroupRoot), encoding), + writeFileString: (path, data, options) => + fileSystem.writeFileString(path.replace("/sys/fs/cgroup", cgroupRoot), data, options), }), ); const { stateDir } = yield* ServerConfig.ServerConfig; @@ -213,6 +215,36 @@ describe("AgentScope service", () => { ), ); + it.effect.each([{ oomPolicy: true }, { oomPolicy: false }])( + "Force stop kills a thread's own scope but never a shared Codex scope (OOM policy $oomPolicy)", + ({ oomPolicy }) => + withFakeSystemd( + ({ scope, units, calls, cgroupRoot }) => + Effect.gen(function* () { + const agent = yield* scope.wrap({ + command: "agent", + args: [], + name: "t", + threadId: "a", + }); + exitedAgent(units, cgroupRoot, unitOf(agent), { populated: true, oomKills: 0 }); + const codex = yield* launchCodex(scope, "b"); + exitedAgent(units, cgroupRoot, unitOf(codex), { populated: true, oomKills: 0 }); + + expect(yield* scope.kill("a")).toBe(true); + expect( + NodeFS.readFileSync(NodePath.join(cgroupRoot, unitOf(agent), "cgroup.kill"), "utf8"), + ).toBe("1"); + expect(yield* scope.kill("b")).toBe(false); + expect(NodeFS.existsSync(NodePath.join(cgroupRoot, unitOf(codex), "cgroup.kill"))).toBe( + false, + ); + expect(calls.filter(([, verb]) => verb === "kill")).toEqual([]); + }), + { oomPolicy }, + ), + ); + it.effect("keeps a running scope while hundreds of other threads launch", () => withFakeSystemd(({ scope, units }) => Effect.gen(function* () { @@ -245,6 +277,34 @@ describe("AgentScope service", () => { ), ); + it.effect("caps agent memory and yields CPU, disk, and tasks to the server", () => + withFakeSystemd(({ scope, calls }) => + Effect.gen(function* () { + yield* scope.wrap({ command: "agent", args: [], name: "t", threadId: "a" }); + const setProperties = calls.filter(([, verb]) => verb === "set-property"); + expect(setProperties).toEqual([ + [ + "--user", + "set-property", + "--runtime", + "app-t3code-agents.slice", + expect.stringMatching(/^MemoryHigh=\d+$/), + expect.stringMatching(/^MemoryMax=\d+$/), + ], + [ + "--user", + "set-property", + "--runtime", + "app-t3code.slice", + "CPUWeight=10", + "IOWeight=10", + "TasksMax=25%", + ], + ]); + }), + ), + ); + it.effect("stops agents a killed server left before the first launch", () => withFakeSystemd(({ scope, units, calls, stateDir }) => Effect.gen(function* () { diff --git a/apps/server/src/process/agentScope.ts b/apps/server/src/process/agentScope.ts index cc6d3a04121f6..b9b6b56acb140 100644 --- a/apps/server/src/process/agentScope.ts +++ b/apps/server/src/process/agentScope.ts @@ -19,6 +19,12 @@ import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; const AGENT_SLICE = "app-t3code-agents.slice"; +// Owns every agent slice and competes with the server's unit or scope under app.slice. +const AGENTS_PARENT_SLICE = "app-t3code.slice"; +// Low weights yield CPU and disk to the server under contention, where the user manager +// has those controllers: current systemd delegates cpu, rarely io. TasksMax keeps agents +// below logind's 33% per-user task limit, leaving forks for the server. +const AGENTS_PARENT_PROPERTIES = ["CPUWeight=10", "IOWeight=10", "TasksMax=25%"]; export function ownedAgentSlice(stateDir: string): string { const hash = NodeCrypto.createHash("sha256").update(stateDir).digest("hex").slice(0, 12); @@ -173,20 +179,28 @@ export const make = Effect.gen(function* () { yield* Effect.logInfo("Agent scopes are off: no systemd user manager"); return undefined; } + const setProperties = Effect.fn("agentScope.setProperties")(function* ( + slice: string, + properties: ReadonlyArray, + ) { + const result = yield* run(systemctl, [ + "--user", + "set-property", + "--runtime", + slice, + ...properties, + ]); + if (result._tag === "None" || result.value.code !== 0) { + yield* Effect.logWarning("Could not set resource limits on an agents slice", { + slice, + properties, + stderr: result._tag === "Some" ? result.value.stderr.trim() : undefined, + }); + } + }); const limits = agentSliceMemoryLimits(NodeOS.totalmem()); - const limited = yield* run(systemctl, [ - "--user", - "set-property", - "--runtime", - AGENT_SLICE, - `MemoryHigh=${limits.high}`, - `MemoryMax=${limits.max}`, - ]); - if (limited._tag === "None" || limited.value.code !== 0) { - yield* Effect.logWarning("Could not set memory limits on the agents slice", { - stderr: limited._tag === "Some" ? limited.value.stderr.trim() : undefined, - }); - } + yield* setProperties(AGENT_SLICE, [`MemoryHigh=${limits.high}`, `MemoryMax=${limits.max}`]); + yield* setProperties(AGENTS_PARENT_SLICE, AGENTS_PARENT_PROPERTIES); yield* Effect.logInfo("Agent scopes are on", { slice: AGENT_SLICE, ...limits, @@ -201,7 +215,8 @@ export const make = Effect.gen(function* () { }), ); - const scopeByThread = new Map(); + // `shared` scopes serve other threads too: tracked only for OOM diagnosis on old systemd. + const scopeByThread = new Map(); const readScope = Effect.fn("agentScope.readScope")(function* (systemctl: string, unit: string) { const shown = yield* run(systemctl, [ @@ -263,10 +278,11 @@ export const make = Effect.gen(function* () { systemctl: string, threadId: string, unit: string | undefined, + shared = false, ) { const previous = scopeByThread.get(threadId); scopeByThread.delete(threadId); - if (unit !== undefined) scopeByThread.set(threadId, { unit, oomKilled: false }); + if (unit !== undefined) scopeByThread.set(threadId, { unit, oomKilled: false, shared }); if (previous !== undefined) yield* forget(systemctl, previous.unit); if (scopeByThread.size > MAX_TRACKED_THREADS) yield* evictOldest(systemctl); }); @@ -326,6 +342,40 @@ export const make = Effect.gen(function* () { } }); + const kill: AgentScopeShape["kill"] = Effect.fn("AgentScope.kill")(function* (threadId) { + const tracked = scopeByThread.get(threadId); + if (tracked === undefined || tracked.shared) return false; + const scope = yield* systemd; + if (scope === undefined) return false; + const shown = yield* run(scope.systemctl, [ + "--user", + "show", + tracked.unit, + "--property=ActiveState,ControlGroup", + ]); + if (shown._tag === "None" || shown.value.code !== 0) return false; + const values = parseKeyValues(shown.value.stdout); + if (values.get("ActiveState") !== "active") return false; + const controlGroup = values.get("ControlGroup") ?? ""; + // cgroup.kill (Linux 5.14+) kills the whole tree at once; systemd's kill is the fallback. + const killedByCgroup = + controlGroup.length > 0 && + (yield* fileSystem + .writeFileString(path.join("/sys/fs/cgroup", controlGroup, "cgroup.kill"), "1") + .pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + )); + if (killedByCgroup) return true; + const killed = yield* run(scope.systemctl, [ + "--user", + "kill", + "--signal=SIGKILL", + tracked.unit, + ]); + return killed._tag === "Some" && killed.value.code === 0; + }); + const clear: AgentScopeShape["clear"] = Effect.fn("agentScope.clear")(function* (threadId) { if (!scopeByThread.has(threadId)) return; const scope = yield* systemd; @@ -351,7 +401,8 @@ export const make = Effect.gen(function* () { // Clear the launch thread's previous scope instead. const continuing = shared === true && scope.supportsOomPolicy; const owned = threadId !== undefined && !continuing; - if (threadId !== undefined) yield* track(scope.systemctl, threadId, owned ? unit : undefined); + if (threadId !== undefined) + yield* track(scope.systemctl, threadId, owned ? unit : undefined, shared === true); const systemdScope: AgentSystemdScope = { systemdRun: scope.systemdRun, runtimeDir: scope.runtimeDir, @@ -371,6 +422,7 @@ export const make = Effect.gen(function* () { }), oomKilled, stop, + kill, clear, } satisfies AgentScopeShape; }); diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 9c26b187a55bf..7ec79c38a38a9 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -1112,9 +1112,11 @@ export const make = Effect.gen(function* () { const seenHomes = new Set(); for (const { instanceId, config: instance } of instances) { const homeVariable = source === "claudeAgent" ? "CLAUDE_CONFIG_DIR" : "CODEX_HOME"; + const configuredHome = instance.environment?.findLast( + (variable) => variable.name === homeVariable, + )?.value; const environmentHome = - instance.environment?.findLast((variable) => variable.name === homeVariable)?.value ?? - hostEnvironment[homeVariable]; + typeof configuredHome === "string" ? configuredHome : hostEnvironment[homeVariable]; let homePath: string; if (source === "claudeAgent") { diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index a522c5b230299..7a5b5272e89c2 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -7,15 +7,21 @@ import { type CodexSettings, ProviderDriverKind, type ProviderInstanceConfigMap, + type ResolvedProviderInstanceEnvironment, ProviderInstanceId, + SecretProviderId, + type SecretReference, } from "@t3tools/contracts"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as HostProcess from "@t3tools/shared/HostProcess"; +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 Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; import { HttpClient, HttpClientResponse } from "effect/http"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; @@ -29,6 +35,9 @@ import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEve import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistry.ts"; import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts"; import * as ProviderHostLive from "./ProviderHostLive.ts"; +// Coder: secret providers are not carried; the registry reads through the stand-in resolver. +import * as SecretResolver from "../secrets/coderSecretResolver.ts"; +import type { AnyProviderDriver, ProviderInstance } from "@t3tools/provider-core/server/driver"; vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); // Coder: the Claude probe runs through the workspace CLI transport. @@ -129,6 +138,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { }).pipe( Layer.provideMerge(NodeServices.layer), Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})), + Layer.provideMerge(Layer.mock(SecretResolver.SecretResolver)({})), Layer.provideMerge(layerBackgroundPolicyAlwaysRun), Layer.provideMerge(ServerSettings.layerTest()), Layer.provideMerge(layerTestHttpClient), @@ -478,3 +488,169 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { }).pipe(Effect.provide(layerTest)), ); }); + +const DisabledByDefaultConfig = Schema.Struct({ + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), +}); + +describe("ProviderInstanceRegistry secret environment", () => { + const driverKind = ProviderDriverKind.make("fake"); + const instanceId = ProviderInstanceId.make("fake_main"); + const secret: SecretReference = { + provider: SecretProviderId.make("personal"), + reference: "op://Private/OpenAI/credential", + }; + + function setup( + initial: Record, + configSchema: AnyProviderDriver["configSchema"] = Schema.Unknown, + ) { + const vault = new Map(Object.entries(initial)); + const reads: Array> = []; + const invalidated: Array> = []; + const created: Array = []; + const driver: AnyProviderDriver = { + driverKind, + metadata: { displayName: "Fake" }, + configSchema, + defaultConfig: () => ({}), + create: (input) => + Effect.sync(() => { + created.push(input.environment); + return { instanceId: input.instanceId, enabled: input.enabled } as ProviderInstance; + }), + }; + const gate = Deferred.makeUnsafe(); + const resolver = Layer.succeed(SecretResolver.SecretResolver, { + read: (references) => + Deferred.await(gate).pipe( + Effect.andThen( + Effect.sync(() => { + reads.push(references.map((reference) => reference.reference)); + return references.map((reference) => vault.get(reference.reference)); + }), + ), + ), + invalidate: (references = []) => + Effect.sync(() => { + invalidated.push(references.map((reference) => reference.reference)); + }), + }); + const configMap: ProviderInstanceConfigMap = { + [instanceId]: { + driver: driverKind, + config: {}, + environment: [ + { name: "OPENAI_API_KEY", value: secret, sensitive: true }, + { name: "BASE_URL", value: "https://example.test", sensitive: false }, + ], + }, + }; + const makeGated = makeProviderInstanceRegistry({ drivers: [driver], configMap }).pipe( + Effect.provide(resolver), + ); + // Secrets are read after startup, so wait for the change that applies them. + const make = Effect.gen(function* () { + const built = yield* makeGated; + const changes = yield* built.registry.subscribeChanges; + yield* Deferred.succeed(gate, undefined); + yield* PubSub.take(changes); + return built; + }); + return { vault, reads, invalidated, created, make, makeGated, gate }; + } + + it.effect("starts an instance with the secret its environment points at", () => { + const fixture = setup({ [secret.reference]: "sk-live" }); + return Effect.gen(function* () { + const { registry } = yield* fixture.make; + + expect(yield* registry.getInstance(instanceId)).toBeDefined(); + expect(fixture.created).toEqual([ + [ + { name: "OPENAI_API_KEY", value: "sk-live", sensitive: true }, + { name: "BASE_URL", value: "https://example.test", sensitive: false }, + ], + ]); + }).pipe(Effect.scoped); + }); + + it.effect("marks an instance unavailable and names the variable it could not read", () => { + const fixture = setup({}); + return Effect.gen(function* () { + const { registry } = yield* fixture.make; + + expect(yield* registry.getInstance(instanceId)).toBeUndefined(); + expect(fixture.created).toEqual([]); + const [unavailable] = yield* registry.listUnavailable; + expect(unavailable?.instanceId).toBe(instanceId); + expect(unavailable?.unavailableReason).toContain("OPENAI_API_KEY"); + }).pipe(Effect.scoped); + }); + + it.effect("does not read the secrets of an instance its config decodes as disabled", () => { + const fixture = setup({ [secret.reference]: "sk-live" }, DisabledByDefaultConfig); + return Effect.gen(function* () { + const { mutator } = yield* fixture.makeGated; + yield* mutator.reloadSecrets(); + + expect(fixture.reads).toEqual([]); + expect(fixture.invalidated).toEqual([]); + expect(fixture.created).toHaveLength(1); + }).pipe(Effect.scoped); + }); + + it.effect("re-reads secrets on reload and rebuilds only when they changed", () => { + const fixture = setup({}); + return Effect.gen(function* () { + const { registry, mutator } = yield* fixture.make; + + fixture.vault.set(secret.reference, "sk-live"); + yield* mutator.reloadSecrets(); + expect(fixture.invalidated).toEqual([[secret.reference]]); + expect(yield* registry.getInstance(instanceId)).toBeDefined(); + expect(yield* registry.listUnavailable).toEqual([]); + + yield* mutator.reloadSecrets(instanceId); + expect(fixture.created).toHaveLength(1); + + fixture.vault.set(secret.reference, "sk-rotated"); + yield* mutator.reloadSecrets(instanceId); + expect(fixture.created.map((environment) => environment[0]?.value)).toEqual([ + "sk-live", + "sk-rotated", + ]); + }).pipe(Effect.scoped); + }); + + it.effect("keeps a working instance live when a reload cannot read its secret", () => { + const fixture = setup({ [secret.reference]: "sk-live" }); + return Effect.gen(function* () { + const { registry, mutator } = yield* fixture.make; + + fixture.vault.delete(secret.reference); + yield* mutator.reloadSecrets(); + + expect(yield* registry.getInstance(instanceId)).toBeDefined(); + expect(yield* registry.listUnavailable).toEqual([]); + expect(fixture.created).toHaveLength(1); + }).pipe(Effect.scoped); + }); + + it.effect("starts without waiting for secrets to be read", () => { + const fixture = setup({ [secret.reference]: "sk-live" }); + return Effect.gen(function* () { + const { registry } = yield* fixture.makeGated; + const changes = yield* registry.subscribeChanges; + + expect(yield* registry.getInstance(instanceId)).toBeUndefined(); + const [pending] = yield* registry.listUnavailable; + expect(pending?.unavailableReason).toBe("Reading secrets…"); + + yield* Deferred.succeed(fixture.gate, undefined); + yield* PubSub.take(changes); + expect(yield* registry.getInstance(instanceId)).toBeDefined(); + expect(yield* registry.listUnavailable).toEqual([]); + }).pipe(Effect.scoped); + }); +}); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.ts b/apps/server/src/provider/ProviderInstanceRegistry.ts index 3bbbe2900b9f2..0807186655ae2 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.ts @@ -3,7 +3,10 @@ import { ProviderInstanceId, type ProviderInstanceConfig, type ProviderInstanceConfigMap, + type ProviderInstanceEnvironment, type ProviderDriverKind, + type ResolvedProviderInstanceEnvironment, + type SecretReference, type ServerProvider, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -11,12 +14,17 @@ import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; +// Coder: secret providers are not carried; the stand-in resolver reads nothing. +import * as SecretResolver from "../secrets/coderSecretResolver.ts"; + import { buildUnavailableProviderSnapshot } from "./unavailableProviderSnapshot.ts"; import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts"; import type { AnyProviderDriver, ProviderInstance } from "@t3tools/provider-core/server/driver"; @@ -39,6 +47,7 @@ interface LiveEntry { readonly instance: ProviderInstance; readonly scope: Scope.Closeable; readonly entry: ProviderInstanceConfig; + readonly environment: ResolvedProviderInstanceEnvironment; } interface RegistryState { @@ -50,6 +59,39 @@ interface RegistryState { const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boolean => Equal.equals(a, b); +const secretReferencesIn = (environment: ProviderInstanceEnvironment): Array => + environment.flatMap((variable) => (typeof variable.value === "string" ? [] : [variable.value])); + +const secretReferencesOf = (entry: ProviderInstanceConfig): ReadonlyArray => + secretReferencesIn(entry.environment ?? []); + +const withoutSecretReferences = ( + environment: ProviderInstanceEnvironment, +): ResolvedProviderInstanceEnvironment => + environment.flatMap((variable) => + typeof variable.value === "string" ? [{ ...variable, value: variable.value }] : [], + ); + +const readSecretEnvironment = Effect.fn("ProviderInstanceRegistry.readSecretEnvironment")( + function* (environment: ProviderInstanceEnvironment) { + const references = secretReferencesIn(environment); + const values = + references.length === 0 ? [] : yield* (yield* SecretResolver.SecretResolver).read(references); + const unreadable: Array = []; + let index = 0; + const resolved: ResolvedProviderInstanceEnvironment = environment.flatMap((variable) => { + if (typeof variable.value === "string") return [{ ...variable, value: variable.value }]; + const secret = values[index++]; + if (secret === undefined) { + unreadable.push(variable.name); + return []; + } + return [{ ...variable, value: secret, sensitive: true }]; + }); + return { environment: resolved, unreadable }; + }, +); + /** Conflicting saved flags must preserve any explicit disable. */ const resolveEntryEnabled = (entry: ProviderInstanceConfig, typedConfig: unknown): boolean => { const rawConfigEnabled = providerInstanceConfigEnabledFlag(entry.config); @@ -59,19 +101,32 @@ const resolveEntryEnabled = (entry: ProviderInstanceConfig, typedConfig: unknown return entry.enabled ?? providerInstanceConfigEnabledFlag(typedConfig) ?? true; }; +// The enabled state `buildEntry` decodes, so a disabled instance never reads its secrets. +const isEntryEnabled = Effect.fn("ProviderInstanceRegistry.isEntryEnabled")(function* ( + driversById: ReadonlyMap>, + entry: ProviderInstanceConfig, +) { + const driver = driversById.get(entry.driver); + if (!driver) return false; + const decoder = Schema.decodeUnknownEffect(driver.configSchema); + const decoded = yield* decoder(entry.config ?? driver.defaultConfig()).pipe(Effect.option); + return Option.isSome(decoded) && resolveEntryEnabled(entry, decoded.value); +}); + const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* (input: { readonly driversById: ReadonlyMap>; readonly parentScope: Scope.Scope; readonly instanceId: ProviderInstanceId; readonly rawInstanceId: string; readonly entry: ProviderInstanceConfig; + readonly deferSecrets: boolean; }): Effect.fn.Return< | { readonly kind: "live"; readonly live: LiveEntry } | { readonly kind: "unavailable"; readonly snapshot: ServerProvider }, never, - R + R | SecretResolver.SecretResolver > { - const { driversById, parentScope, instanceId, rawInstanceId, entry } = input; + const { driversById, parentScope, instanceId, rawInstanceId, entry, deferSecrets } = input; const driver = driversById.get(entry.driver); if (!driver) { return { @@ -109,6 +164,37 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* 0) { + return { + kind: "unavailable" as const, + snapshot: yield* buildUnavailableProviderSnapshot({ + driverKind: entry.driver, + instanceId, + displayName: entry.displayName, + accentColor: entry.accentColor, + reason: "Reading secrets…", + }), + }; + } + const { environment, unreadable } = enabled + ? yield* readSecretEnvironment(entry.environment ?? []) + : { environment: withoutSecretReferences(entry.environment ?? []), unreadable: [] }; + // Starting with a blank credential would fail later with a less useful error, or fall back to + // whatever the server inherited. + if (unreadable.length > 0) { + return { + kind: "unavailable" as const, + snapshot: yield* buildUnavailableProviderSnapshot({ + driverKind: entry.driver, + instanceId, + displayName: entry.displayName, + accentColor: entry.accentColor, + reason: `Could not read ${unreadable.join(", ")} from its secret provider. Unlock it, then refresh.`, + }), + }; + } + const childScope = yield* Scope.make(); yield* Scope.addFinalizer(parentScope, Scope.close(childScope, Exit.void).pipe(Effect.ignore)); @@ -117,8 +203,8 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* (input: { readonly state: RegistryState; readonly driversById: ReadonlyMap>; readonly parentScope: Scope.Scope; -}): ((configMap: ProviderInstanceConfigMap) => Effect.Effect) => { +}): (( + configMap: ProviderInstanceConfigMap, + rebuild?: ReadonlySet, + deferSecrets?: boolean, +) => Effect.Effect) => { const { state, driversById, parentScope } = input; - return (configMap: ProviderInstanceConfigMap) => + return (configMap, rebuild = new Set(), deferSecrets = false) => Effect.gen(function* () { const previousEntries = yield* Ref.get(state.entries); const previousUnavailable = yield* Ref.get(state.unavailable); @@ -175,7 +266,10 @@ const makeReconcile = (input: { continue; } const nextEntry = configMap[instanceId]; - if (nextEntry !== undefined && !entryEqual(live.entry, nextEntry)) { + if ( + rebuild.has(instanceId) || + (nextEntry !== undefined && !entryEqual(live.entry, nextEntry)) + ) { replacedIds.add(instanceId); } } @@ -186,6 +280,20 @@ const makeReconcile = (input: { } } + // Read every secret the builds below need in one go, so a vault unlocks once. + const references = yield* Effect.forEach(nextRaw, ([rawInstanceId, entry]) => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make(rawInstanceId); + const kept = previousEntries.has(instanceId) && !replacedIds.has(instanceId); + return kept || !(yield* isEntryEnabled(driversById, entry)) + ? [] + : secretReferencesOf(entry); + }), + ).pipe(Effect.map((perEntry) => perEntry.flat())); + if (references.length > 0 && !deferSecrets) { + yield* (yield* SecretResolver.SecretResolver).read(references); + } + const builtEntries = new Map(); const builtUnavailable = new Map(); let orderChanged = false; @@ -208,6 +316,7 @@ const makeReconcile = (input: { instanceId, rawInstanceId, entry, + deferSecrets, }); if (result.kind === "live") { builtEntries.set(instanceId, result.live); @@ -260,7 +369,7 @@ export const makeProviderInstanceRegistry = Effect.fn( readonly mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape; }, never, - R | Scope.Scope + R | SecretResolver.SecretResolver | Scope.Scope > { const driversById = new Map>( input.drivers.map((driver) => [driver.driverKind, driver]), @@ -272,7 +381,7 @@ export const makeProviderInstanceRegistry = Effect.fn( // scopes to the *caller's* scope instead of the registry's. const parentScope = yield* Scope.Scope; - const driverContext = yield* Effect.context(); + const driverContext = yield* Effect.context(); const entries = yield* Ref.make>(new Map()); const unavailable = yield* Ref.make>(new Map()); @@ -281,10 +390,76 @@ export const makeProviderInstanceRegistry = Effect.fn( const state: RegistryState = { entries, unavailable, changes }; const reconcileWithR = makeReconcile({ state, driversById, parentScope }); + const configMapRef = yield* Ref.make(input.configMap); + const semaphore = yield* Semaphore.make(1); const reconcile: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape["reconcile"] = - (configMap) => reconcileWithR(configMap).pipe(Effect.provideContext(driverContext)); + (configMap) => + semaphore.withPermits(1)( + Ref.set(configMapRef, configMap).pipe( + Effect.andThen(reconcileWithR(configMap)), + Effect.provideContext(driverContext), + ), + ); - yield* reconcile(input.configMap); + // Rebuilds an instance only when it was unavailable or its secrets changed, so a refresh does + // not restart providers whose credentials are the same. + const reloadSecrets: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape["reloadSecrets"] = + Effect.fn("ProviderInstanceRegistry.reloadSecrets")( + function* (instanceId) { + const configMap = yield* Ref.get(configMapRef); + const targets = yield* Effect.filter(Object.entries(configMap), ([rawInstanceId, entry]) => + (instanceId === undefined || rawInstanceId === instanceId) && + secretReferencesOf(entry).length > 0 + ? isEntryEnabled(driversById, entry) + : Effect.succeed(false), + ); + if (targets.length === 0) return; + const resolver = yield* SecretResolver.SecretResolver; + const references = targets.flatMap(([, entry]) => secretReferencesOf(entry)); + yield* resolver.invalidate(references); + yield* resolver.read(references); + const live = yield* Ref.get(entries); + const rebuild = new Set(); + for (const [rawInstanceId, entry] of targets) { + const current = live.get(ProviderInstanceId.make(rawInstanceId)); + if (current === undefined) continue; + const { environment, unreadable } = yield* readSecretEnvironment(entry.environment ?? []); + // A failed re-read keeps the working instance and the value it already holds. + if (unreadable.length === 0 && !Equal.equals(environment, current.environment)) { + rebuild.add(current.instance.instanceId); + } + } + yield* reconcileWithR(configMap, rebuild); + }, + (effect) => semaphore.withPermits(1)(effect), + Effect.provideContext(driverContext), + ); + + // Reading secrets can wait on a vault unlock, so the server does not wait for it to start. + yield* semaphore.withPermits(1)( + Ref.set(configMapRef, input.configMap).pipe( + Effect.andThen(reconcileWithR(input.configMap, new Set(), true)), + Effect.provideContext(driverContext), + ), + ); + const startupReferences = yield* Effect.filter(Object.entries(input.configMap), ([, entry]) => + secretReferencesOf(entry).length > 0 + ? isEntryEnabled(driversById, entry) + : Effect.succeed(false), + ).pipe(Effect.map((targets) => targets.flatMap(([, entry]) => secretReferencesOf(entry)))); + if (startupReferences.length > 0) { + // The read waits outside the lock so other mutations are not held for a vault prompt; a + // configuration saved meanwhile has already reconciled itself. + yield* Effect.gen(function* () { + yield* (yield* SecretResolver.SecretResolver).read(startupReferences); + yield* semaphore.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(configMapRef); + if (current === input.configMap) yield* reconcileWithR(current); + }), + ); + }).pipe(Effect.provideContext(driverContext), Effect.forkScoped); + } const registry: ProviderInstanceRegistry["Service"] = { getInstance: (id) => Ref.get(entries).pipe(Effect.map((map) => map.get(id)?.instance)), @@ -307,6 +482,7 @@ export const makeProviderInstanceRegistry = Effect.fn( const mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape = { reconcile, + reloadSecrets, }; return { registry, mutator }; @@ -318,7 +494,7 @@ export const layer = (input: { }): Layer.Layer< ProviderInstanceRegistry | ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, never, - R + R | SecretResolver.SecretResolver > => Layer.effectContext( makeProviderInstanceRegistry(input).pipe( @@ -331,5 +507,5 @@ export const layer = (input: { ) as Layer.Layer< ProviderInstanceRegistry | ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, never, - R + R | SecretResolver.SecretResolver >; diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts index 7f50652fe7fa8..39d8755b06b59 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts @@ -12,6 +12,8 @@ import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; import * as Settings from "../serverSettings.ts"; +// Coder: secret providers are not carried; the stand-in resolver reads nothing. +import * as SecretResolver from "../secrets/coderSecretResolver.ts"; import { BUILT_IN_DRIVERS, type BuiltInDriversEnv } from "./builtInDrivers.ts"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts"; @@ -93,7 +95,8 @@ const layerSettingsWatcher = (settingsChanges: Stream.Stream) => ); export const layer: Layer.Layer< - ProviderInstanceRegistry.ProviderInstanceRegistry, + | ProviderInstanceRegistry.ProviderInstanceRegistry + | ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, never, ProviderInstanceRegistryHydrationEnv > = Layer.unwrap( @@ -116,12 +119,14 @@ export const layer: Layer.Layer< Layer.provide(ProviderOrchestrationAdapterInfrastructure.layer), Layer.provide(AcpRegistrySupport.layerFromHost), Layer.provide(ProviderHostLive.layer), + Layer.provide(SecretResolver.layer), ); return layerSettingsWatcher(settingsChanges).pipe(Layer.provideMerge(layerMutable)); }), ) as Layer.Layer< - ProviderInstanceRegistry.ProviderInstanceRegistry, + | ProviderInstanceRegistry.ProviderInstanceRegistry + | ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, never, ProviderInstanceRegistryHydrationEnv >; diff --git a/apps/server/src/provider/ProviderInstanceRegistryMutator.ts b/apps/server/src/provider/ProviderInstanceRegistryMutator.ts index 51322826230d8..35eb52d3f2906 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryMutator.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryMutator.ts @@ -1,10 +1,12 @@ /** Reconcile closes removed/replaced scopes before creating replacements: at most one live instance per id. */ -import type { ProviderInstanceConfigMap } from "@t3tools/contracts"; +import type { ProviderInstanceConfigMap, ProviderInstanceId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; export interface ProviderInstanceRegistryMutatorShape { readonly reconcile: (configMap: ProviderInstanceConfigMap) => Effect.Effect; + /** Re-reads the secrets of every instance, or one, and rebuilds those whose secrets changed. */ + readonly reloadSecrets: (instanceId?: ProviderInstanceId) => Effect.Effect; } export class ProviderInstanceRegistryMutator extends Context.Service< diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index ececc7c4c5461..87adb43a52375 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -56,6 +56,7 @@ import type { ProviderWorkspaceSnapshot, } from "@t3tools/provider-core/server/driver"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; +import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts"; import * as ProviderRegistry from "./ProviderRegistry.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; @@ -90,6 +91,10 @@ const withBundledCompatibility = (snapshot: ServerProvider) => ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, ); +const layerUnusedMutator = Layer.mock( + ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, +)({}); + const layerTestHttpClient = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => @@ -955,7 +960,12 @@ it.layer( retainedModels, ); }).pipe( - Effect.provide(ProviderRegistry.layer.pipe(Layer.provide(layerInstanceRegistry))), + Effect.provide( + ProviderRegistry.layer.pipe( + Layer.provide(layerInstanceRegistry), + Layer.provide(layerUnusedMutator), + ), + ), Effect.scoped, ); } @@ -1086,7 +1096,12 @@ it.layer( retainedModels, ); }).pipe( - Effect.provide(ProviderRegistry.layer.pipe(Layer.provide(layerInstanceRegistry))), + Effect.provide( + ProviderRegistry.layer.pipe( + Layer.provide(layerInstanceRegistry), + Layer.provide(layerUnusedMutator), + ), + ), Effect.scoped, ); } @@ -1358,6 +1373,7 @@ it.layer( yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const runtimeServices = yield* Layer.build( ProviderRegistry.layer.pipe( + Layer.provide(layerUnusedMutator), Layer.provideMerge(layerInstanceRegistry), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { @@ -1453,6 +1469,7 @@ it.layer( yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const runtimeServices = yield* Layer.build( ProviderRegistry.layer.pipe( + Layer.provide(layerUnusedMutator), Layer.provideMerge(layerInstanceRegistry), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { @@ -1573,6 +1590,7 @@ it.layer( yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const runtimeServices = yield* Layer.build( ProviderRegistry.layer.pipe( + Layer.provide(layerUnusedMutator), Layer.provideMerge(instanceRegistryLayer), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { @@ -1679,6 +1697,7 @@ it.layer( yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const runtimeServices = yield* Layer.build( ProviderRegistry.layer.pipe( + Layer.provide(layerUnusedMutator), Layer.provideMerge(layerInstanceRegistry), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { @@ -1807,6 +1826,7 @@ it.layer( yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const runtimeServices = yield* Layer.build( ProviderRegistry.layer.pipe( + Layer.provide(layerUnusedMutator), Layer.provideMerge(layerInstanceRegistry), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { @@ -1910,6 +1930,7 @@ it.layer( yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const runtimeServices = yield* Layer.build( ProviderRegistry.layer.pipe( + Layer.provide(layerUnusedMutator), Layer.provideMerge(layerInstanceRegistry), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { @@ -2074,7 +2095,7 @@ it.layer( enabled: false, config: { arbitrary: "payload" }, }, - } as unknown as ContractServerSettings["providerInstances"], + } as unknown as (typeof ServerSettings.Encoded)["providerInstances"], }), ), ); diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 26191cde65b7f..0932422d8496a 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -19,6 +19,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; @@ -27,6 +28,7 @@ import * as ModelManifest from "./ModelManifest.ts"; import { applyProviderCompatibility } from "./providerCompatibility.ts"; import * as ServerConfig from "../config.ts"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; +import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts"; import { hydrateCachedProvider, isCachedProviderCorrelated, @@ -61,6 +63,11 @@ export class ProviderRegistry extends Context.Service< instanceId: ProviderInstanceId, ) => Effect.Effect>; + /** Re-reads secret-backed environments first, so a fixed secret applies without a restart. */ + readonly reloadSecretsAndRefresh: ( + instanceId?: ProviderInstanceId, + ) => Effect.Effect>; + /** Only refreshed instances can verify an update; the published list still includes every row. */ readonly refreshAfterUpdate: ( provider: ProviderDriverKind, @@ -334,6 +341,8 @@ export const layer = Layer.effect( ProviderRegistry, Effect.gen(function* () { const instanceRegistry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; + const instanceRegistryMutator = + yield* ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator; const manifestService = yield* ModelManifest.ModelManifest; const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; const serviceScope = yield* Effect.scope; @@ -618,7 +627,9 @@ export const layer = Layer.effect( ); if (claimed.owner) { // Detach shared passes so the first caller's interruption cannot cancel other callers' work. - yield* getLiveSources.pipe( + // Syncing first keeps a refresh right after a rebuild from probing the replaced instance. + yield* syncLiveSourcesAndContinue.pipe( + Effect.andThen(getLiveSources), Effect.flatMap((sources) => Effect.forEach(sources, (source) => refreshOneSource(source), { concurrency: "unbounded", @@ -655,6 +666,7 @@ export const layer = Layer.effect( const refreshInstance = Effect.fn("refreshInstance")(function* ( instanceId: ProviderInstanceId, ) { + yield* syncLiveSourcesAndContinue; const sources = yield* getLiveSources; const providerSource = sources.find((candidate) => candidate.instanceId === instanceId); if (!providerSource) { @@ -663,6 +675,13 @@ export const layer = Layer.effect( return yield* refreshOneSource(providerSource); }); + const reloadSecretsAndRefresh = Effect.fn("reloadSecretsAndRefresh")(function* ( + instanceId?: ProviderInstanceId, + ) { + yield* instanceRegistryMutator.reloadSecrets(instanceId); + return yield* instanceId === undefined ? refresh() : refreshInstance(instanceId); + }); + const refreshAfterUpdate = Effect.fn("refreshAfterUpdate")(function* ( provider: ProviderDriverKind, selectedInstanceId: ProviderInstanceId, @@ -862,6 +881,7 @@ export const layer = Layer.effect( }, ); }), + Scope.provide(serviceScope), ); yield* upsertProviders(fallbackProviders, { publish: false }); @@ -1012,6 +1032,8 @@ export const layer = Layer.effect( refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), refreshInstance: (instanceId: ProviderInstanceId) => refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), + reloadSecretsAndRefresh: (instanceId?: ProviderInstanceId) => + reloadSecretsAndRefresh(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), refreshWorkspaceSnapshot: (input) => refreshWorkspaceSnapshot(input).pipe(Effect.catchCause(recoverRefreshFailure)), refreshAfterUpdate, diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts index e3d4f672016c0..352c5902462b7 100644 --- a/apps/server/src/provider/providerCompatibility.test.ts +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -13,6 +13,7 @@ import * as ModelManifest from "./ModelManifest.ts"; import * as ProviderRegistry from "./ProviderRegistry.ts"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; +import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts"; import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; import * as Schema from "effect/Schema"; @@ -294,6 +295,15 @@ it.effect("a remote policy refresh preserves a newer health result on the regist assert.strictEqual(updated?.compatibilityAdvisory?.status, "supported"); assert.strictEqual(updated?.status, "error"); assert.strictEqual(updated?.message, "Authentication failed"); - }).pipe(Effect.provide(ProviderRegistry.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe( + Effect.provide( + ProviderRegistry.layer.pipe( + Layer.provide(layerDependencies), + Layer.provide( + Layer.mock(ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator)({}), + ), + ), + ), + ); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 75c592b7811a1..943928c4e18cf 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -192,6 +192,7 @@ function makeRegistry( getProviders: Ref.get(providersRef), refresh: () => Ref.get(providersRef), refreshInstance: () => Ref.get(providersRef), + reloadSecretsAndRefresh: () => Ref.get(providersRef), refreshAfterUpdate: () => Ref.get(providersRef), refreshWorkspaceSnapshot: () => Ref.get(providersRef), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts index 358d49afe45c2..a4008acc18efb 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -11,6 +11,7 @@ const makeProviderRegistryMock = ( getProviders: Effect.succeed(providers), refresh: () => Effect.succeed(providers), refreshInstance: () => Effect.succeed(providers), + reloadSecretsAndRefresh: () => Effect.succeed(providers), refreshAfterUpdate: () => Effect.succeed(providers), refreshWorkspaceSnapshot: () => Effect.succeed(providers), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index a6e5ec7e6f6eb..4334485a98a18 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1614,6 +1614,98 @@ it.effect("does not revive old summaries when project epochs are evicted", () => }), ); +it.effect("a refresh or a finished turn drops the provider's own change request reads", () => + Effect.gen(function* () { + const forgotten: Array<{ readonly repository: string; readonly number?: number } | undefined> = + []; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/a", + repository: "acme/web", + provider: SourceControlProviderKind.make("gitlab"), + }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("gitlab"), { + forgetChangeRequestReads: (scope) => Effect.sync(() => void forgotten.push(scope)), + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + + yield* service.invalidate({ reference }); + yield* service.refreshAfterTurn(reference.projectId); + yield* service.invalidate({}); + + assert.deepStrictEqual(forgotten, [ + { repository: "acme/web", number: 1 }, + undefined, + undefined, + ]); + }), +); + +it.effect("a read during a refresh cannot keep the provider's old change request", () => + Effect.gen(function* () { + let hostTitle = "old"; + let held: string | undefined; + let gate: + | { readonly entered: Deferred.Deferred; readonly release: Deferred.Deferred } + | undefined; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/a", + repository: "acme/web", + provider: SourceControlProviderKind.make("gitlab"), + }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("gitlab"), { + getChangeRequest: () => + Effect.sync(() => { + held ??= hostTitle; + return { ...hostedChangeRequest("body"), title: held }; + }), + forgetChangeRequestReads: () => + Effect.gen(function* () { + if (gate !== undefined) { + yield* Deferred.succeed(gate.entered, undefined); + yield* Deferred.await(gate.release); + } + held = undefined; + }), + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const refreshWithReadInBetween = (refresh: Effect.Effect) => + Effect.gen(function* () { + gate = { entered: yield* Deferred.make(), release: yield* Deferred.make() }; + const running = yield* refresh.pipe(Effect.forkChild()); + yield* Deferred.await(gate.entered); + yield* service.detail(reference); + yield* Deferred.succeed(gate.release, undefined); + yield* Fiber.join(running); + gate = undefined; + }); + + assert.strictEqual((yield* service.detail(reference)).title, "old"); + hostTitle = "after turn"; + yield* refreshWithReadInBetween(service.refreshAfterTurn(reference.projectId)); + assert.strictEqual((yield* service.detail(reference)).title, "after turn"); + + hostTitle = "after refresh"; + yield* refreshWithReadInBetween(service.invalidate({ reference })); + assert.strictEqual((yield* service.detail(reference)).title, "after refresh"); + }), +); + it.effect("close and reopen notify subscribed readers after invalidating their cached state", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 8062447694ab3..ab5f855191ced 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -507,6 +507,9 @@ function withRateLimitBackoff( listChangeRequestStats: wrap("listChangeRequestStats", api.listChangeRequestStats), }), getChangeRequest: wrap("getChangeRequest", api.getChangeRequest), + ...(api.forgetChangeRequestReads === undefined + ? {} + : { forgetChangeRequestReads: api.forgetChangeRequestReads }), ...(api.getChangeRequestPreview === undefined ? {} : { getChangeRequestPreview: wrap("getChangeRequestPreview", api.getChangeRequestPreview) }), @@ -3302,6 +3305,13 @@ export const make = Effect.gen(function* () { return { stats: [...held, ...result.stats] }; }); + const forgetProviderReads = (scope?: { readonly repository: string; readonly number?: number }) => + Effect.forEach( + registry.kinds, + (kind) => registry.get(kind)?.forgetChangeRequestReads?.(scope) ?? Effect.void, + { discard: true }, + ); + const invalidate: PullRequestService["Service"]["invalidate"] = Effect.fn( "PullRequestService.invalidate", )(function* (input, options) { @@ -3317,13 +3327,15 @@ export const make = Effect.gen(function* () { if (reference !== undefined) { yield* canonicalRef(reference).pipe( Effect.flatMap((ref) => - readCache - .invalidate(refScope(ref)) - .pipe(Effect.andThen(Effect.sync(() => bumpRefEpoch(ref)))), + forgetProviderReads({ repository: ref.repository, number: ref.number }).pipe( + Effect.andThen(readCache.invalidate(refScope(ref))), + Effect.andThen(Effect.sync(() => bumpRefEpoch(ref))), + ), ), Effect.ignore, ); } else { + yield* forgetProviderReads(); listingsEpoch = ++epochCounter; everyFileRevisionEpoch = ++epochCounter; viewersByHost.clear(); @@ -3334,22 +3346,27 @@ export const make = Effect.gen(function* () { } }); - const refreshAfterTurn: PullRequestService["Service"]["refreshAfterTurn"] = (projectId) => - Effect.suspend(() => { - listingsEpoch = ++epochCounter; - projectEpochs.delete(projectId); - if (projectEpochs.size >= REF_EPOCH_CAPACITY) { - const oldest = projectEpochs.keys().next().value; - if (oldest !== undefined) { - projectEpochFloor = projectEpochs.get(oldest)!; - projectEpochs.delete(oldest); - } + const refreshAfterTurn: PullRequestService["Service"]["refreshAfterTurn"] = Effect.fn( + "PullRequestService.refreshAfterTurn", + )(function* (projectId) { + // Everything the provider holds, not just the project's repository: a turn can touch a + // change request reached through another repository, and no epoch may move until those + // reads are gone, or a reader in between keeps the old one under the new epoch. + yield* forgetProviderReads(); + listingsEpoch = ++epochCounter; + projectEpochs.delete(projectId); + if (projectEpochs.size >= REF_EPOCH_CAPACITY) { + const oldest = projectEpochs.keys().next().value; + if (oldest !== undefined) { + projectEpochFloor = projectEpochs.get(oldest)!; + projectEpochs.delete(oldest); } - projectEpochs.set(projectId, listingsEpoch); - return readCache - .invalidate(`project:${projectId}`) - .pipe(Effect.andThen(SubscriptionRef.set(pullRequestRefreshes, listingsEpoch))); - }); + } + projectEpochs.set(projectId, listingsEpoch); + const epoch = listingsEpoch; + yield* readCache.invalidate(`project:${projectId}`); + yield* SubscriptionRef.set(pullRequestRefreshes, epoch); + }); const invalidatedByMutation = ( diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index c8416a092b087..1bdfe31db1bc6 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -15,6 +15,8 @@ import { type ReviewDiffFileChunkResult, VcsRepositoryDetectionError, VcsUnsupportedOperationError, + type ReviewBranchCommitsInput, + type ReviewBranchCommitsResult, type ReviewDiffFileContentsInput, type ReviewDiffFileContentsResult, type ReviewDiffFileError, @@ -30,9 +32,18 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as KnownWorkspaces from "../workspace/KnownWorkspaces.ts"; +const WORKSPACE_BOUND_SUBJECTS = { + "ReviewService.getBranchCommits": "commit list", + "ReviewService.getDiffPreview": "diff preview", + "ReviewService.getDiffFileContents": "diff file contents", +} as const; + export class ReviewService extends Context.Service< ReviewService, { + readonly getBranchCommits: ( + input: ReviewBranchCommitsInput, + ) => Effect.Effect; readonly getDiffPreview: ( input: ReviewDiffPreviewInput, ) => Effect.Effect; @@ -120,7 +131,7 @@ export const make = Effect.gen(function* () { }; const assertWorkspaceBoundCwd = Effect.fn("ReviewService.assertWorkspaceBoundCwd")(function* ( - operation: "ReviewService.getDiffPreview" | "ReviewService.getDiffFileContents", + operation: keyof typeof WORKSPACE_BOUND_SUBJECTS, cwd: string, ) { const [candidate, workspaceRoot] = yield* Effect.all([ @@ -137,13 +148,27 @@ export const make = Effect.gen(function* () { return yield* new VcsRepositoryDetectionError({ operation, cwd, - detail: - operation === "ReviewService.getDiffPreview" - ? "Review diff preview cwd must stay within the configured workspace root." - : "Review diff file contents cwd must stay within the configured workspace root.", + detail: `Review ${WORKSPACE_BOUND_SUBJECTS[operation]} cwd must stay within the configured workspace root.`, }); }); + const getBranchCommits: ReviewService["Service"]["getBranchCommits"] = Effect.fn( + "ReviewService.getBranchCommits", + )(function* (input) { + yield* assertWorkspaceBoundCwd("ReviewService.getBranchCommits", input.cwd); + + const handle = yield* vcsRegistry.detect({ cwd: input.cwd, requestedKind: "auto" }); + if (!handle) return { commits: [], truncated: false }; + if (handle.kind !== "git") { + return yield* new VcsUnsupportedOperationError({ + operation: "ReviewService.getBranchCommits", + kind: handle.kind, + detail: "Review commit lists require a Git repository.", + }); + } + return yield* git.getReviewBranchCommits(input); + }); + const getDiffPreview: ReviewService["Service"]["getDiffPreview"] = Effect.fn( "ReviewService.getDiffPreview", )(function* (input) { @@ -280,6 +305,7 @@ export const make = Effect.gen(function* () { ); return ReviewService.of({ + getBranchCommits, getDiffPreview, openDiffFileContents, readDiffFileChunk, diff --git a/apps/server/src/secrets/coderSecretResolver.ts b/apps/server/src/secrets/coderSecretResolver.ts new file mode 100644 index 0000000000000..38e62bca7ec7e --- /dev/null +++ b/apps/server/src/secrets/coderSecretResolver.ts @@ -0,0 +1,33 @@ +// Coder: secret providers are not carried (#25709, #27044, #27115), so this stands in for +// upstream's `@t3tools/secret-provider-core` resolver and its server definitions. No provider can +// read a reference, so the provider instance registry reports a variable that points at one as +// unreadable, and settings writes reject every secret provider kind as unsupported. +import type { SecretReference } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Schema from "effect/Schema"; + +export class SecretResolver extends Context.Service< + SecretResolver, + { + /** Values in input order; `undefined` marks a reference that could not be read. */ + readonly read: ( + references: ReadonlyArray, + ) => Effect.Effect>; + /** Forgets the given references, or every reference, so the next read asks the provider again. */ + readonly invalidate: (references?: ReadonlyArray) => Effect.Effect; + } +>()("@t3tools/secret-provider-core/server/SecretResolver") {} + +export const layer = Layer.succeed( + SecretResolver, + SecretResolver.of({ + read: (references) => Effect.succeed(references.map(() => undefined)), + invalidate: () => Effect.void, + }), +); + +export const secretProviderDefinitions: { + readonly definitions: ReadonlyArray<{ readonly kind: string; readonly configSchema: Schema.Top }>; +} = { definitions: [] }; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 78741d03c200f..e81da3b6386ff 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -7,7 +7,9 @@ import { ProviderDriverKind, ProviderInstanceId, resolveProviderInstanceEnabled, + SecretProviderId, ServerSettings, + ServerSettingsPatch, UsageLimitSourceId, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; @@ -32,6 +34,7 @@ import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.t const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); const decodeServerSettingsJson = Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings)); +const decodeServerSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); const layerServerSettings = () => ServerSettingsModule.layer.pipe( @@ -269,14 +272,34 @@ it.layer(NodeServices.layer)("server settings", (it) => { }); assert.deepStrictEqual( ServerSettingsModule.redactServerSettingsForClient(settings).secretProviders, + // Coder: no secret provider kind is loaded, so 1Password's config stays on the server too. { - personal: { kind: "one_password", label: "Personal", config: { account: "my" } }, + personal: { kind: "one_password", label: "Personal" }, vault: { kind: "openbao", label: "Vault" }, }, ); }), ); + it.effect("stores no secret provider of an unloaded kind or with a config its kind rejects", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const write = (entry: unknown) => + decodeServerSettingsPatch({ secretProviders: { personal: entry } }).pipe( + Effect.flatMap(service.updateSettings), + Effect.flip, + ); + for (const entry of [ + { kind: "one_password", label: "Personal", config: { account: "--help" } }, + { kind: "one_password", label: "Personal" }, + { kind: "openbao", label: "Vault", config: { address: "https://x" } }, + ]) { + assert.deepInclude(yield* write(entry), { operation: "write-secret-provider" }); + } + assert.deepStrictEqual((yield* service.getSettings).secretProviders, {}); + }).pipe(Effect.provide(layerServerSettings())), + ); + it.effect("saves through a symlinked settings file without replacing the link", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; @@ -1340,6 +1363,27 @@ it.layer(NodeServices.layer)("server settings", (it) => { for (const name of secretNames) { assert.isTrue(Option.isSome(yield* store.get(name)), name); } + const secret = { + provider: SecretProviderId.make("personal"), + reference: "op://Private/Antigravity/token", + }; + const referenced = yield* service.updateProviderInstance({ + operation: "upsert", + instanceId, + instance: { + ...client.providerInstances[instanceId]!, + environment: [ + { name: "API_TOKEN", value: secret, sensitive: true, valueRedacted: true }, + ], + }, + }); + assert.isTrue(Option.isNone(yield* store.get(secretNames[1]!))); + assert.deepEqual( + ServerSettingsModule.redactServerSettingsForClient(referenced).providerInstances[ + instanceId + ]?.environment, + [{ name: "API_TOKEN", value: secret, sensitive: true }], + ); yield* service.updateSettings({ providerInstances: {}, usageLimitSources: { [UsageLimitSourceId.make("hub")]: null }, diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 48839bb8535d9..3bd0232f7e276 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -8,7 +8,6 @@ import { type ProviderInstanceConfig, type ProviderInstanceMutation, defaultInstanceIdForDriver, - isKnownSecretProviderKind, isProviderDriverKind, ProviderDriverKind, ProviderInstanceId, @@ -53,6 +52,8 @@ import { isModelSelectionProviderEnabled, } from "@t3tools/shared/serverSettings"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; +// Coder: secret providers are not carried, so no kind is known and every write is rejected. +import { secretProviderDefinitions } from "./secrets/coderSecretResolver.ts"; import { readSecretFieldValues, sourceControlHostSecretName, @@ -144,7 +145,7 @@ function withoutUnknownSecretProviderConfigs( ): ServerSettings["secretProviders"] { return Object.fromEntries( Object.entries(settings.secretProviders).map(([id, entry]) => { - if (isKnownSecretProviderKind(entry.kind)) return [id, entry]; + if (secretProviderConfigGuards.has(entry.kind)) return [id, entry]; const { config: _config, ...envelope } = entry; return [id, envelope]; }), @@ -164,6 +165,40 @@ export function applyProviderInstanceMutation( return { ...settings, providerInstances }; } +const secretProviderConfigGuards = new Map( + secretProviderDefinitions.definitions.map((definition) => [ + definition.kind as string, + Schema.is(definition.configSchema), + ]), +); + +// Rejects a kind this server has not loaded rather than storing a config it cannot check. +function ensureSecretProviderWritesAllowed( + patch: ServerSettingsPatch, + settingsPath: string, +): Effect.Effect { + for (const [id, entry] of Object.entries(patch.secretProviders ?? {})) { + if (entry === null) continue; + const isConfig = secretProviderConfigGuards.get(entry.kind); + const problem = + isConfig === undefined + ? `Unsupported secret provider kind: ${entry.kind}.` + : isConfig(entry.config) + ? undefined + : `Invalid ${entry.kind} config for secret provider ${id}.`; + if (problem !== undefined) { + return Effect.fail( + new ServerSettingsError({ + settingsPath, + operation: "write-secret-provider", + cause: new Error(problem), + }), + ); + } + } + return Effect.void; +} + function ensureProviderInstanceMutationAllowed( settings: ServerSettings, mutation: ProviderInstanceMutation, @@ -1185,11 +1220,14 @@ const make = Effect.gen(function* () { getSettings: getMaterializedSettings, updateSettings: (patch) => updateAndPersistSettings((current) => - Effect.succeed(applyServerSettingsPatch(current, patch)), + ensureSecretProviderWritesAllowed(patch, settingsPath).pipe( + Effect.as(applyServerSettingsPatch(current, patch)), + ), ), updateProviderInstance: (mutation, patch = {}) => updateAndPersistSettings((current) => Effect.gen(function* () { + yield* ensureSecretProviderWritesAllowed(patch, settingsPath); yield* ensureProviderInstanceMutationAllowed(current, mutation, settingsPath); const patched = applyServerSettingsPatch(current, patch); return applyProviderInstanceMutation(patched, mutation); diff --git a/apps/server/src/settingsSecrets.ts b/apps/server/src/settingsSecrets.ts index 866186dc06cf7..e4a35527dc39e 100644 --- a/apps/server/src/settingsSecrets.ts +++ b/apps/server/src/settingsSecrets.ts @@ -97,16 +97,19 @@ export function mapSettingsSecrets( ...(instance.environment ? { environment: instance.environment.map((variable) => { + const { valueRedacted: _omit, ...rest } = variable; + // A secret reference is not itself secret; storage sees the value cleared. + const reference = typeof variable.value === "string" ? undefined : variable.value; const result = map({ secretName: `provider-env-${encode(instanceId)}-${encode(variable.name)}`, kind: "environment", - value: variable.value, - redacted: variable.valueRedacted === true, + value: typeof variable.value === "string" ? variable.value : "", + redacted: reference === undefined && variable.valueRedacted === true, sensitive: variable.sensitive, providerInstanceId: instanceId, environmentVariable: variable.name, }); - const { valueRedacted: _omit, ...rest } = variable; + if (reference !== undefined) return { ...rest, value: reference }; return { ...rest, value: result.value, diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 07be828ba3093..4c82f615f19c8 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -10,19 +10,24 @@ import { TerminalProviderInstanceNotFoundError, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Base64Url from "effect/encoding/Base64Url"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Path from "effect/Path"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as TestClock from "effect/testing/TestClock"; import { expect, vi } from "vite-plus/test"; import * as ProcessRunner from "../processRunner.ts"; @@ -894,6 +899,154 @@ it.layer( }), ); + // Answers process-table queries after `queryDuration`, honoring the caller's + // timeout the way ProcessRunner's `timedOutResult` mode does. + const slowProcessTable = ( + platform: "linux" | "win32", + queryDuration: Duration.Input, + queries: Array<{ start: number; end?: number }>, + started: ReadonlyArray>, + ): ProcessRunner.ProcessRunner["Service"] => ({ + run: (input) => + Effect.gen(function* () { + const query: { start: number; end?: number } = { start: yield* Clock.currentTimeMillis }; + queries.push(query); + const deferred = started[queries.length - 1]; + if (deferred) yield* Deferred.succeed(deferred, undefined); + const completed = yield* Effect.sleep(queryDuration).pipe( + Effect.timeoutOption(input.timeout ?? "1 hour"), + ); + query.end = yield* Clock.currentTimeMillis; + return { + stdout: Option.isNone(completed) + ? "" + : platform === "win32" + ? "100|9000|node.exe\n" + : " 100 9000 node\n", + stderr: "", + code: Option.isSome(completed) ? ChildProcessSpawner.ExitCode(0) : null, + timedOut: Option.isNone(completed), + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }), + }); + + const createFallbackManager = ( + platform: "linux" | "win32", + runner: ProcessRunner.ProcessRunner["Service"], + ) => + createManager(5, { subprocessPollIntervalMs: 1_000, processKillGraceMs: 0 }).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.provide(layerWithHostPlatform(platform)), + ); + + it.effect.each(["linux", "win32"] as const)( + "reports activity from a slow but successful %s process-table query", + (platform) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const queries: Array<{ start: number; end?: number }> = []; + const { manager, getEvents } = yield* createFallbackManager( + platform, + slowProcessTable(platform, "5 seconds", queries, [started]), + ); + + yield* manager.open(openInput()); + yield* TestClock.adjust("1 second"); + yield* Deferred.await(started); + yield* TestClock.adjust("5 seconds"); + + expect( + (yield* getEvents).some( + (event) => + event.type === "activity" && event.hasRunningSubprocess && event.label === "node", + ), + ).toBe(true); + yield* manager.close({ threadId: "thread-1" }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("idles at least as long as a slow process-table query took before the next", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const queries: Array<{ start: number; end?: number }> = []; + const { manager } = yield* createFallbackManager( + "linux", + slowProcessTable("linux", "5 seconds", queries, [started]), + ); + + yield* manager.open(openInput()); + yield* TestClock.adjust("1 second"); + yield* Deferred.await(started); + yield* TestClock.adjust("5 seconds"); + yield* TestClock.adjust(Duration.millis(19_999)); + + expect(queries).toHaveLength(1); + yield* TestClock.adjust("1 millis"); + const [slow, next] = queries; + expect(next?.start).toBe(slow!.end! + 20_000); + yield* manager.close({ threadId: "thread-1" }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("ends a hung process-table query and paces the retry", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const queries: Array<{ start: number; end?: number }> = []; + const { manager } = yield* createFallbackManager( + "win32", + slowProcessTable("win32", "1 day", queries, [started]), + ); + + yield* manager.open(openInput()); + yield* TestClock.adjust("1 second"); + yield* Deferred.await(started); + yield* TestClock.adjust("2 minutes"); + yield* TestClock.adjust("2 minutes"); + + const [hung, next] = queries; + expect(hung!.end! - hung!.start).toBe(15_000); + expect(next!.start - hung!.end!).toBe(60_000); + yield* manager.close({ threadId: "thread-1" }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect.each([ + ["linux", 1_000], + ["win32", 1_500], + ] as const)( + "bounds closeIdle's %s query so other operations on the thread still run", + ([platform, budgetMs]) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const queries: Array<{ start: number; end?: number }> = []; + const { manager, ptyAdapter } = yield* createFallbackManager( + platform, + slowProcessTable(platform, "1 day", queries, [started]), + ); + yield* manager.open(openInput()); + + const closing = yield* manager + .closeIdle({ threadId: "thread-1" }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(started); + const resizing = yield* manager + .resize({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cols: 80, rows: 30 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(Duration.millis(budgetMs)); + + expect(closing.pollUnsafe()).toBeDefined(); + expect(resizing.pollUnsafe()).toBeDefined(); + expect(queries[0]!.end! - queries[0]!.start).toBe(budgetMs); + expect(ptyAdapter.processes[0]!.resizeCalls).toEqual([{ cols: 80, rows: 30 }]); + expect(ptyAdapter.processes[0]!.killed).toBe(false); + yield* manager.close({ threadId: "thread-1" }); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("keeps terminals that get input or output while closeIdle checks them", () => Effect.gen(function* () { const ptyAdapter = new FakePtyAdapter(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 2c43d17cf1032..ac8e43378cb68 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -43,6 +43,7 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Base64Url from "effect/encoding/Base64Url"; import * as Equal from "effect/Equal"; @@ -52,6 +53,7 @@ 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 Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -93,6 +95,12 @@ const DEFAULT_ATTACH_REPLAY_TOTAL_BYTE_LIMIT = 64 * 1024 * 1024; const DEFAULT_PERSIST_DEBOUNCE_MS = 250; const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; const MAX_SUBPROCESS_POLL_INTERVAL_MS = 60_000; +// `ps` and WMI can take seconds on a loaded host and their tables are still +// valid then; the timeout only ends a query that is truly stuck. +const PROCESS_TABLE_QUERY_TIMEOUT = "15 seconds"; +// closeIdle queries while holding the thread lock, so it keeps a short budget +// rather than stalling other operations on that thread behind a slow table. +const CLOSE_IDLE_QUERY_TIMEOUT = { posix: "1 second", win32: "1500 millis" } as const; // Discover children started without terminal input or output. const QUIET_SUBPROCESS_SNAPSHOT_EVERY_TICKS = 30; const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000; @@ -676,8 +684,17 @@ interface TerminalProcessTableSnapshot { readonly commandById: ReadonlyMap; } -function subprocessSnapshotPollDelayMs(pollIntervalMs: number, failureCount: number): number { - return Math.min(pollIntervalMs * 2 ** failureCount, MAX_SUBPROCESS_POLL_INTERVAL_MS); +// Idles at least four times as long as the last fallback query took, so a slow +// process table never keeps the poll loop busy most of the time. +function subprocessSnapshotPollDelayMs( + pollIntervalMs: number, + failureCount: number, + queryElapsedMs: number, +): number { + return Math.min( + Math.max(pollIntervalMs * 2 ** failureCount, queryElapsedMs * 4), + MAX_SUBPROCESS_POLL_INTERVAL_MS, + ); } function parsePosixProcessTable(stdout: string): TerminalProcessTableSnapshot { @@ -772,6 +789,7 @@ const resolvePosixPsCommand = Effect.fn("terminal.resolvePosixPsCommand")(functi const posixProcessTableSnapshot = Effect.fn("terminal.posixProcessTableSnapshot")(function* ( psCommand: string, + timeout: Duration.Input, ): Effect.fn.Return< TerminalProcessTableSnapshot, TerminalSubprocessCheckError, @@ -782,7 +800,7 @@ const posixProcessTableSnapshot = Effect.fn("terminal.posixProcessTableSnapshot" .run({ command: psCommand, args: ["-eo", "pid=,ppid=,comm="], - timeout: "1 second", + timeout, maxOutputBytes: 524_288, outputMode: "truncate", timeoutBehavior: "timedOutResult", @@ -808,48 +826,46 @@ const posixProcessTableSnapshot = Effect.fn("terminal.posixProcessTableSnapshot" return parsePosixProcessTable(result.stdout); }); -const windowsProcessTableSnapshot = Effect.fn("terminal.windowsProcessTableSnapshot")( - function* (): Effect.fn.Return< - TerminalProcessTableSnapshot, - TerminalSubprocessCheckError, - ProcessRunner.ProcessRunner - > { - const processRunner = yield* ProcessRunner.ProcessRunner; - const command = - 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }'; - const result = yield* processRunner - .run({ - command: "powershell.exe", - args: ["-NoProfile", "-NonInteractive", "-Command", command], - timeout: "1500 millis", - maxOutputBytes: 262_144, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", - }) - .pipe( - Effect.mapError( - (cause) => new TerminalSubprocessCheckError({ cause, command: "powershell" }), - ), - ); - if (result.code !== 0 || result.timedOut || result.stdoutTruncated) { - return yield* new TerminalSubprocessCheckError({ - command: "powershell", - exitCode: result.code, - timedOut: result.timedOut, - stdoutTruncated: result.stdoutTruncated, - }); - } - const processes = result.stdout.split(/\r?\n/g).flatMap((line) => { - const [pidRaw, ppidRaw, name = ""] = line.trim().split("|", 3); - const pid = Number(pidRaw); - const ppid = Number(ppidRaw); - return Number.isInteger(pid) && pid > 0 && Number.isInteger(ppid) - ? [{ pid, ppid, name }] - : []; +const windowsProcessTableSnapshot = Effect.fn("terminal.windowsProcessTableSnapshot")(function* ( + timeout: Duration.Input, +): Effect.fn.Return< + TerminalProcessTableSnapshot, + TerminalSubprocessCheckError, + ProcessRunner.ProcessRunner +> { + const processRunner = yield* ProcessRunner.ProcessRunner; + const command = + 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }'; + const result = yield* processRunner + .run({ + command: "powershell.exe", + args: ["-NoProfile", "-NonInteractive", "-Command", command], + timeout, + maxOutputBytes: 262_144, + outputMode: "truncate", + timeoutBehavior: "timedOutResult", + }) + .pipe( + Effect.mapError( + (cause) => new TerminalSubprocessCheckError({ cause, command: "powershell" }), + ), + ); + if (result.code !== 0 || result.timedOut || result.stdoutTruncated) { + return yield* new TerminalSubprocessCheckError({ + command: "powershell", + exitCode: result.code, + timedOut: result.timedOut, + stdoutTruncated: result.stdoutTruncated, }); - return processTableSnapshotFromProcesses(processes); - }, -); + } + const processes = result.stdout.split(/\r?\n/g).flatMap((line) => { + const [pidRaw, ppidRaw, name = ""] = line.trim().split("|", 3); + const pid = Number(pidRaw); + const ppid = Number(ppidRaw); + return Number.isInteger(pid) && pid > 0 && Number.isInteger(ppid) ? [{ pid, ppid, name }] : []; + }); + return processTableSnapshotFromProcesses(processes); +}); interface TerminalHistoryChunk { data: string; @@ -1554,54 +1570,74 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }, ); // Share one process table per poll; per-terminal scans can exhaust PIDs on busy hosts. - const fallbackProcessTableSnapshot = ( - platform === "win32" - ? windowsProcessTableSnapshot() - : posixProcessTableSnapshot(yield* resolvePosixPsCommand()) - ).pipe(Effect.provideService(ProcessRunner.ProcessRunner, processRunner)); - const fetchProcessTableSnapshot: Effect.Effect< - { - readonly snapshot: TerminalProcessTableSnapshot; - readonly snapshotSucceeded: boolean; - }, - TerminalSubprocessCheckError - > = options.processTable - ? options.processTable.pipe( - Effect.map((entries) => ({ - snapshot: processTableSnapshotFromProcesses(entries), - snapshotSucceeded: true, - })), - Effect.catch(() => - fallbackProcessTableSnapshot.pipe( - Effect.map((snapshot) => ({ snapshot, snapshotSucceeded: false })), + // Timed on success and failure so the poll loop can pace the next query. + const posixPsCommand = platform === "win32" ? undefined : yield* resolvePosixPsCommand(); + const fallbackProcessTableSnapshot = (timeout: Duration.Input) => + (posixPsCommand === undefined + ? windowsProcessTableSnapshot(timeout) + : posixProcessTableSnapshot(posixPsCommand, timeout) + ).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + Effect.result, + Effect.timed, + Effect.map(([elapsed, snapshot]) => ({ + snapshot, + queryElapsedMs: Duration.toMillis(elapsed), + })), + ); + // `fallbackTimeout` only bounds the ps/WMI fallback; a native process table + // or custom inspector is unaffected. + const fetchProcessTableSnapshot = ( + fallbackTimeout: Duration.Input, + ): Effect.Effect<{ + readonly snapshot: Result.Result; + readonly snapshotSucceeded: boolean; + readonly queryElapsedMs: number; + }> => + options.processTable + ? options.processTable.pipe( + Effect.map((entries) => ({ + snapshot: Result.succeed(processTableSnapshotFromProcesses(entries)), + snapshotSucceeded: true, + queryElapsedMs: 0, + })), + Effect.catch(() => + fallbackProcessTableSnapshot(fallbackTimeout).pipe( + Effect.map((fallback) => ({ ...fallback, snapshotSucceeded: false })), + ), ), - ), - ) - : fallbackProcessTableSnapshot.pipe( - Effect.map((snapshot) => ({ snapshot, snapshotSucceeded: true })), - ); + ) + : fallbackProcessTableSnapshot(fallbackTimeout).pipe( + Effect.map((fallback) => ({ + ...fallback, + snapshotSucceeded: Result.isSuccess(fallback.snapshot), + })), + ); const customSubprocessInspector = options.subprocessInspector; - const acquireSubprocessInspector: Effect.Effect< - { - readonly inspector: TerminalSubprocessInspector; - readonly snapshotSucceeded: boolean; - }, - TerminalSubprocessCheckError - > = + const acquireSubprocessInspector = ( + fallbackTimeout: Duration.Input, + ): Effect.Effect<{ + readonly inspector: Result.Result; + readonly snapshotSucceeded: boolean; + readonly queryElapsedMs: number; + }> => customSubprocessInspector !== undefined - ? Effect.succeed({ inspector: customSubprocessInspector, snapshotSucceeded: true }) + ? Effect.succeed({ + inspector: Result.succeed(customSubprocessInspector), + snapshotSucceeded: true, + queryElapsedMs: 0, + }) : Effect.map( - fetchProcessTableSnapshot, - ({ - snapshot, - snapshotSucceeded, - }): { - readonly inspector: TerminalSubprocessInspector; - readonly snapshotSucceeded: boolean; - } => ({ - inspector: (terminalPid) => - Effect.succeed(deriveSubprocessInspectResult(snapshot, terminalPid, platform)), + fetchProcessTableSnapshot(fallbackTimeout), + ({ snapshot, snapshotSucceeded, queryElapsedMs }) => ({ + inspector: Result.map( + snapshot, + (table): TerminalSubprocessInspector => + (terminalPid) => + Effect.succeed(deriveSubprocessInspectResult(table, terminalPid, platform)), + ), snapshotSucceeded, + queryElapsedMs, }), ); const subprocessPollIntervalMs = @@ -2650,39 +2686,29 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func })); if (runningSessions.length === 0) { - return true; + return { snapshotSucceeded: true, queryElapsedMs: 0 }; } if ( runningSessions.every(({ session }) => isQuiet(session)) && skippedQuietTicks < QUIET_SUBPROCESS_SNAPSHOT_EVERY_TICKS - 1 ) { skippedQuietTicks += 1; - return true; + return { snapshotSucceeded: true, queryElapsedMs: 0 }; } skippedQuietTicks = 0; - const inspectorOption = yield* acquireSubprocessInspector.pipe( - Effect.asSome, - Effect.catch((reason) => - Effect.logWarning("failed to snapshot processes for terminal subprocess polling", { - reason, - }).pipe( - Effect.as( - Option.none<{ - readonly inspector: TerminalSubprocessInspector; - readonly snapshotSucceeded: boolean; - }>(), - ), - ), - ), + const { inspector, snapshotSucceeded, queryElapsedMs } = yield* acquireSubprocessInspector( + PROCESS_TABLE_QUERY_TIMEOUT, ); - - if (Option.isNone(inspectorOption)) { + if (Result.isFailure(inspector)) { + yield* Effect.logWarning("failed to snapshot processes for terminal subprocess polling", { + reason: inspector.failure, + }); quietSessions = new WeakMap(); - return false; + return { snapshotSucceeded: false, queryElapsedMs }; } - const { inspector: subprocessInspector, snapshotSucceeded } = inspectorOption.value; + const subprocessInspector = inspector.success; const checkSubprocessActivity = Effect.fn("terminal.checkSubprocessActivity")(function* ( candidate: (typeof runningSessions)[number], ) { @@ -2778,7 +2804,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func discard: true, }); - return snapshotSucceeded; + return { snapshotSucceeded, queryElapsedMs }; }); const hasRunningSessions = readManagerState.pipe( @@ -2793,13 +2819,14 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func Effect.flatMap((active) => active ? pollSubprocessActivity().pipe( - Effect.flatMap((snapshotSucceeded) => { + Effect.flatMap(({ snapshotSucceeded, queryElapsedMs }) => { subprocessSnapshotFailureCount = snapshotSucceeded ? 0 : Math.min(subprocessSnapshotFailureCount + 1, 30); const delayMs = subprocessSnapshotPollDelayMs( subprocessPollIntervalMs, subprocessSnapshotFailureCount, + queryElapsedMs, ); return Effect.sleep(delayMs); }), @@ -3484,7 +3511,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func running.map((session) => [session.terminalId, terminalActivityMark(session)]), ); // Inspect now so commands started after the last poll still protect their terminals. - const { inspector } = yield* acquireSubprocessInspector; + const { inspector: inspectorResult } = yield* acquireSubprocessInspector( + platform === "win32" ? CLOSE_IDLE_QUERY_TIMEOUT.win32 : CLOSE_IDLE_QUERY_TIMEOUT.posix, + ); + const inspector = yield* Effect.fromResult(inspectorResult); yield* Effect.forEach( running, (session) => diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index b4d9af81cfbf8..6b46c2a450e78 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -20,6 +20,8 @@ import { type VcsCreateRefResult, type VcsCreateWorktreeInput, type VcsCreateWorktreeResult, + type ReviewBranchCommitsInput, + type ReviewBranchCommitsResult, type ReviewDiffPreviewInput, type ReviewDiffPreviewResult, type ReviewDiffFileContentsInput, @@ -342,6 +344,9 @@ export class GitVcsDriver extends Context.Service< ) => Effect.Effect; // Coder: ref-only polling must never refresh or rewrite the real index. readonly refStatusLocal: (cwd: string) => Effect.Effect; + readonly getReviewBranchCommits: ( + input: ReviewBranchCommitsInput, + ) => Effect.Effect; readonly getReviewDiffPreview: ( input: ReviewDiffPreviewInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 200799adcdd63..14eac2d47d220 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1,5 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not. import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, it, describe } from "@effect/vitest"; @@ -923,6 +925,54 @@ it.effect("retries a fetch that lost a ref update to another Git process", () => }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), ); +it.effect("lets a worktree checkout that is still working run past five minutes", () => + Effect.gen(function* () { + const checkoutStarted = yield* Deferred.make(); + const checkoutFinished = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) + return yield* Effect.die("expected Git command"); + if (!command.args.includes("worktree")) return makeSuccessfulHandle(""); + yield* Deferred.succeed(checkoutStarted, undefined); + return ChildProcessSpawner.makeHandle({ + ...makeSuccessfulHandle(""), + exitCode: Deferred.await(checkoutFinished).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + stderr: Stream.encodeText( + Stream.concat( + Stream.make("Updating files: 40% (49052/122629)\n"), + Stream.fromEffect( + Deferred.await(checkoutFinished).pipe( + Effect.as("Updating files: 100% (122629/122629), done.\n"), + ), + ), + ), + ), + }); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const path = yield* Path.Path; + const cwd = yield* makeTmpDir(); + const worktreePath = path.join(cwd, "worktree"); + + const creation = yield* driver + .createWorktree({ cwd, refName: "main", newRefName: "t3/slow-checkout", path: worktreePath }) + .pipe(Effect.forkChild); + yield* Deferred.await(checkoutStarted); + yield* TestClock.adjust("6 minutes"); + yield* Deferred.succeed(checkoutFinished, undefined); + + const result = yield* Fiber.join(creation); + assert.equal(result.worktree.path, worktreePath); + assert.equal(result.worktree.refName, "t3/slow-checkout"); + }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), +); + describe("rejected git refs", () => { const remoteUrl = "https://user:token-123@git.example.invalid/org/secret-repo.git"; const failedPush = `error: failed to push some refs to '${remoteUrl}'`; @@ -1421,6 +1471,141 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); describe("review diff previews", () => { + it.effect("diffs a root commit against the empty tree but rejects a shallow boundary", () => + Effect.gen(function* () { + const upstream = yield* makeTmpDir(); + yield* initRepoWithCommit(upstream); + const driver = yield* GitVcsDriver.GitVcsDriver; + const emptyTree = yield* git(upstream, ["hash-object", "-t", "tree", NodeOS.devNull]); + const root = yield* git(upstream, ["rev-parse", "HEAD"]); + const rootSource = (yield* driver.getReviewDiffPreview({ cwd: upstream, commitSha: root })) + .sources[0]!; + assert.equal(rootSource.baseRef, emptyTree); + assert.include(rootSource.diff, "+# test"); + + yield* writeTextFile(upstream, "README.md", "# boundary\n"); + yield* git(upstream, ["commit", "-am", "boundary"]); + const sha = yield* git(upstream, ["rev-parse", "HEAD"]); + const cwd = yield* makeTmpDir(); + yield* git(upstream, ["clone", "--depth=1", NodeURL.pathToFileURL(upstream).href, cwd], { + GIT_CONFIG_GLOBAL: NodeOS.devNull, + GIT_CONFIG_NOSYSTEM: "1", + }); + assert.equal(yield* git(cwd, ["rev-parse", "--is-shallow-repository"]), "true"); + for (const request of [ + driver.getReviewDiffPreview({ cwd, commitSha: sha }), + driver.getReviewDiffFileContents( + makeReviewDiffFileContentsInput(cwd, { + sourceKind: "commit", + changeType: "new", + headRef: sha, + baseRef: emptyTree, + }), + ), + ]) { + // Coder: file contents can also fail with the chunked review size error. + const error = yield* Effect.asVoid( + request as Effect.Effect, + ).pipe(Effect.flip); + assert.include(error.detail, "parent"); + } + }), + ); + + it.effect("lists the branch's commits newest first, at most 100, and flags the rest", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["checkout", "-b", "feature/commits"]); + for (let index = 0; index < 102; index++) { + yield* git(cwd, ["commit", "--allow-empty", "-m", `feature ${index}`]); + } + + const result = yield* driver.getReviewBranchCommits({ cwd, baseRef: initialBranch }); + assert.strictEqual(result.truncated, true); + assert.strictEqual(result.commits.length, 100); + assert.strictEqual(result.commits[0]?.sha, yield* git(cwd, ["rev-parse", "HEAD"])); + assert.deepStrictEqual( + [result.commits[0]?.subject, result.commits[99]?.subject], + ["feature 101", "feature 2"], + ); + }), + ); + + it.effect("previews one selected commit, a merge against its first parent", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["checkout", "-b", "feature/merge-preview"]); + yield* git(cwd, ["checkout", "-b", "feature/side"]); + yield* writeTextFile(cwd, "README.md", "# side commit\n"); + yield* git(cwd, ["add", "README.md"]); + yield* git(cwd, ["commit", "-m", "side change"]); + yield* git(cwd, ["checkout", "feature/merge-preview"]); + yield* writeTextFile(cwd, "feature.txt", "feature commit\n"); + yield* git(cwd, ["add", "feature.txt"]); + yield* git(cwd, ["commit", "-m", "feature change"]); + const firstParent = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* git(cwd, ["merge", "--no-ff", "--no-edit", "feature/side"]); + const mergeSha = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* git(cwd, ["checkout", "-b", "feature/elsewhere", initialBranch]); + yield* writeTextFile(cwd, "elsewhere.txt", "elsewhere\n"); + yield* git(cwd, ["add", "elsewhere.txt"]); + yield* git(cwd, ["commit", "-m", "elsewhere change"]); + const elsewhereSha = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* git(cwd, ["checkout", "feature/merge-preview"]); + yield* writeTextFile(cwd, "README.md", "# dirty tree\n"); + + const elsewhere = yield* driver.getReviewDiffPreview({ cwd, commitSha: elsewhereSha }); + assert.deepStrictEqual( + elsewhere.sources + .find((candidate) => candidate.kind === "commit") + ?.files?.map((file) => file.path), + ["elsewhere.txt"], + ); + + const preview = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + commitSha: mergeSha, + }); + const source = preview.sources.find((candidate) => candidate.kind === "commit")!; + assert.strictEqual(source.baseRef, firstParent); + assert.strictEqual(source.headRef, mergeSha); + assert.deepStrictEqual( + source.files?.map((file) => file.path), + ["README.md"], + ); + assert.include(source.diff, "+# side commit"); + assert.notInclude(source.diff, "dirty tree"); + assert.notInclude(source.diff, "feature.txt"); + + const patch = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + commitSha: mergeSha, + file: { path: "README.md", previousPath: null, sourceKind: "commit" }, + }); + assert.include( + patch.sources.find((candidate) => candidate.kind === "commit")!.diff, + "+# side commit", + ); + const contents = yield* driver.getReviewDiffFileContents( + makeReviewDiffFileContentsInput(cwd, { + sourceKind: "commit", + baseRef: source.baseRef, + headRef: source.headRef, + }), + ); + assert.deepStrictEqual(contents, { + oldContents: "# test\n", + newContents: "# side commit\n", + }); + }), + ); + it.effect("loads repository-relative files from a nested project directory", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 73ecd4c5929ca..4cbe628a88e57 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -28,6 +28,7 @@ import { MAX_REVIEW_DIFF_FILE_BYTES, ReviewDiffFileTooLargeError, type GitCommandFailureReason, + type ReviewBranchCommitsInput, type ReviewDiffFileContentsInput, type ReviewDiffPreviewInput, type ReviewDiffFileStat, @@ -62,7 +63,6 @@ const branchBaseConfigKey = (branch: string) => `branch.${branch}.t3-merge-base` */ const legacyBranchBaseConfigKey = (branch: string) => `branch.${branch}.gh-merge-base`; const gitProcesses = Semaphore.makeUnsafe(8); -const WORKTREE_ADD_TIMEOUT_MS = 300_000; const WORKTREE_REMOVE_TIMEOUT_MS = Duration.toMillis(Duration.minutes(5)); const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; @@ -75,6 +75,9 @@ const REVIEW_METADATA_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; // Temporary-index git add scales poorly (50k files took ~30 s); report incomplete totals above this cap. const REVIEW_UNTRACKED_MAX_FILES = 5_000; const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; +const REVIEW_BRANCH_COMMIT_LIMIT = 100; +const REVIEW_BRANCH_SUBJECT_LIMIT = 300; +const REVIEW_BRANCH_COMMIT_MAX_OUTPUT_BYTES = 128 * 1024; // Client patch parsers require a/ and b/ prefixes; override user diff prefix settings. export const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const; const REVIEW_DIFF_ARGS = [ @@ -3103,6 +3106,141 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { baseRef, insertions, deletions }; }, Effect.scoped); + const reviewCommitError = (cwd: string, detail: string) => + new GitCommandError({ + operation: "GitVcsDriver.reviewCommit", + command: "git cat-file", + cwd, + detail, + }); + + const resolveReviewCommit = Effect.fn("resolveReviewCommit")(function* ( + cwd: string, + sha: string, + ) { + if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) { + return yield* reviewCommitError(cwd, "Selected commit is invalid or no longer available."); + } + const present = yield* executeGit( + "GitVcsDriver.reviewCommit.present", + cwd, + ["cat-file", "-e", `${sha}^{commit}`], + { allowNonZeroExit: true, env: { GIT_NO_LAZY_FETCH: "1" } }, + ); + if (present.exitCode !== 0) { + return yield* reviewCommitError(cwd, "Selected commit is invalid or no longer available."); + } + // The raw object keeps the parents a shallow clone grafts away, so a boundary is not a root. + const object = yield* runGitStdout("GitVcsDriver.reviewCommit.parents", cwd, [ + "cat-file", + "commit", + sha, + ]); + const secondLine = object.split("\n", 2)[1]; + const firstParent = secondLine?.startsWith("parent ") ? secondLine.slice(7) : undefined; + if (firstParent !== undefined) { + const parent = yield* executeGit( + "GitVcsDriver.reviewCommit.parent", + cwd, + ["cat-file", "-e", `${firstParent}^{commit}`], + { allowNonZeroExit: true, env: { GIT_NO_LAZY_FETCH: "1" } }, + ); + if (parent.exitCode !== 0) { + return yield* reviewCommitError(cwd, "Selected commit's parent is not available locally."); + } + } + return { sha: sha.toLowerCase(), parent: firstParent ?? (yield* readEmptyTreeHash(cwd)) }; + }); + + const readReviewCommitMessage = Effect.fn("GitVcsDriver.readReviewCommitMessage")(function* ( + cwd: string, + sha: string, + ) { + const result = yield* executeGit( + "GitVcsDriver.readReviewCommitMessage", + cwd, + ["show", "-s", "--no-notes", "--no-show-signature", "--format=%B", sha], + { maxOutputBytes: 8_192, appendTruncationMarker: true }, + ); + const endOfSubject = result.stdout.indexOf("\n"); + return { + subject: result.stdout.slice(0, endOfSubject < 0 ? undefined : endOfSubject).slice(0, 300), + }; + }); + + const getReviewBranchCommits = Effect.fn("getReviewBranchCommits")(function* ( + input: ReviewBranchCommitsInput, + ) { + const repository = yield* resolveRepositoryPathsUncached(input.cwd); + if (!repository?.worktreeRoot) return { commits: [], truncated: false }; + const cwd = repository.worktreeRoot; + const head = yield* executeGit( + "GitVcsDriver.getReviewBranchCommits.head", + cwd, + ["rev-parse", "--verify", "--quiet", "HEAD"], + { allowNonZeroExit: true }, + ); + if (head.exitCode !== 0) { + if (head.exitCode === 1 && head.stderr.trim() === "") { + return { commits: [], truncated: false }; + } + return yield* reviewCommitError(cwd, "Could not resolve the current branch commit."); + } + const { mergeBase } = yield* resolveReviewMergeBase( + cwd, + repository.currentBranch, + input.baseRef, + ); + const result = yield* executeGit( + "GitVcsDriver.getReviewBranchCommits", + cwd, + [ + "log", + "-z", + "--no-notes", + "--no-show-signature", + "--format=%H%x00%s%x00%cI", + `--max-count=${REVIEW_BRANCH_COMMIT_LIMIT + 1}`, + `${mergeBase}..HEAD`, + ], + { + maxOutputBytes: REVIEW_BRANCH_COMMIT_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ); + if (!result.stdoutTruncated && result.stdout.length > 0 && !result.stdout.endsWith("\0")) { + return yield* reviewCommitError(cwd, "Could not read branch commit metadata."); + } + const fields = result.stdout.split("\0"); + const completeFieldCount = fields.length - 1; + const commits: Array<{ sha: string; subject: string; committedAt: string }> = []; + for ( + let index = 0; + index + 2 < completeFieldCount && commits.length <= REVIEW_BRANCH_COMMIT_LIMIT; + index += 3 + ) { + const sha = fields[index]; + const subject = fields[index + 1]; + const committedAt = fields[index + 2]; + if ( + !sha || + subject === undefined || + !committedAt || + !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha) + ) { + return yield* reviewCommitError(cwd, "Could not read branch commit metadata."); + } + commits.push({ sha, subject: subject.slice(0, REVIEW_BRANCH_SUBJECT_LIMIT), committedAt }); + } + if (result.stdoutTruncated && commits.length === 0) { + return yield* reviewCommitError(cwd, "Branch commit metadata exceeds the response limit."); + } + return { + commits: commits.slice(0, REVIEW_BRANCH_COMMIT_LIMIT), + truncated: result.stdoutTruncated || commits.length > REVIEW_BRANCH_COMMIT_LIMIT, + }; + }); + const getReviewDiffPreview = Effect.fn("getReviewDiffPreview")(function* ( input: ReviewDiffPreviewInput, ) { @@ -3129,6 +3267,64 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } const cwd = repository.worktreeRoot; + const hashDiff = (diff: string, files: ReadonlyArray) => + crypto.digest("SHA-256", new TextEncoder().encode(JSON.stringify([diff, files]))).pipe( + Effect.map(Hex.encode), + Effect.mapError( + (cause) => + new GitCommandError({ + operation: "GitVcsDriver.getReviewDiffPreview.hash", + command: "crypto.digest SHA-256", + cwd, + detail: "Failed to hash review diff.", + cause, + }), + ), + ); + if (input.commitSha !== undefined || input.file?.sourceKind === "commit") { + if (!input.commitSha || (input.file && input.file.sourceKind !== "commit")) { + return yield* reviewCommitError(cwd, "Selected commit request is invalid."); + } + const commit = yield* resolveReviewCommit(cwd, input.commitSha); + const { subject } = yield* readReviewCommitMessage(cwd, commit.sha); + const diffArgs = [ + ...REVIEW_DIFF_ARGS, + ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), + ]; + const stat = yield* executeGit( + "GitVcsDriver.getReviewDiffPreview.commitStat", + cwd, + [...diffArgs, "--numstat", "-z", commit.parent, commit.sha, "--", ...pathArgs], + { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, + ); + const files = parseReviewNumstat(stat.stdout); + const patch = yield* executeGit( + "GitVcsDriver.getReviewDiffPreview.commitPatch", + cwd, + [...diffArgs, "--patch", commit.parent, commit.sha, "--", ...pathArgs], + { maxOutputBytes: patchLimit, appendTruncationMarker: true }, + ); + const diffHash = yield* hashDiff(patch.stdout, files); + return { + cwd: input.cwd, + generatedAt: yield* DateTime.now, + sources: [ + { + id: commit.sha, + kind: "commit" as const, + title: `Commit ${commit.sha.slice(0, 7)}`, + ...(subject.trim() ? { commitSubject: subject.trim() } : {}), + baseRef: commit.parent, + headRef: commit.sha, + diff: patch.stdout, + files, + diffHash, + truncated: patch.stdoutTruncated, + }, + ], + }; + } + // Coder: thread switching can request only one review source. const sourceKind = input.file?.sourceKind ?? input.sourceKind; const dirtyRef = sourceKind === "branch-range" ? null : "HEAD"; @@ -3210,20 +3406,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const baseFiles = baseResult.files; const dirtyDiff = dirtyTrackedResult.stdout; const baseDiff = baseResult.stdout; - const hashDiff = (diff: string, files: ReadonlyArray) => - crypto.digest("SHA-256", new TextEncoder().encode(JSON.stringify([diff, files]))).pipe( - Effect.map(Hex.encode), - Effect.mapError( - (cause) => - new GitCommandError({ - operation: "GitVcsDriver.getReviewDiffPreview.hash", - command: "crypto.digest SHA-256", - cwd, - detail: "Failed to hash review diff.", - cause, - }), - ), - ); const [dirtyDiffHash, baseDiffHash] = yield* Effect.all([ hashDiff(dirtyDiff, dirtyFiles ?? []), hashDiff(baseDiff, baseFiles ?? []), @@ -3391,6 +3573,30 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (repositoryRoot.length === 0) { return yield* reviewDiffFileError(input, "Could not resolve the Git repository root."); } + if (input.sourceKind === "commit") { + if (!input.headRef) { + return yield* reviewDiffFileError(input, "Selected commit is no longer available."); + } + const commit = yield* resolveReviewCommit(input.cwd, input.headRef); + if (input.baseRef !== commit.parent) { + return yield* reviewDiffFileError( + input, + "Selected commit changed. Refresh the comparison.", + ); + } + const [oldContents, newContents] = yield* Effect.all( + [ + input.changeType === "new" + ? Effect.succeed("") + : readReviewFileAtRevision(input, commit.parent, input.oldPath), + input.changeType === "deleted" + ? Effect.succeed("") + : readReviewFileAtRevision(input, commit.sha, input.newPath), + ], + { concurrency: 2 }, + ); + return { oldContents, newContents }; + } const oldRevision = input.sourceKind === "working-tree" ? (input.baseRef ?? "HEAD") @@ -3868,7 +4074,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "GitVcsDriver.moveWorktree", input.cwd, ["worktree", "move", input.oldPath, input.newPath], - { timeoutMs: WORKTREE_ADD_TIMEOUT_MS }, + // Upstream dropped the worktree add deadline (#23802); a move keeps the same 5 minutes. + { timeoutMs: WORKTREE_REMOVE_TIMEOUT_MS }, ).pipe(Effect.asVoid); const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn( @@ -3926,7 +4133,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ["-c", `checkout.workers=${checkoutWorkers}`, ...args], { fallbackErrorDetail: "git worktree add failed", - timeoutMs: WORKTREE_ADD_TIMEOUT_MS, + // Large checkouts and post-checkout hooks can legitimately run for many + // minutes; a fixed deadline kills them and leaves an orphaned worktree. + timeoutMs: null, ...(onCheckoutProgress ? { // Git only prints checkout progress when stderr is a tty or the @@ -4690,6 +4899,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* readRangeContext, // Coder: index-preserving ref-only polling. refStatusLocal, + getReviewBranchCommits, getReviewDiffPreview, getReviewDiffFileContents, readConfigValue, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e7d9a9363b325..10a3b4fd20b14 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -970,6 +970,7 @@ const layerWsRpc = () => threadFind: true, threadFindProgressive: true, secretProviderSettings: true, + // Coder: no secret provider is loaded, so settings never point at one (`secretReferences`). ...Option.match(scratchWorkspaceRoot, { onNone: () => ({}), onSome: (root) => ({ scratchWorkspaceRoot: root }), @@ -1085,6 +1086,8 @@ const layerWsRpc = () => ), ), [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: (input) => threadManagement.stopWorkflow(input), + [ORCHESTRATION_V2_WS_METHODS.forceStopThread]: (input) => + threadManagement.forceStopThread(input), [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: (input) => readWorkflowScript({ scriptPath: input.scriptPath }), [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: (input) => @@ -1343,9 +1346,11 @@ const layerWsRpc = () => cwd: input.cwd, fresh: input.fresh === true, }) - : input.instanceId !== undefined - ? providerRegistry.refreshInstance(input.instanceId) - : providerRegistry.refresh(); + : input.reloadSecrets === true + ? providerRegistry.reloadSecretsAndRefresh(input.instanceId) + : input.instanceId !== undefined + ? providerRegistry.refreshInstance(input.instanceId) + : providerRegistry.refresh(); if (input.workspaceSnapshotsOnDemand !== true) return { providers }; // Coder: no usage-limit commands (API usage only), so the command providers are the // providers themselves. @@ -1805,6 +1810,7 @@ const layerWsRpc = () => gitWorkflow.switchRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.vcsInit]: (input) => vcsProvisioning.initRepository(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + [WS_METHODS.reviewGetBranchCommits]: (input) => review.getBranchCommits(input), [WS_METHODS.reviewGetDiffPreview]: (input) => review.getDiffPreview(input), [WS_METHODS.terminalOpen]: (input) => terminalManager.open(input), [WS_METHODS.terminalAttach]: (input, metadata) => diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index da7ff1e2c2cb5..891a0fa2f323a 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -389,6 +389,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { defaultOpen style={sidebarProviderStyle} > + {/* Coder: no desktop back/forward buttons or swipe navigation (#27430). */} !parseComposerContextHref(href)) + .map(normalizeMarkdownLinkHrefKey) + .filter((href) => resolveMarkdownFileLinkMeta(href, cwd, imageBaseDir ?? cwd)); + const spans = extractInlineCodeSpans(text).filter((span) => + resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd), + ); + return JSON.stringify([[...new Set(hrefs)], [...new Set(spans)]]); +} + +function resolveFileLinkSources( + key: string, + cwd: string | undefined, + imageBaseDir: string | undefined, +) { + const [hrefs, spans] = JSON.parse(key) as [string[], string[]]; + const markdownFileLinkMetaByHref = new Map(); + for (const href of hrefs) { + const meta = resolveMarkdownFileLinkMeta(href, cwd, imageBaseDir ?? cwd); + if (meta) markdownFileLinkMetaByHref.set(href, meta); + } + const inlineCodeFileLinkMetaByText = new Map(); + for (const span of spans) { + const meta = resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd); + if (meta) inlineCodeFileLinkMetaByText.set(span, meta); + } + const fileLinkParentSuffixByPath = buildFileLinkParentSuffixByPath([ + ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath), + ...[...inlineCodeFileLinkMetaByText.values()].map((meta) => meta.filePath), + ]); + return { markdownFileLinkMetaByHref, inlineCodeFileLinkMetaByText, fileLinkParentSuffixByPath }; +} + function useChatMarkdownState({ text, inline = false, @@ -509,40 +548,6 @@ function useChatMarkdownState({ const { pullRequestsEnabled } = useAtomValue(markdownServerConfigFieldsAtom(environmentId)); const projects = useProjects(); const diffThemeName = resolveDiffThemeName(resolvedTheme); - const markdownFileLinkMetaByHref = (() => { - const metaByHref = new Map< - string, - NonNullable> - >(); - for (const href of extractMarkdownLinkHrefs(renderCodexInlineDirectivesAsMarkdown(text))) { - if (parseComposerContextHref(href)) continue; - const normalizedHref = normalizeMarkdownLinkHrefKey(href); - if (metaByHref.has(normalizedHref)) continue; - const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd); - if (meta) { - metaByHref.set(normalizedHref, meta); - } - } - return metaByHref; - })(); - const inlineCodeFileLinkMetaByText = (() => { - const metaByText = new Map(); - for (const span of extractInlineCodeSpans(text)) { - if (metaByText.has(span)) continue; - const meta = resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd); - if (meta) { - metaByText.set(span, meta); - } - } - return metaByText; - })(); - const fileLinkParentSuffixByPath = (() => { - const filePaths = [ - ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath), - ...[...inlineCodeFileLinkMetaByText.values()].map((meta) => meta.filePath), - ]; - return buildFileLinkParentSuffixByPath(filePaths); - })(); const markdownUrlTransform = (href: string) => { if (parseAssistantCitationHref(href)) return href; if (parseComposerContextHref(href)) return href; @@ -568,6 +573,12 @@ function useChatMarkdownState({ const openChangeRequestLink = useOpenChangeRequestLink(threadRef, pullRequestPanelRef); const openDeferredMarkdownLink = useOpenLink(threadRef); const mathEnabled = useClientSettings((settings) => settings.mathRenderingEnabled); + // Keyed by the file links found, not the text, so streamed text that adds none keeps the + // renderer context, and every block reading it, unchanged. + // A template literal marks the key as a string, so the compiler memoizes the maps on its value. + const fileLinksKey = `${fileLinkSourcesKey(text, cwd, imageBaseDir)}`; + const { markdownFileLinkMetaByHref, inlineCodeFileLinkMetaByText, fileLinkParentSuffixByPath } = + resolveFileLinkSources(fileLinksKey, cwd, imageBaseDir); const resolveThreadPullRequest = ( href: string, ): (ThreadPullRequestKey & { readonly url: string }) | null => { @@ -1396,6 +1407,32 @@ const CHAT_MARKDOWN_COMPONENTS = { }, } satisfies Components; +const MarkdownSource = memo(function MarkdownSource({ + text, + remarkPlugins, + rehypePlugins, + urlTransform, +}: { + text: string; + remarkPlugins: NonNullable; + rehypePlugins: NonNullable; + urlTransform: ReactMarkdownOptions["urlTransform"]; +}) { + return ( + + + {text} + + + ); +}); + function ChatMarkdown({ text, className, @@ -1413,8 +1450,17 @@ function ChatMarkdown({ localMediaPreview, closeLocalMediaPreview, } = useChatMarkdownState({ text, inline, ...props }); + // Streaming re-renders only the last block; offsets for task edits need the whole text. + const streamingBlocks = + props.isStreaming === true && + !inline && + extraRemarkPlugins.length === 0 && + !props.onTaskListChange + ? splitMarkdownBlocks(text) + : null; const incrementalParsing = props.isStreaming === true && + streamingBlocks === null && extraRemarkPlugins.length === 0 && /(?:^|\n) {0,3}(?:`{3}|~{3})/.test(text); const Root = inline ? "span" : "div"; @@ -1442,21 +1488,20 @@ function ChatMarkdown({ onCopy={handleCopy} > - - ( + - {text} - - + /> + ))} {localMediaPreview ? ( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 42660924349c3..88ce7aa18129f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -906,7 +906,12 @@ export default function ChatView(props: ChatViewProps) { useLayoutEffect(() => { const explicitThreadRef = explicitDiffOpenRef.current; explicitDiffOpenRef.current = null; - if (diffOpen && activeThreadRef && explicitThreadRef !== activeThreadRef) { + if ( + diffOpen && + activeThreadRef && + explicitThreadRef !== activeThreadRef && + !useDiffPanelStore.getState().consumeKeepSelectionOnOpen(activeThreadRef) + ) { useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); } }, [activeThreadRef, diffOpen]); @@ -2111,7 +2116,8 @@ export default function ChatView(props: ChatViewProps) { const { acknowledgeActiveThreadWoke, activeBranchMismatchKey, - backgroundWorkNow, + attentionNow, + workNow, childInputNow, goalNow, handleSwitchCheckoutToThread, @@ -2628,10 +2634,7 @@ export default function ChatView(props: ChatViewProps) { const onExpandTimelineImageStable = useStableCallback(onExpandTimelineImage); const shownThreadSyncPhase = activeEnvironmentUnavailable ? null : delayedThreadSyncPhase; const hasThreadNow = - childInputNow !== null || - backgroundWorkNow !== null || - goalNow !== null || - shownThreadSyncPhase !== null; + attentionNow !== null || workNow !== null || goalNow !== null || shownThreadSyncPhase !== null; const queuedRunCount = serverProjection ? getUserQueuedThreadRuns(serverProjection).length : 0; // The queue control is mounted for every server thread but joins the footer only while it shows // something, by its own rule: an optimistic message stops counting once the server has it. @@ -2669,8 +2672,8 @@ export default function ChatView(props: ChatViewProps) { )} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 4be40cba2a2e8..8e39f7e09e75d 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,6 +1,6 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; import type { FileDiffContentsLoader, FileDiffMetadata } from "@pierre/diffs"; -import type { ScopedThreadRef, RunId } from "@t3tools/contracts"; +import type { ReviewBranchCommitsResult, ScopedThreadRef, RunId } from "@t3tools/contracts"; import { ArrowRightIcon, CheckIcon, @@ -14,7 +14,7 @@ import { } from "lucide-react"; import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide"; import * as Schema from "effect/Schema"; -import { useEffect, useRef, useState } from "react"; +import { type MouseEvent, useEffect, useRef, useState } from "react"; import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; import { useFilesystemReadAccess } from "~/state/filesystem"; import { useFileContextMenuHandler } from "../fileContextMenu"; @@ -26,6 +26,7 @@ import { revisionedQuery } from "~/state/revisionedQuery"; import { cn } from "~/lib/utils"; import { resolveToolCallDiffTarget, + selectThreadBranchBaseRef, selectThreadDiffPanelSelection, useDiffPanelSessionStore, useDiffPanelStore, @@ -53,7 +54,7 @@ import { useQuietThreadVisibleTurnItems, } from "../state/entities"; import { useClientSettings, updateClientSettings } from "../hooks/useSettings"; -import { formatShortTimestamp } from "../timestampFormat"; +import { formatRelativeTimeLabel, formatShortTimestamp } from "../timestampFormat"; import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; import { DiffStatLabel } from "./chat/DiffStatLabel"; @@ -80,6 +81,7 @@ import { import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSub, @@ -99,6 +101,8 @@ import { useReviewFilePatches } from "./diffs/useReviewFilePatches"; import { DiffFileLoadingBoundary } from "./diffs/DiffFileLoadingBoundary"; import { DiffFileStatus } from "./diffs/DiffFileStatus"; import { useThreadRouteRef } from "../hooks/useThreadRouteTarget"; +import { projectCommitUrl } from "@t3tools/client-runtime/change-request-links"; +import { useOpenLink } from "../browser/useOpenLink"; type DiffThemeType = "light" | "dark"; const AUTOMATIC_BASE_REF = "__automatic_base_ref__"; @@ -225,6 +229,8 @@ export default function DiffPanel({ ); const [hideTestFiles, setHideTestFiles] = useHideTestFiles(); const [baseRefQuery, setBaseRefQuery] = useState(""); + // The open commit menu keeps the list it opened with, so rows do not move under the pointer. + const [openCommitMenu, setOpenCommitMenu] = useState(null); const [codeViewRevision, setCodeViewRevision] = useState(0); const [diffFileExpansionErrors, setDiffFileExpansionErrors] = useState({ @@ -268,8 +274,14 @@ export default function DiffPanel({ const diffSelection = useDiffPanelStore((state) => selectThreadDiffPanelSelection(state.byThreadKey, routeThreadRef), ); + const rememberedBaseRef = useDiffPanelStore((state) => + selectThreadBranchBaseRef(state, routeThreadRef), + ); const toolCallSelection = diffSelection.kind === "tool-call" ? diffSelection : null; - const isGitSelection = diffSelection.kind === "branch" || diffSelection.kind === "unstaged"; + const isGitSelection = + diffSelection.kind === "branch" || + diffSelection.kind === "unstaged" || + diffSelection.kind === "commit"; const toolCallItems = useQuietThreadVisibleTurnItems(toolCallSelection ? routeThreadRef : null); const toolCallDetail = useTurnItemDetail( toolCallSelection && routeThreadRef @@ -299,8 +311,15 @@ export default function DiffPanel({ }, [diffSelection, orderedTurnDiffSummaries, routeThreadRef]); const selectedRunId = diffSelection.kind === "turn" ? diffSelection.turnId : null; - const selectedGitScope = diffSelection.kind === "unstaged" ? "unstaged" : "branch"; - const selectedBaseRef = diffSelection.kind === "branch" ? diffSelection.baseRef : null; + const selectedGitScope = + diffSelection.kind === "unstaged" || diffSelection.kind === "commit" + ? diffSelection.kind + : "branch"; + const selectedCommitSha = diffSelection.kind === "commit" ? diffSelection.sha : null; + const selectedBaseRef = + diffSelection.kind === "branch" || diffSelection.kind === "commit" + ? diffSelection.baseRef + : null; const selectedFilePath = diffSelection.kind === "turn" ? diffSelection.filePath : null; const selectedFileRevealRequestId = diffSelection.kind === "turn" ? diffSelection.revealRequestId : 0; @@ -311,31 +330,17 @@ export default function DiffPanel({ orderedTurnDiffSummaries[0]); const selectedCheckpointTurnCount = selectedTurn?.checkpointTurnCount; const latestTurn = orderedTurnDiffSummaries[0]; - const selectedScopeLabel = toolCallSelection - ? "Tool call" - : selectedRunId === null - ? selectedGitScope === "unstaged" - ? "Uncommitted" - : "Changes" - : selectedTurn?.runId === latestTurn?.runId - ? "Latest turn" - : `Turn ${selectedCheckpointTurnCount ?? "?"}`; const reviewSectionId = toolCallSelection ? `tool-call:${toolCallSelection.threadId}:${toolCallSelection.itemId}` : selectedTurn ? `turn:${selectedTurn.runId}` - : selectedGitScope; + : selectedCommitSha !== null + ? `commit:${selectedCommitSha}` + : selectedGitScope; const collapseScopeKey = routeThreadRef ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}` : null; const codeViewMountKey = `${collapseScopeKey ?? reviewSectionId}:${codeViewRevision}`; - const reviewSectionTitle = toolCallSelection - ? "Tool call" - : selectedTurn - ? `Turn ${selectedCheckpointTurnCount ?? "?"}` - : selectedGitScope === "unstaged" - ? "Uncommitted" - : "Changes"; const selectedCheckpointRange = typeof selectedCheckpointTurnCount === "number" ? { @@ -359,6 +364,7 @@ export default function DiffPanel({ ? { cwd: activeCwd, ...(selectedBaseRef ? { baseRef: selectedBaseRef } : {}), + ...(selectedCommitSha !== null ? { commitSha: selectedCommitSha } : {}), ignoreWhitespace: diffIgnoreWhitespace, // Coder: ask the helper for only the selected source. sourceKind: @@ -380,13 +386,69 @@ export default function DiffPanel({ ) : null, ); + const branchCommitsQuery = useEnvironmentQuery( + visible && isGitRepo && canReadFiles && activeThread && activeCwd + ? reviewEnvironment.branchCommits({ + environmentId: activeThread.environmentId, + input: { + cwd: activeCwd, + ...(rememberedBaseRef ? { baseRef: rememberedBaseRef } : {}), + }, + }) + : null, + ); + const branchCommits = openCommitMenu ?? branchCommitsQuery.data; const activeThreadRefreshKey = routeThreadRef ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}` : null; const selectedGitSource = branchDiffPreview.data?.sources.find( - (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"), + (source) => + source.kind === + (selectedGitScope === "unstaged" + ? "working-tree" + : selectedGitScope === "commit" + ? "commit" + : "branch-range"), ); + const selectedCommitSubject = + selectedCommitSha !== null && selectedGitSource?.headRef === selectedCommitSha + ? selectedGitSource.commitSubject + : undefined; + const selectedGitScopeLabel = + selectedCommitSha !== null + ? (selectedCommitSubject ?? `Commit ${selectedCommitSha.slice(0, 7)}`) + : selectedGitScope === "unstaged" + ? "Uncommitted" + : "Changes"; + const selectedScopeLabel = toolCallSelection + ? "Tool call" + : selectedRunId === null + ? selectedCommitSha !== null + ? (selectedCommitSubject ?? "Commit") + : selectedGitScopeLabel + : selectedTurn?.runId === latestTurn?.runId + ? "Latest turn" + : `Turn ${selectedCheckpointTurnCount ?? "?"}`; + const reviewSectionTitle = toolCallSelection + ? "Tool call" + : selectedTurn + ? `Turn ${selectedCheckpointTurnCount ?? "?"}` + : selectedGitScopeLabel; + const headerCommitSha = selectedRunId === null ? selectedCommitSha : null; + const selectedCommitUrl = + headerCommitSha !== null + ? projectCommitUrl(activeProject?.repositoryIdentity, headerCommitSha) + : null; + const openLink = useOpenLink(routeThreadRef); + // A SHA link goes to the code host; it must not route back into this pane's commit selection. + const openCommitUrl = (event: MouseEvent, url: string) => { + event.stopPropagation(); + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button === 1) + return; + event.preventDefault(); + void openLink(url, { event }); + }; const refreshPreviewQuery = branchDiffPreview.refresh; const refreshDiffFromUserAction = refreshPreviewQuery; @@ -552,7 +614,8 @@ export default function DiffPanel({ environmentId: activeThread?.environmentId, cwd: branchDiffPreview.data?.cwd, source: lazySource, - baseRef: lazySource?.baseRef ?? selectedBaseRef, + baseRef: + selectedCommitSha !== null ? selectedBaseRef : (lazySource?.baseRef ?? selectedBaseRef), ignoreWhitespace: diffIgnoreWhitespace, theme: resolvedTheme, previewInput: branchDiffPreviewInput, @@ -718,16 +781,23 @@ export default function DiffPanel({ useDiffPanelStore.getState().selectBranchBaseRef(routeThreadRef, baseRef); }; const selectedTurnValue = selectedTurn ? `turn:${selectedTurn.runId}` : ""; + const selectedCommitValue = selectedCommitSha !== null ? `commit:${selectedCommitSha}` : ""; const selectedScopeValue = toolCallSelection ? "" : selectedRunId === null - ? selectedGitScope + ? selectedCommitValue || selectedGitScope : selectedTurn?.runId === latestTurn?.runId ? "latest" : selectedTurnValue; const selectScopeValue = (value: string) => { if (value === "unstaged" || value === "branch") { selectGitScope(value); + } else if (value === selectedCommitValue) { + selectGitScope("branch"); + } else if (value.startsWith("commit:")) { + if (routeThreadRef) { + useDiffPanelStore.getState().selectCommit(routeThreadRef, value.slice("commit:".length)); + } } else if (value === "latest") { if (latestTurn) selectTurn(latestTurn.runId); } else { @@ -742,13 +812,37 @@ export default function DiffPanel({ } - className="max-w-full" + className={cn("max-w-full", headerCommitSha !== null && "min-w-0 shrink")} aria-label={`Diff scope: ${selectedScopeLabel}`} data-workspace-focus-default > {selectedScopeLabel} + {headerCommitSha !== null ? ( + selectedCommitUrl ? ( + + openCommitUrl(event, selectedCommitUrl)} + /> + } + > + {headerCommitSha.slice(0, 7)} + + {selectedCommitUrl} + + ) : ( + + {headerCommitSha.slice(0, 7)} + + ) + ) : null} @@ -785,6 +879,99 @@ export default function DiffPanel({ + { + setOpenCommitMenu(open ? (branchCommitsQuery.data ?? null) : null); + if (!open) return; + // The menu keeps the list it opened with; the refreshed one shows next time. + branchCommitsQuery.refresh(); + }} + > + Commits + + + {branchCommits?.commits.map((commit) => { + const commitUrl = projectCommitUrl( + activeProject?.repositoryIdentity, + commit.sha, + ); + return ( +
+ + + } + > + + {commit.subject} + + {formatRelativeTimeLabel(commit.committedAt)} + + + + + {commit.subject} + + + {commitUrl ? ( + + } + onClick={(event) => openCommitUrl(event, commitUrl)} + > + + {commit.sha.slice(0, 7)} + + + ) : ( + + {commit.sha.slice(0, 7)} + + )} +
+ ); + })} +
+ {branchCommitsQuery.error ? ( + {branchCommitsQuery.error} + ) : !canReadFiles ? ( + + {fileAccess.isPending + ? "Checking file access..." + : (fileAccess.error ?? "This connection cannot read local diffs.")} + + ) : branchCommits === null ? ( + Loading commits... + ) : branchCommits.commits.length === 0 ? ( + No commits on this branch. + ) : branchCommits.truncated ? ( + + {`Showing latest ${branchCommits.commits.length} commits`} + + ) : null} +
+
{isGitSelection && selectedGitScope === "branch" && selectedGitSource?.baseRef && ( @@ -919,7 +1106,12 @@ export default function DiffPanel({ )} -
+
{codeViewFiles.length > 0 || (isGitSelection && selectedGitSource?.files?.length) ? ( ) : ( diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 8b8600b37ab28..5f0ffddfc7548 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -88,7 +88,7 @@ import { useVcsInitAction, useVcsPullAction, } from "../state/sourceControlActions"; -import { useThreadProjection, useQuietThreadShell } from "~/state/entities"; +import { useQuietThreadProjection, useQuietThreadShell } from "~/state/entities"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { useEnvironmentQuery } from "~/state/query"; import { sourceControlEnvironment } from "~/state/sourceControl"; @@ -954,7 +954,7 @@ export default function GitActionsControl({ ? store.getDraftThreadByRef(activeThreadRef) : null, ); - const activeServerThreadProjection = useThreadProjection( + const activeServerThreadProjection = useQuietThreadProjection( activeDraftThread !== null && activeServerThreadShell === null ? null : activeThreadRef, ); const activeServerThread = diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 8f4b15cc11f91..bd74694dc46af 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1841,10 +1841,10 @@ describe("deriveMessagesTimelineRows", () => { const settled = rows({ resume: "completed", working: false }); expect(shape(settled)).toEqual([ "user:launch", - "fold:Worked for 8.0s", + "fold:Worked for 8s", "assistant:launch-answer", "user:resume", - "fold:Worked for 8.0s", + "fold:Worked for 8s", "assistant:resume-answer", ]); @@ -1857,17 +1857,17 @@ describe("deriveMessagesTimelineRows", () => { }); expect(shape(expanded)).toEqual([ "user:launch", - "fold:Worked for 8.0s", + "fold:Worked for 8s", "work-toggle", "assistant:launch-answer", "user:resume", - "fold:Worked for 8.0s", + "fold:Worked for 8s", "assistant:resume-answer", ]); expect(shape(rows({ resume: "running", working: true }))).toEqual([ "user:launch", - "fold:Worked for 8.0s", + "fold:Worked for 8s", "assistant:launch-answer", "user:resume", "working", @@ -1876,7 +1876,7 @@ describe("deriveMessagesTimelineRows", () => { expect(shape(rows({ resume: "failed", working: false }))).toEqual([ "user:launch", - "fold:Worked for 8.0s", + "fold:Worked for 8s", "assistant:launch-answer", "user:resume", "work", diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a9cab1554a3d3..faa733c76e896 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -659,12 +659,11 @@ const ConversationTimeline = memo(function ConversationTimeline({ ? new Set(paintedExpandedAttemptIds).add(activeFindAttemptId) : paintedExpandedAttemptIds; - const pendingApprovalsByRequestId = new Map( - pendingApprovals - .filter((approval) => !heldApprovalRequestIds?.has(approval.requestId)) - .map((approval) => [approval.requestId, approval]), + const isVisiblePendingApproval = (approval: PendingApproval) => + !heldApprovalRequestIds?.has(approval.requestId); + const pendingApprovalRequestIds = new Set( + pendingApprovals.filter(isVisiblePendingApproval).map((approval) => approval.requestId), ); - const pendingApprovalRequestIds = new Set(pendingApprovalsByRequestId.keys()); const rawRows = useIncrementalTimelineRows(`${listIdentityKey}\0${workspaceRoot ?? ""}`, { timelineEntries, pendingApprovalRequestIds, @@ -683,17 +682,19 @@ const ConversationTimeline = memo(function ConversationTimeline({ worktreeSetup, }); const rows = useStableRows(rawRows, listIdentityKey); - const unmatchedPendingApprovals = new Map(pendingApprovalsByRequestId); - if (unmatchedPendingApprovals.size > 0) { - for (const row of rows) { - const entries = - row.kind === "work" ? row.groupedEntries : row.kind === "work-live" ? [row.entry] : []; - for (const entry of entries) { - const item = entry.projectedItem?.item; - if (item?.type === "approval_request") unmatchedPendingApprovals.delete(item.requestId); - } + const matchedApprovalRequestIds = new Set(); + for (const row of pendingApprovalRequestIds.size > 0 ? rows : []) { + const entries = + row.kind === "work" ? row.groupedEntries : row.kind === "work-live" ? [row.entry] : []; + for (const entry of entries) { + const item = entry.projectedItem?.item; + if (item?.type === "approval_request") matchedApprovalRequestIds.add(item.requestId); } } + const unmatchedPendingApprovals = pendingApprovals.filter( + (approval) => + isVisiblePendingApproval(approval) && !matchedApprovalRequestIds.has(approval.requestId), + ); // Run status/timestamps change per stream event; keep them out of shared row context. const runs = useStableHandoffRuns(runsProp); const minimapItems = deriveTimelineMinimapItems(rows); @@ -706,7 +707,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ restoringThreadPosition && restoreRowIndex >= 0 ? { indices: [restoreRowIndex] } : undefined; const showsEmptyState = rows.length === 0 && - unmatchedPendingApprovals.size === 0 && + unmatchedPendingApprovals.length === 0 && !isWorking && parentThreadLink === null && historyControls === undefined && @@ -1000,6 +1001,13 @@ const ConversationTimeline = memo(function ConversationTimeline({ }; }; + // Built from props after the last hook so the compiler can memoize the row context; a map + // made before a hook would rebuild it, and re-render every row, on each streamed update. + const pendingApprovalsByRequestId = new Map( + pendingApprovals + .filter(isVisiblePendingApproval) + .map((approval) => [approval.requestId, approval]), + ); const sharedState: TimelineRowSharedState = { pendingApprovals: pendingApprovalsByRequestId, respondingRequestIds, @@ -1238,12 +1246,12 @@ const ConversationTimeline = memo(function ConversationTimeline({ onGrow={onFooterGrow} > {footer} - {unmatchedPendingApprovals.size > 0 ? ( + {unmatchedPendingApprovals.length > 0 ? (
- {[...unmatchedPendingApprovals.values()].map((approval) => ( + {unmatchedPendingApprovals.map((approval) => ( ))} diff --git a/apps/web/src/components/chat/QueuedRunsControl.tsx b/apps/web/src/components/chat/QueuedRunsControl.tsx index 7a95c76175881..efc5b5fa3a880 100644 --- a/apps/web/src/components/chat/QueuedRunsControl.tsx +++ b/apps/web/src/components/chat/QueuedRunsControl.tsx @@ -1,7 +1,10 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { deriveThreadQueueWorkflowState } from "@t3tools/client-runtime/state/thread-workflows"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; -import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import { + isAttachmentOnlyBootstrapPrompt, + replaceComposerContextReferences, +} from "@t3tools/shared/composerContextReferences"; import type { ChatAttachment as ContractChatAttachment, EnvironmentId, @@ -233,11 +236,13 @@ export function QueuedRunsControl({
    {visibleItems.map((item, position) => { - const previewText = assistantCitationsToPlainText( - replaceComposerContextReferences(item.text, (reference) => - reference.kind === "image" && item.thumbnails.length > 0 ? "" : reference.label, - ), - ).trim(); + const previewText = isAttachmentOnlyBootstrapPrompt(item.text) + ? "" + : assistantCitationsToPlainText( + replaceComposerContextReferences(item.text, (reference) => + reference.kind === "image" && item.thumbnails.length > 0 ? "" : reference.label, + ), + ).trim(); const isEditing = item.runId !== null && item.runId === props.editingRunId; return (
  1. void; +}): ThreadNowItem { + const unresponsive = input.stop.state === "unresponsive"; + const requestedAtMs = Date.parse(input.stop.requestedAt); + return { + id: `run-stop:${input.threadId}`, + icon: unresponsive ? ( + + ) : ( + + ), + title: unresponsive ? "Agent not responding to Stop" : "Stopping", + description: unresponsive + ? "It may be short on memory. Force stop kills its processes." + : undefined, + actions: ( + <> + formatDuration(Math.max(1_000, nowMs - requestedAtMs)), + true, + )} + className="tabular-nums" + /> + {unresponsive && input.canForceStop ? ( + + {input.forceStopping ? "Force stopping..." : "Force stop"} + + ) : null} + + ), + }; +} + /** Background commands and subagents, for the end of the timeline. */ export function backgroundWorkNowItem(input: { readonly threadId: ThreadId; diff --git a/apps/web/src/components/chat/ThreadTimeline.tsx b/apps/web/src/components/chat/ThreadTimeline.tsx index 7d1db86178cf4..a68d494d8b0a0 100644 --- a/apps/web/src/components/chat/ThreadTimeline.tsx +++ b/apps/web/src/components/chat/ThreadTimeline.tsx @@ -2,7 +2,7 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; import { type ComponentProps, useLayoutEffect } from "react"; import { useIncrementalTimelineEntries } from "../../hooks/useIncrementalTimelineEntries"; import type { TimelineEntriesInput, TimelineEntry } from "../../session-logic"; -import { useThreadVisibleTurnItems } from "../../state/entities"; +import { useTimelineVisibleTurnItems } from "../../state/entities"; import { peekRememberedThreadTimeline, rememberReadyThreadTimeline, @@ -14,7 +14,8 @@ import { MessagesTimeline } from "./MessagesTimeline"; /** * The timeline with the streamed text. ChatView reads the thread through its quiet * atoms, which hold through text deltas; this reads the live turn items, so a - * delta re-renders the timeline without re-rendering ChatView. + * reasoning delta re-renders the timeline without re-rendering ChatView. Streamed + * assistant text reaches only its own row, through useAssistantMessageText. */ export function ThreadTimeline({ threadDetailRef, @@ -34,7 +35,7 @@ export function ThreadTimeline({ syncing: boolean; detailLoading: boolean; }) { - const visibleTurnItems = useThreadVisibleTurnItems(threadDetailRef); + const visibleTurnItems = useTimelineVisibleTurnItems(threadDetailRef); const serverEntries = useIncrementalTimelineEntries(activeThreadKey, { ...entriesInput, visibleTurnItems, diff --git a/apps/web/src/components/chat/TimelineAssistantRows.tsx b/apps/web/src/components/chat/TimelineAssistantRows.tsx index 55f1a164b5eb2..3609a76bd46e0 100644 --- a/apps/web/src/components/chat/TimelineAssistantRows.tsx +++ b/apps/web/src/components/chat/TimelineAssistantRows.tsx @@ -12,7 +12,7 @@ import { McpAppFrame } from "./McpAppFrame"; import { checkpointFileTotals } from "@t3tools/client-runtime/state/thread-checkpoints"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { useFileContextMenuHandler } from "../../fileContextMenu"; -import { useProject, useThreadShell } from "../../state/entities"; +import { useAssistantMessageText, useProject, useThreadShell } from "../../state/entities"; import { MessageCopyButton } from "./MessageCopyButton"; import { AssistantCitationSource } from "./AssistantCitationSource"; import { resolveAssistantMessageCopyState } from "./MessagesTimeline.logic"; @@ -26,7 +26,13 @@ import { MESSAGE_HEADING_LEVEL, MessageAuthorHeading } from "./TimelineRowParts" export function AssistantTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); - const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); + // The timeline holds a streaming message's row while only its text grows. + const liveText = useAssistantMessageText( + row.message.streaming ? ctx.threadRef : null, + row.projectedItem?.item.id, + ); + const text = liveText ?? row.message.text; + const messageText = text || (row.message.streaming ? "" : "(empty response)"); return ( <> diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index 755011e94a4a7..27e234aa21c73 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -74,6 +74,10 @@ import { import { UserMessageContextReferenceChip } from "./UserMessageContextChips"; import { resolvePreviewAnnotationImage } from "./TimelineUserMessageRow.logic"; +// Drawn inside the media so dark screenshots keep an edge on the dark background. +const MEDIA_EDGE_CLASS_NAME = + "relative after:pointer-events-none after:absolute after:inset-0 after:z-10 after:rounded-[inherit] after:ring-1 after:ring-inset after:ring-black/9 dark:after:ring-white/12"; + const BUBBLE_CHIP_CLASS_NAME = "[&_[data-slot=context-chip]]:[--context-chip-ink:var(--context-chip-message,var(--context-chip-accent))]"; @@ -312,8 +316,7 @@ function UserTimelineRow({ row }: { row: Extract image.name.startsWith("preview-annotation-")); const revertTurnCount = row.revertTurnCount; const chippedAttachmentIds = new Set( @@ -324,6 +327,27 @@ function UserTimelineRow({ row }: { row: Extract !image.name.startsWith("preview-annotation-")); const unchippedFiles = otherUserFiles.filter((file) => !chippedAttachmentIds.has(file.id)); + // Images or videos sent without words show as media, not as a bubble around them. + const mediaOnly = + !folded && + userMessage.attribution !== "guest" && + resolvedContext.text.trim() === "" && + userAudio.length === 0 && + unchippedFiles.length === 0 && + unknownAttachments.length === 0 && + regularImages.length + userVideos.length > 0; + // A lone image keeps its own shape; a 4:3 crop hides most of a wide screenshot. + const loneImage = + mediaOnly && + userVideos.length === 0 && + regularImages.length === 1 && + regularImages[0]!.source?.kind !== "snap-shot"; + // Several images sent without words share one rounded shape, split by hairlines. + const collage = + mediaOnly && + userVideos.length === 0 && + regularImages.length > 1 && + regularImages.every((image) => image.source?.kind !== "snap-shot"); const annotationRecordIds = resolvedContext.records .filter((record) => record.kind === "preview-annotation") .map((record) => record.contextId); @@ -445,9 +469,11 @@ function UserTimelineRow({ row }: { row: Extract @@ -473,11 +499,44 @@ function UserTimelineRow({ row }: { row: Extract {(regularImages.length > 0 || userVideos.length > 0) && ( -
    - {regularImages.map((image) => ( +
    + {regularImages.map((image, index) => (
    3 && + regularImages.length % 2 === 1 && + index === regularImages.length - 1 && + "col-span-2", + ) + : "aspect-[4/3] overflow-hidden rounded-lg border border-border/80", + )} > {image.previewUrl ? ( ) : ( -
    +
    {image.name}
    )} diff --git a/apps/web/src/components/chat/WorktreeSetupCard.browser.test.tsx b/apps/web/src/components/chat/WorktreeSetupCard.browser.test.tsx index 1a34f537efd15..eddd6691884ef 100644 --- a/apps/web/src/components/chat/WorktreeSetupCard.browser.test.tsx +++ b/apps/web/src/components/chat/WorktreeSetupCard.browser.test.tsx @@ -53,13 +53,13 @@ it("ticks the running stage while finished stages keep their duration", async () // Assert synchronously: expect.element polls on timers these tests fake. const section = screen.getByRole("region", { name: "Worktree setup" }); - expect(section.element().textContent).toContain("Fetch base branch3.0s"); - expect(section.element().textContent).toContain("Check out files7.0s"); + expect(section.element().textContent).toContain("Fetch base branch3s"); + expect(section.element().textContent).toContain("Check out files7s"); await act(async () => { await vi.advanceTimersByTimeAsync(5_000); }); - expect(section.element().textContent).toContain("Fetch base branch3.0s"); + expect(section.element().textContent).toContain("Fetch base branch3s"); expect(section.element().textContent).toContain("Check out files12s"); }); @@ -70,9 +70,9 @@ it("stops a stage interrupted by the setup ending at the setup's end", async () await using screen = await render(card(failed)); const section = screen.getByRole("region", { name: "Worktree setup" }); - expect(section.element().textContent).toContain("Check out files2.0s"); + expect(section.element().textContent).toContain("Check out files2s"); await act(async () => { await vi.advanceTimersByTimeAsync(5_000); }); - expect(section.element().textContent).toContain("Check out files2.0s"); + expect(section.element().textContent).toContain("Check out files2s"); }); diff --git a/apps/web/src/components/chat/markdownStreamBlocks.test.tsx b/apps/web/src/components/chat/markdownStreamBlocks.test.tsx new file mode 100644 index 0000000000000..ae81316580bbd --- /dev/null +++ b/apps/web/src/components/chat/markdownStreamBlocks.test.tsx @@ -0,0 +1,61 @@ +import { + CHAT_MARKDOWN_REHYPE_PLUGINS, + CHAT_MARKDOWN_REMARK_PLUGINS, +} from "@t3tools/shared/markdownPipeline"; +import { renderToStaticMarkup } from "react-dom/server"; +import ReactMarkdown from "react-markdown"; +import { describe, expect, it } from "vite-plus/test"; + +import { splitMarkdownBlocks } from "./markdownStreamBlocks"; + +// Whitespace between top-level blocks collapses in the rendered message. +const html = (source: string) => + renderToStaticMarkup( + + {source} + , + ) + .replace(/>\s+<") + .trim(); + +describe("splitMarkdownBlocks", () => { + it.each([ + "# Title\n\nFirst paragraph with `code` and **bold**.\n\nSecond [link](https://example.com).", + "- one\n- two\n\n- loose three\n\n continued\n\nAfter the list.", + "1. first\n\n2. second\n\nText\n\n3) restarted", + "```ts\nconst a = 1;\n\n\nconst b = 2;\n```\n\nafter\n\n~~~\nx\n\n~~~~\n\ny\n~~~\n\nend", + "> quote\n\n> another quote\n\n> [!NOTE]\n> alert", + "| a | b |\n| - | - |\n| 1 | 2 |\n\nAfter the table.", + "para\n\n indented code\n\nnext", + "Setext\n===\n\nnext\n---\n\n- [ ] task\n- [x] done", + ])("renders each block as the whole text renders it, at every streamed prefix: %j", (text) => { + for (let end = 0; end <= text.length; end++) { + const source = text.slice(0, end); + const blocks = splitMarkdownBlocks(source); + expect(blocks?.join("")).toBe(source); + expect(blocks!.map(html).join("")).toBe(html(source)); + } + }); + + it("splits only between closed blocks", () => { + expect(splitMarkdownBlocks("a\n\n```\nb\n\nc\n```\n\n- d\n\n- e\n\nf")).toEqual([ + "a\n\n", + "```\nb\n\nc\n```\n\n- d\n\n- e\n\n", + "f", + ]); + }); + + it.each([ + "[ref]\n\n[ref]: /later", + "a[^x]\n\n[^x]: note", + "
    \n\nbody\n\n
    ", + ":::note\n\ncontent\n\n:::", + "$$\na\n\nb\n$$", + "a\r\n\r\nb", + ])("keeps text with document-wide syntax whole: %j", (text) => { + expect(splitMarkdownBlocks(text)).toBeNull(); + }); +}); diff --git a/apps/web/src/components/chat/markdownStreamBlocks.ts b/apps/web/src/components/chat/markdownStreamBlocks.ts new file mode 100644 index 0000000000000..52415ae71977b --- /dev/null +++ b/apps/web/src/components/chat/markdownStreamBlocks.ts @@ -0,0 +1,43 @@ +// Definitions, footnotes, raw HTML, container directives and display math can tie blocks together. +const DOCUMENT_WIDE_SYNTAX = /^ {0,3}(?:\[[^\]\n]+\]:|<[A-Za-z!?/]|:::|\$\$)|\[\^/m; +const FENCE_PATTERN = /^ {0,3}(`{3,}|~{3,})/; +// After a blank line these still continue the previous block: a list item or indented content. +const CONTINUATION_PATTERN = /^(?:[ \t]|(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$))/; + +/** + * Cuts Markdown at blank lines that close every open block, so each piece renders exactly as it + * would inside the whole text. Returns null when the text uses syntax that spans blocks. + */ +export function splitMarkdownBlocks(text: string): string[] | null { + if (text.includes("\r") || DOCUMENT_WIDE_SYNTAX.test(text)) return null; + const blocks: string[] = []; + let blockStart = 0; + let lineStart = 0; + let fence: string | null = null; + let afterBlankLine = false; + for (const line of text.split("\n")) { + const opener = FENCE_PATTERN.exec(line)?.[1]; + if (fence !== null) { + if ( + opener && + opener[0] === fence[0] && + opener.length >= fence.length && + line.trim() === opener + ) { + fence = null; + } + } else if (line.trim() === "") { + afterBlankLine = true; + } else { + if (afterBlankLine && lineStart > blockStart && !CONTINUATION_PATTERN.test(line)) { + blocks.push(text.slice(blockStart, lineStart)); + blockStart = lineStart; + } + afterBlankLine = false; + fence = opener ?? null; + } + lineStart += line.length + 1; + } + blocks.push(text.slice(blockStart)); + return blocks; +} diff --git a/apps/web/src/components/chat/useForceStopThread.ts b/apps/web/src/components/chat/useForceStopThread.ts new file mode 100644 index 0000000000000..00876c758a8b6 --- /dev/null +++ b/apps/web/src/components/chat/useForceStopThread.ts @@ -0,0 +1,59 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId, RunId, ThreadId } from "@t3tools/contracts"; +import { useState } from "react"; + +import { useServerConfigs } from "../../state/entities"; +import { orchestrationEnvironment } from "../../state/orchestration"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { toastManager } from "../ui/toast"; + +/** Force stop for an agent that no longer answers Stop; shared by every place that offers it. */ +export function useForceStopThread(environmentId: EnvironmentId) { + const forceStop = useAtomCommand(orchestrationEnvironment.forceStopThread); + const permitted = useAtomValue( + orchestrationEnvironment.forceStopThread.permissionAtom(environmentId), + ); + const supported = + useServerConfigs().get(environmentId)?.environment.capabilities.threadForceStop === true; + const canForceStop = permitted && supported; + const [stoppingThreadId, setStoppingThreadId] = useState(null); + /** Pass the run that ignored Stop, so a Force stop left open cannot hit a newer run. */ + const forceStopThread = async (threadId: ThreadId, runId?: RunId) => { + if (!canForceStop || stoppingThreadId !== null) return; + setStoppingThreadId(threadId); + const result = await forceStop({ + environmentId, + input: runId === undefined ? { threadId } : { threadId, runId }, + }); + setStoppingThreadId(null); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Could not force stop the agent", + description: error instanceof Error ? error.message : undefined, + }); + return; + } + const others = result.value.sharedWithThreadIds.length; + if (result.value.outcome === "releasing") { + toastManager.add({ + type: "info", + title: "Turn stopped; the agent is still shutting down", + }); + } + if (result.value.outcome === "shared") { + toastManager.add({ + type: "info", + title: "Turn stopped; the agent process kept running", + description: `It is shared with ${others === 0 ? "other" : others} working ${others === 1 ? "thread" : "threads"}. Stop those to free it.`, + }); + } + }; + return { canForceStop, stoppingThreadId, forceStopThread }; +} diff --git a/apps/web/src/components/chat/useThreadLifecycle.tsx b/apps/web/src/components/chat/useThreadLifecycle.tsx index 921aa8ddfbaa5..ef16d53fc5e74 100644 --- a/apps/web/src/components/chat/useThreadLifecycle.tsx +++ b/apps/web/src/components/chat/useThreadLifecycle.tsx @@ -58,9 +58,22 @@ import { appAtomRegistry } from "../../rpc/atomRegistry"; import { readEnvironmentScope } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; import { useOrchestrationCommand } from "../../state/use-orchestration-command"; -import { backgroundWorkNowItem, childInputNowItem, goalNowItem } from "./ThreadActivityBanners"; +import { + backgroundWorkNowItem, + childInputNowItem, + goalNowItem, + runStopNowItem, +} from "./ThreadActivityBanners"; +import { useForceStopThread } from "./useForceStopThread"; +import { useAtomValue } from "@effect/atom-react"; +import type { RunStopPresentation } from "@t3tools/client-runtime/state/thread-execution"; +import { Atom } from "effect/reactivity"; +import { environmentThreadDetails } from "../../state/threads"; import type { useLocalDispatchState } from "./useLocalDispatchState"; const RELATED_THREAD_SHELL_WAIT_MS = 1_000; +const NO_RUN_STOP_ATOM = Atom.make(null).pipe( + Atom.withLabel("web-thread-run-stop:empty"), +); type PendingThreadRequests = ReturnType; type LocalDispatchState = ReturnType; @@ -505,6 +518,22 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { }) : null; + const forceStop = useForceStopThread(environmentId); + const anyRunStop = useAtomValue( + activeThreadRef ? environmentThreadDetails.runStopAtom(activeThreadRef) : NO_RUN_STOP_ATOM, + ); + // A background Stop matters only while that work is still pending. + const runStop = anyRunStop?.background && activeBackgroundTasks.length === 0 ? null : anyRunStop; + const runStopNow = + activeThread && runStop !== null + ? runStopNowItem({ + threadId: activeThread.id, + stop: runStop, + canForceStop: forceStop.canForceStop, + forceStopping: forceStop.stoppingThreadId === activeThread.id, + onForceStop: () => void forceStop.forceStopThread(activeThread.id, runStop.runId), + }) + : null; const backgroundWorkNow = activeThread ? backgroundWorkNowItem({ threadId: activeThread.id, @@ -588,7 +617,9 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { shortcuts, acknowledgeActiveThreadWoke, activeBranchMismatchKey, - backgroundWorkNow, + // A Stop that went unanswered needs the user; one in progress is the thread's work. + attentionNow: runStop?.state === "unresponsive" ? runStopNow : childInputNow, + workNow: runStop?.state === "stopping" ? runStopNow : backgroundWorkNow, childInputNow, goalNow, handleSwitchCheckoutToThread, diff --git a/apps/web/src/components/diffs/useReviewFilePatches.ts b/apps/web/src/components/diffs/useReviewFilePatches.ts index b683e13a8f6ee..69dafc2dd7823 100644 --- a/apps/web/src/components/diffs/useReviewFilePatches.ts +++ b/apps/web/src/components/diffs/useReviewFilePatches.ts @@ -193,6 +193,7 @@ function reviewFileQueries({ request: { cwd, ...(baseRef ? { baseRef } : {}), + ...(source.kind === "commit" ? { commitSha: source.id } : {}), ignoreWhitespace, file: { path: file.path, diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index abb8754633680..a988e2462b5e9 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -194,7 +194,9 @@ function ProviderEnvironmentFieldRow(props: { readonly onRemove: (field: ProviderEnvironmentField) => void; }) { const inputId = `${props.idPrefix}-environment-${props.field.name}`; - const value = props.variable?.valueRedacted ? "" : (props.variable?.value ?? ""); + // Coder: secret providers are not carried, so a stored secret reference shows as no value. + const storedValue = props.variable?.valueRedacted ? "" : (props.variable?.value ?? ""); + const value = typeof storedValue === "string" ? storedValue : ""; return ( { const result = await refreshServerProviders({ environmentId, - input: { refreshModels: true }, + input: { refreshModels: true, reloadSecrets: true }, }); refreshingRef.current = false; setIsRefreshingProviders(false); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 3dc8fce1e565a..34602c566dbef 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -527,7 +527,7 @@ export const SETTINGS_SEARCH_ITEMS = [ id: "pull-request-links-open-in-app", title: "Open merge requests in T3 Coder", to: "/settings/diffs", - searchTerms: ["mr links gitlab browser external click default merge request view panel"], + searchTerms: ["mr commit links gitlab browser external click default merge request view panel"], }, { id: "skills-in-slash-menu", diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 5a91a335161d5..c46ad7dba840d 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -62,6 +62,7 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ />
    + {/* Coder: no desktop back/forward buttons beside the brand (#27430). */} {pillLabel ? (
    @@ -99,10 +100,11 @@ export function SidebarBrandWidthProbe({ ); } +// Coder: the logo link is named "T3 Coder home". function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { return ( ; branchBaseRefByThreadKey: Record; + /** A link that selected a commit before opening the closed pane; opening keeps that selection. */ + keepSelectionOnOpenThreadKey: string | null; selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void; selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void; selectToolCall: (ref: ScopedThreadRef, target: ToolCallDiffTarget) => void; selectTurn: (ref: ScopedThreadRef, turnId: RunId, filePath?: string) => void; + selectCommit: (ref: ScopedThreadRef, sha: string, keepOnOpen?: boolean) => void; + consumeKeepSelectionOnOpen: (ref: ScopedThreadRef) => boolean; reconcileTurnSelection: (ref: ScopedThreadRef, availableTurnIds: ReadonlyArray) => void; removeThread: (ref: ScopedThreadRef) => void; } @@ -37,19 +42,27 @@ function normalizeBaseRef(baseRef: string | null): string | null { return normalized ? normalized : null; } +function branchBaseRef( + state: Pick, + threadKey: string, +): string | null { + const previous = state.byThreadKey[threadKey]; + return previous?.kind === "branch" || previous?.kind === "commit" + ? previous.baseRef + : (state.branchBaseRefByThreadKey[threadKey] ?? null); +} + export const useDiffPanelStore = create()( persist( - (set) => ({ + (set, get) => ({ byThreadKey: {}, branchBaseRefByThreadKey: {}, + keepSelectionOnOpenThreadKey: null, selectGitScope: (ref, scope) => set((state) => { const threadKey = scopedThreadKey(ref); const previous = state.byThreadKey[threadKey]; - const previousBaseRef = - previous?.kind === "branch" - ? previous.baseRef - : (state.branchBaseRefByThreadKey[threadKey] ?? null); + const previousBaseRef = branchBaseRef(state, threadKey); return { byThreadKey: { ...state.byThreadKey, @@ -59,7 +72,7 @@ export const useDiffPanelStore = create()( : { kind: "unstaged" }, }, branchBaseRefByThreadKey: - previous?.kind === "branch" + previous?.kind === "branch" || previous?.kind === "commit" ? { ...state.branchBaseRefByThreadKey, [threadKey]: previous.baseRef } : state.branchBaseRefByThreadKey, }; @@ -102,6 +115,26 @@ export const useDiffPanelStore = create()( }, }; }), + selectCommit: (ref, sha, keepOnOpen = false) => + set((state) => { + const threadKey = scopedThreadKey(ref); + const baseRef = branchBaseRef(state, threadKey); + return { + byThreadKey: { + ...state.byThreadKey, + [threadKey]: { kind: "commit", sha, baseRef }, + }, + branchBaseRefByThreadKey: { ...state.branchBaseRefByThreadKey, [threadKey]: baseRef }, + keepSelectionOnOpenThreadKey: keepOnOpen + ? threadKey + : state.keepSelectionOnOpenThreadKey, + }; + }), + consumeKeepSelectionOnOpen: (ref) => { + if (get().keepSelectionOnOpenThreadKey !== scopedThreadKey(ref)) return false; + set({ keepSelectionOnOpenThreadKey: null }); + return true; + }, reconcileTurnSelection: (ref, availableTurnIds) => set((state) => { const threadKey = scopedThreadKey(ref); @@ -178,6 +211,13 @@ export function selectThreadDiffPanelSelection( return byThreadKey[scopedThreadKey(ref)] ?? DEFAULT_SELECTION; } +export function selectThreadBranchBaseRef( + state: Pick, + ref: ScopedThreadRef | null | undefined, +): string | null { + return ref ? branchBaseRef(state, scopedThreadKey(ref)) : null; +} + export function resolveToolCallDiffTarget( target: ToolCallDiffTarget, items: ReadonlyArray, diff --git a/apps/web/src/lib/openPullRequestLink.browser.test.tsx b/apps/web/src/lib/openPullRequestLink.browser.test.tsx new file mode 100644 index 0000000000000..5bb2dd97dbb0b --- /dev/null +++ b/apps/web/src/lib/openPullRequestLink.browser.test.tsx @@ -0,0 +1,160 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { AsyncResult } from "effect/reactivity"; +import { expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +import { act, render, stubGlobal } from "../../test/render"; + +const state = vi.hoisted(() => ({ preview: vi.fn(), openLink: vi.fn() })); +vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.preview })); +vi.mock("../browser/useOpenLink", () => ({ useOpenLink: () => state.openLink })); +vi.mock(import("../state/environments"), async (original) => ({ + ...(await original()), + usePrimaryEnvironmentId: () => EnvironmentId.make("local"), +})); +vi.mock("../state/entities", async (original) => ({ + ...(await original()), + readProjects: () => [project], + useProjects: () => [project], + readProject: () => project, + readThreadShell: () => ({ projectId: "project", worktreePath: null }), +})); +vi.mock("../state/server", async (original) => { + const { Atom } = await import("effect/reactivity"); + return { + ...(await original()), + environmentServerConfigsAtom: Atom.make(new Map([["local", {}]])), + }; +}); + +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import { __setClientSettingsForTests } from "../hooks/useSettings"; +import { ToastProvider } from "../components/ui/toast"; +import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; +import { useRightPanelStore } from "../rightPanelStore"; +import { useDiffPanelStore } from "../diffPanelStore"; +import { useOpenChangeRequestLink } from "./openPullRequestLink"; + +const environmentId = EnvironmentId.make("local"); +const threadRef = scopeThreadRef(environmentId, ThreadId.make("thread")); +const project = { + id: "project", + environmentId, + workspaceRoot: "/repo", + repositoryIdentity: { + provider: "gitlab", + canonicalKey: "gitlab.com/effect-ts/effect", + displayName: "Effect-TS/effect", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://gitlab.com/Effect-TS/effect.git", + }, + }, +}; +// Coder: GitLab commit URLs, since only GitLab's definition is registered. +const commitUrl = `https://gitlab.com/Effect-TS/effect/-/commit/${"a".repeat(40)}?plain=1#diff-original`; + +function CommitLink({ url = commitUrl }: { url?: string }) { + const open = useOpenChangeRequestLink(threadRef); + return ( + open(event, url)}> + Commit + + ); +} + +function setup() { + state.preview.mockReset().mockResolvedValue(AsyncResult.success({ sources: [] })); + state.openLink.mockReset().mockResolvedValue(undefined); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + pullRequestLinksOpenInApp: true, + browserLinkTarget: "system", + }); + useDiffPanelStore.setState({ + byThreadKey: {}, + branchBaseRefByThreadKey: {}, + keepSelectionOnOpenThreadKey: null, + }); + useRightPanelStore.setState({ byThreadKey: {}, userActionRevisionByThreadKey: {} }); +} + +// Coder: no merge request commit case; GitLab's definition parses only `/-/commit/` URLs. +it.each([ + ["shiftKey", commitUrl], + ["altKey", commitUrl], +] as const)("keeps %s clicks on %s native", async (modifier, url) => { + setup(); + await using screen = await render( + + + , + ); + const event = new MouseEvent("click", { bubbles: true, cancelable: true, [modifier]: true }); + const defaults: boolean[] = []; + screen.container.addEventListener( + "click", + (nativeEvent) => { + defaults.push(nativeEvent.defaultPrevented); + nativeEvent.preventDefault(); + }, + { once: true }, + ); + await act(() => screen.getByRole("link", { name: "Commit" }).element().dispatchEvent(event)); + expect(defaults).toEqual([false]); + expect(state.preview).not.toHaveBeenCalled(); + expect(useRightPanelStore.getState().byThreadKey).toEqual({}); +}); + +it.each(["system", "app"] as const)( + "offers the original unavailable commit URL when web popups are blocked (%s)", + async (browserLinkTarget) => { + setup(); + // Without preview access, the "app" preference opens a popup too. + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + pullRequestLinksOpenInApp: true, + browserLinkTarget, + }); + using _desktop = stubGlobal("desktopBridge", undefined); + await using screen = await render( + + + + + , + ); + using open = vi.spyOn(window, "open").mockReturnValue(null); + await screen.getByRole("link", { name: "Commit" }).click(); + await expect + .element(page.getByRole("button", { name: "Open link", exact: true })) + .toBeVisible(); + expect(open).not.toHaveBeenCalled(); + await page.getByRole("button", { name: "Open link", exact: true }).click(); + expect(open).toHaveBeenCalledExactlyOnceWith(commitUrl, "_blank", "noopener,noreferrer"); + expect(state.openLink).not.toHaveBeenCalled(); + }, +); + +it("opens an available commit in the closed Diff pane without the pane resetting it", async () => { + setup(); + const sha = "a".repeat(40); + state.preview.mockResolvedValue( + AsyncResult.success({ sources: [{ kind: "commit", headRef: sha }] }), + ); + await using screen = await render( + + + , + ); + await screen.getByRole("link", { name: "Commit" }).click(); + await vi.waitFor(() => + expect(useDiffPanelStore.getState().byThreadKey).toEqual({ + [scopedThreadKey(threadRef)]: { kind: "commit", sha, baseRef: null }, + }), + ); + // ChatView resets a pane opened without an explicit request to Changes unless this is set. + expect(useDiffPanelStore.getState().consumeKeepSelectionOnOpen(threadRef)).toBe(true); +}); diff --git a/apps/web/src/lib/openPullRequestLink.react.test.tsx b/apps/web/src/lib/openPullRequestLink.react.test.tsx index b3cc4a97ef9a1..eb86b2d247283 100644 --- a/apps/web/src/lib/openPullRequestLink.react.test.tsx +++ b/apps/web/src/lib/openPullRequestLink.react.test.tsx @@ -23,7 +23,12 @@ vi.mock("../state/entities", () => { }, }, ]; - return { useProjects: () => projects, readProjects: () => projects }; + return { + useProjects: () => projects, + readProjects: () => projects, + readProject: () => projects[0], + readThreadShell: () => ({ projectId: "frontend", worktreePath: null }), + }; }); // The link handler reads the server configs from the atom registry when clicked. vi.mock("../state/server", async (original) => { diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 057c5b0656dcd..d1cffd59cd0a5 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -8,15 +8,24 @@ import { resolvePullRequestPreviewTarget, } from "@t3tools/client-runtime/change-request-links"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; + import { useOpenLink } from "../browser/useOpenLink"; import { useClientSettings } from "../hooks/useSettings"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; -import { useRightPanelStore } from "../rightPanelStore"; +import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; +import { + selectThreadBranchBaseRef, + selectThreadDiffPanelSelection, + useDiffPanelStore, +} from "../diffPanelStore"; import { appAtomRegistry } from "../rpc/atomRegistry"; -import { readProjects, useProjects } from "../state/entities"; +import { readProject, readProjects, readThreadShell, useProjects } from "../state/entities"; import { environmentServerConfigsAtom, serverEnvironment } from "../state/server"; import { usePrimaryEnvironmentId } from "../state/environments"; +import { reviewEnvironment } from "../state/review"; +import { useAtomQueryRunner } from "../state/use-atom-query-runner"; export function usePullRequestPreviewTarget(environmentId: EnvironmentId | null, url: string) { const projects = useProjects(); @@ -98,71 +107,168 @@ export function useOpenChangeRequestLink( event: Pick< MouseEvent, "preventDefault" | "stopPropagation" | "metaKey" | "ctrlKey" - >, + > & + Partial, "button" | "shiftKey" | "altKey">>, targetUrl: string, targetThreadRef?: ScopedThreadRef, targetEnvironmentId?: EnvironmentId, + onCommitUnavailable?: () => void, ) => boolean { const navigate = useNavigate(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const openInApp = useClientSettings((settings) => settings.pullRequestLinksOpenInApp); - return (event, targetUrl, targetThreadRef, targetEnvironmentId) => { + const ignoreWhitespace = useClientSettings((settings) => settings.diffIgnoreWhitespace); + const readDiffPreview = useAtomQueryRunner(reviewEnvironment.diffPreview, { + reportFailure: false, + refresh: true, + }); + return (event, targetUrl, targetThreadRef, targetEnvironmentId, onCommitUnavailable) => { if (!openInApp || shouldOpenPullRequestExternally(event)) return false; - // Read at click time: every provider status push replaces the config map, and - // subscribing would hand every rendered link a new handler. Projects likewise, so a - // link can resolve against a snapshot that arrived after this handler was built. - const serverConfigs = appAtomRegistry.get(environmentServerConfigsAtom); const resolvedThreadRef = targetThreadRef ?? threadRef; const resolvedPanelRef = panelRef ?? resolvedThreadRef; - const target = resolveChangeRequestLinkTarget({ - url: targetUrl, - projects: readProjects(), - configLoaded: (environmentId) => serverConfigs.has(environmentId), - environmentId: resolvedThreadRef?.environmentId ?? targetEnvironmentId, - primaryEnvironmentId, - lendHostProject: resolvedPanelRef !== undefined, - }); - if (target === null) return false; - const { project, repository, number, host } = target; - event.preventDefault(); - event.stopPropagation(); - const pullRequestsSearch = { - repository, - number, - ...(host === undefined ? {} : { selectedHost: host }), - selectedProjectId: project.id, - selectedEnvironmentId: project.environmentId, - }; - if (resolvedPanelRef) { - useRightPanelStore.getState().openPullRequest(resolvedPanelRef, { - // The standalone PR panel has a synthetic ref; each tab keeps its real environment. - ...(resolvedPanelRef.environmentId === project.environmentId - ? {} - : { environmentId: project.environmentId }), - projectId: project.id, - ...(host === undefined ? {} : { host }), - repository, + const openPullRequest = () => { + // Read at click time: every provider status push replaces the config map, and + // subscribing would hand every rendered link a new handler. Projects likewise, so a + // link can resolve against a snapshot that arrived after this handler was built. + const serverConfigs = appAtomRegistry.get(environmentServerConfigsAtom); + const target = resolveChangeRequestLinkTarget({ url: targetUrl, - number, + projects: readProjects(), + configLoaded: (environmentId) => serverConfigs.has(environmentId), + environmentId: resolvedThreadRef?.environmentId ?? targetEnvironmentId, + primaryEnvironmentId, + lendHostProject: resolvedPanelRef !== undefined, }); - if (!resolvedThreadRef) { - void navigate({ - to: "/pull-requests", - search: (previous) => ({ - ...previous, - involvement: previous.involvement ?? "all", - state: previous.state ?? "all", - ...pullRequestsSearch, - }), - replace: true, + if (target === null) return false; + const { project, repository, number, host } = target; + const pullRequestsSearch = { + repository, + number, + ...(host === undefined ? {} : { selectedHost: host }), + selectedProjectId: project.id, + selectedEnvironmentId: project.environmentId, + }; + if (resolvedPanelRef) { + useRightPanelStore.getState().openPullRequest(resolvedPanelRef, { + // The standalone PR panel has a synthetic ref; each tab keeps its real environment. + ...(resolvedPanelRef.environmentId === project.environmentId + ? {} + : { environmentId: project.environmentId }), + projectId: project.id, + ...(host === undefined ? {} : { host }), + repository, + url: targetUrl, + number, }); + if (!resolvedThreadRef) { + void navigate({ + to: "/pull-requests", + search: (previous) => ({ + ...previous, + involvement: previous.involvement ?? "all", + state: previous.state ?? "all", + ...pullRequestsSearch, + }), + replace: true, + }); + } + return true; } + void navigate({ + to: "/pull-requests", + search: { involvement: "all", state: "all", ...pullRequestsSearch }, + }); + return true; + }; + const commitThread = resolvedThreadRef ? readThreadShell(resolvedThreadRef) : null; + const commitProject = + commitThread && resolvedThreadRef + ? readProject({ + environmentId: resolvedThreadRef.environmentId, + projectId: commitThread.projectId, + }) + : null; + const identity = commitProject?.repositoryIdentity; + const parsedCommit = sourceControlClients.parseCommitUrl(targetUrl, identity?.provider); + // Shift and Alt keep their browser meaning (new window, download) on commit links. + if (parsedCommit !== null && (event.shiftKey || event.altKey)) return false; + if ( + parsedCommit !== null && + commitThread && + resolvedThreadRef && + commitProject?.environmentId === resolvedThreadRef.environmentId && + identity && + identity.provider === parsedCommit.definition.kind && + parsedCommit.definition.isChangeRequestInRepository(identity, parsedCommit.link) + ) { + const commit = parsedCommit.link; + event.preventDefault(); + event.stopPropagation(); + const userActionRevision = useRightPanelStore.getState().markUserAction(resolvedThreadRef); + const baseRef = selectThreadBranchBaseRef(useDiffPanelStore.getState(), resolvedThreadRef); + const selection = selectThreadDiffPanelSelection( + useDiffPanelStore.getState().byThreadKey, + resolvedThreadRef, + ); + void readDiffPreview({ + environmentId: resolvedThreadRef.environmentId, + input: { + cwd: commitThread.worktreePath ?? commitProject.workspaceRoot, + ...(baseRef ? { baseRef } : {}), + commitSha: commit.sha, + ignoreWhitespace, + }, + }) + .then((result) => { + if ( + useRightPanelStore.getState().getUserActionRevision(resolvedThreadRef) !== + userActionRevision || + selectThreadDiffPanelSelection( + useDiffPanelStore.getState().byThreadKey, + resolvedThreadRef, + ) !== selection + ) + return; + if ( + result._tag === "Success" && + result.value.sources.some( + (source) => source.kind === "commit" && source.headRef === commit.sha, + ) + ) { + const diffOpen = + selectActiveRightPanel( + useRightPanelStore.getState().byThreadKey, + resolvedThreadRef, + ) === "diff"; + useDiffPanelStore.getState().selectCommit(resolvedThreadRef, commit.sha, !diffOpen); + useRightPanelStore.getState().open(resolvedThreadRef, "diff"); + return; + } + if (openPullRequest()) return; + if (onCommitUnavailable) return onCommitUnavailable(); + // A popup opened after the lookup has lost the click's activation, so browsers block it. + // Coder: links only open in the system browser (no desktop app or in-app browser), so + // this toast is always the fallback. + const toastId = toastManager.add( + stackedThreadToast({ + type: "info", + title: "This commit is not in the thread's workspace", + actionProps: { + children: "Open link", + onClick: () => { + toastManager.close(toastId); + window.open(targetUrl, "_blank", "noopener,noreferrer"); + }, + }, + }), + ); + }) + .catch((error: unknown) => console.error(error)); return true; } - void navigate({ - to: "/pull-requests", - search: { involvement: "all", state: "all", ...pullRequestsSearch }, - }); + if (!openPullRequest()) return false; + event.preventDefault(); + event.stopPropagation(); return true; }; } diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 33c537a3cf942..ea043c0298bdb 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -109,6 +109,7 @@ interface RightPanelStoreState { userActionRevisionByThreadKey: Record; closeRevisionByThreadKey: Record; getUserActionRevision: (ref: ScopedThreadRef) => number; + markUserAction: (ref: ScopedThreadRef) => number; /** Refused if the user chose a panel after expectedUserActionRevision was read. */ openProactive: ( ref: ScopedThreadRef, @@ -598,6 +599,16 @@ export const useRightPanelStore = create()( closeRevisionByThreadKey: {}, getUserActionRevision: (ref) => get().userActionRevisionByThreadKey[scopedThreadKey(ref)] ?? 0, + markUserAction: (ref) => { + const revision = get().getUserActionRevision(ref) + 1; + set((state) => ({ + userActionRevisionByThreadKey: { + ...state.userActionRevisionByThreadKey, + [scopedThreadKey(ref)]: revision, + }, + })); + return revision; + }, openProactive: (ref, request, expectedUserActionRevision, activateExisting = false) => { let opened = false; set((state) => { diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 4fd2e3f59a029..c85e4e6f066a4 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -66,6 +66,9 @@ const EMPTY_VISIBLE_TURN_ITEMS_ATOM = Atom.make(EMPTY_VISIBLE_TURN_ITEMS).pipe( const EMPTY_CHECKPOINT_SUMMARIES_ATOM = Atom.make>( Object.freeze([]), ).pipe(Atom.withLabel("web-thread-checkpoint-summaries:empty")); +const EMPTY_ASSISTANT_MESSAGE_TEXT_ATOM = Atom.make(null).pipe( + Atom.withLabel("web-assistant-message-text:empty"), +); const EMPTY_LAST_VISITED_AT_ATOM = Atom.make(null).pipe( Atom.withLabel("web-thread-last-visited:empty"), ); @@ -220,6 +223,29 @@ export function useThreadVisibleTurnItems( ); } +/** Visible turn items held through streamed assistant text; see useAssistantMessageText. */ +export function useTimelineVisibleTurnItems( + ref: ScopedThreadRef | null, +): ReadonlyArray { + return useAtomValue( + ref === null + ? EMPTY_VISIBLE_TURN_ITEMS_ATOM + : environmentThreadDetails.timelineVisibleTurnItemsAtom(ref), + ); +} + +/** One assistant message's live text, or null when the thread does not hold the item. */ +export function useAssistantMessageText( + ref: ScopedThreadRef | null, + itemId: string | undefined, +): string | null { + return useAtomValue( + ref === null || itemId === undefined + ? EMPTY_ASSISTANT_MESSAGE_TEXT_ATOM + : environmentThreadDetails.assistantMessageTextAtom(ref, itemId), + ); +} + /** Visible turn items without their streamed text; see ThreadStructure. */ export function useQuietThreadVisibleTurnItems( ref: ScopedThreadRef | null, diff --git a/docs/internals/coder-only.md b/docs/internals/coder-only.md index b162f94c58f50..bbdc0cacef7cd 100644 --- a/docs/internals/coder-only.md +++ b/docs/internals/coder-only.md @@ -586,7 +586,21 @@ listed here is drift to remove rather than fork behavior to keep. `settings/SecretsSettings.tsx`, `SecretProviderDialog*`, the `/settings/secrets` route, its nav and search entries, the `T3CODE_FEATURE_SECRETS` flag, and `OnePasswordIcon` are cut. Contracts, shared `serverSettings.ts`, and the server's `secretProviders` write check and - `secretProviderSettings` capability stay as upstream's. + `secretProviderSettings` capability stay as upstream's. Provider environment variables that + point at a secret provider (#27115) are cut the same way: contracts keep upstream's + `SecretReference` value, `ResolvedProviderInstanceEnvironment`, and `reloadSecrets`, and the + server keeps upstream's registry code around a stand-in resolver, + `secrets/coderSecretResolver.ts`, in place of `@t3tools/secret-provider-core` and the + server's `secrets/SecretResolver.ts` and `secretProviderDefinitions.ts`. It reads no + reference, so a variable that points at one makes its instance unavailable with upstream's + "Could not read …" reason, and it lists no kind, so `serverSettings.ts` rejects every + `secretProviders` write as an unsupported kind and keeps every stored config from clients + (`serverSettings.test.ts` expects 1Password's config withheld too). + `ProviderInstanceRegistry.ts`, its test, `ProviderInstanceRegistryHydration.ts` (which + also needs no `Crypto`), and `serverSettings.ts` import the stand-in; `ws.ts` does not + advertise `secretReferences`. Web `settings/SecretSourceInput.tsx` and its browser test are + cut; `ProviderInstanceCard.tsx`'s environment field rows keep the plain value and + `SecretInput`, showing a stored secret reference as no value. - The thread dev-servers panel (#22542) is not carried: it lists and stops servers that upstream's preview `PortScanner` discovers, and the browser preview is not carried. `ClaudeAdapterV2.ts`'s query runner gives the CLI's jobs no agent-thread variable and counts @@ -602,7 +616,10 @@ listed here is drift to remove rather than fork behavior to keep. (`PullRequestsColumn.tsx`). `ui/standalone-page.tsx`'s wordmark (#25281) reads "T3 Coder". `QueuedRunsControl.tsx` (#26159) says "Sent by T3 Coder" and shows no guest name or border. The replay provider (#26207) has no `DevReplayHost.tsx`, `__root.tsx` mount, or `window.t3dev` - type. + type. `SidebarChrome.tsx`'s logo link is named "T3 Coder home" (#24209). + - Desktop back/forward buttons and swipe navigation (#27430) are not carried: + `HistorySwipeNavigation.tsx`, `navigationHistory.ts`, and their `AppSidebarLayout.tsx` and + `SidebarChrome.tsx` hunks are cut. - `ws.ts` strips workspace snapshots from provider config when a client asks for them on demand (`workspaceSnapshotsOnDemand`) without upstream's usage-limit commands, and `coderEnvironment.ts` advertises `providerWorkspaceSnapshotsOnDemand`. @@ -1110,7 +1127,10 @@ listed here is drift to remove rather than fork behavior to keep. definition (`referenceAutolinks`), which the web's upstream `pullRequestMarkdown.logic.ts` calls. Actor avatars load as on main. MR links everywhere (Markdown, the sidebar, composer and timeline chips, and the thread MR panel) open through - upstream's `lib/openPullRequestLink.ts`. The GitLab-only registry claims only GitLab's + upstream's `lib/openPullRequestLink.ts`, which always offers upstream's "Open link" toast when a + commit link (#19941) names a commit outside the thread's workspace, since links open only in the + system browser; its browser test uses GitLab `/-/commit/` URLs and drops the merge request + commit-URL case, which GitLab's definition does not parse. The GitLab-only registry claims only GitLab's `/-/merge_requests/` URLs, and project matching (client-runtime `changeRequestLinks.ts`'s `isGitLabProject`, also used by `findProjectOnChangeRequestHost`, which #24738 moved there from the web's `openPullRequestLink.ts`; the web keeps only `gitLabMergeRequestBrowserUrl`) uses upstream's host @@ -1223,11 +1243,14 @@ listed here is drift to remove rather than fork behavior to keep. `vcs/VcsStatusBroadcaster.ts`, `git/GitManager.ts`, `git/GitWorkflowService.ts`, and `git/remoteRefs.ts` are upstream's. Each difference is marked `Coder:` in the file: - The driver core has no Git metrics or span events. It adds `moveWorktree`, which - renames a thread's worktree folder together with its branch, and `refStatusLocal`, a + renames a thread's worktree folder together with its branch (bounded by the 5-minute + worktree remove deadline, since upstream dropped the add deadline in #23802), and + `refStatusLocal`, a branch read that never refreshes the real index. Review file expansion allows up to `MAX_REVIEW_DIFF_FILE_BYTES` and fails with `ReviewDiffFileTooLargeError` for the chunked review RPCs, rejecting malformed UTF-8; `GitVcsDriverCore.test.ts`'s split-output test - expects that field, with a branded exit code so the comparison typechecks. A top-level review `sourceKind` returns only that + expects that field, with a branded exit code so the comparison typechecks, and its commit + review test (#19941) reads the error as a `GitCommandError`. A top-level review `sourceKind` returns only that source. Worktree creation reads `t3.json` through the bounded `readProjectConfig`. Checkpoint commits use the "T3 Coder" identity. - `VcsProcess` records no Git command metrics. Its concurrency keys and failure @@ -1443,6 +1466,10 @@ listed here is drift to remove rather than fork behavior to keep. upstream's new publish-push, local-remote, display-casing, partial-clone, and missing-parent clone cases on GitLab remotes with the fork's mocks. `ProviderRegistry.test.ts` runs upstream's startup settings-change hydration tests without the uncarried Antigravity and OpenCode layers. + `PullRequestService.test.ts`'s shared merge request read tests (#24392) pass branded GitLab + kinds to the fork's typed project helper. Shared `AgentScope.ts` leaves out upstream's + `agentScopeLauncherScript` (#27239), which only the uncarried preview browser launches, and + `AgentScope.test.ts` drops its launcher check. `ThreadDetailsPrRows.browser.test.tsx` checks the run context, not the editor picker. `CommandPalette.browser.test.tsx` mocks `useCoder`, and `GitLabPullRequestCli.legacy.test.ts` expects the fork's `DIFF_MAX_OUTPUT_BYTES`. `SourceControlProviderRegistry.test.ts` is upstream's fake-driver file, with its unknown-host diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 810bf25bc8f9a..262c31b14f2a3 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -42,6 +42,16 @@ against your base branch. Restore still returns the complete saved workspace. You can comment on diff lines. Comments annotate the review and can be sent back to Claude as part of your next message, which makes "fix this spot" conversations precise. +Open the Diff pane source picker and choose **Commits** to review one local commit at a time. The +list shows the newest 100 commits in the branch comparison. A merge commit shows its changes against +its first parent. + +A commit link from GitLab opens in the Diff pane when the thread's repository has that commit +locally, on any branch. Otherwise a merge request commit link opens the merge request. Other commit +links follow your **Open links in** setting; when that opens a new tab, T3 Coder first shows an +**Open link** button. Command/Ctrl+Click always opens the link in your browser. Commit links follow +the **Open merge requests in T3 Coder** setting in **Settings → Diffs**. + ## Automatic project pull Turn on **Automatically pull** in [Project settings](./project-settings.md) to keep a project's diff --git a/packages/client-runtime/src/changeRequestLinks.ts b/packages/client-runtime/src/changeRequestLinks.ts index 59ecf952c7b9d..47c4bf3820da1 100644 --- a/packages/client-runtime/src/changeRequestLinks.ts +++ b/packages/client-runtime/src/changeRequestLinks.ts @@ -161,3 +161,21 @@ export function projectChangeRequestUrl( ...(ownRepository && identity.webUrl !== undefined ? { webUrl: identity.webUrl } : {}), }); } + +export function projectCommitUrl( + identity: RepositoryIdentity | null | undefined, + sha: string, +): string | null { + if (identity?.provider === undefined) return null; + const [host, ...path] = identity.canonicalKey.split("/"); + if (!host) return null; + return ( + sourceControlClients.get(identity.provider).commitUrl?.({ + host, + repository: path.join("/"), + sha, + remoteUrl: identity.locator.remoteUrl.trim(), + ...(identity.webUrl !== undefined ? { webUrl: identity.webUrl } : {}), + }) ?? null + ); +} diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts index 35f67f7d7a853..fee99df172608 100644 --- a/packages/client-runtime/src/state/orchestration.ts +++ b/packages/client-runtime/src/state/orchestration.ts @@ -79,6 +79,10 @@ export function createOrchestrationEnvironmentAtoms( label: "environment-data:orchestration:stop-workflow", tag: ORCHESTRATION_V2_WS_METHODS.stopWorkflow, }), + forceStopThread: createEnvironmentRpcCommand(runtime, { + label: "environment-data:orchestration:force-stop-thread", + tag: ORCHESTRATION_V2_WS_METHODS.forceStopThread, + }), turnItem: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:orchestration:turn-item", tag: ORCHESTRATION_V2_WS_METHODS.getTurnItem, diff --git a/packages/client-runtime/src/state/review.ts b/packages/client-runtime/src/state/review.ts index 0a0b3e34f804e..b7b99b9b2ae3f 100644 --- a/packages/client-runtime/src/state/review.ts +++ b/packages/client-runtime/src/state/review.ts @@ -51,6 +51,11 @@ export function createReviewEnvironmentAtoms( }); }); return { + branchCommits: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:review:branch-commits", + tag: WS_METHODS.reviewGetBranchCommits, + staleTimeMs: 5_000, + }), diffPreview, diffFilePatch: createEnvironmentQueryAtomFamily(runtime, { label: "environment-data:review:diff-file-patch", diff --git a/packages/client-runtime/src/state/threadDetail.test.ts b/packages/client-runtime/src/state/threadDetail.test.ts index 8fc01839183fe..3e66202d0bb4b 100644 --- a/packages/client-runtime/src/state/threadDetail.test.ts +++ b/packages/client-runtime/src/state/threadDetail.test.ts @@ -197,6 +197,42 @@ describe("quietThreadAtom", () => { }); }); +describe("timelineVisibleTurnItemsAtom", () => { + const ref = { environmentId: EnvironmentId.make("environment-1"), threadId: v2ThreadId }; + + it("holds through streamed assistant text while the message reads its own text", () => { + const stateAtom = Atom.make( + AsyncResult.success({ + ...EMPTY_ENVIRONMENT_THREAD_STATE, + data: Option.some(withItem(assistant)), + }), + ); + const details = createEnvironmentThreadDetailAtoms(() => stateAtom); + const timelineAtom = details.timelineVisibleTurnItemsAtom(ref); + const textAtom = details.assistantMessageTextAtom(ref, assistant.id); + const registry = AtomRegistry.make(); + registry.mount(timelineAtom); + registry.mount(textAtom); + const push = (item: OrchestrationV2TurnItem) => + registry.set( + stateAtom, + AsyncResult.success({ + ...EMPTY_ENVIRONMENT_THREAD_STATE, + data: Option.some(withItem(item, later)), + }), + ); + const first = registry.get(timelineAtom); + + push({ ...assistant, text: "Hello", updatedAt: later }); + expect(registry.get(timelineAtom)).toBe(first); + expect(registry.get(textAtom)).toBe("Hello"); + + push({ ...assistant, text: "Hello", streaming: false, status: "completed" }); + expect(registry.get(timelineAtom)[0]?.item).toMatchObject({ text: "Hello", streaming: false }); + registry.dispose(); + }); +}); + it("only compares replaced entries in a streamed reducer delta", () => { const size = 100; let slotReads = 0; diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index e1f30998caefb..eeeda6a2f348f 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -14,6 +14,7 @@ import { type ThreadQueueWorkflowState, } from "./threadWorkflows.ts"; import { deriveThreadTurnSubagents, type ThreadTurnSubagents } from "./threadSubagents.ts"; +import { presentRunStop, type RunStopPresentation } from "./threadExecution.ts"; import { deriveThreadCheckpointSummaries, type ThreadCheckpointSummary, @@ -147,6 +148,26 @@ function sameVisibleTurnItemStructure(before: VisibleTurnItem, after: VisibleTur ); } +function isStreamingAssistantMessage(visibleItem: VisibleTurnItem): boolean { + return visibleItem.item.type === "assistant_message" && visibleItem.item.streaming; +} + +/** Whether `next` only streams more text into assistant messages that were already streaming. */ +function sameApartFromStreamedAssistantText( + previous: OrchestrationV2ThreadProjection["visibleTurnItems"], + next: OrchestrationV2ThreadProjection["visibleTurnItems"], +): boolean { + return sameProjectionArray( + previous, + next, + (before, after) => + before === after || + (isStreamingAssistantMessage(before) && + isStreamingAssistantMessage(after) && + sameVisibleTurnItemStructure(before, after)), + ); +} + /** * Whether `previous` and `next` have the same ThreadStructure: they differ * only by text streamed into existing assistant and reasoning items or by a @@ -266,6 +287,32 @@ export function createEnvironmentThreadDetailAtoms( ); }); + // Holds while only streamed assistant text moves, so the timeline re-renders on structure; + // each streaming message reads its text through assistantMessageTextAtom. + const timelineVisibleTurnItemsAtomFamily = Atom.family((key: string) => { + let previous = EMPTY_VISIBLE_TURN_ITEMS; + return Atom.make((get) => { + const next = get(visibleTurnItemsAtomFamily(key)); + if (!sameApartFromStreamedAssistantText(previous, next)) previous = next; + return previous; + }).pipe( + Atom.setIdleTTL(0), + Atom.withLabel(`environment-thread-timeline-visible-turn-items:${key}`), + ); + }); + + const assistantMessageTextAtomFamily = Atom.family((key: string) => { + const separator = key.lastIndexOf("\u0000"); + const thread = key.slice(0, separator); + const itemId = key.slice(separator + 1); + return Atom.make((get) => { + const item = get(visibleTurnItemsAtomFamily(thread)).find( + (visibleItem) => visibleItem.item.id === itemId, + )?.item; + return item?.type === "assistant_message" ? item.text : null; + }).pipe(Atom.setIdleTTL(0), Atom.withLabel(`environment-thread-assistant-message-text:${key}`)); + }); + const queueWorkflowAtomFamily = Atom.family((key: string) => { let previous: Pick< OrchestrationV2ThreadProjection, @@ -294,6 +341,32 @@ export function createEnvironmentThreadDetailAtoms( return value; }).pipe(Atom.setIdleTTL(0), Atom.withLabel(`environment-thread-queue:${key}`)); }); + const runStopAtomFamily = Atom.family((key: string) => { + let previous: Pick | null = null; + let stop: RunStopPresentation | null = null; + return Atom.make((get) => { + const projection = Option.getOrNull(get(threadStateValueAtomFamily(key)).data); + if (projection === null) { + previous = null; + stop = null; + } else if ( + projection.runs !== previous?.runs || + projection.turnItems !== previous?.turnItems + ) { + const { runs, turnItems } = projection; + previous = { runs, turnItems }; + const next = presentRunStop(previous); + if ( + next?.state !== stop?.state || + next?.runId !== stop?.runId || + next?.requestedAt !== stop?.requestedAt || + next?.background !== stop?.background + ) + stop = next; + } + return stop; + }).pipe(Atom.setIdleTTL(0), Atom.withLabel(`environment-thread-run-stop:${key}`)); + }); const queuedCountAtomFamily = Atom.family((key: string) => { let previous: Pick | null = null; let count = 0; @@ -435,6 +508,7 @@ export function createEnvironmentThreadDetailAtoms( pendingRequestsAtom: (ref: ScopedThreadRef) => pendingRequestsAtomFamily(threadKey(ref)), queueWorkflowAtom: (ref: ScopedThreadRef) => queueWorkflowAtomFamily(threadKey(ref)), queuedCountAtom: (ref: ScopedThreadRef) => queuedCountAtomFamily(threadKey(ref)), + runStopAtom: (ref: ScopedThreadRef) => runStopAtomFamily(threadKey(ref)), turnSubagentsAtom: (ref: ScopedThreadRef) => turnSubagentsAtomFamily(threadKey(ref)), checkpointSummariesAtom: (ref: ScopedThreadRef) => checkpointSummariesAtomFamily(threadKey(ref)), @@ -444,6 +518,10 @@ export function createEnvironmentThreadDetailAtoms( visibleTurnItemsAtom: (ref: ScopedThreadRef) => visibleTurnItemsAtomFamily(threadKey(ref)), quietVisibleTurnItemsAtom: (ref: ScopedThreadRef) => quietVisibleTurnItemsAtomFamily(threadKey(ref)), + timelineVisibleTurnItemsAtom: (ref: ScopedThreadRef) => + timelineVisibleTurnItemsAtomFamily(threadKey(ref)), + assistantMessageTextAtom: (ref: ScopedThreadRef, itemId: string) => + assistantMessageTextAtomFamily(`${threadKey(ref)}\u0000${itemId}`), statusAtom: (ref: ScopedThreadRef) => statusAtomFamily(threadKey(ref)), sequenceAtom: (ref: ScopedThreadRef) => sequenceAtomFamily(threadKey(ref)), errorAtom: (ref: ScopedThreadRef) => errorAtomFamily(threadKey(ref)), diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index 8ef37f959cd40..3f8ef2a7f9d31 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -25,6 +25,7 @@ import { deriveRunlessWorkStartedAt, deriveThreadActivityRun, deriveThreadRuntime, + presentRunStop, threadRuntimeHasInterruptibleRun, } from "./threadExecution.ts"; @@ -259,6 +260,74 @@ describe("thread execution presentation", () => { }); }); +describe("presentRunStop", () => { + const signal = (type: "run_interrupt_request" | "run_interrupt_result", runId: RunId) => ({ + id: TurnItemId.make(`${type}:${runId}`), + threadId: v2Projection.thread.id, + runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: type === "run_interrupt_result" ? ("interrupted" as const) : ("completed" as const), + title: "Interrupt", + startedAt: now, + completedAt: now, + updatedAt: now, + type, + message: "Stopped by user", + }); + const running = run("run-stopping", 1, "running"); + const request = signal("run_interrupt_request", running.id); + + it.each([ + { name: "no Stop", runs: [running], turnItems: [], state: null }, + { + name: "a Stop the agent is winding down", + runs: [running], + turnItems: [request], + state: "stopping", + }, + { + name: "a Stop the provider never confirmed", + runs: [running], + turnItems: [{ ...request, unresponsiveAt: now }], + state: "unresponsive", + }, + { + name: "a Stop that ended the run", + runs: [running], + turnItems: [request, signal("run_interrupt_result", running.id)], + state: null, + }, + { + name: "a settled run's background work still stopping", + runs: [{ ...running, status: "completed" as const, completedAt: now }], + turnItems: [request], + state: null, + }, + { + name: "a settled run whose background work ignored Stop", + runs: [{ ...running, status: "completed" as const, completedAt: now }], + turnItems: [{ ...request, unresponsiveAt: now }], + state: "unresponsive", + }, + { + name: "a settled run whose background work ignored Stop, with a queued follow-up", + runs: [ + { ...running, status: "completed" as const, completedAt: now }, + run("run-queued", 2, "queued"), + ], + turnItems: [{ ...request, unresponsiveAt: now }], + state: "unresponsive", + }, + ])("presents $name", ({ runs, turnItems, state }) => { + expect(presentRunStop({ runs, turnItems })?.state ?? null).toBe(state); + }); +}); + describe("deriveRunlessWorkStartedAt", () => { const later = DateTime.makeUnsafe("2026-07-28T10:05:00.000Z"); const rootTurn = ( diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index ba87930bebbd5..52d07babfbb61 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -294,6 +294,55 @@ const BACKGROUND_WORK_KINDS: Record< background_task: { order: 3, singular: "background task", plural: "background tasks" }, }; +export interface RunStopPresentation { + /** `unresponsive`: the provider did not confirm the stop in time, so Force stop is offered. */ + readonly state: "stopping" | "unresponsive"; + readonly runId: ThreadStructure["runs"][number]["id"]; + readonly requestedAt: string; + /** The run already ended; the Stop targeted its background work. */ + readonly background: boolean; +} + +/** + * A Stop the provider has not finished yet. It stays `stopping` while the agent winds down, + * since some providers confirm the request before the turn actually ends. On a run that + * already ended, only an unanswered Stop of its background work counts. + */ +export function presentRunStop( + projection: Pick, +): RunStopPresentation | null { + let active: ThreadStructure["runs"][number] | null = null; + let latest: ThreadStructure["runs"][number] | null = null; + for (const candidate of projection.runs) { + // Queued follow-ups never ran, so they hold no Stop of their own. + if (candidate.status !== "queued" && candidate.ordinal > (latest?.ordinal ?? -1)) + latest = candidate; + if ( + INTERRUPTIBLE_RUN_STATUSES.has(candidate.status) && + candidate.ordinal > (active?.ordinal ?? -1) + ) + active = candidate; + } + const run = active ?? latest; + if (run === null) return null; + let request: + | Extract + | undefined; + for (const item of projection.turnItems) { + if (item.runId !== run.id) continue; + if (item.type === "run_interrupt_result") return null; + if (item.type === "run_interrupt_request") request = item; + } + if (request === undefined || (active === null && request.unresponsiveAt === undefined)) + return null; + return { + state: request.unresponsiveAt === undefined ? "stopping" : "unresponsive", + runId: run.id, + requestedAt: DateTime.formatIso(request.startedAt ?? request.updatedAt), + background: active === null, + }; +} + export interface PendingBackgroundWorkItem { readonly taskId: string; readonly kind: BackgroundWorkKind; diff --git a/packages/client-runtime/src/userMessage.test.ts b/packages/client-runtime/src/userMessage.test.ts index 0447dc54d1b26..207939cf2f39d 100644 --- a/packages/client-runtime/src/userMessage.test.ts +++ b/packages/client-runtime/src/userMessage.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { GuestMessageId } from "@t3tools/contracts"; +import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; import { resolveUserMessagePresentation } from "./userMessage.ts"; @@ -21,6 +22,19 @@ describe("resolveUserMessagePresentation", () => { } }); + it("hides the prompt that stands in for an attachment-only message", () => { + for (const text of [ + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + `Ultrathink:\n${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT}`, + ]) { + expect(resolveUserMessagePresentation({ role: "user", text })).toEqual({ + text: "", + attribution: null, + scheduledTaskId: undefined, + }); + } + }); + it("shows a guest's own words and name, not the attributed text the agent reads", () => { expect( resolveUserMessagePresentation({ diff --git a/packages/client-runtime/src/userMessage.ts b/packages/client-runtime/src/userMessage.ts index d5d7c5ac08956..8a80f31b155d0 100644 --- a/packages/client-runtime/src/userMessage.ts +++ b/packages/client-runtime/src/userMessage.ts @@ -5,6 +5,7 @@ import { type OrchestrationV2IdleCompaction, ScheduledTaskId, } from "@t3tools/contracts"; +import { isAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences"; /** The working row while T3 Code's own compaction runs. */ export const IDLE_COMPACTING_LABELS = { @@ -68,7 +69,8 @@ export function resolveUserMessagePresentation(message: { }; } return { - text: message.text, + // The agent needs words to start a turn; people sent only the attachments. + text: isAttachmentOnlyBootstrapPrompt(message.text) ? "" : message.text, attribution: message.createdBy !== "agent" ? null diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index f9564b2e2441c..d3139f2eeff24 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -60,6 +60,8 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.scheduledTasksRunNow]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksRotateWebhookToken]: AuthOrchestrationOperateScope, [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope, + // Force stop kills agent processes, as Stop on a terminal does. + [ORCHESTRATION_V2_WS_METHODS.forceStopThread]: AuthOrchestrationOperateScope, // A guest link hands thread access to someone outside, like a pairing link. [WS_METHODS.guestShareCreate]: AuthAccessWriteScope, [WS_METHODS.peerLinksLink]: AuthAccessWriteScope, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 75a546bed875f..a10c6c2a6bce7 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -99,6 +99,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Hide the switch when absent; older servers silently drop per-project provider patches. */ projectProviderInstanceOverrides: Schema.optionalKey(Schema.Boolean), threadSnooze: Schema.optionalKey(Schema.Boolean), + /** Serves `orchestration.forceStopThread`; hide Force stop where it is absent. */ + threadForceStop: Schema.optionalKey(Schema.Boolean), /** Every protocol-2 server sends this; older mobile builds still read it. */ environmentThemes: Schema.optionalKey(Schema.Boolean), usageLimitSources: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/guestShare.ts b/packages/contracts/src/guestShare.ts index fd991fe1e4c3b..715a837daeae3 100644 --- a/packages/contracts/src/guestShare.ts +++ b/packages/contracts/src/guestShare.ts @@ -129,6 +129,11 @@ export const guestSessionCookieName = (shareId: string) => `t3_guest_${shareId}` export const GUEST_RENDER_ROUTE = `${GUEST_ROUTE_PREFIX}/render`; /** One item of the shared thread, as an expanded row loads it: `?share=&item=`. */ export const GUEST_ITEM_ROUTE = `${GUEST_ROUTE_PREFIX}/item`; +/** + * Older turns of the shared thread, as the owner's "Load earlier turns" pages them: + * `?share=&cursor=`. + */ +export const GUEST_HISTORY_ROUTE = `${GUEST_ROUTE_PREFIX}/history`; /** Server-sent output frames of one command in the shared thread: `?share=&item=`. */ export const GUEST_COMMAND_OUTPUT_ROUTE = `${GUEST_ROUTE_PREFIX}/command-output`; /** diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 4cd959b031659..e4818a03ff236 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -59,4 +59,5 @@ export * from "./worktreeSetup.ts"; export * from "./coderAssets.ts"; export type * from "./coderGateway.ts"; export * from "./secretRequest.ts"; +export * from "./secretReference.ts"; export * from "./clientRpcPermissions.ts"; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index bf321de74d191..a84fe882245a1 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1686,6 +1686,8 @@ export const OrchestrationV2TurnItem = Schema.Union([ ...OrchestrationV2TurnItemBaseFields, type: Schema.Literal("run_interrupt_request"), message: Schema.String, + /** Set when the provider did not confirm the stop in time; clients then offer Force stop. */ + unresponsiveAt: Schema.optionalKey(Schema.DateTimeUtc), }), Schema.Struct({ ...OrchestrationV2TurnItemBaseFields, @@ -2510,6 +2512,7 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ ...OrchestrationV2TurnItemJsonBaseFields, type: Schema.Literal("run_interrupt_request"), message: Schema.String, + unresponsiveAt: Schema.optionalKey(Schema.DateTimeUtcFromString), }), Schema.Struct({ ...OrchestrationV2TurnItemJsonBaseFields, @@ -3446,12 +3449,22 @@ const OrchestrationV2InternalCommand = Schema.Union([ threadId: ThreadId, requestId: RuntimeRequestId, }), + /** The provider did not confirm a Stop of this turn in time. */ + Schema.Struct({ + type: Schema.Literal("thread.interrupt.unresponsive"), + commandId: CommandId, + threadId: ThreadId, + providerThreadId: ProviderThreadId, + providerTurnId: ProviderTurnId, + }), Schema.Struct({ type: Schema.Literal("thread.background-work.settle"), commandId: CommandId, threadId: ThreadId, providerThreadId: ProviderThreadId, providerTurnId: ProviderTurnId, + /** Replaces the Stop's own reason on the result, as Force stop does. */ + reason: Schema.optional(Schema.String), subagent: Schema.optional( Schema.Struct({ id: NodeId, @@ -3531,6 +3544,7 @@ export const ORCHESTRATION_V2_WS_METHODS = { getThreadProjection: "orchestration.getThreadProjection", getWorkflowScript: "orchestration.getWorkflowScript", stopWorkflow: "orchestration.stopWorkflow", + forceStopThread: "orchestration.forceStopThread", getTurnItem: "orchestration.getTurnItem", launchThread: "orchestration.launchThread", subscribeShell: "orchestration.subscribeShell", @@ -3918,6 +3932,41 @@ export class OrchestrationV2StopWorkflowError extends Schema.TaggedError()( + "OrchestrationV2ForceStopThreadError", + { + threadId: ThreadId, + /** `superseded`: a newer run started since the user asked; nothing was stopped. */ + reason: Schema.optional(Schema.Literal("superseded")), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.reason === "superseded" + ? "A new turn started, so nothing was force stopped." + : "Failed to force stop the agent."; + } +} + export const OrchestrationV2GetWorkflowScriptInput = Schema.Struct({ threadId: ThreadId, scriptPath: TrimmedNonEmptyString, @@ -4104,6 +4153,10 @@ export const OrchestrationV2RpcSchemas = { input: OrchestrationV2StopWorkflowInput, output: Schema.Void, }, + forceStopThread: { + input: OrchestrationV2ForceStopThreadInput, + output: OrchestrationV2ForceStopThreadResult, + }, getWorkflowScript: { input: OrchestrationV2GetWorkflowScriptInput, output: OrchestrationV2GetWorkflowScriptResult, diff --git a/packages/contracts/src/providerInstance.ts b/packages/contracts/src/providerInstance.ts index 9cc0a3f52375d..61fee930c3193 100644 --- a/packages/contracts/src/providerInstance.ts +++ b/packages/contracts/src/providerInstance.ts @@ -1,6 +1,8 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import * as SchemaTransformation from "effect/SchemaTransformation"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { SecretReference } from "./secretReference.ts"; const PROVIDER_SLUG_MAX_CHARS = 64; const PROVIDER_SLUG_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; @@ -27,17 +29,48 @@ export const ProviderInstanceEnvironmentVariableName = TrimmedNonEmptyString.che export type ProviderInstanceEnvironmentVariableName = typeof ProviderInstanceEnvironmentVariableName.Type; -export const ProviderInstanceEnvironmentVariable = Schema.Struct({ +const ProviderInstanceEnvironmentVariableValue = Schema.Struct({ + name: ProviderInstanceEnvironmentVariableName, + value: Schema.Union([Schema.String, SecretReference]), + sensitive: Schema.Boolean, + valueRedacted: Schema.optionalKey(Schema.Boolean), +}); + +// A reference is encoded beside an empty value so older servers and clients still decode it. +const StoredProviderInstanceEnvironmentVariable = Schema.Struct({ name: ProviderInstanceEnvironmentVariableName, value: Schema.String.pipe(Schema.withDecodingDefault(Effect.succeed(""))), sensitive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), valueRedacted: Schema.optionalKey(Schema.Boolean), + secret: Schema.optionalKey(SecretReference), }); + +export const ProviderInstanceEnvironmentVariable = StoredProviderInstanceEnvironmentVariable.pipe( + Schema.decodeTo( + Schema.toType(ProviderInstanceEnvironmentVariableValue), + SchemaTransformation.transform< + typeof ProviderInstanceEnvironmentVariableValue.Type, + typeof StoredProviderInstanceEnvironmentVariable.Type + >({ + decode: ({ secret, ...variable }) => + secret === undefined ? variable : { ...variable, value: secret }, + encode: ({ value, ...variable }) => + typeof value === "string" + ? { ...variable, value } + : { ...variable, value: "", secret: value }, + }), + ), +); export type ProviderInstanceEnvironmentVariable = typeof ProviderInstanceEnvironmentVariable.Type; export const ProviderInstanceEnvironment = Schema.Array(ProviderInstanceEnvironmentVariable); export type ProviderInstanceEnvironment = typeof ProviderInstanceEnvironment.Type; +/** What drivers receive: every secret reference has been read into a plain value. */ +export type ResolvedProviderInstanceEnvironment = ReadonlyArray< + Omit & { readonly value: string } +>; + /** Drivers decode their own config; unknown-driver envelopes round-trip verbatim across versions. */ export const ProviderInstanceConfig = Schema.Struct({ driver: ProviderDriverKind, diff --git a/packages/contracts/src/review.ts b/packages/contracts/src/review.ts index 2e5a28f36f65a..1948bc122d293 100644 --- a/packages/contracts/src/review.ts +++ b/packages/contracts/src/review.ts @@ -3,12 +3,17 @@ import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchema import { GitCommandError } from "./git.ts"; import { VcsError } from "./vcs.ts"; -export const ReviewDiffPreviewSourceKind = Schema.Literals(["working-tree", "branch-range"]); +export const ReviewDiffPreviewSourceKind = Schema.Literals([ + "working-tree", + "branch-range", + "commit", +]); export type ReviewDiffPreviewSourceKind = typeof ReviewDiffPreviewSourceKind.Type; export const ReviewDiffPreviewInput = Schema.Struct({ cwd: TrimmedNonEmptyString, baseRef: Schema.optional(TrimmedNonEmptyString), + commitSha: Schema.optional(TrimmedNonEmptyString), ignoreWhitespace: Schema.optionalKey(Schema.Boolean), // Coder: a branch preview may ask for one top-level source. sourceKind: Schema.optionalKey(ReviewDiffPreviewSourceKind), @@ -22,6 +27,24 @@ export const ReviewDiffPreviewInput = Schema.Struct({ }); export type ReviewDiffPreviewInput = typeof ReviewDiffPreviewInput.Type; +export const ReviewBranchCommitsInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + baseRef: Schema.optional(TrimmedNonEmptyString), +}); +export type ReviewBranchCommitsInput = typeof ReviewBranchCommitsInput.Type; + +export const ReviewBranchCommitsResult = Schema.Struct({ + commits: Schema.Array( + Schema.Struct({ + sha: Schema.String, + subject: Schema.String, + committedAt: Schema.String, + }), + ), + truncated: Schema.Boolean, +}); +export type ReviewBranchCommitsResult = typeof ReviewBranchCommitsResult.Type; + export const ReviewDiffFileStat = Schema.Struct({ path: Schema.String, previousPath: Schema.NullOr(Schema.String), @@ -34,6 +57,7 @@ export const ReviewDiffPreviewSource = Schema.Struct({ id: TrimmedNonEmptyString, kind: ReviewDiffPreviewSourceKind, title: TrimmedNonEmptyString, + commitSubject: Schema.optionalKey(TrimmedNonEmptyString), baseRef: Schema.NullOr(TrimmedNonEmptyString), headRef: Schema.NullOr(TrimmedNonEmptyString), diff: Schema.String, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 7dae9d2598b74..2864ba4479b0f 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -117,6 +117,8 @@ import { VcsStatusStreamEvent, } from "./git.ts"; import { + ReviewBranchCommitsInput, + ReviewBranchCommitsResult, ReviewDiffFileContentsInput, ReviewDiffFileContentsResult, ReviewDiffPreviewError, @@ -175,6 +177,7 @@ import { ORCHESTRATION_V2_WS_METHODS, OrchestrationGetWorkflowScriptError, OrchestrationV2StopWorkflowError, + OrchestrationV2ForceStopThreadError, OrchestrationV2DispatchCommandError, OrchestrationV2GetShellSnapshotError, OrchestrationV2CommandOutputError, @@ -430,6 +433,7 @@ export const WS_METHODS = { gitResolvePullRequest: "git.resolvePullRequest", gitPreparePullRequestThread: "git.preparePullRequestThread", + reviewGetBranchCommits: "review.getBranchCommits", reviewGetDiffPreview: "review.getDiffPreview", reviewGetDiffFileContents: "review.getDiffFileContents", @@ -621,6 +625,8 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv fresh: Schema.optional(Schema.Boolean), /** Bypass model caches; explicit rediscovery may open sessions, background refreshes must not. */ refreshModels: Schema.optional(Schema.Boolean), + /** Read secret references again; unlocking a vault can prompt on the host, so only explicit user refreshes ask. */ + reloadSecrets: Schema.optional(Schema.Boolean), /** * Reply without snapshots, or with only the requested instance's cwd snapshot. Snapshots then * reach the client only through these replies, so another client's scan shows on its next @@ -1332,6 +1338,12 @@ const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { error: Schema.Union([VcsError, EnvironmentAuthorizationError]), }); +const WsReviewGetBranchCommitsRpc = Rpc.make(WS_METHODS.reviewGetBranchCommits, { + payload: ReviewBranchCommitsInput, + success: ReviewBranchCommitsResult, + error: Schema.Union([ReviewDiffPreviewError, EnvironmentAuthorizationError]), +}); + const WsReviewGetDiffPreviewRpc = Rpc.make(WS_METHODS.reviewGetDiffPreview, { payload: ReviewDiffPreviewInput, success: ReviewDiffPreviewResult, @@ -1605,6 +1617,12 @@ const WsOrchestrationV2StopWorkflowRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.st error: Schema.Union([OrchestrationV2StopWorkflowError, EnvironmentAuthorizationError]), }); +const WsOrchestrationV2ForceStopThreadRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.forceStopThread, { + payload: OrchestrationV2RpcSchemas.forceStopThread.input, + success: OrchestrationV2RpcSchemas.forceStopThread.output, + error: Schema.Union([OrchestrationV2ForceStopThreadError, EnvironmentAuthorizationError]), +}); + const WsOrchestrationV2GetWorkflowScriptRpc = Rpc.make( ORCHESTRATION_V2_WS_METHODS.getWorkflowScript, { @@ -1885,6 +1903,7 @@ export const WsRpcGroup = RpcGroup.make( WsVcsCreateRefRpc, WsVcsSwitchRefRpc, WsVcsInitRpc, + WsReviewGetBranchCommitsRpc, WsReviewGetDiffPreviewRpc, WsReviewGetDiffFileContentsRpc, WsTerminalOpenRpc, @@ -1923,6 +1942,7 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationV2DispatchCommandRpc, WsOrchestrationV2GetWorkflowScriptRpc, WsOrchestrationV2StopWorkflowRpc, + WsOrchestrationV2ForceStopThreadRpc, WsOrchestrationV2GetTurnItemRpc, WsOrchestrationV2GetTurnDiffRpc, WsOrchestrationV2GetFullThreadDiffRpc, @@ -2126,6 +2146,7 @@ export const CoderWsRpcGroup = RpcGroup.make( WsPullRequestsRequestReviewersRpc, WsPullRequestsLabelCandidatesRpc, WsPullRequestsSetLabelsRpc, + WsReviewGetBranchCommitsRpc, WsReviewGetDiffPreviewRpc, WsReviewOpenDiffFileContentsRpc, WsReviewReadDiffFileChunkRpc, @@ -2144,6 +2165,7 @@ export const CoderWsRpcGroup = RpcGroup.make( WsOrchestrationV2GetFullThreadDiffRpc, WsOrchestrationV2GetWorkflowScriptRpc, WsOrchestrationV2StopWorkflowRpc, + WsOrchestrationV2ForceStopThreadRpc, WsOrchestrationV2SearchThreadsRpc, WsOrchestrationV2SearchThreadRpc, WsOrchestrationV2SearchThreadStreamRpc, diff --git a/packages/contracts/src/secretReference.ts b/packages/contracts/src/secretReference.ts new file mode 100644 index 0000000000000..4e03f8a675688 --- /dev/null +++ b/packages/contracts/src/secretReference.ts @@ -0,0 +1,27 @@ +import * as Schema from "effect/Schema"; + +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; + +// Separate module avoids an import cycle between settings and provider instance contracts. +// Final once shipped: older builds drop entries whose key fails here, and erase them on their next save. +// Unlike provider slugs it may start with a digit, so a provider named "1Password" fits. +export const SecretProviderSlug = TrimmedNonEmptyString.check( + Schema.isMaxLength(64), + Schema.isPattern(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/), +); + +export const SecretProviderId = SecretProviderSlug.pipe(Schema.brand("SecretProviderId")); +export type SecretProviderId = typeof SecretProviderId.Type; + +// Provider-specific syntax is the secret provider driver's to check. +export const SecretReferencePath = TrimmedNonEmptyString.check( + Schema.isMaxLength(1024), + Schema.isPattern(/^[^\r\n]+$/), +); + +/** Points at a secret held by a configured secret provider; the secret itself is never stored. */ +export const SecretReference = Schema.Struct({ + provider: SecretProviderId, + reference: SecretReferencePath, +}); +export type SecretReference = typeof SecretReference.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 3202ee479136a..b914f45913139 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -633,6 +633,8 @@ export const ServerConfig = Schema.Struct({ threadFindProgressive: Schema.optionalKey(Schema.Boolean), /** Settings patches may carry `secretProviders`; older servers drop the key without an error. */ secretProviderSettings: Schema.optionalKey(Schema.Boolean), + /** Secret settings may point at a secret provider; older servers drop the reference. */ + secretReferences: Schema.optionalKey(Schema.Boolean), scratchWorkspaceRoot: Schema.optionalKey(TrimmedNonEmptyString), newProjectsRoot: Schema.optionalKey(TrimmedNonEmptyString), /** diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 9d765ccee92c6..859644a54c72f 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; import { type CommandId, ProjectId } from "./baseSchemas.ts"; -import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; +import { + ProviderDriverKind, + ProviderInstanceEnvironment, + ProviderInstanceId, +} from "./providerInstance.ts"; import { ClientSettingsSchema, ClientSettingsPatch, @@ -23,6 +27,14 @@ const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema); const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch); const encodeClientSettings = Schema.encodeSync(ClientSettingsSchema); const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); +const encodeProviderInstanceEnvironment = Schema.encodeSync(ProviderInstanceEnvironment); +const decodeProviderInstanceEnvironment = Schema.decodeSync(ProviderInstanceEnvironment); +// The environment variable shape builds released before secret references decode. +const decodeStringOnlyEnvironment = Schema.decodeUnknownSync( + Schema.Array( + Schema.Struct({ name: Schema.String, value: Schema.String, sensitive: Schema.Boolean }), + ), +); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); const encodeServerSettingsPatch = Schema.encodeSync(ServerSettingsPatch); @@ -154,14 +166,45 @@ describe("ServerSettings secret providers", () => { ).toThrow(); }); - it("rejects writes of an unknown kind or an account op could misread", () => { - const write = (entry: unknown) => () => - decodeServerSettingsPatch({ secretProviders: { personal: entry } }); - for (const account of ["", "my account", "--help"]) { - expect(write(onePassword(account))).toThrow(); + it("stores a provider environment secret reference in a layout older builds still decode", () => { + const providerInstances = (secret: unknown) => ({ + codex: { + driver: "codex", + environment: [ + { name: "OPENAI_API_KEY", sensitive: true, value: "", secret }, + { name: "BASE_URL", value: "https://example.test" }, + ], + }, + }); + const secret = { provider: "personal", reference: "op://Private/OpenAI/credential" }; + const environment = decodeServerSettings({ providerInstances: providerInstances(secret) }) + .providerInstances[ProviderInstanceId.make("codex")]?.environment; + expect(environment).toEqual([ + { name: "OPENAI_API_KEY", value: secret, sensitive: true }, + { name: "BASE_URL", value: "https://example.test", sensitive: false }, + ]); + + const encoded = encodeProviderInstanceEnvironment(environment ?? []); + expect(encoded).toEqual([ + { name: "OPENAI_API_KEY", value: "", sensitive: true, secret }, + { name: "BASE_URL", value: "https://example.test", sensitive: false }, + ]); + expect(decodeProviderInstanceEnvironment(encoded)).toEqual(environment); + expect(decodeStringOnlyEnvironment(encoded)).toEqual([ + { name: "OPENAI_API_KEY", value: "", sensitive: true }, + { name: "BASE_URL", value: "https://example.test", sensitive: false }, + ]); + + for (const invalid of [ + { provider: "My Vault", reference: "op://a/b/c" }, + { provider: "personal", reference: " " }, + { provider: "personal", reference: "op://Private/OpenAI\n/credential" }, + { provider: "personal", reference: "x".repeat(1025) }, + ]) { + expect(() => + decodeServerSettings({ providerInstances: providerInstances(invalid) }), + ).toThrow(); } - expect(write({ kind: "one_password", label: "Personal" })).toThrow(); - expect(write({ kind: "openbao", label: "Vault", config: { address: "https://x" } })).toThrow(); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 41a431ea514a2..f43f390511f95 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -21,6 +21,7 @@ import { TrimmedString, } from "./baseSchemas.ts"; import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; +import { SecretProviderId, SecretProviderSlug } from "./secretReference.ts"; import { EnvironmentMachineKind, ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; import { KeybindingShortcut } from "./keybindings.ts"; import { @@ -874,42 +875,11 @@ export const UsageLimitSourceConfig = Schema.Struct({ }); export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; -// Final once shipped: older builds drop entries whose key fails here, and erase them on their next save. -// Unlike provider slugs it may start with a digit, so a provider named "1Password" fits. -const SecretProviderSlug = TrimmedNonEmptyString.check( - Schema.isMaxLength(64), - Schema.isPattern(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/), -); - -export const SecretProviderId = SecretProviderSlug.pipe(Schema.brand("SecretProviderId")); -export type SecretProviderId = typeof SecretProviderId.Type; - export const SecretProviderKind = SecretProviderSlug.pipe(Schema.brand("SecretProviderKind")); export type SecretProviderKind = typeof SecretProviderKind.Type; -const ONE_PASSWORD_ACCOUNT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; - -/** - * Any value `op --account` accepts: shorthand, sign-in address, account ID, or user ID. - * It is passed to the CLI as an argument, so it can never start with `-`. - */ -export const OnePasswordAccount = Schema.String.check( - Schema.isMaxLength(253), - Schema.makeFilter((value: string) => - ONE_PASSWORD_ACCOUNT_PATTERN.test(value) - ? true - : "1Password account must be a shorthand, sign-in address, or ID with no spaces.", - ), -).pipe(Schema.brand("OnePasswordAccount")); -export type OnePasswordAccount = typeof OnePasswordAccount.Type; - export const SecretProviderLabel = TrimmedNonEmptyString.check(Schema.isMaxLength(64)); -export const OnePasswordSecretProviderConfig = Schema.Struct({ - account: OnePasswordAccount, -}); -export type OnePasswordSecretProviderConfig = typeof OnePasswordSecretProviderConfig.Type; - /** Kinds decode their own config; unknown-kind envelopes round-trip verbatim across versions. */ export const SecretProviderConfig = Schema.Struct({ kind: SecretProviderKind, @@ -918,31 +888,12 @@ export const SecretProviderConfig = Schema.Struct({ }); export type SecretProviderConfig = typeof SecretProviderConfig.Type; -/** Kinds this build can resolve. Their `config` holds no credentials, so clients may read it. */ -const SECRET_PROVIDER_KIND_CONFIG_GUARDS: Readonly boolean>> = { - one_password: Schema.is(OnePasswordSecretProviderConfig), -}; - -export function isKnownSecretProviderKind(kind: string): boolean { - return Object.hasOwn(SECRET_PROVIDER_KIND_CONFIG_GUARDS, kind); -} - -// Limits that may change later are enforced on writes only, never on the stored envelope. -// Writes must be a kind this build knows with a config it accepts, so an older server -// rejects a newer kind instead of storing what it cannot check. +// Limits that may change later are enforced on writes only, never on the stored envelope. The +// server also checks the kind and its config against the secret providers it has loaded. const SecretProviderConfigWrite = Schema.Struct({ ...SecretProviderConfig.fields, label: SecretProviderLabel, -}).check( - Schema.makeFilter((entry) => { - if (!isKnownSecretProviderKind(entry.kind)) { - return `Unsupported secret provider kind: ${entry.kind}.`; - } - return SECRET_PROVIDER_KIND_CONFIG_GUARDS[entry.kind]!(entry.config) - ? true - : `Invalid ${entry.kind} secret provider config.`; - }), -); +}); export const SourceControlHostSettings = Schema.Record(Schema.String, Schema.Unknown); export type SourceControlHostSettings = typeof SourceControlHostSettings.Type; @@ -1542,6 +1493,7 @@ export const ServerSettingsOperation = Schema.Literals([ "remove-secret", "remove-stale-secret", "write-secret", + "write-secret-provider", "write-file", "prepare-directory", ]); diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 106ad1204e01b..abdb21c875f4a 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -1301,6 +1301,8 @@ interface SnapshotMessageState { loadingIndex: number; } +/** How long a Stop waits for the agent to confirm before the user is offered Force stop. */ +const ACP_STOP_CONFIRM_TIMEOUT_MS = 10_000; const TOOL_UPDATE_PERSIST_EVERY = 10; // Agents can resend whole calls while streaming arguments. @@ -7971,7 +7973,9 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { yield* restore(runtimeTransitionPermit.take(1)); - return yield* Effect.gen(function* () { + // Stop's caller times out on this call. The transition runs in its own fiber so + // a stalled hard teardown keeps its state and permit without holding the caller. + const transitionFiber = yield* Effect.gen(function* () { const transition = yield* Effect.gen(function* () { const interruptContext = yield* Ref.get(activeTurn); if ( @@ -7982,8 +7986,12 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ) { // ACP cancel leaves native children alive; close confirms session-wide Stop. pendingStopCloseTurn = interruptContext; - const closed = yield* runtime.closeSession().pipe( - Effect.timeoutOption("10 seconds"), + // Interruptible, or neither this timeout nor the caller's could end a hung close. + const closed = yield* restore( + runtime + .closeSession() + .pipe(Effect.timeoutOption(ACP_STOP_CONFIRM_TIMEOUT_MS)), + ).pipe( Effect.mapError( (cause) => new ProviderAdapter.ProviderAdapterProtocolError({ @@ -7995,10 +8003,11 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ), ); if (Option.isNone(closed)) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + return yield* new ProviderAdapter.ProviderAdapterInterruptTimeoutError({ driver, - detail: - "The agent did not confirm session close; background work may still run", + providerThreadId: turnInput.providerThread.id, + providerTurnId: turnInput.providerTurnId, + timeoutMs: ACP_STOP_CONFIRM_TIMEOUT_MS, }); } yield* runtimeCallbackPermit.withPermit( @@ -8228,15 +8237,16 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } } const stopped = yield* Deferred.await(context.completed).pipe( - Effect.timeoutOption("10 seconds"), + Effect.timeoutOption(ACP_STOP_CONFIRM_TIMEOUT_MS), ); + // The turn stays open: the agent may still be working, and the user + // decides whether to Force stop it. if (Option.isNone(stopped)) { - if (!context.finalized) { - yield* finalizeTurn(context, "interrupted"); - } - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + return yield* new ProviderAdapter.ProviderAdapterInterruptTimeoutError({ driver, - detail: `ACP provider turn ${turnInput.providerTurnId} did not acknowledge cancellation before the interrupt timeout`, + providerThreadId: turnInput.providerThread.id, + providerTurnId: turnInput.providerTurnId, + timeoutMs: ACP_STOP_CONFIRM_TIMEOUT_MS, }); } }); @@ -8257,20 +8267,29 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }), ), ); - }).pipe(Effect.ensuring(runtimeTransitionPermit.release(1))); + }).pipe( + Effect.ensuring(runtimeTransitionPermit.release(1)), + Effect.forkIn(sessionScope, { startImmediately: true, uninterruptible: true }), + ); + return yield* restore(Fiber.join(transitionFiber)).pipe( + Effect.onInterrupt(() => + Fiber.interrupt(transitionFiber).pipe(Effect.forkDetach, Effect.asVoid), + ), + ); }), ); }, (effect, turnInput) => effect.pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterInterruptError({ - driver, - providerThreadId: turnInput.providerThread.id, - providerTurnId: turnInput.providerTurnId, - cause, - }), + Effect.mapError((cause) => + cause._tag === "ProviderAdapterInterruptTimeoutError" + ? cause + : new ProviderAdapter.ProviderAdapterInterruptError({ + driver, + providerThreadId: turnInput.providerThread.id, + providerTurnId: turnInput.providerTurnId, + cause, + }), ), ), ), diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index e168512736c7c..0b5459f647f8d 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -327,6 +327,21 @@ export class ProviderAdapterInterruptError extends Schema.TaggedError()( + "ProviderAdapterInterruptTimeoutError", + { + driver: ProviderDriverKind, + providerThreadId: ProviderThreadId, + providerTurnId: ProviderTurnId, + timeoutMs: Schema.Number, + }, +) { + override get message(): string { + return `The ${this.driver} provider did not confirm stopping turn ${this.providerTurnId} within ${this.timeoutMs} ms.`; + } +} + export class ProviderAdapterRuntimeRequestResponseError extends Schema.TaggedError()( "ProviderAdapterRuntimeRequestResponseError", { @@ -388,6 +403,7 @@ export const ProviderAdapterV2Error = Schema.Union([ ProviderAdapterSteerRunUnsupportedError, ProviderAdapterSteerRunError, ProviderAdapterInterruptError, + ProviderAdapterInterruptTimeoutError, ProviderAdapterRuntimeRequestResponseError, ProviderAdapterEventStreamError, ProviderAdapterProtocolError, diff --git a/packages/provider-core/src/server/driver.ts b/packages/provider-core/src/server/driver.ts index f2efbdb7155bc..a9ab271f78fbe 100644 --- a/packages/provider-core/src/server/driver.ts +++ b/packages/provider-core/src/server/driver.ts @@ -6,7 +6,7 @@ import type { AcpRegistryOperationError, AcpRegistrySetProviderInput, ProviderDriverKind, - ProviderInstanceEnvironment, + ResolvedProviderInstanceEnvironment, ProviderInstanceId, ServerProvider, ServerProviderWorkspaceSnapshot, @@ -95,7 +95,7 @@ export interface ProviderDriverCreateInput { readonly instanceId: ProviderInstanceId; readonly displayName: string | undefined; readonly accentColor?: string | undefined; - readonly environment: ProviderInstanceEnvironment; + readonly environment: ResolvedProviderInstanceEnvironment; readonly enabled: boolean; readonly config: Config; } diff --git a/packages/provider-core/src/server/instanceEnvironment.test.ts b/packages/provider-core/src/server/instanceEnvironment.test.ts index 1899d1f9d5c3f..7f70cdddad573 100644 --- a/packages/provider-core/src/server/instanceEnvironment.test.ts +++ b/packages/provider-core/src/server/instanceEnvironment.test.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; +import { SecretProviderId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -61,12 +62,21 @@ describe("mergeProviderInstanceEnvironment", () => { [ { name: "OPENROUTER_API_KEY", value: "sk-or-test", sensitive: true }, { name: "ANTHROPIC_API_KEY", value: "", sensitive: false }, + { + name: "OPENAI_API_KEY", + value: { + provider: SecretProviderId.make("personal"), + reference: "op://Private/OpenAI/credential", + }, + sensitive: true, + }, ], - { ANTHROPIC_API_KEY: "inherited", PATH: "/bin" }, + { ANTHROPIC_API_KEY: "inherited", OPENAI_API_KEY: "inherited", PATH: "/bin" }, ), ).toMatchObject({ OPENROUTER_API_KEY: "sk-or-test", ANTHROPIC_API_KEY: "", + OPENAI_API_KEY: "inherited", PATH: "/bin", }); }), diff --git a/packages/provider-core/src/server/instanceEnvironment.ts b/packages/provider-core/src/server/instanceEnvironment.ts index d0cddd119a13f..4a73f678da0c0 100644 --- a/packages/provider-core/src/server/instanceEnvironment.ts +++ b/packages/provider-core/src/server/instanceEnvironment.ts @@ -15,6 +15,8 @@ export const mergeProviderInstanceEnvironment = Effect.fn(function* ( const home = yield* HostProcess.HomeDirectory; const next: NodeJS.ProcessEnv = { ...baseEnv }; for (const variable of environment) { + // An unresolved secret reference carries no value; the registry resolves it for the provider. + if (typeof variable.value !== "string") continue; next[variable.name] = variable.name === "CODEX_HOME" || variable.name === "CLAUDE_CONFIG_DIR" ? expandHomePath(variable.value, home) diff --git a/packages/shared/src/AgentScope.test.ts b/packages/shared/src/AgentScope.test.ts index cf3ca64acce0a..8a3d99b32f7cc 100644 --- a/packages/shared/src/AgentScope.test.ts +++ b/packages/shared/src/AgentScope.test.ts @@ -25,5 +25,7 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("agentScopeComm expect(Number(pid)).toBe(child.pid); expect(Number(score)).toBe(AGENT_OOM_SCORE_ADJ); expect(args).toEqual(["a b", "$HOME"]); + + // Coder: no launcher script check; `agentScopeLauncherScript` is not carried. }); }); diff --git a/packages/shared/src/AgentScope.ts b/packages/shared/src/AgentScope.ts index 361bfc49108c6..7de5e8544ce20 100644 --- a/packages/shared/src/AgentScope.ts +++ b/packages/shared/src/AgentScope.ts @@ -22,6 +22,12 @@ export interface AgentScopeShape { * descendants outside its process group. Preserves systemd OOM diagnosis. */ readonly stop: (unit: string) => Effect.Effect; + /** + * SIGKILLs every process in the thread's own scope, which works even while + * the kernel throttles them. Shared scopes serve other threads and are never + * killed here. Succeeds with whether a scope was killed. + */ + readonly kill: (threadId: string) => Effect.Effect; /** * Forgets the thread's scope. The session manager calls it before it opens * a provider session, so a scope only answers for the session that launched @@ -42,6 +48,7 @@ export const AgentScope = Context.Reference("@t3tools/shared/Ag wrap: ({ command, args }) => Effect.succeed({ command, args }), oomKilled: () => Effect.succeed(false), stop: () => Effect.void, + kill: () => Effect.succeed(false), clear: () => Effect.void, }), }); @@ -74,6 +81,8 @@ export interface AgentSystemdScope { readonly options: ReadonlyArray; } +// Coder: upstream's `agentScopeLauncherScript` (#27239) serves only the cut preview browser. + /** Exec at every step preserves the agent PID, stdio, and signals; systemd-run places it and descendants in the scope. */ export function agentScopeCommand(input: { readonly command: string; diff --git a/packages/shared/src/composerContextReferences.ts b/packages/shared/src/composerContextReferences.ts index 432669a0dd5ee..0d641f8aa73ab 100644 --- a/packages/shared/src/composerContextReferences.ts +++ b/packages/shared/src/composerContextReferences.ts @@ -132,6 +132,11 @@ export function collectComposerContextReferences( export const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]"; +/** Claude's ultrathink effort sends the prompt as `Ultrathink:\n`. */ +export function isAttachmentOnlyBootstrapPrompt(text: string): boolean { + return text.replace(/^Ultrathink:\s*/i, "") === ATTACHMENT_ONLY_BOOTSTRAP_PROMPT; +} + export function isImageReferencesOnly(text: string): boolean { const references = collectComposerContextReferences(text); return ( diff --git a/packages/shared/src/orchestrationTiming.ts b/packages/shared/src/orchestrationTiming.ts index 187db6c6d97d4..1f43f99453456 100644 --- a/packages/shared/src/orchestrationTiming.ts +++ b/packages/shared/src/orchestrationTiming.ts @@ -22,10 +22,7 @@ export function deriveSubagentElapsedMs( export function formatDuration(durationMs: number): string { if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms"; if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`; - if (durationMs < 10_000) { - const tenths = Math.round(durationMs / 100) / 10; - return tenths >= 10 ? "10s" : `${tenths.toFixed(1)}s`; - } + if (durationMs < 10_000) return `${Math.round(durationMs / 100) / 10}s`; const totalSeconds = Math.round(durationMs / 1_000); // Choose the unit after rounding so 59.5 seconds never renders as "60s". if (totalSeconds < 60) return `${totalSeconds}s`; diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 5a02f302826de..26cbfb3df9d59 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -1,6 +1,5 @@ import { DEFAULT_SERVER_SETTINGS, - OnePasswordAccount, ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -590,7 +589,7 @@ describe("serverSettings helpers", () => { const onePassword = (label: string, account: string) => ({ kind: SecretProviderKind.make("one_password"), label, - config: { account: OnePasswordAccount.make(account) }, + config: { account }, }); const current = { ...DEFAULT_SERVER_SETTINGS, diff --git a/packages/shared/src/threadFindText.test.ts b/packages/shared/src/threadFindText.test.ts index 0dd6a87ae1dea..77a0874c21336 100644 --- a/packages/shared/src/threadFindText.test.ts +++ b/packages/shared/src/threadFindText.test.ts @@ -1,6 +1,7 @@ import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; import { expect, it } from "vite-plus/test"; import { serializeAssistantCitation } from "./assistantCitations.ts"; +import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "./composerContextReferences.ts"; import { searchableMessageSegments } from "./threadFindText.ts"; it("excludes review attachments rendered as context chips", () => { @@ -31,6 +32,16 @@ it("excludes structured context chips without joining text across them", () => { ).toEqual(["before", "after"]); }); +it("excludes the hidden prompt of an attachment-only message", () => { + expect( + searchableMessageSegments({ + role: "user", + streaming: false, + text: ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + }), + ).toEqual([]); +}); + it("does not upgrade literal legacy tags in messages with structured context", () => { const text = "\n- Terminal 1 line 12:\n 12 | visible output\n"; diff --git a/packages/shared/src/threadFindText.ts b/packages/shared/src/threadFindText.ts index a2e5b4dbb982d..3afec6e04729e 100644 --- a/packages/shared/src/threadFindText.ts +++ b/packages/shared/src/threadFindText.ts @@ -21,7 +21,10 @@ import { resolveMarkdownFileLinkTarget, } from "./markdownLinks.ts"; import { upgradeLegacyContextMessage } from "./composerContextLegacy.ts"; -import { parseComposerContextHref } from "./composerContextReferences.ts"; +import { + isAttachmentOnlyBootstrapPrompt, + parseComposerContextHref, +} from "./composerContextReferences.ts"; import { assistantCitationLabel, parseAssistantCitationHref } from "./assistantCitations.ts"; import type { OrchestrationV2ConversationMessage } from "@t3tools/contracts"; import { proposedPlanTitle, stripDisplayedPlanMarkdown } from "./proposedPlanText.ts"; @@ -216,6 +219,8 @@ export function searchableMessageSegments( skills: readonly InlineSkill[] = [], ): readonly string[] | null { if (message.role === "user") { + // Clients hide this prompt, so find must not match text nobody can see. + if (isAttachmentOnlyBootstrapPrompt(message.text)) return []; const text = message.context ? message.text : upgradeLegacyContextMessage(message.text).text; return markdownThreadFindText(text, true, cwd, skills); } diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index 7e1ffa319bf09..520d52f40dfdd 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -339,6 +339,16 @@ export interface PullRequestProviderApi { input: ProviderRepositoryRef & { readonly number: number }, ) => Effect.Effect; + /** + * For a provider that holds change request reads of its own: called when the service drops its + * reads, so a refresh or a finished turn reaches the host. No scope forgets everything; a scope + * without a number forgets the repository's change requests. + */ + readonly forgetChangeRequestReads?: (scope?: { + readonly repository: string; + readonly number?: number; + }) => Effect.Effect; + readonly getChangeRequestPreview?: ( input: ProviderRepositoryRef & { readonly number: number }, ) => Effect.Effect< diff --git a/packages/source-control-core/src/shared/definition.ts b/packages/source-control-core/src/shared/definition.ts index 3df9e9a06ed4c..0779d5a630297 100644 --- a/packages/source-control-core/src/shared/definition.ts +++ b/packages/source-control-core/src/shared/definition.ts @@ -30,6 +30,17 @@ export interface ParsedChangeRequestUrl extends ChangeRequestLink { readonly repositoryUrl: string; } +export interface CommitLink { + readonly host: string; + readonly repository: string; + readonly sha: string; + readonly authority?: string; +} + +export interface CommitUrlInput extends Omit { + readonly sha: string; +} + export interface ChangeRequestTerminology { readonly shortLabel: string; /** Spelled out because the article follows how the label is said: "an MR". */ @@ -217,6 +228,9 @@ export interface SourceControlDefinition { readonly newRepositoryOwner: (account: string | null) => { readonly owner: string | null } | null; readonly defaultCloneTransport: "https" | "ssh"; readonly changeRequestUrl: (input: ChangeRequestUrlInput) => string | null; + readonly commitUrl?: (input: CommitUrlInput) => string | null; + /** `known` is set when the caller already knows this host serves the URL's repository. */ + readonly parseCommitUrl?: (url: URL, known: boolean) => CommitLink | null; /** Null when the URL is not one of this host's change requests. */ readonly parseChangeRequestUrl: (url: URL) => ParsedChangeRequestUrl | null; /** @@ -269,7 +283,7 @@ export interface SourceControlDefinition { readonly isChangeRequestReference: (url: string) => boolean; readonly isChangeRequestInRepository: ( identity: ChangeRequestProjectIdentity, - link: ChangeRequestLink, + link: ChangeRequestLink | CommitLink, ) => boolean; readonly canReadChangeRequestOnHost: ( identity: ChangeRequestProjectIdentity, @@ -314,6 +328,42 @@ export type SourceControlHostSettingsSchema = { readonly fields: Readonly>; } & Schema.Decoder; +const FULL_COMMIT_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu; + +export function parseCommitPath(url: URL, route: RegExp): CommitLink | null { + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + const match = route.exec(url.pathname); + const repository = match?.[1]; + const sha = match?.[2]; + return repository && sha && FULL_COMMIT_SHA.test(sha) + ? { + host: url.hostname.toLowerCase(), + repository: repository.toLowerCase(), + sha: sha.toLowerCase(), + ...(url.port ? { authority: url.host.toLowerCase() } : {}), + } + : null; +} + +export function buildCommitUrl( + { host, repository, sha, remoteUrl, webUrl }: CommitUrlInput, + route: string, +): string | null { + if (!repository || !FULL_COMMIT_SHA.test(sha)) return null; + try { + const url = new URL(webUrl || `${remoteWebOrigin(host, remoteUrl)}/${repository}`); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + url.pathname = changeRequestUrlBelow(url.pathname, `${route}/${sha.toLowerCase()}`); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return null; + } +} + /** * Reads a change request from `url` with `route`, whose first group is the repository path and * second the number. @@ -362,7 +412,7 @@ export function defineSourceControl( export function isChangeRequestOnProjectHost( projectHost: string, identity: ChangeRequestProjectIdentity, - link: ChangeRequestLink, + link: ChangeRequestLink | CommitLink, ): boolean { if (!isChangeRequestAuthorityOfProject(identity, link)) return false; return projectHost === link.host.toLowerCase() || projectHost === link.authority; @@ -405,7 +455,7 @@ function projectRepositoryPath(identity: ChangeRequestProjectIdentity): string | export function isChangeRequestInProjectRepository( projectHost: string, identity: ChangeRequestProjectIdentity, - link: ChangeRequestLink, + link: ChangeRequestLink | CommitLink, ): boolean { const repository = projectRepositoryPath(identity); return ( @@ -512,6 +562,11 @@ export interface ParsedChangeRequest { readonly link: ParsedChangeRequestUrl; } +export interface ParsedCommit { + readonly definition: SourceControlDefinition; + readonly link: CommitLink; +} + export interface SourceControlMedia extends SourceControlMediaUrl { readonly definition: SourceControlDefinition; } @@ -528,6 +583,7 @@ export interface SourceControlDefinitionRegistry { readonly findByPublicDomain: (hostname: string) => SourceControlDefinition | undefined; /** The first host, in registry order, that reads `url` as one of its change requests. */ readonly parseChangeRequestUrl: (url: string) => ParsedChangeRequest | null; + readonly parseCommitUrl: (url: string, kind?: SourceControlProviderKind) => ParsedCommit | null; readonly findByChangeRequestUrl: (url: string) => SourceControlDefinition | undefined; readonly hostLabelForChangeRequestUrl: (url: string) => string; readonly isSameChangeRequest: (left: string, right: string) => boolean; @@ -629,6 +685,16 @@ export function makeSourceControlDefinitionRegistry( ); }, parseChangeRequestUrl, + parseCommitUrl: (value, kind) => { + const url = httpUrl(value); + if (url === null) return null; + const preferred = kind === undefined ? undefined : byKind.get(kind); + for (const definition of kind === undefined ? claimOrder : preferred ? [preferred] : []) { + const link = definition.parseCommitUrl?.(url, preferred !== undefined); + if (link != null) return { definition, link }; + } + return null; + }, findByChangeRequestUrl: (url) => parseChangeRequestUrl(url)?.definition, hostLabelForChangeRequestUrl: (url) => parseChangeRequestUrl(url)?.definition.label ?? "the host", diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts index 6eec9ccd09142..cc6bab6c53f42 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts @@ -1,6 +1,7 @@ import { assert, describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; import * as GitLabCli from "./GitLabCli.ts"; import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; @@ -122,6 +123,157 @@ describe("getChangeRequest base freshness", () => { ); }); +describe("merge request reads", () => { + const reference = { cwd: "/w", repository: "acme/web", host: "gitlab.com", number: 7 }; + const detail = { + number: 7, + title: "Merge request 7", + url: "https://gitlab.com/acme/web/-/merge_requests/7", + author: null, + headBranch: "feat/page", + baseBranch: "main", + state: "open" as const, + isDraft: false, + mergeability: "mergeable" as const, + additions: 0, + deletions: 0, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + reviewRequestLogins: [], + labels: [], + body: "", + changedFiles: 1, + mergedAt: null, + closedAt: null, + reviewers: [], + checks: [], + viewerCanMerge: true, + reviewerIds: [], + }; + + const countingProvider = Effect.gen(function* () { + const calls = { detail: 0, mergeCapabilities: 0 }; + const host = { viewerCanMerge: true }; + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ + getMergeRequestDetail: (input) => + Effect.sync(() => { + calls.detail += 1; + return { ...detail, number: input.number, viewerCanMerge: host.viewerCanMerge }; + }), + getProjectMergeCapabilities: () => + Effect.sync(() => { + calls.mergeCapabilities += 1; + return { merge: true, squash: true, rebase: true }; + }), + runMergeRequestAction: () => Effect.void, + }), + ), + ); + const checks = provider.getChangeRequestChecks; + const forget = provider.forgetChangeRequestReads; + if (checks === undefined || forget === undefined) { + return yield* Effect.die("GitLab answers checks and forgets its reads"); + } + return { provider, checks, forget, calls, host }; + }); + + it.effect("asks for a project's merge methods once across its merge requests", () => + Effect.gen(function* () { + const { provider, calls } = yield* countingProvider; + + yield* provider.getChangeRequest(reference); + yield* provider.getChangeRequest(reference); + yield* provider.getChangeRequest({ ...reference, number: 8 }); + + expect(calls.mergeCapabilities).toBe(1); + }), + ); + + it.effect("answers checks from the merge request the detail just read", () => + Effect.gen(function* () { + const { provider, checks, calls } = yield* countingProvider; + + yield* provider.getChangeRequest(reference); + yield* checks(reference); + + expect(calls.detail).toBe(1); + }), + ); + + it.effect("reads a merge request again after a write to it", () => + Effect.gen(function* () { + const { provider, checks, calls } = yield* countingProvider; + + yield* provider.getChangeRequest(reference); + yield* provider.runAction({ ...reference, action: "ready" }); + yield* checks(reference); + + expect(calls.detail).toBe(2); + }), + ); + + it.effect("does not share reads between checkouts", () => + Effect.gen(function* () { + const { provider, calls } = yield* countingProvider; + + yield* provider.getChangeRequest(reference); + yield* provider.getChangeRequest({ ...reference, cwd: "/other" }); + + expect(calls).toEqual({ detail: 2, mergeCapabilities: 2 }); + }), + ); + + it.effect("reads a merge request again once a minute has passed", () => + Effect.gen(function* () { + const { provider, calls } = yield* countingProvider; + + yield* provider.getChangeRequest(reference); + yield* TestClock.adjust("61 seconds"); + yield* provider.getChangeRequest(reference); + + expect(calls.detail).toBe(2); + }), + ); + + it.effect("answers viewer permissions from the host, not the page's read", () => + Effect.gen(function* () { + const { provider, host } = yield* countingProvider; + + host.viewerCanMerge = false; + yield* provider.getChangeRequest(reference); + host.viewerCanMerge = true; + const granted = yield* provider.getViewerPermissions(reference); + host.viewerCanMerge = false; + const revoked = yield* provider.getViewerPermissions(reference); + + expect(granted.actions).toContain("merge"); + expect(revoked.actions).not.toContain("merge"); + }), + ); + + it.effect("reads a merge request again once its reads are forgotten", () => + Effect.gen(function* () { + const { provider, checks, forget, calls } = yield* countingProvider; + const other = { ...reference, repository: "acme/api" }; + + yield* provider.getChangeRequest(reference); + yield* provider.getChangeRequest(other); + yield* forget({ repository: "ACME/web", number: 7 }); + yield* checks(reference); + yield* checks(other); + expect(calls.detail).toBe(3); + + yield* forget({ repository: "acme/web" }); + yield* checks(reference); + yield* forget(); + yield* checks(other); + expect(calls.detail).toBe(5); + }), + ); +}); + describe("optional activity reads", () => { const reference = { cwd: "/w", repository: "acme/web", host: "gitlab.com", number: 7 }; const comment = { diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts index a9856f907ebb8..79c3d43c01966 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts @@ -1,5 +1,8 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; +import * as Cache from "effect/Cache"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import type { PullRequestCapabilities, PullRequestReaction, @@ -72,6 +75,16 @@ export function gitLabViewerPermissions(input: { }; } +/** + * One page view asks for the detail and then its checks within a second, and the poll that + * follows asks again. A minute keeps those to one `glab` call without hiding a pipeline for long; + * a refresh or a finished turn drops it sooner through `forgetChangeRequestReads`. + */ +const MERGE_REQUEST_DETAIL_TTL = Duration.minutes(1); +/** A project's merge methods change in its settings, which nobody does between page views. */ +const PROJECT_MERGE_CAPABILITIES_TTL = Duration.minutes(15); +const READ_CACHE_CAPACITY = 256; + function gitLabProviderFailure( error: GitLabPullRequestCli.GitLabPullRequestCliError, ): PullRequestProviderFailure { @@ -85,6 +98,55 @@ function gitLabProviderFailure( export const make = Effect.gen(function* () { const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + // Keyed on the checkout as well: `glab` picks the host and the account to answer as from the + // checkout's remote and config, so two checkouts may read the same project as different people. + const mergeRequestDetails = yield* Cache.makeWith( + (key: string) => { + const [cwd, repository, number] = JSON.parse(key) as [string, string, number]; + return cli.getMergeRequestDetail({ cwd, repository, number }); + }, + { + capacity: READ_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? MERGE_REQUEST_DETAIL_TTL : Duration.zero), + }, + ); + const projectMergeCapabilities = yield* Cache.makeWith( + (key: string) => { + const [cwd, repository] = JSON.parse(key) as [string, string]; + return cli.getProjectMergeCapabilities({ cwd, repository }); + }, + { + capacity: READ_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? PROJECT_MERGE_CAPABILITIES_TTL : Duration.zero), + }, + ); + + const detailKey = (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => JSON.stringify([input.cwd, input.repository, input.number]); + const mergeRequestDetail = (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Cache.get(mergeRequestDetails, detailKey(input)); + + // Dropped on both sides, as the service does with its own reads: a read that starts while the + // write is in flight must not outlive it. + const writing = + < + I extends { readonly cwd: string; readonly repository: string; readonly number: number }, + A, + E, + >( + write: (input: I) => Effect.Effect, + ) => + (input: I): Effect.Effect => { + const invalidate = Cache.invalidate(mergeRequestDetails, detailKey(input)); + return invalidate.pipe(Effect.andThen(write(input)), Effect.ensuring(invalidate)); + }; + const fail = (operation: string) => (error: GitLabPullRequestCli.GitLabPullRequestCliError) => new PullRequestProviderError({ provider: SourceControlProviderKind.make("gitlab"), @@ -138,7 +200,7 @@ export const make = Effect.gen(function* () { ), getChangeRequestChecks: (input) => - cli.getMergeRequestDetail(input).pipe( + mergeRequestDetail(input).pipe( Effect.map(({ state, checks }) => ({ state, checks })), Effect.mapError(fail("getChangeRequestChecks")), ), @@ -153,8 +215,8 @@ export const make = Effect.gen(function* () { getChangeRequest: (input) => Effect.all( [ - cli.getMergeRequestDetail(input), - cli.getProjectMergeCapabilities({ cwd: input.cwd, repository: input.repository }), + mergeRequestDetail(input), + Cache.get(projectMergeCapabilities, JSON.stringify([input.cwd, input.repository])), ], { concurrency: 2 }, ).pipe( @@ -209,11 +271,31 @@ export const make = Effect.gen(function* () { })), ), + // Straight from the host, never the shared read: this is what a write is refused or let + // through on, and access granted or withdrawn within the minute must count. getViewerPermissions: (input) => cli .getMergeRequestDetail(input) .pipe(Effect.mapError(fail("getViewerPermissions")), Effect.map(gitLabViewerPermissions)), + forgetChangeRequestReads: (scope) => + Cache.keys(mergeRequestDetails).pipe( + Effect.flatMap((keys) => + Effect.forEach( + Array.from(keys).filter((key) => { + if (scope === undefined) return true; + const [, repository, number] = JSON.parse(key) as [string, string, number]; + return ( + repository.toLowerCase() === scope.repository.toLowerCase() && + (scope.number === undefined || number === scope.number) + ); + }), + (key) => Cache.invalidate(mergeRequestDetails, key), + { discard: true }, + ), + ), + ), + getDiff: (input) => cli.getMergeRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))), getDiffFileContents: (input) => @@ -245,7 +327,7 @@ export const make = Effect.gen(function* () { .listProjectUsers({ cwd: input.cwd, repository: input.repository, search: input.query }) .pipe(Effect.mapError(fail("searchRepositoryAuthors"))), - setReviewerRequest: (input) => + setReviewerRequest: writing((input) => cli .setReviewerRequest({ cwd: input.cwd, @@ -255,8 +337,9 @@ export const make = Effect.gen(function* () { requested: input.requested, }) .pipe(Effect.mapError(fail("setReviewerRequest"))), + ), - runAction: (input) => + runAction: writing((input) => cli .runMergeRequestAction({ cwd: input.cwd, @@ -266,8 +349,9 @@ export const make = Effect.gen(function* () { ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), }) .pipe(Effect.mapError(fail("runAction"))), + ), - updateChangeRequest: (input) => + updateChangeRequest: writing((input) => cli .updateMergeRequest({ cwd: input.cwd, @@ -277,10 +361,13 @@ export const make = Effect.gen(function* () { ...(input.body === undefined ? {} : { description: input.body }), }) .pipe(Effect.mapError(fail("updateChangeRequest"))), + ), - comment: (input) => cli.commentOnMergeRequest(input).pipe(Effect.mapError(fail("comment"))), + comment: writing((input) => + cli.commentOnMergeRequest(input).pipe(Effect.mapError(fail("comment"))), + ), - updateComment: (input) => + updateComment: writing((input) => cli .updateNote({ cwd: input.cwd, @@ -290,10 +377,13 @@ export const make = Effect.gen(function* () { body: input.body, }) .pipe(Effect.mapError(fail("updateComment"))), + ), - submitReview: (input) => cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))), + submitReview: writing((input) => + cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))), + ), - replyToThread: (input) => + replyToThread: writing((input) => cli .replyToDiscussion({ cwd: input.cwd, @@ -303,8 +393,9 @@ export const make = Effect.gen(function* () { body: input.body, }) .pipe(Effect.mapError(fail("replyToThread"))), + ), - setReaction: (input) => + setReaction: writing((input) => cli .setReaction({ cwd: input.cwd, @@ -315,8 +406,9 @@ export const make = Effect.gen(function* () { reacted: input.reacted, }) .pipe(Effect.mapError(fail("setReaction"))), + ), - setThreadResolution: (input) => + setThreadResolution: writing((input) => cli .setDiscussionResolution({ cwd: input.cwd, @@ -326,6 +418,7 @@ export const make = Effect.gen(function* () { resolved: input.resolved, }) .pipe(Effect.mapError(fail("setThreadResolution"))), + ), }; return provider; diff --git a/packages/source-control-gitlab/src/shared/definition.ts b/packages/source-control-gitlab/src/shared/definition.ts index cc8ae144e8a4d..c7adc30287911 100644 --- a/packages/source-control-gitlab/src/shared/definition.ts +++ b/packages/source-control-gitlab/src/shared/definition.ts @@ -1,10 +1,12 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { + buildCommitUrl, changeRequestUrlBelow, defineSourceControl, isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, parseChangeRequestPath, + parseCommitPath, projectChangeRequestHost, } from "@t3tools/source-control-core/shared/definition"; import { gitLabMediaUrl } from "./gitLabMedia.ts"; @@ -17,6 +19,7 @@ const CHANGE_REQUEST_REFERENCE = // Self-managed GitLab runs on any hostname and nests projects in groups. const CHANGE_REQUEST_PATH = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u; +const COMMIT_PATH = /^\/([^/]+(?:\/[^/]+)+)\/-\/commit\/([0-9a-f]+)\/?$/iu; const KIND = SourceControlProviderKind.make("gitlab"); @@ -42,6 +45,8 @@ export const definition = defineSourceControl({ ? changeRequestUrlBelow(webUrl, `-/merge_requests/${number}`) : `https://${host}/${repository}/-/merge_requests/${number}`, parseChangeRequestUrl: (url) => parseChangeRequestPath(url, CHANGE_REQUEST_PATH), + commitUrl: (input) => buildCommitUrl(input, "-/commit"), + parseCommitUrl: (url) => parseCommitPath(url, COMMIT_PATH), changeRequestUrlFromReferenceAutolink: () => null, changeRequestActions: new Set([ "merge", diff --git a/scripts/sync-resolve-deleted.mjs b/scripts/sync-resolve-deleted.mjs index f13b805e5adad..9aa3309aa253d 100644 --- a/scripts/sync-resolve-deleted.mjs +++ b/scripts/sync-resolve-deleted.mjs @@ -39,6 +39,8 @@ const DELETED_PATHS = { // its resize handle. "preview and browser": [ "apps/server/src/mcp/PreviewAutomationBroker.ts", + "apps/server/src/htmlRender/headlessChrome.ts", + "apps/server/src/htmlRender/HtmlRender.test.ts", "apps/server/src/mcp/toolkits/preview/", "apps/server/src/mcp/toolkits/previewControls/", "apps/server/src/preview/", @@ -173,11 +175,14 @@ const DELETED_PATHS = { "apps/web/src/components/settings/CodexSetupSection.tsx", "packages/shared/src/editor.ts", ], - // Browser tests for cut surfaces: the provider environment panel test drives managed Codex - // accounts and ACP sign-in, and the theme import test the remote catalog and desktop picker. - "browser tests for cut surfaces": [ + // Tests for cut surfaces: the provider environment panel test drives managed Codex accounts and + // ACP sign-in, the theme import test the remote catalog and desktop picker, the MCP core test + // peer links and client scopes, and the app render scenarios upstream's fake environment. + "tests for cut surfaces": [ "apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx", "apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx", + "apps/server/src/mcp/toolkits/core.test.ts", + "apps/web/test/appScenarios.renders.tsx", ], // Relay: no relay, Tailscale, or SSH transport. relay: [ @@ -187,6 +192,7 @@ const DELETED_PATHS = { "docs/user/remote-access.md", "infra/", "packages/client-runtime/src/relay/", + "packages/contracts/src/relay.ts", "packages/client-runtime/src/connection/supervisor.test.ts", "packages/ssh/", "packages/tailscale/", @@ -195,6 +201,10 @@ const DELETED_PATHS = { // or trace export. telemetry: [ "apps/server/src/diagnostics/", + "apps/server/src/provider/Drivers/claudeUsage.ts", + "apps/server/src/provider/Drivers/claudeUsage.test.ts", + "apps/web/src/components/settings/DiagnosticsSettings.tsx", + "apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx", "apps/server/src/observability/HeapAttribution.ts", "apps/server/src/observability/HeapAttribution.test.ts", "apps/server/src/observability/Observability.ts", @@ -233,6 +243,8 @@ const DELETED_PATHS = { "scripts/build-cli-archive.ts", "scripts/build-npm-launcher.ts", "scripts/generate-release-notes.ts", + "scripts/release-smoke.ts", + "docs/user/background-service.md", "scripts/npm-launcher/", "scripts/licenses/libatomic.LICENSE", "scripts/notify-discord-release.ts", @@ -277,6 +289,11 @@ const DELETED_PATHS = { "apps/web/src/components/settings/SecretProviderDialog.tsx", "apps/web/src/components/settings/SecretsSettings.tsx", "apps/web/src/routes/_chat.settings.secrets.tsx", + "apps/web/src/components/settings/SecretSourceInput.tsx", + "apps/web/src/components/settings/SecretSourceInput.browser.test.tsx", + "apps/server/src/secrets/SecretResolver.ts", + "apps/server/src/secrets/secretProviderDefinitions.ts", + "apps/server/src/secrets/secretProviderDefinitions.test.ts", "packages/secret-provider-core/", "packages/secret-provider-definitions/", "packages/secret-provider-onepassword/",