diff --git a/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx b/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx index a92f196569..d1273ce3c3 100644 --- a/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx +++ b/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx @@ -124,7 +124,10 @@ beforeEach(() => { operationId: "edit-op-1", requestSequence: 42, }); - vi.mocked(sdk.threads.send).mockResolvedValue({ ok: true }); + vi.mocked(sdk.threads.send).mockResolvedValue({ + ok: true, + delivery: "sent", + }); vi.mocked(sdk.threads.queuedMessages.create).mockResolvedValue( makeQueuedMessage(), ); diff --git a/apps/cli/src/__tests__/command-output/thread-tell.test.ts b/apps/cli/src/__tests__/command-output/thread-tell.test.ts index dbca18dc1b..df2627007d 100644 --- a/apps/cli/src/__tests__/command-output/thread-tell.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-tell.test.ts @@ -14,7 +14,7 @@ describe("bb thread tell command output", () => { registerThreadCommands(program, () => "http://server"); it("bb thread tell --json prints the raw response plus thread id", async () => { - const post = vi.fn(async () => ({ ok: true })); + const post = vi.fn(async () => ({ ok: true, delivery: "sent" })); stubServerApi({ "v1.threads.:id.send.$post": post }); await runCommand( @@ -27,10 +27,33 @@ describe("bb thread tell command output", () => { ).toEqual({ threadId: "thread-json-tell", ok: true, + delivery: "sent", mode: "steer", }); }); + it("bb thread tell says when the target is awaiting user interaction and the message is held", async () => { + const post = vi.fn(async () => ({ ok: true, delivery: "deferred" })); + stubServerApi({ "v1.threads.:id.send.$post": post }); + + await runCommand(["thread", "tell", "thread-blocked", "hello"], register); + + expect(vi.mocked(console.log).mock.calls[0]?.[0]).toBe( + "Thread thread-blocked is awaiting user interaction; message held and delivers once the interaction settles", + ); + }); + + it("bb thread tell keeps the steered wording for servers that only report ok", async () => { + const post = vi.fn(async () => ({ ok: true })); + stubServerApi({ "v1.threads.:id.send.$post": post }); + + await runCommand(["thread", "tell", "thread-legacy", "hello"], register); + + expect(vi.mocked(console.log).mock.calls[0]?.[0]).toBe( + "Thread thread-legacy steered", + ); + }); + it("bb thread tell --mode queue preserves non-urgent queued delivery", async () => { const post = vi.fn(async () => ({ ok: true })); stubServerApi({ "v1.threads.:id.send.$post": post }); diff --git a/apps/cli/src/commands/thread/actions.ts b/apps/cli/src/commands/thread/actions.ts index 5d8937c9bb..f1e32bab6b 100644 --- a/apps/cli/src/commands/thread/actions.ts +++ b/apps/cli/src/commands/thread/actions.ts @@ -9,6 +9,7 @@ import { } from "@bb/domain"; import { action } from "../../action.js"; import { createCliBbSdk } from "../../client.js"; +import type { ThreadSendResult } from "@bb/sdk"; import { confirmDestructiveAction, outputJson, @@ -101,10 +102,9 @@ interface PostThreadMessageArgs { images?: readonly string[]; } -interface PostThreadMessageResult { - ok: true; +type PostThreadMessageResult = ThreadSendResult & { mode: ThreadTellDeliveryMode; -} +}; interface ThreadUpdateBody { title?: string; @@ -452,11 +452,7 @@ export function registerActionsCommands( images: opts.image, }); if (outputJson(opts, { threadId: id, ...response })) return; - console.log( - response.mode === "steer" - ? `Thread ${id} steered` - : `Thread ${id} updated`, - ); + console.log(describeThreadTellOutcome(id, response)); }, ), ); @@ -526,7 +522,7 @@ async function postThreadMessage( args: PostThreadMessageArgs, ): Promise { const sdk = createCliBbSdk(args.getUrl()); - await sdk.threads.send({ + const response = await sdk.threads.send({ threadId: args.threadId, input: buildPromptInputs({ message: args.message, @@ -546,11 +542,27 @@ async function postThreadMessage( ...(args.senderThreadId ? { senderThreadId: args.senderThreadId } : {}), }); return { - ok: true, + ...response, mode: args.mode, }; } +function describeThreadTellOutcome( + threadId: string, + response: PostThreadMessageResult, +): string { + if (response.delivery === "deferred") { + return `Thread ${threadId} is awaiting user interaction; message held and delivers once the interaction settles`; + } + if (response.delivery === "queued") { + return `Thread ${threadId} message queued`; + } + // `sent`, or an older server that reports only `ok`. + return response.mode === "steer" + ? `Thread ${threadId} steered` + : `Thread ${threadId} updated`; +} + function resolveSenderThreadId(targetThreadId: string): string | undefined { const senderThreadId = resolveContextThreadId(); if (!senderThreadId || senderThreadId === targetThreadId) { diff --git a/apps/server/src/lifecycle-dedupers.ts b/apps/server/src/lifecycle-dedupers.ts index 5f8fa116e1..77888b9b13 100644 --- a/apps/server/src/lifecycle-dedupers.ts +++ b/apps/server/src/lifecycle-dedupers.ts @@ -22,6 +22,7 @@ export interface ProviderModelListMemoValue { } export interface LifecycleDedupers { + deferredThreadMessageFlush: AsyncDeduper; environmentCleanupAdvance: AsyncDeduper; /** * Memo for host model probes: every execution-options read (each thread @@ -34,6 +35,7 @@ export interface LifecycleDedupers { export function createLifecycleDedupers(): LifecycleDedupers { return { + deferredThreadMessageFlush: createAsyncDeduper(), environmentCleanupAdvance: createAsyncDeduper(), providerModelList: createAsyncTtlMemo({ ttlMs: PROVIDER_MODEL_LIST_MEMO_TTL_MS, diff --git a/apps/server/src/routes/threads/actions.ts b/apps/server/src/routes/threads/actions.ts index 4255fd57fd..512e9247e0 100644 --- a/apps/server/src/routes/threads/actions.ts +++ b/apps/server/src/routes/threads/actions.ts @@ -1,9 +1,7 @@ import { - createQueuedThreadMessageInTransaction, deleteQueuedThreadMessage, getEnvironment, getQueuedThreadMessage, - getThread, listActiveVisiblePinnedThreadRootsWithPendingInteractionState, pinThread, reorderPinnedThread, @@ -16,15 +14,12 @@ import { type ReorderPinnedThreadResult, type ReorderQueuedThreadMessageResult, type SetQueuedThreadMessageGroupBoundaryResult, - type DbQueryConnection, } from "@bb/db"; import { publicApiRoutes, typedRoutes, - type CreateQueuedMessageRequest, type ThreadListResponse, type PublicApiSchema, - type SendMessageRequest, } from "@bb/server-contract"; import type { Hono } from "hono"; import { @@ -42,33 +37,25 @@ import { } from "../../services/environments/environment-cleanup-internal.js"; import { applyLoggedEnvironmentLifecycleEvent } from "../../services/environments/lifecycle-outcome.js"; import { requirePublicThread } from "../../services/lib/entity-lookup.js"; -import { - goneThreadEnvironmentDetails, - threadEnvironmentUnavailableDetails, - throwThreadEnvironmentUnavailable, -} from "../../services/lib/lifecycle-api-errors.js"; import { parseSafeRelativeRoutePath } from "../relative-route-path.js"; import { validatePromptAttachmentReferences } from "../../services/projects/attachments.js"; import { - requestQueuedMessageAutoSendForThread, + createQueuedMessageForThread, sendQueuedMessage, } from "../../services/threads/queued-messages.js"; import { ensureThreadIsNotAwaitingUserInteraction, ensureThreadIsWritable, - resolveMessageSenderThreadId, sendThreadMessage, } from "../../services/threads/thread-send.js"; +import { acceptThreadSendRequest } from "../../services/threads/thread-send-request.js"; import { editThreadMessage } from "../../services/threads/thread-edit-message.js"; import { buildExecutionOptions, dispatchThreadUnarchiveCommand, prepareTurnSubmitCommandPayload, } from "../../services/threads/thread-commands.js"; -import { - getLastProviderThreadId, - isManualCompactionActive, -} from "../../services/threads/thread-events.js"; +import { getLastProviderThreadId } from "../../services/threads/thread-events.js"; import { stopThreadForCurrentState } from "../../services/threads/thread-lifecycle.js"; import { getThreadPromptBannerActivity, @@ -232,142 +219,6 @@ function assertPinnedThreadOrderResult( } } -interface CreateQueuedMessageForThreadArgs { - payload: CreateQueuedMessageRequest; - thread: Thread; -} - -function queuedMessagePayloadFromSendRequest( - payload: SendMessageRequest, -): CreateQueuedMessageRequest { - return { - input: payload.input, - ...(payload.model !== undefined ? { model: payload.model } : {}), - ...(payload.serviceTier !== undefined - ? { serviceTier: payload.serviceTier } - : {}), - ...(payload.reasoningLevel !== undefined - ? { reasoningLevel: payload.reasoningLevel } - : {}), - ...(payload.permissionMode !== undefined - ? { permissionMode: payload.permissionMode } - : {}), - ...(payload.executionInputSources !== undefined - ? { executionInputSources: payload.executionInputSources } - : {}), - ...(payload.senderThreadId !== undefined - ? { senderThreadId: payload.senderThreadId } - : {}), - }; -} - -/** - * Admits a queued message against the current thread and environment rows. - * Returns the provider thread id so the caller can decide on auto-send without - * a second event-history read. - * - * A queued message can only drain into the thread's environment. A gone - * environment (`destroying`/`destroyed`) is never reprovisioned, so accepting - * the message would park it in the queue forever while the thread keeps - * reporting `idle` (#1789). Refuse with the same 409 the direct send path - * returns. - * - * A thread with no environment row is accepted while it has never run: the - * queue is how messages wait for provisioning. Once the thread has a provider - * thread id, a missing environment means the row was pruned after destroy, and - * the direct send path already refuses with `never_attached`. - */ -function admitQueuedMessage( - db: DbQueryConnection, - thread: Thread, -): { providerThreadId: string | null } { - ensureThreadIsWritable(thread); - const providerThreadId = getLastProviderThreadId({ db }, thread.id); - if (thread.environmentId === null) { - if (providerThreadId !== null) { - throwThreadEnvironmentUnavailable( - threadEnvironmentUnavailableDetails("never_attached", null), - ); - } - return { providerThreadId }; - } - const environment = getEnvironment(db, thread.environmentId); - const goneDetails = environment - ? goneThreadEnvironmentDetails(environment) - : null; - if (goneDetails) { - throwThreadEnvironmentUnavailable(goneDetails); - } - return { providerThreadId }; -} - -async function createQueuedMessageForThread( - deps: AppDeps, - args: CreateQueuedMessageForThreadArgs, -): Promise { - const { payload, thread } = args; - ensureThreadIsWritable(thread); - await validatePromptAttachmentReferences({ - dataDir: deps.config.dataDir, - input: payload.input, - projectId: thread.projectId, - }); - const execution = await buildExecutionOptions( - deps, - payload, - { - threadId: thread.id, - }, - "client/turn/requested", - ); - const senderThreadId = resolveMessageSenderThreadId(deps, { - senderThreadId: payload.senderThreadId, - targetThread: thread, - }); - // The awaits above can interleave with an archive or environment destroy, so - // admit against the rows as they are at insert time, in the same immediate - // transaction as the insert. - const { currentThread, providerThreadId, queuedMessage } = - deps.db.transaction( - (tx) => { - const currentThread = getThread(tx, thread.id); - if (!currentThread) { - throw new ApiError(404, "thread_not_found", "Thread not found"); - } - const { providerThreadId } = admitQueuedMessage(tx, currentThread); - const queuedMessage = createQueuedThreadMessageInTransaction(tx, { - threadId: thread.id, - content: payload.input, - senderThreadId, - model: execution.model, - reasoningLevel: execution.reasoningLevel, - permissionMode: execution.permissionMode, - serviceTier: execution.serviceTier, - }); - return { currentThread, providerThreadId, queuedMessage }; - }, - { behavior: "immediate" }, - ); - deps.hub.notifyThread(thread.id, ["queue-changed"]); - if (senderThreadId === null && payload.input.length > 0) { - deps.telemetry.capture({ - name: "user_message_sent", - properties: { - is_child_thread: thread.parentThreadId !== null, - message_source: "queued_message", - provider: thread.providerId, - }, - }); - } - if (currentThread.status === "idle" && providerThreadId !== null) { - requestQueuedMessageAutoSendForThread(deps, { - queuedMessageId: queuedMessage.id, - threadId: thread.id, - }); - } - return toThreadQueuedMessage(queuedMessage); -} - export function registerThreadActionRoutes(app: Hono, deps: AppDeps): void { const { post, patch, del } = typedRoutes(app, { onValidationError: (msg) => new ApiError(400, "invalid_request", msg), @@ -376,28 +227,9 @@ export function registerThreadActionRoutes(app: Hono, deps: AppDeps): void { post(routes.send, async (context, payload) => { const thread = requirePublicThread(deps.db, context.req.param("id")); - const shouldQueue = - thread.status === "active" && - (payload.mode === "queue-if-active" || - (payload.mode !== "start" && isManualCompactionActive(deps, thread))); - if (shouldQueue) { - ensureThreadIsNotAwaitingUserInteraction(deps, thread.id); - await createQueuedMessageForThread(deps, { - payload: queuedMessagePayloadFromSendRequest(payload), - thread, - }); - return context.json({ ok: true }); - } - const environment = await requireThreadCommandEnvironment(deps, { - thread, - }); - await sendThreadMessage(deps, { - environment, - payload, - thread, - trigger: "user", - }); - return context.json({ ok: true }); + return context.json( + await acceptThreadSendRequest(deps, { payload, thread }), + ); }); post(routes.editMessage, async (context, payload) => { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9ba6f1d82a..6741f9d54f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -30,6 +30,7 @@ import { } from "./services/plugins/plugin-service.js"; import { setPluginAgentContributions } from "./services/plugins/plugin-agent-contributions.js"; import { setPluginThreadEventEmitter } from "./services/plugins/plugin-thread-events.js"; +import { requestDeferredThreadMessageFlush } from "./services/threads/thread-send-request.js"; import { registerInternalEventRoutes } from "./internal/events.js"; import { registerInternalHostRoutes } from "./internal/hosts.js"; import { registerInternalInteractiveRequestRoutes } from "./internal/interactive-requests.js"; @@ -446,6 +447,11 @@ export function createApp( watchBuiltinPluginSources: process.env.BB_MANAGED_DEV_BUILTIN_PLUGIN_HOT_RELOAD === "1", }); + // Messages held back while a thread awaited user interaction deliver once + // that interaction settles (#1650); the periodic sweep covers the rest. + deps.pendingInteractions.setThreadInteractionSettledListener((threadId) => { + requestDeferredThreadMessageFlush(deps, threadId); + }); // Bridge the thread lifecycle seams to this service's plugins (§4.5). setPluginThreadEventEmitter(pluginService.events); // Bridge runtime-config assembly to plugin skills + context (§4.4). diff --git a/apps/server/src/services/interactions/pending-interactions.ts b/apps/server/src/services/interactions/pending-interactions.ts index 2f66106263..6312bb20bf 100644 --- a/apps/server/src/services/interactions/pending-interactions.ts +++ b/apps/server/src/services/interactions/pending-interactions.ts @@ -222,6 +222,8 @@ function buildInteractiveResolveCommand( type PendingInteractionLifecycleArgs = CreateLifecycleDeps; +export type ThreadInteractionSettledListener = (threadId: string) => void; + function buildInteractionChangeMetadata({ db, hasPendingInteraction, @@ -261,6 +263,8 @@ export class PendingInteractionLifecycle { private readonly deps: CreateLifecycleDeps; private readonly pluginWaiters = new Map(); private started = false; + private interactionSettledListener: ThreadInteractionSettledListener | null = + null; constructor(args: PendingInteractionLifecycleArgs) { this.deps = { @@ -294,6 +298,20 @@ export class PendingInteractionLifecycle { ); } + /** + * Registers the one listener that runs after an interaction reaches a + * terminal state (resolving, resolved, or interrupted). It releases work held + * back while the thread was blocked. The listener must re-check + * `hasPendingThreadInteraction`: a thread can settle one interaction and + * still hold another, and a `resolving` interaction still counts as pending. + * It may run inside a database transaction, so it must only schedule work. + */ + setThreadInteractionSettledListener( + listener: ThreadInteractionSettledListener, + ): void { + this.interactionSettledListener = listener; + } + listThreadInteractions(threadId: string): PendingInteraction[] { return this.parseListRows( listPendingInteractionsByThread(this.deps.db, { threadId }), @@ -896,6 +914,7 @@ export class PendingInteractionLifecycle { hasPendingInteraction: false, threadId: interaction.threadId, }); + this.notifyInteractionSettled(interaction.threadId); } private settleInteractionTerminalStateInTransaction( @@ -908,6 +927,21 @@ export class PendingInteractionLifecycle { hasPendingInteraction: false, threadId: interaction.threadId, }); + this.notifyInteractionSettled(interaction.threadId); + } + + private notifyInteractionSettled(threadId: string): void { + if (!this.interactionSettledListener) { + return; + } + try { + this.interactionSettledListener(threadId); + } catch (error) { + this.deps.logger.warn( + { err: error, threadId }, + "Pending interaction settled listener failed", + ); + } } private cancelPluginInteractionFromCallback(args: { diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md index 4814c66f4a..a192a3ae0f 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md @@ -398,6 +398,12 @@ or artifacts, validation performed, and blockers. agent can finish its current work first. Steer is especially important for a wrong direction, hard stop, or critical clarification. Example: `bb thread tell "Stop and use approach B" --mode steer`. +- If the target thread is awaiting user interaction (an open question or + approval), `bb thread tell` cannot interrupt it. The message is held and + delivers in the requested mode once the interaction settles; the CLI prints + "message held". That outcome is not a failure, so do not resend. For a hard + stop use `bb thread stop `. `--json` reports `delivery` as `sent`, + `queued`, or `deferred`. ## Inspecting Results diff --git a/apps/server/src/services/system/periodic-sweeps.ts b/apps/server/src/services/system/periodic-sweeps.ts index cedd16fca7..dd93531c97 100644 --- a/apps/server/src/services/system/periodic-sweeps.ts +++ b/apps/server/src/services/system/periodic-sweeps.ts @@ -47,6 +47,7 @@ import { import { hasLiveThreadStartInFlight } from "../threads/thread-lifecycle.js"; import { advanceThreadProvisioning } from "../threads/thread-provisioning.js"; import { runQueuedMessageAutoSendSweep } from "../threads/queued-messages.js"; +import { runDeferredThreadMessageSweep } from "../threads/thread-send-request.js"; import { LIVE_DAEMON_COMMAND_TIMEOUT_MS } from "../hosts/live-command.js"; import { runEventLoopWork } from "./event-loop-work.js"; @@ -560,6 +561,12 @@ const PERIODIC_SWEEP_JOBS: PeriodicSweepJob[] = [ name: "queued-message-auto-send", run: runQueuedMessageAutoSendSweep, }, + { + cadenceMs: 0, + category: "durable-intent-retry", + name: "deferred-thread-message-flush", + run: runDeferredThreadMessageSweep, + }, { cadenceMs: 0, category: "durable-intent-retry", diff --git a/apps/server/src/services/threads/deferred-thread-messages.ts b/apps/server/src/services/threads/deferred-thread-messages.ts new file mode 100644 index 0000000000..54459858a2 --- /dev/null +++ b/apps/server/src/services/threads/deferred-thread-messages.ts @@ -0,0 +1,61 @@ +import { + createDeferredThreadMessage, + type DeferredThreadMessageRow, +} from "@bb/db"; +import { + promptInputSchema, + systemMessageKindSchema, + systemMessageSubjectSchema, +} from "@bb/domain"; +import { sendMessageRequestSchema } from "@bb/server-contract"; +import { z } from "zod"; +import type { AppDeps } from "../../types.js"; + +// A thread that awaits user interaction (an AskUserQuestion, a command +// approval, a plugin input request) cannot take a prompt. Messages addressed to +// it while blocked used to be refused with a 409 (sends) or silently dropped +// (parent system messages), so the recipient never learned they existed +// (#1650). They now wait in `deferred_thread_messages` and deliver, in arrival +// order and in the mode the sender asked for, once the interaction settles. +// `thread-send-request.ts` owns the delivery side. +export const deferredThreadMessagePayloadSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("send"), + /** The public `send` request exactly as the sender posted it. */ + request: sendMessageRequestSchema, + }), + z.object({ + kind: z.literal("parent-system"), + input: z.array(promptInputSchema), + systemMessageKind: systemMessageKindSchema, + systemMessageSubject: systemMessageSubjectSchema.nullable(), + }), +]); +export type DeferredThreadMessagePayload = z.infer< + typeof deferredThreadMessagePayloadSchema +>; + +export function deferThreadMessage( + deps: Pick, + args: { threadId: string; payload: DeferredThreadMessagePayload }, +): void { + const row = createDeferredThreadMessage(deps.db, { + threadId: args.threadId, + kind: args.payload.kind, + payload: JSON.stringify(args.payload), + }); + deps.logger.info( + { + deferredMessageId: row.id, + kind: args.payload.kind, + threadId: args.threadId, + }, + "Thread awaits user interaction; deferred message until it settles", + ); +} + +export function parseDeferredThreadMessagePayload( + row: DeferredThreadMessageRow, +): DeferredThreadMessagePayload { + return deferredThreadMessagePayloadSchema.parse(JSON.parse(row.payload)); +} diff --git a/apps/server/src/services/threads/parent-system-messages.ts b/apps/server/src/services/threads/parent-system-messages.ts index 60aa77bfc9..d506bffe3d 100644 --- a/apps/server/src/services/threads/parent-system-messages.ts +++ b/apps/server/src/services/threads/parent-system-messages.ts @@ -16,6 +16,7 @@ import type { import type { HostDaemonCommand } from "@bb/host-daemon-contract"; import type { LoggedPendingInteractionWorkSessionDeps } from "../../types.js"; import { requireThreadEnvironment } from "../lib/entity-lookup.js"; +import { deferThreadMessage } from "./deferred-thread-messages.js"; import { addRequestIdToTurnSubmitCommandPayload, buildExecutionOptions, @@ -420,7 +421,19 @@ export async function queueParentSystemMessage( return false; } if (deps.pendingInteractions.hasPendingThreadInteraction(parentThread.id)) { - return false; + // A prompt cannot interrupt an open question or approval, and dropping the + // notice left the parent believing its child had gone silent (#1650). It + // waits and flushes when the parent's interactions settle. + deferThreadMessage(deps, { + threadId: parentThread.id, + payload: { + kind: "parent-system", + input: args.input, + systemMessageKind: args.systemMessageKind, + systemMessageSubject: args.systemMessageSubject, + }, + }); + return true; } const { environment } = requireThreadEnvironment( diff --git a/apps/server/src/services/threads/queued-messages.ts b/apps/server/src/services/threads/queued-messages.ts index cd878b0721..2af87e765b 100644 --- a/apps/server/src/services/threads/queued-messages.ts +++ b/apps/server/src/services/threads/queued-messages.ts @@ -1,6 +1,7 @@ import { claimQueuedThreadMessageGroup, claimNextQueuedThreadMessageGroup, + createQueuedThreadMessageInTransaction, deleteClaimedQueuedThreadMessageBatchInTransaction, getQueuedThreadMessage, getEnvironment, @@ -8,6 +9,7 @@ import { listIdleThreadsWithQueuedMessages, releaseQueuedMessageClaim, releaseStaleQueuedMessageClaims, + type DbQueryConnection, } from "@bb/db"; import type { PromptInput, @@ -16,6 +18,7 @@ import type { ThreadTurnInitiator, } from "@bb/domain"; import type { + CreateQueuedMessageRequest, SendMessageRequest, SendQueuedMessageMode, } from "@bb/server-contract"; @@ -50,11 +53,22 @@ import { recoverThreadModelOverride } from "./thread-execution-override.js"; import { ensureThreadCanStartRequest } from "./thread-lifecycle.js"; import { requireReadyThreadEnvironment } from "./thread-turn-dispatch.js"; import { resolvePermissionEscalation } from "./thread-runtime-config.js"; -import { formatAgentThreadInput, sendThreadMessage } from "./thread-send.js"; +import { + ensureThreadIsWritable, + formatAgentThreadInput, + resolveMessageSenderThreadId, + sendThreadMessage, +} from "./thread-send.js"; import { recordAcceptedPromptHistoryEntry } from "../prompt-history.js"; import { requireThreadCommandEnvironment } from "./thread-command-environment.js"; import { applyLoggedThreadLifecycleEventInTransaction } from "./lifecycle-outcome.js"; import { applyLoggedEnvironmentLifecycleEvent } from "../environments/lifecycle-outcome.js"; +import { + goneThreadEnvironmentDetails, + threadEnvironmentUnavailableDetails, + throwThreadEnvironmentUnavailable, +} from "../lib/lifecycle-api-errors.js"; +import { validatePromptAttachmentReferences } from "../projects/attachments.js"; interface SendQueuedMessageArgs { mode: SendQueuedMessageMode; @@ -101,6 +115,142 @@ async function requireReadyQueuedMessageEnvironment( ); } +export interface CreateQueuedMessageForThreadArgs { + payload: CreateQueuedMessageRequest; + thread: Thread; +} + +export function queuedMessagePayloadFromSendRequest( + payload: SendMessageRequest, +): CreateQueuedMessageRequest { + return { + input: payload.input, + ...(payload.model !== undefined ? { model: payload.model } : {}), + ...(payload.serviceTier !== undefined + ? { serviceTier: payload.serviceTier } + : {}), + ...(payload.reasoningLevel !== undefined + ? { reasoningLevel: payload.reasoningLevel } + : {}), + ...(payload.permissionMode !== undefined + ? { permissionMode: payload.permissionMode } + : {}), + ...(payload.executionInputSources !== undefined + ? { executionInputSources: payload.executionInputSources } + : {}), + ...(payload.senderThreadId !== undefined + ? { senderThreadId: payload.senderThreadId } + : {}), + }; +} + +/** + * Admits a queued message against the current thread and environment rows. + * Returns the provider thread id so the caller can decide on auto-send without + * a second event-history read. + * + * A queued message can only drain into the thread's environment. A gone + * environment (`destroying`/`destroyed`) is never reprovisioned, so accepting + * the message would park it in the queue forever while the thread keeps + * reporting `idle` (#1789). Refuse with the same 409 the direct send path + * returns. + * + * A thread with no environment row is accepted while it has never run: the + * queue is how messages wait for provisioning. Once the thread has a provider + * thread id, a missing environment means the row was pruned after destroy, and + * the direct send path already refuses with `never_attached`. + */ +function admitQueuedMessage( + db: DbQueryConnection, + thread: Thread, +): { providerThreadId: string | null } { + ensureThreadIsWritable(thread); + const providerThreadId = getLastProviderThreadId({ db }, thread.id); + if (thread.environmentId === null) { + if (providerThreadId !== null) { + throwThreadEnvironmentUnavailable( + threadEnvironmentUnavailableDetails("never_attached", null), + ); + } + return { providerThreadId }; + } + const environment = getEnvironment(db, thread.environmentId); + const goneDetails = environment + ? goneThreadEnvironmentDetails(environment) + : null; + if (goneDetails) { + throwThreadEnvironmentUnavailable(goneDetails); + } + return { providerThreadId }; +} + +export async function createQueuedMessageForThread( + deps: LoggedPendingInteractionWorkSessionDeps, + args: CreateQueuedMessageForThreadArgs, +): Promise { + const { payload, thread } = args; + ensureThreadIsWritable(thread); + await validatePromptAttachmentReferences({ + dataDir: deps.config.dataDir, + input: payload.input, + projectId: thread.projectId, + }); + const execution = await buildExecutionOptions( + deps, + payload, + { + threadId: thread.id, + }, + "client/turn/requested", + ); + const senderThreadId = resolveMessageSenderThreadId(deps, { + senderThreadId: payload.senderThreadId, + targetThread: thread, + }); + // The awaits above can interleave with an archive or environment destroy, so + // admit against the rows as they are at insert time, in the same immediate + // transaction as the insert. + const { currentThread, providerThreadId, queuedMessage } = + deps.db.transaction( + (tx) => { + const currentThread = getThread(tx, thread.id); + if (!currentThread) { + throw new ApiError(404, "thread_not_found", "Thread not found"); + } + const { providerThreadId } = admitQueuedMessage(tx, currentThread); + const queuedMessage = createQueuedThreadMessageInTransaction(tx, { + threadId: thread.id, + content: payload.input, + senderThreadId, + model: execution.model, + reasoningLevel: execution.reasoningLevel, + permissionMode: execution.permissionMode, + serviceTier: execution.serviceTier, + }); + return { currentThread, providerThreadId, queuedMessage }; + }, + { behavior: "immediate" }, + ); + deps.hub.notifyThread(thread.id, ["queue-changed"]); + if (senderThreadId === null && payload.input.length > 0) { + deps.telemetry.capture({ + name: "user_message_sent", + properties: { + is_child_thread: thread.parentThreadId !== null, + message_source: "queued_message", + provider: thread.providerId, + }, + }); + } + if (currentThread.status === "idle" && providerThreadId !== null) { + requestQueuedMessageAutoSendForThread(deps, { + queuedMessageId: queuedMessage.id, + threadId: thread.id, + }); + } + return toThreadQueuedMessage(queuedMessage); +} + interface QueuedMessageAutoSendRequestArgs { queuedMessageId: string; threadId: string; diff --git a/apps/server/src/services/threads/thread-send-request.ts b/apps/server/src/services/threads/thread-send-request.ts new file mode 100644 index 0000000000..323a3eee89 --- /dev/null +++ b/apps/server/src/services/threads/thread-send-request.ts @@ -0,0 +1,291 @@ +import { + deleteDeferredThreadMessage, + deleteDeferredThreadMessagesForThread, + getThread, + listDeferredThreadMessages, + listThreadIdsWithDeferredThreadMessages, + type DeferredThreadMessageRow, +} from "@bb/db"; +import type { Thread } from "@bb/domain"; +import type { + SendMessageRequest, + SendMessageResponse, +} from "@bb/server-contract"; +import { ApiError } from "../../errors.js"; +import type { LoggedPendingInteractionWorkSessionDeps } from "../../types.js"; +import { + isCommandTimeoutError, + runtimeErrorLogFields, +} from "../lib/error-log-fields.js"; +import { deferAfterResponse } from "../lib/response-deferral.js"; +import { validatePromptAttachmentReferences } from "../projects/attachments.js"; +import { + deferThreadMessage, + parseDeferredThreadMessagePayload, + type DeferredThreadMessagePayload, +} from "./deferred-thread-messages.js"; +import { queueParentSystemMessage } from "./parent-system-messages.js"; +import { + createQueuedMessageForThread, + queuedMessagePayloadFromSendRequest, +} from "./queued-messages.js"; +import { requireThreadCommandEnvironment } from "./thread-command-environment.js"; +import { isManualCompactionActive } from "./thread-events.js"; +import { + ensureThreadIsWritable, + resolveMessageSenderThreadId, + sendThreadMessage, +} from "./thread-send.js"; + +interface AcceptThreadSendRequestArgs { + payload: SendMessageRequest; + thread: Thread; +} + +/** + * Takes a public `send` request (the `/threads/:id/send` route, `bb thread + * tell`, `sdk.threads.send`) and decides how it reaches the thread: + * + * - the thread queue when the sender asked for `queue-if-active` on an active + * thread, or the thread is compacting; + * - a deferred message when the thread awaits user interaction (#1650): a + * prompt cannot interrupt an open question or approval, so the message waits + * and {@link flushDeferredThreadMessages} delivers it through this same + * function once the interaction settles. `start` is the exception: it asks + * for a fresh turn on an idle thread and keeps its 409. + * - otherwise an immediate start or steer. + */ +export async function acceptThreadSendRequest( + deps: LoggedPendingInteractionWorkSessionDeps, + args: AcceptThreadSendRequestArgs, +): Promise { + const { payload, thread } = args; + const shouldQueue = + thread.status === "active" && + (payload.mode === "queue-if-active" || + (payload.mode !== "start" && isManualCompactionActive(deps, thread))); + if (shouldQueue) { + await createQueuedMessageForThread(deps, { + payload: queuedMessagePayloadFromSendRequest(payload), + thread, + }); + return { ok: true, delivery: "queued" }; + } + if ( + payload.mode !== "start" && + deps.pendingInteractions.hasPendingThreadInteraction(thread.id) + ) { + ensureThreadIsWritable(thread); + // Reject what can never deliver while the sender is still listening; the + // rest of the send pipeline (execution options, plugin mentions) resolves + // at delivery time, exactly like a queued message. + resolveMessageSenderThreadId(deps, { + senderThreadId: payload.senderThreadId, + targetThread: thread, + }); + await validatePromptAttachmentReferences({ + dataDir: deps.config.dataDir, + input: payload.input, + projectId: thread.projectId, + }); + deferThreadMessage(deps, { + threadId: thread.id, + payload: { kind: "send", request: payload }, + }); + return { ok: true, delivery: "deferred" }; + } + const environment = await requireThreadCommandEnvironment(deps, { thread }); + await sendThreadMessage(deps, { + environment, + payload, + thread, + trigger: "user", + }); + return { ok: true, delivery: "sent" }; +} + +interface DeliverDeferredThreadMessageArgs { + payload: DeferredThreadMessagePayload; + row: DeferredThreadMessageRow; + thread: Thread; +} + +/** Returns false when delivery must wait for a later flush. */ +async function deliverDeferredThreadMessage( + deps: LoggedPendingInteractionWorkSessionDeps, + args: DeliverDeferredThreadMessageArgs, +): Promise { + const { payload, row, thread } = args; + switch (payload.kind) { + case "send": { + // Re-enters the normal send policy: a thread that blocked again between + // the settle and this flush re-defers the message as a new row. + const result = await acceptThreadSendRequest(deps, { + payload: payload.request, + thread, + }); + deleteDeferredThreadMessage(deps.db, { id: row.id, threadId: thread.id }); + deps.logger.info( + { + deferredMessageId: row.id, + delivery: result.delivery, + kind: payload.kind, + threadId: thread.id, + }, + "Delivered deferred thread message", + ); + return true; + } + case "parent-system": { + // `false` means the thread changed under the send (for example it went + // idle between the prepared command and its transaction); the row stays + // and the next flush takes the matching path. + const delivered = await queueParentSystemMessage(deps, { + input: payload.input, + parentThreadId: thread.id, + systemMessageKind: payload.systemMessageKind, + systemMessageSubject: payload.systemMessageSubject, + }); + if (!delivered) { + return false; + } + deleteDeferredThreadMessage(deps.db, { id: row.id, threadId: thread.id }); + deps.logger.info( + { + deferredMessageId: row.id, + kind: payload.kind, + systemMessageKind: payload.systemMessageKind, + threadId: thread.id, + }, + "Delivered deferred thread message", + ); + return true; + } + } +} + +/** + * A 400/404 from the send pipeline means the request references something that + * no longer exists. Everything else (409 stopping or environment unavailable, + * 422 plugin mention, 502 host away, timeouts) can clear on a later flush. + */ +function isDeferredThreadMessageRequestInvalid(error: unknown): boolean { + return ( + error instanceof ApiError && (error.status === 400 || error.status === 404) + ); +} + +async function flushDeferredThreadMessagesNow( + deps: LoggedPendingInteractionWorkSessionDeps, + threadId: string, +): Promise { + for (const row of listDeferredThreadMessages(deps.db, threadId)) { + const thread = getThread(deps.db, threadId); + if (!thread || thread.archivedAt !== null || thread.deletedAt !== null) { + const dropped = deleteDeferredThreadMessagesForThread(deps.db, threadId); + deps.logger.info( + { dropped, threadId }, + "Dropped deferred thread messages: thread is gone", + ); + return; + } + if (deps.pendingInteractions.hasPendingThreadInteraction(threadId)) { + return; + } + let payload: DeferredThreadMessagePayload; + try { + payload = parseDeferredThreadMessagePayload(row); + } catch (error) { + deleteDeferredThreadMessage(deps.db, { id: row.id, threadId }); + deps.logger.error( + { err: error, deferredMessageId: row.id, threadId }, + "Dropped malformed deferred thread message", + ); + continue; + } + try { + if ( + !(await deliverDeferredThreadMessage(deps, { payload, row, thread })) + ) { + return; + } + } catch (error) { + const fields = { + deferredMessageId: row.id, + kind: payload.kind, + ...runtimeErrorLogFields(deps.config, error), + threadId, + }; + if (isDeferredThreadMessageRequestInvalid(error)) { + // The request itself can no longer be honored (its sender thread was + // deleted, its attachment is gone): the same 400 the sender would have + // received synchronously. Retrying cannot change that, and leaving the + // row would block every later message for this thread. + deleteDeferredThreadMessage(deps.db, { id: row.id, threadId }); + deps.logger.warn( + fields, + "Dropped deferred thread message: request is no longer valid", + ); + continue; + } + // Keep this row and the ones behind it so arrival order survives; the + // next settle or sweep retries. A thread that is stopping, a host that + // is reconnecting, or a fresh interaction all clear on their own. + if (isCommandTimeoutError(error)) { + deps.logger.debug( + fields, + "Deferred thread message delivery deferred by host timeout", + ); + } else { + deps.logger.warn( + fields, + "Deferred thread message delivery failed; will retry", + ); + } + return; + } + } +} + +/** + * Delivers the messages deferred while `threadId` awaited user interaction. + * A no-op while the thread still has a pending interaction. Flushes for one + * thread never overlap, so a settle and a sweep cannot deliver a row twice. + */ +export async function flushDeferredThreadMessages( + deps: LoggedPendingInteractionWorkSessionDeps, + threadId: string, +): Promise { + await deps.lifecycleDedupers.deferredThreadMessageFlush.run(threadId, () => + flushDeferredThreadMessagesNow(deps, threadId), + ); +} + +/** + * Settle hook: schedules a flush off the caller's stack. The settle can run + * inside a database transaction, so nothing here touches the database. + */ +export function requestDeferredThreadMessageFlush( + deps: LoggedPendingInteractionWorkSessionDeps, + threadId: string, +): void { + deferAfterResponse({ + config: deps.config, + context: { threadId }, + logger: deps.logger, + name: "Deferred thread message flush", + work: () => flushDeferredThreadMessages(deps, threadId), + }); +} + +/** + * Sweep entry: re-drives rows whose settle flush did not deliver (a restart + * before the settle, a thread that was still stopping, a host that was away). + */ +export async function runDeferredThreadMessageSweep( + deps: LoggedPendingInteractionWorkSessionDeps, +): Promise { + for (const threadId of listThreadIdsWithDeferredThreadMessages(deps.db)) { + await flushDeferredThreadMessages(deps, threadId); + } +} diff --git a/apps/server/test/internal/internal-events-tool-calls.test.ts b/apps/server/test/internal/internal-events-tool-calls.test.ts index fe56214740..f082d59b79 100644 --- a/apps/server/test/internal/internal-events-tool-calls.test.ts +++ b/apps/server/test/internal/internal-events-tool-calls.test.ts @@ -597,7 +597,10 @@ describe("internal event and tool-call routes", () => { ); expect(sendResponse.status).toBe(200); - await expect(readJson(sendResponse)).resolves.toEqual({ ok: true }); + await expect(readJson(sendResponse)).resolves.toEqual({ + ok: true, + delivery: "queued", + }); const queuedRows = listQueuedThreadMessages(harness.db, thread.id); expect(queuedRows).toHaveLength(1); expect(JSON.parse(queuedRows[0]?.content ?? "null")).toEqual([ diff --git a/apps/server/test/public/public-thread-data.test.ts b/apps/server/test/public/public-thread-data.test.ts index 4670f21ee3..6f300f93bf 100644 --- a/apps/server/test/public/public-thread-data.test.ts +++ b/apps/server/test/public/public-thread-data.test.ts @@ -2567,7 +2567,10 @@ describe("public thread data routes", () => { ); expect(response.status).toBe(200); - await expect(readJson(response)).resolves.toEqual({ ok: true }); + await expect(readJson(response)).resolves.toEqual({ + ok: true, + delivery: "queued", + }); const queuedRows = listQueuedThreadMessages(harness.db, thread.id); expect(queuedRows).toMatchObject([ { @@ -2628,7 +2631,10 @@ describe("public thread data routes", () => { // Sender attribution is allowed across projects, so the message queues // with the cross-project sender preserved for the reply affordance. expect(response.status).toBe(200); - await expect(readJson(response)).resolves.toEqual({ ok: true }); + await expect(readJson(response)).resolves.toEqual({ + ok: true, + delivery: "queued", + }); expect(listQueuedThreadMessages(harness.db, thread.id)).toMatchObject([ { senderThreadId: crossProjectSender.id, diff --git a/apps/server/test/public/public-thread-interactions.test.ts b/apps/server/test/public/public-thread-interactions.test.ts index dc10258bab..e450411275 100644 --- a/apps/server/test/public/public-thread-interactions.test.ts +++ b/apps/server/test/public/public-thread-interactions.test.ts @@ -1,4 +1,8 @@ -import { createQueuedThreadMessage, listQueuedThreadMessages } from "@bb/db"; +import { + createQueuedThreadMessage, + listDeferredThreadMessages, + listQueuedThreadMessages, +} from "@bb/db"; import { turnScope, USER_QUESTION_MAX_FREE_TEXT_LENGTH, @@ -814,7 +818,7 @@ describe("public thread interaction routes", () => { }, ); - it("rejects send and queued-message send while a thread awaits user interaction", async () => { + it("holds sends and rejects queued-message send while a thread awaits user interaction", async () => { await withTestHarness(async (harness) => { const { host } = seedHostSession(harness.deps, { id: "host-public-thread-blocked-send", @@ -876,8 +880,30 @@ describe("public thread interaction routes", () => { }), }, ); - expect(sendResponse.status).toBe(409); + // A prompt cannot interrupt the interaction, but the message is not lost: + // it waits and delivers once the interaction settles (#1650). + expect(sendResponse.status).toBe(200); await expect(readJson(sendResponse)).resolves.toEqual({ + ok: true, + delivery: "deferred", + }); + expect(listDeferredThreadMessages(harness.db, thread.id)).toHaveLength(1); + + const startResponse = await harness.app.request( + `/api/v1/threads/${thread.id}/send`, + { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + mode: "start", + input: [{ type: "text", text: "Try to start" }], + }), + }, + ); + expect(startResponse.status).toBe(409); + await expect(readJson(startResponse)).resolves.toEqual({ code: "awaiting_user_interaction", message: "Thread is awaiting user interaction. Resolve the pending interaction before sending another prompt.", @@ -905,6 +931,11 @@ describe("public thread interaction routes", () => { environmentId: environment.id, status: "active", }); + seedThreadRuntimeState(harness.deps, { + threadId: activeThread.id, + environmentId: environment.id, + providerThreadId: "provider-thread-active-blocked", + }); const activeThreadPending = registerPendingInteraction( harness.deps, harness.deps.pendingInteractions, @@ -941,14 +972,15 @@ describe("public thread interaction routes", () => { }), }, ); - expect(activeSendResponse.status).toBe(409); + // The queue drains when the thread is next idle, which an open + // interaction does not change, so an explicit queue request queues. + expect(activeSendResponse.status).toBe(200); await expect(readJson(activeSendResponse)).resolves.toEqual({ - code: "awaiting_user_interaction", - message: - "Thread is awaiting user interaction. Resolve the pending interaction before sending another prompt.", + ok: true, + delivery: "queued", }); expect(listQueuedThreadMessages(harness.db, activeThread.id)).toHaveLength( - 0, + 1, ); }); }); diff --git a/apps/server/test/services/plugins/plugin-sdk.test.ts b/apps/server/test/services/plugins/plugin-sdk.test.ts index e27c566529..d67f202410 100644 --- a/apps/server/test/services/plugins/plugin-sdk.test.ts +++ b/apps/server/test/services/plugins/plugin-sdk.test.ts @@ -544,7 +544,7 @@ describe("plugin bb.sdk against a running server", () => { mode: "auto", input: [{ type: "text", text: "Continue", mentions: [] }], }), - ).resolves.toEqual({ ok: true }); + ).resolves.toEqual({ ok: true, delivery: "sent" }); const stopPromise = api.sdk.threads.stop({ threadId: operable.id }); const stop = await waitForQueuedCommand( server, diff --git a/apps/server/test/threads/deferred-thread-messages.test.ts b/apps/server/test/threads/deferred-thread-messages.test.ts new file mode 100644 index 0000000000..18cdcc0596 --- /dev/null +++ b/apps/server/test/threads/deferred-thread-messages.test.ts @@ -0,0 +1,461 @@ +// Messages addressed to a thread that is blocked on a pending user interaction +// (AskUserQuestion, approvals) used to be lost (#1650): `/send` returned 409 +// and persisted nothing, and `queueParentSystemMessage` returned false with +// nothing logged. They now wait in `deferred_thread_messages` and deliver when +// the interaction settles. +import { and, eq } from "drizzle-orm"; +import { + events, + getThread, + listDeferredThreadMessages, + listQueuedThreadMessages, + markThreadDeleted, + type DbConnection, +} from "@bb/db"; +import { + turnRequestEventDataSchema, + type PendingInteractionCreate, + type TurnRequestEventData, +} from "@bb/domain"; +import { describe, expect, it } from "vitest"; +import { applyLoggedThreadLifecycleEvent } from "../../src/services/threads/lifecycle-outcome.js"; +import { queueParentSystemMessage } from "../../src/services/threads/parent-system-messages.js"; +import { + flushDeferredThreadMessages, + runDeferredThreadMessageSweep, +} from "../../src/services/threads/thread-send-request.js"; +import { + reportQueuedCommandSuccess, + waitForQueuedCommand, +} from "../helpers/commands.js"; +import { readJson } from "../helpers/json.js"; +import { + createUserAnswerResolution, + createUserQuestionPayload, +} from "../helpers/pending-interactions.js"; +import { textInput } from "../helpers/prompt-input.js"; +import { + seedEnvironment, + seedHostSession, + seedProjectWithSource, + seedThread, + seedThreadRuntimeState, + seedTurnStarted, +} from "../helpers/seed.js"; +import { createTestAppHarness, withTestHarness } from "../helpers/test-app.js"; + +type TestHarness = Awaited>; + +function listTurnRequests( + db: DbConnection, + threadId: string, +): TurnRequestEventData[] { + return db + .select() + .from(events) + .where( + and( + eq(events.threadId, threadId), + eq(events.type, "client/turn/requested"), + ), + ) + .orderBy(events.sequence) + .all() + .map((row) => turnRequestEventDataSchema.parse(JSON.parse(row.data))); +} + +async function waitFor( + read: () => T | null | undefined, + timeoutMs = 4_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = read(); + if (value !== null && value !== undefined) { + return value; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error("Timed out waiting for the expected state"); +} + +/** + * A thread in the middle of a turn (runtime state + turn/started) that is now + * parked on an AskUserQuestion, exactly like an orchestrator that asked the + * user something. + */ +function seedBlockedThread( + harness: TestHarness, + args: { hostId: string; status?: "active" | "idle" }, +) { + const { host, session } = seedHostSession(harness.deps, { id: args.hostId }); + const { project } = seedProjectWithSource(harness.deps, { hostId: host.id }); + const environment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + path: `/tmp/${args.hostId}`, + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + status: args.status ?? "active", + title: "Orchestrator", + }); + const providerThreadId = `provider-${args.hostId}`; + const turnId = `turn-${args.hostId}`; + seedThreadRuntimeState(harness.deps, { + threadId: thread.id, + environmentId: environment.id, + providerThreadId, + inputText: "Orchestrate", + model: "fake-model", + }); + seedTurnStarted(harness.deps, { + threadId: thread.id, + environmentId: environment.id, + turnId, + providerThreadId, + }); + const interaction: PendingInteractionCreate = { + threadId: thread.id, + turnId, + providerId: "codex", + providerThreadId, + providerRequestId: `request-${args.hostId}`, + payload: createUserQuestionPayload(), + }; + const registered = + harness.deps.pendingInteractions.registerPendingInteraction({ + interaction, + }); + if (registered.outcome === "rejected") { + throw new Error(registered.reason); + } + return { + environment, + host, + interactionId: registered.interaction.id, + project, + session, + thread, + }; +} + +async function answerQuestion( + harness: TestHarness, + args: { interactionId: string; threadId: string }, +): Promise { + const resolveResponse = await harness.app.request( + `/api/v1/threads/${args.threadId}/interactions/${args.interactionId}/resolve`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(createUserAnswerResolution()), + }, + ); + expect(resolveResponse.status).toBe(200); + const queuedResolve = await waitForQueuedCommand( + harness, + ({ command }) => + command.type === "interactive.resolve" && + command.interactionId === args.interactionId, + ); + const reported = await reportQueuedCommandSuccess(harness, queuedResolve, {}); + expect(reported.status).toBe(200); +} + +describe("messages to a thread that awaits user interaction (#1650)", () => { + it("holds a worker's tell instead of refusing it, then steers it in once the question is answered", async () => { + await withTestHarness(async (harness) => { + const { interactionId, project, thread } = seedBlockedThread(harness, { + hostId: "host-1650-tell", + }); + const worker = seedThread(harness.deps, { + projectId: project.id, + environmentId: thread.environmentId, + title: "Worker", + parentThreadId: thread.id, + }); + + // `bb thread tell "..."` from inside the worker. + const response = await harness.app.request( + `/api/v1/threads/${thread.id}/send`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + mode: "steer-if-active", + senderThreadId: worker.id, + input: [{ type: "text", text: "worker report: task done" }], + }), + }, + ); + expect(response.status).toBe(200); + await expect(readJson(response)).resolves.toEqual({ + ok: true, + delivery: "deferred", + }); + expect(listDeferredThreadMessages(harness.db, thread.id)).toHaveLength(1); + expect(listQueuedThreadMessages(harness.db, thread.id)).toHaveLength(0); + // Nothing reaches the provider while the question is open. + expect(listTurnRequests(harness.db, thread.id)).toHaveLength(1); + + await answerQuestion(harness, { interactionId, threadId: thread.id }); + + const delivered = await waitFor(() => + listTurnRequests(harness.db, thread.id).find( + (request) => request.senderThreadId === worker.id, + ), + ); + expect(delivered.initiator).toBe("agent"); + expect(delivered.target.kind).toBe("steer"); + expect(listDeferredThreadMessages(harness.db, thread.id)).toHaveLength(0); + }); + }); + + it("keeps 409 for mode=start, the one mode that demands an idle thread", async () => { + await withTestHarness(async (harness) => { + const { thread } = seedBlockedThread(harness, { + hostId: "host-1650-start", + status: "idle", + }); + const response = await harness.app.request( + `/api/v1/threads/${thread.id}/send`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + mode: "start", + input: [{ type: "text", text: "Start over" }], + }), + }, + ); + expect(response.status).toBe(409); + await expect(readJson(response)).resolves.toMatchObject({ + code: "awaiting_user_interaction", + }); + expect(listDeferredThreadMessages(harness.db, thread.id)).toHaveLength(0); + }); + }); + + it("rejects a tell from a sender thread that does not exist before holding it", async () => { + await withTestHarness(async (harness) => { + const { thread } = seedBlockedThread(harness, { + hostId: "host-1650-bad-sender", + }); + const response = await harness.app.request( + `/api/v1/threads/${thread.id}/send`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + mode: "steer-if-active", + senderThreadId: "thr_missing", + input: [{ type: "text", text: "hello" }], + }), + }, + ); + expect(response.status).toBe(400); + expect(listDeferredThreadMessages(harness.db, thread.id)).toHaveLength(0); + }); + }); + + it("holds a child-completed notice for a blocked parent and flushes it after the answer", async () => { + await withTestHarness(async (harness) => { + const { + interactionId, + project, + thread: parent, + } = seedBlockedThread(harness, { hostId: "host-1650-parent" }); + const child = seedThread(harness.deps, { + projectId: project.id, + environmentId: parent.environmentId, + title: "Worker child", + parentThreadId: parent.id, + }); + + const accepted = await queueParentSystemMessage(harness.deps, { + input: textInput("[bb system] child completed"), + parentThreadId: parent.id, + systemMessageKind: "child-completed", + systemMessageSubject: { + kind: "thread", + threadId: child.id, + threadName: "Worker child", + }, + }); + expect(accepted).toBe(true); + expect(listDeferredThreadMessages(harness.db, parent.id)).toHaveLength(1); + expect( + listTurnRequests(harness.db, parent.id).filter( + (request) => request.initiator === "system", + ), + ).toHaveLength(0); + + await answerQuestion(harness, { interactionId, threadId: parent.id }); + + const notice = await waitFor(() => + listTurnRequests(harness.db, parent.id).find( + (request) => request.initiator === "system", + ), + ); + expect(notice.systemMessageKind).toBe("child-completed"); + expect(notice.systemMessageSubject).toEqual({ + kind: "thread", + threadId: child.id, + threadName: "Worker child", + }); + expect(listDeferredThreadMessages(harness.db, parent.id)).toHaveLength(0); + }); + }); + + it("delivers held messages in arrival order and leaves them in place while the thread is still blocked", async () => { + await withTestHarness(async (harness) => { + const { interactionId, thread } = seedBlockedThread(harness, { + hostId: "host-1650-order", + }); + for (const text of ["first", "second"]) { + const response = await harness.app.request( + `/api/v1/threads/${thread.id}/send`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + mode: "auto", + input: [{ type: "text", text }], + }), + }, + ); + expect(response.status).toBe(200); + } + await queueParentSystemMessage(harness.deps, { + input: textInput("third"), + parentThreadId: thread.id, + systemMessageKind: "child-completed", + systemMessageSubject: null, + }); + expect(listDeferredThreadMessages(harness.db, thread.id)).toHaveLength(3); + + // A sweep (or a settle of some other interaction) must not deliver while + // the question is still open. + await runDeferredThreadMessageSweep(harness.deps); + expect(listDeferredThreadMessages(harness.db, thread.id)).toHaveLength(3); + expect(listTurnRequests(harness.db, thread.id)).toHaveLength(1); + + harness.deps.pendingInteractions.interruptPendingInteraction({ + interactionId, + reason: "answered", + }); + await flushDeferredThreadMessages(harness.deps, thread.id); + + const texts = listTurnRequests(harness.db, thread.id) + .slice(1) + .map((request) => + request.input + .map((part) => (part.type === "text" ? part.text : "")) + .join(""), + ); + expect(texts).toEqual(["first", "second", "third"]); + expect(listDeferredThreadMessages(harness.db, thread.id)).toHaveLength(0); + }); + }); + + it("keeps a held message while the thread is stopping and delivers it from the sweep once idle", async () => { + await withTestHarness(async (harness) => { + const { interactionId, thread } = seedBlockedThread(harness, { + hostId: "host-1650-stop", + }); + const response = await harness.app.request( + `/api/v1/threads/${thread.id}/send`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + mode: "steer-if-active", + input: [{ type: "text", text: "report while blocked" }], + }), + }, + ); + expect(response.status).toBe(200); + + // The user gives up on the question and stops the thread. The stop + // interrupts the interaction while the thread is still `stopping`, so the + // settle flush cannot deliver yet; the row must survive for the sweep. + applyLoggedThreadLifecycleEvent(harness.deps, { + event: { type: "stop.requested" }, + threadId: thread.id, + }); + harness.deps.pendingInteractions.interruptPendingInteraction({ + interactionId, + reason: "thread-stopped", + }); + await flushDeferredThreadMessages(harness.deps, thread.id); + expect(listDeferredThreadMessages(harness.db, thread.id)).toHaveLength(1); + expect(listTurnRequests(harness.db, thread.id)).toHaveLength(1); + + applyLoggedThreadLifecycleEvent(harness.deps, { + event: { type: "stop.settled" }, + threadId: thread.id, + }); + expect(getThread(harness.db, thread.id)?.status).toBe("idle"); + + await runDeferredThreadMessageSweep(harness.deps); + const delivered = listTurnRequests(harness.db, thread.id).at(-1); + expect(delivered?.target.kind).toBe("new-turn"); + expect(listDeferredThreadMessages(harness.db, thread.id)).toHaveLength(0); + }); + }); + + it("drops a held message whose sender was deleted in the meantime instead of blocking the ones behind it", async () => { + await withTestHarness(async (harness) => { + const { interactionId, project, thread } = seedBlockedThread(harness, { + hostId: "host-1650-gone-sender", + }); + const worker = seedThread(harness.deps, { + projectId: project.id, + environmentId: thread.environmentId, + title: "Worker", + parentThreadId: thread.id, + }); + for (const [text, senderThreadId] of [ + ["from the worker", worker.id], + ["from the user", undefined], + ] as const) { + const response = await harness.app.request( + `/api/v1/threads/${thread.id}/send`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + mode: "steer-if-active", + senderThreadId, + input: [{ type: "text", text }], + }), + }, + ); + expect(response.status).toBe(200); + } + expect(listDeferredThreadMessages(harness.db, thread.id)).toHaveLength(2); + + // The worker is deleted while the question is still open. Its tell can + // no longer deliver (the sender must be a live reply target), which must + // not strand the user's message behind it forever. + markThreadDeleted(harness.db, harness.deps.hub, { threadId: worker.id }); + harness.deps.pendingInteractions.interruptPendingInteraction({ + interactionId, + reason: "answered", + }); + await flushDeferredThreadMessages(harness.deps, thread.id); + + const texts = listTurnRequests(harness.db, thread.id) + .slice(1) + .map((request) => + request.input + .map((part) => (part.type === "text" ? part.text : "")) + .join(""), + ); + expect(texts).toEqual(["from the user"]); + expect(listDeferredThreadMessages(harness.db, thread.id)).toHaveLength(0); + }); + }); +}); diff --git a/packages/db/drizzle/0105_deferred_thread_messages.sql b/packages/db/drizzle/0105_deferred_thread_messages.sql new file mode 100644 index 0000000000..4f0434066d --- /dev/null +++ b/packages/db/drizzle/0105_deferred_thread_messages.sql @@ -0,0 +1,10 @@ +CREATE TABLE `deferred_thread_messages` ( + `id` text PRIMARY KEY NOT NULL, + `thread_id` text NOT NULL, + `kind` text NOT NULL, + `payload` text NOT NULL, + `created_at` integer NOT NULL, + FOREIGN KEY (`thread_id`) REFERENCES `threads`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `deferred_thread_messages_thread_created_idx` ON `deferred_thread_messages` (`thread_id`,`created_at`,`id`); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0105_snapshot.json b/packages/db/drizzle/meta/0105_snapshot.json new file mode 100644 index 0000000000..7db0c6aaab --- /dev/null +++ b/packages/db/drizzle/meta/0105_snapshot.json @@ -0,0 +1,3802 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f3dcb7bb-cbcb-4543-a0f1-fefdd9fbf6a1", + "prevId": "4b28b004-f79b-40fd-8d7d-60fd4543c92e", + "tables": { + "app_settings": { + "name": "app_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "caffeinate": { + "name": "caffeinate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_keyboard_hints": { + "name": "show_keyboard_hints", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "steer_active_thread_on_enter": { + "name": "steer_active_thread_on_enter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_unhandled_provider_events": { + "name": "show_unhandled_provider_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "codex_memory_enabled": { + "name": "codex_memory_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "claude_code_memory_enabled": { + "name": "claude_code_memory_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "codex_subagents_disabled": { + "name": "codex_subagents_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "claude_code_subagents_disabled": { + "name": "claude_code_subagents_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "claude_code_workflows_disabled": { + "name": "claude_code_workflows_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "keybinding_overrides": { + "name": "keybinding_overrides", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_settings_values": { + "name": "app_settings_values", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_theme": { + "name": "app_theme", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "theme_id": { + "name": "theme_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "favicon_color": { + "name": "favicon_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "referenceId": { + "name": "referenceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refillInterval": { + "name": "refillInterval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refillAmount": { + "name": "refillAmount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastRefillAt": { + "name": "lastRefillAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitEnabled": { + "name": "rateLimitEnabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitTimeWindow": { + "name": "rateLimitTimeWindow", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitMax": { + "name": "rateLimitMax", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestCount": { + "name": "requestCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastRequest": { + "name": "lastRequest", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "configId": { + "name": "configId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_unique": { + "name": "apikey_key_unique", + "columns": [ + "key" + ], + "isUnique": true + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": [ + "referenceId" + ], + "isUnique": false + }, + "apikey_config_id_idx": { + "name": "apikey_config_id_idx", + "columns": [ + "configId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "apikey_referenceId_user_id_fk": { + "name": "apikey_referenceId_user_id_fk", + "tableFrom": "apikey", + "tableTo": "user", + "columnsFrom": [ + "referenceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deferred_thread_messages": { + "name": "deferred_thread_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "deferred_thread_messages_thread_created_idx": { + "name": "deferred_thread_messages_thread_created_idx", + "columns": [ + "thread_id", + "created_at", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "deferred_thread_messages_thread_id_threads_id_fk": { + "name": "deferred_thread_messages_thread_id_threads_id_fk", + "tableFrom": "deferred_thread_messages", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environments": { + "name": "environments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "managed": { + "name": "managed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_git_repo": { + "name": "is_git_repo", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_worktree": { + "name": "is_worktree", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "merge_base_branch": { + "name": "merge_base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "destroy_attempt_id": { + "name": "destroy_attempt_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retire_requested_at": { + "name": "retire_requested_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_provision_type": { + "name": "workspace_provision_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'provisioning'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "environments_project_host_path_idx": { + "name": "environments_project_host_path_idx", + "columns": [ + "project_id", + "host_id", + "path" + ], + "isUnique": true + }, + "environments_host_path_lookup_idx": { + "name": "environments_host_path_lookup_idx", + "columns": [ + "host_id", + "path" + ], + "isUnique": false + }, + "environments_project_idx": { + "name": "environments_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "environments_status_idx": { + "name": "environments_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "environments_project_id_projects_id_fk": { + "name": "environments_project_id_projects_id_fk", + "tableFrom": "environments", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environments_host_id_hosts_id_fk": { + "name": "environments_host_id_hosts_id_fk", + "tableFrom": "environments", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_tool_call_id": { + "name": "parent_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "generated": { + "as": "(CASE WHEN json_valid(data) THEN json_extract(data, '$.item.tool') END)", + "type": "virtual" + } + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "events_thread_sequence_idx": { + "name": "events_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": true + }, + "events_tool_call_parent_lookup_idx": { + "name": "events_tool_call_parent_lookup_idx", + "columns": [ + "thread_id", + "item_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"item_kind\" = 'toolCall'" + }, + "events_todo_tool_call_thread_tool_sequence_idx": { + "name": "events_todo_tool_call_thread_tool_sequence_idx", + "columns": [ + "thread_id", + "tool_name", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"item_kind\" = 'toolCall' AND \"events\".\"type\" IN ('item/started', 'item/completed')" + }, + "events_parent_tool_call_thread_parent_sequence_idx": { + "name": "events_parent_tool_call_thread_parent_sequence_idx", + "columns": [ + "thread_id", + "parent_tool_call_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"parent_tool_call_id\" IS NOT NULL" + }, + "events_thread_type_item_kind_sequence_idx": { + "name": "events_thread_type_item_kind_sequence_idx", + "columns": [ + "thread_id", + "type", + "item_kind", + "sequence" + ], + "isUnique": false + }, + "events_background_task_thread_type_item_sequence_idx": { + "name": "events_background_task_thread_type_item_sequence_idx", + "columns": [ + "thread_id", + "type", + "item_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"item_kind\" = 'backgroundTask'" + }, + "events_thread_type_sequence_idx": { + "name": "events_thread_type_sequence_idx", + "columns": [ + "thread_id", + "type", + "sequence" + ], + "isUnique": false + }, + "events_thread_turn_type_item_sequence_idx": { + "name": "events_thread_turn_type_item_sequence_idx", + "columns": [ + "thread_id", + "turn_id", + "type", + "item_id", + "sequence" + ], + "isUnique": false + }, + "events_item_lifecycle_thread_item_sequence_idx": { + "name": "events_item_lifecycle_thread_item_sequence_idx", + "columns": [ + "thread_id", + "item_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"type\" IN ('item/started', 'item/completed', 'item/backgroundTask/completed')" + }, + "events_environment_idx": { + "name": "events_environment_idx", + "columns": [ + "environment_id" + ], + "isUnique": false + }, + "events_completed_item_truncation_idx": { + "name": "events_completed_item_truncation_idx", + "columns": [ + "item_kind", + "created_at", + "id" + ], + "isUnique": false, + "where": "\"events\".\"type\" = 'item/completed'" + }, + "events_goal_thread_sequence_idx": { + "name": "events_goal_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"type\" IN ('thread/goal/updated', 'thread/goal/cleared')" + } + }, + "foreignKeys": { + "events_thread_id_threads_id_fk": { + "name": "events_thread_id_threads_id_fk", + "tableFrom": "events", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "events_environment_id_environments_id_fk": { + "name": "events_environment_id_environments_id_fk", + "tableFrom": "events", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "events_scope_shape_check": { + "name": "events_scope_shape_check", + "value": "(\n (\"events\".\"scope_kind\" = 'turn' AND \"events\".\"turn_id\" IS NOT NULL)\n OR\n (\"events\".\"scope_kind\" = 'thread' AND \"events\".\"turn_id\" IS NULL)\n )" + } + } + }, + "host_daemon_sessions": { + "name": "host_daemon_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_type": { + "name": "host_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_dir": { + "name": "data_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "heartbeat_interval_ms": { + "name": "heartbeat_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lease_timeout_ms": { + "name": "lease_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "host_daemon_sessions_host_status_idx": { + "name": "host_daemon_sessions_host_status_idx", + "columns": [ + "host_id", + "status" + ], + "isUnique": false + }, + "host_daemon_sessions_host_latest_idx": { + "name": "host_daemon_sessions_host_latest_idx", + "columns": [ + "host_id", + "updated_at", + "created_at", + "id" + ], + "isUnique": false + }, + "host_daemon_sessions_closed_prune_idx": { + "name": "host_daemon_sessions_closed_prune_idx", + "columns": [ + "status", + "closed_at", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_daemon_sessions_host_id_hosts_id_fk": { + "name": "host_daemon_sessions_host_id_hosts_id_fk", + "tableFrom": "host_daemon_sessions", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hosts": { + "name": "hosts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connect_machine_id": { + "name": "connect_machine_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_permission_mode": { + "name": "max_permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'full'" + }, + "destroyed_at": { + "name": "destroyed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_rejected_protocol_version": { + "name": "last_rejected_protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "hosts_last_seen_idx": { + "name": "hosts_last_seen_idx", + "columns": [ + "last_seen_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugins": { + "name": "plugins", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'direct'" + }, + "catalog_entry_id": { + "name": "catalog_entry_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "catalog_marketplace_name": { + "name": "catalog_marketplace_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'path'" + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_builtin_name": { + "name": "source_builtin_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_package": { + "name": "source_npm_package", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_registry": { + "name": "source_npm_registry", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_requested_spec": { + "name": "source_npm_requested_spec", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_spec_kind": { + "name": "source_npm_spec_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_url": { + "name": "source_git_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_subdirectory": { + "name": "source_git_subdirectory", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_requested_ref": { + "name": "source_git_requested_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_ref_kind": { + "name": "source_git_ref_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_range": { + "name": "source_git_range", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_tag_prefix": { + "name": "source_git_tag_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_resolved_tag": { + "name": "source_git_resolved_tag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "npm_resolved_version": { + "name": "npm_resolved_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "npm_integrity": { + "name": "npm_integrity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_resolved_commit": { + "name": "git_resolved_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_update_check_at": { + "name": "last_update_check_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "available_compatible_version": { + "name": "available_compatible_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "newest_incompatible_version": { + "name": "newest_incompatible_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "update_status_detail": { + "name": "update_status_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_version": { + "name": "last_failure_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_at": { + "name": "last_failure_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_detail": { + "name": "last_failure_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_artifact_id": { + "name": "active_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "normalization_version": { + "name": "normalization_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "root_dir": { + "name": "root_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_at": { + "name": "installed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "plugins_active_artifact_id_plugin_artifacts_id_fk": { + "name": "plugins_active_artifact_id_plugin_artifacts_id_fk", + "tableFrom": "plugins", + "tableTo": "plugin_artifacts", + "columnsFrom": [ + "active_artifact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance_scan_cursors": { + "name": "maintenance_scan_cursors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_path": { + "name": "output_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_created_at": { + "name": "last_created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_event_id": { + "name": "last_event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "maintenance_scan_cursors_path_idx": { + "name": "maintenance_scan_cursors_path_idx", + "columns": [ + "policy", + "version", + "item_kind", + "output_path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pending_interactions": { + "name": "pending_interactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'provider'" + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "renderer_id": { + "name": "renderer_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pending_interactions_provider_request_idx": { + "name": "pending_interactions_provider_request_idx", + "columns": [ + "provider_id", + "provider_thread_id", + "provider_request_id" + ], + "isUnique": true + }, + "pending_interactions_thread_created_idx": { + "name": "pending_interactions_thread_created_idx", + "columns": [ + "thread_id", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_thread_status_created_idx": { + "name": "pending_interactions_thread_status_created_idx", + "columns": [ + "thread_id", + "status", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_status_created_idx": { + "name": "pending_interactions_status_created_idx", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_plugin_status_created_idx": { + "name": "pending_interactions_plugin_status_created_idx", + "columns": [ + "plugin_id", + "status", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "pending_interactions_thread_id_threads_id_fk": { + "name": "pending_interactions_thread_id_threads_id_fk", + "tableFrom": "pending_interactions", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_artifacts": { + "name": "plugin_artifacts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "npm_resolved_version": { + "name": "npm_resolved_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_resolved_commit": { + "name": "git_resolved_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_checkout_root": { + "name": "git_checkout_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integrity": { + "name": "integrity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "validation_result": { + "name": "validation_result", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "validated_at": { + "name": "validated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "plugin_artifacts_plugin_idx": { + "name": "plugin_artifacts_plugin_idx", + "columns": [ + "plugin_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_kv": { + "name": "plugin_kv", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_kv_plugin_id_key_pk": { + "columns": [ + "plugin_id", + "key" + ], + "name": "plugin_kv_plugin_id_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_marketplace_icons": { + "name": "plugin_marketplace_icons", + "columns": { + "marketplace_name": { + "name": "marketplace_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entry_id": { + "name": "entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_marketplace_icons_marketplace_name_entry_id_pk": { + "columns": [ + "marketplace_name", + "entry_id" + ], + "name": "plugin_marketplace_icons_marketplace_name_entry_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_marketplaces": { + "name": "plugin_marketplaces", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'https'" + }, + "manifest_url": { + "name": "manifest_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_git_ref": { + "name": "source_git_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_commit": { + "name": "source_git_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manifest_json": { + "name": "manifest_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_modified": { + "name": "last_modified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_successful_refresh_at": { + "name": "last_successful_refresh_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_attempted_refresh_at": { + "name": "last_attempted_refresh_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_schedules": { + "name": "plugin_schedules", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_schedules_plugin_id_name_pk": { + "columns": [ + "plugin_id", + "name" + ], + "name": "plugin_schedules_plugin_id_name_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_settings": { + "name": "plugin_settings", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_settings_plugin_id_key_pk": { + "columns": [ + "plugin_id", + "key" + ], + "name": "plugin_settings_plugin_id_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_state_snapshots": { + "name": "plugin_state_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_artifact_id": { + "name": "from_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "to_artifact_id": { + "name": "to_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_path": { + "name": "snapshot_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "database_path": { + "name": "database_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state_path": { + "name": "state_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secrets_path": { + "name": "secrets_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registration_path": { + "name": "registration_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rollback_candidate_version": { + "name": "rollback_candidate_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_source_fingerprint": { + "name": "rollback_source_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_bb_version": { + "name": "rollback_bb_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_sdk_version": { + "name": "rollback_sdk_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_detail": { + "name": "rollback_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retained_until": { + "name": "retained_until", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "plugin_state_snapshots_plugin_idx": { + "name": "plugin_state_snapshots_plugin_idx", + "columns": [ + "plugin_id" + ], + "isUnique": false + }, + "plugin_state_snapshots_retention_idx": { + "name": "plugin_state_snapshots_retention_idx", + "columns": [ + "retained_until" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_execution_defaults": { + "name": "project_execution_defaults", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_tier": { + "name": "service_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_execution_defaults_project_idx": { + "name": "project_execution_defaults_project_idx", + "columns": [ + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_execution_defaults_project_id_projects_id_fk": { + "name": "project_execution_defaults_project_id_projects_id_fk", + "tableFrom": "project_execution_defaults", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_sources": { + "name": "project_sources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_sources_project_idx": { + "name": "project_sources_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "project_sources_host_idx": { + "name": "project_sources_host_idx", + "columns": [ + "host_id" + ], + "isUnique": false + }, + "project_sources_project_host_idx": { + "name": "project_sources_project_host_idx", + "columns": [ + "project_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_sources_project_id_projects_id_fk": { + "name": "project_sources_project_id_projects_id_fk", + "tableFrom": "project_sources", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_sources_host_id_hosts_id_fk": { + "name": "project_sources_host_id_hosts_id_fk", + "tableFrom": "project_sources", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_sources_shape_check": { + "name": "project_sources_shape_check", + "value": "(\n \"project_sources\".\"type\" = 'local_path' AND \"project_sources\".\"host_id\" IS NOT NULL AND \"project_sources\".\"path\" IS NOT NULL\n )" + } + } + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'standard'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_remote_url": { + "name": "git_remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_key": { + "name": "sort_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'V'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_updated_idx": { + "name": "projects_updated_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "projects_deleted_idx": { + "name": "projects_deleted_idx", + "columns": [ + "deleted_at" + ], + "isUnique": false + }, + "projects_sort_idx": { + "name": "projects_sort_idx", + "columns": [ + "sort_key", + "id" + ], + "isUnique": false + }, + "projects_personal_singleton_idx": { + "name": "projects_personal_singleton_idx", + "columns": [ + "kind" + ], + "isUnique": true, + "where": "\"projects\".\"kind\" = 'personal'" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "prompt_history_entries": { + "name": "prompt_history_entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "prompt_history_entries_thread_request_idx": { + "name": "prompt_history_entries_thread_request_idx", + "columns": [ + "thread_id", + "request_sequence" + ], + "isUnique": true + }, + "prompt_history_entries_project_scope_created_idx": { + "name": "prompt_history_entries_project_scope_created_idx", + "columns": [ + "project_id", + "scope", + "created_at", + "request_sequence", + "id" + ], + "isUnique": false + }, + "prompt_history_entries_thread_scope_created_idx": { + "name": "prompt_history_entries_thread_scope_created_idx", + "columns": [ + "thread_id", + "scope", + "created_at", + "request_sequence", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "prompt_history_entries_project_id_projects_id_fk": { + "name": "prompt_history_entries_project_id_projects_id_fk", + "tableFrom": "prompt_history_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "prompt_history_entries_thread_id_threads_id_fk": { + "name": "prompt_history_entries_thread_id_threads_id_fk", + "tableFrom": "prompt_history_entries", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "queued_thread_messages": { + "name": "queued_thread_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_thread_id": { + "name": "sender_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_tier": { + "name": "service_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_with_next": { + "name": "group_with_next", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_key": { + "name": "sort_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "queued_thread_messages_thread_created_idx": { + "name": "queued_thread_messages_thread_created_idx", + "columns": [ + "thread_id", + "created_at", + "id" + ], + "isUnique": false + }, + "queued_thread_messages_thread_sort_idx": { + "name": "queued_thread_messages_thread_sort_idx", + "columns": [ + "thread_id", + "sort_key", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "queued_thread_messages_thread_id_threads_id_fk": { + "name": "queued_thread_messages_thread_id_threads_id_fk", + "tableFrom": "queued_thread_messages", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_experiments": { + "name": "system_experiments", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "terminal_sessions": { + "name": "terminal_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "daemon_session_id": { + "name": "daemon_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initial_cwd": { + "name": "initial_cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cols": { + "name": "cols", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rows": { + "name": "rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_user_input_at": { + "name": "last_user_input_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "terminal_sessions_thread_status_updated_idx": { + "name": "terminal_sessions_thread_status_updated_idx", + "columns": [ + "thread_id", + "status", + "updated_at" + ], + "isUnique": false + }, + "terminal_sessions_environment_status_idx": { + "name": "terminal_sessions_environment_status_idx", + "columns": [ + "environment_id", + "status" + ], + "isUnique": false + }, + "terminal_sessions_host_status_idx": { + "name": "terminal_sessions_host_status_idx", + "columns": [ + "host_id", + "status" + ], + "isUnique": false + }, + "terminal_sessions_daemon_session_idx": { + "name": "terminal_sessions_daemon_session_idx", + "columns": [ + "daemon_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "terminal_sessions_thread_id_threads_id_fk": { + "name": "terminal_sessions_thread_id_threads_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_environment_id_environments_id_fk": { + "name": "terminal_sessions_environment_id_environments_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_host_id_hosts_id_fk": { + "name": "terminal_sessions_host_id_hosts_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk": { + "name": "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "host_daemon_sessions", + "columnsFrom": [ + "daemon_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_dynamic_context_file_states": { + "name": "thread_dynamic_context_file_states", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_key": { + "name": "file_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_status": { + "name": "content_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shown_at": { + "name": "shown_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_dynamic_context_file_states_thread_file_idx": { + "name": "thread_dynamic_context_file_states_thread_file_idx", + "columns": [ + "thread_id", + "file_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "thread_dynamic_context_file_states_thread_id_threads_id_fk": { + "name": "thread_dynamic_context_file_states_thread_id_threads_id_fk", + "tableFrom": "thread_dynamic_context_file_states", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_search_segments": { + "name": "thread_search_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_seq": { + "name": "source_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_search_segments_source_idx": { + "name": "thread_search_segments_source_idx", + "columns": [ + "thread_id", + "source_kind", + "source_key" + ], + "isUnique": true + }, + "thread_search_segments_thread_source_seq_idx": { + "name": "thread_search_segments_thread_source_seq_idx", + "columns": [ + "thread_id", + "source_seq" + ], + "isUnique": false + } + }, + "foreignKeys": { + "thread_search_segments_thread_id_threads_id_fk": { + "name": "thread_search_segments_thread_id_threads_id_fk", + "tableFrom": "thread_search_segments", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_sections": { + "name": "thread_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_sections_name_idx": { + "name": "thread_sections_name_idx", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_tabs": { + "name": "thread_tabs", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tabs_json": { + "name": "tabs_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "thread_tabs_thread_id_threads_id_fk": { + "name": "thread_tabs_thread_id_threads_id_fk", + "tableFrom": "thread_tabs", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "threads": { + "name": "threads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_override": { + "name": "model_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_level_override": { + "name": "reasoning_level_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title_fallback": { + "name": "title_fallback", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'starting'" + }, + "parent_thread_id": { + "name": "parent_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_thread_id": { + "name": "source_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_plugin_id": { + "name": "origin_plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'visible'" + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin_sort_key": { + "name": "pin_sort_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latest_attention_at": { + "name": "latest_attention_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "threads_project_updated_idx": { + "name": "threads_project_updated_idx", + "columns": [ + "project_id", + "updated_at" + ], + "isUnique": false + }, + "threads_project_archived_deleted_idx": { + "name": "threads_project_archived_deleted_idx", + "columns": [ + "project_id", + "archived_at", + "deleted_at", + "id" + ], + "isUnique": false + }, + "threads_pin_sort_idx": { + "name": "threads_pin_sort_idx", + "columns": [ + "archived_at", + "deleted_at", + "pin_sort_key", + "id" + ], + "isUnique": false, + "where": "\"threads\".\"pinned_at\" IS NOT NULL" + }, + "threads_environment_idx": { + "name": "threads_environment_idx", + "columns": [ + "environment_id" + ], + "isUnique": false + }, + "threads_parent_idx": { + "name": "threads_parent_idx", + "columns": [ + "parent_thread_id" + ], + "isUnique": false + }, + "threads_source_origin_idx": { + "name": "threads_source_origin_idx", + "columns": [ + "source_thread_id", + "origin_kind" + ], + "isUnique": false + }, + "threads_origin_plugin_archived_idx": { + "name": "threads_origin_plugin_archived_idx", + "columns": [ + "origin_plugin_id", + "archived_at" + ], + "isUnique": false + }, + "threads_section_archived_deleted_idx": { + "name": "threads_section_archived_deleted_idx", + "columns": [ + "section_id", + "archived_at", + "deleted_at", + "id" + ], + "isUnique": false + }, + "threads_archived_status_idx": { + "name": "threads_archived_status_idx", + "columns": [ + "archived_at", + "status" + ], + "isUnique": false + }, + "threads_environment_archived_deleted_idx": { + "name": "threads_environment_archived_deleted_idx", + "columns": [ + "environment_id", + "archived_at", + "deleted_at" + ], + "isUnique": false + }, + "threads_active_maintenance_idx": { + "name": "threads_active_maintenance_idx", + "columns": [ + "status" + ], + "isUnique": false, + "where": "\"threads\".\"deleted_at\" IS NULL" + } + }, + "foreignKeys": { + "threads_project_id_projects_id_fk": { + "name": "threads_project_id_projects_id_fk", + "tableFrom": "threads", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "threads_environment_id_environments_id_fk": { + "name": "threads_environment_id_environments_id_fk", + "tableFrom": "threads", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_section_id_thread_sections_id_fk": { + "name": "threads_section_id_thread_sections_id_fk", + "tableFrom": "threads", + "tableTo": "thread_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_parent_thread_id_threads_id_fk": { + "name": "threads_parent_thread_id_threads_id_fk", + "tableFrom": "threads", + "tableTo": "threads", + "columnsFrom": [ + "parent_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_source_thread_id_threads_id_fk": { + "name": "threads_source_thread_id_threads_id_fk", + "tableFrom": "threads", + "tableTo": "threads", + "columnsFrom": [ + "source_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index dd70b64157..606ed30260 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -736,6 +736,13 @@ "when": 1787212680694, "tag": "0104_chunky_redwing", "breakpoints": true + }, + { + "idx": 105, + "version": "6", + "when": 1787277490441, + "tag": "0105_deferred_thread_messages", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/data/deferred-thread-messages.ts b/packages/db/src/data/deferred-thread-messages.ts new file mode 100644 index 0000000000..5b98c9a416 --- /dev/null +++ b/packages/db/src/data/deferred-thread-messages.ts @@ -0,0 +1,86 @@ +import { and, asc, eq, sql } from "drizzle-orm"; +import type { DbConnection, DbQueryConnection } from "../connection.js"; +import { createDeferredThreadMessageId } from "../ids.js"; +import { deferredThreadMessages } from "../schema.js"; + +export type DeferredThreadMessageRow = + typeof deferredThreadMessages.$inferSelect; + +export interface CreateDeferredThreadMessageInput { + threadId: string; + kind: string; + /** JSON-encoded message; the server owns the shape behind each `kind`. */ + payload: string; +} + +export function createDeferredThreadMessage( + db: DbConnection, + input: CreateDeferredThreadMessageInput, +): DeferredThreadMessageRow { + const row: DeferredThreadMessageRow = { + id: createDeferredThreadMessageId(), + threadId: input.threadId, + kind: input.kind, + payload: input.payload, + createdAt: Date.now(), + }; + db.insert(deferredThreadMessages).values(row).run(); + return row; +} + +/** + * Oldest first: deferred messages deliver in arrival order. Rows created in + * the same millisecond have random ids, so the insertion rowid breaks ties. + */ +export function listDeferredThreadMessages( + db: DbQueryConnection, + threadId: string, +): DeferredThreadMessageRow[] { + return db + .select() + .from(deferredThreadMessages) + .where(eq(deferredThreadMessages.threadId, threadId)) + .orderBy( + asc(deferredThreadMessages.createdAt), + asc(sql`${deferredThreadMessages}.rowid`), + ) + .all(); +} + +export function listThreadIdsWithDeferredThreadMessages( + db: DbQueryConnection, +): string[] { + return db + .selectDistinct({ threadId: deferredThreadMessages.threadId }) + .from(deferredThreadMessages) + .all() + .map((row) => row.threadId); +} + +/** Returns true when the row still existed, so a caller can claim it. */ +export function deleteDeferredThreadMessage( + db: DbQueryConnection, + args: { id: string; threadId: string }, +): boolean { + return ( + db + .delete(deferredThreadMessages) + .where( + and( + eq(deferredThreadMessages.id, args.id), + eq(deferredThreadMessages.threadId, args.threadId), + ), + ) + .run().changes > 0 + ); +} + +export function deleteDeferredThreadMessagesForThread( + db: DbConnection, + threadId: string, +): number { + return db + .delete(deferredThreadMessages) + .where(eq(deferredThreadMessages.threadId, threadId)) + .run().changes; +} diff --git a/packages/db/src/data/index.ts b/packages/db/src/data/index.ts index ded9bbfb5b..2a742fa1b8 100644 --- a/packages/db/src/data/index.ts +++ b/packages/db/src/data/index.ts @@ -527,6 +527,17 @@ export { setQueuedThreadMessageGroupBoundary, updateQueuedThreadMessage, } from "./queued-thread-messages.js"; +export { + createDeferredThreadMessage, + deleteDeferredThreadMessage, + deleteDeferredThreadMessagesForThread, + listDeferredThreadMessages, + listThreadIdsWithDeferredThreadMessages, +} from "./deferred-thread-messages.js"; +export type { + CreateDeferredThreadMessageInput, + DeferredThreadMessageRow, +} from "./deferred-thread-messages.js"; export type { ClaimedQueuedThreadMessageRow, ClaimedQueuedThreadMessageMutationArgs, diff --git a/packages/db/src/ids.ts b/packages/db/src/ids.ts index 19fa7f8036..6f308ce971 100644 --- a/packages/db/src/ids.ts +++ b/packages/db/src/ids.ts @@ -54,6 +54,10 @@ export function createQueuedThreadMessageId(): string { return createId("qmsg"); } +export function createDeferredThreadMessageId(): string { + return createId("dmsg"); +} + export function createQueuedThreadMessageClaimToken(): string { return createId("qclaim"); } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index cea1666dd8..95bbb0330d 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -879,6 +879,32 @@ export const promptHistoryEntries = sqliteTable( ], ); +// Messages addressed to a thread while it awaited user interaction (an +// AskUserQuestion, a command approval, a plugin input request). A blocked thread +// cannot take a prompt, and refusing the message dropped it with no trace on the +// recipient side (#1650). The row holds the message until the thread's pending +// interactions settle, then the server delivers it in the mode the sender asked +// for. `payload` is the JSON-encoded deferred message, discriminated by `kind`. +export const deferredThreadMessages = sqliteTable( + "deferred_thread_messages", + { + id: text("id").primaryKey(), + threadId: text("thread_id") + .notNull() + .references(() => threads.id, { onDelete: "cascade" }), + kind: text("kind").notNull(), + payload: text("payload").notNull(), + createdAt: integer("created_at").notNull(), + }, + (table) => [ + index("deferred_thread_messages_thread_created_idx").on( + table.threadId, + table.createdAt, + table.id, + ), + ], +); + export const queuedThreadMessages = sqliteTable( "queued_thread_messages", { diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index 12e45b5e57..5265d3a394 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -678,6 +678,9 @@ function dropMarketplaceCatalogSchema(db: DbConnection): void { } function dropEventToolNameColumn(db: DbConnection): void { + // Every rewind before 0104 also rewinds the later deferred-message table + // (0105). + db.$client.prepare("DROP TABLE IF EXISTS deferred_thread_messages").run(); // Generated columns are omitted from table_info but included in table_xinfo. const columns = db.$client .prepare<[], TableInfoRow>("PRAGMA table_xinfo(events)") diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts index b33c252a1e..1cb75dabe4 100644 --- a/packages/domain/src/plugin-sdk-version.ts +++ b/packages/domain/src/plugin-sdk-version.ts @@ -16,7 +16,7 @@ // PLUGIN_SDK_MAJOR is 0, so the major-only artifact gate cannot distinguish // 0.x releases and is intentionally vacuous for them until a future 1.0. // Rebuildable artifacts still rebuild on the exact sdkVersion-differs trigger. -export const PLUGIN_SDK_VERSION = "0.4.10"; +export const PLUGIN_SDK_VERSION = "0.4.11"; /** Major of {@link PLUGIN_SDK_VERSION} — the plugin API compatibility number. */ export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]); diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 4499adcf2f..ec4b6548c9 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@get-bb/plugin-sdk", - "version": "0.4.10", + "version": "0.4.11", "homepage": "https://github.com/get-bb/bb#readme", "bugs": { "url": "https://github.com/get-bb/bb/issues" diff --git a/packages/sdk/src/areas/threads.ts b/packages/sdk/src/areas/threads.ts index ee2847c67e..63c3151858 100644 --- a/packages/sdk/src/areas/threads.ts +++ b/packages/sdk/src/areas/threads.ts @@ -46,6 +46,7 @@ import type { ResolveThreadMentionsRequest, ResolveThreadMentionsResponse, SendMessageRequest, + SendMessageResponse, SendQueuedMessageRequest, SetQueuedMessageGroupBoundaryRequest, ThreadEventsQuery, @@ -118,7 +119,7 @@ export type ThreadArchiveResult = ThreadArchiveAllResponse; export type ThreadOpenResult = ThreadOpenResponse; export type ThreadPaneActionResult = ThreadPaneActionResponse; export type ThreadDeleteResult = { ok: true }; -export type ThreadSendResult = { ok: true }; +export type ThreadSendResult = SendMessageResponse; export type ThreadEditMessageResult = EditMessageResponse; export type ThreadStopResult = { ok: true }; export type ThreadCompactResult = { ok: true }; @@ -1055,13 +1056,12 @@ export function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea { ); }, async send(input) { - await transport.readVoid( + return transport.readJson( transport.api.v1.threads[":id"].send.$post({ param: { id: input.threadId }, json: sendJson(input), }), ); - return { ok: true }; }, async spawn(input) { return transport.readJson( diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts index 60eb70c3ae..40a52fff0f 100644 --- a/packages/sdk/test/sdk.test.ts +++ b/packages/sdk/test/sdk.test.ts @@ -1090,7 +1090,7 @@ describe("@bb/sdk", () => { it("forwards every public permission mode through thread surfaces", async () => { const queue = createFetchQueue([ { body: { id: "thr_auto" }, status: 201 }, - { body: null, status: 204 }, + { body: { ok: true, delivery: "sent" } }, { body: { id: "qmsg_full" }, status: 201 }, ]); const sdk = createBbSdk({ diff --git a/packages/server-contract/src/api/threads.ts b/packages/server-contract/src/api/threads.ts index 92cce5067d..1d5ec7f595 100644 --- a/packages/server-contract/src/api/threads.ts +++ b/packages/server-contract/src/api/threads.ts @@ -211,6 +211,23 @@ export const sendMessageRequestSchema = z.object({ }); export type SendMessageRequest = z.infer; +/** + * How a `send` request was taken: + * - `sent`: dispatched now (a new turn or a steer into the active turn). + * - `queued`: placed in the thread queue; it sends when the thread is next idle. + * - `deferred`: the thread awaits user interaction, which a prompt cannot + * interrupt. The server holds the message and delivers it in the requested + * mode as soon as the interaction settles. + */ +export const sendMessageDeliverySchema = z.enum(["sent", "queued", "deferred"]); +export type SendMessageDelivery = z.infer; + +export const sendMessageResponseSchema = z.object({ + ok: z.literal(true), + delivery: sendMessageDeliverySchema, +}); +export type SendMessageResponse = z.infer; + export const editMessageRequestSchema = sendMessageRequestSchema .omit({ mode: true }) .extend({ diff --git a/packages/server-contract/src/public-api.ts b/packages/server-contract/src/public-api.ts index 5a7ec84001..c384d628b6 100644 --- a/packages/server-contract/src/public-api.ts +++ b/packages/server-contract/src/public-api.ts @@ -136,6 +136,7 @@ import type { ResolveThreadMentionsResponse, RespondPluginInteractionRequest, SendMessageRequest, + SendMessageResponse, SetQueuedMessageGroupBoundaryRequest, SendQueuedMessageRequest, SendQueuedMessageResponse, @@ -972,6 +973,10 @@ export const publicApiRoutes = { * starts a turn. mode=steer-if-active steers when the thread is active; * otherwise it starts a turn. Legacy mode=auto starts idle threads and * uses the provider's auto target for active turns. + * A thread that awaits user interaction cannot take a prompt: every mode + * but `start` is then held (`delivery: "deferred"`) and delivered once the + * interaction settles; `start` still fails with 409 + * `awaiting_user_interaction`. */ send: defineRoute({ path: "/threads/:id/send", @@ -979,7 +984,7 @@ export const publicApiRoutes = { request: jsonRequest( sendMessageRequestSchema, ), - response: jsonResponse<{ ok: true }>(), + response: jsonResponse(), }), /** * Replace an accepted root user turn and every later turn. A running diff --git a/packages/templates/src/templates/bb-guide-threads.md b/packages/templates/src/templates/bb-guide-threads.md index 0d7bf8e9a5..fe55da5363 100644 --- a/packages/templates/src/templates/bb-guide-threads.md +++ b/packages/templates/src/templates/bb-guide-threads.md @@ -178,7 +178,11 @@ Messaging: Tell steers by default, delivering the message immediately into the active turn. Use --mode queue for non-urgent follow-ups that can wait until the agent - is free. + is free. A target that is awaiting user interaction (an open question or + approval) cannot take a prompt; tell then holds the message and delivers it + in the requested mode once the interaction settles. That outcome is not a + failure, so do not resend. `--json` reports `delivery` as `sent`, `queued`, + or `deferred`. bb thread stop [id] Stop work and release the agent runtime bb thread compact [id] Request compaction of an idle or errored thread's context diff --git a/plugins/provider-retry/server.test.ts b/plugins/provider-retry/server.test.ts index 1eb7878c9e..9dca87c660 100644 --- a/plugins/provider-retry/server.test.ts +++ b/plugins/provider-retry/server.test.ts @@ -305,10 +305,7 @@ function createRetryHost(args: { }, threads: { get: async ({ threadId }) => { - inspectionByThreadId.set( - threadId, - await args.inspect({ threadId }), - ); + inspectionByThreadId.set(threadId, await args.inspect({ threadId })); return makeThreadResponse({ id: threadId, environmentId: "environment-one", @@ -349,7 +346,9 @@ function createRetryHost(args: { ); }, }, - send: args.continueFailedTurn ?? (async () => ({ ok: true as const })), + send: + args.continueFailedTurn ?? + (async () => ({ ok: true as const, delivery: "sent" as const })), }, subscribe: ({ event, callback }) => { if (event === "host:changed") { @@ -469,7 +468,10 @@ describe("provider retry scheduler", () => { }); it("classifies provider events and schedules subscription-window failures", async () => { - const continueFailedTurn = vi.fn(async () => ({ ok: true as const })); + const continueFailedTurn = vi.fn(async () => ({ + ok: true as const, + delivery: "sent" as const, + })); const host = createRetryHost({ inspect: async ({ threadId }) => failedTurnInspection(threadId), continueFailedTurn, @@ -637,7 +639,10 @@ describe("provider retry scheduler", () => { }); it("releases immediately when a later provider observation is allowed", async () => { - const continueFailedTurn = vi.fn(async () => ({ ok: true as const })); + const continueFailedTurn = vi.fn(async () => ({ + ok: true as const, + delivery: "sent" as const, + })); const host = createRetryHost({ inspect: async ({ threadId }) => failedTurnInspection(threadId, { @@ -703,7 +708,10 @@ describe("provider retry scheduler", () => { }); it("keeps non-resettable limits manual and retries them through the plugin CLI", async () => { - const continueFailedTurn = vi.fn(async () => ({ ok: true as const })); + const continueFailedTurn = vi.fn(async () => ({ + ok: true as const, + delivery: "sent" as const, + })); const host = createRetryHost({ inspect: async ({ threadId }) => manualInspection(threadId), continueFailedTurn, @@ -749,7 +757,10 @@ describe("provider retry scheduler", () => { }); it("paces threads sharing one provider account", async () => { - const continueFailedTurn = vi.fn(async () => ({ ok: true as const })); + const continueFailedTurn = vi.fn(async () => ({ + ok: true as const, + delivery: "sent" as const, + })); const host = createRetryHost({ inspect: async ({ threadId }) => failedTurnInspection(threadId), continueFailedTurn, @@ -777,7 +788,10 @@ describe("provider retry scheduler", () => { }); it("attempts each reported reset window only once per plugin process", async () => { - const continueFailedTurn = vi.fn(async () => ({ ok: true as const })); + const continueFailedTurn = vi.fn(async () => ({ + ok: true as const, + delivery: "sent" as const, + })); const host = createRetryHost({ inspect: async ({ threadId }) => failedTurnInspection(threadId), continueFailedTurn, @@ -858,7 +872,10 @@ describe("provider retry scheduler", () => { providerId: "claude-code", }), ); - const continueFailedTurn = vi.fn(async () => ({ ok: true as const })); + const continueFailedTurn = vi.fn(async () => ({ + ok: true as const, + delivery: "sent" as const, + })); const host = createRetryHost({ inspect, continueFailedTurn, @@ -998,7 +1015,7 @@ describe("provider retry scheduler", () => { status: 502, }), ) - .mockResolvedValueOnce({ ok: true as const }); + .mockResolvedValueOnce({ ok: true as const, delivery: "sent" as const }); const subscription = { hostChanged: null as | ((changes: Array<"host-connected" | "host-disconnected">) => void) diff --git a/qa/manual-runbook.md b/qa/manual-runbook.md index 5c3c1e8234..f83f1a3cb7 100644 --- a/qa/manual-runbook.md +++ b/qa/manual-runbook.md @@ -905,7 +905,9 @@ Expected result: - `accept-edits` turns allow workspace changes but surface pending interactions for the explicit outside-workspace probes; inspect them with `bb thread interactions list/show`. -- `bb thread tell` is rejected while the thread is awaiting user interaction. +- `bb thread tell` reports the message as held while the thread is awaiting + user interaction and delivers it after the interaction settles; + `--mode start` is still rejected with 409 `awaiting_user_interaction`. - `approve`, `deny`, and `grant` resolve their matching interaction kinds. - Approved/granted threads continue to `idle`; denied threads either reply with the denial handling text or clearly record the denied approval in the log.