From 6ed47b538c1ac1d94043d96d3b1faf79c024811d Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 23 Oct 2026 00:20:36 -0500 Subject: [PATCH 01/16] fix(server): route SQLite reads around writer contention (#23902) --- apps/server/src/mcpApps/McpAppModelContext.ts | 3 +- .../src/orchestration-v2/EffectOutbox.ts | 5 +- .../FoundationPersistence.test.ts | 80 ++++++++++++++++++ .../src/orchestration-v2/ProjectStore.ts | 5 +- .../src/orchestration-v2/ProjectionStore.ts | 83 ++++++++++-------- .../server/src/orchestration-v2/ThreadFind.ts | 3 +- .../legacy/LegacyV1ThreadImporter.ts | 10 ++- .../src/persistence/AuthPairingLinks.ts | 3 + apps/server/src/persistence/AuthSessions.ts | 3 + .../OrchestrationCommandReceipts.ts | 2 + .../persistence/OrchestrationEventStore.ts | 7 +- .../src/persistence/ProviderSessionRuntime.ts | 3 + .../src/persistence/PullRequestFilesViewed.ts | 2 + .../persistence/PullRequestReviewGuides.ts | 2 + apps/server/src/pullRequest/linkedThreads.ts | 3 +- .../scheduledTasks/ScheduledTaskService.ts | 24 ++++-- apps/server/src/serverSettings.ts | 3 + apps/server/src/storageCleanup.ts | 6 +- apps/server/src/workspace/KnownWorkspaces.ts | 6 +- packages/shared/src/nodeSqliteClient.test.ts | 37 ++++++++ packages/shared/src/nodeSqliteClient.ts | 84 +++++++++++++++++-- 21 files changed, 305 insertions(+), 69 deletions(-) diff --git a/apps/server/src/mcpApps/McpAppModelContext.ts b/apps/server/src/mcpApps/McpAppModelContext.ts index d6894b38f8a3..d50236ce6cd1 100644 --- a/apps/server/src/mcpApps/McpAppModelContext.ts +++ b/apps/server/src/mcpApps/McpAppModelContext.ts @@ -1,5 +1,6 @@ import type { ThreadId, TurnItemId } from "@t3tools/contracts"; import * as Context from "effect/Context"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -83,7 +84,7 @@ export const layer = Layer.effect( OR run.thread_id <> context.thread_id ) ORDER BY context.updated_at, context.item_id - `.pipe(Effect.mapError(fail)); + `.pipe(NodeSqliteClient.readOnly, Effect.mapError(fail)); }); return McpAppModelContext.of({ set, forThread }); diff --git a/apps/server/src/orchestration-v2/EffectOutbox.ts b/apps/server/src/orchestration-v2/EffectOutbox.ts index 05be41e57285..c89beae951e7 100644 --- a/apps/server/src/orchestration-v2/EffectOutbox.ts +++ b/apps/server/src/orchestration-v2/EffectOutbox.ts @@ -15,6 +15,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -420,6 +421,7 @@ export const layer: Layer.Layer = La WHERE effect_id = ${effectId} LIMIT 1 `.pipe( + NodeSqliteClient.readOnly, Effect.flatMap((rows) => { const row = rows[0]; return row === undefined @@ -437,6 +439,7 @@ export const layer: Layer.Layer = La WHERE command_id = ${commandId} ORDER BY created_at ASC, effect_id ASC `.pipe( + NodeSqliteClient.readOnly, Effect.flatMap((rows) => decodeRows("list", rows)), Effect.mapError((cause) => isEffectOutboxError(cause) @@ -571,7 +574,7 @@ export const layer: Layer.Layer = La SELECT MIN(candidate.available_at) AS available_at FROM orchestration_v2_effect_outbox AS candidate WHERE ${claimableCandidatePredicate()} - `.pipe(Effect.withTracerEnabled(false)); + `.pipe(NodeSqliteClient.readOnly, Effect.withTracerEnabled(false)); const availableAt = rows[0]?.available_at; if (availableAt === undefined || availableAt === null) return Option.none(); const parsed = DateTime.make(availableAt); diff --git a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts index accf2de72068..a37bcf8f2cd8 100644 --- a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts +++ b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts @@ -3218,3 +3218,83 @@ it.effect.each(["events", "receipts"] as const)( }).pipe(Effect.provide(maintenanceLayer)); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +it.effect("routes projection node lookups to the reader connection", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "projection-reader-" }); + const database = SqlitePersistence.layerFromPath(path.join(directory, "statev2.sqlite")); + const projectionLayer = ProjectionStore.layer.pipe(Layer.provideMerge(database)); + yield* Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const nodeId = NodeId.make("node:reader-routing"); + const node = { + id: nodeId, + threadId: ThreadId.make("thread:reader-routing"), + runId: null, + parentNodeId: null, + rootNodeId: nodeId, + kind: "subagent" as const, + status: "running" as const, + countsForRun: false, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }; + const spans: Array = []; + const observe: Statement.Transformer = (statement, _sql, _fiber, span) => { + const [query] = statement.compile(); + if (/orchestration_v2_projection_nodes WHERE node_id/.test(query)) { + spans.push(span); + } + return Effect.succeed(statement); + }; + yield* Effect.gen(function* () { + assert.isFalse(yield* projections.isNodeStored(node)); + }).pipe( + Effect.provideService(Statement.CurrentTransformer, observe), + Effect.provideService(Statement.SpanPropagationEnabled, true), + ); + assert.equal(spans.length, 1); + assert.isTrue( + spans.every((span) => span.attributes.get("db.connection") === "reader"), + JSON.stringify(spans.map((span) => span.attributes.get("db.connection"))), + ); + }).pipe(Effect.provide(projectionLayer)); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("loads a shell snapshot while the writer connection is occupied", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "shell-reader-" }); + const database = SqlitePersistence.layerFromPath(path.join(directory, "statev2.sqlite")); + const projectionLayer = ProjectionStore.layer.pipe(Layer.provideMerge(database)); + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const held = yield* Deferred.make(); + const release = yield* Deferred.make(); + const writer = yield* sql + .withTransaction( + sql`SELECT 1`.pipe( + Effect.andThen(Deferred.succeed(held, undefined)), + Effect.andThen(Deferred.await(release)), + ), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(held); + const snapshot = yield* projections.getShellSnapshot(); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(writer); + assert.deepEqual(snapshot.threads, []); + }).pipe(Effect.provide(projectionLayer)); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/orchestration-v2/ProjectStore.ts b/apps/server/src/orchestration-v2/ProjectStore.ts index 03206781e0fb..aa7b27de6048 100644 --- a/apps/server/src/orchestration-v2/ProjectStore.ts +++ b/apps/server/src/orchestration-v2/ProjectStore.ts @@ -9,6 +9,7 @@ import { ThreadEnvMode, } from "@t3tools/contracts"; import * as Context from "effect/Context"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -191,6 +192,7 @@ export const make = Effect.gen(function* () { const get: ProjectStoreV2["Service"]["get"] = (projectId, options) => selectRows({ projectId, includeDeleted: options?.includeDeleted === true }).pipe( + NodeSqliteClient.readOnly, Effect.map((rows) => Option.fromUndefinedOr(rows[0])), mapError("get"), ); @@ -199,12 +201,13 @@ export const make = Effect.gen(function* () { selectRows({ ...(options?.projectIds === undefined ? {} : { projectIds: options.projectIds }), includeDeleted: options?.includeDeleted === true, - }).pipe(mapError("list")); + }).pipe(NodeSqliteClient.readOnly, mapError("list")); const findActiveByWorkspaceRoot: ProjectStoreV2["Service"]["findActiveByWorkspaceRoot"] = ( workspaceRoot, ) => selectRows({ workspaceRoot, includeDeleted: false }).pipe( + NodeSqliteClient.readOnly, Effect.map((rows) => Option.fromUndefinedOr(rows[0])), mapError("findActiveByWorkspaceRoot"), ); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 92ef73ebf1ba..4f08378db528 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1,6 +1,7 @@ import { applyThreadPullRequestLinkUpdate } from "@t3tools/shared/threadPullRequests"; import { projectTurnItemForWire } from "./WireProjection.ts"; import * as Stream from "effect/Stream"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { makeThreadFind, findProjectedThreadItems } from "./ThreadFind.ts"; import type { OrchestrationV2SearchThreadInput, @@ -6511,47 +6512,55 @@ export const layer: Layer.Layer = ) .pipe(Effect.mapError((cause) => new ProjectionStoreReadError({ threadId, cause }))); + const readOnReader = + , A, E, R>( + method: (...args: Args) => Effect.Effect, + ) => + (...args: Args): Effect.Effect => + method(...args).pipe(NodeSqliteClient.readOnly); + return { apply, - getShellSnapshot, - readShellSnapshot, - getThreadShell, - getThread, - getSettlementCandidates, - getThreadsWithPullRequests, - getThreadWorktreePaths, + getShellSnapshot: readOnReader(getShellSnapshot), + readShellSnapshot: readOnReader(readShellSnapshot), + getThreadShell: readOnReader(getThreadShell), + getThread: readOnReader(getThread), + getSettlementCandidates: readOnReader(getSettlementCandidates), + getThreadsWithPullRequests: readOnReader(getThreadsWithPullRequests), + getThreadWorktreePaths: readOnReader(getThreadWorktreePaths), getThreadProjection, - getTurnStartContext, - getTurnStartHistory, - getRuntimeRecoveryProjection, - getRunningTurnContext, - getThreadProviderContext, - getRuntimeResponseContext, - getCheckpointContext, - getCheckpointCaptureContext, - getCheckpointRollbackContext, - getRunMessage, - canStartQueuedRun, - getPendingNativeUserInputs, - hasUnpairedRunInterruptRequest, - isNodeStored, - getMessageCount, - getNextTurnItemOrdinal, - getTurnItem, - getThreadRecords, - getRuntimeRequest, - getPlan, - getProviderControlContext, - getLimitRecoveryCandidates, - getRecoveryThreadIds, - getUnreadableThreadIds, - getThreadSnapshot, - getThreadSnapshotWindow, - getTimelinePage, - getThreadHistoryPage, - searchThread, + getTurnStartContext: readOnReader(getTurnStartContext), + getTurnStartHistory: readOnReader(getTurnStartHistory), + getRuntimeRecoveryProjection: readOnReader(getRuntimeRecoveryProjection), + getRunningTurnContext: readOnReader(getRunningTurnContext), + getThreadProviderContext: readOnReader(getThreadProviderContext), + getRuntimeResponseContext: readOnReader(getRuntimeResponseContext), + getCheckpointContext: readOnReader(getCheckpointContext), + getCheckpointCaptureContext: readOnReader(getCheckpointCaptureContext), + getCheckpointRollbackContext: readOnReader(getCheckpointRollbackContext), + getRunMessage: readOnReader(getRunMessage), + canStartQueuedRun: readOnReader(canStartQueuedRun), + getPendingNativeUserInputs: readOnReader(getPendingNativeUserInputs), + hasUnpairedRunInterruptRequest: readOnReader(hasUnpairedRunInterruptRequest), + isNodeStored: readOnReader(isNodeStored), + getMessageCount: readOnReader(getMessageCount), + getNextTurnItemOrdinal: readOnReader(getNextTurnItemOrdinal), + getTurnItem: readOnReader(getTurnItem), + getThreadRecords: (threadId, fields, filter) => + getThreadRecords(threadId, fields, filter).pipe(NodeSqliteClient.readOnly), + getRuntimeRequest: readOnReader(getRuntimeRequest), + getPlan: readOnReader(getPlan), + getProviderControlContext: readOnReader(getProviderControlContext), + getLimitRecoveryCandidates: readOnReader(getLimitRecoveryCandidates), + getRecoveryThreadIds: readOnReader(getRecoveryThreadIds), + getUnreadableThreadIds: readOnReader(getUnreadableThreadIds), + getThreadSnapshot: readOnReader(getThreadSnapshot), + getThreadSnapshotWindow: readOnReader(getThreadSnapshotWindow), + getTimelinePage: readOnReader(getTimelinePage), + getThreadHistoryPage: readOnReader(getThreadHistoryPage), + searchThread: readOnReader(searchThread), searchThreadStream, - getThreadAttachmentIds, + getThreadAttachmentIds: readOnReader(getThreadAttachmentIds), } satisfies ProjectionStoreV2Shape; }), ); diff --git a/apps/server/src/orchestration-v2/ThreadFind.ts b/apps/server/src/orchestration-v2/ThreadFind.ts index 288737471e03..2a663ae6d08f 100644 --- a/apps/server/src/orchestration-v2/ThreadFind.ts +++ b/apps/server/src/orchestration-v2/ThreadFind.ts @@ -14,6 +14,7 @@ import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as SqlClient from "effect/sql/SqlClient"; import { ProjectionStoreReadError, @@ -345,7 +346,7 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( } return { payloads, cwd, sequence, cacheKey, sourceThreadIds }; }, - sql.withTransaction, + (effect) => sql.withTransaction(effect).pipe(NodeSqliteClient.readOnly), (effect, input) => effect.pipe( Effect.mapError((cause) => diff --git a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts index 9f5b3ab38c9b..610be4c1ba3a 100644 --- a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts +++ b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts @@ -22,6 +22,7 @@ import { TurnItemId, } from "@t3tools/contracts"; import * as KeyedLock from "@t3tools/shared/KeyedLock"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -359,7 +360,7 @@ const make = Effect.gen(function* () { WHERE thread_id = ${threadId} AND role IN ('user', 'assistant') ORDER BY created_at ASC, message_id ASC - `; + `.pipe(NodeSqliteClient.readOnly); const listShellMessages = Effect.fn("LegacyV1ThreadImporter.listShellMessages")(function* ( threadId: ThreadId, @@ -669,6 +670,7 @@ const make = Effect.gen(function* () { WHERE legacy_import.transcript_imported_at IS NULL ) `.pipe( + NodeSqliteClient.readOnly, Effect.map((rows) => rows[0]?.count ?? 0), Effect.mapError( (cause) => new LegacyV1ThreadImportError({ operation: "inspect pending", cause }), @@ -690,7 +692,7 @@ const make = Effect.gen(function* () { FROM orchestration_v2_legacy_imports WHERE thread_id = ${threadId} LIMIT 1 - `; + `.pipe(NodeSqliteClient.readOnly); const imported = imports[0]; if (imported === undefined || imported.transcript_imported_at !== null) { if (imported !== undefined) { @@ -708,7 +710,7 @@ const make = Effect.gen(function* () { AND aggregate_kind = 'thread' AND stream_id = ${threadId} AND event_id LIKE ${`${IMPORT_EVENT_PREFIX}:message:%`} - `; + `.pipe(NodeSqliteClient.readOnly); const existing = new Set(existingRows.map((row) => row.event_id)); const missing = messages.filter( (message) => !existing.has(`${IMPORT_EVENT_PREFIX}:message:${message.message_id}`), @@ -772,7 +774,7 @@ const make = Effect.gen(function* () { FROM orchestration_v2_legacy_imports WHERE transcript_imported_at IS NULL ORDER BY shell_imported_at ASC, thread_id ASC - `; + `.pipe(NodeSqliteClient.readOnly); let importedThreadCount = 0; let importedMessageCount = 0; for (const row of rows) { diff --git a/apps/server/src/persistence/AuthPairingLinks.ts b/apps/server/src/persistence/AuthPairingLinks.ts index 623fc8159db9..4a8bc699b7d8 100644 --- a/apps/server/src/persistence/AuthPairingLinks.ts +++ b/apps/server/src/persistence/AuthPairingLinks.ts @@ -1,4 +1,5 @@ import * as Context from "effect/Context"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -294,6 +295,7 @@ export const make = Effect.gen(function* () { const listActive: AuthPairingLinkRepository["Service"]["listActive"] = (input) => listActivePairingLinkRows(input).pipe( + NodeSqliteClient.readOnly, Effect.mapError( toPersistenceSqlOrDecodeError( "AuthPairingLinkRepository.listActive:query", @@ -329,6 +331,7 @@ export const make = Effect.gen(function* () { const getByCredential: AuthPairingLinkRepository["Service"]["getByCredential"] = (input) => getPairingLinkRowByCredential(input).pipe( + NodeSqliteClient.readOnly, Effect.mapError( toPersistenceSqlOrDecodeError( "AuthPairingLinkRepository.getByCredential:query", diff --git a/apps/server/src/persistence/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index 0cb64a8e6475..687c2c019b9c 100644 --- a/apps/server/src/persistence/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -1,4 +1,5 @@ import * as Context from "effect/Context"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -416,6 +417,7 @@ export const make = Effect.gen(function* () { const getById: AuthSessionRepository["Service"]["getById"] = (input) => getSessionRowById(input).pipe( + NodeSqliteClient.readOnly, Effect.mapError( toPersistenceSqlOrDecodeError( "AuthSessionRepository.getById:query", @@ -443,6 +445,7 @@ export const make = Effect.gen(function* () { const listActive: AuthSessionRepository["Service"]["listActive"] = (input) => listActiveSessionRows(input).pipe( + NodeSqliteClient.readOnly, Effect.mapError( toPersistenceSqlOrDecodeError( "AuthSessionRepository.listActive:query", diff --git a/apps/server/src/persistence/OrchestrationCommandReceipts.ts b/apps/server/src/persistence/OrchestrationCommandReceipts.ts index a64e008cf918..4a39061a00cb 100644 --- a/apps/server/src/persistence/OrchestrationCommandReceipts.ts +++ b/apps/server/src/persistence/OrchestrationCommandReceipts.ts @@ -1,4 +1,5 @@ import { CommandId, IsoDateTime, NonNegativeInt, ProjectId, ThreadId } from "@t3tools/contracts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as SqlClient from "effect/sql/SqlClient"; import * as SqlSchema from "effect/sql/SqlSchema"; import * as Context from "effect/Context"; @@ -149,6 +150,7 @@ const makeOrchestrationCommandReceiptRepository = Effect.gen(function* () { input, ) => findReceiptByCommandId(input).pipe( + NodeSqliteClient.readOnly, Effect.mapError( toPersistenceSqlError("OrchestrationCommandReceiptRepository.getByCommandId:query"), ), diff --git a/apps/server/src/persistence/OrchestrationEventStore.ts b/apps/server/src/persistence/OrchestrationEventStore.ts index 394f627a4e42..265bb6ad0fbf 100644 --- a/apps/server/src/persistence/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/OrchestrationEventStore.ts @@ -15,6 +15,7 @@ import { type OrchestrationV2DomainEvent, } from "@t3tools/contracts"; import * as Context from "effect/Context"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as SqlClient from "effect/sql/SqlClient"; import * as SqlSchema from "effect/sql/SqlSchema"; import * as Effect from "effect/Effect"; @@ -441,7 +442,7 @@ const makeEventStore = Effect.gen(function* () { ])} ORDER BY sequence ASC LIMIT ${input.limit} - `; + `.pipe(NodeSqliteClient.readOnly); const appendAgentEvents: OrchestrationEventStore["Service"]["appendAgentEvents"] = (input) => Effect.forEach( @@ -617,6 +618,7 @@ const makeEventStore = Effect.gen(function* () { LIMIT ${Math.max(0, Math.floor(input.maxEvents)) + 1} ) `.pipe( + NodeSqliteClient.readOnly, Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.getAgentReplayStats:query")), Effect.map((rows) => ({ eventCount: rows[0]?.eventCount ?? 0, @@ -638,6 +640,7 @@ const makeEventStore = Effect.gen(function* () { OR (application_event_version = 2 AND aggregate_kind = 'thread') ) `.pipe( + NodeSqliteClient.readOnly, Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.getReplayStats:query")), Effect.map((rows) => ({ eventCount: rows[0]?.eventCount ?? 0, @@ -660,6 +663,7 @@ const makeEventStore = Effect.gen(function* () { AND aggregate_kind = 'thread' ${threadId === undefined ? sql`` : sql`AND stream_id = ${threadId}`} `.pipe( + NodeSqliteClient.readOnly, Effect.map((rows) => rows[0]?.sequence ?? 0), Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.latestAgentSequence:query")), ); @@ -671,6 +675,7 @@ const makeEventStore = Effect.gen(function* () { WHERE aggregate_kind = 'project' OR (application_event_version = 2 AND aggregate_kind = 'thread') `.pipe( + NodeSqliteClient.readOnly, Effect.map((rows) => rows[0]?.sequence ?? 0), Effect.mapError( toPersistenceSqlError("OrchestrationEventStore.latestApplicationSequence:query"), diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts index d500d2ba714b..861be3c01742 100644 --- a/apps/server/src/persistence/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -1,4 +1,5 @@ import * as Arr from "effect/Array"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -357,6 +358,7 @@ export const make = Effect.gen(function* () { const getByThreadId: ProviderSessionRuntimeRepository["Service"]["getByThreadId"] = (input) => getRuntimeRowByThreadId(input).pipe( + NodeSqliteClient.readOnly, Effect.mapError( toPersistenceSqlOrDecodeError( "ProviderSessionRuntimeRepository.getByThreadId:query", @@ -384,6 +386,7 @@ export const make = Effect.gen(function* () { const list: ProviderSessionRuntimeRepository["Service"]["list"] = (options) => listRuntimeRows({ excludeStopped: options?.excludeStopped === true }).pipe( + NodeSqliteClient.readOnly, Effect.mapError( toPersistenceSqlOrDecodeError( "ProviderSessionRuntimeRepository.list:query", diff --git a/apps/server/src/persistence/PullRequestFilesViewed.ts b/apps/server/src/persistence/PullRequestFilesViewed.ts index ca56912e29d4..99f79e4936ef 100644 --- a/apps/server/src/persistence/PullRequestFilesViewed.ts +++ b/apps/server/src/persistence/PullRequestFilesViewed.ts @@ -1,4 +1,5 @@ import * as Context from "effect/Context"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -90,6 +91,7 @@ const make = Effect.gen(function* () { return PullRequestFilesViewedRepository.of({ list: (input) => listRows(input).pipe( + NodeSqliteClient.readOnly, Effect.map((rows) => ({ files: rows.slice(0, MAX_FILES_VIEWED_ROWS), truncated: rows.length > MAX_FILES_VIEWED_ROWS, diff --git a/apps/server/src/persistence/PullRequestReviewGuides.ts b/apps/server/src/persistence/PullRequestReviewGuides.ts index 31c3651f9125..6dd2780eceb2 100644 --- a/apps/server/src/persistence/PullRequestReviewGuides.ts +++ b/apps/server/src/persistence/PullRequestReviewGuides.ts @@ -1,4 +1,5 @@ import * as Context from "effect/Context"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -52,6 +53,7 @@ const make = Effect.gen(function* () { return PullRequestReviewGuideRepository.of({ get: (key) => findGuide(key).pipe( + NodeSqliteClient.readOnly, Effect.map(Option.map((row) => row.guide)), Effect.mapError((cause) => Schema.isSchemaError(cause) diff --git a/apps/server/src/pullRequest/linkedThreads.ts b/apps/server/src/pullRequest/linkedThreads.ts index b250ef4a6c63..fa51017ebcd5 100644 --- a/apps/server/src/pullRequest/linkedThreads.ts +++ b/apps/server/src/pullRequest/linkedThreads.ts @@ -8,6 +8,7 @@ import { type ThreadPullRequestKey, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; @@ -39,7 +40,7 @@ export const listLinkedPullRequestThreads = Effect.fn("listLinkedPullRequestThre AND json_extract(link.value, '$.source') != 'stack-dismissed' AND t.deleted_at IS NULL ORDER BY "updatedAt" DESC, id ASC - `; + `.pipe(NodeSqliteClient.readOnly); const threads = rows .filter((row) => threadPullRequestKeysEqual(row, key)) .map(({ id, projectId, title, archivedAt }) => ({ id, projectId, title, archivedAt })); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 374d6b38027b..a4714e3ae417 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -29,6 +29,7 @@ import * as Crypto from "effect/Crypto"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Exit from "effect/Exit"; import * as Duration from "effect/Duration"; import * as Metric from "effect/Metric"; @@ -370,7 +371,7 @@ export const listDueTasks = Effect.fn("ScheduledTaskService.listDueTasks")(funct WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ${iso(now)} AND last_run_status <> 'running' ORDER BY next_run_at ASC, task_id ASC - `; + `.pipe(NodeSqliteClient.readOnly); const tasks: ScheduledTask[] = []; for (const row of rows) { const decoded = yield* Effect.result(decodeRow(row)); @@ -431,7 +432,8 @@ export const layer = Layer.effect( const changesPubSub = yield* PubSub.sliding(1); const notifyChanged = PubSub.publish(changesPubSub, undefined).pipe(Effect.asVoid); - const selectAllRows = () => sql` + const selectAllRows = () => + sql` SELECT task_id, title, @@ -459,7 +461,7 @@ export const layer = Layer.effect( -- Not updated_at: toggles, runs and edits rewrite it, and clients render -- this order as-is, so rows would move under the pointer. ORDER BY created_at DESC, task_id ASC - `; + `.pipe(NodeSqliteClient.readOnly); const listRows = Effect.fn("ScheduledTaskService.listRows")(function* () { const rows = yield* selectAllRows(); @@ -467,7 +469,8 @@ export const layer = Layer.effect( return yield* Effect.forEach(rows, (row) => decodeRow(row, origin), { concurrency: 1 }); }); - const getRows = (id: ScheduledTaskId) => sql` + const getRows = (id: ScheduledTaskId) => + sql` SELECT task_id, title, @@ -493,7 +496,7 @@ export const layer = Layer.effect( webhook_secret FROM scheduled_tasks WHERE task_id = ${id} - `; + `.pipe(NodeSqliteClient.readOnly); const findTask = Effect.fn("ScheduledTaskService.findTask")(function* (id: ScheduledTaskId) { const rows = yield* getRows(id).pipe( @@ -768,8 +771,9 @@ export const layer = Layer.effect( } if ( webhook !== undefined && - (yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE delivery_id = ${webhook.deliveryId} AND state = 'completed'`) - .length > 0 + (yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE delivery_id = ${webhook.deliveryId} AND state = 'completed'`.pipe( + NodeSqliteClient.readOnly, + )).length > 0 ) return { task: { ...active, lastRunStatus: "succeeded" as const }, threadId: null }; const parsedNextRunAt = @@ -1351,6 +1355,7 @@ export const layer = Layer.effect( FROM scheduled_task_webhook_deliveries d WHERE task_id = ${input.id} ORDER BY received_at DESC, rowid DESC `.pipe( + NodeSqliteClient.readOnly, Effect.map((rows) => ({ deliveries: rows .filter( @@ -1371,6 +1376,7 @@ export const layer = Layer.effect( WHERE p.delivery_id = d.delivery_id AND p.state IN ('pending', 'dispatching')) AS pending_window FROM scheduled_task_webhook_deliveries d WHERE task_id = ${input.id} AND delivery_id = ${input.deliveryId} `.pipe( + NodeSqliteClient.readOnly, Effect.mapError((cause) => taskError("Could not load webhook delivery.", { taskId: input.id, cause }), ), @@ -2065,7 +2071,7 @@ export const layer = Layer.effect( yield* sql`UPDATE scheduled_task_webhook_pending SET state = 'dispatching' WHERE state = 'pending' AND due_at <= ${now}`; const tasks = yield* sql<{ task_id: ScheduledTaskId }>` SELECT DISTINCT task_id FROM scheduled_task_webhook_pending WHERE state IN ('dispatching', 'running', 'completed', 'failed') - `; + `.pipe(NodeSqliteClient.readOnly); yield* Effect.forEach( tasks, ({ task_id: taskId }) => @@ -2089,7 +2095,7 @@ export const layer = Layer.effect( }>` SELECT delivery_id, task_json, request_json, state FROM scheduled_task_webhook_pending WHERE task_id = ${taskId} AND state IN ('dispatching', 'running', 'completed', 'failed') ORDER BY due_at, rowid LIMIT 1 - `; + `.pipe(NodeSqliteClient.readOnly); if (entry === undefined) return; const remove = sql`DELETE FROM scheduled_task_webhook_pending WHERE delivery_id = ${entry.delivery_id}`; if (entry.state === "completed" || entry.state === "failed") { diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 65c114c75ca8..47bbefa509e0 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -25,6 +25,7 @@ import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; import * as Effect from "effect/Effect"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -711,6 +712,7 @@ const make = Effect.gen(function* () { FROM provider_session_runtime WHERE provider_name IN ('cursor', 'grok', 'opencode') `.pipe( + NodeSqliteClient.readOnly, Effect.mapError( (cause) => new ServerSettingsError({ @@ -734,6 +736,7 @@ const make = Effect.gen(function* () { FROM projection_projects WHERE deleted_at IS NULL `.pipe( + NodeSqliteClient.readOnly, Effect.mapError( (cause) => new ServerSettingsError({ diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index a196321e3809..1a0a19682e4f 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -737,7 +737,7 @@ const make = Effect.gen(function* () { FROM orchestration_v2_projection_threads t JOIN projection_projects p ON p.project_id = t.project_id WHERE t.deleted_at IS NOT NULL - `; + `.pipe(NodeSqliteClient.readOnly); const deletedThreads = (yield* Effect.forEach(deletedRows, (row) => decodeCleanupThread(row.payload_json).pipe( Effect.map((thread) => ({ ...thread, workspaceRoot: row.workspaceRoot })), @@ -938,7 +938,7 @@ const make = Effect.gen(function* () { const pendingCleanup = yield* sql` SELECT 1 FROM orchestration_v2_effect_outbox WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled') LIMIT 1 - `; + `.pipe(NodeSqliteClient.readOnly); if (pendingCleanup.length > 0) return "thread deletion is still pending"; } else { const latestShells = yield* Effect.forEach(latest, (entry) => @@ -970,7 +970,7 @@ const make = Effect.gen(function* () { const sessionRows = yield* sql<{ payload_json: string }>` SELECT payload_json FROM orchestration_v2_projection_provider_sessions WHERE status != 'stopped' - `; + `.pipe(NodeSqliteClient.readOnly); const sessions = yield* Effect.forEach(sessionRows, (row) => decodeCleanupSession(row.payload_json), ); diff --git a/apps/server/src/workspace/KnownWorkspaces.ts b/apps/server/src/workspace/KnownWorkspaces.ts index d98939415d91..20df30331029 100644 --- a/apps/server/src/workspace/KnownWorkspaces.ts +++ b/apps/server/src/workspace/KnownWorkspaces.ts @@ -8,6 +8,7 @@ * @module KnownWorkspaces */ import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -105,7 +106,10 @@ export const make = Effect.gen(function* () { project_id AS "projectId" FROM orchestration_v2_projection_threads WHERE deleted_at IS NULL AND json_extract(payload_json, '$.worktreePath') IS NOT NULL - `.pipe(Effect.orElseSucceed(() => [])); + `.pipe( + NodeSqliteClient.readOnly, + Effect.orElseSucceed(() => []), + ); const projectWorktrees = new Map>(); const isProjectWorktree = Effect.fnUntraced(function* ( projectId: string, diff --git a/packages/shared/src/nodeSqliteClient.test.ts b/packages/shared/src/nodeSqliteClient.test.ts index c29652ae847b..1df72d51427f 100644 --- a/packages/shared/src/nodeSqliteClient.test.ts +++ b/packages/shared/src/nodeSqliteClient.test.ts @@ -8,6 +8,8 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as SqlClient from "effect/sql/SqlClient"; +import * as Statement from "effect/sql/Statement"; +import type * as Tracer from "effect/Tracer"; import { vi } from "vite-plus/test"; import * as SqliteClient from "./nodeSqliteClient.ts"; @@ -64,6 +66,41 @@ const countEntries = (sql: SqlClient.SqlClient) => const layer = it.layer(SqliteClient.layer({ filename: ":memory:" })); layer("NodeSqliteClient", (it) => { + it.effect("records the writer connection owner on a waiting query", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const held = yield* Deferred.make(); + const release = yield* Deferred.make(); + const holder = yield* sql + .withTransaction( + sql`SELECT 1`.pipe( + Effect.andThen(Deferred.succeed(held, undefined)), + Effect.andThen(Deferred.await(release)), + ), + ) + .pipe(Effect.withSpan("test.writerHolder"), Effect.forkChild); + yield* Deferred.await(held); + + const waitingSpan = yield* Deferred.make(); + const observeWaiter: Statement.Transformer = (statement, _sql, _fiber, span) => + Deferred.succeed(waitingSpan, span).pipe(Effect.as(statement)); + const waiter = yield* sql`SELECT 1`.pipe( + Effect.provideService(Statement.CurrentTransformer, observeWaiter), + Effect.provideService(Statement.SpanPropagationEnabled, true), + (effect) => Effect.forkChild(effect, { startImmediately: true }), + ); + const span = yield* Deferred.await(waitingSpan); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(holder); + yield* Fiber.join(waiter); + + assert.equal(span.attributes.get("db.lock_owner_at_start.name"), "test.writerHolder"); + assert.typeOf(span.attributes.get("db.lock_owner_at_start.fiber_id"), "number"); + assert.typeOf(span.attributes.get("db.lock_owner_at_start.trace_id"), "string"); + assert.typeOf(span.attributes.get("db.lock_owner_at_start.span_id"), "string"); + }), + ); + it.effect("retries preparing a query after the missing schema becomes available", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/packages/shared/src/nodeSqliteClient.ts b/packages/shared/src/nodeSqliteClient.ts index 03283a614ee6..59ef57e59fe6 100644 --- a/packages/shared/src/nodeSqliteClient.ts +++ b/packages/shared/src/nodeSqliteClient.ts @@ -70,15 +70,53 @@ interface Acquired { readonly lockWaitMs: number; } -const annotateAcquired = (fiber: Fiber.Fiber, acquired: Acquired | undefined) => { +interface ConnectionOwner { + readonly fiberId: number; + readonly name: string; + readonly traceId?: string; + readonly spanId?: string; +} + +const connectionOwner = (fiber: Fiber.Fiber): ConnectionOwner => { + const current = Context.getOrUndefined(fiber.context, Tracer.ParentSpan); + if (current?._tag !== "Span") return { fiberId: fiber.id, name: "untraced" }; + let span: Tracer.Span = current; + while (span.name === "sqlite" || span.name === "sql.transaction") { + const parent: Tracer.AnySpan | undefined = Option.getOrUndefined(span.parent); + if (parent?._tag !== "Span") break; + span = parent; + } + return { fiberId: fiber.id, name: span.name, traceId: span.traceId, spanId: span.spanId }; +}; + +const annotateAcquired = ( + fiber: Fiber.Fiber, + acquired: Acquired | undefined, + ownerAtWaitStart?: ConnectionOwner, +) => { const span = Context.getOrUndefined(fiber.context, Tracer.ParentSpan); if (span?._tag !== "Span" || acquired === undefined) return; span.attribute("db.connection", acquired.connection); span.attribute("db.lock_wait_ms", acquired.lockWaitMs); + if (ownerAtWaitStart !== undefined) { + span.attribute("db.lock_owner_at_start.fiber_id", ownerAtWaitStart.fiberId); + span.attribute("db.lock_owner_at_start.name", ownerAtWaitStart.name); + if (ownerAtWaitStart.traceId !== undefined) { + span.attribute("db.lock_owner_at_start.trace_id", ownerAtWaitStart.traceId); + } + if (ownerAtWaitStart.spanId !== undefined) { + span.attribute("db.lock_owner_at_start.span_id", ownerAtWaitStart.spanId); + } + } }; const TransactionAcquire = Context.Reference< - { acquired?: Acquired; restore: typeof Effect.interruptible | undefined } | undefined + | { + acquired?: Acquired; + ownerAtWaitStart: ConnectionOwner | undefined; + restore: typeof Effect.interruptible | undefined; + } + | undefined >("@t3tools/shared/nodeSqliteClient/TransactionAcquire", { defaultValue: () => undefined }); export class UnsupportedNodeSqliteVersionError extends Schema.TaggedError()( @@ -1070,6 +1108,7 @@ const make = Effect.fn("makeWithDatabase")(function* ( const readerSemaphore = yield* Semaphore.make(1); const writer = { name: "writer" as const, lock: semaphore, connection }; + let activeWriter: ConnectionOwner | undefined; const route = Effect.withFiber((fiber) => acquireReader !== undefined && Context.get(fiber.context, ReadOnly) ? Effect.map(acquireReader, (readerConnection) => @@ -1096,19 +1135,30 @@ const make = Effect.fn("makeWithDatabase")(function* ( Effect.withFiber((fiber) => { const clock = fiber.getRef(Clock.Clock); const start = clock.monotonicTimeNanosUnsafe(); + const ownerAtWaitStart = target.name === "writer" ? activeWriter : undefined; + const owner = target.name === "writer" ? connectionOwner(fiber) : undefined; const useConnection = Effect.suspend(() => { + if (target.name === "writer") activeWriter = owner; if ( fiber.getRef(References.TracerEnabled) && fiber.getRef(Statement.SpanPropagationEnabled) ) { - annotateAcquired(fiber, acquired(target, clock, start)); + annotateAcquired(fiber, acquired(target, clock, start), ownerAtWaitStart); } return use(target.connection); }); + const useOwnedConnection = + target.name === "writer" + ? Effect.onExit(useConnection, () => + Effect.sync(() => { + if (activeWriter === owner) activeWriter = undefined; + }), + ) + : useConnection; if (!options.checkpointWorker || target.name !== "writer") { - return target.lock.withPermits(1)(useConnection); + return target.lock.withPermits(1)(useOwnedConnection); } - const writerUse = Effect.onExit(useConnection, (exit) => + const writerUse = Effect.onExit(useOwnedConnection, (exit) => Effect.sync(() => { writerBorrowsSinceYield = Exit.hasInterrupts(exit) ? 0 @@ -1142,9 +1192,20 @@ const make = Effect.fn("makeWithDatabase")(function* ( const clock = fiber.getRef(Clock.Clock); const start = clock.monotonicTimeNanosUnsafe(); const pending = fiber.getRef(TransactionAcquire); + const ownerAtWaitStart = target.name === "writer" ? activeWriter : undefined; + const owner = target.name === "writer" ? connectionOwner(fiber) : undefined; const acquire = Effect.tap((pending?.restore ?? restore)(target.lock.take(1)), () => { - if (pending !== undefined) pending.acquired = acquired(target, clock, start); - return Scope.addFinalizer(scope, target.lock.release(1)); + if (target.name === "writer") activeWriter = owner; + if (pending !== undefined) { + pending.acquired = acquired(target, clock, start); + pending.ownerAtWaitStart = ownerAtWaitStart; + } + return Scope.addFinalizer( + scope, + Effect.sync(() => { + if (target.name === "writer" && activeWriter === owner) activeWriter = undefined; + }).pipe(Effect.andThen(target.lock.release(1))), + ); }); return Effect.as( // A scheduler yield after taking the permit must not precede its release finalizer. @@ -1174,11 +1235,16 @@ const make = Effect.fn("makeWithDatabase")(function* ( withTransaction: (effect: Effect.Effect) => Effect.uninterruptibleMask((restore) => { // SqlClient masks setup; acquisition must preserve the caller's interruptibility. - const pending: { acquired?: Acquired; restore: typeof Effect.interruptible | undefined } = { + const pending: { + acquired?: Acquired; + ownerAtWaitStart: ConnectionOwner | undefined; + restore: typeof Effect.interruptible | undefined; + } = { + ownerAtWaitStart: undefined, restore, }; const body = Effect.withFiber((fiber) => { - annotateAcquired(fiber, pending.acquired); + annotateAcquired(fiber, pending.acquired, pending.ownerAtWaitStart); pending.restore = undefined; return effect; }); From b7e802bdec917c948b6b4302676e2266ec01bc1d Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 23 Oct 2026 00:32:29 -0500 Subject: [PATCH 02/16] fix(claude): route approval feedback to the requesting agent (#23657) --- .../features/threads/ThreadDetailScreen.tsx | 54 +++++-- .../src/state/thread-task-permissions.test.ts | 38 +++++ .../src/state/use-selected-thread-requests.ts | 3 +- .../src/state/use-thread-composer-state.ts | 15 +- .../Adapters/ClaudeAdapterV2.test.ts | 66 ++++++++- .../Adapters/ClaudeAdapterV2.ts | 29 ++-- .../src/orchestration-v2/EffectOutbox.ts | 1 + .../src/orchestration-v2/EffectWorker.ts | 3 + .../Orchestrator.control-reads.test.ts | 136 +++++++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 78 ++++++++++ .../RuntimeRequestService.test.ts | 138 ++++++++++-------- .../orchestration-v2/RuntimeRequestService.ts | 2 + apps/web/src/components/chat/ChatComposer.tsx | 4 +- .../src/components/chat/useComposerSend.ts | 47 +++++- .../chat/usePendingThreadRequests.ts | 2 + docs/user/permission-modes.md | 2 + .../client-runtime/src/operations/commands.ts | 2 + .../src/state/threadRequests.test.ts | 43 ++++++ .../src/state/threadRequests.ts | 16 ++ packages/contracts/src/orchestrationV2.ts | 8 +- .../src/server/ProviderAdapter.ts | 1 + 21 files changed, 596 insertions(+), 92 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index e8f99bf4e642..57f671957a88 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -55,6 +55,7 @@ import { } from "@t3tools/shared/codexArtifactTemplates"; import { declinePendingApprovals, + sendApprovalFeedback, type ApprovalResponder, type ThreadUserInputQuestion, } from "@t3tools/client-runtime/state/thread-requests"; @@ -258,7 +259,10 @@ export interface ThreadDetailScreenProps { readonly onResumeQueue: (() => Promise) | null; readonly onSendMessage: ( followUp?: ActiveTurnComposerAction, - beforeDispatch?: () => Promise, + beforeDispatch?: ( + text: string, + hasAttachmentsOrContext: boolean, + ) => Promise, ) => Promise; readonly onReconnectEnvironment: () => void; readonly canSwitchThreadProvider: boolean; @@ -1199,18 +1203,40 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai const approvalsToDecline = props.activePendingApprovals; const declineBeforeDispatch = approvalsToDecline.length > 0 && props.activePendingUserInput === null - ? async () => - props.respondingApprovalId === null && - (await declinePendingApprovals(approvalsToDecline, props.onRespondToApproval)) && - selectedThreadKeyRef.current === targetThreadKey && - // A draft edited or pasted into during the decline isn't what the user sent. - stillSendable?.() !== false && - // An approval that arrived during the decline would block the redirect; the draft - // stays so the next send declines it too. - !activePendingApprovalsRef.current.some( - (approval) => - !approvalsToDecline.some((declined) => declined.requestId === approval.requestId), + ? async (text: string, hasAttachmentsOrContext: boolean) => { + if ( + selectedThreadKeyRef.current !== targetThreadKey || + stillSendable?.() === false || + props.respondingApprovalId !== null ) + return false; + const feedbackTarget = approvalsToDecline[0]; + if (feedbackTarget?.supportsFeedback) { + if (hasAttachmentsOrContext) { + Dialog.alert( + "Approval feedback", + "Send approval feedback without attachments or added context.", + ); + return false; + } + return (await sendApprovalFeedback(feedbackTarget, text, props.onRespondToApproval)) + ? ("responded" as const) + : false; + } + return ( + props.respondingApprovalId === null && + (await declinePendingApprovals(approvalsToDecline, props.onRespondToApproval)) && + selectedThreadKeyRef.current === targetThreadKey && + // A draft edited or pasted into during the decline isn't what the user sent. + stillSendable?.() !== false && + // An approval that arrived during the decline would block the redirect; the draft + // stays so the next send declines it too. + !activePendingApprovalsRef.current.some( + (approval) => + !approvalsToDecline.some((declined) => declined.requestId === approval.requestId), + ) + ); + } : undefined; const messageId = await props.onSendMessage(followUp, declineBeforeDispatch); if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) { @@ -1642,7 +1668,9 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai editorRef={composerEditorRef} placeholder={ props.activePendingApproval - ? "Send a message to decline and redirect the agent" + ? props.activePendingApproval.supportsFeedback + ? "Send feedback to decline this request" + : "Send a message to decline and redirect the agent" : "Ask the repo agent, or run a command…" } contentMaxWidth={contentMaxWidth} diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index eac2e40e9835..4c962cf5be65 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -18,6 +18,7 @@ import { } from "../../../../packages/client-runtime/src/state/orchestrationV2TestFixtures"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import type { PendingThreadRequests } from "@t3tools/client-runtime/state/thread-requests"; +import { sendApprovalFeedback } from "@t3tools/client-runtime/state/thread-requests"; import { AsyncResult, type Atom } from "effect/reactivity"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -202,6 +203,43 @@ beforeEach(() => { }); describe("mobile task permissions", () => { + it("consumes an approval reply without queueing a parent message", async () => { + state.grantedEnvironments.add("secondary"); + const composer = useThreadComposerState(); + const requests = useSelectedThreadRequests(); + const approval = { ...state.pendingRequests.approvals[0]!, supportsFeedback: true }; + const messageId = await composer.onSendMessage(undefined, async (text) => + (await sendApprovalFeedback(approval, text, requests.onRespondToApproval)) + ? "responded" + : false, + ); + expect(messageId).toBeNull(); + expect(state.approve).toHaveBeenCalledWith({ + environmentId: "secondary", + input: { + threadId: "thread", + requestId: "approval", + decision: "decline", + feedback: "Keep my draft", + }, + }); + expect(state.enqueue).not.toHaveBeenCalled(); + expect(state.clearDraft).toHaveBeenCalledOnce(); + }); + + it("preserves a draft changed during an approval reply and never sends it to the parent", async () => { + state.grantedEnvironments.add("secondary"); + const composer = useThreadComposerState(); + const beforeDispatch = async () => { + state.draft = { ...state.draft, text: "New feedback" }; + return "responded" as const; + }; + expect(await composer.onSendMessage(undefined, beforeDispatch)).toBeNull(); + expect(state.enqueue).not.toHaveBeenCalled(); + expect(state.clearDraft).not.toHaveBeenCalled(); + expect(state.draft.text).toBe("New feedback"); + }); + it("keeps a connected read-only task's draft instead of queueing it with another environment's grant", async () => { const composer = useThreadComposerState(); expect(await composer.onSendMessage()).toBeNull(); diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index 98bec9753325..1371b5206040 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -236,7 +236,7 @@ export function useSelectedThreadRequests() { ); const onRespondToApproval = useCallback( - async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => { + async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision, feedback?: string) => { if ( !selectedThreadShell || !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope) @@ -257,6 +257,7 @@ export function useSelectedThreadRequests() { threadId: selectedThreadShell.id, requestId, decision, + ...(feedback === undefined ? {} : { feedback }), }, }); setRespondingApprovalId((current) => (current === requestId ? null : current)); diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index afd952d43abd..ec71677013b4 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -526,7 +526,10 @@ export function useThreadComposerState() { const onSendMessage = async ( followUpOverride?: ActiveTurnComposerAction, - beforeDispatch?: () => Promise, + beforeDispatch?: ( + text: string, + hasAttachmentsOrContext: boolean, + ) => Promise, ) => { if ( selectedThreadShell && @@ -645,7 +648,11 @@ export function useThreadComposerState() { } if (beforeDispatch !== undefined) { - if (!(await beforeDispatch())) return null; + const response = await beforeDispatch( + text, + attachments.length > 0 || draft.context !== undefined, + ); + if (!response) return null; // A context paste still landing, or any draft or settings change while approvals were // declined, means this snapshot is no longer what the user sent. if (appAtomRegistry.get(composerContextImportsAtom)[threadKey]) return null; @@ -660,6 +667,10 @@ export function useThreadComposerState() { current.attachments.some((attachment, index) => attachment.id !== attachments[index]?.id) ) return null; + if (response === "responded") { + clearComposerDraftContent(threadKey); + return null; + } } // Resolved here rather than at drain time: the outbox can deliver minutes diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index b231fbfca9d9..0c8b2ad588ac 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -967,7 +967,10 @@ describe("ClaudeAdapterV2 append-system-prompt-file launch argument", () => { describe("ClaudeAdapterV2 approval cancellation", () => { it.effect("observes an approval signal that was already aborted", () => Effect.gen(function* () { - const decision = yield* Deferred.make(); + const decision = yield* Deferred.make<{ + decision: ProviderApprovalDecision; + feedback?: string; + }>(); const controller = new AbortController(); controller.abort(); @@ -976,7 +979,7 @@ describe("ClaudeAdapterV2 approval cancellation", () => { controller.signal, ); - assert.equal(result, "cancel"); + assert.deepEqual(result, { decision: "cancel" }); }), ); }); @@ -2873,12 +2876,14 @@ describe("ClaudeAdapterV2 background wake turns", () => { turn: Effect.Success>, toolName: string, toolInput: Record, + options?: { toolUseID: string; agentID?: string }, ) { const decision = yield* Effect.promise(() => turn.canUseTool(toolName, toolInput, { signal: new AbortController().signal, - toolUseID: "tool-plan-write", + toolUseID: options?.toolUseID ?? "tool-plan-write", requestId: "request-plan-write", + ...(options?.agentID === undefined ? {} : { agentID: options.agentID }), }), ).pipe(Effect.forkScoped); const first = yield* Effect.raceFirst( @@ -2954,6 +2959,61 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect( + "returns approval feedback to the requesting tool without sending a parent prompt", + () => + Effect.gen(function* () { + const turn = yield* openPlanTurn("full-access"); + const parentPermission = yield* requestWrite( + turn, + "Bash", + { command: "git status" }, + { toolUseID: "parent-command" }, + ); + assert.equal(parentPermission.first.type, "request"); + if (parentPermission.first.type !== "request") return; + const permission = yield* requestWrite( + turn, + "Bash", + { command: "rm -f $W/*/*.obj" }, + { toolUseID: "child-command", agentID: "child-worker" }, + ); + assert.equal(permission.first.type, "request"); + if (permission.first.type !== "request") return; + const request = permission.first.event.runtimeRequest; + assert.equal(request.responseCapability.type, "live"); + assert.isTrue( + request.responseCapability.type === "live" && + request.responseCapability.supportsFeedback, + ); + yield* turn.runtime.respondToRuntimeRequest({ + requestId: request.id, + decision: "decline", + feedback: "Run it in a safer manner.", + }); + const result = yield* Fiber.join(permission.decision); + assert.equal(result?.behavior, "deny"); + if (result?.behavior === "deny") { + assert.include(result.message, "Run it in a safer manner."); + assert.notProperty(result, "interrupt"); + } + assert.equal(turn.offeredMessages.length, 1); + yield* turn.runtime.respondToRuntimeRequest({ + requestId: parentPermission.first.event.runtimeRequest.id, + decision: "accept", + }); + const parentResult = yield* Fiber.join(parentPermission.decision); + assert.equal(parentResult?.behavior, "allow"); + if (parentResult?.behavior === "allow") + assert.equal(parentResult.toolUseID, "parent-command"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect("still captures ExitPlanMode when Plan approvals are never allowed", () => Effect.gen(function* () { const turn = yield* openPlanTurn("full-access", "never"); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 49b344961b3f..91c08f565a29 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2892,6 +2892,7 @@ export function permissionResultFromDecision(input: { readonly toolInput: Record; readonly toolUseID: string; readonly suggestions?: Parameters[2]["suggestions"]; + readonly feedback?: string; }): PermissionResult { if (input.decision === "accept" || input.decision === "acceptForSession") { return { @@ -2911,9 +2912,10 @@ export function permissionResultFromDecision(input: { return { behavior: "deny", message: - input.decision === "cancel" + input.feedback?.trim() || + (input.decision === "cancel" ? "User cancelled tool execution." - : "User declined tool execution.", + : "User declined tool execution."), toolUseID: input.toolUseID, decisionClassification: "user_reject", ...(input.decision === "cancel" ? { interrupt: true } : {}), @@ -2942,11 +2944,11 @@ function toSessionPermissionUpdates( } export const awaitClaudeApprovalDecision = Effect.fn("awaitClaudeApprovalDecision")(function* ( - decision: Deferred.Deferred, + decision: Deferred.Deferred, signal: AbortSignal, ) { - const cancellation = Effect.callback((resume) => { - const abort = () => resume(Effect.succeed("cancel")); + const cancellation = Effect.callback((resume) => { + const abort = () => resume(Effect.succeed({ decision: "cancel" })); signal.addEventListener("abort", abort, { once: true }); if (signal.aborted) { abort(); @@ -3625,12 +3627,17 @@ function rememberClaudeSubagentLaunch( } } +type ClaudeApprovalResponse = { + readonly decision: ProviderApprovalDecision; + readonly feedback?: string; +}; + type PendingClaudeRuntimeRequest = | { readonly type: "approval"; readonly requestId: OrchestrationV2RuntimeRequest["id"]; readonly requestKind: ProviderRequestKind; - readonly decision: Deferred.Deferred; + readonly decision: Deferred.Deferred; } | { readonly type: "user_input"; @@ -6467,6 +6474,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( responseCapability: { type: "live", providerSessionId, + ...(input.questions === undefined ? { supportsFeedback: true } : {}), }, createdAt, resolvedAt: null, @@ -8986,7 +8994,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( callbackOptions, }), }); - const decision = yield* Deferred.make(); + const decision = yield* Deferred.make(); yield* Ref.update(pendingRuntimeRequests, (current) => { const updated = new Map(current); updated.set(String(artifacts.request.id), { @@ -9033,7 +9041,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return permissionResultFromDecision({ toolName, - decision: resolvedDecision, + ...resolvedDecision, toolInput, toolUseID: callbackOptions.toolUseID, ...(callbackOptions.suggestions === undefined @@ -10283,7 +10291,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }), }); } - yield* Deferred.succeed(pending.decision, requestInput.decision); + yield* Deferred.succeed(pending.decision, { + decision: requestInput.decision, + ...(requestInput.feedback === undefined ? {} : { feedback: requestInput.feedback }), + }); }, (effect, requestInput) => effect.pipe( diff --git a/apps/server/src/orchestration-v2/EffectOutbox.ts b/apps/server/src/orchestration-v2/EffectOutbox.ts index c89beae951e7..6818ba361df3 100644 --- a/apps/server/src/orchestration-v2/EffectOutbox.ts +++ b/apps/server/src/orchestration-v2/EffectOutbox.ts @@ -80,6 +80,7 @@ export const OrchestrationEffectRequestV2 = Schema.Union([ providerSessionId: ProviderSessionId, requestId: RuntimeRequestId, decision: Schema.optional(ProviderApprovalDecision), + feedback: Schema.optional(Schema.String), answers: Schema.optional(ProviderUserInputAnswers), }), Schema.Struct({ diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index ebcb50debd11..42128b883711 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -384,6 +384,9 @@ export const layerExecutor: Layer.Layer< ...(effect.request.decision === undefined ? {} : { decision: effect.request.decision }), + ...(effect.request.feedback === undefined + ? {} + : { feedback: effect.request.feedback }), ...(effect.request.answers === undefined ? {} : { answers: effect.request.answers }), diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index eda76857a9ef..6259fd78db62 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -57,6 +57,142 @@ const layerTest = Layer.mergeAll( ), ); +it.effect( + "records approval feedback and targets only its runtime request without dispatching a parent turn", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const threadId = ThreadId.make("thread:approval-feedback"); + const sessionId = ProviderSessionId.make("session:approval-feedback"); + const now = yield* DateTime.now; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-approval-feedback"), + threadId, + projectId: ProjectId.make("project:approval-feedback"), + title: "Parent", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* projections.apply({ + id: EventId.make("session:approval-feedback"), + type: "provider-session.attached", + threadId, + occurredAt: now, + payload: { + id: sessionId, + driver: adapter.driver, + providerInstanceId: instanceId, + status: "ready", + cwd: "/repo", + model: modelSelection.model, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + }); + for (const name of ["child", "other"]) { + yield* projections.apply({ + id: EventId.make(`request:${name}`), + type: "runtime-request.updated", + threadId, + occurredAt: now, + payload: { + id: RuntimeRequestId.make(name), + nodeId: NodeId.make(`node:${name}`), + providerTurnId: null, + nativeRequestRef: null, + kind: "command", + status: "pending", + responseCapability: { + type: "live", + providerSessionId: sessionId, + supportsFeedback: name === "child", + }, + createdAt: now, + resolvedAt: null, + }, + }); + } + const commandId = CommandId.make("respond:approval-feedback"); + const feedback = "Run it in a safer manner."; + for (const [index, invalid] of [ + { requestId: RuntimeRequestId.make("other"), decision: "decline" as const, feedback }, + { requestId: RuntimeRequestId.make("child"), decision: "accept" as const, feedback }, + { requestId: RuntimeRequestId.make("child"), decision: "decline" as const, feedback: " " }, + ].entries()) { + const rejectedId = CommandId.make(`invalid-feedback:${index}`); + const rejected = yield* orchestrator + .dispatch({ + type: "runtime-request.respond", + commandId: rejectedId, + threadId, + ...invalid, + }) + .pipe(Effect.result); + assert.isTrue(Result.isFailure(rejected)); + assert.deepEqual(yield* outbox.listByCommandId(rejectedId), []); + } + yield* orchestrator.dispatch({ + type: "runtime-request.respond", + commandId, + threadId, + requestId: RuntimeRequestId.make("child"), + decision: "decline", + feedback, + }); + const projection = yield* projections.getThreadProjection(threadId); + assert.equal( + projection.runtimeRequests.find((request) => request.id === "child")?.feedback, + feedback, + ); + assert.equal( + projection.runtimeRequests.find((request) => request.id === "other")?.status, + "pending", + ); + assert.equal(projection.messages.at(-1)?.text, feedback); + const feedbackMessageId = MessageId.make("approval-feedback:child"); + assert.ok( + projection.visibleTurnItems.some( + ({ item }) => + item.type === "user_message" && + item.messageId === feedbackMessageId && + item.text === feedback, + ), + ); + const history = yield* projections.getTimelinePage(threadId, { limit: 20, view: "messages" }); + assert.ok( + history.items.some( + ({ item }) => + item.type === "user_message" && + item.messageId === feedbackMessageId && + item.text === feedback, + ), + ); + assert.equal(projection.runs.length, 0); + assert.deepEqual( + (yield* outbox.listByCommandId(commandId)).map((effect) => effect.request), + [ + { + type: "runtime-request.respond", + providerSessionId: sessionId, + requestId: RuntimeRequestId.make("child"), + decision: "decline", + feedback, + }, + ], + ); + }).pipe(Effect.provide(layerTest)), +); + it.effect("marks a never-visited thread's first completion unread", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index f9cb1bf9dd9d..5dd48ba548d2 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -7673,6 +7673,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: runtimeRequest.responseCapability.reason, }); } + if ( + command.feedback !== undefined && + (runtimeRequest.responseCapability.type !== "live" || + !runtimeRequest.responseCapability.supportsFeedback || + runtimeRequest.kind === "user_input" || + command.decision !== "decline" || + !command.feedback.trim()) + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Feedback requires a live approval that supports it and a decline decision.", + }); + } const providerSessionId = runtimeRequest.responseCapability.type === "live" ? runtimeRequest.responseCapability.providerSessionId @@ -7702,10 +7716,73 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio status: "resolved" as const, resolvedAt: now, ...(command.decision === undefined ? {} : { decision: command.decision }), + ...(command.feedback === undefined ? {} : { feedback: command.feedback }), ...(answers === undefined ? {} : { answers }), }; const emitEvent = emit(events, command); const requestNode = context.node; + if (command.feedback !== undefined) { + const messageId = MessageId.make(`approval-feedback:${command.requestId}`); + // Record the reply for every client without dispatching a prompt to the parent agent. + yield* emitEvent({ + type: "message.updated", + threadId: command.threadId, + ...(requestNode?.runId == null ? {} : { runId: requestNode.runId }), + nodeId: runtimeRequest.nodeId, + occurredAt: now, + payload: { + id: messageId, + threadId: command.threadId, + runId: requestNode?.runId ?? null, + nodeId: runtimeRequest.nodeId, + role: "user", + text: command.feedback, + attachments: [], + streaming: false, + createdBy: "user", + creationSource: "server", + createdAt: now, + updatedAt: now, + }, + }); + yield* emitEvent({ + type: "turn-item.updated", + threadId: command.threadId, + ...(requestNode?.runId == null ? {} : { runId: requestNode.runId }), + nodeId: runtimeRequest.nodeId, + occurredAt: now, + payload: { + id: idAllocator.derive.userTurnItem({ messageId }), + threadId: command.threadId, + runId: requestNode?.runId ?? null, + nodeId: runtimeRequest.nodeId, + providerThreadId: + context.item?.providerThreadId ?? requestNode?.providerThreadId ?? null, + providerTurnId: runtimeRequest.providerTurnId, + nativeItemRef: null, + parentItemId: null, + ordinal: yield* projectionStore + .getNextTurnItemOrdinal(command.threadId) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ), + status: "completed", + title: null, + startedAt: now, + completedAt: now, + updatedAt: now, + type: "user_message", + messageId, + inputIntent: "steer", + text: command.feedback, + attachments: [], + createdBy: "user", + creationSource: "server", + }, + }); + } const resolvedNodeStatus = command.decision === "decline" || command.decision === "cancel" ? ("cancelled" as const) @@ -7872,6 +7949,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio providerSessionId, requestId: command.requestId, ...(command.decision === undefined ? {} : { decision: command.decision }), + ...(command.feedback === undefined ? {} : { feedback: command.feedback }), ...(answers === undefined ? {} : { answers }), }, } satisfies PendingOrchestrationEffectV2, diff --git a/apps/server/src/orchestration-v2/RuntimeRequestService.test.ts b/apps/server/src/orchestration-v2/RuntimeRequestService.test.ts index 70ddb5e84ef4..0c6aa9d97b16 100644 --- a/apps/server/src/orchestration-v2/RuntimeRequestService.test.ts +++ b/apps/server/src/orchestration-v2/RuntimeRequestService.test.ts @@ -16,70 +16,88 @@ import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as RuntimeRequestService from "./RuntimeRequestService.ts"; -it.effect("rejects expired runtime requests before invoking the live adapter", () => { - const threadId = ThreadId.make("thread-runtime-request-expired"); - const providerSessionId = ProviderSessionId.make("provider-session-runtime-request-expired"); - const requestId = RuntimeRequestId.make("request-expired"); - const respondToRuntimeRequest = vi.fn( - (_input: ProviderAdapter.ProviderAdapterV2RuntimeRequestResponseInput) => Effect.void, - ); - const getSession = vi.fn(() => - Effect.succeed( - Option.some({ - respondToRuntimeRequest, - } as never), - ), - ); - const projection = { - runtimeRequests: [ - { - id: requestId, - nodeId: NodeId.make("node-runtime-request-expired"), - providerTurnId: null, - nativeRequestRef: null, - kind: "command", - status: "expired", - responseCapability: { - type: "live", - providerSessionId, +it.effect.each(["expired", "resolved"] as const)( + "responds only to resolved requests and preserves feedback (%s)", + (status) => { + const threadId = ThreadId.make("thread-runtime-request-expired"); + const providerSessionId = ProviderSessionId.make("provider-session-runtime-request-expired"); + const requestId = RuntimeRequestId.make("request-expired"); + const respondToRuntimeRequest = vi.fn( + (_input: ProviderAdapter.ProviderAdapterV2RuntimeRequestResponseInput) => Effect.void, + ); + const getSession = vi.fn(() => + Effect.succeed( + Option.some({ + respondToRuntimeRequest, + } as never), + ), + ); + const projection = { + runtimeRequests: [ + { + id: requestId, + nodeId: NodeId.make("node-runtime-request-expired"), + providerTurnId: null, + nativeRequestRef: null, + kind: "command", + status, + responseCapability: { + type: "live", + providerSessionId, + }, + createdAt: DateTime.makeUnsafe("2026-07-29T00:00:00.000Z"), + resolvedAt: DateTime.makeUnsafe("2026-07-29T00:00:01.000Z"), }, - createdAt: DateTime.makeUnsafe("2026-07-29T00:00:00.000Z"), - resolvedAt: DateTime.makeUnsafe("2026-07-29T00:00:01.000Z"), - }, - ], - } as unknown as OrchestrationV2ThreadProjection; - const layerTest = RuntimeRequestService.layer.pipe( - Layer.provide( - Layer.mergeAll( - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getRuntimeRequest: (_threadId, requestId) => - Effect.succeed(projection.runtimeRequests.find((request) => request.id === requestId)), - }), - Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ - get: getSession, - }), + ], + } as unknown as OrchestrationV2ThreadProjection; + const layerTest = RuntimeRequestService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getRuntimeRequest: (_threadId, requestId) => + Effect.succeed( + projection.runtimeRequests.find((request) => request.id === requestId), + ), + }), + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ + get: getSession, + }), + ), ), - ), - ); + ); - return Effect.gen(function* () { - const service = yield* RuntimeRequestService.RuntimeRequestServiceV2; - const error = yield* service - .respond({ + return Effect.gen(function* () { + const service = yield* RuntimeRequestService.RuntimeRequestServiceV2; + const response = service.respond({ threadId, providerSessionId, requestId, - decision: "accept", - }) - .pipe(Effect.flip); + decision: "decline", + feedback: "Use an explicit path.", + }); + if (status === "resolved") { + yield* response; + assert.deepEqual(respondToRuntimeRequest.mock.calls, [ + [ + { + requestId, + decision: "decline", + feedback: "Use an explicit path.", + }, + ], + ]); + return; + } + const error = yield* response.pipe(Effect.flip); - assert.equal(error.reason, "request-not-ready"); - assert.equal( - error.message, - `Runtime request ${requestId} on thread ${threadId} is not ready for response execution.`, - ); - assert.isUndefined(error.cause); - assert.equal(getSession.mock.calls.length, 0); - assert.equal(respondToRuntimeRequest.mock.calls.length, 0); - }).pipe(Effect.provide(layerTest)); -}); + assert.equal(error.reason, "request-not-ready"); + assert.equal( + error.message, + `Runtime request ${requestId} on thread ${threadId} is not ready for response execution.`, + ); + assert.isUndefined(error.cause); + assert.equal(getSession.mock.calls.length, 0); + assert.equal(respondToRuntimeRequest.mock.calls.length, 0); + }).pipe(Effect.provide(layerTest)); + }, +); diff --git a/apps/server/src/orchestration-v2/RuntimeRequestService.ts b/apps/server/src/orchestration-v2/RuntimeRequestService.ts index 9c96ea5d6b40..e58b7211ea31 100644 --- a/apps/server/src/orchestration-v2/RuntimeRequestService.ts +++ b/apps/server/src/orchestration-v2/RuntimeRequestService.ts @@ -54,6 +54,7 @@ export interface RuntimeRequestServiceV2Shape { readonly providerSessionId: ProviderSessionId; readonly requestId: RuntimeRequestId; readonly decision?: ProviderApprovalDecision; + readonly feedback?: string; readonly answers?: ProviderUserInputAnswers; }) => Effect.Effect; } @@ -118,6 +119,7 @@ export const layer: Layer.Layer< yield* session.value.respondToRuntimeRequest({ requestId: input.requestId, ...(input.decision === undefined ? {} : { decision: input.decision }), + ...(input.feedback === undefined ? {} : { feedback: input.feedback }), ...(input.answers === undefined ? {} : { answers: input.answers }), }); }, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b74e691ddd13..162069163f9b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3801,7 +3801,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPaste={onComposerPasteStable} placeholder={ isComposerApprovalState - ? "Send a message to decline and redirect the agent" + ? activePendingApproval?.supportsFeedback + ? "Send feedback to decline this request" + : "Send a message to decline and redirect the agent" : activePendingProgress ? isChoiceOnlyPendingQuestion ? "Choose an option above" diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index d3bcbdf47020..ed7def98c88c 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -28,7 +28,10 @@ import { } from "@t3tools/contracts"; import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations"; import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; -import { declinePendingApprovals } from "@t3tools/client-runtime/state/thread-requests"; +import { + declinePendingApprovals, + sendApprovalFeedback, +} from "@t3tools/client-runtime/state/thread-requests"; import { parseCodexFeedbackCommand, submitCodexFeedback, @@ -475,6 +478,10 @@ export function useComposerSend(input: ComposerSendInput) { return; } const multipleModelSelections = sendCtx.multipleModelSelections; + const approvalFeedbackTarget = pendingApprovalsRef.current.find( + (approval) => + approval.supportsFeedback && approval.requestId === sendCtx.shownPendingApprovalIds[0], + ); const multipleModelBlock = multipleModelSelections === null ? null @@ -945,7 +952,7 @@ export function useComposerSend(input: ComposerSendInput) { sendCtx?.shownPendingApprovalIds.includes(approval.requestId), ) : []; - if (approvalsToDecline.length > 0) { + if (approvalsToDecline.length > 0 || approvalFeedbackTarget !== undefined) { // A draft edited since send, or a paste still landing in it, is no longer the message // the user sent. const attachmentIdsAtSend = composerAttachmentsSnapshot.map((attachment) => attachment.id); @@ -981,6 +988,42 @@ export function useComposerSend(input: ComposerSendInput) { const approvalResponding = approvalsToDecline.some((approval) => respondingRequestIdsRef.current.includes(approval.requestId), ); + if (approvalFeedbackTarget !== undefined) { + if (composerAttachmentsSnapshot.length > 0 || outgoingMessageContext !== undefined) { + setThreadError( + threadIdForSend, + "Send approval feedback without attachments or added context.", + ); + sendInFlightRef.current = false; + return; + } + if ( + changedBeforeDecline || + approvalResponding || + currentRouteThreadKeyRef.current !== routeThreadKey + ) { + sendInFlightRef.current = false; + return; + } + const sent = await sendApprovalFeedback( + approvalFeedbackTarget, + messageTextForSend, + onRespondToApprovalStable, + ).catch(() => false); + sendInFlightRef.current = false; + if ( + sent && + !draftChangedSinceSend() && + currentRouteThreadKeyRef.current === routeThreadKey && + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) === + draftBeforeDecline + ) { + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + } + return; + } const declined = !changedBeforeDecline && !approvalResponding && diff --git a/apps/web/src/components/chat/usePendingThreadRequests.ts b/apps/web/src/components/chat/usePendingThreadRequests.ts index 3b1e73e59389..0f29e61e5033 100644 --- a/apps/web/src/components/chat/usePendingThreadRequests.ts +++ b/apps/web/src/components/chat/usePendingThreadRequests.ts @@ -300,6 +300,7 @@ export function usePendingThreadRequests(input: { const onRespondToApproval = async ( requestId: RuntimeRequestId, decision: ProviderApprovalDecision, + feedback?: string, ) => { if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return; @@ -318,6 +319,7 @@ export function usePendingThreadRequests(input: { threadId: activeThreadId, requestId, decision, + ...(feedback === undefined ? {} : { feedback }), }, }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index 150c857db944..a15850973fb2 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -37,6 +37,8 @@ by the permission mode. See [ACP Registry permissions](./providers-acp.md#permis Claude can still ask in **Full access** when its settings disable bypass permissions or require approval for an action. +While a Claude approval is pending, type a text reply to decline that request and give feedback +directly to the agent that asked, including a subagent. Other pending requests stay unanswered. Antigravity can still send native approval requests in **Full access**. It only offers remembered approvals for actions that support them. diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index db343d2d0f8f..8663b433d2c2 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -185,6 +185,7 @@ export interface InterruptThreadTurnInput extends ThreadCommandInput { export interface RespondToThreadApprovalInput extends ThreadCommandInput { readonly requestId: RuntimeRequestId; readonly decision: ProviderApprovalDecision; + readonly feedback?: string; } export interface RespondToThreadUserInputInput extends ThreadCommandInput { @@ -873,6 +874,7 @@ export const respondToThreadApproval = Effect.fn("EnvironmentCommands.respondToT threadId: input.threadId, requestId: input.requestId, decision: input.decision, + ...(input.feedback === undefined ? {} : { feedback: input.feedback }), }); }, ); diff --git a/packages/client-runtime/src/state/threadRequests.test.ts b/packages/client-runtime/src/state/threadRequests.test.ts index 1d3db9ce27d4..b4a6447c2fce 100644 --- a/packages/client-runtime/src/state/threadRequests.test.ts +++ b/packages/client-runtime/src/state/threadRequests.test.ts @@ -13,6 +13,7 @@ import { createQuestionHistoryProjector, derivePendingThreadRequests, declinePendingApprovals, + sendApprovalFeedback, seedUserInputDraftAnswers, type ApprovalResponder, type ThreadPendingApproval, @@ -72,6 +73,48 @@ describe("declining approvals before sending", () => { ).toBe(true); expect(respond).not.toHaveBeenCalled(); }); + + it("sends feedback only to the displayed request, leaving other approvals pending", async () => { + const respond = vi.fn(async () => AsyncResult.success(undefined)); + const pending = derivePendingThreadRequests({ + runtimeRequests: [ + { + ...projection.runtimeRequests[0]!, + kind: "command", + responseCapability: { + type: "live", + providerSessionId: ProviderSessionId.make("claude-session"), + supportsFeedback: true, + }, + }, + { ...projection.runtimeRequests[0]!, id: second.requestId, kind: "command" }, + ], + turnItems: [], + }); + expect(await sendApprovalFeedback(pending.approvals[0]!, " Safer please. ", respond)).toBe( + true, + ); + expect(respond.mock.calls).toEqual([[approval.requestId, "decline", "Safer please."]]); + }); + + it("keeps feedback unsent when unsupported, stale, empty or rejected", async () => { + const respond = vi.fn(async () => AsyncResult.failure(Cause.fail("failed"))); + expect(await sendApprovalFeedback(approval, "Safer please.", respond)).toBe(false); + expect( + await sendApprovalFeedback( + { ...approval, supportsFeedback: true, responseCapability: "not_resumable" }, + "Safer please.", + respond, + ), + ).toBe(false); + expect(await sendApprovalFeedback({ ...approval, supportsFeedback: true }, " ", respond)).toBe( + false, + ); + expect(respond).not.toHaveBeenCalled(); + expect( + await sendApprovalFeedback({ ...approval, supportsFeedback: true }, "Safer please.", respond), + ).toBe(false); + }); }); describe("initial user input drafts", () => { diff --git a/packages/client-runtime/src/state/threadRequests.ts b/packages/client-runtime/src/state/threadRequests.ts index 5a9289fe396e..e14f54715712 100644 --- a/packages/client-runtime/src/state/threadRequests.ts +++ b/packages/client-runtime/src/state/threadRequests.ts @@ -15,8 +15,20 @@ import type { ThreadStructure, ThreadStructureVisibleTurnItem } from "./models.t export type ApprovalResponder = ( requestId: RuntimeRequestId, decision: ProviderApprovalDecision, + feedback?: string, ) => Promise | undefined>; +/** Feedback belongs to the displayed request, which may be a native child's tool call. */ +export async function sendApprovalFeedback( + approval: ThreadPendingApproval, + feedback: string, + respond: ApprovalResponder, +): Promise { + if (!approval.supportsFeedback || approval.responseCapability !== "live" || !feedback.trim()) + return false; + return (await respond(approval.requestId, "decline", feedback.trim()))?._tag === "Success"; +} + /** Declines every approval a redirect replaces; any one still pending would block the agent. */ export async function declinePendingApprovals( approvals: ReadonlyArray, @@ -40,6 +52,7 @@ export interface ThreadPendingApproval { readonly appName?: string; readonly options?: ReadonlyArray; readonly responseCapability: "live" | "not_resumable"; + readonly supportsFeedback?: boolean; } export const FULL_ACCESS_OVERRIDE_QUESTION = "Why am I being asked this?"; @@ -142,6 +155,9 @@ export function derivePendingThreadRequests( ? { options: item.options } : {}), responseCapability: responseCapability === "live" ? "live" : "not_resumable", + ...(request.responseCapability.type === "live" && request.responseCapability.supportsFeedback + ? { supportsFeedback: true } + : {}), }); } diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 881a316f59ae..cb748cc5005e 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1020,13 +1020,18 @@ export const OrchestrationV2RuntimeRequest = Schema.Struct({ ]), status: Schema.Literals(["pending", "resolved", "expired", "cancelled"]), responseCapability: Schema.Union([ - Schema.Struct({ type: Schema.Literal("live"), providerSessionId: ProviderSessionId }), + Schema.Struct({ + type: Schema.Literal("live"), + providerSessionId: ProviderSessionId, + supportsFeedback: Schema.optional(Schema.Boolean), + }), Schema.Struct({ type: Schema.Literal("message") }), Schema.Struct({ type: Schema.Literal("not_resumable"), reason: Schema.String }), ]), createdAt: Schema.DateTimeUtc, resolvedAt: Schema.NullOr(Schema.DateTimeUtc), decision: Schema.optional(ProviderApprovalDecision), + feedback: Schema.optional(Schema.String), answers: Schema.optional(ProviderUserInputAnswers), }); export type OrchestrationV2RuntimeRequest = typeof OrchestrationV2RuntimeRequest.Type; @@ -2921,6 +2926,7 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, requestId: RuntimeRequestId, decision: Schema.optional(ProviderApprovalDecision), + feedback: Schema.optional(Schema.String), answers: Schema.optional(ProviderUserInputAnswers), attachmentsByQuestionId: Schema.optional(UserInputAttachments), }), diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index db5a18f77b46..314705eca3af 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -455,6 +455,7 @@ export interface ProviderAdapterV2InterruptInput { export interface ProviderAdapterV2RuntimeRequestResponseInput { readonly requestId: RuntimeRequestId; readonly decision?: ProviderApprovalDecision; + readonly feedback?: string; readonly answers?: ProviderUserInputAnswers; readonly response?: unknown; } From 0c25c2974ace440cc1f94dddf395977ca2e5c604 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 23 Oct 2026 00:32:53 -0500 Subject: [PATCH 03/16] fix(web): align pull request file counts with diff stats (#23905) --- .../src/components/pullRequest/PullRequestDetailLayout.tsx | 6 +++--- .../src/components/pullRequest/PullRequestDetailPanel.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx b/apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx index 92bd0ab324d5..c9bcfc1a378f 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx @@ -44,9 +44,9 @@ export function PullRequestDetailHeaderBody({ /> {head} - - - + + + {files} {diffStat} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 01ff0a961ba8..0e3372b81a1a 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1440,14 +1440,14 @@ export function PullRequestDetailPanel({ {detail.headBranch} - + - + {detail.changedFiles.toLocaleString()} Date: Fri, 23 Oct 2026 01:33:15 -0400 Subject: [PATCH 04/16] fix(web): task durations over an hour no longer wrap in the tasks panel (#13472) Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Julius Marminge --- apps/web/src/components/chat/ComposerTasksBadge.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ComposerTasksBadge.tsx b/apps/web/src/components/chat/ComposerTasksBadge.tsx index 704bfc38e4a1..008370aa10b4 100644 --- a/apps/web/src/components/chat/ComposerTasksBadge.tsx +++ b/apps/web/src/components/chat/ComposerTasksBadge.tsx @@ -201,7 +201,7 @@ export const ComposerTasksContent = memo(function ComposerTasksContent({ {step.durationMs !== undefined From 53245cce537af0e7dc722c2850749127a4c90681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B4=81=E6=9C=88=E6=98=9F?= Date: Fri, 23 Oct 2026 13:33:51 +0800 Subject: [PATCH 05/16] fix(server): preserve Codex sign-in guidance in usage notices (#17055) Co-authored-by: Codex (gpt-6-astra) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- apps/server/src/provider/CodexProvider.ts | 1 + apps/server/src/provider/ProviderRegistry.test.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 4a60f12891ee..81d6842692e2 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -796,6 +796,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu ? makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed", + ...(accountStatus.message ? { message: accountStatus.message } : {}), ...(snapshot.rateLimits ? { message: snapshot.rateLimits.failure } : {}), }) : codexRateLimitsToLimits({ diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 8bcde5f9420c..65ceac5012ab 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -400,6 +400,10 @@ it.layer( status.message, "Codex CLI is not authenticated. Run `codex login` and try again.", ); + assert.deepStrictEqual(status.usageLimits?.unavailable, { + reason: "probeFailed", + message: "Codex CLI is not authenticated. Run `codex login` and try again.", + }); }), ); From 7e52513a13d972c6c3f9685a263e09fcefa2769e Mon Sep 17 00:00:00 2001 From: Nand Date: Fri, 23 Oct 2026 07:35:11 +0200 Subject: [PATCH 06/16] fix(server): restore Claude subscription usage through proxies (#12069) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- apps/server/src/usage/cliproxyApi.test.ts | 5 +++-- apps/server/src/usage/cliproxyApi.ts | 7 ++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index ba7f5de41ace..bca612a044f4 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -317,8 +317,9 @@ describe("CLIProxyAPI built-in management API", () => { Effect.gen(function* () { const test = fixture({ accounts: [{ ...accounts[0]!, provider: "claude" }], - upstream: () => ({ - status: 200, + // The usage endpoint answers 429 to requests without a Claude Code user agent. + upstream: (request) => ({ + status: request.header?.["User-Agent"]?.startsWith("claude-code/") ? 200 : 429, body: { five_hour: { utilization: 10, resets_at: null }, seven_day: { utilization: 50, resets_at: "2099-01-01T00:00:00Z" }, diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index f097aa7a358c..4c981b96dfb6 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -214,7 +214,12 @@ export const makeCliproxyApi = Effect.gen(function* () { ? { "Chatgpt-Account-Id": account.id_token.chatgpt_account_id } : {}), } - : { Authorization: "Bearer $TOKEN$", "anthropic-beta": "oauth-2025-04-20" }; + : { + Authorization: "Bearer $TOKEN$", + "anthropic-beta": "oauth-2025-04-20", + // The OAuth usage endpoint can return 429 without a Claude Code user agent. + "User-Agent": "claude-code/2.1.273", + }; const raw = yield* management(config, "api-call", { auth_index: account.auth_index, method: data === undefined ? "GET" : "POST", From 71a283aed253f23be1860a31d4ea48f1c06e0b1c Mon Sep 17 00:00:00 2001 From: Igor Makowski <56691628+Mnigos@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:41:49 +0200 Subject: [PATCH 07/16] fix(server): Forgejo skipped jobs no longer keep a pull request watch pending (#16668) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- .../ForgejoSourceControlProvider.test.ts | 61 +++++++++++++++++++ .../src/server/forgejoPullRequestJson.ts | 27 ++++++-- 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts index dfef1329efc5..337f9c515e6a 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts @@ -278,6 +278,67 @@ it.effect.each([ }).pipe(Effect.provide(requiredChecksLayer(Effect.fail(cause)))); }); +it.effect("reads Forgejo jobs that did not run as finished checks", () => + Effect.gen(function* () { + const provider = yield* ForgejoPullRequestProvider.make; + const read = provider.getChangeRequestChecks; + if (read === undefined) return yield* Effect.die("checks read missing"); + const result = yield* read({ + cwd: "/repo", + repository: "acme/web", + host: "forgejo.test", + number: 1, + }); + assert.deepStrictEqual( + result.checks.map((check) => [check.name, check.status]), + [ + ["deploy", "skipped"], + ["audit", "neutral"], + ["docs", "pending"], + ], + ); + }).pipe( + Effect.provide( + Layer.mock(ForgejoCli.ForgejoCli)({ + api: (input) => { + const status = (context: string, state: string) => ({ + context, + status: state, + description: null, + target_url: null, + updated_at: "2026-10-11T00:00:00Z", + }); + const body = input.path.endsWith("pulls/1") + ? { + number: 1, + title: "Checks", + body: "", + html_url: "https://forgejo.test/acme/web/pulls/1", + user: null, + state: "open", + merged: false, + head: { ref: "feature", sha: "head", repo: null }, + base: { ref: "main", sha: "base", repo: null }, + created_at: "2026-10-11T00:00:00Z", + updated_at: "2026-10-11T00:00:00Z", + closed_at: null, + merged_at: null, + labels: [], + } + : input.path.endsWith("page=1") + ? [ + status("deploy", "skipped"), + status("audit", "warning"), + status("docs", "future-state"), + ] + : []; + return encodeJsonEffect(body).pipe(Effect.orDie, Effect.map(processOutput)); + }, + }), + ), + ), +); + it.effect("opens Forgejo Actions check links on the pull request's server", () => Effect.gen(function* () { const provider = yield* ForgejoPullRequestProvider.make; diff --git a/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts b/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts index 00244c17d999..a0984bc8736f 100644 --- a/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts +++ b/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts @@ -4,6 +4,7 @@ import * as Option from "effect/Option"; import type { PullRequestActor, PullRequestCheck, + PullRequestCheckStatus, PullRequestComment, PullRequestCommit, PullRequestReaction, @@ -213,6 +214,25 @@ export function forgejoCommit(commit: typeof ForgejoCommit.Type): PullRequestCom }; } +// Forgejo Actions reports a job that did not run as `skipped` and a cancelled one as `failure`, +// and `warning` is finished without being a pass. Only `pending` and states we do not know may +// hold a watch open. +function forgejoCheckStatus(state: string): PullRequestCheckStatus { + switch (state) { + case "success": + return "success"; + case "failure": + case "error": + return "failure"; + case "skipped": + return "skipped"; + case "warning": + return "neutral"; + default: + return "pending"; + } +} + export function forgejoChecks( statuses: ReadonlyArray, pullRequestUrl: string, @@ -227,12 +247,7 @@ export function forgejoChecks( url: status.target_url ? (URL.parse(status.target_url, pullRequestUrl)?.href ?? null) : null, - status: - status.status === "success" - ? "success" - : status.status === "failure" || status.status === "error" - ? "failure" - : "pending", + status: forgejoCheckStatus(status.status), }, })), ); From a68e6e97c501d265e1edccb47b96f8365c8e38f4 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 22 Oct 2026 23:17:23 -0700 Subject: [PATCH 08/16] fix(mobile): move Android undo toasts above bottom navigation (#23928) --- .../home/ThreadUndoNotice.android.tsx | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 apps/mobile/src/features/home/ThreadUndoNotice.android.tsx diff --git a/apps/mobile/src/features/home/ThreadUndoNotice.android.tsx b/apps/mobile/src/features/home/ThreadUndoNotice.android.tsx new file mode 100644 index 000000000000..d1caf4e664b7 --- /dev/null +++ b/apps/mobile/src/features/home/ThreadUndoNotice.android.tsx @@ -0,0 +1,128 @@ +import { useLayoutEffect, useState } from "react"; +import { StyleSheet, View } from "react-native"; +import { GestureDetector, usePanGesture } from "react-native-gesture-handler"; +import Animated, { + cancelAnimation, + Easing, + FadeOut, + ReduceMotion, + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, + type EntryAnimationsValues, +} from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Toast } from "../../components/ui/Toast"; +import { MOBILE_MOTION } from "../../lib/designTokens"; +import { + dismissThreadUndoNotice, + readThreadUndoNotice, + useThreadUndoNotice, +} from "./thread-undo-notice"; + +const SWIPE_TIMING = { duration: 160, reduceMotion: ReduceMotion.System }; +const ENTER_TIMING = { + duration: MOBILE_MOTION.enter, + easing: Easing.out(Easing.cubic), + reduceMotion: ReduceMotion.System, +}; +const NOTICE_EXITING = FadeOut.duration(MOBILE_MOTION.exit).reduceMotion(ReduceMotion.System); +const noticeEntering = (values: EntryAnimationsValues) => { + "worklet"; + return { + initialValues: { originY: values.targetOriginY + values.targetHeight + 12 }, + animations: { originY: withTiming(values.targetOriginY, ENTER_TIMING) }, + }; +}; + +export function ThreadUndoNotice(props: { readonly visible: boolean }) { + const notice = useThreadUndoNotice(); + // Replacing a notice must also cancel the native gesture tracking the old one. + const [instance, setInstance] = useState({ notice, key: 0 }); + if (instance.notice !== notice) setInstance({ notice, key: instance.key + 1 }); + if (!props.visible || notice === null) return null; + return ; +} + +function SwipeableUndoNotice(props: { + readonly notice: NonNullable>; +}) { + const { notice } = props; + const insets = useSafeAreaInsets(); + const translation = useSharedValue(0); + const width = useSharedValue(0); + + useLayoutEffect( + () => () => { + cancelAnimation(translation); + }, + [translation], + ); + + const dismiss = () => { + // A newer action can replace the notice while the swipe is animating out. + if (readThreadUndoNotice() === notice) dismissThreadUndoNotice(); + }; + const gesture = usePanGesture({ + activeOffsetX: [-10, 10], + failOffsetY: [-12, 12], + onUpdate: (event) => { + translation.set(event.translationX); + }, + onDeactivate: (event) => { + if ( + !event.canceled && + (Math.abs(event.translationX) > width.value * 0.25 || + (Math.abs(event.velocityX) > 800 && event.translationX * event.velocityX > 0)) + ) { + const direction = Math.sign(event.translationX || event.velocityX); + translation.set( + withTiming(direction * (width.value + 24), SWIPE_TIMING, (finished) => { + if (finished) runOnJS(dismiss)(); + }), + ); + } else { + translation.set(withTiming(0, SWIPE_TIMING)); + } + }, + }); + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: translation.value }], + })); + + // Clip the entrance above navigation; the notice intentionally covers the anchored FAB. + return ( + { + width.set(event.nativeEvent.layout.width); + }} + > + + + {/* Keep the dismiss target separate so TalkBack can also focus Undo. */} + { + if (nativeEvent.actionName === "dismiss") dismiss(); + }} + pointerEvents="none" + style={StyleSheet.absoluteFill} + /> + void notice.undo() }} + /> + + + + ); +} From 9a6380263b0304494151d297a53210c10b1d07ef Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 23:29:40 -0700 Subject: [PATCH 09/16] perf(server): skip unused Git output line parsing (#23953) Co-authored-by: Jet Co-authored-by: Claude Fable 5.1 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 73 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 14 ++-- 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 84861d6dd4ae..6bd0137154da 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -87,6 +87,79 @@ const makeFailedHandle = (stderr: string) => stderr: Stream.encodeText(Stream.make(stderr)), }); +describe("Git output collection", () => { + // Git writes in arbitrary chunk sizes; one byte per chunk splits every + // multibyte character across reads. + const byteChunks = (text: string) => + Stream.fromIterable(Array.from(new TextEncoder().encode(text), (byte) => Uint8Array.of(byte))); + const makeDriver = (output: { stdout?: string; stderr?: string }) => + makeGitVcsDriverCore().pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + ...makeSuccessfulHandle(""), + stdout: byteChunks(output.stdout ?? ""), + stderr: byteChunks(output.stderr ?? ""), + }), + ), + ), + ), + ); + + it.effect("returns the same split UTF-8 and NUL output with and without line callbacks", () => + Effect.gen(function* () { + const stdout = "a\\b\0雪\nlast "; + const stderr = "Receiving é\r\nUpdating 雪\rdone"; + const driver = yield* makeDriver({ stdout, stderr }); + const input = { operation: "test.chunkedGitOutput", cwd: "/repo", args: ["status"] }; + const expected = { + exitCode: 0, + stdout, + stderr, + stdoutTruncated: false, + stderrTruncated: false, + }; + + assert.deepEqual(yield* driver.execute(input), expected); + + const stdoutLines: string[] = []; + const stderrLines: string[] = []; + const withCallbacks = yield* driver.execute({ + ...input, + progress: { + onStdoutLine: (line) => Effect.sync(() => void stdoutLines.push(line)), + onStderrLine: (line) => Effect.sync(() => void stderrLines.push(line)), + }, + }); + assert.deepEqual(withCallbacks, expected); + assert.deepEqual(stdoutLines, ["a\\b\0雪", "last "]); + assert.deepEqual(stderrLines, ["Receiving é", "Updating 雪", "done"]); + }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + ); + + it.effect("caps output without line callbacks", () => + Effect.gen(function* () { + const driver = yield* makeDriver({ stderr: "é雪\rdone" }); + const input = { + operation: "test.cappedGitOutput", + cwd: "/repo", + args: ["status"], + maxOutputBytes: 3, + }; + + const truncated = yield* driver.execute({ ...input, appendTruncationMarker: true }); + assert.equal(truncated.stderr, "é\uFFFD"); + assert.isTrue(truncated.stderrTruncated); + + const error = yield* driver.execute(input).pipe(Effect.flip); + assert.equal(error.outputLength, 4); + assert.include(error.detail, "exceeded 3 bytes"); + }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + ); +}); + const makeTmpDir = ( prefix = "git-vcs-driver-test-", ): Effect.Effect => diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 62798374d2aa..b5a523876a58 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -900,8 +900,10 @@ const collectOutput = Effect.fnUntraced(function* ( const decoded = decoder.decode(chunkToDecode, { stream: !truncated }); text += decoded; - lineBuffer += lineDecoder ? lineDecoder.decode(chunk, { stream: true }) : decoded; - yield* emitCompleteLines(false); + if (onLine) { + lineBuffer += lineDecoder ? lineDecoder.decode(chunk, { stream: true }) : decoded; + yield* emitCompleteLines(false); + } }); yield* Stream.runForEach(stream, processChunk).pipe( @@ -917,9 +919,11 @@ const collectOutput = Effect.fnUntraced(function* ( const remainder = truncated ? "" : decoder.decode(); text += remainder; - lineBuffer += remainder; - if (lineDecoder) lineBuffer += lineDecoder.decode(); - yield* emitCompleteLines(true); + if (onLine) { + lineBuffer += remainder; + if (lineDecoder) lineBuffer += lineDecoder.decode(); + yield* emitCompleteLines(true); + } return { text, truncated, From e64a6d4ca84e8f7adab8066173d432e76ebf1996 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 23:44:50 -0700 Subject: [PATCH 10/16] fix(server): abort interrupted ACP MCP fetches (#23955) Co-authored-by: kiiwee <6160452+kiiwee@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- apps/server/src/mcp/AcpMcpStdioBridge.test.ts | 49 ++++++++++++++++++- apps/server/src/mcp/AcpMcpStdioBridge.ts | 6 ++- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/apps/server/src/mcp/AcpMcpStdioBridge.test.ts b/apps/server/src/mcp/AcpMcpStdioBridge.test.ts index 0368c8971420..0f1bedc17c63 100644 --- a/apps/server/src/mcp/AcpMcpStdioBridge.test.ts +++ b/apps/server/src/mcp/AcpMcpStdioBridge.test.ts @@ -2,10 +2,11 @@ import * as NodeStream from "node:stream"; import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; -import { runAcpMcpStdioBridge } from "./AcpMcpStdioBridge.ts"; +import { mcpToolSession, runAcpMcpStdioBridge } from "./AcpMcpStdioBridge.ts"; function makeHarness(responder: (request: Request) => Promise | Response) { const input = new NodeStream.PassThrough(); @@ -29,6 +30,13 @@ function makeHarness(responder: (request: Request) => Promise | Respon return { input, written, requests, bridge }; } +function pendingFetch(started: Deferred.Deferred) { + return (_url: string, init?: RequestInit): Promise => { + Deferred.doneUnsafe(started, Effect.succeed(init?.signal ?? undefined)); + return new Promise(() => {}); + }; +} + describe("AcpMcpStdioBridge", () => { it.effect("forwards requests and replays the session id and protocol version", () => Effect.gen(function* () { @@ -282,4 +290,43 @@ describe("AcpMcpStdioBridge", () => { expect(JSON.parse(written[0]!)).toMatchObject({ id: 7, result: {} }); }), ); + + it.effect("aborts the pending fetch when a tool call is interrupted", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const fiber = yield* mcpToolSession({ + endpoint: "http://127.0.0.1:1/mcp", + authorization: "Bearer bridge-test", + clientName: "t3-code-acp-cli", + fetchImplementation: pendingFetch(started), + }).pipe( + Effect.flatMap((session) => session.call("orchestrator_capabilities", {})), + Effect.scoped, + Effect.forkChild, + ); + + const signal = yield* Deferred.await(started); + yield* Fiber.interrupt(fiber); + expect(signal?.aborted).toBe(true); + }), + ); + + it.effect("aborts the pending fetch when the stdio bridge is interrupted", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const input = new NodeStream.PassThrough(); + const fiber = yield* runAcpMcpStdioBridge({ + endpoint: "http://127.0.0.1:1/mcp", + authorization: "Bearer bridge-test", + input, + output: { write: () => undefined }, + fetchImplementation: pendingFetch(started), + }).pipe(Effect.forkChild); + + input.write(`${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" })}\n`); + const signal = yield* Deferred.await(started); + yield* Fiber.interrupt(fiber); + expect(signal?.aborted).toBe(true); + }), + ); }); diff --git a/apps/server/src/mcp/AcpMcpStdioBridge.ts b/apps/server/src/mcp/AcpMcpStdioBridge.ts index 2db36ce5abc2..ea9a4c93b00f 100644 --- a/apps/server/src/mcp/AcpMcpStdioBridge.ts +++ b/apps/server/src/mcp/AcpMcpStdioBridge.ts @@ -92,11 +92,12 @@ export const mcpToolSession = ( message: unknown, ): Effect.fn.Return, AcpMcpBridgeError> { const response = yield* Effect.tryPromise({ - try: () => + try: (signal) => fetchImplementation(options.endpoint, { method: "POST", headers: { "content-type": "application/json", ...headers() }, body: JSON.stringify(message), + signal, }), catch: bridgeError, }); @@ -212,7 +213,7 @@ export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect. )( function* (line: string, envelope: JsonRpcEnvelope) { const response = yield* Effect.tryPromise({ - try: () => + try: (signal) => fetchImplementation(options.endpoint, { method: "POST", headers: { @@ -223,6 +224,7 @@ export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect. ...(protocolVersion === null ? {} : { "mcp-protocol-version": protocolVersion }), }, body: line, + signal, }), catch: bridgeError, }); From 56c365cc29de58746d9888567aed1a71f07d9370 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 22 Oct 2026 23:53:50 -0700 Subject: [PATCH 11/16] fix(web): new threads no longer hang when a project's machine is offline (#23967) Co-authored-by: Claude Opus 5.5 --- apps/web/src/hooks/useHandleNewThread.test.ts | 37 +++++++++++++++++-- apps/web/src/hooks/useHandleNewThread.ts | 9 ++++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 5abfd0768849..678d72b038da 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -5,8 +5,9 @@ const testState = vi.hoisted(() => { let completeProjectFileRead: (value: null) => void = () => undefined; let projectFileRead = Promise.resolve(null); let projectRemoved = false; + let connectedEnvironmentIds = ["environment-primary", "environment-ssh"]; let targetSettings = { - defaultThreadEnvMode: "local" as "local" | "worktree", + defaultThreadEnvMode: "local" as "local" | "worktree" | null, newWorktreesStartFromOrigin: false, defaultModelSelection: null, defaultRuntimeMode: "full-access" as RuntimeMode, @@ -46,6 +47,12 @@ const testState = vi.hoisted(() => { get projectRemoved() { return projectRemoved; }, + get connectedEnvironmentIds() { + return connectedEnvironmentIds; + }, + disconnect(environmentId: string) { + connectedEnvironmentIds = connectedEnvironmentIds.filter((id) => id !== environmentId); + }, removeProject() { projectRemoved = true; }, @@ -55,12 +62,13 @@ const testState = vi.hoisted(() => { reset( nextStoredDraft: typeof storedDraft, workspaceDefaults = { - envMode: "local" as "local" | "worktree", + envMode: "local" as "local" | "worktree" | null, startFromOrigin: false, }, ) { storedDraft = nextStoredDraft; projectRemoved = false; + connectedEnvironmentIds = ["environment-primary", "environment-ssh"]; targetSettings = { defaultThreadEnvMode: workspaceDefaults.envMode, newWorktreesStartFromOrigin: workspaceDefaults.startFromOrigin, @@ -94,7 +102,12 @@ const testServerConfigs = () => ]); vi.mock("../rpc/atomRegistry", () => ({ - appAtomRegistry: { get: () => testServerConfigs() }, + appAtomRegistry: { + get: (atom: unknown) => + atom === "connected-environment-ids" + ? testState.connectedEnvironmentIds + : testServerConfigs(), + }, })); vi.mock("@effect/atom-react", () => ({ @@ -187,6 +200,9 @@ vi.mock("../state/entities", () => { useThread: () => null, }; }); +vi.mock("../state/presentation", () => ({ + environmentSummaries: { connectedEnvironmentIdsAtom: "connected-environment-ids" }, +})); vi.mock("../state/server", () => ({ environmentServerConfigsAtom: {}, primaryServerSettingsAtom: "primary-settings", @@ -244,3 +260,18 @@ describe.each([ expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); }); }); + +describe("useNewThreadHandler", () => { + it("opens the draft without waiting on a project file from an offline machine", async () => { + testState.reset(null, { envMode: null, startFromOrigin: false }); + testState.disconnect("environment-ssh"); + + const opened = await useNewThreadHandler()({ + environmentId: "environment-ssh", + projectId: "project-remote", + } as never); + + expect(opened).toEqual({ draftId: "draft-delayed", threadId: "thread-delayed" }); + expect(testState.router.navigate).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index cfb98fc83bd0..f079e65c8129 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -36,6 +36,7 @@ import { } from "../lib/chatThreadActions"; import { readT3ProjectFile } from "../lib/t3ProjectFileDefaults"; import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentSummaries } from "../state/presentation"; import { environmentServerConfigsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; @@ -139,8 +140,14 @@ export function useNewThreadHandler() { destinationDraftId, }); const resolveDefaultEnvMode = async (): Promise => { + // Environment queries wait for a connection, so reading the project file on an + // offline machine would never settle and the draft would never open. const consultProjectFile = - project !== undefined && projectSettings.settings.defaultThreadEnvMode === null; + project !== undefined && + projectSettings.settings.defaultThreadEnvMode === null && + appAtomRegistry + .get(environmentSummaries.connectedEnvironmentIdsAtom) + .includes(project.environmentId); const projectFile = consultProjectFile ? await readT3ProjectFile(project.environmentId, project.workspaceRoot) : null; From 6d9ec7bdf9a633da430a47149f271e1e36ef3c79 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 23 Oct 2026 02:09:55 -0500 Subject: [PATCH 12/16] fix(pi): stop blank input placeholders from failing the run (#23235) Co-authored-by: Claude Opus 5.5 --- .../provider-pi/src/server/adapter.test.ts | 34 +++++++++++++++++++ packages/provider-pi/src/server/adapter.ts | 6 +++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 9c81bab0abb5..be502f953373 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -3126,6 +3126,40 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); + it.effect("asks an input dialog's title when its placeholder is blank", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + const title = "Which database?\n1. Postgres\n2. Type something.\nType your answer:"; + yield* fake.emit({ + type: "extension_ui_request", + id: "ui-input", + method: "input", + title, + placeholder: "", + }); + const event = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", + ); + if (event.type !== "turn_item.updated" || event.turnItem.type !== "user_input_request") { + return assert.fail("expected a user input request"); + } + assert.equal(decodeUserInputQuestions(event.turnItem.questions)[0]?.question, title); + yield* runtime.respondToRuntimeRequest({ + requestId: event.turnItem.requestId, + answers: { "ui-input": "SQLite" }, + }); + const response = yield* fake.takeRequest("extension_ui_response"); + assert.equal(response["value"], "SQLite"); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + it.effect("keeps the complete editor prefill editable and returns exact edited text", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index a2a3caf9267e..6cac78ea9451 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -3322,7 +3322,11 @@ function piQuestion( value: "", }, ]; - const question = recordString(event, "message") ?? recordString(event, "placeholder") ?? title; + // Extensions may send a blank placeholder, which is not a valid question. + const question = + [recordString(event, "message"), recordString(event, "placeholder")].find( + (text) => text !== undefined && text.trim().length > 0, + ) ?? title; return { id: questionId, header: title, From 656f3f40945654f540a98b75ec11afff399e1baa Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 23 Oct 2026 02:10:23 -0500 Subject: [PATCH 13/16] fix(cursor): fail turns that end in a transport error after commentary (#23273) Co-authored-by: Claude Opus 5.5 --- .../src/server/adapter.test.ts | 35 +++++++++++++++---- .../provider-cursor/src/server/adapter.ts | 7 +++- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index a297af469cf4..0686b0c831dd 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -256,7 +256,23 @@ describe("CursorAdapterV2", () => { "Cursor Agent SDK run.wait failed: Too many requests (resource_exhausted, HTTP 429)", }, }, - ])("fails the turn with $reason", ({ wait, failure }) => + { + reason: "a transport dump sent after earlier commentary", + updates: [ + { type: "text-delta", text: "Checking the build first." }, + { type: "step-completed", stepId: 1, stepDurationMs: 1 }, + { type: "text-delta", text: "Error: RetriableError: WritableIterable is closed" }, + ] satisfies ReadonlyArray, + wait: Effect.succeed({ + id: "native-cursor-run", + requestId: "native-request", + status: "finished" as const, + model: { id: "claude-fable-5" }, + durationMs: 1, + }), + failure: { class: "transport_error", message: "Provider turn failed." }, + }, + ])("fails the turn with $reason", ({ updates = [], wait, failure }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-failure-" }); @@ -280,12 +296,17 @@ describe("CursorAdapterV2", () => { agentId: "native-cursor-failure", listMessages: Effect.succeed([]), close: Effect.void, - send: () => - Effect.succeed({ - agentId: "native-cursor-failure", - runId: "native-cursor-run", - wait, - cancel: Effect.void, + send: (input) => + Effect.gen(function* () { + for (const update of updates) { + yield* input.onDelta!(update).pipe(Effect.orDie); + } + return { + agentId: "native-cursor-failure", + runId: "native-cursor-run", + wait, + cancel: Effect.void, + }; }), }), }), diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 9ff5ba75f466..825596c04e6d 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -867,7 +867,7 @@ interface ActiveCursorTurn { readonly tools: Map; readonly subagents: Map; readonly assistant: ActiveCursorTextStream; - readonly assistantReply: CursorTransportFailure; + assistantReply: CursorTransportFailure; readonly reasoning: ActiveCursorTextStream; interrupted: boolean; finalized: boolean; @@ -1916,6 +1916,11 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( } switch (update.type) { case "text-delta": + // Classify each assistant message on its own, so earlier + // commentary does not hide a later standalone transport dump. + if (context.assistant.current === null) { + context.assistantReply = new CursorTransportFailure(); + } context.assistantReply.push(update.text); yield* completeReasoning(context); yield* appendTextSegment({ From 49597c77db579062bb70a26e416df7efbb14db71 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 23 Oct 2026 00:11:26 -0700 Subject: [PATCH 14/16] test(forgejo): answer the base branch read in the skipped-checks test (#23952) Co-authored-by: Claude Opus 5.5 --- .../ForgejoSourceControlProvider.test.ts | 50 ++++++++++--------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts index 337f9c515e6a..ac037da3f9c3 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts @@ -308,30 +308,32 @@ it.effect("reads Forgejo jobs that did not run as finished checks", () => target_url: null, updated_at: "2026-10-11T00:00:00Z", }); - const body = input.path.endsWith("pulls/1") - ? { - number: 1, - title: "Checks", - body: "", - html_url: "https://forgejo.test/acme/web/pulls/1", - user: null, - state: "open", - merged: false, - head: { ref: "feature", sha: "head", repo: null }, - base: { ref: "main", sha: "base", repo: null }, - created_at: "2026-10-11T00:00:00Z", - updated_at: "2026-10-11T00:00:00Z", - closed_at: null, - merged_at: null, - labels: [], - } - : input.path.endsWith("page=1") - ? [ - status("deploy", "skipped"), - status("audit", "warning"), - status("docs", "future-state"), - ] - : []; + const body = input.path.endsWith("branches/main") + ? {} + : input.path.endsWith("pulls/1") + ? { + number: 1, + title: "Checks", + body: "", + html_url: "https://forgejo.test/acme/web/pulls/1", + user: null, + state: "open", + merged: false, + head: { ref: "feature", sha: "head", repo: null }, + base: { ref: "main", sha: "base", repo: null }, + created_at: "2026-10-11T00:00:00Z", + updated_at: "2026-10-11T00:00:00Z", + closed_at: null, + merged_at: null, + labels: [], + } + : input.path.endsWith("page=1") + ? [ + status("deploy", "skipped"), + status("audit", "warning"), + status("docs", "future-state"), + ] + : []; return encodeJsonEffect(body).pipe(Effect.orDie, Effect.map(processOutput)); }, }), From 807319c276c6831b2c219640e9da5b38019a8f1d Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 23 Oct 2026 02:13:41 -0500 Subject: [PATCH 15/16] fix(opencode): retry failed model catalogs until they recover (#23939) --- .../src/server/managedProvider.test.ts | 75 ++++++++ .../src/server/managedProvider.ts | 16 +- .../src/server/driver.test.ts | 170 ++++++++++++++++++ .../provider-opencode/src/server/driver.ts | 11 +- .../src/server/status.test.ts | 148 +++++++++++++++ .../provider-opencode/src/server/status.ts | 92 ++++++++-- .../ForgejoSourceControlProvider.test.ts | 3 + 7 files changed, 491 insertions(+), 24 deletions(-) diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 393372e46fe0..668502f93e43 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -13,6 +13,7 @@ import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as ProviderHost from "./ProviderHost.ts"; import { makeManagedServerProvider } from "./managedProvider.ts"; @@ -179,6 +180,80 @@ describe("makeManagedServerProvider", () => { ).pipe(Effect.provide(layerAlwaysRunTest)), ); + it.effect("skips periodic provider refreshes without foreground provider-status demand", () => + Effect.scoped( + Effect.gen(function* () { + const checkCalls = yield* Ref.make(0); + const initialCheckDone = yield* Deferred.make(); + yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + refreshOnInterval: () => true, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( + Effect.tap((count) => + count === 1 + ? Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore) + : Effect.void, + ), + Effect.as(refreshedSnapshot), + ), + refreshInterval: "1 second", + }); + + yield* Deferred.await(initialCheckDone); + yield* TestClock.adjust("1 second"); + yield* Effect.yieldNow; + + assert.strictEqual(yield* Ref.get(checkCalls), 1); + }), + ).pipe( + Effect.provide( + Layer.mergeAll(layerProviderHost({ runBackgroundWork: false }), TestClock.layer()), + ), + ), + ); + + it.effect("stops periodic recovery checks once the snapshot is ready", () => + Effect.scoped( + Effect.gen(function* () { + const checkCalls = yield* Ref.make(0); + const releaseInitialCheck = yield* Deferred.make(); + const failedSnapshot = { ...refreshedSnapshot, status: "error" as const }; + const provider = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + refreshOnInterval: (snapshot) => snapshot.status === "error", + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Deferred.await(releaseInitialCheck).pipe( + Effect.andThen(Ref.updateAndGet(checkCalls, (count) => count + 1)), + Effect.map((count) => (count === 1 ? failedSnapshot : refreshedSnapshot)), + ), + refreshInterval: "1 second", + }); + const updates = yield* provider.streamChanges.pipe( + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* Deferred.succeed(releaseInitialCheck, undefined); + yield* TestClock.adjust("1 second"); + + assert.deepStrictEqual(Array.from(yield* Fiber.join(updates)), [ + failedSnapshot, + refreshedSnapshot, + ]); + yield* TestClock.adjust("3 seconds"); + assert.strictEqual(yield* Ref.get(checkCalls), 2); + }), + ).pipe(Effect.provide(Layer.mergeAll(layerAlwaysRunTest, TestClock.layer()))), + ); + it.effect("reruns the provider check when streamed settings change", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index e069bf3af004..12a4523884c8 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -51,7 +51,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; }) => Effect.Effect; readonly refreshInterval?: Duration.Input; - readonly refreshOnInterval?: boolean; + readonly refreshOnInterval?: boolean | ((snapshot: ServerProvider) => boolean); readonly checkProviderOnSettingsChange?: (previous: Settings, next: Settings) => boolean; }): Effect.fn.Return< ManagedServerProvider, @@ -218,11 +218,15 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return yield* applySnapshot(nextSettings, { forceRefresh: true }); }); - const hasProviderStatusDemand = Effect.gen(function* () { + const shouldRefreshOnInterval = Effect.gen(function* () { const state = yield* Ref.get(snapshotStateRef); // A disabled instance has nothing to probe; a periodic check would only // republish a snapshot that differs in checkedAt. - if (!state.snapshot.enabled) { + if ( + !state.snapshot.enabled || + input.refreshOnInterval === false || + (typeof input.refreshOnInterval === "function" && !input.refreshOnInterval(state.snapshot)) + ) { return false; } const instanceId = state.snapshot.instanceId; @@ -276,10 +280,8 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( Queue.take(refreshIntervalChanges).pipe(Effect.as(false)), ).pipe( Effect.flatMap((intervalElapsed) => - input.refreshOnInterval !== false && - intervalElapsed && - Duration.toMillis(Duration.fromInputUnsafe(refreshInterval)) > 0 - ? hasProviderStatusDemand.pipe( + intervalElapsed && Duration.toMillis(Duration.fromInputUnsafe(refreshInterval)) > 0 + ? shouldRefreshOnInterval.pipe( Effect.flatMap((shouldRefresh) => shouldRefresh ? refreshSnapshot().pipe(Effect.asVoid) : Effect.void, ), diff --git a/packages/provider-opencode/src/server/driver.test.ts b/packages/provider-opencode/src/server/driver.test.ts index 20afe88d8104..6b529dfd9dcc 100644 --- a/packages/provider-opencode/src/server/driver.test.ts +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -8,11 +8,16 @@ import { assert, it } from "@effect/vitest"; import { ProviderInstanceId } from "@t3tools/contracts"; import type { OpenCodeSettings } from "../settings.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as References from "effect/References"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; @@ -21,6 +26,7 @@ import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderS import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as ProviderMaintenance from "@t3tools/provider-core/server/maintenanceResolver"; import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; +import { OPENCODE_2_RESPONSES, replayOpenCodeServer } from "./probeResponses.fixture.ts"; import { OpenCodeDriver, openCodeUpdateFor } from "./driver.ts"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; @@ -94,6 +100,170 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { yield* Fiber.join(capabilities); }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); + + it.effect.each(["probe", "error", "empty"] as const)( + "recovers discovery without a manual refresh after %s", + (initialCatalog) => + Effect.gen(function* () { + const releaseCatalog = yield* Deferred.make(); + const discoveryLogs: Array> = []; + const logger = Logger.make(({ message, fiber }) => { + if (Array.isArray(message) && message.includes("OpenCode discovery failed")) { + discoveryLogs.push({ ...fiber.getRef(References.CurrentLogAnnotations) }); + } + }); + let catalogAvailable = false; + let modelReads = 0; + const replies = { + ...OPENCODE_2_RESPONSES, + "/api/provider": { + status: 200, + contentType: "application/json", + body: '{"location":{"directory":"/work"},"data":[]}', + }, + "/api/model": { + status: 200, + contentType: "application/json", + body: JSON.stringify({ + location: { directory: "/work" }, + data: [ + { + id: "big-pickle", + modelID: "big-pickle", + providerID: "opencode", + name: "Big Pickle", + package: "@opencode/ai/providers/openai-compatible", + settings: {}, + compatibility: {}, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + variants: [], + time: { released: 1789516800000 }, + cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }], + status: "active", + enabled: true, + limit: { context: 1000000, output: 65536 }, + }, + ], + }), + }, + }; + const replay = replayOpenCodeServer(replies, "secret"); + const server = HttpClient.make((request) => + Effect.gen(function* () { + if (initialCatalog === "probe" && !catalogAvailable) { + yield* Deferred.await(releaseCatalog); + return HttpClientResponse.fromWeb( + request, + new Response('{"_tag":"InternalServerError","message":"server starting"}', { + status: 503, + headers: { "content-type": "application/json" }, + }), + ); + } + if (new URL(request.url).pathname === "/api/model") { + modelReads += 1; + yield* Deferred.await(releaseCatalog); + if (!catalogAvailable) { + return HttpClientResponse.fromWeb( + request, + new Response( + initialCatalog === "error" + ? '{"_tag":"InternalServerError","message":"catalog unavailable"}' + : '{"location":{"directory":"/work"},"data":[]}', + { + status: initialCatalog === "error" ? 500 : 200, + headers: { "content-type": "application/json" }, + }, + ), + ); + } + } + return yield* replay.execute(request); + }), + ); + const instance = yield* create( + { serverUrl: "http://127.0.0.1:4096", serverPassword: "secret" }, + server, + ).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + const initial = yield* instance.snapshot.streamChanges.pipe( + Stream.filter( + (snapshot) => + snapshot.installed && + snapshot.status === (initialCatalog === "empty" ? "warning" : "error"), + ), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* Deferred.succeed(releaseCatalog, undefined); + yield* TestClock.adjust("5 seconds"); + const [failed] = Array.from(yield* Fiber.join(initial)); + assert.strictEqual(failed?.auth.status, "unknown"); + assert.deepStrictEqual(failed?.models, []); + if (initialCatalog === "probe") assert.strictEqual(failed?.version, null); + assert.strictEqual(discoveryLogs.length, 1); + assert.strictEqual(discoveryLogs[0]?.providerInstanceId, "opencode-test"); + assert.strictEqual(discoveryLogs[0]?.serverMode, "external"); + assert.strictEqual( + discoveryLogs[0]?.phase, + initialCatalog === "probe" ? "version" : "models", + ); + + catalogAvailable = true; + const recovered = yield* instance.snapshot.streamChanges.pipe( + Stream.filter((snapshot) => snapshot.status === "ready"), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + yield* TestClock.adjust("5 minutes"); + assert.strictEqual((yield* instance.snapshot.getSnapshot).status, "ready"); + const [ready] = Array.from(yield* Fiber.join(recovered)); + assert.strictEqual(ready?.auth.status, "authenticated"); + assert.deepStrictEqual( + ready?.models.map((model) => model.slug), + ["opencode/big-pickle"], + ); + + const readsAfterRecovery = modelReads; + yield* TestClock.adjust("15 minutes"); + assert.strictEqual(modelReads, readsAfterRecovery); + assert.strictEqual(discoveryLogs.length, 1); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + + it.effect("does not retry a rejected server password in the background", () => + Effect.gen(function* () { + const releaseResponse = yield* Deferred.make(); + const requested: Array = []; + const server = replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret", requested); + const instance = yield* create( + { serverUrl: "http://127.0.0.1:4096", serverPassword: "wrong" }, + HttpClient.make((request) => + Deferred.await(releaseResponse).pipe(Effect.andThen(server.execute(request))), + ), + ); + // Wait for startup and its maintenance enrichment, both of which probe. + const rejected = yield* instance.snapshot.streamChanges.pipe( + Stream.filter( + (snapshot) => snapshot.status === "error" && snapshot.versionAdvisory !== undefined, + ), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* Deferred.succeed(releaseResponse, undefined); + const [snapshot] = Array.from(yield* Fiber.join(rejected)); + assert.strictEqual(snapshot?.version, null); + assert.strictEqual(snapshot?.auth.status, "unauthenticated"); + const readsAfterRejection = requested.length; + + yield* TestClock.adjust("15 minutes"); + assert.strictEqual(requested.length, readsAfterRejection); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); }); // npm links `/bin/opencode` into the package that installed it: diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 5348e66ddda0..14de2ce4942f 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -350,6 +350,9 @@ export const OpenCodeDriver: ProviderDriver< host.paths.cwd, runtimeProbe.refresh, loadOpenCode2Models, + ).pipe( + Effect.annotateLogs({ providerInstanceId: instanceId }), + Effect.annotateSpans({ providerInstanceId: instanceId }), ), usageLimits: readOpenCodeGoUsageLimits({ enabled: effectiveConfig.enabled, @@ -440,7 +443,13 @@ export const OpenCodeDriver: ProviderDriver< streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, checkProviderOnSettingsChange: () => false, - refreshOnInterval: false, + // A failed or still-loading catalog must recover without a manual + // refresh. Healthy instances need no recurring server starts. + refreshOnInterval: (snapshot) => + snapshot.enabled && + snapshot.installed && + snapshot.status !== "ready" && + snapshot.auth.status === "unknown", initialSnapshot: (settings) => makePendingOpenCodeProvider(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider, diff --git a/packages/provider-opencode/src/server/status.test.ts b/packages/provider-opencode/src/server/status.test.ts index f55e07d2fa02..e2ba4277e450 100644 --- a/packages/provider-opencode/src/server/status.test.ts +++ b/packages/provider-opencode/src/server/status.test.ts @@ -8,7 +8,9 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Schema from "effect/Schema"; +import * as Tracer from "effect/Tracer"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { beforeEach } from "vite-plus/test"; @@ -462,6 +464,110 @@ const checkProvider = Effect.fn("checkProvider")(function* ( }); it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { + it.effect.each(["version", "inventory", "models", "empty-v1", "empty-v2"] as const)( + "records %s discovery failures in the existing trace and logger", + (failure) => + Effect.gen(function* () { + const cause = new Error("discovery connection reset"); + const v2 = failure === "models" || failure === "empty-v2"; + runtimeMock.state.versionStdout = v2 ? "opencode v2.0.18\n" : DEFAULT_VERSION_STDOUT; + if (failure === "version") runtimeMock.state.runVersionError = cause; + if (failure === "inventory") runtimeMock.state.inventoryError = cause; + if (failure === "empty-v1") { + runtimeMock.state.inventory = { + providerList: { + connected: ["openai"], + all: [{ id: "openai", name: "OpenAI", models: {} }], + default: {}, + }, + agents: [], + skills: [], + }; + } + const spans: Array = []; + yield* checkProvider( + makeOpenCodeSettings({ serverPassword: "private-password" }), + process.cwd(), + undefined, + undefined, + failure === "models" + ? Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "model.list", + detail: "model list failed", + cause, + }), + ) + : Effect.succeed([]), + ).pipe( + Effect.provideService( + Tracer.Tracer, + Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }), + ), + Effect.provide(Logger.layer([Logger.tracerLogger], { mergeWithExisting: false })), + ); + + const diagnostics = spans.flatMap((span) => + span.events + .filter(([name]) => name === "OpenCode discovery failed") + .map((event) => ({ span, event })), + ); + NodeAssert.equal(diagnostics.length, 1); + const { + span, + event: [, timestamp, attributes], + } = diagnostics[0]!; + NodeAssert.equal(attributes.provider, "opencode"); + NodeAssert.equal( + attributes.phase, + failure.startsWith("empty") ? (v2 ? "models" : "inventory") : failure, + ); + NodeAssert.equal(attributes.outcome, failure.startsWith("empty") ? "empty" : "failed"); + NodeAssert.equal(attributes.serverMode, "managed"); + NodeAssert.equal(attributes["effect.logLevel"], "WARN"); + NodeAssert.ok(timestamp >= 0n); + NodeAssert.ok(span.traceId.length > 0); + NodeAssert.equal(span.attributes.get("discovery.outcome"), attributes.outcome); + if (!failure.startsWith("empty")) { + NodeAssert.match(String(attributes["effect.cause"]), /discovery connection reset/); + } + NodeAssert.ok(!JSON.stringify(attributes).includes("private-password")); + }), + ); + + it.effect("keeps an empty legacy catalog retryable until connected providers list models", () => + Effect.gen(function* () { + const upstream = { id: "openai", name: "OpenAI", models: {} }; + runtimeMock.state.inventory = { + providerList: { connected: ["openai"], all: [upstream], default: {} }, + agents: [], + skills: [], + }; + const settings = makeOpenCodeSettings(); + const empty = yield* checkProvider(settings); + NodeAssert.equal(empty.status, "warning"); + NodeAssert.equal(empty.auth.status, "unknown"); + NodeAssert.deepEqual(empty.models, []); + + upstream.models = { + "gpt-5.4": { id: "gpt-5.4", name: "GPT-5.4", variants: {} }, + }; + const ready = yield* checkProvider(settings); + NodeAssert.equal(ready.status, "ready"); + NodeAssert.equal(ready.auth.status, "authenticated"); + NodeAssert.deepEqual( + ready.models.map((model) => model.slug), + ["openai/gpt-5.4"], + ); + }), + ); + it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => Effect.gen(function* () { runtimeMock.state.inventory = { @@ -776,6 +882,48 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { }); it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (it) => { + it.effect.each([ + "401 Unauthorized", + "403 Forbidden", + "status=401 body={}", + "status=403 body={}", + "HTTP 401", + "HTTP/1.1 403", + "Response status code: 401", + "Response status code: 403", + "Request failed with status code 401", + "Request failed with status code 403", + ])("keeps explicit authentication rejection actionable: %s", (detail) => + Effect.gen(function* () { + runtimeMock.state.connectionError = new Error(detail); + const snapshot = yield* checkProvider( + makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }), + ); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.auth.status, "unauthenticated"); + NodeAssert.equal( + snapshot.message, + "OpenCode server rejected authentication. Check the server URL and password.", + ); + }), + ); + + it.effect.each([401, 403, 4010, 4030])("keeps connection failures on port %s retryable", (port) => + Effect.gen(function* () { + const serverUrl = `http://127.0.0.1:${port}`; + runtimeMock.state.connectionError = new Error(`connect ECONNREFUSED 127.0.0.1:${port}`); + const snapshot = yield* checkProvider(makeOpenCodeSettings({ serverUrl })); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.auth.status, "unknown"); + NodeAssert.equal( + snapshot.message, + `Couldn't reach the configured OpenCode server at ${serverUrl}. Check that the server is running and the URL is correct.`, + ); + }), + ); + it.effect("routes a configured OpenCode 2 server to the 2.x check, never via 1.x", () => Effect.gen(function* () { const settings = makeOpenCodeSettings({ diff --git a/packages/provider-opencode/src/server/status.ts b/packages/provider-opencode/src/server/status.ts index a3d4697e6bcd..c5dcc057735d 100644 --- a/packages/provider-opencode/src/server/status.ts +++ b/packages/provider-opencode/src/server/status.ts @@ -66,19 +66,25 @@ function formatOpenCodeProbeError(input: { readonly isExternalServer: boolean; readonly phase: "version" | "inventory"; readonly serverUrl: string; -}): { readonly installed: boolean; readonly message: string } { +}): { + readonly installed: boolean; + readonly message: string; + readonly authStatus?: "unauthenticated"; +} { const detail = normalizedErrorMessage(input.cause); const lower = detail?.toLowerCase() ?? ""; if (input.isExternalServer) { + // Bare auth-code digits can be part of the endpoint's port or path. if ( - lower.includes("401") || - lower.includes("403") || - lower.includes("unauthorized") || - lower.includes("forbidden") + /(?:^|\s)(?:status(?:\s+code)?(?:\s*[:=]\s*|\s+)|http(?:\/\d(?:\.\d)?)?\s+)(?:401|403)\b/.test( + lower, + ) || + /\b(?:unauthorized|forbidden)\b/.test(lower) ) { return { installed: true, + authStatus: "unauthenticated", message: "OpenCode server rejected authentication. Check the server URL and password.", }; } @@ -137,6 +143,32 @@ function formatOpenCodeProbeError(input: { }; } +// The status check owns the span; keep handled failures visible without adding another span. +const logDiscoveryFailure = Effect.fnUntraced(function* (input: { + readonly phase: "version" | "inventory" | "models"; + readonly isExternalServer: boolean; + readonly version?: string; + readonly cause?: Cause.Cause; +}) { + const outcome = input.cause ? "failed" : "empty"; + const attributes = { + provider: "opencode", + phase: input.phase, + outcome, + serverMode: input.isExternalServer ? "external" : "managed", + ...(input.version === undefined ? {} : { version: input.version }), + }; + yield* Effect.annotateCurrentSpan({ + "discovery.phase": input.phase, + "discovery.outcome": outcome, + }); + yield* ( + input.cause + ? Effect.logWarning("OpenCode discovery failed", input.cause) + : Effect.logWarning("OpenCode discovery failed") + ).pipe(Effect.annotateLogs(attributes)); +}); + function titleCaseSlug(value: string): string { const segments: Array = []; for (const segment of value.split(/[-_/]+/)) { @@ -592,7 +624,12 @@ const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( message, }); if (result._tag === "Failure") { - yield* Effect.logWarning("OpenCode 2 model list failed", result.cause); + yield* logDiscoveryFailure({ + phase: "models", + isExternalServer: settings.serverUrl.trim().length > 0, + version, + cause: result.cause, + }); return buildServerProvider({ presentation: OPENCODE_2_PRESENTATION, enabled: true, @@ -606,6 +643,13 @@ const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( probe: probe("error", "OpenCode could not load its model list."), }); } + if (result.value.length === 0) { + yield* logDiscoveryFailure({ + phase: "models", + isExternalServer: settings.serverUrl.trim().length > 0, + version, + }); + } const models = providerModelsFromSettings( markOpenCodeLegacy( result.value.map((model) => { @@ -680,7 +724,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu installed: failure.installed, version, status: "error", - auth: { status: "unknown" }, + auth: { status: failure.authStatus ?? "unknown" }, message: failure.message, }, }); @@ -715,7 +759,10 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu ), ), ); - if (probedExit._tag === "Failure") return fallback(Cause.squash(probedExit.cause)); + if (probedExit._tag === "Failure") { + yield* logDiscoveryFailure({ phase: "version", isExternalServer, cause: probedExit.cause }); + return fallback(Cause.squash(probedExit.cause)); + } const probed = probedExit.value; if (probed.generation === "v2") { return yield* checkOpenCode2(openCodeSettings, probed.version, checkedAt, loadOpenCode2Models); @@ -777,19 +824,30 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu ), ); if (inventoryExit._tag === "Failure") { + yield* logDiscoveryFailure({ + phase: "inventory", + isExternalServer, + version, + cause: inventoryExit.cause, + }); return { ...fallback(Cause.squash(inventoryExit.cause), version, "inventory"), ...installedVersion, }; } + const inventoryModels = flattenOpenCodeModels(inventoryExit.value); const models = providerModelsFromSettings( - flattenOpenCodeModels(inventoryExit.value), + inventoryModels, customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ); const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.skills); const connectedCount = inventoryExit.value.providerList.connected.length; + const catalogReady = connectedCount > 0 && inventoryModels.length > 0; + if (connectedCount > 0 && inventoryModels.length === 0) { + yield* logDiscoveryFailure({ phase: "inventory", isExternalServer, version }); + } return { ...buildServerProvider({ presentation: OPENCODE_PRESENTATION, @@ -801,17 +859,19 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu probe: { installed: true, version, - status: connectedCount > 0 ? "ready" : "warning", + status: catalogReady ? "ready" : "warning", auth: { - status: connectedCount > 0 ? "authenticated" : "unknown", + status: catalogReady ? "authenticated" : "unknown", type: "opencode", }, message: - connectedCount > 0 - ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` - : isExternalServer - ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." - : "OpenCode is available, but it did not report any connected upstream providers.", + connectedCount > 0 && !catalogReady + ? "OpenCode upstream providers are connected, but it did not list any models yet." + : connectedCount > 0 + ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` + : isExternalServer + ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." + : "OpenCode is available, but it did not report any connected upstream providers.", }, }), ...installedVersion, diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts index ac037da3f9c3..af8a44676243 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts @@ -301,6 +301,9 @@ it.effect("reads Forgejo jobs that did not run as finished checks", () => Effect.provide( Layer.mock(ForgejoCli.ForgejoCli)({ api: (input) => { + if (input.path.endsWith("branches/main")) { + return encodeJsonEffect({}).pipe(Effect.orDie, Effect.map(processOutput)); + } const status = (context: string, state: string) => ({ context, status: state, From d7a8d6f32001444e013aa07c8a468ce3e987f9cf Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 23 Oct 2026 03:23:40 -0500 Subject: [PATCH 16/16] fix(server): avoid full shell read on resume (#23987) --- .../transferBudgetV2.integration.test.ts | 2 ++ .../src/orchestration-v2/Orchestrator.ts | 13 ++++++++ .../orchestration-v2/ProjectionStore.test.ts | 30 +++++++++++++++++ .../src/orchestration-v2/ProjectionStore.ts | 32 +++++++++++++++++++ .../ProviderTurnControlService.test.ts | 1 + .../ThreadManagementService.ts | 2 ++ .../src/relay/AgentAwarenessRelay.test.ts | 1 + apps/server/src/ws.test.ts | 3 +- apps/server/src/ws.ts | 7 +--- 9 files changed, 84 insertions(+), 7 deletions(-) diff --git a/apps/server/integration/transferBudgetV2.integration.test.ts b/apps/server/integration/transferBudgetV2.integration.test.ts index 5064848571be..75b0be5ae57a 100644 --- a/apps/server/integration/transferBudgetV2.integration.test.ts +++ b/apps/server/integration/transferBudgetV2.integration.test.ts @@ -89,6 +89,8 @@ const layerManagement = Layer.unwrap( projections.getThreadSnapshotWindow(id, options).pipe(Effect.orDie), getThreadShell: (id) => projections.getThreadShell(id).pipe(Effect.orDie), getShellSnapshot: (options) => projections.getShellSnapshot(options).pipe(Effect.orDie), + listActiveSubagentThreadIds: () => + projections.listActiveSubagentThreadIds().pipe(Effect.orDie), readShellSnapshot: (options) => projections.readShellSnapshot(options).pipe(Effect.map(Effect.orDie), Effect.orDie), streamStoredEventsFrom: (input) => diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 5dd48ba548d2..4a3b101b4948 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -371,6 +371,10 @@ export interface OrchestratorV2Shape { readonly getShellSnapshot: ( options?: ShellSnapshotOptions, ) => Effect.Effect; + readonly listActiveSubagentThreadIds: () => Effect.Effect< + ReadonlyArray, + OrchestratorV2Error + >; readonly readShellSnapshot: ( options?: ShellSnapshotOptions, ) => Effect.Effect< @@ -12237,6 +12241,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), getShellSnapshot: (options) => projectionStore.getShellSnapshot(options).pipe(Effect.mapError(shellProjectionError)), + listActiveSubagentThreadIds: () => + projectionStore.listActiveSubagentThreadIds().pipe(Effect.mapError(shellProjectionError)), readShellSnapshot: (options) => projectionStore .readShellSnapshot(options) @@ -12382,6 +12388,13 @@ const layerUnavailable: Layer.Layer = Layer.succeed( cause: "Orchestration V2 live runtime is not configured.", }), ), + listActiveSubagentThreadIds: () => + Effect.fail( + new OrchestratorProjectionError({ + threadId: ThreadId.make("thread:shell"), + cause: "Orchestration V2 live runtime is not configured.", + }), + ), readShellSnapshot: () => Effect.fail( new OrchestratorProjectionError({ diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 6f87afb2eb23..fcadd8036b92 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -1105,6 +1105,36 @@ const assertProjectScopedShells = Effect.fn("assertProjectScopedShells")(functio return { projectId, activeThreadId, forkThreadId, otherThreadId }; }); +it.effect("lists only active subagent IDs without loading shell data", () => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const sql = yield* SqlClient.SqlClient; + const createdAt = "2026-01-05T00:00:00.000Z"; + for (const [id, relationship, archivedAt, deletedAt] of [ + ["parent", null, null, null], + ["active-subagent", "subagent", null, null], + ["archived-subagent", "subagent", createdAt, null], + ["deleted-subagent", "subagent", null, createdAt], + ] as const) { + yield* sql` + INSERT INTO orchestration_v2_projection_threads ( + thread_id, project_id, title, default_provider, runtime_mode, interaction_mode, + created_at, updated_at, archived_at, deleted_at, payload_json + ) VALUES ( + ${id}, 'project:test', ${id}, 'codex', 'full-access', 'default', + ${createdAt}, ${createdAt}, ${archivedAt}, ${deletedAt}, + ${JSON.stringify({ lineage: { relationshipToParent: relationship }, archivedAt })} + ) + `; + } + + assert.sameMembers( + [...(yield* store.listActiveSubagentThreadIds())], + [ThreadId.make("active-subagent")], + ); + }).pipe(Effect.provide(layerTest)), +); + it.layer(layerTest)("ProjectionStoreV2", (it) => { // regression: #21969 keep corrupt plan JSON in the typed read error it.effect("returns a typed read error for malformed stored plan JSON", () => diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 4f08378db528..00333529dd1f 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -424,6 +424,10 @@ export interface ProjectionStoreV2Shape { readonly getShellSnapshot: ( options?: ShellSnapshotOptions, ) => Effect.Effect; + readonly listActiveSubagentThreadIds: () => Effect.Effect< + ReadonlyArray, + ProjectionStoreV2Error + >; /** * Callers that combine the shell with other reads run this inside their own * transaction and the returned decode after it commits, so the shared @@ -6430,6 +6434,20 @@ export const layer: Layer.Layer = .withTransaction(readShellSnapshotRows(options)) .pipe(Effect.mapError(shellSnapshotReadError), Effect.flatMap(decodeShellSnapshot)); + const listActiveSubagentThreadIds = Effect.fn("ProjectionStore.listActiveSubagentThreadIds")( + function* () { + const rows = yield* sql<{ readonly thread_id: string }>` + SELECT thread_id + FROM orchestration_v2_projection_threads + WHERE deleted_at IS NULL + AND json_extract(payload_json, '$.archivedAt') IS NULL + AND json_extract(payload_json, '$.lineage.relationshipToParent') = 'subagent' + `; + return rows.map((row) => ThreadId.make(row.thread_id)); + }, + Effect.mapError(shellSnapshotReadError), + ); + const getThreadShell: ProjectionStoreV2Shape["getThreadShell"] = (threadId) => sql .withTransaction( @@ -6522,6 +6540,7 @@ export const layer: Layer.Layer = return { apply, getShellSnapshot: readOnReader(getShellSnapshot), + listActiveSubagentThreadIds: readOnReader(listActiveSubagentThreadIds), readShellSnapshot: readOnReader(readShellSnapshot), getThreadShell: readOnReader(getThreadShell), getThread: readOnReader(getThread), @@ -6630,6 +6649,19 @@ export const layerMemory: Layer.Layer = Layer.effect( archivedThreads: visible.filter((thread) => thread.archivedAt !== null), }; }), + listActiveSubagentThreadIds: Effect.fn("ProjectionStore.listActiveSubagentThreadIds")( + function* () { + const existing = (yield* Ref.get(replayState)).projections; + return [...existing.values()] + .filter( + ({ thread }) => + thread.deletedAt === null && + thread.archivedAt === null && + thread.lineage.relationshipToParent === "subagent", + ) + .map(({ thread }) => thread.id); + }, + ), readShellSnapshot: (options) => service.getShellSnapshot(options).pipe(Effect.map(Effect.succeed)), getThreadShell: Effect.fn("ProjectionStore.getThreadShell")(function* (threadId) { diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 178c6ec3dcc0..3ae3a9974b48 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -211,6 +211,7 @@ it.effect( apply: () => Effect.void, getLimitRecoveryCandidates: () => Effect.die("unused getLimitRecoveryCandidates"), getShellSnapshot: () => Effect.die("unused getShellSnapshot"), + listActiveSubagentThreadIds: () => Effect.die("unused listActiveSubagentThreadIds"), readShellSnapshot: () => Effect.die("unused readShellSnapshot"), getThreadShell: () => Effect.die("unused getThreadShell"), getThread: () => Ref.get(projection).pipe(Effect.map((state) => state.thread)), diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 543b3e2f89fe..f2efb71637fe 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -362,6 +362,7 @@ export interface ThreadManagementServiceShape { readonly getShellSnapshot: (options?: { readonly location?: "active" | "archive"; }) => Effect.Effect; + readonly listActiveSubagentThreadIds: Orchestrator.OrchestratorV2["Service"]["listActiveSubagentThreadIds"]; readonly readShellSnapshot: (options?: { readonly location?: "active" | "archive"; }) => Effect.Effect< @@ -1173,6 +1174,7 @@ const make = Effect.gen(function* () { getProjectThreadRecords, getProjectThread, getShellSnapshot: orchestrator.getShellSnapshot, + listActiveSubagentThreadIds: orchestrator.listActiveSubagentThreadIds, readShellSnapshot: orchestrator.readShellSnapshot, getThreadShell: orchestrator.getThreadShell, listProjectThreads, diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 64b71140fdcf..d1ad9e7a58a6 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -187,6 +187,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( catchUp.shellSnapshotReads += 1; return { schemaVersion: 2, snapshotSequence: 1, threads: [], archivedThreads: [] }; }), + listActiveSubagentThreadIds: unused, readShellSnapshot: unused, ensureLegacyTranscript: unused, dispatch: unused, diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 75da37ad3b6c..787fdc268f4a 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -252,7 +252,8 @@ it.live.each(["thread", "shell"] as const)( const items = yield* subscription.pipe( Effect.provideService(ThreadManagement.ThreadManagementService, { ensureLegacyTranscript: () => Effect.void, - getShellSnapshot: () => Effect.succeed({ threads: [] }), + getShellSnapshot: () => Effect.die("shell resume must not load a full snapshot"), + listActiveSubagentThreadIds: () => Effect.succeed([]), streamStoredEventsFrom: ( input: Parameters< ThreadManagement.ThreadManagementService["Service"]["streamStoredEventsFrom"] diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 556a023a01c2..7656ddca9254 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1172,12 +1172,7 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc }); } - const currentShell = yield* threadManagement.getShellSnapshot({ location: "active" }); - subagentThreadIds = new Set( - currentShell.threads - .filter((thread) => thread.lineage.relationshipToParent === "subagent") - .map((thread) => thread.id), - ); + subagentThreadIds = new Set(yield* threadManagement.listActiveSubagentThreadIds()); const loaded = yield* loadProjectMetadataSnapshot(highWater); const replay = toShellStream(