From 9a3cfc99b1fd2fd4765b4d29be2165084592a2ce Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 11 Sep 2026 21:38:39 +0000 Subject: [PATCH 1/9] fix(db): resolve a thread's provider session from owned identity events `getLastStoredProviderThreadId` took the newest `provider_thread_id` stamped on any event of the thread, so one mis-stamped event, or a `thread/identity` recorded after the server itself resumed into the wrong session, redirected every later resume, archive forward, and interruption stamp into another thread's provider session (#3461). Resolution now reads only `thread/identity` events recorded since the thread's last completed context clear, newest first, and classifies each announced session against every same-provider, same-host claim: - owned: no other thread claimed it, or this thread's earliest claim is strictly earlier than every other thread's. - foreign: another thread claimed it strictly earlier. Resolution skips it and falls back to the thread's own earlier session, which repairs the persisted contamination the 2026-09-11 incident left behind. - ambiguous: another thread's earliest claim falls in the same millisecond. Daemon event batches share one timestamp and rowid order is not durable, so resolution stops instead of picking a winner. `resolveStoredProviderSessions` returns none, owned, foreign, or ambiguous, so callers can refuse explicitly instead of treating an unavailable session like a brand-new thread. `classifyStoredProviderThreadClaim` gives edit and fork paths the same classification for a completion's stamp. A partial index on identity rows backs the claim lookup. Co-Authored-By: Claude Code --- packages/db/drizzle/0125_silent_guardian.sql | 1 + packages/db/drizzle/meta/0125_snapshot.json | 4973 ++++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/data/events.ts | 362 +- packages/db/src/data/index.ts | 5 + packages/db/src/schema.ts | 3 + packages/db/test/data/events.test.ts | 592 ++- packages/db/test/migrate.test.ts | 1 + packages/db/test/query-plans.test.ts | 34 + 9 files changed, 5921 insertions(+), 57 deletions(-) create mode 100644 packages/db/drizzle/0125_silent_guardian.sql create mode 100644 packages/db/drizzle/meta/0125_snapshot.json diff --git a/packages/db/drizzle/0125_silent_guardian.sql b/packages/db/drizzle/0125_silent_guardian.sql new file mode 100644 index 0000000000..e3b1767eda --- /dev/null +++ b/packages/db/drizzle/0125_silent_guardian.sql @@ -0,0 +1 @@ +CREATE INDEX `events_provider_identity_idx` ON `events` (`provider_thread_id`,`created_at`) WHERE "events"."type" = 'thread/identity'; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0125_snapshot.json b/packages/db/drizzle/meta/0125_snapshot.json new file mode 100644 index 0000000000..44643d5cbe --- /dev/null +++ b/packages/db/drizzle/meta/0125_snapshot.json @@ -0,0 +1,4973 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a7db3457-1600-41c5-a249-ea8dffc32375", + "prevId": "fc9edbbc-45a5-4b15-81b4-9cbfe8a78e61", + "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": {} + }, + "environment_hook_operations": { + "name": "environment_hook_operations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "operation_id": { + "name": "operation_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": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environment_variables": { + "name": "environment_variables", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryption_version": { + "name": "encryption_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "environment_variables_global_name": { + "name": "environment_variables_global_name", + "columns": [ + "name" + ], + "isUnique": true, + "where": "\"environment_variables\".\"project_id\" IS NULL" + }, + "environment_variables_project_name": { + "name": "environment_variables_project_name", + "columns": [ + "project_id", + "name" + ], + "isUnique": true, + "where": "\"environment_variables\".\"project_id\" IS NOT NULL" + } + }, + "foreignKeys": { + "environment_variables_project_id_projects_id_fk": { + "name": "environment_variables_project_id_projects_id_fk", + "tableFrom": "environment_variables", + "tableTo": "projects", + "columnsFrom": [ + "project_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 + }, + "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 + }, + "environment_provider_id": { + "name": "environment_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_provider_plugin_id": { + "name": "environment_provider_plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_owns_path": { + "name": "provider_owns_path", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "environment_provider_selection": { + "name": "environment_provider_selection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_provider_instance_key": { + "name": "environment_provider_instance_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retire_at": { + "name": "retire_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teardown_attempt": { + "name": "teardown_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "teardown_status": { + "name": "teardown_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teardown_message": { + "name": "teardown_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_thread_id": { + "name": "owner_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status_message": { + "name": "status_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pending_log": { + "name": "pending_log", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "claim_path": { + "name": "claim_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "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_owner_thread_idx": { + "name": "environments_owner_thread_idx", + "columns": [ + "owner_thread_id" + ], + "isUnique": true + }, + "environments_claim_idx": { + "name": "environments_claim_idx", + "columns": [ + "host_id", + "claim_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 + }, + "environments_provider_instance_idx": { + "name": "environments_provider_instance_idx", + "columns": [ + "environment_provider_id", + "environment_provider_instance_key" + ], + "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": "'{}'" + }, + "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_delegating_item_lookup_idx": { + "name": "events_delegating_item_lookup_idx", + "columns": [ + "thread_id", + "item_id", + "sequence", + "item_kind" + ], + "isUnique": false, + "where": "\"events\".\"item_kind\" IN ('toolCall', 'delegation')" + }, + "events_plan_steps_thread_sequence_idx": { + "name": "events_plan_steps_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": false, + "where": "(\"events\".\"item_kind\" = 'planSteps' AND \"events\".\"type\" = 'item/completed') OR \"events\".\"type\" = 'turn/plan/updated'" + }, + "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_provider_identity_idx": { + "name": "events_provider_identity_idx", + "columns": [ + "provider_thread_id", + "created_at" + ], + "isUnique": false, + "where": "\"events\".\"type\" = 'thread/identity'" + }, + "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_thread_state_thread_sequence_idx": { + "name": "events_thread_state_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"type\" IN ('thread/goal/updated', 'thread/goal/cleared', 'thread/extensionState/updated')" + } + }, + "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 + }, + "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 + }, + "machine_provider_id": { + "name": "machine_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "launch_key": { + "name": "launch_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "machine_inputs": { + "name": "machine_inputs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "machine_attempt": { + "name": "machine_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "pending_log": { + "name": "pending_log", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "machine_operation_id": { + "name": "machine_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_access_provider_id": { + "name": "server_access_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_access_grant_id": { + "name": "server_access_grant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "suspended_at": { + "name": "suspended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_message": { + "name": "status_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suspend_retry_at": { + "name": "suspend_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remove_retry_at": { + "name": "remove_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teardown_attempt": { + "name": "teardown_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "teardown_status": { + "name": "teardown_status", + "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 + }, + "hosts_live_launch_key_idx": { + "name": "hosts_live_launch_key_idx", + "columns": [ + "launch_key" + ], + "isUnique": true, + "where": "\"hosts\".\"destroyed_at\" is null" + } + }, + "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 + }, + "stats_json": { + "name": "stats_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "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_attachment_backfills": { + "name": "project_attachment_backfills", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_cursor": { + "name": "thread_cursor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_cursor": { + "name": "input_cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_id": { + "name": "input_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_sequence": { + "name": "input_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempted_at": { + "name": "attempted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_attachment_backfills_project_id_projects_id_fk": { + "name": "project_attachment_backfills_project_id_projects_id_fk", + "tableFrom": "project_attachment_backfills", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_attachment_threads": { + "name": "project_attachment_threads", + "columns": { + "attachment_id": { + "name": "attachment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_attachment_threads_thread_idx": { + "name": "project_attachment_threads_thread_idx", + "columns": [ + "thread_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "project_attachment_threads_attachment_id_project_attachments_id_fk": { + "name": "project_attachment_threads_attachment_id_project_attachments_id_fk", + "tableFrom": "project_attachment_threads", + "tableTo": "project_attachments", + "columnsFrom": [ + "attachment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_attachment_threads_thread_id_threads_id_fk": { + "name": "project_attachment_threads_thread_id_threads_id_fk", + "tableFrom": "project_attachment_threads", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_attachment_threads_attachment_id_thread_id_pk": { + "columns": [ + "attachment_id", + "thread_id" + ], + "name": "project_attachment_threads_attachment_id_thread_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_attachments": { + "name": "project_attachments", + "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 + }, + "stored_path": { + "name": "stored_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ready_at": { + "name": "ready_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletion_claimed_at": { + "name": "deletion_claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_attachments_project_path_idx": { + "name": "project_attachments_project_path_idx", + "columns": [ + "project_id", + "stored_path" + ], + "isUnique": true + }, + "project_attachments_project_created_idx": { + "name": "project_attachments_project_created_idx", + "columns": [ + "project_id", + "created_at" + ], + "isUnique": false + }, + "project_attachments_deletion_idx": { + "name": "project_attachments_deletion_idx", + "columns": [ + "project_id", + "deletion_claimed_at", + "id" + ], + "isUnique": false, + "where": "\"project_attachments\".\"deletion_claimed_at\" IS NOT NULL" + } + }, + "foreignKeys": { + "project_attachments_project_id_projects_id_fk": { + "name": "project_attachments_project_id_projects_id_fk", + "tableFrom": "project_attachments", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_attachments_size_check": { + "name": "project_attachments_size_check", + "value": "\"project_attachments\".\"size_bytes\" >= 0" + } + } + }, + "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 + }, + "owns_path": { + "name": "owns_path", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 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": {} + }, + "provider_model_catalogs": { + "name": "provider_model_catalogs", + "columns": { + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "models_json": { + "name": "models_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selected_only_models_json": { + "name": "selected_only_models_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "provider_model_catalogs_host_id_hosts_id_fk": { + "name": "provider_model_catalogs_host_id_hosts_id_fk", + "tableFrom": "provider_model_catalogs", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "provider_model_catalogs_host_id_provider_id_scope_key_pk": { + "columns": [ + "host_id", + "provider_id", + "scope_key" + ], + "name": "provider_model_catalogs_host_id_provider_id_scope_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "queued_thread_messages": { + "name": "queued_thread_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "system_notice": { + "name": "system_notice", + "type": "text", + "primaryKey": false, + "notNull": false, + "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 + }, + "send_at": { + "name": "send_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "waiting_on": { + "name": "waiting_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "wait_holder": { + "name": "wait_holder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_kind": { + "name": "payload_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'inline'" + }, + "retry_of_turn_request_id": { + "name": "retry_of_turn_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_attempt": { + "name": "retry_attempt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_reason": { + "name": "retry_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": 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 + }, + "queued_thread_messages_due_idx": { + "name": "queued_thread_messages_due_idx", + "columns": [ + "send_at", + "id" + ], + "isUnique": false, + "where": "\"queued_thread_messages\".\"send_at\" IS NOT NULL AND \"queued_thread_messages\".\"claimed_at\" IS NULL AND \"queued_thread_messages\".\"claim_token\" IS NULL" + }, + "queued_thread_messages_wait_holder_idx": { + "name": "queued_thread_messages_wait_holder_idx", + "columns": [ + "wait_holder", + "id" + ], + "isUnique": false, + "where": "\"queued_thread_messages\".\"wait_holder\" IS NOT NULL" + } + }, + "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": {} + }, + "retained_event_outputs": { + "name": "retained_event_outputs", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "output_path": { + "name": "output_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "retained_event_outputs_expiry_idx": { + "name": "retained_event_outputs_expiry_idx", + "columns": [ + "expires_at", + "event_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "retained_event_outputs_event_id_events_id_fk": { + "name": "retained_event_outputs_event_id_events_id_fk", + "tableFrom": "retained_event_outputs", + "tableTo": "events", + "columnsFrom": [ + "event_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_conversation_outlines": { + "name": "thread_conversation_outlines", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "projection_key": { + "name": "projection_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "items_json": { + "name": "items_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "thread_conversation_outlines_thread_id_threads_id_fk": { + "name": "thread_conversation_outlines_thread_id_threads_id_fk", + "tableFrom": "thread_conversation_outlines", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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_plugin_metadata": { + "name": "thread_plugin_metadata", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "thread_plugin_metadata_thread_id_threads_id_fk": { + "name": "thread_plugin_metadata_thread_id_threads_id_fk", + "tableFrom": "thread_plugin_metadata", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "thread_plugin_metadata_thread_id_plugin_id_pk": { + "columns": [ + "thread_id", + "plugin_id" + ], + "name": "thread_plugin_metadata_thread_id_plugin_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_pruning_cursors": { + "name": "thread_pruning_cursors", + "columns": { + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_thread_id": { + "name": "last_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "current_thread_id": { + "name": "current_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "upper_sequence": { + "name": "upper_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cycle": { + "name": "cycle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "latest_root_sequence": { + "name": "latest_root_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "latest_context_sequence": { + "name": "latest_context_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "probe_event_id": { + "name": "probe_event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "probe_phase": { + "name": "probe_phase", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "probe_sequence": { + "name": "probe_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "probe_witness_id": { + "name": "probe_witness_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_pruning_cursors_thread_idx": { + "name": "thread_pruning_cursors_thread_idx", + "columns": [ + "thread_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "thread_pruning_cursors_thread_id_threads_id_fk": { + "name": "thread_pruning_cursors_thread_id_threads_id_fk", + "tableFrom": "thread_pruning_cursors", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "thread_pruning_cursors_policy_scope_pk": { + "columns": [ + "policy", + "scope" + ], + "name": "thread_pruning_cursors_policy_scope_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": { + "thread_pruning_cursors_scope_check": { + "name": "thread_pruning_cursors_scope_check", + "value": "\"thread_pruning_cursors\".\"scope\" = coalesce(\"thread_pruning_cursors\".\"thread_id\", '')" + } + } + }, + "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'" + }, + "startup_context": { + "name": "startup_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_thread_id": { + "name": "parent_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lifecycle_owner_thread_id": { + "name": "lifecycle_owner_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 + }, + "storage_deleted_at": { + "name": "storage_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_id_idx": { + "name": "threads_project_id_idx", + "columns": [ + "project_id", + "id" + ], + "isUnique": false + }, + "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_lifecycle_owner_idx": { + "name": "threads_lifecycle_owner_idx", + "columns": [ + "lifecycle_owner_thread_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_lifecycle_owner_thread_id_threads_id_fk": { + "name": "threads_lifecycle_owner_thread_id_threads_id_fk", + "tableFrom": "threads", + "tableTo": "threads", + "columnsFrom": [ + "lifecycle_owner_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "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": {} + }, + "ui_preferences": { + "name": "ui_preferences", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value_json": { + "name": "value_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": {}, + "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 86c3a74c2b..b04900ede0 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -876,6 +876,13 @@ "when": 1789586944199, "tag": "0124_thread_pruning", "breakpoints": true + }, + { + "idx": 125, + "version": "6", + "when": 1789588549270, + "tag": "0125_silent_guardian", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/data/events.ts b/packages/db/src/data/events.ts index dc2f64dd1b..06628c4ae6 100644 --- a/packages/db/src/data/events.ts +++ b/packages/db/src/data/events.ts @@ -3485,33 +3485,314 @@ export function getLatestCompletedThreadContextClearSequence( return row?.sequence ?? null; } -export function getLastStoredProviderThreadId( +interface StoredProviderThreadIdentityScope { + hostId: string | null; + providerId: string; + threadId: string; +} + +interface StoredProviderThreadIdentityCandidate { + providerThreadId: string; + threadId: string; +} + +interface ListStoredProviderThreadClaimsArgs { + hostId: string | null; + providerId: string; + providerThreadIds: readonly string[]; +} + +interface ResolveStoredProviderSessionsArgs { + threadIds: readonly string[]; +} + +interface ClassifyStoredProviderThreadClaimArgs { + providerThreadId: string; + threadId: string; +} + +type StoredProviderThreadClaim = + | { kind: "owned"; threadId: string } + | { kind: "tied"; threadIds: string[] }; + +export type StoredProviderSession = + | { kind: "none" } + | { kind: "owned"; providerThreadId: string } + | { + kind: "foreign"; + providerThreadId: string; + claimantThreadIds: string[]; + } + | { + kind: "ambiguous"; + providerThreadId: string; + claimantThreadIds: string[]; + }; + +export type StoredProviderThreadClaimClass = + | "owned" + | "unannounced" + | "foreign" + | "ambiguous"; + +const storedProviderThreadIdentityEpochFilter = sql`${events.sequence} > COALESCE(( + SELECT MAX(context_clear.sequence) + FROM events AS context_clear + WHERE context_clear.thread_id = ${events.threadId} + AND context_clear.type = 'system/operation' + AND json_extract(context_clear.data, '$.operation') = ${THREAD_CONTEXT_CLEAR_OPERATION} + AND json_extract(context_clear.data, '$.status') = 'completed' +), 0)`; + +function listStoredProviderThreadIdentityScopes( db: DbQueryConnection, - threadId: string, -): string | null { - const latestProviderRow = db - .select({ providerThreadId: events.providerThreadId }) + threadIds: readonly string[], +): StoredProviderThreadIdentityScope[] { + return db + .select({ + hostId: environments.hostId, + providerId: threads.providerId, + threadId: threads.id, + }) + .from(threads) + .leftJoin(environments, eq(environments.id, threads.environmentId)) + .where(inArray(threads.id, [...threadIds])) + .all(); +} + +function listStoredProviderThreadIdentityCandidates( + db: DbQueryConnection, + threadIds: readonly string[], +): StoredProviderThreadIdentityCandidate[] { + return db + .select({ + providerThreadId: sql`${events.providerThreadId}`, + threadId: events.threadId, + }) + .from(events) + .where( + and( + inArray(events.threadId, [...threadIds]), + eq(events.type, "thread/identity"), + isNotNull(events.providerThreadId), + storedProviderThreadIdentityEpochFilter, + ), + ) + .orderBy(events.threadId, desc(events.sequence)) + .all(); +} + +function listStoredProviderThreadClaims( + db: DbQueryConnection, + args: ListStoredProviderThreadClaimsArgs, +): Map { + const claims = new Map(); + if (args.providerThreadIds.length === 0) { + return claims; + } + const rows = db + .select({ + firstClaimedAt: sql`MIN(${events.createdAt})`, + providerThreadId: sql`${events.providerThreadId}`, + threadId: events.threadId, + }) .from(events) + .innerJoin(threads, eq(threads.id, events.threadId)) + .leftJoin(environments, eq(environments.id, threads.environmentId)) .where( - sql`${events.threadId} = ${threadId} - AND ${events.providerThreadId} IS NOT NULL - AND ${events.sequence} > COALESCE(( - SELECT MAX(context_clear.sequence) - FROM events AS context_clear - WHERE context_clear.thread_id = ${threadId} - AND context_clear.type = 'system/operation' - AND json_extract(context_clear.data, '$.operation') = ${THREAD_CONTEXT_CLEAR_OPERATION} - AND json_extract(context_clear.data, '$.status') = 'completed' - ), 0)`, + and( + eq(events.type, "thread/identity"), + inArray(events.providerThreadId, [...args.providerThreadIds]), + eq(threads.providerId, args.providerId), + args.hostId === null + ? undefined + : or( + isNull(environments.hostId), + eq(environments.hostId, args.hostId), + ), + ), ) - .orderBy(sql`${events.sequence} DESC`) - .limit(1) - .get(); - if (!latestProviderRow?.providerThreadId) { - return null; + .groupBy(events.providerThreadId, events.threadId) + .all(); + const earliestByProviderThreadId = new Map< + string, + { firstClaimedAt: number; threadIds: string[] } + >(); + for (const row of rows) { + const earliest = earliestByProviderThreadId.get(row.providerThreadId); + if (earliest === undefined || row.firstClaimedAt < earliest.firstClaimedAt) { + earliestByProviderThreadId.set(row.providerThreadId, { + firstClaimedAt: row.firstClaimedAt, + threadIds: [row.threadId], + }); + } else if (row.firstClaimedAt === earliest.firstClaimedAt) { + earliest.threadIds.push(row.threadId); + } + } + for (const [providerThreadId, earliest] of earliestByProviderThreadId) { + const [owner, ...others] = earliest.threadIds; + if (owner === undefined) { + continue; + } + claims.set( + providerThreadId, + others.length === 0 + ? { kind: "owned", threadId: owner } + : { kind: "tied", threadIds: [...earliest.threadIds].sort() }, + ); } + return claims; +} - return latestProviderRow.providerThreadId; +function classifyClaim( + claim: StoredProviderThreadClaim | undefined, + threadId: string, +): StoredProviderThreadClaimClass { + if (claim === undefined) { + return "unannounced"; + } + if (claim.kind === "owned") { + return claim.threadId === threadId ? "owned" : "foreign"; + } + return claim.threadIds.includes(threadId) ? "ambiguous" : "foreign"; +} + +function otherClaimants( + claim: StoredProviderThreadClaim | undefined, + threadId: string, +): string[] { + if (claim === undefined) { + return []; + } + const claimants = claim.kind === "owned" ? [claim.threadId] : claim.threadIds; + return claimants.filter((claimant) => claimant !== threadId); +} + +function resolveThreadProviderSession(args: { + candidates: readonly string[]; + claims: ReadonlyMap; + threadId: string; +}): StoredProviderSession { + let firstForeign: StoredProviderSession | null = null; + for (const providerThreadId of args.candidates) { + const claim = args.claims.get(providerThreadId); + switch (classifyClaim(claim, args.threadId)) { + case "owned": + case "unannounced": + return { kind: "owned", providerThreadId }; + case "ambiguous": + return { + kind: "ambiguous", + providerThreadId, + claimantThreadIds: otherClaimants(claim, args.threadId), + }; + case "foreign": + firstForeign ??= { + kind: "foreign", + providerThreadId, + claimantThreadIds: otherClaimants(claim, args.threadId), + }; + } + } + return firstForeign ?? { kind: "none" }; +} + +function identityScopeKey(scope: StoredProviderThreadIdentityScope): string { + return `${scope.providerId}\n${scope.hostId ?? ""}`; +} + +export function resolveStoredProviderSessions( + db: DbQueryConnection, + args: ResolveStoredProviderSessionsArgs, +): Map { + const threadIds = [...new Set(args.threadIds)]; + const resolved = new Map( + threadIds.map((threadId) => [threadId, { kind: "none" }]), + ); + if (threadIds.length === 0) { + return resolved; + } + const candidatesByThreadId = new Map(); + for (const row of listStoredProviderThreadIdentityCandidates(db, threadIds)) { + const candidates = candidatesByThreadId.get(row.threadId) ?? []; + if (candidates.at(-1) !== row.providerThreadId) { + candidates.push(row.providerThreadId); + } + candidatesByThreadId.set(row.threadId, candidates); + } + if (candidatesByThreadId.size === 0) { + return resolved; + } + const scopeGroups = new Map(); + for (const scope of listStoredProviderThreadIdentityScopes(db, [ + ...candidatesByThreadId.keys(), + ])) { + const key = identityScopeKey(scope); + scopeGroups.set(key, [...(scopeGroups.get(key) ?? []), scope]); + } + for (const group of scopeGroups.values()) { + const [first] = group; + if (first === undefined) { + continue; + } + const claims = listStoredProviderThreadClaims(db, { + hostId: first.hostId, + providerId: first.providerId, + providerThreadIds: [ + ...new Set( + group.flatMap( + (scope) => candidatesByThreadId.get(scope.threadId) ?? [], + ), + ), + ], + }); + for (const scope of group) { + resolved.set( + scope.threadId, + resolveThreadProviderSession({ + candidates: candidatesByThreadId.get(scope.threadId) ?? [], + claims, + threadId: scope.threadId, + }), + ); + } + } + return resolved; +} + +export function getStoredProviderSession( + db: DbQueryConnection, + threadId: string, +): StoredProviderSession { + return ( + resolveStoredProviderSessions(db, { threadIds: [threadId] }).get( + threadId, + ) ?? { kind: "none" } + ); +} + +export function getLastStoredProviderThreadId( + db: DbQueryConnection, + threadId: string, +): string | null { + const session = getStoredProviderSession(db, threadId); + return session.kind === "owned" ? session.providerThreadId : null; +} + +export function classifyStoredProviderThreadClaim( + db: DbQueryConnection, + args: ClassifyStoredProviderThreadClaimArgs, +): StoredProviderThreadClaimClass { + const [scope] = listStoredProviderThreadIdentityScopes(db, [args.threadId]); + if (scope === undefined) { + return "foreign"; + } + const claims = listStoredProviderThreadClaims(db, { + hostId: scope.hostId, + providerId: scope.providerId, + providerThreadIds: [args.providerThreadId], + }); + return classifyClaim(claims.get(args.providerThreadId), args.threadId); } export function listThreadTurnInterruptionEventStates( @@ -3574,40 +3855,13 @@ export function listThreadTurnInterruptionEventStates( } } - const latestProviderRows = db - .select({ - providerThreadId: events.providerThreadId, - threadId: events.threadId, - }) - .from(events) - .where( - and( - inArray(events.threadId, threadIds), - isNotNull(events.providerThreadId), - sql`${events.sequence} = ( - SELECT MAX(latest.sequence) - FROM events AS latest - WHERE latest.thread_id = ${events.threadId} - AND latest.provider_thread_id IS NOT NULL - AND latest.sequence > COALESCE(( - SELECT MAX(context_clear.sequence) - FROM events AS context_clear - WHERE context_clear.thread_id = ${events.threadId} - AND context_clear.type = 'system/operation' - AND json_extract(context_clear.data, '$.operation') = ${THREAD_CONTEXT_CLEAR_OPERATION} - AND json_extract(context_clear.data, '$.status') = 'completed' - ), 0) - )`, - ), - ) - .all(); - for (const row of latestProviderRows) { - if (row.providerThreadId === null) { - continue; - } - const state = statesByThreadId.get(row.threadId); + for (const [threadId, session] of resolveStoredProviderSessions(db, { + threadIds, + })) { + const state = statesByThreadId.get(threadId); if (state) { - state.latestProviderThreadId = row.providerThreadId; + state.latestProviderThreadId = + session.kind === "owned" ? session.providerThreadId : null; } } diff --git a/packages/db/src/data/index.ts b/packages/db/src/data/index.ts index f26981c03a..5d5dd73220 100644 --- a/packages/db/src/data/index.ts +++ b/packages/db/src/data/index.ts @@ -274,7 +274,12 @@ export { getActiveStoredTurnId, hasRootStoredTurnStarted, hasStoredTurnStarted, + classifyStoredProviderThreadClaim, getLastStoredProviderThreadId, + getStoredProviderSession, + resolveStoredProviderSessions, + type StoredProviderSession, + type StoredProviderThreadClaimClass, getLastStoredTurnRequestEvent, getStoredTurnRequestEventForTurn, getLatestThreadOutputEventRow, diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 29faf465c6..1b90727a57 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -818,6 +818,9 @@ export const events = sqliteTable( sql`${table.type} IN ('item/started', 'item/completed', 'item/backgroundTask/completed')`, ), index("events_environment_idx").on(table.environmentId), + index("events_provider_identity_idx") + .on(table.providerThreadId, table.createdAt) + .where(sql`${table.type} = 'thread/identity'`), index("events_completed_item_truncation_idx") .on(table.itemKind, table.createdAt, table.id) .where(sql`${table.type} = 'item/completed'`), diff --git a/packages/db/test/data/events.test.ts b/packages/db/test/data/events.test.ts index f3bc5148f2..a3e790416f 100644 --- a/packages/db/test/data/events.test.ts +++ b/packages/db/test/data/events.test.ts @@ -26,6 +26,9 @@ import { getFirstParentedTimelineBoundarySequence, getHighWaterMarks, getLastStoredProviderThreadId, + classifyStoredProviderThreadClaim, + getStoredProviderSession, + resolveStoredProviderSessions, getLatestCompletedThreadContextClearSequence, getLatestStoredConversationOutlineSequence, getLastStoredTurnRequestEvent, @@ -68,6 +71,7 @@ import type { InsertEventInput, } from "../../src/data/events.js"; import { upsertHost } from "../../src/data/hosts.js"; +import type { DbConnection } from "../../src/connection.js"; import { createMigratedConnection } from "../helpers/migrated-connection.js"; function setup() { @@ -2603,6 +2607,14 @@ describe("events", () => { expect(firstSequence).toBe(1); expect(secondSequence).toBe(2); expect(getActiveStoredTurnId(db, thread.id)).toBe("turn_1"); + expect(getLastStoredProviderThreadId(db, thread.id)).toBeNull(); + appendStoredThreadEvent(db, noopNotifier, { + threadId: thread.id, + scope: threadScope(), + providerThreadId: "provider_thr_1", + type: "thread/identity", + data: { providerThreadId: "provider_thr_1" }, + }); expect(getLastStoredProviderThreadId(db, thread.id)).toBe("provider_thr_1"); expect(getLastStoredTurnRequestEvent(db, thread.id)).toMatchObject({ threadId: thread.id, @@ -3033,6 +3045,28 @@ describe("events", () => { { threadId: thread.id, sequence: 1, + scope: threadScope(), + providerThreadId: "provider_active", + type: "thread/identity", + itemId: null, + itemKind: null, + parentToolCallId: null, + data: JSON.stringify({ providerThreadId: "provider_active" }), + }, + { + threadId: completedThread.id, + sequence: 1, + scope: threadScope(), + providerThreadId: "provider_done", + type: "thread/identity", + itemId: null, + itemKind: null, + parentToolCallId: null, + data: JSON.stringify({ providerThreadId: "provider_done" }), + }, + { + threadId: thread.id, + sequence: 2, scope: turnScope("turn_active"), providerThreadId: "provider_active", type: "turn/started", @@ -3046,7 +3080,7 @@ describe("events", () => { }, { threadId: completedThread.id, - sequence: 1, + sequence: 2, scope: turnScope("turn_done"), providerThreadId: "provider_done", type: "turn/started", @@ -3060,7 +3094,7 @@ describe("events", () => { }, { threadId: completedThread.id, - sequence: 2, + sequence: 3, scope: turnScope("turn_done"), providerThreadId: "provider_done", type: "turn/completed", @@ -3129,6 +3163,17 @@ describe("events", () => { { threadId: thread.id, sequence: 1, + scope: threadScope(), + providerThreadId: "provider_thr_1", + type: "thread/identity", + itemId: null, + itemKind: null, + parentToolCallId: null, + data: JSON.stringify({ providerThreadId: "provider_thr_1" }), + }, + { + threadId: thread.id, + sequence: 2, scope: turnScope("root_turn"), providerThreadId: "provider_thr_1", type: "turn/started", @@ -3142,7 +3187,7 @@ describe("events", () => { }, { threadId: thread.id, - sequence: 2, + sequence: 3, scope: turnScope("child_turn"), providerThreadId: "provider_thr_1", type: "turn/started", @@ -5552,3 +5597,544 @@ describe("timeline read-boundary output truncation", () => { expect(rows.at(-1)?.data).toBe(stored.at(-1)?.data); }); }); + +describe("stored provider thread identity ownership", () => { + function setupThreads( + args: { providerIds?: readonly string[] } = {}, + ) { + const db = createMigratedConnection(); + const host = upsertHost(db, noopNotifier, { + name: "identity-host", + type: "persistent", + }); + const { project } = createProject(db, noopNotifier, { + name: "identity-project", + source: { type: "local_path", hostId: host.id, path: "/tmp/identity" }, + }); + const environment = createEnvironment(db, noopNotifier, { + projectId: project.id, + hostId: host.id, + path: "/tmp/identity", + status: "ready", + providerOwnsPath: false, + }); + const threadIds = (args.providerIds ?? ["codex", "codex"]).map( + (providerId) => + createThread(db, noopNotifier, { + projectId: project.id, + environmentId: environment.id, + providerId, + }).id, + ); + return { db, environment, host, project, threadIds }; + } + + function nextSequence(db: DbConnection, threadId: string): number { + return (getHighWaterMarks(db, [threadId])[threadId] ?? 0) + 1; + } + + function announceIdentity( + db: DbConnection, + args: { createdAt: number; providerThreadId: string; threadId: string }, + ): void { + insertEvents(db, noopNotifier, [ + { + threadId: args.threadId, + sequence: nextSequence(db, args.threadId), + createdAt: args.createdAt, + scope: threadScope(), + providerThreadId: args.providerThreadId, + type: "thread/identity", + ...emptyItemFields, + data: JSON.stringify({ providerThreadId: args.providerThreadId }), + }, + ]); + } + + function stampTurn( + db: DbConnection, + args: { + createdAt: number; + providerThreadId: string; + threadId: string; + turnId: string; + }, + ): void { + for (const type of ["turn/started", "turn/completed"] as const) { + insertEvents(db, noopNotifier, [ + { + threadId: args.threadId, + sequence: nextSequence(db, args.threadId), + createdAt: args.createdAt, + scope: turnScope(args.turnId), + providerThreadId: args.providerThreadId, + type, + ...emptyItemFields, + data: JSON.stringify( + type === "turn/completed" + ? { providerThreadId: args.providerThreadId, status: "completed" } + : { providerThreadId: args.providerThreadId }, + ), + }, + ]); + } + } + + function clearContext(db: DbConnection, threadId: string): void { + appendStoredThreadEvent(db, noopNotifier, { + threadId, + scope: threadScope(), + type: "system/operation", + data: { + operation: THREAD_CONTEXT_CLEAR_OPERATION, + operationId: `evt_clear_${threadId}`, + status: "completed", + message: "Context cleared", + }, + }); + } + + function requireThreadIds(threadIds: readonly string[]): [string, string] { + const [first, second] = threadIds; + if (first === undefined || second === undefined) { + throw new Error("Expected two threads"); + } + return [first, second]; + } + + it("ignores provider ids stamped on ordinary events", () => { + const { db, threadIds } = setupThreads(); + const [first, second] = requireThreadIds(threadIds); + announceIdentity(db, { + createdAt: 1_787_775_164_121, + providerThreadId: "session-alpha", + threadId: first, + }); + announceIdentity(db, { + createdAt: 1_787_775_164_240, + providerThreadId: "session-beta", + threadId: second, + }); + stampTurn(db, { + createdAt: 1_787_775_656_758, + providerThreadId: "session-beta", + threadId: first, + turnId: "turn-contaminated", + }); + + expect(getStoredProviderSession(db, first)).toEqual({ + kind: "owned", + providerThreadId: "session-alpha", + }); + expect(getLastStoredProviderThreadId(db, second)).toBe("session-beta"); + db.$client.close(); + }); + + it("falls back to the thread's own session when a later identity names a session another thread claimed first", () => { + const { db, threadIds } = setupThreads(); + const [owner, contaminated] = requireThreadIds(threadIds); + announceIdentity(db, { + createdAt: 1_787_776_927_220, + providerThreadId: "session-own", + threadId: contaminated, + }); + announceIdentity(db, { + createdAt: 1_787_776_927_349, + providerThreadId: "session-shared", + threadId: owner, + }); + stampTurn(db, { + createdAt: 1_787_777_231_409, + providerThreadId: "session-shared", + threadId: contaminated, + turnId: "turn-stamped-foreign", + }); + announceIdentity(db, { + createdAt: 1_787_777_258_917, + providerThreadId: "session-shared", + threadId: contaminated, + }); + + expect(getStoredProviderSession(db, contaminated)).toEqual({ + kind: "owned", + providerThreadId: "session-own", + }); + expect(getStoredProviderSession(db, owner)).toEqual({ + kind: "owned", + providerThreadId: "session-shared", + }); + expect( + classifyStoredProviderThreadClaim(db, { + providerThreadId: "session-shared", + threadId: contaminated, + }), + ).toBe("foreign"); + expect( + classifyStoredProviderThreadClaim(db, { + providerThreadId: "session-shared", + threadId: owner, + }), + ).toBe("owned"); + expect( + classifyStoredProviderThreadClaim(db, { + providerThreadId: "session-never-announced", + threadId: contaminated, + }), + ).toBe("unannounced"); + expect( + classifyStoredProviderThreadClaim(db, { + providerThreadId: "session-shared", + threadId: "thr_missing", + }), + ).toBe("foreign"); + db.$client.close(); + }); + + it("reports a thread whose only identity belongs to another thread as foreign", () => { + const { db, threadIds } = setupThreads(); + const [owner, contaminated] = requireThreadIds(threadIds); + announceIdentity(db, { + createdAt: 1_787_952_405_288, + providerThreadId: "session-shared", + threadId: owner, + }); + announceIdentity(db, { + createdAt: 1_787_952_405_428, + providerThreadId: "session-shared", + threadId: contaminated, + }); + + expect(getStoredProviderSession(db, contaminated)).toEqual({ + kind: "foreign", + providerThreadId: "session-shared", + claimantThreadIds: [owner], + }); + expect(getLastStoredProviderThreadId(db, contaminated)).toBeNull(); + expect(getLastStoredProviderThreadId(db, owner)).toBe("session-shared"); + db.$client.close(); + }); + + it("treats claims tied in the same millisecond as ambiguous for both threads, as in archived pre-#3496 concurrent starts", () => { + const { db, threadIds } = setupThreads(); + const [early, late] = requireThreadIds(threadIds); + const batchAt = 1_787_775_164_121; + announceIdentity(db, { + createdAt: batchAt, + providerThreadId: "01a03fb4-1bb9-session-a", + threadId: early, + }); + announceIdentity(db, { + createdAt: batchAt, + providerThreadId: "01a03fb4-1c1a-session-b", + threadId: late, + }); + announceIdentity(db, { + createdAt: batchAt, + providerThreadId: "01a03fb4-1bb9-session-a", + threadId: early, + }); + announceIdentity(db, { + createdAt: batchAt, + providerThreadId: "01a03fb4-1bb9-session-a", + threadId: late, + }); + for (const [threadId, providerThreadId] of [ + [early, "01a03fb4-1c1a-session-b"], + [late, "01a03fb4-1bb9-session-a"], + ] as const) { + stampTurn(db, { + createdAt: 1_787_775_164_240, + providerThreadId, + threadId, + turnId: `turn-${threadId}`, + }); + } + + expect( + resolveStoredProviderSessions(db, { threadIds: [early, late] }), + ).toEqual( + new Map([ + [ + early, + { + kind: "ambiguous", + providerThreadId: "01a03fb4-1bb9-session-a", + claimantThreadIds: [late], + }, + ], + [ + late, + { + kind: "ambiguous", + providerThreadId: "01a03fb4-1bb9-session-a", + claimantThreadIds: [early], + }, + ], + ]), + ); + expect(getLastStoredProviderThreadId(db, early)).toBeNull(); + expect(getLastStoredProviderThreadId(db, late)).toBeNull(); + expect( + classifyStoredProviderThreadClaim(db, { + providerThreadId: "01a03fb4-1bb9-session-a", + threadId: early, + }), + ).toBe("ambiguous"); + expect( + classifyStoredProviderThreadClaim(db, { + providerThreadId: "01a03fb4-1c1a-session-b", + threadId: late, + }), + ).toBe("owned"); + expect( + listThreadTurnInterruptionEventStates(db, { threadIds: [early, late] }).map( + (state) => state.latestProviderThreadId, + ), + ).toEqual([null, null]); + db.$client.close(); + }); + + it("keeps a strictly earlier claim as the owner even when a later batch ties, as in archived pre-#3496 starts 140 ms apart", () => { + const { db, threadIds } = setupThreads(); + const [first, second] = requireThreadIds(threadIds); + announceIdentity(db, { + createdAt: 1_787_952_405_288, + providerThreadId: "01a04a44-7dd6-session-a", + threadId: first, + }); + announceIdentity(db, { + createdAt: 1_787_952_405_288, + providerThreadId: "01a04a44-80e8-session-b", + threadId: second, + }); + announceIdentity(db, { + createdAt: 1_787_952_405_428, + providerThreadId: "01a04a44-7dd6-session-a", + threadId: first, + }); + announceIdentity(db, { + createdAt: 1_787_952_405_428, + providerThreadId: "01a04a44-7dd6-session-a", + threadId: second, + }); + + expect( + resolveStoredProviderSessions(db, { threadIds: [first, second] }), + ).toEqual( + new Map([ + [ + first, + { kind: "owned", providerThreadId: "01a04a44-7dd6-session-a" }, + ], + [ + second, + { kind: "owned", providerThreadId: "01a04a44-80e8-session-b" }, + ], + ]), + ); + db.$client.close(); + }); + + it("stops at an ambiguous newest identity instead of resuming an older session", () => { + const { db, threadIds } = setupThreads(); + const [first, second] = requireThreadIds(threadIds); + announceIdentity(db, { + createdAt: 1_787_789_348_554, + providerThreadId: "session-own-earlier", + threadId: second, + }); + announceIdentity(db, { + createdAt: 1_787_789_957_543, + providerThreadId: "session-tied", + threadId: first, + }); + announceIdentity(db, { + createdAt: 1_787_789_957_543, + providerThreadId: "session-tied", + threadId: second, + }); + + expect(getStoredProviderSession(db, second)).toEqual({ + kind: "ambiguous", + providerThreadId: "session-tied", + claimantThreadIds: [first], + }); + db.$client.close(); + }); + + it("lets a thread outside a tie treat the tied session as foreign", () => { + const { db, threadIds } = setupThreads({ + providerIds: ["codex", "codex", "codex"], + }); + const [first, second, third] = threadIds; + if (first === undefined || second === undefined || third === undefined) { + throw new Error("Expected three threads"); + } + for (const threadId of [first, second]) { + announceIdentity(db, { + createdAt: 1_787_775_164_121, + providerThreadId: "session-tied", + threadId, + }); + } + announceIdentity(db, { + createdAt: 1_787_775_100_000, + providerThreadId: "session-third-own", + threadId: third, + }); + announceIdentity(db, { + createdAt: 1_787_775_200_000, + providerThreadId: "session-tied", + threadId: third, + }); + + expect(getStoredProviderSession(db, third)).toEqual({ + kind: "owned", + providerThreadId: "session-third-own", + }); + expect( + classifyStoredProviderThreadClaim(db, { + providerThreadId: "session-tied", + threadId: third, + }), + ).toBe("foreign"); + db.$client.close(); + }); + + it("resolves no session after a completed context clear while ownership of earlier sessions persists", () => { + const { db, threadIds } = setupThreads(); + const [owner, other] = requireThreadIds(threadIds); + announceIdentity(db, { + createdAt: 1_787_775_164_121, + providerThreadId: "session-alpha", + threadId: owner, + }); + announceIdentity(db, { + createdAt: 1_787_775_164_121, + providerThreadId: "session-alpha", + threadId: other, + }); + clearContext(db, owner); + + expect(getStoredProviderSession(db, owner)).toEqual({ kind: "none" }); + expect(getStoredProviderSession(db, other)).toEqual({ + kind: "ambiguous", + providerThreadId: "session-alpha", + claimantThreadIds: [owner], + }); + announceIdentity(db, { + createdAt: 1_787_775_300_000, + providerThreadId: "session-gamma", + threadId: owner, + }); + expect(getStoredProviderSession(db, owner)).toEqual({ + kind: "owned", + providerThreadId: "session-gamma", + }); + clearContext(db, other); + expect(getStoredProviderSession(db, other)).toEqual({ kind: "none" }); + db.$client.close(); + }); + + it("scopes claims to threads of the same provider on the same host", () => { + const { db, host, project, threadIds } = setupThreads({ + providerIds: ["codex", "claude-code"], + }); + const [codexThread, claudeThread] = requireThreadIds(threadIds); + const otherHost = upsertHost(db, noopNotifier, { + name: "identity-other-host", + type: "persistent", + }); + const otherEnvironment = createEnvironment(db, noopNotifier, { + projectId: project.id, + hostId: otherHost.id, + path: "/tmp/identity-other", + status: "ready", + providerOwnsPath: false, + }); + const otherHostThread = createThread(db, noopNotifier, { + projectId: project.id, + environmentId: otherEnvironment.id, + providerId: "codex", + }); + const detachedThread = createThread(db, noopNotifier, { + projectId: project.id, + providerId: "codex", + }); + for (const threadId of [claudeThread, otherHostThread.id]) { + announceIdentity(db, { + createdAt: 1_787_775_100_000, + providerThreadId: "session-shared", + threadId, + }); + } + announceIdentity(db, { + createdAt: 1_787_775_200_000, + providerThreadId: "session-shared", + threadId: codexThread, + }); + + expect(getLastStoredProviderThreadId(db, codexThread)).toBe( + "session-shared", + ); + expect(getLastStoredProviderThreadId(db, claudeThread)).toBe( + "session-shared", + ); + expect(getLastStoredProviderThreadId(db, otherHostThread.id)).toBe( + "session-shared", + ); + + announceIdentity(db, { + createdAt: 1_787_775_050_000, + providerThreadId: "session-shared", + threadId: detachedThread.id, + }); + expect(getStoredProviderSession(db, detachedThread.id)).toEqual({ + kind: "owned", + providerThreadId: "session-shared", + }); + expect(getStoredProviderSession(db, codexThread)).toEqual({ + kind: "foreign", + providerThreadId: "session-shared", + claimantThreadIds: [detachedThread.id], + }); + expect(host.id).not.toBe(otherHost.id); + db.$client.close(); + }); + + it("resolves a batch of threads and unknown ids", () => { + const { db, threadIds } = setupThreads(); + const [first, second] = requireThreadIds(threadIds); + announceIdentity(db, { + createdAt: 1_787_775_164_121, + providerThreadId: "session-alpha", + threadId: first, + }); + announceIdentity(db, { + createdAt: 1_787_775_164_240, + providerThreadId: "session-beta", + threadId: second, + }); + + expect( + resolveStoredProviderSessions(db, { + threadIds: [first, second, "thr_missing", first], + }), + ).toEqual( + new Map([ + [first, { kind: "owned", providerThreadId: "session-alpha" }], + [second, { kind: "owned", providerThreadId: "session-beta" }], + ["thr_missing", { kind: "none" }], + ]), + ); + expect( + listThreadTurnInterruptionEventStates(db, { + threadIds: [first, second], + }).map((state) => state.latestProviderThreadId), + ).toEqual(["session-alpha", "session-beta"]); + expect(resolveStoredProviderSessions(db, { threadIds: [] })).toEqual( + new Map(), + ); + db.$client.close(); + }); +}); diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index c01fc233aa..b2c32c8a06 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -4569,6 +4569,7 @@ describe("migrate", () => { "events_item_lifecycle_thread_item_sequence_idx", "events_parent_tool_call_thread_parent_sequence_idx", "events_plan_steps_thread_sequence_idx", + "events_provider_identity_idx", "events_thread_sequence_idx", "events_thread_state_thread_sequence_idx", "events_thread_turn_type_item_sequence_idx", diff --git a/packages/db/test/query-plans.test.ts b/packages/db/test/query-plans.test.ts index e592935e42..096efed6b5 100644 --- a/packages/db/test/query-plans.test.ts +++ b/packages/db/test/query-plans.test.ts @@ -19,6 +19,7 @@ import { getFirstParentedTimelineBoundarySequence, hasTimelineGroupingContextRowsInRange, listStoredEventRowsInSequenceRange, + getLastStoredProviderThreadId, insertEvents, listActiveBackgroundTaskCountsByThreadIds, listItemEventSpansByItems, @@ -241,6 +242,39 @@ function assertEmittedQueryPlanUsesIndex( } describe("slow query index plans", () => { + it("looks up provider thread owners through the identity index", () => { + const { db, thread } = setup(); + try { + insertEvents(db, noopNotifier, [ + { + threadId: thread.id, + sequence: 1, + scope: threadScope(), + providerThreadId: "provider-owner-plan", + type: "thread/identity", + itemId: null, + itemKind: null, + parentToolCallId: null, + data: JSON.stringify({ providerThreadId: "provider-owner-plan" }), + }, + ]); + const captured = captureStatements(db, () => { + expect(getLastStoredProviderThreadId(db, thread.id)).toBe( + "provider-owner-plan", + ); + }); + const ownerQuery = captured.find((statement) => + statement.sql.includes('inner join "threads"'), + ); + expect(ownerQuery).toBeDefined(); + expect(queryPlanDetails({ db, ...ownerQuery! })).toContain( + "USING INDEX events_provider_identity_idx", + ); + } finally { + db.$client.close(); + } + }); + it("seeks accepted inputs past each thread's latest interruption", () => { const { db, thread } = setup(); try { From d1484558f87955f10b7ca2a25a0982134eeabcb1 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 11 Sep 2026 21:49:50 +0000 Subject: [PATCH 2/9] fix(server): refuse to dispatch a thread into another thread's provider session Sends, queued dispatch, turn submission, and goal clears resumed whatever session id the stored history named. With a contaminated stamp that was another thread's rollout (#3461); with only a foreign or tied identity, a send silently started a new session without the thread's context. Dispatch now goes through `requireDispatchableProviderThreadId`. An owned session resumes and no session since the last context clear starts fresh. A foreign or ambiguous session answers 409 `provider_session_unavailable` with the session id and claimant threads in `details`, and the message "Send /clear to start a new session; history is kept." Nothing is dispatched and no history is rewritten; a queued row records the same text as its failure reason. Message-edit rewinds and anchored or tip forks refuse a completion or source whose session is foreign or ambiguous. Empty-input fork starts record the daemon's start result as a real `thread/identity` event, so the resolver sees the session the server itself established. Test fixtures that established a session through a stamped `turn/started` alone now announce it through `seedThreadIdentity`, matching what every bridge emits. Fixes #3461 Co-Authored-By: Claude Code --- .../src/services/threads/queued-messages.ts | 24 +- .../src/services/threads/thread-commands.ts | 8 +- .../services/threads/thread-edit-message.ts | 18 + .../src/services/threads/thread-events.ts | 30 ++ .../services/threads/thread-fork-history.ts | 59 ++- .../src/services/threads/thread-lifecycle.ts | 16 +- apps/server/test/helpers/seed.ts | 24 + .../test/public/public-thread-data.test.ts | 11 +- .../test/public/public-thread-fork.test.ts | 90 ++++ ...blic-thread-queue-gone-environment.test.ts | 6 + .../public/public-thread-stop-runtime.test.ts | 11 +- .../project-execution-defaults.test.ts | 5 + .../threads/generated-thread-titles.test.ts | 5 + .../thread-create-seed-without-run.test.ts | 34 ++ .../test/threads/thread-edit-message.test.ts | 90 ++++ ...thread-provider-identity-ownership.test.ts | 427 ++++++++++++++++++ .../bb-cli/references/failure-recovery.md | 6 + 17 files changed, 836 insertions(+), 28 deletions(-) create mode 100644 apps/server/test/threads/thread-provider-identity-ownership.test.ts diff --git a/apps/server/src/services/threads/queued-messages.ts b/apps/server/src/services/threads/queued-messages.ts index 5007b687e2..9e5049ec76 100644 --- a/apps/server/src/services/threads/queued-messages.ts +++ b/apps/server/src/services/threads/queued-messages.ts @@ -1,11 +1,12 @@ import { - claimQueuedThreadMessageGroup, claimNextQueuedThreadMessageGroup, + claimQueuedThreadMessageGroup, createQueuedThreadMessageInTransaction, deleteClaimedQueuedThreadMessageBatchInTransaction, - getQueuedThreadMessage, getEnvironment, getHost, + getQueuedThreadMessage, + getStoredProviderSession, getThread, isOrdinaryTurnEndQueuedMessage, isThreadQueueAutoSendPaused, @@ -188,16 +189,17 @@ export interface CreateQueuedMessageForThreadArgs { function admitQueuedMessage( db: DbQueryConnection, thread: Thread, -): { providerThreadId: string | null } { +): { hasProviderSession: boolean } { ensureThreadQueueIsWritable(thread); - const providerThreadId = getLastProviderThreadId({ db }, thread.id); + const hasProviderSession = + getStoredProviderSession(db, thread.id).kind !== "none"; if (thread.environmentId === null) { - if (providerThreadId !== null) { + if (hasProviderSession) { throwThreadEnvironmentUnavailable( threadEnvironmentUnavailableDetails("never_attached", null), ); } - return { providerThreadId }; + return { hasProviderSession }; } const environment = getEnvironment(db, thread.environmentId); const goneDetails = environment @@ -206,7 +208,7 @@ function admitQueuedMessage( if (goneDetails) { throwThreadEnvironmentUnavailable(goneDetails); } - return { providerThreadId }; + return { hasProviderSession }; } export async function createQueuedMessageForThread( @@ -228,14 +230,14 @@ export async function createQueuedMessageForThread( senderThreadId: payload.senderThreadId, targetThread: thread, }); - const { currentThread, providerThreadId, queuedMessage } = + const { currentThread, hasProviderSession, 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 { hasProviderSession } = admitQueuedMessage(tx, currentThread); const queuedMessage = createQueuedThreadMessageInTransaction(tx, { threadId: thread.id, content: payload.input, @@ -261,7 +263,7 @@ export async function createQueuedMessageForThread( payload: { kind: "inline" }, systemNotice: null, }); - return { currentThread, providerThreadId, queuedMessage }; + return { currentThread, hasProviderSession, queuedMessage }; }, { behavior: "immediate" }, ); @@ -273,7 +275,7 @@ export async function createQueuedMessageForThread( providerId: thread.providerId, }); } - if (currentThread.status === "idle" && providerThreadId !== null) { + if (currentThread.status === "idle" && hasProviderSession) { requestQueuedMessageDispatch(deps, { kind: "thread-ready", threadId: thread.id, diff --git a/apps/server/src/services/threads/thread-commands.ts b/apps/server/src/services/threads/thread-commands.ts index 6c0b80b5e5..06eb43fe44 100644 --- a/apps/server/src/services/threads/thread-commands.ts +++ b/apps/server/src/services/threads/thread-commands.ts @@ -24,7 +24,10 @@ import { LIVE_DAEMON_COMMAND_TIMEOUT_MS, startLiveHostCommand, } from "../hosts/live-command.js"; -import { getLastProviderThreadId } from "./thread-events.js"; +import { + getLastProviderThreadId, + requireDispatchableProviderThreadId, +} from "./thread-events.js"; import type { ThreadForkDescriptor } from "./thread-startup-store.js"; import { resolveThreadRuntimeCommandConfig, @@ -356,7 +359,8 @@ export async function prepareTurnSubmitCommandPayload( ): Promise { await deps.providerRegistry.whenRegistrationsSettled(); const providerThreadId = requireProviderThreadId( - args.providerThreadId ?? getLastProviderThreadId(deps, args.thread.id), + args.providerThreadId ?? + requireDispatchableProviderThreadId(deps, args.thread.id), args.thread.id, ); const runtimeContext = await resolveThreadRuntimeCommandConfig(deps, { diff --git a/apps/server/src/services/threads/thread-edit-message.ts b/apps/server/src/services/threads/thread-edit-message.ts index 8952d71826..b68d12f407 100644 --- a/apps/server/src/services/threads/thread-edit-message.ts +++ b/apps/server/src/services/threads/thread-edit-message.ts @@ -8,6 +8,7 @@ import { getThread, hasQueuedThreadMessages, hasRootStoredTurnStarted, + classifyStoredProviderThreadClaim, listActiveBackgroundTaskCountsByThreadIds, type DbQueryConnection, } from "@bb/db"; @@ -274,6 +275,23 @@ function resolveEditableTurnCandidate( ) { conflict("This earlier turn has no provider history"); } + const precedingSessionClaim = + precedingCompletion?.providerThreadId == null + ? null + : classifyStoredProviderThreadClaim(db, { + providerThreadId: precedingCompletion.providerThreadId, + threadId: thread.id, + }); + if (precedingSessionClaim === "foreign") { + conflict( + "This earlier turn is recorded under another thread's provider session", + ); + } + if (precedingSessionClaim === "ambiguous") { + conflict( + "This earlier turn is recorded under a provider session another thread announced at the same moment", + ); + } const precedingProviderCheckpoint = precedingTurnId === null ? null diff --git a/apps/server/src/services/threads/thread-events.ts b/apps/server/src/services/threads/thread-events.ts index 8d27716268..643b09fc39 100644 --- a/apps/server/src/services/threads/thread-events.ts +++ b/apps/server/src/services/threads/thread-events.ts @@ -4,6 +4,7 @@ import { getActiveStoredTurnId, getLastStoredProviderThreadId, getLastStoredTurnRequestEvent, + getStoredProviderSession, getStoredTurnRequestEventForTurn, getThread, listStoredTurnStartedKeys, @@ -879,6 +880,35 @@ export function getLastProviderThreadId( return getLastStoredProviderThreadId(deps.db, threadId); } +export function requireDispatchableProviderThreadId( + deps: ThreadEventReadDeps, + threadId: string, +): string | null { + const session = getStoredProviderSession(deps.db, threadId); + switch (session.kind) { + case "none": + return null; + case "owned": + return session.providerThreadId; + case "ambiguous": + case "foreign": + throw new ApiError( + 409, + "provider_session_unavailable", + session.kind === "ambiguous" + ? "Another thread claimed this thread's provider session in the same millisecond, so bb will not guess whose it is. Clear context (/clear or bb thread clear) for a new session; history is kept." + : "This thread's only provider session belongs to another thread, so bb will not resume it. Clear context (/clear or bb thread clear) for a new session; history is kept.", + { + details: { + reason: session.kind, + providerThreadId: session.providerThreadId, + claimantThreadIds: session.claimantThreadIds, + }, + }, + ); + } +} + export function getLastExecutionOptions( deps: Pick, threadId: string, diff --git a/apps/server/src/services/threads/thread-fork-history.ts b/apps/server/src/services/threads/thread-fork-history.ts index b4bce2b1f0..6eefae679a 100644 --- a/apps/server/src/services/threads/thread-fork-history.ts +++ b/apps/server/src/services/threads/thread-fork-history.ts @@ -2,6 +2,8 @@ import { copyStoredThreadEventsInTransaction, findLastCompletedRootStoredTurn, findLastRootStoredTurnStarted, + classifyStoredProviderThreadClaim, + getStoredProviderSession, listStoredEventRows, listStoredTurnCompletedRowsByTurnIds, type StoredEventRow, @@ -10,10 +12,7 @@ import type { Thread, ThreadEvent, ThreadEventType } from "@bb/domain"; import type { AppDeps } from "../../types.js"; import { ApiError } from "../../errors.js"; import { parseStoredEvent } from "./thread-data.js"; -import { - getLastProviderThreadId, - parseStoredTurnRequestEvent, -} from "./thread-events.js"; +import { parseStoredTurnRequestEvent } from "./thread-events.js"; import { resolveTurnProviderCheckpointId } from "./thread-edit-message.js"; import type { ThreadForkDescriptor } from "./thread-startup-store.js"; @@ -50,6 +49,20 @@ function readTurnCompletion( return { event, sequence: row.sequence }; } +function classifyCompletionSession( + deps: Pick, + args: { completion: StoredTurnCompletion; sourceThreadId: string }, +): "owned" | "foreign" | "ambiguous" { + if (args.completion.event.providerThreadId === null) { + return "foreign"; + } + const claim = classifyStoredProviderThreadClaim(deps.db, { + providerThreadId: args.completion.event.providerThreadId, + threadId: args.sourceThreadId, + }); + return claim === "unannounced" ? "owned" : claim; +} + function resolveCheckpointForkDescriptor(args: { completion: StoredTurnCompletion; providerId: string; @@ -99,6 +112,20 @@ function resolveAnchoredForkPoint( `Cannot fork at sequence ${args.sourceSeqEnd}: the turn containing it has no provider session`, ); } + const completionSession = classifyCompletionSession(deps, { + completion, + sourceThreadId: args.sourceThread.id, + }); + if (completionSession === "foreign") { + forkPointUnavailable( + `Cannot fork at sequence ${args.sourceSeqEnd}: the turn containing it is recorded under another thread's provider session`, + ); + } + if (completionSession === "ambiguous") { + forkPointUnavailable( + `Cannot fork at sequence ${args.sourceSeqEnd}: the turn containing it is recorded under a provider session another thread announced at the same moment`, + ); + } const latestRootTurn = findLastRootStoredTurnStarted(deps.db, { threadId: args.sourceThread.id, }); @@ -146,13 +173,21 @@ export function resolveThreadForkPoint( sourceThread: args.sourceThread, }); } - const sourceProviderThreadId = getLastProviderThreadId( - deps, - args.sourceThread.id, - ); - if (sourceProviderThreadId === null) { + const sourceSession = getStoredProviderSession(deps.db, args.sourceThread.id); + if (sourceSession.kind === "none") { return null; } + if (sourceSession.kind === "ambiguous") { + forkPointUnavailable( + "Cannot fork: another thread announced the source thread's provider session at the same moment, so bb cannot tell whose it is", + ); + } + if (sourceSession.kind === "foreign") { + forkPointUnavailable( + "Cannot fork: the source thread's only provider session belongs to another thread", + ); + } + const sourceProviderThreadId = sourceSession.providerThreadId; const lastCompletedTurn = findLastCompletedRootStoredTurn(deps.db, { threadId: args.sourceThread.id, }); @@ -178,7 +213,11 @@ export function resolveThreadForkPoint( turnId: lastCompletedTurn.turnId, }); const descriptor = - completion === null + completion === null || + classifyCompletionSession(deps, { + completion, + sourceThreadId: args.sourceThread.id, + }) !== "owned" ? null : resolveCheckpointForkDescriptor({ completion, diff --git a/apps/server/src/services/threads/thread-lifecycle.ts b/apps/server/src/services/threads/thread-lifecycle.ts index 54eb586feb..91cd32e4cf 100644 --- a/apps/server/src/services/threads/thread-lifecycle.ts +++ b/apps/server/src/services/threads/thread-lifecycle.ts @@ -61,13 +61,14 @@ import { } from "../../internal/command-result-side-effects.js"; import { appendSystemErrorEventInTransaction, - buildSystemErrorEventData, appendThreadEventInTransaction, appendThreadEventsInTransaction, appendThreadInterruptedEventInTransaction, appendThreadProvisioningEventInTransaction, + buildSystemErrorEventData, getActiveTurnId, getLastProviderThreadId, + requireDispatchableProviderThreadId, } from "./thread-events.js"; import { applyLoggedThreadLifecycleEvent, @@ -776,6 +777,14 @@ function recordEmptyThreadStartProviderSessionInTransaction( ) { return; } + appendThreadEventInTransaction(args.deps.db, { + threadId: args.thread.id, + environmentId: args.command.environmentId, + providerThreadId: args.report.result.providerThreadId, + type: "thread/identity", + scope: threadScope(), + data: { providerThreadId: args.report.result.providerThreadId }, + }); appendThreadEventInTransaction(args.deps.db, { threadId: args.thread.id, environmentId: args.command.environmentId, @@ -968,7 +977,10 @@ export async function prepareReadyThreadTurnCommand( await ensureHostSessionReadyForWork(deps, { hostId: args.environment.hostId, }); - const providerThreadId = getLastProviderThreadId(deps, args.thread.id); + const providerThreadId = requireDispatchableProviderThreadId( + deps, + args.thread.id, + ); if (providerThreadId) { const preparedCommand = await prepareTurnSubmitCommandPayload(deps, { environment: args.environment, diff --git a/apps/server/test/helpers/seed.ts b/apps/server/test/helpers/seed.ts index 80aca93e53..27d6f1cdab 100644 --- a/apps/server/test/helpers/seed.ts +++ b/apps/server/test/helpers/seed.ts @@ -323,6 +323,30 @@ export function seedTurnStarted( }); } +export function seedThreadIdentity( + deps: Pick, + args: { + createdAt?: number; + environmentId?: string | null; + providerThreadId: string; + sequence?: number; + threadId: string; + }, +): void { + seedEvent(deps, { + threadId: args.threadId, + environmentId: args.environmentId ?? null, + providerThreadId: args.providerThreadId, + createdAt: args.createdAt, + sequence: + args.sequence ?? + getLatestThreadSequence(deps.db, { threadId: args.threadId }) + 1, + type: "thread/identity", + scope: threadScope(), + data: {}, + }); +} + export function seedThreadRuntimeState( deps: Pick, args: { diff --git a/apps/server/test/public/public-thread-data.test.ts b/apps/server/test/public/public-thread-data.test.ts index 97bd7c4b55..1dd22d1965 100644 --- a/apps/server/test/public/public-thread-data.test.ts +++ b/apps/server/test/public/public-thread-data.test.ts @@ -55,14 +55,15 @@ import { import { readJson } from "../helpers/json.js"; import { textInput } from "../helpers/prompt-input.js"; import { - seedQueuedMessage, seedEnvironment, seedEvent, seedHostSession, seedProjectWithSource, + seedQueuedMessage, seedStoredEvent, seedThread, seedThreadFixture, + seedThreadIdentity, seedThreadRuntimeState, } from "../helpers/seed.js"; import { installFakeEnvironmentProvider } from "../helpers/environment-provider.js"; @@ -3918,12 +3919,18 @@ describe("public thread data routes", () => { const senderThread = seedThread(harness.deps, { projectId: project.id, }); + seedThreadIdentity(harness.deps, { + threadId: thread.id, + environmentId: environment.id, + providerThreadId: "provider-active-grouped-sender", + sequence: 1, + }); seedEvent(harness.deps, { threadId: thread.id, environmentId: environment.id, providerThreadId: "provider-active-grouped-sender", scope: turnScope("turn-active-grouped-sender"), - sequence: 1, + sequence: 2, type: "turn/started", data: {}, }); diff --git a/apps/server/test/public/public-thread-fork.test.ts b/apps/server/test/public/public-thread-fork.test.ts index ce9f25ed86..3133aedd85 100644 --- a/apps/server/test/public/public-thread-fork.test.ts +++ b/apps/server/test/public/public-thread-fork.test.ts @@ -1459,6 +1459,96 @@ describe("fork branch point and inherited history", () => { }); }); + it("refuses to fork a turn recorded under another thread's provider session", async () => { + await withTestHarness(async (harness) => { + const { environment, sourceThread } = seedConversationForkSource(harness); + const owner = seedThread(harness.deps, { + environmentId: environment.id, + projectId: sourceThread.projectId, + }); + seedEvent(harness.deps, { + threadId: owner.id, + environmentId: environment.id, + providerThreadId: HISTORY_PROVIDER_THREAD_ID, + createdAt: 1, + sequence: 1, + type: "thread/identity", + scope: threadScope(), + data: {}, + }); + + const anchored = await postFork(harness, { + sourceThreadId: sourceThread.id, + sourceSeqEnd: 5, + }); + expect(anchored.status).toBe(400); + expect(await readJson(anchored)).toMatchObject({ + code: "fork_source_session_unavailable", + message: + "Cannot fork at sequence 5: the turn containing it is recorded under another thread's provider session", + }); + + const tip = await postFork(harness, { + sourceThreadId: sourceThread.id, + }); + expect(tip.status).toBe(400); + expect(await readJson(tip)).toMatchObject({ + code: "fork_source_session_unavailable", + message: + "Cannot fork: the source thread's only provider session belongs to another thread", + }); + expect(listQueuedCommands(harness, "thread.start")).toHaveLength(0); + }); + }); + + it("refuses to fork a source whose session another thread announced in the same millisecond", async () => { + await withTestHarness(async (harness) => { + const { environment, sourceThread } = seedConversationForkSource(harness); + const sourceIdentity = listEvents(harness.db, { + threadId: sourceThread.id, + }).find((event) => event.type === "thread/identity"); + if (sourceIdentity === undefined) { + throw new Error("Expected the source thread's identity event"); + } + const other = seedThread(harness.deps, { + environmentId: environment.id, + projectId: sourceThread.projectId, + }); + seedEvent(harness.deps, { + threadId: other.id, + environmentId: environment.id, + providerThreadId: HISTORY_PROVIDER_THREAD_ID, + createdAt: sourceIdentity.createdAt, + sequence: 1, + type: "thread/identity", + scope: threadScope(), + data: {}, + }); + + const anchored = await postFork(harness, { + sourceThreadId: sourceThread.id, + sourceSeqEnd: 5, + }); + expect(anchored.status).toBe(400); + expect(await readJson(anchored)).toMatchObject({ + code: "fork_source_session_unavailable", + message: + "Cannot fork at sequence 5: the turn containing it is recorded under a provider session another thread announced at the same moment", + }); + + const tip = await postFork(harness, { + sourceThreadId: sourceThread.id, + }); + expect(tip.status).toBe(400); + expect(await readJson(tip)).toMatchObject({ + code: "fork_source_session_unavailable", + message: + "Cannot fork: another thread announced the source thread's provider session at the same moment, so bb cannot tell whose it is", + }); + expect(listQueuedCommands(harness, "thread.start")).toHaveLength(0); + }); + }); + it("rejects an anchor inside a running turn or before the first turn", async () => { await withTestHarness(async (harness) => { const { sourceThread } = seedConversationForkSource(harness); diff --git a/apps/server/test/public/public-thread-queue-gone-environment.test.ts b/apps/server/test/public/public-thread-queue-gone-environment.test.ts index 145f259b8f..5470b6a0be 100644 --- a/apps/server/test/public/public-thread-queue-gone-environment.test.ts +++ b/apps/server/test/public/public-thread-queue-gone-environment.test.ts @@ -20,6 +20,7 @@ import { seedProjectWithSource, seedQueuedMessage, seedThread, + seedThreadIdentity, seedTurnStarted, } from "../helpers/seed.js"; import { withTestHarness, type TestAppHarness } from "../helpers/test-app.js"; @@ -145,6 +146,11 @@ describe("queued message into a thread whose environment is gone (#1789)", () => scope: threadScope(), data: earlierTurnEventData, }); + seedThreadIdentity(harness.deps, { + threadId: thread.id, + environmentId: null, + providerThreadId: "provider-turn-1", + }); seedTurnStarted(harness.deps, { threadId: thread.id, environmentId: null, diff --git a/apps/server/test/public/public-thread-stop-runtime.test.ts b/apps/server/test/public/public-thread-stop-runtime.test.ts index e7c42edc33..4221c0a8ab 100644 --- a/apps/server/test/public/public-thread-stop-runtime.test.ts +++ b/apps/server/test/public/public-thread-stop-runtime.test.ts @@ -4,7 +4,7 @@ import { listEvents, listQueuedThreadMessages, } from "@bb/db"; -import { turnScope } from "@bb/domain"; +import { threadScope, turnScope } from "@bb/domain"; import { groupHostDaemonEvents } from "@bb/host-daemon-contract"; import { describe, expect, it, vi } from "vitest"; import { @@ -765,6 +765,15 @@ describe("thread runtime stop", () => { body: JSON.stringify({ sessionId: session.id, eventGroups: groupHostDaemonEvents([ + { + threadId: thread.id, + event: { + type: "thread/identity", + threadId: thread.id, + providerThreadId: "provider-stopped-runtime", + scope: threadScope(), + }, + }, { threadId: thread.id, event: { diff --git a/apps/server/test/services/threads/project-execution-defaults.test.ts b/apps/server/test/services/threads/project-execution-defaults.test.ts index ae2229d740..2a0f61916b 100644 --- a/apps/server/test/services/threads/project-execution-defaults.test.ts +++ b/apps/server/test/services/threads/project-execution-defaults.test.ts @@ -9,6 +9,7 @@ import { seedHostSession, seedProjectWithSource, seedThread, + seedThreadIdentity, seedTurnStarted, } from "../../helpers/seed.js"; import { textInput } from "../../helpers/prompt-input.js"; @@ -133,6 +134,10 @@ describe("project execution defaults persistence", () => { projectId: project.id, environmentId: parentEnvironment.id, }); + seedThreadIdentity(harness.deps, { + threadId: parentThread.id, + providerThreadId: "provider-origin-kind-defaults-source", + }); seedTurnStarted(harness.deps, { threadId: parentThread.id, turnId: "turn-origin-kind-defaults-source", diff --git a/apps/server/test/threads/generated-thread-titles.test.ts b/apps/server/test/threads/generated-thread-titles.test.ts index 63d9cd0b3e..227cc23afb 100644 --- a/apps/server/test/threads/generated-thread-titles.test.ts +++ b/apps/server/test/threads/generated-thread-titles.test.ts @@ -22,6 +22,7 @@ import { seedHostSession, seedProjectWithSource, seedThread, + seedThreadIdentity, seedTurnStarted, } from "../helpers/seed.js"; import { @@ -435,6 +436,10 @@ describe("generated thread titles", () => { projectId: project.id, environmentId: environment.id, }); + seedThreadIdentity(harness.deps, { + threadId: sourceThread.id, + providerThreadId: "provider-generated-fork-title-source", + }); seedTurnStarted(harness.deps, { threadId: sourceThread.id, turnId: "turn-generated-fork-title-source", diff --git a/apps/server/test/threads/thread-create-seed-without-run.test.ts b/apps/server/test/threads/thread-create-seed-without-run.test.ts index 2b19305aa3..604620f42f 100644 --- a/apps/server/test/threads/thread-create-seed-without-run.test.ts +++ b/apps/server/test/threads/thread-create-seed-without-run.test.ts @@ -31,6 +31,7 @@ import { seedProjectWithSource, seedQueuedMessage, seedThread, + seedThreadIdentity, seedThreadRuntimeState, seedTurnStarted, } from "../helpers/seed.js"; @@ -121,6 +122,10 @@ describe("thread creation with startedOnBehalfOf (seed-without-run)", () => { projectId: project.id, environmentId: environment.id, }); + seedThreadIdentity(harness.deps, { + threadId: sourceThread.id, + providerThreadId: "provider-seed-without-run-source", + }); seedTurnStarted(harness.deps, { threadId: sourceThread.id, turnId: "turn-seed-without-run-source", @@ -569,6 +574,10 @@ describe("thread creation child-thread boundary validation", () => { await withChildBoundaryHarness( "valid-fork", async ({ harness, hostId, path, projectId, sourceThreadId }) => { + seedThreadIdentity(harness.deps, { + threadId: sourceThreadId, + providerThreadId: "provider-valid-fork-source", + }); seedTurnStarted(harness.deps, { threadId: sourceThreadId, turnId: "turn-valid-fork-source", @@ -605,6 +614,10 @@ describe("thread creation child-thread boundary validation", () => { await withChildBoundaryHarness( "empty-native-fork", async ({ harness, hostId, path, projectId, sourceThreadId }) => { + seedThreadIdentity(harness.deps, { + threadId: sourceThreadId, + providerThreadId: "provider-parent-session", + }); seedTurnStarted(harness.deps, { threadId: sourceThreadId, turnId: "turn-parent", @@ -732,6 +745,10 @@ describe("thread creation child-thread boundary validation", () => { "empty-side-chat-preload", async ({ harness, hostId, path, projectId, sourceThreadId }) => { const capture = installTelemetryCaptureSpy(harness); + seedThreadIdentity(harness.deps, { + threadId: sourceThreadId, + providerThreadId: "provider-parent-session", + }); seedTurnStarted(harness.deps, { threadId: sourceThreadId, turnId: "turn-parent", @@ -809,6 +826,10 @@ describe("thread creation child-thread boundary validation", () => { projectId: PERSONAL_PROJECT_ID, environmentId: environment.id, }); + seedThreadIdentity(harness.deps, { + threadId: sourceThread.id, + providerThreadId: "provider-personal-side-chat-source", + }); seedTurnStarted(harness.deps, { threadId: sourceThread.id, turnId: "turn-personal-side-chat-source", @@ -868,6 +889,11 @@ describe("thread creation child-thread boundary validation", () => { environmentId: sourceEnvironment.id, projectId: PERSONAL_PROJECT_ID, }); + seedThreadIdentity(harness.deps, { + threadId: sourceThread.id, + environmentId: sourceEnvironment.id, + providerThreadId: "provider-personal-fork-source", + }); seedTurnStarted(harness.deps, { environmentId: sourceEnvironment.id, providerThreadId: "provider-personal-fork-source", @@ -960,6 +986,10 @@ describe("thread creation child-thread boundary validation", () => { await withChildBoundaryHarness( "empty-side-chat-preload-queued-message", async ({ harness, hostId, path, projectId, sourceThreadId }) => { + seedThreadIdentity(harness.deps, { + threadId: sourceThreadId, + providerThreadId: "provider-parent-session", + }); seedTurnStarted(harness.deps, { threadId: sourceThreadId, turnId: "turn-parent", @@ -1130,6 +1160,10 @@ describe("thread creation child-thread boundary validation", () => { await withChildBoundaryHarness( "valid-side-chat", async ({ harness, hostId, path, projectId, sourceThreadId }) => { + seedThreadIdentity(harness.deps, { + threadId: sourceThreadId, + providerThreadId: "provider-valid-side-chat-source", + }); seedTurnStarted(harness.deps, { threadId: sourceThreadId, turnId: "turn-valid-side-chat-source", diff --git a/apps/server/test/threads/thread-edit-message.test.ts b/apps/server/test/threads/thread-edit-message.test.ts index 8e211b4756..9982bcaa00 100644 --- a/apps/server/test/threads/thread-edit-message.test.ts +++ b/apps/server/test/threads/thread-edit-message.test.ts @@ -1292,6 +1292,96 @@ describe("editThreadMessage", () => { }); }); + it("refuses to rewind through a turn recorded under another thread's provider session", async () => { + await withTestHarness(async (harness) => { + const { environment, thread } = seedEditableThread(harness, { + firstProviderThreadId: "provider-foreign", + }); + const owner = seedThread(harness.deps, { + environmentId: environment.id, + projectId: thread.projectId, + providerId: "codex", + }); + seedStoredEvent(harness.deps, { + threadId: owner.id, + environmentId: environment.id, + providerThreadId: "provider-foreign", + createdAt: 1, + sequence: 1, + type: "thread/identity", + scope: threadScope(), + data: {}, + }); + + await expect( + editThreadMessage(harness.deps, { + environment, + thread, + payload: { + operationId: "edit-op-foreign-session", + expectedRequestSequence: 7, + input: [{ type: "text", text: "Replacement", mentions: [] }], + }, + }), + ).rejects.toThrow("recorded under another thread's provider session"); + expect( + listQueuedThreadCommands(harness, "thread.rewind.prepare", thread.id), + ).toHaveLength(0); + expect( + listQueuedThreadCommands(harness, "thread.start", thread.id), + ).toHaveLength(0); + }); + }); + + it("refuses to rewind through a turn whose session another thread announced in the same millisecond", async () => { + await withTestHarness(async (harness) => { + const { environment, thread } = seedEditableThread(harness, { + firstProviderThreadId: "provider-tied", + }); + const other = seedThread(harness.deps, { + environmentId: environment.id, + projectId: thread.projectId, + providerId: "codex", + }); + for (const [threadId, sequence] of [ + [thread.id, 100], + [other.id, 1], + ] as const) { + seedStoredEvent(harness.deps, { + threadId, + environmentId: environment.id, + providerThreadId: "provider-tied", + createdAt: 1_787_789_348_554, + sequence, + type: "thread/identity", + scope: threadScope(), + data: {}, + }); + } + const eventsBefore = listEvents(harness.db, { threadId: thread.id }); + + await expect( + editThreadMessage(harness.deps, { + environment, + thread, + payload: { + operationId: "edit-op-tied-session", + expectedRequestSequence: 7, + input: [{ type: "text", text: "Replacement", mentions: [] }], + }, + }), + ).rejects.toThrow( + "recorded under a provider session another thread announced at the same moment", + ); + expect( + listQueuedThreadCommands(harness, "thread.rewind.prepare", thread.id), + ).toHaveLength(0); + expect(listEvents(harness.db, { threadId: thread.id })).toEqual( + eventsBefore, + ); + }); + }); + it.each(["claude-code", "pi"] as const)( "rejects a %s rewind through legacy history without a checkpoint", async (providerId) => { diff --git a/apps/server/test/threads/thread-provider-identity-ownership.test.ts b/apps/server/test/threads/thread-provider-identity-ownership.test.ts new file mode 100644 index 0000000000..24739d8fd8 --- /dev/null +++ b/apps/server/test/threads/thread-provider-identity-ownership.test.ts @@ -0,0 +1,427 @@ +import { listEvents, listQueuedThreadMessages } from "@bb/db"; +import { turnScope, type PromptInput } from "@bb/domain"; +import { describe, expect, it, vi } from "vitest"; +import { ApiError } from "../../src/errors.js"; +import { runQueuedMessageDispatch } from "../../src/services/threads/queued-message-dispatch.js"; +import { sendThreadMessage } from "../../src/services/threads/thread-send.js"; +import { + listQueuedThreadCommands, + waitForQueuedCommand, +} from "../helpers/commands.js"; +import { registerHostRpcResponder } from "../helpers/host-rpc.js"; +import { textInput } from "../helpers/prompt-input.js"; +import { + seedEnvironment, + seedHostSession, + seedProjectWithSource, + seedQueuedMessage, + seedStoredEvent, + seedThread, + seedThreadIdentity, + seedThreadRuntimeState, +} from "../helpers/seed.js"; +import { withTestHarness, type TestAppHarness } from "../helpers/test-app.js"; + +const OWN_SESSION = "session-alpha"; +const FOREIGN_SESSION = "session-beta"; +const BATCH_AT = 1_787_775_164_121; + +function seedThreads(harness: TestAppHarness) { + const { host, session } = seedHostSession(harness.deps); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: "/tmp/identity-ownership", + }); + const environment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + path: "/tmp/identity-ownership", + status: "ready", + }); + const first = seedThread(harness.deps, { + environmentId: environment.id, + projectId: project.id, + status: "idle", + }); + const second = seedThread(harness.deps, { + environmentId: environment.id, + projectId: project.id, + status: "idle", + }); + return { environment, first, host, second, session }; +} + +function seedStampedTurn( + harness: TestAppHarness, + args: { + createdAt: number; + environmentId: string; + providerThreadId: string; + sequence: number; + threadId: string; + turnId: string; + }, +): void { + for (const [offset, type] of [ + [0, "turn/started"], + [1, "turn/completed"], + ] as const) { + seedStoredEvent(harness.deps, { + threadId: args.threadId, + environmentId: args.environmentId, + providerThreadId: args.providerThreadId, + createdAt: args.createdAt, + sequence: args.sequence + offset, + type, + scope: turnScope(args.turnId), + data: + type === "turn/completed" + ? { providerThreadId: args.providerThreadId, status: "completed" } + : { providerThreadId: args.providerThreadId }, + }); + } +} + +function seedArchivedConcurrentStartPair( + harness: TestAppHarness, + args: { environmentId: string; earlyId: string; lateId: string }, +): void { + seedThreadIdentity(harness.deps, { + threadId: args.earlyId, + environmentId: args.environmentId, + providerThreadId: "01a03fb4-1bb9-session-a", + createdAt: BATCH_AT, + sequence: 3, + }); + seedThreadIdentity(harness.deps, { + threadId: args.lateId, + environmentId: args.environmentId, + providerThreadId: "01a03fb4-1c1a-session-b", + createdAt: BATCH_AT, + sequence: 3, + }); + for (const threadId of [args.earlyId, args.lateId]) { + seedThreadIdentity(harness.deps, { + threadId, + environmentId: args.environmentId, + providerThreadId: "01a03fb4-1bb9-session-a", + createdAt: BATCH_AT, + sequence: 6, + }); + } + seedStampedTurn(harness, { + createdAt: BATCH_AT + 119, + environmentId: args.environmentId, + providerThreadId: "01a03fb4-1c1a-session-b", + sequence: 7, + threadId: args.earlyId, + turnId: "turn-early", + }); + seedStampedTurn(harness, { + createdAt: BATCH_AT + 119, + environmentId: args.environmentId, + providerThreadId: "01a03fb4-1bb9-session-a", + sequence: 7, + threadId: args.lateId, + turnId: "turn-late", + }); +} + +const CLEAR_COMMAND_INPUT: PromptInput[] = [ + { + type: "text", + text: "/clear", + mentions: [ + { + start: 0, + end: "/clear".length, + resource: { + kind: "command", + trigger: "/", + name: "clear", + source: "command", + origin: "builtin", + label: "clear", + argumentHint: null, + }, + }, + ], + }, +]; + +async function sendUserMessage( + harness: TestAppHarness, + args: { + environment: Parameters[1]["environment"]; + input?: PromptInput[]; + thread: Parameters[1]["thread"]; + }, +): Promise { + await sendThreadMessage(harness.deps, { + environment: args.environment, + payload: { + input: args.input ?? textInput("continue"), + mode: "start", + model: "gpt-5", + permissionMode: "full", + reasoningLevel: "medium", + serviceTier: "default", + }, + thread: args.thread, + trigger: "user", + }); +} + +async function expectProviderSessionUnavailable( + promise: Promise, + details: Record, +): Promise { + const error = await promise.then( + () => null, + (reason: unknown) => reason, + ); + expect(error).toBeInstanceOf(ApiError); + if (!(error instanceof ApiError)) { + return; + } + expect(error.status).toBe(409); + expect(error.body).toMatchObject({ + code: "provider_session_unavailable", + details, + }); + expect(error.body.message).toContain( + "Clear context (/clear or bb thread clear) for a new session; history is kept.", + ); +} + +describe("provider session ownership on dispatch", () => { + it("resumes a thread into its own session when later events carry another thread's session", async () => { + await withTestHarness(async (harness) => { + const { + environment, + first: owner, + second: contaminated, + } = seedThreads(harness); + seedThreadIdentity(harness.deps, { + threadId: owner.id, + environmentId: environment.id, + providerThreadId: FOREIGN_SESSION, + createdAt: BATCH_AT, + sequence: 1, + }); + seedThreadRuntimeState(harness.deps, { + environmentId: environment.id, + providerThreadId: OWN_SESSION, + threadId: contaminated.id, + }); + seedStampedTurn(harness, { + createdAt: BATCH_AT + 600_000, + environmentId: environment.id, + providerThreadId: FOREIGN_SESSION, + sequence: 3, + threadId: contaminated.id, + turnId: "turn-contaminated", + }); + seedThreadIdentity(harness.deps, { + threadId: contaminated.id, + environmentId: environment.id, + providerThreadId: FOREIGN_SESSION, + createdAt: BATCH_AT + 900_000, + sequence: 5, + }); + + await sendUserMessage(harness, { environment, thread: contaminated }); + + const queued = await waitForQueuedCommand( + harness, + (candidate) => + candidate.command.type === "turn.submit" && + candidate.command.threadId === contaminated.id, + ); + expect(queued.command).toMatchObject({ + type: "turn.submit", + resumeContext: { providerThreadId: OWN_SESSION }, + }); + expect( + listQueuedThreadCommands(harness, "thread.start", contaminated.id), + ).toHaveLength(0); + }); + }); + + it("refuses to resume a thread whose only session another thread claimed first, and starts fresh after /clear", async () => { + await withTestHarness(async (harness) => { + const { + environment, + first: owner, + host, + second: contaminated, + session, + } = seedThreads(harness); + seedThreadIdentity(harness.deps, { + threadId: owner.id, + environmentId: environment.id, + providerThreadId: FOREIGN_SESSION, + createdAt: BATCH_AT, + sequence: 1, + }); + seedThreadIdentity(harness.deps, { + threadId: contaminated.id, + environmentId: environment.id, + providerThreadId: FOREIGN_SESSION, + createdAt: BATCH_AT + 140, + sequence: 1, + }); + const responder = registerHostRpcResponder(harness, { + hostId: host.id, + sessionId: session.id, + handle: ({ command }) => { + switch (command.type) { + case "thread.stop": + return { ok: true, result: { providerCheckpointId: null } }; + case "thread.start": + return { + ok: true, + result: { providerThreadId: "session-fresh" }, + }; + case "turn.submit": + return { ok: true, result: { appliedAs: "new-turn" } }; + case "host.list_files": + return { ok: true, result: { files: [], truncated: false } }; + case "host.read_file": + return { + ok: false, + errorCode: "ENOENT", + errorMessage: "Missing", + }; + default: + throw new Error(`Unexpected command: ${command.type}`); + } + }, + }); + const eventsBefore = listEvents(harness.db, { + threadId: contaminated.id, + }); + + await expectProviderSessionUnavailable( + sendUserMessage(harness, { environment, thread: contaminated }), + { + reason: "foreign", + providerThreadId: FOREIGN_SESSION, + claimantThreadIds: [owner.id], + }, + ); + expect(listEvents(harness.db, { threadId: contaminated.id })).toEqual( + eventsBefore, + ); + expect( + responder.requests.filter( + ({ command }) => + "threadId" in command && command.threadId === contaminated.id, + ), + ).toEqual([]); + + await sendUserMessage(harness, { + environment, + input: CLEAR_COMMAND_INPUT, + thread: contaminated, + }); + await sendUserMessage(harness, { environment, thread: contaminated }); + const dispatchedFor = (threadId: string) => + responder.requests + .map(({ command }) => command) + .filter( + (command) => + (command.type === "thread.start" || + command.type === "turn.submit") && + command.threadId === threadId, + ); + await vi.waitFor(() => { + expect(dispatchedFor(contaminated.id)).toHaveLength(1); + }); + expect(dispatchedFor(contaminated.id)[0]).toMatchObject({ + type: "thread.start", + }); + + await sendUserMessage(harness, { environment, thread: owner }); + await vi.waitFor(() => { + expect(dispatchedFor(owner.id)).toEqual([ + expect.objectContaining({ + type: "turn.submit", + resumeContext: expect.objectContaining({ + providerThreadId: FOREIGN_SESSION, + }), + }), + ]); + }); + responder.unregister(); + }); + }); + + it("refuses both threads of a same-millisecond claim without dispatching or rewriting history", async () => { + await withTestHarness(async (harness) => { + const { environment, first: early, second: late } = seedThreads(harness); + seedArchivedConcurrentStartPair(harness, { + environmentId: environment.id, + earlyId: early.id, + lateId: late.id, + }); + const earlyEvents = listEvents(harness.db, { threadId: early.id }); + const lateEvents = listEvents(harness.db, { threadId: late.id }); + + await expectProviderSessionUnavailable( + sendUserMessage(harness, { environment, thread: early }), + { + reason: "ambiguous", + providerThreadId: "01a03fb4-1bb9-session-a", + claimantThreadIds: [late.id], + }, + ); + await expectProviderSessionUnavailable( + sendUserMessage(harness, { environment, thread: late }), + { + reason: "ambiguous", + providerThreadId: "01a03fb4-1bb9-session-a", + claimantThreadIds: [early.id], + }, + ); + for (const type of ["thread.start", "turn.submit"] as const) { + for (const threadId of [early.id, late.id]) { + expect(listQueuedThreadCommands(harness, type, threadId)).toEqual([]); + } + } + expect(listEvents(harness.db, { threadId: early.id })).toEqual( + earlyEvents, + ); + expect(listEvents(harness.db, { threadId: late.id })).toEqual(lateEvents); + }); + }); + + it("records the refusal on a queued message instead of starting a new session", async () => { + await withTestHarness(async (harness) => { + const { environment, first: early, second: late } = seedThreads(harness); + seedArchivedConcurrentStartPair(harness, { + environmentId: environment.id, + earlyId: early.id, + lateId: late.id, + }); + const queued = seedQueuedMessage(harness.deps, { + threadId: early.id, + content: textInput("Queued follow-up"), + }); + + await runQueuedMessageDispatch(harness.deps, { + kind: "thread-ready", + threadId: early.id, + }); + + const [row] = listQueuedThreadMessages(harness.db, early.id); + expect(row?.id).toBe(queued.id); + expect(row?.failureReason).toBe( + "Another thread claimed this thread's provider session in the same millisecond, so bb will not guess whose it is. Clear context (/clear or bb thread clear) for a new session; history is kept.", + ); + for (const type of ["thread.start", "turn.submit"] as const) { + expect(listQueuedThreadCommands(harness, type, early.id)).toEqual([]); + } + }); + }); +}); diff --git a/plugins/bb-guide/skills/bb-cli/references/failure-recovery.md b/plugins/bb-guide/skills/bb-cli/references/failure-recovery.md index 6292f44584..511a084da6 100644 --- a/plugins/bb-guide/skills/bb-cli/references/failure-recovery.md +++ b/plugins/bb-guide/skills/bb-cli/references/failure-recovery.md @@ -30,6 +30,12 @@ sendAt?, reason? })`. - Use `bb thread clear ` on an idle or failed thread to reset its active timeline and model context in place while keeping the same BB thread, workspace, durable event history, and sticky execution settings. +- A send that fails with `provider_session_unavailable` means the thread's + recorded provider session belongs to another thread (`details.reason: + "foreign"`) or was announced by another thread in the same millisecond + (`"ambiguous"`). bb refuses to resume it rather than write into another + conversation. `bb thread clear ` starts a new provider session on the + next send and keeps the thread's history. - Use `bb thread cancel-plan ` to exit an active Plan turn without optimistically clearing its banner. Use `bb thread clear-goal ` to clear a thread's durable active Goal when supported by its provider. Both wait for provider confirmation. From 98134d54bc1b140aecaac9908f140c2b30c0ba77 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 11 Sep 2026 21:49:50 +0000 Subject: [PATCH 3/9] Require bridges to announce unique, durable provider sessions bb persists a provider session only from `thread/identity`, resumes only sessions recorded that way, and now refuses a session another thread announced first or in the same millisecond. The kit checked that `thread/start` and `thread/fork` return `providerThreadId` on the result but never that the session is announced, so a third-party bridge that skipped the notification would pass conformance and start a new provider session on every send. `session/start-identity-announced` and `session/fork-identity-announced` require a `thread/identity` notification or `thread.identity` delta naming the returned session for that thread. Every in-repo and example bridge already passes, and their expected-status maps pin both checks. The protocol doc now states what resume already relied on: a `providerThreadId` is a durable handle bb sends to a new bridge process after a restart, so it must name one session among all of that provider's sessions on the host and never come from a per-process counter. Real bridges mint UUIDs, and the published echo example adds a random per-process nonce. The scripted test bridge keeps readable `prov-N` counters for single-process suites and gains `uniqueProviderThreadIds`, which the integration suite enables because it runs several bridge processes against one server store. Co-Authored-By: Claude Code --- docs/provider-bridge-protocol.md | 6 ++ .../provider-bridge.conformance.test.ts | 1 + .../bridge.conformance-calibration.test.ts | 2 + .../src/conformance/scenarios.ts | 92 ++++++++++++++++++- .../test/conformance-session-identity.test.ts | 72 ++++++++++++++- .../src/bridge/bridge.conformance.test.ts | 2 + .../src/bridge/bridge.conformance.test.ts | 2 + .../src/bridge/bridge.conformance.test.ts | 2 + tests/integration/vitest.config.ts | 1 + tests/scripted-echo-provider/README.md | 4 +- .../src/provider-bridge.ts | 10 +- 11 files changed, 185 insertions(+), 9 deletions(-) diff --git a/docs/provider-bridge-protocol.md b/docs/provider-bridge-protocol.md index e4841e3891..a0ae84349c 100644 --- a/docs/provider-bridge-protocol.md +++ b/docs/provider-bridge-protocol.md @@ -395,6 +395,12 @@ Three identifier families, three owners: | `providerThreadId` | the provider | Its session handle (rollout id, session id). Returned on the `thread/start`/`thread/resume`/`thread/fork` result (required) and echoed by `thread/identity`; never used to scope bb events directly. | | turn ids and item ids on `ThreadEvent`s | **the runtime's assembler** | Never the provider, never the bridge. | +A `providerThreadId` is a durable handle: bb persists it and sends it to a new +bridge process to resume the session after a restart. It must name exactly one +provider session among all of that provider's sessions on the host, so never +mint it from a per-process counter. bb refuses to resume a handle that another +thread announced first, or announced in the same millisecond. + The central-minting rule is the #1320 lesson made structural: a provider can inject arbitrary identifiers on its own wire, but the ids that reach bb's persistence are always minted by bb-owned assembler code. Bridges forward diff --git a/examples/plugins/echo-provider/provider-bridge.conformance.test.ts b/examples/plugins/echo-provider/provider-bridge.conformance.test.ts index 81338e3256..175eb94ebf 100644 --- a/examples/plugins/echo-provider/provider-bridge.conformance.test.ts +++ b/examples/plugins/echo-provider/provider-bridge.conformance.test.ts @@ -51,6 +51,7 @@ it("passes the canonical protocol suite", async () => { "rpc/response-not-request": "pass", "handshake/initialize": "pass", "session/start-identity": "pass", + "session/start-identity-announced": "pass", "turn/lifecycle": "pass", "events/schema-valid": "pass", "item/opens-before-delta": "pass", diff --git a/packages/provider-bridge-acp/src/bridge/bridge.conformance-calibration.test.ts b/packages/provider-bridge-acp/src/bridge/bridge.conformance-calibration.test.ts index b29cb5bf42..ce406ff9c8 100644 --- a/packages/provider-bridge-acp/src/bridge/bridge.conformance-calibration.test.ts +++ b/packages/provider-bridge-acp/src/bridge/bridge.conformance-calibration.test.ts @@ -70,6 +70,7 @@ it("passes the canonical protocol suite against the fake agent", async () => { "rpc/response-not-request": "pass", "handshake/initialize": "pass", "session/start-identity": "pass", + "session/start-identity-announced": "pass", "turn/lifecycle": "pass", "events/schema-valid": "pass", "item/opens-before-delta": "pass", @@ -77,6 +78,7 @@ it("passes the canonical protocol suite against the fake agent", async () => { "session/resume-identity": "pass", "session/resume-id-uniqueness": "pass", "session/fork-identity": "pass", + "session/fork-identity-announced": "pass", "turn/settles-without-activity": "pass", "session/threads-independent": "pass", "stop/interrupt-settles-before-result": "pass", diff --git a/packages/provider-bridge-protocol/src/conformance/scenarios.ts b/packages/provider-bridge-protocol/src/conformance/scenarios.ts index b485867aa9..56d495e194 100644 --- a/packages/provider-bridge-protocol/src/conformance/scenarios.ts +++ b/packages/provider-bridge-protocol/src/conformance/scenarios.ts @@ -8,6 +8,7 @@ import { import { z } from "zod"; import { BRIDGE_JSON_RPC_ERRORS, + BRIDGE_NOTIFICATION_METHODS, BRIDGE_REQUEST_METHODS, bridgeErrorDataSchema, initializeResultSchema, @@ -57,6 +58,65 @@ function identityProblem( .join("; ")} (got ${JSON.stringify(result)})`; } +const identityNotificationParamsSchema = z + .object({ threadId: z.string(), providerThreadId: z.string() }) + .passthrough(); + +async function checkIdentityAnnounced( + context: ScenarioContext, + args: { + id: string; + providerThreadId: string; + request: string; + threadId: string; + title: string; + }, +): Promise { + const { client } = context; + const announced = await client.waitFor(() => { + const notified = client + .notifications(BRIDGE_NOTIFICATION_METHODS.threadIdentity) + .some((message) => { + const params = identityNotificationParamsSchema.safeParse( + message.params, + ); + return ( + params.success && + params.data.threadId === args.threadId && + params.data.providerThreadId === args.providerThreadId + ); + }); + const assembled = threadEvents(context, args.threadId).some( + (event) => + event.type === "thread/identity" && + event.providerThreadId === args.providerThreadId, + ); + return notified || assembled ? true : undefined; + }); + if (announced === null) { + const named = client + .notifications(BRIDGE_NOTIFICATION_METHODS.threadIdentity) + .flatMap((message) => { + const params = identityNotificationParamsSchema.safeParse( + message.params, + ); + return params.success && params.data.threadId === args.threadId + ? [params.data.providerThreadId] + : []; + }); + return fail( + args.id, + args.title, + `${args.request} returned providerThreadId "${args.providerThreadId}" for thread "${args.threadId}", but no thread/identity notification or thread.identity delta announced it${ + named.length === 0 + ? "" + : ` (thread/identity named ${named.map((id) => `"${id}"`).join(", ")})` + }; bb persists and resumes only sessions announced this way, so every later send would start a new session`, + ); + } + return pass(args.id, args.title); +} + function pass(id: string, title: string): ConformanceCheckResult { return { id, title, status: "pass", detail: "" }; } @@ -410,6 +470,10 @@ async function runSkillsConfigureDeclaredScenario( ]; } +const START_IDENTITY_ANNOUNCED_ID = "session/start-identity-announced"; +const START_IDENTITY_ANNOUNCED_TITLE = + "thread/start announces the returned session through thread/identity"; + export async function runSessionLifecycleScenarios( context: ScenarioContext, ): Promise { @@ -455,6 +519,22 @@ export async function runSessionLifecycleScenarios( const startSkipDetail = "prerequisite session/start-identity failed"; + results.push( + context.providerThreadId === undefined + ? skipped( + START_IDENTITY_ANNOUNCED_ID, + START_IDENTITY_ANNOUNCED_TITLE, + startSkipDetail, + ) + : await checkIdentityAnnounced(context, { + id: START_IDENTITY_ANNOUNCED_ID, + providerThreadId: context.providerThreadId, + request: "thread/start", + threadId, + title: START_IDENTITY_ANNOUNCED_TITLE, + }), + ); + if (context.providerThreadId === undefined) { results.push( skipped( @@ -725,6 +805,9 @@ const RESUME_IDENTITY_TITLE = "thread/resume returns a provider thread identity"; const FORK_IDENTITY_ID = "session/fork-identity"; +const FORK_IDENTITY_ANNOUNCED_ID = "session/fork-identity-announced"; +const FORK_IDENTITY_ANNOUNCED_TITLE = + "thread/fork announces the forked session through thread/identity"; const FORK_IDENTITY_TITLE = "thread/fork returns a provider thread identity for the forked session"; @@ -782,6 +865,13 @@ async function runForkIdentityScenario( ), ]; } + const announced = await checkIdentityAnnounced(context, { + id: FORK_IDENTITY_ANNOUNCED_ID, + providerThreadId: parsed.data.providerThreadId, + request: "thread/fork", + threadId: forkThreadId, + title: FORK_IDENTITY_ANNOUNCED_TITLE, + }); const releaseId = client.request(BRIDGE_REQUEST_METHODS.threadStop, { threadId: forkThreadId, providerThreadId: parsed.data.providerThreadId, @@ -789,7 +879,7 @@ async function runForkIdentityScenario( activeTurnId: null, }); await client.waitForResponse(releaseId); - return [pass(FORK_IDENTITY_ID, FORK_IDENTITY_TITLE)]; + return [pass(FORK_IDENTITY_ID, FORK_IDENTITY_TITLE), announced]; } const THREADS_INDEPENDENT_ID = "session/threads-independent"; diff --git a/packages/provider-bridge-protocol/test/conformance-session-identity.test.ts b/packages/provider-bridge-protocol/test/conformance-session-identity.test.ts index 09e2da1f6f..51ee150433 100644 --- a/packages/provider-bridge-protocol/test/conformance-session-identity.test.ts +++ b/packages/provider-bridge-protocol/test/conformance-session-identity.test.ts @@ -86,6 +86,7 @@ interface StubBridgeOptions { fork: "none" | "tip"; blankResume?: boolean; blankFork?: boolean; + announceIdentity?: "match" | "none" | "other-id"; } interface StubBridge { @@ -110,9 +111,26 @@ function stubBridge(options: StubBridgeOptions): StubBridge { params: { threadId, deltas }, }); }; - const identity = (): { providerThreadId: string } => { + const identity = ( + threadId: string | undefined, + ): { providerThreadId: string } => { sessions += 1; - return { providerThreadId: `prov-${sessions}` }; + const providerThreadId = `prov-${sessions}`; + const announce = options.announceIdentity ?? "match"; + if (announce !== "none") { + emitted.push({ + jsonrpc: "2.0", + method: "thread/identity", + params: { + threadId, + providerThreadId: + announce === "match" + ? providerThreadId + : `${providerThreadId}-other`, + }, + }); + } + return { providerThreadId }; }; return { forks, @@ -144,11 +162,12 @@ function stubBridge(options: StubBridgeOptions): StubBridge { }); return; case BRIDGE_REQUEST_METHODS.threadStart: - reply(message.id, { result: identity() }); + reply(message.id, { result: identity(params.threadId) }); return; case BRIDGE_REQUEST_METHODS.threadResume: reply(message.id, { - result: options.blankResume === true ? {} : identity(), + result: + options.blankResume === true ? {} : identity(params.threadId), }); return; case BRIDGE_REQUEST_METHODS.threadFork: @@ -163,7 +182,8 @@ function stubBridge(options: StubBridgeOptions): StubBridge { return; } reply(message.id, { - result: options.blankFork === true ? {} : identity(), + result: + options.blankFork === true ? {} : identity(params.threadId), }); return; case BRIDGE_REQUEST_METHODS.threadStop: @@ -289,6 +309,48 @@ describe("conformance session/start-identity", () => { }); }); +describe("conformance session/start-identity-announced", () => { + it("fails a bridge that returns a session but never announces it", async () => { + const { report } = await runStub({ fork: "tip", announceIdentity: "none" }); + const results = byId(report); + expect(results.get("session/start-identity")?.status).toBe("pass"); + expect(results.get("session/start-identity-announced")).toMatchObject({ + status: "fail", + detail: expect.stringContaining( + 'thread/start returned providerThreadId "prov-1" for thread "thr_conformance_1", but no thread/identity notification or thread.identity delta announced it', + ), + }); + expect(results.get("session/fork-identity")?.status).toBe("pass"); + expect(results.get("session/fork-identity-announced")?.status).toBe("fail"); + expect(failedIds(report)).toEqual([ + "session/start-identity-announced", + "session/fork-identity-announced", + ]); + expect(report.passed).toBe(false); + }); + + it("fails a bridge whose thread/identity names a different session than the result", async () => { + const { report } = await runStub({ + fork: "none", + announceIdentity: "other-id", + }); + expect( + byId(report).get("session/start-identity-announced")?.detail, + ).toContain('(thread/identity named "prov-1-other")'); + expect(failedIds(report)).toEqual(["session/start-identity-announced"]); + }); + + it("passes a bridge that announces the returned session", async () => { + const { report } = await runStub({ fork: "tip" }); + const results = byId(report); + expect(results.get("session/start-identity-announced")?.status).toBe( + "pass", + ); + expect(results.get("session/fork-identity-announced")?.status).toBe("pass"); + expect(report.passed).toBe(true); + }); +}); + describe("conformance session/resume-identity", () => { it("names the missing providerThreadId when thread/resume answers without one", async () => { const { report } = await runStub({ fork: "none", blankResume: true }); diff --git a/plugins/provider-claude-code/src/bridge/bridge.conformance.test.ts b/plugins/provider-claude-code/src/bridge/bridge.conformance.test.ts index 6019ece381..adb07c2ba6 100644 --- a/plugins/provider-claude-code/src/bridge/bridge.conformance.test.ts +++ b/plugins/provider-claude-code/src/bridge/bridge.conformance.test.ts @@ -204,6 +204,7 @@ it("passes the canonical protocol suite against the scripted claude session", as "handshake/initialize": "pass", "skills/configure-declared": "pass", "session/start-identity": "pass", + "session/start-identity-announced": "pass", "turn/lifecycle": "pass", "events/schema-valid": "pass", "item/opens-before-delta": "pass", @@ -211,6 +212,7 @@ it("passes the canonical protocol suite against the scripted claude session", as "session/resume-identity": "pass", "session/resume-id-uniqueness": "pass", "session/fork-identity": "pass", + "session/fork-identity-announced": "pass", "turn/settles-without-activity": "pass", }); diff --git a/plugins/provider-codex/src/bridge/bridge.conformance.test.ts b/plugins/provider-codex/src/bridge/bridge.conformance.test.ts index 0d3c5d52dc..59704f7e62 100644 --- a/plugins/provider-codex/src/bridge/bridge.conformance.test.ts +++ b/plugins/provider-codex/src/bridge/bridge.conformance.test.ts @@ -63,6 +63,7 @@ it("passes the canonical protocol suite against supervised fake app-server child "handshake/initialize": "pass", "skills/configure-declared": "pass", "session/start-identity": "pass", + "session/start-identity-announced": "pass", "turn/lifecycle": "pass", "events/schema-valid": "pass", "item/opens-before-delta": "pass", @@ -70,6 +71,7 @@ it("passes the canonical protocol suite against supervised fake app-server child "session/resume-identity": "pass", "session/resume-id-uniqueness": "pass", "session/fork-identity": "pass", + "session/fork-identity-announced": "pass", "turn/settles-without-activity": "pass", "recovery/session-archived": "pass", "session/threads-independent": "pass", diff --git a/plugins/provider-pi/src/bridge/bridge.conformance.test.ts b/plugins/provider-pi/src/bridge/bridge.conformance.test.ts index dc68e4baba..fc67513cc4 100644 --- a/plugins/provider-pi/src/bridge/bridge.conformance.test.ts +++ b/plugins/provider-pi/src/bridge/bridge.conformance.test.ts @@ -45,6 +45,7 @@ it("passes the canonical protocol suite against a scripted pi rpc child", async "rpc/response-not-request": "pass", "handshake/initialize": "pass", "session/start-identity": "pass", + "session/start-identity-announced": "pass", "turn/lifecycle": "pass", "events/schema-valid": "pass", "item/opens-before-delta": "pass", @@ -52,6 +53,7 @@ it("passes the canonical protocol suite against a scripted pi rpc child", async "session/resume-identity": "pass", "session/resume-id-uniqueness": "pass", "session/fork-identity": "pass", + "session/fork-identity-announced": "pass", "session/threads-independent": "pass", "stop/interrupt-settles-before-result": "pass", }); diff --git a/tests/integration/vitest.config.ts b/tests/integration/vitest.config.ts index 7017859f3b..b202563f8c 100644 --- a/tests/integration/vitest.config.ts +++ b/tests/integration/vitest.config.ts @@ -14,6 +14,7 @@ export default defineWorkspaceTestConfig({ BB_SERVER_PORT: "49161", BB_SERVER_URL: "http://127.0.0.1:49161", BB_HOST_DAEMON_PORT: "49162", + SCRIPTED_ECHO_OPTIONS: JSON.stringify({ uniqueProviderThreadIds: true }), }, silent: "passed-only", testTimeout: Math.ceil(60_000 * timeoutScale), diff --git a/tests/scripted-echo-provider/README.md b/tests/scripted-echo-provider/README.md index 14db6c5fa6..9ce2a79758 100644 --- a/tests/scripted-echo-provider/README.md +++ b/tests/scripted-echo-provider/README.md @@ -22,7 +22,9 @@ Session- and process-level behaviour (archived sessions, failing commands, crashes, slow starts) is scripted through `ScriptedEchoOptions`: set `options.providerOptions.scripted` on a bridge launch, or the `SCRIPTED_ECHO_OPTIONS` env JSON for behaviour that must apply before any -session exists. `SCRIPTED_ECHO_RECORD_PATH` appends every handled request to +session exists. Session ids default to per-process counters (`prov-1`); +`uniqueProviderThreadIds` appends a per-process nonce so bridges in several +environments on one host mint distinct, durable handles, as real providers do. `SCRIPTED_ECHO_RECORD_PATH` appends every handled request to a JSONL file for assertions on what reached the provider. The integration harness builds `host.ts` into an artifact exactly as the diff --git a/tests/scripted-echo-provider/src/provider-bridge.ts b/tests/scripted-echo-provider/src/provider-bridge.ts index b8f3f76f88..b51143a554 100644 --- a/tests/scripted-echo-provider/src/provider-bridge.ts +++ b/tests/scripted-echo-provider/src/provider-bridge.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { type ClientTurnRequestId, type PendingInteractionPayload, @@ -96,6 +97,7 @@ export const scriptedEchoOptionsSchema = z recoveryThreadIdHint: z.string().min(1).optional(), approvalEnforcedBy: z.enum(["runtime", "provider"]).optional(), identifyProcess: z.boolean().optional(), + uniqueProviderThreadIds: z.boolean().optional(), failStopForThreadIds: z.array(z.string().min(1)).optional(), emitIdentityOnSigterm: z.boolean().optional(), }) @@ -186,6 +188,7 @@ const openBackgroundTasks = new Map< >(); let discardFailed = false; let providerThreadCounter = 0; +const providerThreadNonce = randomUUID().slice(0, 8); let outboundRequestCounter = 0; type OutboundMessage = { jsonrpc: "2.0" } & Record; @@ -824,8 +827,11 @@ function notifySessionIdentity(session: Session): void { function mintProviderThreadId(options: ScriptedEchoOptions): string { providerThreadCounter += 1; - return options.identifyProcess === true - ? `prov-${process.pid}-${providerThreadCounter}` + if (options.identifyProcess === true) { + return `prov-${process.pid}-${providerThreadCounter}`; + } + return options.uniqueProviderThreadIds === true + ? `prov-${providerThreadCounter}-${providerThreadNonce}` : `prov-${providerThreadCounter}`; } From 173193fe0015b9c6f63060c92245ef4d364f09ff Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Sat, 12 Sep 2026 00:34:25 +0000 Subject: [PATCH 4/9] Resolve a thread's provider session with bounded index seeks Session resolution loaded every thread/identity event since the last context clear and deduplicated them in JavaScript. Codex announces its session on every turn, so the cost grew with turn count: 3.9 ms per lookup on a production thread with 2,464 identity events, against 0.06 ms for the previous LIMIT 1 query. Resolution now reads the newest identity event (LIMIT 1), then finds the session's earliest claim with a single statement whose ORDER BY created_at LIMIT 1 subquery seeks the partial identity index. It reads further back only when the newest session belongs to another thread. The ownership semantics are unchanged: on a copy of the production store the result matched the previous resolver for all 4,812 threads and 9,620 claim checks, and a lookup takes about 0.25 ms regardless of history length. The query-plan test now pins two statements, each using an index, for a thread with a long identity history. Co-Authored-By: Claude Code --- packages/db/src/data/events.ts | 284 +++++++++++---------------- packages/db/test/query-plans.test.ts | 23 ++- 2 files changed, 131 insertions(+), 176 deletions(-) diff --git a/packages/db/src/data/events.ts b/packages/db/src/data/events.ts index 06628c4ae6..ba2c28d10b 100644 --- a/packages/db/src/data/events.ts +++ b/packages/db/src/data/events.ts @@ -3491,17 +3491,6 @@ interface StoredProviderThreadIdentityScope { threadId: string; } -interface StoredProviderThreadIdentityCandidate { - providerThreadId: string; - threadId: string; -} - -interface ListStoredProviderThreadClaimsArgs { - hostId: string | null; - providerId: string; - providerThreadIds: readonly string[]; -} - interface ResolveStoredProviderSessionsArgs { threadIds: readonly string[]; } @@ -3535,15 +3524,6 @@ export type StoredProviderThreadClaimClass = | "foreign" | "ambiguous"; -const storedProviderThreadIdentityEpochFilter = sql`${events.sequence} > COALESCE(( - SELECT MAX(context_clear.sequence) - FROM events AS context_clear - WHERE context_clear.thread_id = ${events.threadId} - AND context_clear.type = 'system/operation' - AND json_extract(context_clear.data, '$.operation') = ${THREAD_CONTEXT_CLEAR_OPERATION} - AND json_extract(context_clear.data, '$.status') = 'completed' -), 0)`; - function listStoredProviderThreadIdentityScopes( db: DbQueryConnection, threadIds: readonly string[], @@ -3560,88 +3540,57 @@ function listStoredProviderThreadIdentityScopes( .all(); } -function listStoredProviderThreadIdentityCandidates( +function readStoredProviderThreadClaim( db: DbQueryConnection, - threadIds: readonly string[], -): StoredProviderThreadIdentityCandidate[] { - return db - .select({ - providerThreadId: sql`${events.providerThreadId}`, - threadId: events.threadId, - }) - .from(events) - .where( - and( - inArray(events.threadId, [...threadIds]), - eq(events.type, "thread/identity"), - isNotNull(events.providerThreadId), - storedProviderThreadIdentityEpochFilter, - ), - ) - .orderBy(events.threadId, desc(events.sequence)) - .all(); -} - -function listStoredProviderThreadClaims( - db: DbQueryConnection, - args: ListStoredProviderThreadClaimsArgs, -): Map { - const claims = new Map(); - if (args.providerThreadIds.length === 0) { - return claims; - } - const rows = db - .select({ - firstClaimedAt: sql`MIN(${events.createdAt})`, - providerThreadId: sql`${events.providerThreadId}`, - threadId: events.threadId, - }) + scope: StoredProviderThreadIdentityScope, + providerThreadId: string, +): StoredProviderThreadClaim | undefined { + const earliestHostCondition = + scope.hostId === null + ? sql`1` + : sql`(earliest_environment.host_id IS NULL OR earliest_environment.host_id = ${scope.hostId})`; + const claimantThreadIds = db + .selectDistinct({ threadId: events.threadId }) .from(events) .innerJoin(threads, eq(threads.id, events.threadId)) .leftJoin(environments, eq(environments.id, threads.environmentId)) .where( and( eq(events.type, "thread/identity"), - inArray(events.providerThreadId, [...args.providerThreadIds]), - eq(threads.providerId, args.providerId), - args.hostId === null + eq(events.providerThreadId, providerThreadId), + eq(threads.providerId, scope.providerId), + scope.hostId === null ? undefined : or( isNull(environments.hostId), - eq(environments.hostId, args.hostId), + eq(environments.hostId, scope.hostId), ), + sql`${events.createdAt} = ( + SELECT earliest.created_at + FROM events AS earliest + INNER JOIN threads AS earliest_thread + ON earliest_thread.id = earliest.thread_id + LEFT JOIN environments AS earliest_environment + ON earliest_environment.id = earliest_thread.environment_id + WHERE earliest.type = 'thread/identity' + AND earliest.provider_thread_id = ${providerThreadId} + AND earliest_thread.provider_id = ${scope.providerId} + AND ${earliestHostCondition} + ORDER BY earliest.created_at + LIMIT 1 + )`, ), ) - .groupBy(events.providerThreadId, events.threadId) - .all(); - const earliestByProviderThreadId = new Map< - string, - { firstClaimedAt: number; threadIds: string[] } - >(); - for (const row of rows) { - const earliest = earliestByProviderThreadId.get(row.providerThreadId); - if (earliest === undefined || row.firstClaimedAt < earliest.firstClaimedAt) { - earliestByProviderThreadId.set(row.providerThreadId, { - firstClaimedAt: row.firstClaimedAt, - threadIds: [row.threadId], - }); - } else if (row.firstClaimedAt === earliest.firstClaimedAt) { - earliest.threadIds.push(row.threadId); - } - } - for (const [providerThreadId, earliest] of earliestByProviderThreadId) { - const [owner, ...others] = earliest.threadIds; - if (owner === undefined) { - continue; - } - claims.set( - providerThreadId, - others.length === 0 - ? { kind: "owned", threadId: owner } - : { kind: "tied", threadIds: [...earliest.threadIds].sort() }, - ); + .orderBy(events.threadId) + .all() + .map((row) => row.threadId); + const [owner, ...others] = claimantThreadIds; + if (owner === undefined) { + return undefined; } - return claims; + return others.length === 0 + ? { kind: "owned", threadId: owner } + : { kind: "tied", threadIds: claimantThreadIds }; } function classifyClaim( @@ -3668,15 +3617,74 @@ function otherClaimants( return claimants.filter((claimant) => claimant !== threadId); } -function resolveThreadProviderSession(args: { - candidates: readonly string[]; - claims: ReadonlyMap; - threadId: string; -}): StoredProviderSession { +function readNewestStoredProviderThreadIdentity( + db: DbQueryConnection, + args: { + excludedProviderThreadIds: readonly string[]; + threadId: string; + }, +): (StoredProviderThreadIdentityScope & { providerThreadId: string }) | null { + const row = db + .select({ + hostId: environments.hostId, + providerId: threads.providerId, + providerThreadId: events.providerThreadId, + }) + .from(events) + .innerJoin(threads, eq(threads.id, events.threadId)) + .leftJoin(environments, eq(environments.id, threads.environmentId)) + .where( + and( + eq(events.threadId, args.threadId), + eq(events.type, "thread/identity"), + isNotNull(events.providerThreadId), + sql`${events.sequence} > COALESCE(( + SELECT MAX(context_clear.sequence) + FROM events AS context_clear + WHERE context_clear.thread_id = ${args.threadId} + AND context_clear.type = 'system/operation' + AND json_extract(context_clear.data, '$.operation') = ${THREAD_CONTEXT_CLEAR_OPERATION} + AND json_extract(context_clear.data, '$.status') = 'completed' + ), 0)`, + args.excludedProviderThreadIds.length === 0 + ? undefined + : notInArray(events.providerThreadId, [ + ...args.excludedProviderThreadIds, + ]), + ), + ) + .orderBy(desc(events.sequence)) + .limit(1) + .get(); + if (row === undefined || row.providerThreadId === null) { + return null; + } + return { + hostId: row.hostId, + providerId: row.providerId, + providerThreadId: row.providerThreadId, + threadId: args.threadId, + }; +} + +function resolveThreadProviderSession( + db: DbQueryConnection, + threadId: string, +): StoredProviderSession { + const visited: string[] = []; let firstForeign: StoredProviderSession | null = null; - for (const providerThreadId of args.candidates) { - const claim = args.claims.get(providerThreadId); - switch (classifyClaim(claim, args.threadId)) { + for (;;) { + const identity = readNewestStoredProviderThreadIdentity(db, { + excludedProviderThreadIds: visited, + threadId, + }); + if (identity === null) { + return firstForeign ?? { kind: "none" }; + } + const { providerThreadId } = identity; + visited.push(providerThreadId); + const claim = readStoredProviderThreadClaim(db, identity, providerThreadId); + switch (classifyClaim(claim, threadId)) { case "owned": case "unannounced": return { kind: "owned", providerThreadId }; @@ -3684,91 +3692,37 @@ function resolveThreadProviderSession(args: { return { kind: "ambiguous", providerThreadId, - claimantThreadIds: otherClaimants(claim, args.threadId), + claimantThreadIds: otherClaimants(claim, threadId), }; case "foreign": firstForeign ??= { kind: "foreign", providerThreadId, - claimantThreadIds: otherClaimants(claim, args.threadId), + claimantThreadIds: otherClaimants(claim, threadId), }; } } - return firstForeign ?? { kind: "none" }; -} - -function identityScopeKey(scope: StoredProviderThreadIdentityScope): string { - return `${scope.providerId}\n${scope.hostId ?? ""}`; } export function resolveStoredProviderSessions( db: DbQueryConnection, args: ResolveStoredProviderSessionsArgs, ): Map { - const threadIds = [...new Set(args.threadIds)]; - const resolved = new Map( - threadIds.map((threadId) => [threadId, { kind: "none" }]), - ); - if (threadIds.length === 0) { - return resolved; - } - const candidatesByThreadId = new Map(); - for (const row of listStoredProviderThreadIdentityCandidates(db, threadIds)) { - const candidates = candidatesByThreadId.get(row.threadId) ?? []; - if (candidates.at(-1) !== row.providerThreadId) { - candidates.push(row.providerThreadId); - } - candidatesByThreadId.set(row.threadId, candidates); - } - if (candidatesByThreadId.size === 0) { - return resolved; - } - const scopeGroups = new Map(); - for (const scope of listStoredProviderThreadIdentityScopes(db, [ - ...candidatesByThreadId.keys(), - ])) { - const key = identityScopeKey(scope); - scopeGroups.set(key, [...(scopeGroups.get(key) ?? []), scope]); - } - for (const group of scopeGroups.values()) { - const [first] = group; - if (first === undefined) { - continue; - } - const claims = listStoredProviderThreadClaims(db, { - hostId: first.hostId, - providerId: first.providerId, - providerThreadIds: [ - ...new Set( - group.flatMap( - (scope) => candidatesByThreadId.get(scope.threadId) ?? [], - ), - ), + return new Map( + [...new Set(args.threadIds)].map( + (threadId): [string, StoredProviderSession] => [ + threadId, + resolveThreadProviderSession(db, threadId), ], - }); - for (const scope of group) { - resolved.set( - scope.threadId, - resolveThreadProviderSession({ - candidates: candidatesByThreadId.get(scope.threadId) ?? [], - claims, - threadId: scope.threadId, - }), - ); - } - } - return resolved; + ), + ); } export function getStoredProviderSession( db: DbQueryConnection, threadId: string, ): StoredProviderSession { - return ( - resolveStoredProviderSessions(db, { threadIds: [threadId] }).get( - threadId, - ) ?? { kind: "none" } - ); + return resolveThreadProviderSession(db, threadId); } export function getLastStoredProviderThreadId( @@ -3787,12 +3741,10 @@ export function classifyStoredProviderThreadClaim( if (scope === undefined) { return "foreign"; } - const claims = listStoredProviderThreadClaims(db, { - hostId: scope.hostId, - providerId: scope.providerId, - providerThreadIds: [args.providerThreadId], - }); - return classifyClaim(claims.get(args.providerThreadId), args.threadId); + return classifyClaim( + readStoredProviderThreadClaim(db, scope, args.providerThreadId), + args.threadId, + ); } export function listThreadTurnInterruptionEventStates( diff --git a/packages/db/test/query-plans.test.ts b/packages/db/test/query-plans.test.ts index 096efed6b5..430d86f596 100644 --- a/packages/db/test/query-plans.test.ts +++ b/packages/db/test/query-plans.test.ts @@ -242,31 +242,34 @@ function assertEmittedQueryPlanUsesIndex( } describe("slow query index plans", () => { - it("looks up provider thread owners through the identity index", () => { + it("resolves a provider session with two indexed lookups regardless of history length", () => { const { db, thread } = setup(); try { - insertEvents(db, noopNotifier, [ - { + insertEvents( + db, + noopNotifier, + Array.from({ length: 50 }, (_, index) => ({ threadId: thread.id, - sequence: 1, + sequence: index + 1, scope: threadScope(), providerThreadId: "provider-owner-plan", - type: "thread/identity", + type: "thread/identity" as const, itemId: null, itemKind: null, parentToolCallId: null, data: JSON.stringify({ providerThreadId: "provider-owner-plan" }), - }, - ]); + })), + ); const captured = captureStatements(db, () => { expect(getLastStoredProviderThreadId(db, thread.id)).toBe( "provider-owner-plan", ); }); - const ownerQuery = captured.find((statement) => - statement.sql.includes('inner join "threads"'), + expect(captured).toHaveLength(2); + const [identityQuery, ownerQuery] = captured; + expect(queryPlanDetails({ db, ...identityQuery! })).toContain( + "USING INDEX events_thread_type_sequence_idx", ); - expect(ownerQuery).toBeDefined(); expect(queryPlanDetails({ db, ...ownerQuery! })).toContain( "USING INDEX events_provider_identity_idx", ); From 259217a331762b95c3dda202f5af60f3a5102813 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Wed, 16 Sep 2026 19:57:51 +0000 Subject: [PATCH 5/9] Guard edits that would erase shared provider session ownership --- .../services/threads/thread-edit-message.ts | 13 +++- packages/db/src/data/events.ts | 65 +++++++++++++++++++ packages/db/src/data/index.ts | 1 + packages/db/test/data/events.test.ts | 30 +++++++++ 4 files changed, 108 insertions(+), 1 deletion(-) diff --git a/apps/server/src/services/threads/thread-edit-message.ts b/apps/server/src/services/threads/thread-edit-message.ts index b68d12f407..411386531f 100644 --- a/apps/server/src/services/threads/thread-edit-message.ts +++ b/apps/server/src/services/threads/thread-edit-message.ts @@ -9,6 +9,7 @@ import { hasQueuedThreadMessages, hasRootStoredTurnStarted, classifyStoredProviderThreadClaim, + wouldRemoveSharedProviderSessionClaim, listActiveBackgroundTaskCountsByThreadIds, type DbQueryConnection, } from "@bb/db"; @@ -303,10 +304,20 @@ function resolveEditableTurnCandidate( if (precedingTurnId !== null && precedingProviderCheckpoint === null) { conflict("This earlier provider turn has no editable history checkpoint"); } + const oldMaxSequence = getHighWaterMarks(db, [thread.id])[thread.id] ?? 0; + if (wouldRemoveSharedProviderSessionClaim(db, { + cutoffSequence: requestRow.sequence, + oldMaxSequence, + threadId: thread.id, + })) { + conflict( + "Editing this message would erase provider session ownership shared with another thread. Clear context (/clear or bb thread clear) for a new session; history is kept.", + ); + } return { leadingAgentOnlyInput: getLeadingAgentOnlyInput(request.input), currentTurnId: accepted.turnId, - oldMaxSequence: getHighWaterMarks(db, [thread.id])[thread.id] ?? 0, + oldMaxSequence, precedingProviderCheckpoint, requestSequence: requestRow.sequence, sourceProviderThreadId: diff --git a/packages/db/src/data/events.ts b/packages/db/src/data/events.ts index ba2c28d10b..08b7f950a6 100644 --- a/packages/db/src/data/events.ts +++ b/packages/db/src/data/events.ts @@ -3733,6 +3733,71 @@ export function getLastStoredProviderThreadId( return session.kind === "owned" ? session.providerThreadId : null; } +export function wouldRemoveSharedProviderSessionClaim( + db: DbQueryConnection, + args: DeleteThreadEventSuffixArgs, +): boolean { + const [scope] = listStoredProviderThreadIdentityScopes(db, [args.threadId]); + if (scope === undefined) return false; + const removedClaims = db + .select({ + providerThreadId: events.providerThreadId, + createdAt: sql`min(${events.createdAt})`, + }) + .from(events) + .where( + and( + eq(events.threadId, args.threadId), + eq(events.type, "thread/identity"), + isNotNull(events.providerThreadId), + gte(events.sequence, args.cutoffSequence), + lte(events.sequence, args.oldMaxSequence), + ), + ) + .groupBy(events.providerThreadId) + .all(); + for (const removed of removedClaims) { + if (removed.providerThreadId === null) continue; + const claim = readStoredProviderThreadClaim(db, scope, removed.providerThreadId); + if (classifyClaim(claim, args.threadId) === "foreign") continue; + const retained = db + .select({ id: events.id }) + .from(events) + .where( + and( + eq(events.threadId, args.threadId), + eq(events.type, "thread/identity"), + eq(events.providerThreadId, removed.providerThreadId), + lte(events.createdAt, removed.createdAt), + or(lt(events.sequence, args.cutoffSequence), gt(events.sequence, args.oldMaxSequence)), + ), + ) + .limit(1) + .get(); + if (retained !== undefined) continue; + const other = db + .select({ id: events.id }) + .from(events) + .innerJoin(threads, eq(threads.id, events.threadId)) + .leftJoin(environments, eq(environments.id, threads.environmentId)) + .where( + and( + eq(events.type, "thread/identity"), + eq(events.providerThreadId, removed.providerThreadId), + sql`${events.threadId} != ${args.threadId}`, + eq(threads.providerId, scope.providerId), + scope.hostId === null + ? undefined + : or(isNull(environments.hostId), eq(environments.hostId, scope.hostId)), + ), + ) + .limit(1) + .get(); + if (other !== undefined) return true; + } + return false; +} + export function classifyStoredProviderThreadClaim( db: DbQueryConnection, args: ClassifyStoredProviderThreadClaimArgs, diff --git a/packages/db/src/data/index.ts b/packages/db/src/data/index.ts index 5d5dd73220..84d7803568 100644 --- a/packages/db/src/data/index.ts +++ b/packages/db/src/data/index.ts @@ -275,6 +275,7 @@ export { hasRootStoredTurnStarted, hasStoredTurnStarted, classifyStoredProviderThreadClaim, + wouldRemoveSharedProviderSessionClaim, getLastStoredProviderThreadId, getStoredProviderSession, resolveStoredProviderSessions, diff --git a/packages/db/test/data/events.test.ts b/packages/db/test/data/events.test.ts index a3e790416f..fb7731c988 100644 --- a/packages/db/test/data/events.test.ts +++ b/packages/db/test/data/events.test.ts @@ -15,6 +15,7 @@ import { noopNotifier } from "../../src/notifier.js"; import type { DbNotifier } from "../../src/notifier.js"; import { appendDaemonEventsInTransaction, + wouldRemoveSharedProviderSessionClaim, appendStoredThreadEvent, appendStoredThreadEventInTransaction, appendStoredThreadEventsInTransaction, @@ -6036,6 +6037,35 @@ describe("stored provider thread identity ownership", () => { db.$client.close(); }); + it("detects edits that would erase the original shared ownership claim", () => { + const { db, threadIds } = setupThreads(); + const [owner, contaminated] = requireThreadIds(threadIds); + announceIdentity(db, { + createdAt: 1_787_775_164_121, + providerThreadId: "session-shared", + threadId: owner, + }); + announceIdentity(db, { + createdAt: 1_787_775_164_240, + providerThreadId: "session-shared", + threadId: contaminated, + }); + expect(getStoredProviderSession(db, contaminated).kind).toBe("foreign"); + + expect(wouldRemoveSharedProviderSessionClaim(db, { + cutoffSequence: 1, + oldMaxSequence: 1, + threadId: owner, + })).toBe(true); + + expect(getStoredProviderSession(db, contaminated)).toEqual({ + kind: "foreign", + providerThreadId: "session-shared", + claimantThreadIds: [owner], + }); + db.$client.close(); + }); + it("scopes claims to threads of the same provider on the same host", () => { const { db, host, project, threadIds } = setupThreads({ providerIds: ["codex", "claude-code"], From 2fa3e9dff45f33009c9074ea29f297725f729e19 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Wed, 16 Sep 2026 19:57:51 +0000 Subject: [PATCH 6/9] Resolve integration bridge manifest after test temp isolation --- .../src/test/integration-global-setup.ts | 4 ++-- .../src/test/integration-provider-bridges.ts | 11 +++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/agent-runtime/src/test/integration-global-setup.ts b/packages/agent-runtime/src/test/integration-global-setup.ts index 62d071fff0..288f68bc16 100644 --- a/packages/agent-runtime/src/test/integration-global-setup.ts +++ b/packages/agent-runtime/src/test/integration-global-setup.ts @@ -9,7 +9,7 @@ import { firstPartyPluginRootDir, } from "./first-party-provider-declarations.js"; import { - INTEGRATION_PROVIDER_BRIDGE_MANIFEST_PATH, + integrationProviderBridgeManifestPath, type IntegrationProviderBridgeManifest, } from "./integration-provider-bridges.js"; @@ -67,7 +67,7 @@ export async function setup(): Promise { } } await writeFile( - INTEGRATION_PROVIDER_BRIDGE_MANIFEST_PATH, + integrationProviderBridgeManifestPath(), JSON.stringify(manifest, null, 2), ); } diff --git a/packages/agent-runtime/src/test/integration-provider-bridges.ts b/packages/agent-runtime/src/test/integration-provider-bridges.ts index 92a89b6613..f6a4fab62e 100644 --- a/packages/agent-runtime/src/test/integration-provider-bridges.ts +++ b/packages/agent-runtime/src/test/integration-provider-bridges.ts @@ -9,10 +9,9 @@ import { } from "@bb/domain"; import type { AgentRuntimeBridgeLaunch } from "../types.js"; -export const INTEGRATION_PROVIDER_BRIDGE_MANIFEST_PATH = join( - tmpdir(), - "bb-agent-runtime-integration-provider-bridges.json", -); +export function integrationProviderBridgeManifestPath(): string { + return join(tmpdir(), "bb-agent-runtime-integration-provider-bridges.json"); +} const bridgeLaunchSchema = z.object({ pluginId: z.string(), @@ -46,10 +45,10 @@ function readManifest(): IntegrationProviderBridgeManifest { } let raw: string; try { - raw = readFileSync(INTEGRATION_PROVIDER_BRIDGE_MANIFEST_PATH, "utf8"); + raw = readFileSync(integrationProviderBridgeManifestPath(), "utf8"); } catch { throw new Error( - `No provider bridge manifest at ${INTEGRATION_PROVIDER_BRIDGE_MANIFEST_PATH}. ` + + `No provider bridge manifest at ${integrationProviderBridgeManifestPath()}. ` + `Integration tests must run through vitest.integration.config.ts, whose ` + `global setup builds the first-party bridge artifacts.`, ); From c964d7a3de4a1ae92b12ac82c1f5144e4375ba95 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Wed, 16 Sep 2026 19:57:51 +0000 Subject: [PATCH 7/9] Verify shared claim edit refusal and preserve migration replay --- .../services/threads/thread-edit-message.ts | 12 +- .../test/threads/thread-edit-message.test.ts | 107 +++++++++++++++++ packages/db/drizzle/0125_silent_guardian.sql | 2 +- packages/db/src/data/events.ts | 16 ++- packages/db/test/data/events.test.ts | 110 +++++++++++++++--- packages/db/test/data/thread-pruning.test.ts | 6 +- .../src/templates/bb-guide-threads.md | 3 + .../bb-cli/references/failure-recovery.md | 8 +- 8 files changed, 236 insertions(+), 28 deletions(-) diff --git a/apps/server/src/services/threads/thread-edit-message.ts b/apps/server/src/services/threads/thread-edit-message.ts index 411386531f..c53ab80bc2 100644 --- a/apps/server/src/services/threads/thread-edit-message.ts +++ b/apps/server/src/services/threads/thread-edit-message.ts @@ -305,11 +305,13 @@ function resolveEditableTurnCandidate( conflict("This earlier provider turn has no editable history checkpoint"); } const oldMaxSequence = getHighWaterMarks(db, [thread.id])[thread.id] ?? 0; - if (wouldRemoveSharedProviderSessionClaim(db, { - cutoffSequence: requestRow.sequence, - oldMaxSequence, - threadId: thread.id, - })) { + if ( + wouldRemoveSharedProviderSessionClaim(db, { + cutoffSequence: requestRow.sequence, + oldMaxSequence, + threadId: thread.id, + }) + ) { conflict( "Editing this message would erase provider session ownership shared with another thread. Clear context (/clear or bb thread clear) for a new session; history is kept.", ); diff --git a/apps/server/test/threads/thread-edit-message.test.ts b/apps/server/test/threads/thread-edit-message.test.ts index 9982bcaa00..5fb9273e78 100644 --- a/apps/server/test/threads/thread-edit-message.test.ts +++ b/apps/server/test/threads/thread-edit-message.test.ts @@ -1292,6 +1292,113 @@ describe("editThreadMessage", () => { }); }); + it.each([0, 140])( + "refuses an edit that erases a shared claim with a %i ms gap before preparing a rewind", + async (gap) => { + await withTestHarness(async (harness) => { + const { environment, thread } = seedEditableThread(harness); + const other = seedThread(harness.deps, { + environmentId: environment.id, + projectId: thread.projectId, + }); + for (const [threadId, sequence, createdAt] of [ + [thread.id, 12, 1_787_775_164_121], + [other.id, 1, 1_787_775_164_121 + gap], + ] as const) { + seedStoredEvent(harness.deps, { + threadId, + sequence, + createdAt, + environmentId: environment.id, + providerThreadId: "provider-shared-after-first-turn", + type: "thread/identity", + scope: threadScope(), + data: {}, + }); + } + const before = listEvents(harness.db, { threadId: thread.id }); + await expect( + editThreadMessage(harness.deps, { + environment, + thread, + payload: { + operationId: "edit-shared-claim", + expectedRequestSequence: 7, + input: [{ type: "text", text: "Replacement", mentions: [] }], + }, + }), + ).rejects.toThrow("would erase provider session ownership"); + expect( + listQueuedThreadCommands(harness, "thread.rewind.prepare", thread.id), + ).toHaveLength(0); + expect( + listQueuedThreadCommands(harness, "thread.start", thread.id), + ).toHaveLength(0); + expect(listEvents(harness.db, { threadId: thread.id })).toEqual(before); + }); + }, + ); + + it("discards a staged rewind without rewriting history when another thread claims the session before commit", async () => { + await withTestHarness(async (harness) => { + const { environment, thread } = seedEditableThread(harness); + const other = seedThread(harness.deps, { + environmentId: environment.id, + projectId: thread.projectId, + }); + seedStoredEvent(harness.deps, { + threadId: thread.id, + sequence: 12, + createdAt: 1_787_775_164_121, + environmentId: environment.id, + providerThreadId: "provider-shared-after-first-turn", + type: "thread/identity", + scope: threadScope(), + data: {}, + }); + const before = listEvents(harness.db, { threadId: thread.id }); + const editing = editThreadMessage(harness.deps, { + environment, + thread, + payload: { + operationId: "edit-racing-shared-claim", + expectedRequestSequence: 7, + input: [{ type: "text", text: "Replacement", mentions: [] }], + }, + }); + const rejected = expect(editing).rejects.toThrow( + "would erase provider session ownership", + ); + const rewind = await waitForQueuedCommand( + harness, + (queued) => queued.command.type === "thread.rewind.prepare", + ); + seedStoredEvent(harness.deps, { + threadId: other.id, + sequence: 1, + createdAt: 1_787_775_164_261, + environmentId: environment.id, + providerThreadId: "provider-shared-after-first-turn", + type: "thread/identity", + scope: threadScope(), + data: {}, + }); + await reportQueuedCommandSuccess(harness, rewind, { + providerThreadId: "provider-staged-claim-race", + }); + const discard = await waitForQueuedCommand( + harness, + (queued) => queued.command.type === "thread.rewind.discard", + ); + await reportQueuedCommandSuccess(harness, discard, {}); + await rejected; + expect(listEvents(harness.db, { threadId: thread.id })).toEqual(before); + expect( + listQueuedThreadCommands(harness, "thread.start", thread.id), + ).toHaveLength(0); + }); + }); + it("refuses to rewind through a turn recorded under another thread's provider session", async () => { await withTestHarness(async (harness) => { const { environment, thread } = seedEditableThread(harness, { diff --git a/packages/db/drizzle/0125_silent_guardian.sql b/packages/db/drizzle/0125_silent_guardian.sql index e3b1767eda..3faa4522b4 100644 --- a/packages/db/drizzle/0125_silent_guardian.sql +++ b/packages/db/drizzle/0125_silent_guardian.sql @@ -1 +1 @@ -CREATE INDEX `events_provider_identity_idx` ON `events` (`provider_thread_id`,`created_at`) WHERE "events"."type" = 'thread/identity'; \ No newline at end of file +CREATE INDEX IF NOT EXISTS `events_provider_identity_idx` ON `events` (`provider_thread_id`,`created_at`) WHERE "events"."type" = 'thread/identity'; \ No newline at end of file diff --git a/packages/db/src/data/events.ts b/packages/db/src/data/events.ts index 08b7f950a6..6147500f4c 100644 --- a/packages/db/src/data/events.ts +++ b/packages/db/src/data/events.ts @@ -3758,7 +3758,11 @@ export function wouldRemoveSharedProviderSessionClaim( .all(); for (const removed of removedClaims) { if (removed.providerThreadId === null) continue; - const claim = readStoredProviderThreadClaim(db, scope, removed.providerThreadId); + const claim = readStoredProviderThreadClaim( + db, + scope, + removed.providerThreadId, + ); if (classifyClaim(claim, args.threadId) === "foreign") continue; const retained = db .select({ id: events.id }) @@ -3769,7 +3773,10 @@ export function wouldRemoveSharedProviderSessionClaim( eq(events.type, "thread/identity"), eq(events.providerThreadId, removed.providerThreadId), lte(events.createdAt, removed.createdAt), - or(lt(events.sequence, args.cutoffSequence), gt(events.sequence, args.oldMaxSequence)), + or( + lt(events.sequence, args.cutoffSequence), + gt(events.sequence, args.oldMaxSequence), + ), ), ) .limit(1) @@ -3788,7 +3795,10 @@ export function wouldRemoveSharedProviderSessionClaim( eq(threads.providerId, scope.providerId), scope.hostId === null ? undefined - : or(isNull(environments.hostId), eq(environments.hostId, scope.hostId)), + : or( + isNull(environments.hostId), + eq(environments.hostId, scope.hostId), + ), ), ) .limit(1) diff --git a/packages/db/test/data/events.test.ts b/packages/db/test/data/events.test.ts index fb7731c988..154067ad5c 100644 --- a/packages/db/test/data/events.test.ts +++ b/packages/db/test/data/events.test.ts @@ -5600,9 +5600,7 @@ describe("timeline read-boundary output truncation", () => { }); describe("stored provider thread identity ownership", () => { - function setupThreads( - args: { providerIds?: readonly string[] } = {}, - ) { + function setupThreads(args: { providerIds?: readonly string[] } = {}) { const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "identity-host", @@ -5888,9 +5886,9 @@ describe("stored provider thread identity ownership", () => { }), ).toBe("owned"); expect( - listThreadTurnInterruptionEventStates(db, { threadIds: [early, late] }).map( - (state) => state.latestProviderThreadId, - ), + listThreadTurnInterruptionEventStates(db, { + threadIds: [early, late], + }).map((state) => state.latestProviderThreadId), ).toEqual([null, null]); db.$client.close(); }); @@ -5923,10 +5921,7 @@ describe("stored provider thread identity ownership", () => { resolveStoredProviderSessions(db, { threadIds: [first, second] }), ).toEqual( new Map([ - [ - first, - { kind: "owned", providerThreadId: "01a04a44-7dd6-session-a" }, - ], + [first, { kind: "owned", providerThreadId: "01a04a44-7dd6-session-a" }], [ second, { kind: "owned", providerThreadId: "01a04a44-80e8-session-b" }, @@ -6052,11 +6047,13 @@ describe("stored provider thread identity ownership", () => { }); expect(getStoredProviderSession(db, contaminated).kind).toBe("foreign"); - expect(wouldRemoveSharedProviderSessionClaim(db, { - cutoffSequence: 1, - oldMaxSequence: 1, - threadId: owner, - })).toBe(true); + expect( + wouldRemoveSharedProviderSessionClaim(db, { + cutoffSequence: 1, + oldMaxSequence: 1, + threadId: owner, + }), + ).toBe(true); expect(getStoredProviderSession(db, contaminated)).toEqual({ kind: "foreign", @@ -6066,6 +6063,89 @@ describe("stored provider thread identity ownership", () => { db.$client.close(); }); + it.each([ + "same-host", + "other-host", + "other-provider", + "unknown-host", + ] as const)("checks shared claim removal within the %s scope", (kind) => { + const { db, environment, project, threadIds } = setupThreads(); + const [owner] = requireThreadIds(threadIds); + const host = upsertHost(db, noopNotifier, { + name: "claim-other-host", + type: "persistent", + }); + const otherEnvironment = createEnvironment(db, noopNotifier, { + projectId: project.id, + hostId: host.id, + path: "/tmp/claim-other-host", + status: "ready", + providerOwnsPath: false, + }); + const other = createThread(db, noopNotifier, { + projectId: project.id, + ...(kind === "unknown-host" + ? {} + : { + environmentId: + kind === "other-host" ? otherEnvironment.id : environment.id, + }), + providerId: kind === "other-provider" ? "claude-code" : "codex", + }); + for (const threadId of [owner, other.id]) { + announceIdentity(db, { + threadId, + createdAt: 100, + providerThreadId: "shared", + }); + } + expect( + wouldRemoveSharedProviderSessionClaim(db, { + threadId: owner, + cutoffSequence: 1, + oldMaxSequence: 1, + }), + ).toBe(kind === "same-host" || kind === "unknown-host"); + db.$client.close(); + }); + + it("allows removing later duplicate or foreign claims while retaining the original evidence", () => { + const { db, threadIds } = setupThreads(); + const [owner, other] = requireThreadIds(threadIds); + for (const [threadId, createdAt] of [ + [owner, 100], + [owner, 101], + [other, 102], + ] as const) { + announceIdentity(db, { threadId, createdAt, providerThreadId: "shared" }); + } + expect( + wouldRemoveSharedProviderSessionClaim(db, { + threadId: owner, + cutoffSequence: 2, + oldMaxSequence: 2, + }), + ).toBe(false); + expect( + wouldRemoveSharedProviderSessionClaim(db, { + threadId: other, + cutoffSequence: 1, + oldMaxSequence: 1, + }), + ).toBe(false); + clearContext(db, owner); + expect(getStoredProviderSession(db, owner)).toEqual({ kind: "none" }); + expect(getStoredProviderSession(db, other).kind).toBe("foreign"); + expect( + wouldRemoveSharedProviderSessionClaim(db, { + threadId: owner, + cutoffSequence: 1, + oldMaxSequence: 2, + }), + ).toBe(true); + db.$client.close(); + }); + it("scopes claims to threads of the same provider on the same host", () => { const { db, host, project, threadIds } = setupThreads({ providerIds: ["codex", "claude-code"], diff --git a/packages/db/test/data/thread-pruning.test.ts b/packages/db/test/data/thread-pruning.test.ts index 027e01207c..5892ea3fcf 100644 --- a/packages/db/test/data/thread-pruning.test.ts +++ b/packages/db/test/data/thread-pruning.test.ts @@ -549,12 +549,12 @@ describe("thread pruning", () => { cycle(f, "turn-diffs"); expect(sequences(f)).toEqual([1, 2, 3]); expect(getHighWaterMarks(f.db, [f.thread.id])[f.thread.id]).toBe(3); - expect(getLastStoredProviderThreadId(f.db, f.thread.id)).toBe("new"); + expect(getLastStoredProviderThreadId(f.db, f.thread.id)).toBeNull(); expect( listThreadTurnInterruptionEventStates(f.db, { threadIds: [f.thread.id], })[0]?.latestProviderThreadId, - ).toBe("new"); + ).toBeNull(); const result = f.db.transaction((tx) => appendDaemonEventsInTransaction(tx, [ { @@ -584,7 +584,7 @@ describe("thread pruning", () => { expect(result.acceptedEvents.map((r) => r.sequence)).toEqual([4]); expect(result.insertedInputIndexes).toEqual([1]); expect(sequences(f)).toEqual([1, 2, 3, 4]); - expect(getLastStoredProviderThreadId(f.db, f.thread.id)).toBe("newer"); + expect(getLastStoredProviderThreadId(f.db, f.thread.id)).toBeNull(); cycle(f, "turn-diffs"); expect(sequences(f)).toEqual([1, 2, 4]); expect(getHighWaterMarks(f.db, [f.thread.id])[f.thread.id]).toBe(4); diff --git a/packages/templates/src/templates/bb-guide-threads.md b/packages/templates/src/templates/bb-guide-threads.md index 1aede931a0..4e9483fc6e 100644 --- a/packages/templates/src/templates/bb-guide-threads.md +++ b/packages/templates/src/templates/bb-guide-threads.md @@ -138,6 +138,9 @@ Editing a sent message: replaces the selected turn and every later turn while retaining workspace changes. From an agent thread, the command carries `BB_THREAD_ID` so the replacement runs under agent permission policy. + An edit is refused if removing its history would erase ownership evidence + shared with another thread. Use bb thread clear to start a new session + while keeping the history and its ownership evidence. Listing: diff --git a/plugins/bb-guide/skills/bb-cli/references/failure-recovery.md b/plugins/bb-guide/skills/bb-cli/references/failure-recovery.md index 511a084da6..9f59430938 100644 --- a/plugins/bb-guide/skills/bb-cli/references/failure-recovery.md +++ b/plugins/bb-guide/skills/bb-cli/references/failure-recovery.md @@ -32,10 +32,16 @@ sendAt?, reason? })`. workspace, durable event history, and sticky execution settings. - A send that fails with `provider_session_unavailable` means the thread's recorded provider session belongs to another thread (`details.reason: - "foreign"`) or was announced by another thread in the same millisecond +"foreign"`) or was announced by another thread in the same millisecond (`"ambiguous"`). bb refuses to resume it rather than write into another conversation. `bb thread clear ` starts a new provider session on the next send and keeps the thread's history. +- Message edits refuse to erase an ownership claim also recorded by another + thread when doing so could transfer or resolve that shared claim. This check + runs before preparing the edit and again before rewriting history. Use + `bb thread clear ` to start fresh while retaining the ownership evidence. + Ownership still depends on retained records; purging all records of the + original owner removes that protection. - Use `bb thread cancel-plan ` to exit an active Plan turn without optimistically clearing its banner. Use `bb thread clear-goal ` to clear a thread's durable active Goal when supported by its provider. Both wait for provider confirmation. From e23319817a80042971fcf1e40036dda9b11ae5e6 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Wed, 16 Sep 2026 19:57:51 +0000 Subject: [PATCH 8/9] Refuse missing or empty persisted provider identities --- .../src/services/threads/thread-events.ts | 9 ++-- .../services/threads/thread-fork-history.ts | 5 +++ ...thread-provider-identity-ownership.test.ts | 41 ++++++++++++++++++- packages/db/src/data/events.ts | 24 ++++++++--- packages/db/test/data/events.test.ts | 33 +++++++++++++++ .../bb-cli/references/failure-recovery.md | 3 +- 6 files changed, 104 insertions(+), 11 deletions(-) diff --git a/apps/server/src/services/threads/thread-events.ts b/apps/server/src/services/threads/thread-events.ts index 643b09fc39..afe31fa816 100644 --- a/apps/server/src/services/threads/thread-events.ts +++ b/apps/server/src/services/threads/thread-events.ts @@ -890,14 +890,17 @@ export function requireDispatchableProviderThreadId( return null; case "owned": return session.providerThreadId; + case "invalid": case "ambiguous": case "foreign": throw new ApiError( 409, "provider_session_unavailable", - session.kind === "ambiguous" - ? "Another thread claimed this thread's provider session in the same millisecond, so bb will not guess whose it is. Clear context (/clear or bb thread clear) for a new session; history is kept." - : "This thread's only provider session belongs to another thread, so bb will not resume it. Clear context (/clear or bb thread clear) for a new session; history is kept.", + session.kind === "invalid" + ? "This thread has a stored identity without a valid provider session, so bb will not replace it silently. Clear context (/clear or bb thread clear) for a new session; history is kept." + : session.kind === "ambiguous" + ? "Another thread claimed this thread's provider session in the same millisecond, so bb will not guess whose it is. Clear context (/clear or bb thread clear) for a new session; history is kept." + : "This thread's only provider session belongs to another thread, so bb will not resume it. Clear context (/clear or bb thread clear) for a new session; history is kept.", { details: { reason: session.kind, diff --git a/apps/server/src/services/threads/thread-fork-history.ts b/apps/server/src/services/threads/thread-fork-history.ts index 6eefae679a..8e3742a899 100644 --- a/apps/server/src/services/threads/thread-fork-history.ts +++ b/apps/server/src/services/threads/thread-fork-history.ts @@ -177,6 +177,11 @@ export function resolveThreadForkPoint( if (sourceSession.kind === "none") { return null; } + if (sourceSession.kind === "invalid") { + forkPointUnavailable( + "Cannot fork: the source thread has a stored identity without a valid provider session", + ); + } if (sourceSession.kind === "ambiguous") { forkPointUnavailable( "Cannot fork: another thread announced the source thread's provider session at the same moment, so bb cannot tell whose it is", diff --git a/apps/server/test/threads/thread-provider-identity-ownership.test.ts b/apps/server/test/threads/thread-provider-identity-ownership.test.ts index 24739d8fd8..7b47f0456d 100644 --- a/apps/server/test/threads/thread-provider-identity-ownership.test.ts +++ b/apps/server/test/threads/thread-provider-identity-ownership.test.ts @@ -1,5 +1,5 @@ import { listEvents, listQueuedThreadMessages } from "@bb/db"; -import { turnScope, type PromptInput } from "@bb/domain"; +import { threadScope, turnScope, type PromptInput } from "@bb/domain"; import { describe, expect, it, vi } from "vitest"; import { ApiError } from "../../src/errors.js"; import { runQueuedMessageDispatch } from "../../src/services/threads/queued-message-dispatch.js"; @@ -248,6 +248,45 @@ describe("provider session ownership on dispatch", () => { }); }); + it.each([null, ""])( + "refuses an invalid persisted identity %s without starting a replacement session", + async (providerThreadId) => { + await withTestHarness(async (harness) => { + const { environment, first: thread } = seedThreads(harness); + seedStoredEvent(harness.deps, { + threadId: thread.id, + environmentId: environment.id, + providerThreadId, + type: "thread/identity", + scope: threadScope(), + sequence: 1, + data: {}, + }); + const before = listEvents(harness.db, { threadId: thread.id }); + await expect( + sendUserMessage(harness, { environment, thread }), + ).rejects.toMatchObject({ + status: 409, + body: { + code: "provider_session_unavailable", + details: { + reason: "invalid", + providerThreadId, + claimantThreadIds: [], + }, + }, + }); + expect( + listQueuedThreadCommands(harness, "thread.start", thread.id), + ).toHaveLength(0); + expect( + listQueuedThreadCommands(harness, "turn.submit", thread.id), + ).toHaveLength(0); + expect(listEvents(harness.db, { threadId: thread.id })).toEqual(before); + }); + }, + ); + it("refuses to resume a thread whose only session another thread claimed first, and starts fresh after /clear", async () => { await withTestHarness(async (harness) => { const { diff --git a/packages/db/src/data/events.ts b/packages/db/src/data/events.ts index 6147500f4c..a050bdb5c6 100644 --- a/packages/db/src/data/events.ts +++ b/packages/db/src/data/events.ts @@ -3506,6 +3506,11 @@ type StoredProviderThreadClaim = export type StoredProviderSession = | { kind: "none" } + | { + kind: "invalid"; + providerThreadId: string | null; + claimantThreadIds: string[]; + } | { kind: "owned"; providerThreadId: string } | { kind: "foreign"; @@ -3623,7 +3628,9 @@ function readNewestStoredProviderThreadIdentity( excludedProviderThreadIds: readonly string[]; threadId: string; }, -): (StoredProviderThreadIdentityScope & { providerThreadId: string }) | null { +): + | (StoredProviderThreadIdentityScope & { providerThreadId: string | null }) + | null { const row = db .select({ hostId: environments.hostId, @@ -3637,7 +3644,6 @@ function readNewestStoredProviderThreadIdentity( and( eq(events.threadId, args.threadId), eq(events.type, "thread/identity"), - isNotNull(events.providerThreadId), sql`${events.sequence} > COALESCE(( SELECT MAX(context_clear.sequence) FROM events AS context_clear @@ -3648,15 +3654,18 @@ function readNewestStoredProviderThreadIdentity( ), 0)`, args.excludedProviderThreadIds.length === 0 ? undefined - : notInArray(events.providerThreadId, [ - ...args.excludedProviderThreadIds, - ]), + : or( + isNull(events.providerThreadId), + notInArray(events.providerThreadId, [ + ...args.excludedProviderThreadIds, + ]), + ), ), ) .orderBy(desc(events.sequence)) .limit(1) .get(); - if (row === undefined || row.providerThreadId === null) { + if (row === undefined) { return null; } return { @@ -3682,6 +3691,9 @@ function resolveThreadProviderSession( return firstForeign ?? { kind: "none" }; } const { providerThreadId } = identity; + if (providerThreadId === null || providerThreadId.length === 0) { + return { kind: "invalid", providerThreadId, claimantThreadIds: [] }; + } visited.push(providerThreadId); const claim = readStoredProviderThreadClaim(db, identity, providerThreadId); switch (classifyClaim(claim, threadId)) { diff --git a/packages/db/test/data/events.test.ts b/packages/db/test/data/events.test.ts index 154067ad5c..7e946d3693 100644 --- a/packages/db/test/data/events.test.ts +++ b/packages/db/test/data/events.test.ts @@ -5701,6 +5701,39 @@ describe("stored provider thread identity ownership", () => { return [first, second]; } + it.each([null, ""])( + "refuses an invalid persisted identity handle %s instead of treating it as a fresh thread", + (providerThreadId) => { + const { db, threadIds } = setupThreads(); + const [threadId] = requireThreadIds(threadIds); + announceIdentity(db, { + threadId, + providerThreadId: "valid-earlier", + createdAt: 100, + }); + insertEvents(db, noopNotifier, [ + { + threadId, + sequence: 2, + createdAt: 101, + scope: threadScope(), + providerThreadId, + type: "thread/identity", + ...emptyItemFields, + data: "{}", + }, + ]); + expect(getStoredProviderSession(db, threadId)).toEqual({ + kind: "invalid", + providerThreadId, + claimantThreadIds: [], + }); + clearContext(db, threadId); + expect(getStoredProviderSession(db, threadId)).toEqual({ kind: "none" }); + db.$client.close(); + }, + ); + it("ignores provider ids stamped on ordinary events", () => { const { db, threadIds } = setupThreads(); const [first, second] = requireThreadIds(threadIds); diff --git a/plugins/bb-guide/skills/bb-cli/references/failure-recovery.md b/plugins/bb-guide/skills/bb-cli/references/failure-recovery.md index 9f59430938..cf12f5d805 100644 --- a/plugins/bb-guide/skills/bb-cli/references/failure-recovery.md +++ b/plugins/bb-guide/skills/bb-cli/references/failure-recovery.md @@ -33,7 +33,8 @@ sendAt?, reason? })`. - A send that fails with `provider_session_unavailable` means the thread's recorded provider session belongs to another thread (`details.reason: "foreign"`) or was announced by another thread in the same millisecond - (`"ambiguous"`). bb refuses to resume it rather than write into another + (`"ambiguous"`), or its identity handle is missing or empty (`"invalid"`). + bb refuses to resume it or silently replace its context rather than write into another conversation. `bb thread clear ` starts a new provider session on the next send and keeps the thread's history. - Message edits refuse to erase an ownership claim also recorded by another From 55097953348f0d583e34317375ed693d4d130716 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Wed, 16 Sep 2026 20:17:48 +0000 Subject: [PATCH 9/9] Seed fork ownership in metadata fixture and format changed tests --- ...blic-thread-queue-gone-environment.test.ts | 4 +- .../thread-plugin-metadata-configure.test.ts | 6 + packages/db/test/migrate.test.ts | 133 ++++++++---------- 3 files changed, 68 insertions(+), 75 deletions(-) diff --git a/apps/server/test/public/public-thread-queue-gone-environment.test.ts b/apps/server/test/public/public-thread-queue-gone-environment.test.ts index 5470b6a0be..cc7ef49619 100644 --- a/apps/server/test/public/public-thread-queue-gone-environment.test.ts +++ b/apps/server/test/public/public-thread-queue-gone-environment.test.ts @@ -70,7 +70,7 @@ describe("queued message into a thread whose environment is gone (#1789)", () => }); const environment = seedEnvironment(harness.deps, { hostId: host.id, - projectId: project.id, + projectId: project.id, path: null, status, isGitRepo: false, @@ -204,7 +204,7 @@ describe("queued message into a thread whose environment is gone (#1789)", () => }); const environment = seedEnvironment(harness.deps, { hostId: host.id, - projectId: project.id, + projectId: project.id, status: "ready", isGitRepo: false, }); diff --git a/apps/server/test/threads/thread-plugin-metadata-configure.test.ts b/apps/server/test/threads/thread-plugin-metadata-configure.test.ts index a1db2f2219..806d136be1 100644 --- a/apps/server/test/threads/thread-plugin-metadata-configure.test.ts +++ b/apps/server/test/threads/thread-plugin-metadata-configure.test.ts @@ -18,6 +18,7 @@ import { seedHostSession, seedProjectWithSource, seedThread, + seedThreadIdentity, seedThreadRuntimeState, seedTurnStarted, } from "../helpers/seed.js"; @@ -244,6 +245,11 @@ describe("thread plugin metadata in agent configuration", () => { projectId: project.id, environmentId: environment.id, }); + seedThreadIdentity(server.deps, { + threadId: source.id, + environmentId: environment.id, + providerThreadId: "provider-plugin-metadata-fork-source", + }); seedTurnStarted(server.deps, { threadId: source.id, turnId: "turn-plugin-metadata-fork-source", diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index b2c32c8a06..abdab5b19a 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -1753,10 +1753,9 @@ describe("migrate", () => { expect( db.$client - .prepare< - [], - MigratedEventDataRow - >("SELECT data FROM events WHERE id = 'evt-retained-output-migration'") + .prepare<[], MigratedEventDataRow>( + "SELECT data FROM events WHERE id = 'evt-retained-output-migration'", + ) .get(), ).toEqual({ data: eventData }); expect( @@ -1827,10 +1826,9 @@ describe("migrate", () => { expect( db.$client - .prepare< - [], - { id: string; root: string | null } - >("SELECT id, git_checkout_root AS root FROM plugin_artifacts ORDER BY id") + .prepare<[], { id: string; root: string | null }>( + "SELECT id, git_checkout_root AS root FROM plugin_artifacts ORDER BY id", + ) .all(), ).toEqual([ { id: "collision", root: `/cache/repo/${commit}` }, @@ -1968,10 +1966,9 @@ describe("migrate", () => { }); expect( db.$client - .prepare< - [], - { key: string; value: string } - >("SELECT key, value FROM app_settings_values WHERE key LIKE 'codex%' OR key LIKE 'claudeCode%' ORDER BY key") + .prepare<[], { key: string; value: string }>( + "SELECT key, value FROM app_settings_values WHERE key LIKE 'codex%' OR key LIKE 'claudeCode%' ORDER BY key", + ) .all(), ).toEqual([ { key: "claudeCodeMemoryEnabled", value: "true" }, @@ -1985,10 +1982,9 @@ describe("migrate", () => { ]); expect( db.$client - .prepare< - [], - { updatedAt: number } - >("SELECT updated_at AS updatedAt FROM app_settings_values WHERE key = 'showKeyboardHints'") + .prepare<[], { updatedAt: number }>( + "SELECT updated_at AS updatedAt FROM app_settings_values WHERE key = 'showKeyboardHints'", + ) .get(), ).toEqual({ updatedAt: 1234 }); } finally { @@ -2034,7 +2030,9 @@ describe("migrate", () => { .prepare< [], { pluginId: string; key: string; value: string; updatedAt: number } - >("SELECT plugin_id AS pluginId, key, value, updated_at AS updatedAt FROM plugin_settings ORDER BY plugin_id, key") + >( + "SELECT plugin_id AS pluginId, key, value, updated_at AS updatedAt FROM plugin_settings ORDER BY plugin_id, key", + ) .all(), ).toEqual([ { @@ -2070,10 +2068,9 @@ describe("migrate", () => { ]); expect( db.$client - .prepare< - [], - { key: string } - >("SELECT key FROM app_settings_values ORDER BY key") + .prepare<[], { key: string }>( + "SELECT key FROM app_settings_values ORDER BY key", + ) .all(), ).toEqual([{ key: "showKeyboardHints" }]); } finally { @@ -2142,10 +2139,9 @@ describe("migrate", () => { expect( db.$client - .prepare< - [], - { count: number } - >("SELECT COUNT(*) AS count FROM app_settings_values") + .prepare<[], { count: number }>( + "SELECT COUNT(*) AS count FROM app_settings_values", + ) .get(), ).toEqual({ count: 0 }); expect(getAppSettings(db)).toEqual(defaultAppSettings); @@ -2221,10 +2217,9 @@ describe("migrate", () => { expect( db.$client - .prepare< - [], - { count: number } - >("SELECT COUNT(*) AS count FROM app_settings_values") + .prepare<[], { count: number }>( + "SELECT COUNT(*) AS count FROM app_settings_values", + ) .get(), ).toEqual({ count: 0 }); expect(getAppSettings(db).steerActiveThreadOnEnter).toBe(true); @@ -2266,10 +2261,9 @@ describe("migrate", () => { expect( db.$client - .prepare< - [], - { value: string; updatedAt: number } - >("SELECT value, updated_at AS updatedAt FROM app_settings_values WHERE key = 'steerActiveThreadOnEnter'") + .prepare<[], { value: string; updatedAt: number }>( + "SELECT value, updated_at AS updatedAt FROM app_settings_values WHERE key = 'steerActiveThreadOnEnter'", + ) .get(), ).toEqual({ value: "true", updatedAt: 1234 }); expect(getAppSettings(db).steerActiveThreadOnEnter).toBe(true); @@ -2329,10 +2323,9 @@ describe("migrate", () => { runMigrationFile({ db, migrationPath: sideChatPluginOnlyMigrationPath }); const rows = db.$client - .prepare< - [], - MigratedThreadOriginRow - >("SELECT id, origin_kind AS originKind, origin_plugin_id AS originPluginId, visibility FROM threads") + .prepare<[], MigratedThreadOriginRow>( + "SELECT id, origin_kind AS originKind, origin_plugin_id AS originPluginId, visibility FROM threads", + ) .all(); const byId = new Map(rows.map((row) => [row.id, row])); @@ -5251,10 +5244,9 @@ describe("migrate", () => { expect(readTableNames(db)).not.toContain("marketplaces"); expect( db.$client - .prepare< - [], - { source: string } - >("SELECT source FROM plugins WHERE id = 'third-party'") + .prepare<[], { source: string }>( + "SELECT source FROM plugins WHERE id = 'third-party'", + ) .get(), ).toEqual({ source: "git:https://example.test/tasks@main" }); } finally { @@ -5324,10 +5316,9 @@ describe("migrate", () => { }); expect( db.$client - .prepare< - [], - MigrationCountRow - >("SELECT COUNT(*) AS count FROM plugin_catalog") + .prepare<[], MigrationCountRow>( + "SELECT COUNT(*) AS count FROM plugin_catalog", + ) .get(), ).toEqual({ count: 0 }); } finally { @@ -5372,18 +5363,16 @@ describe("migrate", () => { expect( db.$client - .prepare< - [], - { name: string } - >("SELECT name FROM plugin_marketplaces ORDER BY name") + .prepare<[], { name: string }>( + "SELECT name FROM plugin_marketplaces ORDER BY name", + ) .all(), ).toEqual([{ name: "acme" }, { name: "bb-community" }]); expect( db.$client - .prepare< - [], - { marketplaceName: string } - >("SELECT marketplace_name AS marketplaceName FROM plugin_marketplace_icons ORDER BY marketplace_name") + .prepare<[], { marketplaceName: string }>( + "SELECT marketplace_name AS marketplaceName FROM plugin_marketplace_icons ORDER BY marketplace_name", + ) .all(), ).toEqual([ { marketplaceName: "acme" }, @@ -5391,10 +5380,9 @@ describe("migrate", () => { ]); expect( db.$client - .prepare< - [], - { id: string; catalogMarketplaceName: string | null } - >("SELECT id, catalog_marketplace_name AS catalogMarketplaceName FROM plugins ORDER BY id") + .prepare<[], { id: string; catalogMarketplaceName: string | null }>( + "SELECT id, catalog_marketplace_name AS catalogMarketplaceName FROM plugins ORDER BY id", + ) .all(), ).toEqual([ { id: "local", catalogMarketplaceName: null }, @@ -5407,7 +5395,9 @@ describe("migrate", () => { .prepare< [], { name: string; etag: string | null; lastModified: string | null } - >("SELECT name, etag, last_modified AS lastModified FROM plugin_marketplaces ORDER BY name") + >( + "SELECT name, etag, last_modified AS lastModified FROM plugin_marketplaces ORDER BY name", + ) .all(), ).toEqual([ { @@ -5702,9 +5692,9 @@ describe("environment providers migration", () => { rewindEnvironmentRowFactsMigration(db); rewindEnvironmentProvidersMigration(db); db.$client - .prepare< - [number] - >("DELETE FROM __drizzle_migrations WHERE created_at >= ?") + .prepare<[number]>( + "DELETE FROM __drizzle_migrations WHERE created_at >= ?", + ) .run(environmentProvidersMigrationWhen); db.$client.exec(` INSERT INTO hosts (id, name, type, created_at, updated_at) @@ -5779,10 +5769,9 @@ describe("environment providers migration", () => { seedPreProviderEnvironments(db); migrate(db); const rows = db.$client - .prepare< - [], - { provider: string; owner: string | null } - >("SELECT DISTINCT environment_provider_id AS provider, environment_provider_plugin_id AS owner FROM environments ORDER BY provider") + .prepare<[], { provider: string; owner: string | null }>( + "SELECT DISTINCT environment_provider_id AS provider, environment_provider_plugin_id AS owner FROM environments ORDER BY provider", + ) .all(); expect(rows).toEqual([ { provider: "git-worktree", owner: "environment-git-worktree" }, @@ -6007,10 +5996,9 @@ describe("environment providers migration", () => { expect( db.$client - .prepare< - [], - { id: string; status: string } - >("SELECT id, status FROM environments ORDER BY id") + .prepare<[], { id: string; status: string }>( + "SELECT id, status FROM environments ORDER BY id", + ) .all(), ).toEqual([ { id: "env_default", status: "ready" }, @@ -6094,10 +6082,9 @@ describe("machine providers migration", () => { expect( db.$client - .prepare< - [], - { id: string; providerId: string | null; type: string } - >("SELECT id, server_access_provider_id AS providerId, type FROM hosts ORDER BY id") + .prepare<[], { id: string; providerId: string | null; type: string }>( + "SELECT id, server_access_provider_id AS providerId, type FROM hosts ORDER BY id", + ) .all(), ).toEqual([ { id: "direct", providerId: null, type: "persistent" },