From a1b4bac496ff7e5e22a9ddca3f6ef3cbccf164c9 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 21 Aug 2026 02:18:00 +0000 Subject: [PATCH 1/2] Hold messages for threads that await user interaction A thread blocked on an AskUserQuestion, a command approval, or a plugin input request cannot take a prompt. The /send route refused every mode with 409 and persisted nothing, and queueParentSystemMessage returned false silently, so bb thread tell reports and child-completed notices addressed to a blocked orchestrator vanished with no trace on the recipient side (#1650). Hold them in a new deferred_thread_messages table instead. Sends (every mode but start) return { ok: true, delivery: "deferred" }, parent system messages are stored with their taxonomy, and a settle hook on the pending-interaction lifecycle plus a periodic sweep deliver them in arrival order and in the requested mode once the thread unblocks. The send policy moves into acceptThreadSendRequest so the route and the flush share one decision; createQueuedMessageForThread moves next to the rest of the queue service. The CLI prints the held outcome, and the guide and bb-cli skill tell agents not to resend. Co-Authored-By: Claude --- .../thread-runtime-mutations.test.tsx | 5 +- .../command-output/thread-tell.test.ts | 25 +- apps/cli/src/commands/thread/actions.ts | 32 +- apps/server/src/lifecycle-dedupers.ts | 2 + apps/server/src/routes/threads/actions.ts | 180 +- apps/server/src/server.ts | 6 + .../interactions/pending-interactions.ts | 34 + .../skills/builtin-skills/bb-cli/SKILL.md | 6 + .../src/services/system/periodic-sweeps.ts | 7 + .../threads/deferred-thread-messages.ts | 61 + .../threads/parent-system-messages.ts | 15 +- .../src/services/threads/queued-messages.ts | 152 +- .../services/threads/thread-send-request.ts | 267 ++ .../internal-events-tool-calls.test.ts | 5 +- .../test/public/public-thread-data.test.ts | 10 +- .../public/public-thread-interactions.test.ts | 48 +- .../test/services/plugins/plugin-sdk.test.ts | 2 +- .../threads/deferred-thread-messages.test.ts | 407 ++ .../drizzle/0105_deferred_thread_messages.sql | 10 + packages/db/drizzle/meta/0105_snapshot.json | 3802 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + .../db/src/data/deferred-thread-messages.ts | 86 + packages/db/src/data/index.ts | 11 + packages/db/src/ids.ts | 4 + packages/db/src/schema.ts | 26 + packages/db/test/migrate.test.ts | 3 + packages/domain/src/plugin-sdk-version.ts | 2 +- packages/plugin-sdk/package.json | 2 +- packages/sdk/src/areas/threads.ts | 6 +- packages/sdk/test/sdk.test.ts | 2 +- packages/server-contract/src/api/threads.ts | 17 + packages/server-contract/src/public-api.ts | 7 +- .../src/templates/bb-guide-threads.md | 6 +- 33 files changed, 5047 insertions(+), 208 deletions(-) create mode 100644 apps/server/src/services/threads/deferred-thread-messages.ts create mode 100644 apps/server/src/services/threads/thread-send-request.ts create mode 100644 apps/server/test/threads/deferred-thread-messages.test.ts create mode 100644 packages/db/drizzle/0105_deferred_thread_messages.sql create mode 100644 packages/db/drizzle/meta/0105_snapshot.json create mode 100644 packages/db/src/data/deferred-thread-messages.ts 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..723e894ccb --- /dev/null +++ b/apps/server/src/services/threads/thread-send-request.ts @@ -0,0 +1,267 @@ +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 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; + } + } +} + +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) { + // 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. + const fields = { + deferredMessageId: row.id, + kind: payload.kind, + ...runtimeErrorLogFields(deps.config, error), + threadId, + }; + 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..69d0b47088 --- /dev/null +++ b/apps/server/test/threads/deferred-thread-messages.test.ts @@ -0,0 +1,407 @@ +// 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, + 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); + }); + }); +}); 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 From 369b625d984ded72416a7bfc94966da944df76a4 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 21 Aug 2026 02:59:29 +0000 Subject: [PATCH 2/2] Drop held messages whose request turned invalid; fix retry mocks The provider-retry plugin's send mocks still returned { ok: true }, which no longer satisfies the widened SendMessageResponse and failed the workspace typecheck. A deferred row whose request can no longer be honored (its sender thread was deleted, its attachment is gone) used to be retried on every sweep and, because flushes stop at the first error to keep arrival order, blocked every later held message for that thread. A 400/404 from the send pipeline is now terminal for that row: it is deleted with a warn log and the flush continues. Stopping threads (409), plugin mentions (422), absent hosts (502) and timeouts still retry. Also update the manual runbook line that still described tells as rejected while a thread awaits user interaction. Co-Authored-By: Claude --- .../services/threads/thread-send-request.ts | 30 +++++++++-- .../threads/deferred-thread-messages.test.ts | 54 +++++++++++++++++++ plugins/provider-retry/server.test.ts | 41 +++++++++----- qa/manual-runbook.md | 4 +- 4 files changed, 113 insertions(+), 16 deletions(-) diff --git a/apps/server/src/services/threads/thread-send-request.ts b/apps/server/src/services/threads/thread-send-request.ts index 723e894ccb..323a3eee89 100644 --- a/apps/server/src/services/threads/thread-send-request.ts +++ b/apps/server/src/services/threads/thread-send-request.ts @@ -11,6 +11,7 @@ import type { SendMessageRequest, SendMessageResponse, } from "@bb/server-contract"; +import { ApiError } from "../../errors.js"; import type { LoggedPendingInteractionWorkSessionDeps } from "../../types.js"; import { isCommandTimeoutError, @@ -163,6 +164,17 @@ async function deliverDeferredThreadMessage( } } +/** + * 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, @@ -198,15 +210,27 @@ async function flushDeferredThreadMessagesNow( return; } } catch (error) { - // 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. 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, diff --git a/apps/server/test/threads/deferred-thread-messages.test.ts b/apps/server/test/threads/deferred-thread-messages.test.ts index 69d0b47088..18cdcc0596 100644 --- a/apps/server/test/threads/deferred-thread-messages.test.ts +++ b/apps/server/test/threads/deferred-thread-messages.test.ts @@ -9,6 +9,7 @@ import { getThread, listDeferredThreadMessages, listQueuedThreadMessages, + markThreadDeleted, type DbConnection, } from "@bb/db"; import { @@ -404,4 +405,57 @@ describe("messages to a thread that awaits user interaction (#1650)", () => { 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/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.