diff --git a/packages/junior/migrations/0023_agent_invocation_parent_notification.sql b/packages/junior/migrations/0023_agent_invocation_parent_notification.sql new file mode 100644 index 0000000000..b362ce7a3e --- /dev/null +++ b/packages/junior/migrations/0023_agent_invocation_parent_notification.sql @@ -0,0 +1,2 @@ +ALTER TABLE "junior_agent_invocations" ADD COLUMN "parent_notification_status" text;--> statement-breakpoint +CREATE INDEX "junior_agent_invocations_parent_notification_idx" ON "junior_agent_invocations" USING btree ("parent_notification_status","terminal_at"); \ No newline at end of file diff --git a/packages/junior/migrations/meta/0023_snapshot.json b/packages/junior/migrations/meta/0023_snapshot.json new file mode 100644 index 0000000000..6f1122a484 --- /dev/null +++ b/packages/junior/migrations/meta/0023_snapshot.json @@ -0,0 +1,2213 @@ +{ + "id": "8daab8e3-2687-414a-ab95-72d7276fa8c2", + "prevId": "108c9745-ff35-44a0-a964-26e2809e2023", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_agent_bindings": { + "name": "junior_agent_bindings", + "schema": "", + "columns": { + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_agent_bindings_child_idx": { + "name": "junior_agent_bindings_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": [ + "parent_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": [ + "child_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_agent_bindings_parent_conversation_id_name_pk": { + "name": "junior_agent_bindings_parent_conversation_id_name_pk", + "columns": [ + "parent_conversation_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_agent_invocations": { + "name": "junior_agent_invocations", + "schema": "", + "columns": { + "invocation_id": { + "name": "invocation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_context_json": { + "name": "credential_context_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_visibility": { + "name": "destination_visibility", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mailbox_status": { + "name": "mailbox_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_notification_status": { + "name": "parent_notification_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_agent_invocations_child_idx": { + "name": "junior_agent_invocations_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_agent_invocations_mailbox_idx": { + "name": "junior_agent_invocations_mailbox_idx", + "columns": [ + { + "expression": "mailbox_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_agent_invocations_parent_notification_idx": { + "name": "junior_agent_invocations_parent_notification_idx", + "columns": [ + { + "expression": "parent_notification_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "terminal_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "parent_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "child_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_api_tokens": { + "name": "junior_api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_email_normalized": { + "name": "owner_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_suffix": { + "name": "token_suffix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_api_tokens_token_hash_uidx": { + "name": "junior_api_tokens_token_hash_uidx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_api_tokens_owner_email_idx": { + "name": "junior_api_tokens_owner_email_idx", + "columns": [ + { + "expression": "owner_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_annotations": { + "name": "junior_conversation_annotations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin": { + "name": "plugin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "annotation_json": { + "name": "annotation_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_conversation_annotations_conversation_id_fk": { + "name": "junior_conversation_annotations_conversation_id_fk", + "tableFrom": "junior_conversation_annotations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_annotations_pk": { + "name": "junior_conversation_annotations_pk", + "columns": [ + "conversation_id", + "plugin", + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_bindings": { + "name": "junior_conversation_bindings", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_conversation_id": { + "name": "provider_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_bindings_conversation_idx": { + "name": "junior_conversation_bindings_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_bindings", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_bindings_provider_conversation_pk": { + "name": "junior_conversation_bindings_provider_conversation_pk", + "columns": [ + "provider", + "provider_tenant_id", + "provider_destination_id", + "provider_conversation_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_events": { + "name": "junior_conversation_events", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "history_version": { + "name": "history_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_events_history_version_idx": { + "name": "junior_conversation_events_history_version_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "history_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_type_idx": { + "name": "junior_conversation_events_type_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_message_search_idx": { + "name": "junior_conversation_events_message_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"payload\"->>'text')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversation_events\".\"type\" = 'message'", + "concurrently": false, + "method": "gin", + "with": {} + }, + "junior_conversation_events_idempotency_idx": { + "name": "junior_conversation_events_idempotency_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_events", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_events_conversation_id_seq_pk": { + "name": "junior_conversation_events_conversation_id_seq_pk", + "columns": [ + "conversation_id", + "seq" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversations": { + "name": "junior_conversations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "origin_type": { + "name": "origin_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_id": { + "name": "origin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_run_id": { + "name": "origin_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_subject_identity_id": { + "name": "credential_subject_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "execution_updated_at": { + "name": "execution_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_status": { + "name": "execution_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checkpoint_at": { + "name": "last_checkpoint_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_enqueued_at": { + "name": "last_enqueued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "root_conversation_id": { + "name": "root_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcript_purged_at": { + "name": "transcript_purged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_duration_ms": { + "name": "execution_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "execution_usage_json": { + "name": "execution_usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metric_run_id": { + "name": "metric_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversations_last_activity_idx": { + "name": "junior_conversations_last_activity_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_active_idx": { + "name": "junior_conversations_active_idx", + "columns": [ + { + "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_destination_activity_idx": { + "name": "junior_conversations_destination_activity_idx", + "columns": [ + { + "expression": "destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_actor_activity_idx": { + "name": "junior_conversations_actor_activity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_origin_idx": { + "name": "junior_conversations_origin_idx", + "columns": [ + { + "expression": "origin_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_parent_idx": { + "name": "junior_conversations_parent_idx", + "columns": [ + { + "expression": "parent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_root_idx": { + "name": "junior_conversations_root_idx", + "columns": [ + { + "expression": "root_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversations_destination_id_junior_destinations_id_fk": { + "name": "junior_conversations_destination_id_junior_destinations_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_destinations", + "columnsFrom": [ + "destination_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": [ + "actor_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": [ + "creator_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_credential_subject_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_credential_subject_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": [ + "credential_subject_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "parent_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "root_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_destinations": { + "name": "junior_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_destination_id": { + "name": "parent_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_destinations_provider_destination_uidx": { + "name": "junior_destinations_provider_destination_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_destinations_provider_kind_idx": { + "name": "junior_destinations_provider_kind_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_event_tasks": { + "name": "junior_event_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "task_json": { + "name": "task_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_event_tasks_team_idx": { + "name": "junior_event_tasks_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_event_tasks_match_idx": { + "name": "junior_event_tasks_match_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_identities": { + "name": "junior_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_normalized": { + "name": "email_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "junior_identities_provider_subject_uidx": { + "name": "junior_identities_provider_subject_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_user_idx": { + "name": "junior_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_verified_email_idx": { + "name": "junior_identities_verified_email_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_kind_provider_idx": { + "name": "junior_identities_kind_provider_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_identities_user_id_junior_users_id_fk": { + "name": "junior_identities_user_id_junior_users_id_fk", + "tableFrom": "junior_identities", + "tableTo": "junior_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_scheduler_runs": { + "name": "junior_scheduler_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_for_ms": { + "name": "scheduled_for_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "record": { + "name": "record", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_scheduler_runs_task_status_idx": { + "name": "junior_scheduler_runs_task_status_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_scheduler_runs_status_idx": { + "name": "junior_scheduler_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_scheduler_tasks": { + "name": "junior_scheduler_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_slack_user_id": { + "name": "creator_slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "next_run_at_ms": { + "name": "next_run_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "run_now_at_ms": { + "name": "run_now_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "record": { + "name": "record", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_scheduler_tasks_creator_idx": { + "name": "junior_scheduler_tasks_creator_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "creator_slack_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_scheduler_tasks\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_scheduler_tasks_creator_identity_idx": { + "name": "junior_scheduler_tasks_creator_identity_idx", + "columns": [ + { + "expression": "creator_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_scheduler_tasks\".\"status\" <> 'deleted' AND \"junior_scheduler_tasks\".\"creator_identity_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_scheduler_tasks_team_status_idx": { + "name": "junior_scheduler_tasks_team_status_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_scheduler_tasks\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_scheduler_tasks_run_now_due_idx": { + "name": "junior_scheduler_tasks_run_now_due_idx", + "columns": [ + { + "expression": "run_now_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_scheduler_tasks\".\"status\" = 'active' AND \"junior_scheduler_tasks\".\"run_now_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_scheduler_tasks_next_run_due_idx": { + "name": "junior_scheduler_tasks_next_run_due_idx", + "columns": [ + { + "expression": "next_run_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_scheduler_tasks\".\"status\" = 'active' AND \"junior_scheduler_tasks\".\"next_run_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_stats": { + "name": "junior_stats", + "schema": "", + "columns": { + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "junior_stats_date_namespace_metric_name_pk": { + "name": "junior_stats_date_namespace_metric_name_pk", + "columns": [ + "date", + "namespace", + "metric", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_task_executions": { + "name": "junior_task_executions", + "schema": "", + "columns": { + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "executed_at_ms": { + "name": "executed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_task_executions_task_time_idx": { + "name": "junior_task_executions_task_time_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "executed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_task_executions_time_kind_idx": { + "name": "junior_task_executions_time_kind_idx", + "columns": [ + { + "expression": "executed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_task_executions_conversation_time_idx": { + "name": "junior_task_executions_conversation_time_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "executed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_task_executions_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_task_executions_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_task_executions", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_task_executions_kind_namespace_execution_id_pk": { + "name": "junior_task_executions_kind_namespace_execution_id_pk", + "columns": [ + "kind", + "namespace", + "execution_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_task_executions_kind_check": { + "name": "junior_task_executions_kind_check", + "value": "\"junior_task_executions\".\"kind\" in ('scheduled', 'event')" + }, + "junior_task_executions_status_check": { + "name": "junior_task_executions_status_check", + "value": "\"junior_task_executions\".\"status\" in ('blocked', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.junior_users": { + "name": "junior_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email_normalized": { + "name": "primary_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_users_primary_email_normalized_uidx": { + "name": "junior_users_primary_email_normalized_uidx", + "columns": [ + { + "expression": "primary_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index 11706888ae..a728e31b4a 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1786128720003, "tag": "0022_task_execution_status", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1786149789087, + "tag": "0023_agent_invocation_parent_notification", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/junior/src/chat/agent-dispatch/heartbeat.ts b/packages/junior/src/chat/agent-dispatch/heartbeat.ts index 93c2df6a8f..3474b12e4c 100644 --- a/packages/junior/src/chat/agent-dispatch/heartbeat.ts +++ b/packages/junior/src/chat/agent-dispatch/heartbeat.ts @@ -5,7 +5,11 @@ import { runScheduledTaskHeartbeat } from "@/chat/scheduled-tasks/heartbeat"; import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; import { getVercelConversationWorkQueue } from "@/chat/task-execution/vercel-queue"; import { createHeartbeatContext } from "./context"; -import { listPendingAgentInvocationMailboxAppends } from "@/chat/agent-invocations/store"; +import { + listPendingAgentInvocationMailboxAppends, + listPendingAgentInvocationParentNotifications, +} from "@/chat/agent-invocations/store"; +import { notifyParentOfAgentInvocationResult } from "@/chat/agent-invocations/parent-notification"; import { enqueueAgentInvocation } from "@/chat/agent-invocations/work"; import { confirmDispatchMailboxAppend, @@ -20,6 +24,7 @@ const DEFAULT_PLUGIN_LIMIT = 25; const PLUGIN_HEARTBEAT_TIMEOUT_MS = 25_000; const DISPATCH_MAILBOX_APPEND_LIMIT = 100; const AGENT_INVOCATION_MAILBOX_APPEND_LIMIT = 100; +const AGENT_INVOCATION_PARENT_NOTIFICATION_LIMIT = 100; async function runWithTimeout( promise: Promise, timeoutMs: number, @@ -151,6 +156,32 @@ export async function recoverPendingAgentInvocationMailboxAppends(args: { } } +/** Repair terminal child results that stopped before parent-mailbox delivery. */ +export async function recoverPendingAgentInvocationParentNotifications(args: { + conversationWorkQueue: ConversationWorkQueue; + nowMs: number; +}): Promise { + const invocations = await listPendingAgentInvocationParentNotifications( + AGENT_INVOCATION_PARENT_NOTIFICATION_LIMIT, + ); + for (const invocation of invocations) { + try { + await notifyParentOfAgentInvocationResult(invocation, { + nowMs: args.nowMs, + queue: args.conversationWorkQueue, + }); + } catch (error) { + logException( + error, + "agent.invocation.parent_notification.recovery.failed", + { + "app.agent.invocation_id": invocation.invocationId, + }, + ); + } + } +} + /** Run the core heartbeat phases. */ export async function runHeartbeat(args: { conversationWorkQueue?: ConversationWorkQueue; @@ -169,6 +200,10 @@ export async function runHeartbeat(args: { conversationWorkQueue: queue, nowMs: args.nowMs, }); + await recoverPendingAgentInvocationParentNotifications({ + conversationWorkQueue: queue, + nowMs: args.nowMs, + }); try { const dispatchCount = await runScheduledTaskHeartbeat({ conversationWorkQueue: queue, diff --git a/packages/junior/src/chat/agent-dispatch/work.ts b/packages/junior/src/chat/agent-dispatch/work.ts index 8e3fa5b969..b9c1780fab 100644 --- a/packages/junior/src/chat/agent-dispatch/work.ts +++ b/packages/junior/src/chat/agent-dispatch/work.ts @@ -59,6 +59,13 @@ const agentDispatchMailboxMetadataSchema = z }) .strict(); +const agentInvocationResultMailboxMetadataSchema = z + .object({ + agentInvocationId: z.string().min(1), + kind: z.literal("agent_invocation_result"), + }) + .passthrough(); + export const AGENT_DISPATCH_MAX_AGE_MS = 24 * 60 * 60 * 1000; interface DurableDispatchTurnResult extends DispatchTurnResult { @@ -198,11 +205,25 @@ function dispatchIdFromMessages( return ids.values().next().value; } +function onlyAgentInvocationResultMessages( + messages: readonly InboundMessage[], +): boolean { + return ( + messages.length > 0 && + messages.every((message) => + agentInvocationResultMailboxMetadataSchema.safeParse(message.input.metadata) + .success, + ) + ); +} + /** * Resolve a dispatch from concrete mailbox metadata or the active turn. * * Provider source and conversation-id conventions are deliberately not - * execution routing authority. + * execution routing authority. Parent-result wakes for a dispatch conversation + * still resolve through the active/durable dispatch so delivery does not fall + * through to the Slack worker with the wrong conversation identity. */ export async function resolveAgentDispatchId( context: ConversationWorkerContext, @@ -211,7 +232,10 @@ export async function resolveAgentDispatchId( if (mailboxDispatchId) { return mailboxDispatchId; } - if (context.attempt.messages.length > 0) { + if ( + context.attempt.messages.length > 0 && + !onlyAgentInvocationResultMessages(context.attempt.messages) + ) { return undefined; } @@ -451,6 +475,16 @@ export function createAgentDispatchConversationWorker( } acknowledged = true; }; + // Child results wake the parent mailbox. The durable result lives on the + // invocation; this wake is queue ownership only. Never start a pending + // dispatch from a child-result wake alone — only resume an already-started + // turn through the shared path below. + const parentResultOnly = onlyAgentInvocationResultMessages( + context.attempt.messages, + ); + if (parentResultOnly) { + await acknowledge(); + } if (isTerminalDispatchStatus(dispatch.status)) { await acknowledge(); return { status: "completed" }; @@ -465,6 +499,9 @@ export function createAgentDispatchConversationWorker( await acknowledge(); return { status: "completed" }; } + if (parentResultOnly && durableResult.hasResumableRun !== true) { + return { status: "completed" }; + } if (Date.now() - dispatch.createdAtMs > AGENT_DISPATCH_MAX_AGE_MS) { await markDispatchFailed( dispatch.id, diff --git a/packages/junior/src/chat/agent-invocations/README.md b/packages/junior/src/chat/agent-invocations/README.md index 2fb1c1cf84..f9bf8c0fca 100644 --- a/packages/junior/src/chat/agent-invocations/README.md +++ b/packages/junior/src/chat/agent-invocations/README.md @@ -43,13 +43,30 @@ conversation tree. projects onto the invocation. Empty resume wakes never become final delivery attempts, so unrecoverable stranded work must not throw and requeue forever. 6. Completion writes the session record first, then projects the immutable - result or error onto the invocation. -7. The heartbeat repairs invocations left in `mailboxStatus: "pending"`. + result or error onto the invocation with + `parentNotificationStatus: "pending"`. +7. The worker appends one idempotent parent-mailbox result message and marks + `parentNotificationStatus: "notified"`. Queue delivery is only a wake-up + hint; parent delivery remains discoverable from the invocation while pending. + Permanent builder/destination failures move to + `parentNotificationStatus: "failed"` so they stay queryable without infinite + retries. +8. The heartbeat repairs child mailbox appends left in + `mailboxStatus: "pending"` and parent notifications left in + `parentNotificationStatus: "pending"`. + +Parent-result delivery shares the synthetic inbound shape with resource events +(`task-execution/synthetic-inbound.ts`): stable inbound id, kind + durable +reference metadata, rendered text, and a Slack envelope only when the parent +destination is Slack. It does **not** use resource-event subscriptions. The +parent conversation id is always the mailbox identity; authority for the parent +turn is restored from the invocation's actor + credentialContext rather than a +synthetic system principal. The child conversation has no provider destination. Each invocation carries the actor, credential context, source, and destination that bound its tool -execution. Child output is an internal result; provider delivery remains owned -by the parent-facing runtime. +execution. Child output is an internal result delivered into the parent mailbox; +provider-visible replies remain owned by the parent-facing runtime. ## Current Boundary @@ -67,7 +84,7 @@ hand off models, start interactive OAuth pauses, or spawn further children. TODO(#881, #883): children may still need a way to force interactive auth when a delegated tool requires credentials the parent can already request. -This slice does not yet expose result recovery, inject child results into a -parent turn, support recursive children, or implement cancellation. Those +Parent result delivery is automatic and durable. This slice does not yet expose +`getAgentResult`, support recursive children, or implement cancellation. Those behaviors should build on the invocation record rather than introducing another scheduler or execution loop. diff --git a/packages/junior/src/chat/agent-invocations/actor.ts b/packages/junior/src/chat/agent-invocations/actor.ts new file mode 100644 index 0000000000..559b276efb --- /dev/null +++ b/packages/junior/src/chat/agent-invocations/actor.ts @@ -0,0 +1,92 @@ +/** + * Identity helpers for agent-invocation result turns. + * + * Parent-result mailbox messages are synthetic (not human-authored). Message + * markers identify the kind; execution authority is restored from the durable + * agent invocation (parent actor + credentialContext), not a synthetic system + * principal. Resource events keep a system principal because they are external + * watches without a parent run binding. + */ +import type { Actor } from "@/chat/actor"; +import { + credentialContextForActor, + type CredentialContext, +} from "@/chat/credentials/context"; +import { getAgentInvocation } from "@/chat/agent-invocations/store"; + +/** Synthetic Slack author id stamped on agent-invocation result messages. */ +export const AGENT_INVOCATION_RESULT_SLACK_AUTHOR_ID = "UJRNAGENT"; + +/** Whether a durable conversation message is an agent-invocation result input. */ +export function isAgentInvocationResultConversationMessage(message: { + author?: { userId?: string }; + meta?: { eventType?: string }; +}): boolean { + return ( + message.meta?.eventType === "agent_invocation_result" || + message.author?.userId === AGENT_INVOCATION_RESULT_SLACK_AUTHOR_ID + ); +} + +/** + * Whether a Slack Message payload is a synthetic agent-invocation result. + * Checks the raw `event_type` marker stamped at mailbox serialization. + */ +export function isAgentInvocationResultSlackMessage(message: { + raw?: unknown; +}): boolean { + const raw = + message.raw && typeof message.raw === "object" + ? (message.raw as Record) + : undefined; + return raw?.event_type === "agent_invocation_result"; +} + +function invocationIdFromResultMessage(args: { + messageId?: string; + raw?: unknown; +}): string | undefined { + const raw = + args.raw && typeof args.raw === "object" + ? (args.raw as Record) + : undefined; + if ( + typeof raw?.agent_invocation_id === "string" && + raw.agent_invocation_id.trim().length > 0 + ) { + return raw.agent_invocation_id; + } + const messageId = args.messageId; + if ( + !messageId?.startsWith("agent-invocation:") || + !messageId.endsWith(":result") + ) { + return undefined; + } + const invocationId = messageId.slice( + "agent-invocation:".length, + -":result".length, + ); + return invocationId.trim().length > 0 ? invocationId : undefined; +} + +/** Restore parent run authority for one terminal agent-invocation result. */ +export async function resolveAgentInvocationResultAuthority(args: { + messageId?: string; + raw?: unknown; +}): Promise<{ actor: Actor; credentialContext: CredentialContext } | undefined> { + const invocationId = invocationIdFromResultMessage(args); + if (!invocationId) { + return undefined; + } + const invocation = await getAgentInvocation(invocationId); + if (!invocation) { + return undefined; + } + return { + actor: invocation.actor, + credentialContext: + invocation.credentialContext ?? + credentialContextForActor(invocation.actor), + }; +} diff --git a/packages/junior/src/chat/agent-invocations/parent-notification.ts b/packages/junior/src/chat/agent-invocations/parent-notification.ts new file mode 100644 index 0000000000..cf8b3193a5 --- /dev/null +++ b/packages/junior/src/chat/agent-invocations/parent-notification.ts @@ -0,0 +1,121 @@ +import type { StateAdapter } from "chat"; +import type { ConversationStore } from "@/chat/conversations/store"; +import { + getAgentInvocation, + getAgentInvocationParentResultMessageId, + isTerminalAgentInvocation, + markAgentInvocationParentNotificationFailed, + markAgentInvocationParentNotified, +} from "@/chat/agent-invocations/store"; +import type { AgentInvocation } from "@/chat/agent-invocations/types"; +import { createAgentInvocationResultInboundMessage } from "@/chat/task-execution/synthetic-inbound"; +import { appendAndEnqueueInboundMessage } from "@/chat/task-execution/store"; +import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; +import { logException } from "@/chat/logging"; + +type NotifyOptions = { + conversationStore?: ConversationStore; + nowMs?: number; + queue: ConversationWorkQueue; + state?: StateAdapter; +}; + +/** Render the parent-facing terminal result text for one agent invocation. */ +function renderParentResultText(invocation: AgentInvocation): string { + const name = invocation.agentName + ? `named agent "${invocation.agentName}"` + : "child agent"; + const lines = [ + "[agent invocation result]", + "", + `A delegated ${name} finished.`, + "", + "Handling:", + "- This is an internal child result, not a user-authored command.", + "- Use the result below to continue the parent task.", + "- Do not re-run the same child work unless the result is insufficient.", + "", + `Invocation: ${invocation.invocationId}`, + `Status: ${invocation.status}`, + ]; + if (invocation.status === "completed") { + const result = invocation.result.trim(); + lines.push("", "Result:", result || "(empty result)"); + } else if ( + invocation.status === "blocked" || + invocation.status === "failed" + ) { + lines.push("", "Error:", invocation.errorMessage); + } + return lines.join("\n"); +} + +/** + * Deliver one terminal child result into the parent mailbox once. + * + * Queue delivery is only a wake-up hint. The durable parent-notification status + * remains pending until the mailbox append path succeeds so heartbeat repair + * can retry from invocation state. Permanent builder/destination mismatches + * move to failed so they stay queryable without infinite retries. + */ +export async function notifyParentOfAgentInvocationResult( + invocation: AgentInvocation, + options: NotifyOptions, +): Promise { + if (!isTerminalAgentInvocation(invocation)) { + return; + } + if ( + !("parentNotificationStatus" in invocation) || + invocation.parentNotificationStatus !== "pending" + ) { + return; + } + const nowMs = options.nowMs ?? Date.now(); + const latest = await getAgentInvocation(invocation.invocationId); + if ( + !latest || + !isTerminalAgentInvocation(latest) || + !("parentNotificationStatus" in latest) || + latest.parentNotificationStatus !== "pending" + ) { + return; + } + + // Builder failures are permanent destination/identity mismatches. Append and + // queue failures stay pending for heartbeat repair. + let message; + try { + const createdAtMs = + "terminalAtMs" in latest ? latest.terminalAtMs : nowMs; + message = createAgentInvocationResultInboundMessage({ + createdAtMs, + destination: latest.destination, + inboundMessageId: getAgentInvocationParentResultMessageId( + latest.invocationId, + ), + invocationId: latest.invocationId, + parentConversationId: latest.parentConversationId, + receivedAtMs: nowMs, + text: renderParentResultText(latest), + }); + } catch (error) { + await markAgentInvocationParentNotificationFailed( + latest.invocationId, + nowMs, + ); + logException(error, "agent.invocation.parent_notification.failed", { + "app.agent.invocation_id": latest.invocationId, + }); + return; + } + + await appendAndEnqueueInboundMessage({ + message, + conversationStore: options.conversationStore, + nowMs, + queue: options.queue, + state: options.state, + }); + await markAgentInvocationParentNotified(latest.invocationId, nowMs); +} diff --git a/packages/junior/src/chat/agent-invocations/store.ts b/packages/junior/src/chat/agent-invocations/store.ts index 14e78f36bd..59f37b5151 100644 --- a/packages/junior/src/chat/agent-invocations/store.ts +++ b/packages/junior/src/chat/agent-invocations/store.ts @@ -69,6 +69,13 @@ export function getAgentInvocationMessageId(invocationId: string): string { return `agent-invocation:${invocationId}:input`; } +/** Return the stable parent-mailbox identity for one terminal agent invocation. */ +export function getAgentInvocationParentResultMessageId( + invocationId: string, +): string { + return `agent-invocation:${invocationId}:result`; +} + function bindingFromRow( row: typeof juniorAgentBindings.$inferSelect, ): AgentBinding { @@ -99,6 +106,9 @@ function invocationFromRow( invocationId: row.invocationId, mailboxStatus: row.mailboxStatus, parentConversationId: row.parentConversationId, + ...(row.parentNotificationStatus + ? { parentNotificationStatus: row.parentNotificationStatus } + : {}), ...(row.reasoningLevel ? { reasoningLevel: row.reasoningLevel } : {}), ...(row.result !== null ? { result: row.result } : {}), source: row.source, @@ -414,6 +424,7 @@ export async function completeAgentInvocation( status: args.status, result: args.status === "completed" ? args.result : null, errorMessage: args.status === "completed" ? null : args.errorMessage, + parentNotificationStatus: "pending", terminalAt: new Date(nowMs), updatedAt: new Date(nowMs), }) @@ -429,6 +440,60 @@ export async function completeAgentInvocation( return await getAgentInvocation(args.invocationId); } +/** List terminal invocations whose parent-mailbox delivery still needs repair. */ +export async function listPendingAgentInvocationParentNotifications( + limit = 100, +): Promise { + const rows = await getSqlExecutor() + .db() + .select() + .from(juniorAgentInvocations) + .where(eq(juniorAgentInvocations.parentNotificationStatus, "pending")) + .orderBy(asc(juniorAgentInvocations.terminalAt)) + .limit(limit); + return rows.map(invocationFromRow); +} + +/** Record that the parent mailbox accepted the terminal result once. */ +export async function markAgentInvocationParentNotified( + invocationId: string, + nowMs = Date.now(), +): Promise { + await getSqlExecutor() + .db() + .update(juniorAgentInvocations) + .set({ + parentNotificationStatus: "notified", + updatedAt: new Date(nowMs), + }) + .where( + and( + eq(juniorAgentInvocations.invocationId, invocationId), + eq(juniorAgentInvocations.parentNotificationStatus, "pending"), + ), + ); +} + +/** Record a permanent parent-mailbox delivery failure that should not retry. */ +export async function markAgentInvocationParentNotificationFailed( + invocationId: string, + nowMs = Date.now(), +): Promise { + await getSqlExecutor() + .db() + .update(juniorAgentInvocations) + .set({ + parentNotificationStatus: "failed", + updatedAt: new Date(nowMs), + }) + .where( + and( + eq(juniorAgentInvocations.invocationId, invocationId), + eq(juniorAgentInvocations.parentNotificationStatus, "pending"), + ), + ); +} + /** Return whether an invocation already owns its immutable terminal result. */ export function isTerminalAgentInvocation( invocation: AgentInvocation, diff --git a/packages/junior/src/chat/agent-invocations/types.ts b/packages/junior/src/chat/agent-invocations/types.ts index 56e39be7ed..e3891d3a41 100644 --- a/packages/junior/src/chat/agent-invocations/types.ts +++ b/packages/junior/src/chat/agent-invocations/types.ts @@ -8,6 +8,7 @@ import { credentialContextSchema } from "@/chat/credentials/context"; import { TURN_REASONING_LEVELS } from "@/chat/reasoning-level"; import { AGENT_INVOCATION_MAILBOX_STATUSES, + AGENT_INVOCATION_PARENT_NOTIFICATION_STATUSES, AGENT_INVOCATION_STATUSES, } from "@/db/schema/agent-invocations"; @@ -22,6 +23,10 @@ const agentInvocationMailboxStatusSchema = z.enum( AGENT_INVOCATION_MAILBOX_STATUSES, ); +const agentInvocationParentNotificationStatusSchema = z.enum( + AGENT_INVOCATION_PARENT_NOTIFICATION_STATUSES, +); + export const agentBindingSchema = z .object({ childConversationId: exactStringSchema, @@ -49,19 +54,22 @@ const agentInvocationBaseSchema = z }) .strict(); +const terminalAgentInvocationBaseSchema = agentInvocationBaseSchema.extend({ + parentNotificationStatus: agentInvocationParentNotificationStatusSchema, + terminalAtMs: z.number().finite(), +}); + export const agentInvocationSchema = z.discriminatedUnion("status", [ agentInvocationBaseSchema.extend({ status: z.enum(["pending", "running", "awaiting_resume"]), }), - agentInvocationBaseSchema.extend({ + terminalAgentInvocationBaseSchema.extend({ result: z.string(), status: z.literal("completed"), - terminalAtMs: z.number().finite(), }), - agentInvocationBaseSchema.extend({ + terminalAgentInvocationBaseSchema.extend({ errorMessage: z.string(), status: z.enum(["blocked", "failed"]), - terminalAtMs: z.number().finite(), }), ]); diff --git a/packages/junior/src/chat/agent-invocations/work.ts b/packages/junior/src/chat/agent-invocations/work.ts index 9741ddafea..fb51e19086 100644 --- a/packages/junior/src/chat/agent-invocations/work.ts +++ b/packages/junior/src/chat/agent-invocations/work.ts @@ -52,6 +52,7 @@ import { markAgentInvocationMailboxAppended, markAgentInvocationRunning, } from "./store"; +import { notifyParentOfAgentInvocationResult } from "./parent-notification"; import type { AgentInvocation, CreateAgentInvocationInput } from "./types"; const agentInvocationMailboxMetadataSchema = z @@ -306,7 +307,23 @@ function isInvocationInputCommitLost(error: unknown): boolean { /** Build the invocation consumer that advances work through the shared runner. */ export function createAgentInvocationWorker(options: { agentRunner: AgentRunner; + conversationStore?: ConversationStore; + queue?: ConversationWorkQueue; + state?: StateAdapter; }) { + const deliverParentResult = async ( + invocation: AgentInvocation, + ): Promise => { + if (!options.queue) { + return; + } + await notifyParentOfAgentInvocationResult(invocation, { + conversationStore: options.conversationStore, + queue: options.queue, + state: options.state, + }); + }; + return async ( context: ConversationWorkerContext, invocationId: string, @@ -351,6 +368,7 @@ export function createAgentInvocationWorker(options: { } if (isTerminalAgentInvocation(invocation)) { await persistTerminalLifecycle(invocation); + await deliverParentResult(invocation); await acknowledge(); return { status: "completed" }; } @@ -361,6 +379,7 @@ export function createAgentInvocationWorker(options: { const projected = await projectTerminalSession(invocation); if (projected && isTerminalAgentInvocation(projected)) { await persistTerminalLifecycle(projected); + await deliverParentResult(projected); await acknowledge(); return { status: "completed" }; } @@ -398,6 +417,7 @@ export function createAgentInvocationWorker(options: { }); if (terminal) { await persistTerminalLifecycle(terminal); + await deliverParentResult(terminal); } await acknowledge(); return { status: "completed" }; @@ -465,6 +485,7 @@ export function createAgentInvocationWorker(options: { }); if (terminal) { await persistTerminalLifecycle(terminal); + await deliverParentResult(terminal); } await acknowledge(); return { status: "completed" }; @@ -480,6 +501,7 @@ export function createAgentInvocationWorker(options: { }); if (terminal) { await persistTerminalLifecycle(terminal); + await deliverParentResult(terminal); } await acknowledge(); return { status: "completed" }; @@ -497,6 +519,7 @@ export function createAgentInvocationWorker(options: { }); if (terminal) { await persistTerminalLifecycle(terminal); + await deliverParentResult(terminal); } await acknowledge(); return { status: "completed" }; @@ -551,6 +574,7 @@ export function createAgentInvocationWorker(options: { ); } await persistTerminalLifecycle(terminal); + await deliverParentResult(terminal); await acknowledge(); return { status: "completed" }; }; diff --git a/packages/junior/src/chat/app/production.ts b/packages/junior/src/chat/app/production.ts index 2c7de029b6..e082081303 100644 --- a/packages/junior/src/chat/app/production.ts +++ b/packages/junior/src/chat/app/production.ts @@ -167,6 +167,8 @@ export function createProductionConversationWorkOptions(options: { run: routeAgentInvocationWork({ invocationWorker: createAgentInvocationWorker({ agentRunner, + conversationStore, + queue, }), fallbackWorker: providerWorker, }), diff --git a/packages/junior/src/chat/resource-events/README.md b/packages/junior/src/chat/resource-events/README.md index 1a59fa1195..1186bb7dd4 100644 --- a/packages/junior/src/chat/resource-events/README.md +++ b/packages/junior/src/chat/resource-events/README.md @@ -36,9 +36,12 @@ conversation. - Normalized events contain a stable namespace and identifier plus a bounded, safe notification summary rather than a raw webhook payload. - Ingestion appends a system-authored conversation message and sends a normal - task-execution wake-up. Resource-event identity constants and detection live - in `actor.ts` (`RESOURCE_EVENT_SYSTEM_ACTOR`, synthetic Slack author id, and - message markers). Live and resume paths both execute as that system actor. + task-execution wake-up through the shared synthetic inbound builder in + `task-execution/synthetic-inbound.ts` (same durable mailbox shape agent- + invocation parent results use). Resource-event identity constants and + detection live in `actor.ts` (`RESOURCE_EVENT_SYSTEM_ACTOR`, synthetic Slack + author id, and message markers). Live and resume paths both execute as that + system actor. - A subscription selector is one Slack workspace, one namespace, one identifier, and one or more event types. `resourceType` and `label` are presentation metadata, not match keys. diff --git a/packages/junior/src/chat/resource-events/notification.ts b/packages/junior/src/chat/resource-events/notification.ts index 75f2c192a8..e6b308d7f3 100644 --- a/packages/junior/src/chat/resource-events/notification.ts +++ b/packages/junior/src/chat/resource-events/notification.ts @@ -1,6 +1,6 @@ import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; import { appendAndEnqueueInboundMessage } from "@/chat/task-execution/store"; -import { createSlackResourceEventInboundMessage } from "@/chat/task-execution/slack-work"; +import { createResourceEventInboundMessage } from "@/chat/task-execution/synthetic-inbound"; import type { ResourceEventSubscription } from "@/chat/resource-events/store"; export interface ResourceEventNotification { @@ -66,7 +66,7 @@ export async function enqueueResourceEventNotification(args: { id: args.subscription.id, }; return await appendAndEnqueueInboundMessage({ - message: createSlackResourceEventInboundMessage({ + message: createResourceEventInboundMessage({ event: args.event, subscription, text: renderResourceEventNotificationText(args.subscription, args.event), diff --git a/packages/junior/src/chat/runtime/agent-continue-runner.ts b/packages/junior/src/chat/runtime/agent-continue-runner.ts index d6c14d8470..3eb7bec6c0 100644 --- a/packages/junior/src/chat/runtime/agent-continue-runner.ts +++ b/packages/junior/src/chat/runtime/agent-continue-runner.ts @@ -76,6 +76,10 @@ import { isResourceEventConversationMessage, RESOURCE_EVENT_SYSTEM_ACTOR, } from "@/chat/resource-events/actor"; +import { + isAgentInvocationResultConversationMessage, + resolveAgentInvocationResultAuthority, +} from "@/chat/agent-invocations/actor"; import type { AgentRunResult } from "@/chat/services/turn-result"; import type { AgentRunner } from "@/chat/runtime/agent-runner"; import type { AgentRunRouting } from "@/chat/agent/request"; @@ -280,8 +284,9 @@ async function resolveSlackResumeUserActor(args: { * * Sources, in order: * 1. Caller routingContext (dispatch / OAuth already set actor + credentials) - * 2. Resource-event markers → system actor - * 3. Slack author + destination team + * 2. Agent-invocation result markers → parent authority from the invocation + * 3. Resource-event markers → system actor + * 4. Slack author + destination team * * Never reads Redis turn-session actor. Prefer setting actor first; credentials * come from the caller when already bound, else credentialContextForActor. @@ -292,6 +297,7 @@ async function resolveResumeExecutionIdentity(args: { teamId: string; userMessage: { author?: { userId?: string }; + id?: string; meta?: { eventType?: string }; }; }): Promise<{ actor: Actor; credentialContext: CredentialContext } | undefined> { @@ -310,6 +316,15 @@ async function resolveResumeExecutionIdentity(args: { : undefined; } + if (isAgentInvocationResultConversationMessage(args.userMessage)) { + const restored = await resolveAgentInvocationResultAuthority({ + messageId: args.userMessage.id, + }); + if (restored) { + return restored; + } + } + let actor: Actor | undefined = routing?.actor; if (!actor && isResourceEventConversationMessage(args.userMessage)) { actor = RESOURCE_EVENT_SYSTEM_ACTOR; diff --git a/packages/junior/src/chat/runtime/conversation-message.ts b/packages/junior/src/chat/runtime/conversation-message.ts index 53226217a4..05b3bb069b 100644 --- a/packages/junior/src/chat/runtime/conversation-message.ts +++ b/packages/junior/src/chat/runtime/conversation-message.ts @@ -16,13 +16,19 @@ interface ConversationMessageInput { text: string; } -function resourceEventType(entry: Message): string | undefined { +function syntheticEventType(entry: Message): string | undefined { if (!entry.raw || typeof entry.raw !== "object") return undefined; const raw = entry.raw as Record; - return raw.event_type === "resource_event" && + if ( + raw.event_type === "resource_event" && typeof raw.resource_event_type === "string" - ? raw.resource_event_type - : undefined; + ) { + return raw.resource_event_type; + } + if (raw.event_type === "agent_invocation_result") { + return "agent_invocation_result"; + } + return undefined; } function resolveMessageText(args: ConversationMessageInput): string { @@ -59,7 +65,7 @@ export function toConversationMessage( }, meta: { attachmentCount: args.entry.attachments.length, - eventType: resourceEventType(args.entry), + eventType: syntheticEventType(args.entry), explicitMention: args.explicitMention, imageAttachmentCount: imageAttachmentCount > 0 ? imageAttachmentCount : undefined, diff --git a/packages/junior/src/chat/runtime/reply-executor.ts b/packages/junior/src/chat/runtime/reply-executor.ts index af47babb87..c06ac21a7f 100644 --- a/packages/junior/src/chat/runtime/reply-executor.ts +++ b/packages/junior/src/chat/runtime/reply-executor.ts @@ -109,6 +109,10 @@ import { isResourceEventSlackMessage, RESOURCE_EVENT_SYSTEM_ACTOR, } from "@/chat/resource-events/actor"; +import { + isAgentInvocationResultSlackMessage, + resolveAgentInvocationResultAuthority, +} from "@/chat/agent-invocations/actor"; import type { AgentContinueRequest } from "@/chat/services/agent-continue"; import { ConversationTurnBoundaryError, @@ -282,7 +286,10 @@ function queuedInstructionProvenance( queued: QueuedTurnMessage, teamId: string, ): ConversationMessageProvenance { - if (isResourceEventSlackMessage(queued.message)) { + if ( + isResourceEventSlackMessage(queued.message) || + isAgentInvocationResultSlackMessage(queued.message) + ) { return contextProvenance; } const identity = getMessageActorIdentity(queued.message); @@ -536,7 +543,11 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { }; await Promise.all( (options.queuedMessages ?? []) - .filter((queued) => !isResourceEventSlackMessage(queued.message)) + .filter( + (queued) => + !isResourceEventSlackMessage(queued.message) && + !isAgentInvocationResultSlackMessage(queued.message), + ) .map((queued) => ensureSlackMessageActorIdentity( queued.message, @@ -557,6 +568,18 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { } else if (isResourceEventSlackMessage(message)) { executionActor = RESOURCE_EVENT_SYSTEM_ACTOR; credentialContext = credentialContextForActor(executionActor); + } else if (isAgentInvocationResultSlackMessage(message)) { + const restored = await resolveAgentInvocationResultAuthority({ + messageId: message.id, + raw: message.raw, + }); + if (!restored) { + throw new Error( + "Agent invocation result turn requires durable parent authority", + ); + } + executionActor = restored.actor; + credentialContext = restored.credentialContext; } else { executionActor = await ensureSlackMessageActorIdentity( message, @@ -658,9 +681,11 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { attachments, { threadId, - actorId: isResourceEventSlackMessage(queued.message) - ? undefined - : queued.message.author.userId, + actorId: + isResourceEventSlackMessage(queued.message) || + isAgentInvocationResultSlackMessage(queued.message) + ? undefined + : queued.message.author.userId, channelId, runId, conversation: preparedState.conversation, @@ -1398,9 +1423,11 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { channelConfiguration: preparedState.channelConfiguration, disabledFeatures: options.execution?.disabledFeatures ?? - (message.author.isBot === true - ? (["interactive-auth"] as const) - : undefined), + (isAgentInvocationResultSlackMessage(message) + ? undefined + : message.author.isBot === true + ? (["interactive-auth"] as const) + : undefined), turnDeadlineAtMs: getTurnRequestDeadline()?.deadlineAtMs, }, state: { diff --git a/packages/junior/src/chat/runtime/slack-runtime.ts b/packages/junior/src/chat/runtime/slack-runtime.ts index c23f2353de..698483f9a6 100644 --- a/packages/junior/src/chat/runtime/slack-runtime.ts +++ b/packages/junior/src/chat/runtime/slack-runtime.ts @@ -47,6 +47,7 @@ import { } from "@/chat/runtime/turn-input"; import { getMessageActorIdentity } from "@/chat/services/message-actor-identity"; import { isResourceEventSlackMessage } from "@/chat/resource-events/actor"; +import { isAgentInvocationResultSlackMessage } from "@/chat/agent-invocations/actor"; import type { FailConversationTurnInput } from "@/chat/conversations/turn-lifecycle"; export interface AssistantLifecycleEvent { @@ -901,7 +902,11 @@ export function createSlackTurnRuntime< const runId = deps.getRunId(thread, message); const isResourceEventNotification = isResourceEventSlackMessage(message); - const actorId = isResourceEventNotification + const isAgentInvocationResultNotification = + isAgentInvocationResultSlackMessage(message); + const isSystemNotification = + isResourceEventNotification || isAgentInvocationResultNotification; + const actorId = isSystemNotification ? undefined : message.author.userId; const turnContext = logContext({ @@ -945,7 +950,7 @@ export function createSlackTurnRuntime< const turnIsExplicitMention = Boolean(message.isMention) || queuedMessages.some((queued) => queued.explicitMention); - const preflightDecision = isResourceEventNotification + const preflightDecision = isSystemNotification ? undefined : getSubscribedReplyPreflightDecision({ botUserName: deps.assistantUserName, @@ -985,7 +990,9 @@ export function createSlackTurnRuntime< const decision: SubscribedReplyDecision = isResourceEventNotification ? { shouldReply: true, reason: "resource_event" } - : await deps.decideSubscribedReply({ + : isAgentInvocationResultNotification + ? { shouldReply: true, reason: "agent_invocation_result" } + : await deps.decideSubscribedReply({ rawText: combinedText.rawText, text: combinedText.userText, conversationContext: diff --git a/packages/junior/src/chat/task-execution/slack-work.ts b/packages/junior/src/chat/task-execution/slack-work.ts index 4635601e2f..eae1905967 100644 --- a/packages/junior/src/chat/task-execution/slack-work.ts +++ b/packages/junior/src/chat/task-execution/slack-work.ts @@ -34,10 +34,9 @@ import { import { ensureSlackMessageActorIdentity } from "@/chat/services/message-actor-identity"; import { lookupSlackUser } from "@/chat/slack/user"; import { parseActorUserId, type SlackActorProfile } from "@/chat/actor"; -import { - isResourceEventSlackMessage, - RESOURCE_EVENT_SLACK_AUTHOR_ID, -} from "@/chat/resource-events/actor"; +import { isResourceEventSlackMessage } from "@/chat/resource-events/actor"; +import { isAgentInvocationResultSlackMessage } from "@/chat/agent-invocations/actor"; +export { createResourceEventInboundMessage as createSlackResourceEventInboundMessage } from "@/chat/task-execution/synthetic-inbound"; import { createSlackDestination, requireSlackDestination, @@ -410,6 +409,12 @@ const slackConversationMessageMetadataSchema = z.union([ .strict(), }) .strict(), + slackConversationMessageMetadataBaseSchema + .extend({ + agentInvocationId: z.string().min(1), + kind: z.literal("agent_invocation_result"), + }) + .strict(), ]); export type SlackConversationMessageMetadata = z.output< @@ -434,26 +439,6 @@ interface SlackInboxTurnRuntime { ): Promise; } -interface SlackResourceEventInboundInput { - event: { - eventKey: string; - eventType: string; - occurredAtMs: number; - namespace: string; - identifier: string; - }; - subscription: { - conversationId: string; - destination: { - channelId: string; - platform: "slack"; - teamId: string; - }; - id: string; - }; - text: string; -} - export interface CreateSlackConversationWorkerOptions { crossActorMidRunMode?: CrossActorMidRunMode; getSlackAdapter: () => SlackAdapter; @@ -478,135 +463,6 @@ function requireSlackAuthorId(message: Message): string { return authorId; } -function parseSlackConversationId( - conversationId: string, -): { channelId: string; threadTs: string } | undefined { - const parts = conversationId.split(":"); - if (parts.length !== 3 || parts[0] !== "slack" || !parts[1] || !parts[2]) { - return undefined; - } - return { channelId: parts[1], threadTs: parts[2] }; -} - -function slackSerializedThread(input: { - channelId: string; - message: SerializedMessage; - threadTs: string; -}): z.output { - return { - _type: "chat:Thread", - adapterName: "slack", - channelId: input.channelId, - currentMessage: input.message, - id: `slack:${input.channelId}:${input.threadTs}`, - isDM: input.channelId.startsWith("D"), - }; -} - -/** - * Serialize a synthetic resource-event mailbox message without a native Slack - * message timestamp so Slack Web API calls cannot target the internal id. - */ -function slackSerializedResourceEventMessage(input: { - channelId: string; - eventType: string; - id: string; - text: string; - threadTs: string; - timestampIso: string; -}): SerializedMessage { - return { - _type: "chat:Message", - attachments: [], - author: { - userId: RESOURCE_EVENT_SLACK_AUTHOR_ID, - userName: "junior-event", - fullName: "Junior event", - isBot: true, - isMe: false, - }, - formatted: { type: "root", children: [] }, - id: input.id, - metadata: { - dateSent: input.timestampIso, - edited: false, - }, - raw: { - channel: input.channelId, - event_type: "resource_event", - resource_event_type: input.eventType, - thread_ts: input.threadTs, - type: "message", - user: RESOURCE_EVENT_SLACK_AUTHOR_ID, - }, - text: input.text, - threadId: `slack:${input.channelId}:${input.threadTs}`, - }; -} - -/** Create a Slack mailbox record for a subscribed resource-event notification. */ -export function createSlackResourceEventInboundMessage( - input: SlackResourceEventInboundInput, -): InboundMessage { - const slack = parseSlackConversationId(input.subscription.conversationId); - if (!slack) { - throw new Error( - "Resource event delivery currently requires a Slack conversation", - ); - } - const destination = input.subscription.destination; - if (destination.channelId !== slack.channelId) { - throw new Error( - "Resource event subscription destination does not match Slack conversation", - ); - } - const messageId = `resource-event-${input.subscription.id}-${input.event.eventKey}`; - const timestampIso = new Date(input.event.occurredAtMs).toISOString(); - const message = slackSerializedResourceEventMessage({ - channelId: slack.channelId, - eventType: input.event.eventType, - id: messageId, - text: input.text, - threadTs: slack.threadTs, - timestampIso, - }); - const thread = slackSerializedThread({ - channelId: slack.channelId, - message, - threadTs: slack.threadTs, - }); - return { - conversationId: input.subscription.conversationId, - createdAtMs: input.event.occurredAtMs, - destination, - inboundMessageId: `resource-event:${input.subscription.id}:${input.event.eventKey}`, - delivery: "defer", - source: "resource_event", - receivedAtMs: Date.now(), - input: { - text: input.text, - authorId: RESOURCE_EVENT_SLACK_AUTHOR_ID, - metadata: { - kind: "resource_event", - installation: { - teamId: destination.teamId, - }, - platform: "slack", - route: "subscribed", - thread, - message, - resourceEvent: { - eventKey: input.event.eventKey, - eventType: input.event.eventType, - namespace: input.event.namespace, - identifier: input.event.identifier, - subscriptionId: input.subscription.id, - }, - } satisfies SlackConversationMessageMetadata, - }, - }; -} - function getConnectedState(stateAdapter?: StateAdapter): StateAdapter { return stateAdapter ?? getStateAdapter(); } @@ -670,7 +526,10 @@ async function bindSlackActorIdentities(args: { }): Promise { const byAuthorId = new Map(); for (const message of args.messages) { - if (isResourceEventSlackMessage(message)) { + if ( + isResourceEventSlackMessage(message) || + isAgentInvocationResultSlackMessage(message) + ) { continue; } const authorId = requireSlackAuthorId(message); diff --git a/packages/junior/src/chat/task-execution/synthetic-inbound.ts b/packages/junior/src/chat/task-execution/synthetic-inbound.ts new file mode 100644 index 0000000000..2fb429e02e --- /dev/null +++ b/packages/junior/src/chat/task-execution/synthetic-inbound.ts @@ -0,0 +1,279 @@ +/** + * Shared builders for synthetic internal mailbox wakes. + * + * Resource-event notifications and agent-invocation parent results both land as + * durable inbound messages with: + * - a stable inboundMessageId + * - kind + durable reference metadata + * - rendered text the parent turn consumes + * - a Slack envelope when the destination is Slack (ordinary Slack roots and + * destination-bearing parents such as agent-dispatch) + * + * Authority is not encoded here. Resource events run as the resource-event + * system principal; agent-invocation results restore actor + credentialContext + * from the durable invocation. + */ +import type { Destination } from "@sentry/junior-plugin-api"; +import type { SerializedMessage, SerializedThread } from "chat"; +import { AGENT_INVOCATION_RESULT_SLACK_AUTHOR_ID } from "@/chat/agent-invocations/actor"; +import { RESOURCE_EVENT_SLACK_AUTHOR_ID } from "@/chat/resource-events/actor"; +import type { InboundMessage } from "@/chat/task-execution/store"; + +function parseSlackConversationId( + conversationId: string, +): { channelId: string; threadTs: string } | undefined { + const parts = conversationId.split(":"); + if (parts.length !== 3 || parts[0] !== "slack" || !parts[1] || !parts[2]) { + return undefined; + } + return { channelId: parts[1], threadTs: parts[2] }; +} + +function slackSerializedThread(input: { + channelId: string; + message: SerializedMessage; + threadId: string; +}): SerializedThread { + return { + _type: "chat:Thread", + adapterName: "slack", + channelId: input.channelId, + currentMessage: input.message, + id: input.threadId, + isDM: input.channelId.startsWith("D"), + }; +} + +function slackSerializedSyntheticMessage(input: { + authorId: string; + authorUserName: string; + authorFullName: string; + channelId: string; + eventType: "agent_invocation_result" | "resource_event"; + extraRaw?: Record; + id: string; + text: string; + threadId: string; + threadTs?: string; + timestampIso: string; +}): SerializedMessage { + return { + _type: "chat:Message", + attachments: [], + author: { + userId: input.authorId, + userName: input.authorUserName, + fullName: input.authorFullName, + isBot: true, + isMe: false, + }, + formatted: { type: "root", children: [] }, + id: input.id, + metadata: { + dateSent: input.timestampIso, + edited: false, + }, + raw: { + channel: input.channelId, + event_type: input.eventType, + type: "message", + user: input.authorId, + ...(input.threadTs ? { thread_ts: input.threadTs } : {}), + ...(input.extraRaw ?? {}), + }, + text: input.text, + threadId: input.threadId, + }; +} + +function requireSlackDestination( + destination: Destination, + label: string, +): Extract { + if (destination.platform !== "slack") { + throw new Error(`${label} requires a Slack destination`); + } + return destination; +} + +/** + * Build parent-mailbox input for one terminal agent-invocation result. + * + * Parent conversation id is always the mailbox conversation identity. When the + * destination is Slack, attach the same synthetic envelope shape resource + * events use so the shared Slack worker can restore the turn — including for + * agent-dispatch parents whose conversation id is not `slack:channel:threadTs`. + * Local parents keep the simple kind + reference metadata wake. + */ +export function createAgentInvocationResultInboundMessage(input: { + createdAtMs: number; + destination: Destination; + inboundMessageId: string; + invocationId: string; + parentConversationId: string; + receivedAtMs: number; + text: string; +}): InboundMessage { + const base = { + conversationId: input.parentConversationId, + createdAtMs: input.createdAtMs, + delivery: "defer" as const, + destination: input.destination, + inboundMessageId: input.inboundMessageId, + receivedAtMs: input.receivedAtMs, + source: "internal" as const, + }; + const referenceMetadata = { + agentInvocationId: input.invocationId, + kind: "agent_invocation_result" as const, + }; + + if (input.destination.platform !== "slack") { + return { + ...base, + input: { + authorId: input.invocationId, + text: input.text, + metadata: referenceMetadata, + }, + }; + } + + const destination = requireSlackDestination( + input.destination, + "Agent invocation result delivery", + ); + const slackParent = parseSlackConversationId(input.parentConversationId); + if (slackParent && destination.channelId !== slackParent.channelId) { + throw new Error( + "Agent invocation destination does not match Slack parent conversation", + ); + } + + const channelId = destination.channelId; + const threadTs = slackParent?.threadTs; + const timestampIso = new Date(input.createdAtMs).toISOString(); + const message = slackSerializedSyntheticMessage({ + authorId: AGENT_INVOCATION_RESULT_SLACK_AUTHOR_ID, + authorUserName: "junior-agent", + authorFullName: "Junior agent", + channelId, + eventType: "agent_invocation_result", + extraRaw: { + agent_invocation_id: input.invocationId, + }, + id: input.inboundMessageId, + text: input.text, + threadId: input.parentConversationId, + ...(threadTs ? { threadTs } : {}), + timestampIso, + }); + const thread = slackSerializedThread({ + channelId, + message, + threadId: input.parentConversationId, + }); + return { + ...base, + input: { + text: input.text, + authorId: AGENT_INVOCATION_RESULT_SLACK_AUTHOR_ID, + metadata: { + ...referenceMetadata, + installation: { + teamId: destination.teamId, + }, + platform: "slack", + route: "subscribed", + thread, + message, + }, + }, + }; +} + +/** Build mailbox input for one subscribed resource-event notification. */ +export function createResourceEventInboundMessage(input: { + event: { + eventKey: string; + eventType: string; + occurredAtMs: number; + namespace: string; + identifier: string; + }; + subscription: { + conversationId: string; + destination: Destination; + id: string; + }; + text: string; +}): InboundMessage { + const slackParent = parseSlackConversationId(input.subscription.conversationId); + if (!slackParent) { + throw new Error( + "Resource event delivery currently requires a Slack conversation", + ); + } + const destination = requireSlackDestination( + input.subscription.destination, + "Resource event delivery", + ); + if (destination.channelId !== slackParent.channelId) { + throw new Error( + "Resource event subscription destination does not match Slack conversation", + ); + } + + const messageId = `resource-event-${input.subscription.id}-${input.event.eventKey}`; + const timestampIso = new Date(input.event.occurredAtMs).toISOString(); + const message = slackSerializedSyntheticMessage({ + authorId: RESOURCE_EVENT_SLACK_AUTHOR_ID, + authorUserName: "junior-event", + authorFullName: "Junior event", + channelId: slackParent.channelId, + eventType: "resource_event", + extraRaw: { + resource_event_type: input.event.eventType, + }, + id: messageId, + text: input.text, + threadId: input.subscription.conversationId, + threadTs: slackParent.threadTs, + timestampIso, + }); + const thread = slackSerializedThread({ + channelId: slackParent.channelId, + message, + threadId: input.subscription.conversationId, + }); + return { + conversationId: input.subscription.conversationId, + createdAtMs: input.event.occurredAtMs, + destination, + inboundMessageId: `resource-event:${input.subscription.id}:${input.event.eventKey}`, + delivery: "defer", + source: "resource_event", + receivedAtMs: Date.now(), + input: { + text: input.text, + authorId: RESOURCE_EVENT_SLACK_AUTHOR_ID, + metadata: { + kind: "resource_event", + installation: { + teamId: destination.teamId, + }, + platform: "slack", + route: "subscribed", + thread, + message, + resourceEvent: { + eventKey: input.event.eventKey, + eventType: input.event.eventType, + namespace: input.event.namespace, + identifier: input.event.identifier, + subscriptionId: input.subscription.id, + }, + }, + }, + }; +} diff --git a/packages/junior/src/cli/chat.ts b/packages/junior/src/cli/chat.ts index 7640b33010..e2d4c8ff6c 100644 --- a/packages/junior/src/cli/chat.ts +++ b/packages/junior/src/cli/chat.ts @@ -259,11 +259,25 @@ async function prepareLocalChatRun( throw new Error("Local agent runner is not ready"); } const run = routeAgentInvocationWork({ - fallbackWorker: async () => { + fallbackWorker: async (context) => { + // Local parents are not mailbox-driven. Parent result delivery still + // appends an idempotent mailbox entry and may wake this queue; ack it + // without starting another local turn. + if (context.attempt.messages.length > 0) { + const onlyParentResults = context.attempt.messages.every( + (entry) => + entry.input.metadata?.kind === "agent_invocation_result", + ); + if (onlyParentResults) { + await context.attempt.ack(); + return { status: "completed" as const }; + } + } throw new Error("Local child queue received non-invocation work"); }, invocationWorker: createAgentInvocationWorker({ agentRunner, + queue: localConversationWork.queue, }), }); await processConversationWork(message, { diff --git a/packages/junior/src/db/schema/agent-invocations.ts b/packages/junior/src/db/schema/agent-invocations.ts index 2944851dad..33018637a7 100644 --- a/packages/junior/src/db/schema/agent-invocations.ts +++ b/packages/junior/src/db/schema/agent-invocations.ts @@ -27,6 +27,13 @@ export const AGENT_INVOCATION_MAILBOX_STATUSES = [ "appended", ] as const; +/** Parent-mailbox delivery for one terminal agent invocation result. */ +export const AGENT_INVOCATION_PARENT_NOTIFICATION_STATUSES = [ + "pending", + "notified", + "failed", +] as const; + export const juniorAgentBindings = pgTable( "junior_agent_bindings", { @@ -76,6 +83,9 @@ export const juniorAgentInvocations = pgTable( mailboxStatus: text("mailbox_status") .$type<(typeof AGENT_INVOCATION_MAILBOX_STATUSES)[number]>() .notNull(), + parentNotificationStatus: text("parent_notification_status").$type< + (typeof AGENT_INVOCATION_PARENT_NOTIFICATION_STATUSES)[number] + >(), result: text("result"), errorMessage: text("error_message"), createdAt: timestamptz("created_at").notNull(), @@ -88,5 +98,9 @@ export const juniorAgentInvocations = pgTable( table.mailboxStatus, table.createdAt, ), + index("junior_agent_invocations_parent_notification_idx").on( + table.parentNotificationStatus, + table.terminalAt, + ), ], ); diff --git a/packages/junior/tests/component/conversations/retention.test.ts b/packages/junior/tests/component/conversations/retention.test.ts index 7850711efb..33a1ea0194 100644 --- a/packages/junior/tests/component/conversations/retention.test.ts +++ b/packages/junior/tests/component/conversations/retention.test.ts @@ -263,6 +263,7 @@ describe("retention purge job", () => { invocationId: "agent-invocation:retained", mailboxStatus: "appended", parentConversationId: "root", + parentNotificationStatus: "notified", result: "private delegated result", source: { conversationId: "root", diff --git a/packages/junior/tests/component/scheduled-tasks-sql.test.ts b/packages/junior/tests/component/scheduled-tasks-sql.test.ts index 7c9deedbea..7df72501f7 100644 --- a/packages/junior/tests/component/scheduled-tasks-sql.test.ts +++ b/packages/junior/tests/component/scheduled-tasks-sql.test.ts @@ -175,7 +175,7 @@ describe("scheduled-task SQL storage", () => { await expect(migrateSchema(fixture.sql)).resolves.toMatchObject({ existing: 16, - migrated: 7, + migrated: 8, }); const [migrated] = await fixture.sql.query<{ creatorIdentityId: string | null; diff --git a/packages/junior/tests/integration/agent-dispatch-work.test.ts b/packages/junior/tests/integration/agent-dispatch-work.test.ts index d1b3350c59..e564ed4318 100644 --- a/packages/junior/tests/integration/agent-dispatch-work.test.ts +++ b/packages/junior/tests/integration/agent-dispatch-work.test.ts @@ -17,6 +17,7 @@ import { JUNIOR_THREAD_STATE_TTL_MS } from "@/chat/state/ttl"; import { completedAgentRun } from "@/chat/runtime/agent-run-outcome"; import { createConversationWorkQueueTestAdapter } from "../fixtures/conversation-work"; import { processConversationQueueMessage } from "@/chat/task-execution/vercel-callback"; +import { createAgentInvocationResultInboundMessage } from "@/chat/task-execution/synthetic-inbound"; import { listAgentTurnSessionSummariesForConversation, recordAgentTurnSessionSummary, @@ -426,4 +427,116 @@ describe("agent dispatch conversation work", () => { }); }); + it("keeps parent-result wakes on the dispatch worker without starting pending work", async () => { + const dispatch = await createDispatch("parent-result-pending"); + const conversationId = getDispatchConversationId(dispatch); + const resultMessage = createAgentInvocationResultInboundMessage({ + createdAtMs: 2_000, + destination, + inboundMessageId: "agent-invocation:inv-dispatch-pending:result", + invocationId: "inv-dispatch-pending", + parentConversationId: conversationId, + receivedAtMs: 2_000, + text: "[agent invocation result]\n\nA delegated child agent finished.", + }); + const ack = vi.fn(async () => {}); + const { context } = createContext(dispatch, { + attempt: { + ack, + conversationId, + destination, + drain: vi.fn(async () => []), + isFinalAttempt: false, + messages: [resultMessage], + }, + }); + const runTurn = vi.fn(async () => ({ outcome: "completed" as const })); + const resumeTurn = vi.fn(); + const slackWorker = vi.fn(async () => ({ status: "completed" as const })); + const route = createAgentDispatchWorkRouter({ + dispatchWorker: createAgentDispatchConversationWorker({ + resumeTurn, + runTurn, + }), + fallbackWorker: slackWorker, + }); + + await expect(route(context)).resolves.toEqual({ status: "completed" }); + expect(slackWorker).not.toHaveBeenCalled(); + expect(runTurn).not.toHaveBeenCalled(); + expect(resumeTurn).not.toHaveBeenCalled(); + expect(ack).toHaveBeenCalledOnce(); + await expect(getDispatchRecord(dispatch.id)).resolves.toMatchObject({ + status: "pending", + }); + }); + + it("resumes an already-started dispatch from a parent-result wake", async () => { + const dispatch = await createDispatch("parent-result-resume"); + const conversationId = getDispatchConversationId(dispatch); + const sessionId = getDispatchTurnId(dispatch.id); + await recordAgentTurnSessionSummary({ + actor: dispatch.actor, + conversationId, + destination: dispatch.destination, + destinationVisibility: dispatch.destinationVisibility, + dispatchId: dispatch.id, + sessionId, + sliceId: 1, + source: dispatch.source, + state: "awaiting_resume", + surface: "api", + }); + const resultMessage = createAgentInvocationResultInboundMessage({ + createdAtMs: 2_000, + destination, + inboundMessageId: "agent-invocation:inv-dispatch-resume:result", + invocationId: "inv-dispatch-resume", + parentConversationId: conversationId, + receivedAtMs: 2_000, + text: "[agent invocation result]\n\nA delegated child agent finished.", + }); + const ack = vi.fn(async () => {}); + const { context } = createContext(dispatch, { + attempt: { + ack, + conversationId, + destination, + drain: vi.fn(async () => []), + isFinalAttempt: false, + messages: [resultMessage], + }, + }); + const runTurn = vi.fn(); + const resumeTurn = vi.fn(async () => { + await recordAgentTurnSessionSummary({ + actor: dispatch.actor, + conversationId, + destination: dispatch.destination, + destinationVisibility: dispatch.destinationVisibility, + dispatchId: dispatch.id, + dispatchOutcome: "completed", + sessionId, + sliceId: 1, + source: dispatch.source, + state: "completed", + surface: "api", + }); + }); + const worker = createAgentDispatchConversationWorker({ + resumeTurn, + runTurn, + }); + + await expect(worker(context, dispatch.id)).resolves.toEqual({ + status: "completed", + }); + expect(runTurn).not.toHaveBeenCalled(); + expect(resumeTurn).toHaveBeenCalledOnce(); + expect(ack).toHaveBeenCalledOnce(); + await expect(getDispatchRecord(dispatch.id)).resolves.toMatchObject({ + status: "completed", + }); + }); + }); diff --git a/packages/junior/tests/integration/agent-invocation-concurrency.test.ts b/packages/junior/tests/integration/agent-invocation-concurrency.test.ts index 9130aeb873..39953bcbfd 100644 --- a/packages/junior/tests/integration/agent-invocation-concurrency.test.ts +++ b/packages/junior/tests/integration/agent-invocation-concurrency.test.ts @@ -108,6 +108,9 @@ async function createHarness( fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), invocationWorker: createAgentInvocationWorker({ agentRunner: { run }, + conversationStore, + queue, + state, }), }); diff --git a/packages/junior/tests/integration/agent-invocation-parent-delivery.test.ts b/packages/junior/tests/integration/agent-invocation-parent-delivery.test.ts new file mode 100644 index 0000000000..dfb5f85946 --- /dev/null +++ b/packages/junior/tests/integration/agent-invocation-parent-delivery.test.ts @@ -0,0 +1,330 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createLocalSource, createSlackSource } from "@sentry/junior-plugin-api"; +import { + completeAgentInvocation, + createAgentInvocation, + getAgentInvocation, + getAgentInvocationParentResultMessageId, +} from "@/chat/agent-invocations/store"; +import { notifyParentOfAgentInvocationResult } from "@/chat/agent-invocations/parent-notification"; +import { recoverPendingAgentInvocationParentNotifications } from "@/chat/agent-dispatch/heartbeat"; +import { migrateSchema } from "@/chat/conversations/sql/migrations"; +import { createSqlStore } from "@/chat/conversations/sql/store"; +import { disconnectStateAdapter, getStateAdapter } from "@/chat/state/adapter"; +import { getConversationWorkState } from "@/chat/task-execution/store"; +import { createAgentInvocationResultInboundMessage } from "@/chat/task-execution/synthetic-inbound"; +import { createConversationWorkQueueTestAdapter } from "../fixtures/conversation-work"; +import { createConfiguredJuniorSqlFixture } from "../fixtures/sql"; +import { createTestChatRuntime } from "../fixtures/chat-runtime"; +import { + createTestDestination, + createTestMessage, + createTestThread, + TEST_SLACK_TEAM_ID, +} from "../fixtures/slack-harness"; +import { + deliverAssistantMessagesForTest, + flattenAgentRunRequestForTest, +} from "../fixtures/agent-runner"; +import { completedAgentRun } from "@/chat/runtime/agent-run-outcome"; + +const localParentConversationId = "local:test:parent-agent"; +const localDestination = { + conversationId: localParentConversationId, + platform: "local", +} as const; + +async function prepareLocalParent() { + const fixture = createConfiguredJuniorSqlFixture(); + await migrateSchema(fixture.sql); + const conversationStore = createSqlStore(fixture.sql); + await conversationStore.recordActivity({ + conversationId: localParentConversationId, + destination: localDestination, + nowMs: 1_000, + source: "local", + }); + return { conversationStore, fixture }; +} + +describe("agent invocation parent delivery", () => { + afterEach(async () => { + await disconnectStateAdapter(); + vi.restoreAllMocks(); + }); + + it("repairs pending parent notification delivery once", async () => { + const { fixture } = await prepareLocalParent(); + const queue = createConversationWorkQueueTestAdapter(); + const state = getStateAdapter(); + await state.connect(); + try { + const created = await createAgentInvocation( + { + actor: { name: "parent-agent", platform: "system" }, + destination: localDestination, + destinationVisibility: "private", + idempotencyKey: "parent-notify-repair-1", + input: "Summarize the durable task.", + parentConversationId: localParentConversationId, + source: createLocalSource(localParentConversationId), + }, + 2_000, + ); + await completeAgentInvocation({ + invocationId: created.invocationId, + nowMs: 3_000, + result: "Parent should see this.", + status: "completed", + }); + + await recoverPendingAgentInvocationParentNotifications({ + conversationWorkQueue: queue, + nowMs: 4_000, + }); + await recoverPendingAgentInvocationParentNotifications({ + conversationWorkQueue: queue, + nowMs: 5_000, + }); + + await expect( + getAgentInvocation(created.invocationId), + ).resolves.toMatchObject({ + parentNotificationStatus: "notified", + }); + const parentWork = await getConversationWorkState({ + conversationId: localParentConversationId, + state, + }); + const resultId = getAgentInvocationParentResultMessageId( + created.invocationId, + ); + expect( + parentWork?.messages.filter( + (message) => message.inboundMessageId === resultId, + ), + ).toHaveLength(1); + expect( + queue + .sentRecords() + .filter( + (record) => record.conversationId === localParentConversationId, + ), + ).toHaveLength(1); + } finally { + await fixture.close(); + } + }); + + it("marks permanent destination mismatches failed instead of retrying", async () => { + const fixture = createConfiguredJuniorSqlFixture(); + await migrateSchema(fixture.sql); + const conversationStore = createSqlStore(fixture.sql); + const queue = createConversationWorkQueueTestAdapter(); + const parentConversationId = "slack:C0PARENT:1700003000.001"; + try { + await conversationStore.recordActivity({ + conversationId: parentConversationId, + destination: { + channelId: "C0PARENT", + platform: "slack", + teamId: TEST_SLACK_TEAM_ID, + }, + nowMs: 1_000, + source: "slack", + }); + const created = await createAgentInvocation( + { + actor: { + platform: "slack", + teamId: TEST_SLACK_TEAM_ID, + userId: "U0PARENT", + }, + destination: { + channelId: "C0OTHER", + platform: "slack", + teamId: TEST_SLACK_TEAM_ID, + }, + destinationVisibility: "private", + idempotencyKey: "parent-notify-failed-1", + input: "Deliver with a mismatched destination.", + parentConversationId, + source: createSlackSource({ + channelId: "C0PARENT", + teamId: TEST_SLACK_TEAM_ID, + threadTs: "1700003000.001", + visibility: "private", + }), + }, + 2_000, + ); + const terminal = await completeAgentInvocation({ + invocationId: created.invocationId, + nowMs: 3_000, + result: "Should not land in the parent mailbox.", + status: "completed", + }); + + await notifyParentOfAgentInvocationResult(terminal!, { + queue, + nowMs: 4_000, + }); + await recoverPendingAgentInvocationParentNotifications({ + conversationWorkQueue: queue, + nowMs: 5_000, + }); + + await expect( + getAgentInvocation(created.invocationId), + ).resolves.toMatchObject({ + parentNotificationStatus: "failed", + }); + expect(queue.sentRecords()).toEqual([]); + } finally { + await fixture.close(); + } + }); + + it("runs parent-result turns with the invocation actor and credentials", async () => { + const fixture = createConfiguredJuniorSqlFixture(); + await migrateSchema(fixture.sql); + const conversationStore = createSqlStore(fixture.sql); + const parentConversationId = "slack:C0PARENT:1700003000.001"; + const slackDestination = { + channelId: "C0PARENT", + platform: "slack" as const, + teamId: TEST_SLACK_TEAM_ID, + }; + const parentActor = { + platform: "slack" as const, + teamId: TEST_SLACK_TEAM_ID, + userId: "U0PARENT", + userName: "parent-user", + fullName: "Parent User", + }; + const replyContexts: unknown[] = []; + try { + await conversationStore.recordActivity({ + conversationId: parentConversationId, + destination: slackDestination, + nowMs: 1_000, + source: "slack", + }); + const created = await createAgentInvocation( + { + actor: parentActor, + credentialContext: { + actor: { type: "user", userId: "U0PARENT" }, + }, + destination: slackDestination, + destinationVisibility: "private", + idempotencyKey: "parent-authority-1", + input: "Inspect the failing checks.", + parentConversationId, + source: createSlackSource({ + channelId: slackDestination.channelId, + teamId: slackDestination.teamId, + threadTs: "1700003000.001", + visibility: "private", + }), + }, + 2_000, + ); + await completeAgentInvocation({ + invocationId: created.invocationId, + nowMs: 3_000, + result: "Checks are green now.", + status: "completed", + }); + const inbound = createAgentInvocationResultInboundMessage({ + createdAtMs: 3_000, + destination: slackDestination, + inboundMessageId: getAgentInvocationParentResultMessageId( + created.invocationId, + ), + invocationId: created.invocationId, + parentConversationId, + receivedAtMs: 4_000, + text: "[agent invocation result]\n\nResult:\nChecks are green now.", + }); + const metadata = inbound.input.metadata as { + message: { raw?: Record }; + }; + + const { slackRuntime } = createTestChatRuntime({ + services: { + subscribedReplyPolicy: { + completeObject: async () => { + throw new Error( + "agent invocation results bypass subscribed classifier", + ); + }, + }, + replyExecutor: { + agentRunner: { + run: async (request) => { + replyContexts.push(flattenAgentRunRequestForTest(request)); + const piMessages = await deliverAssistantMessagesForTest( + request, + [{ text: "I used the child result." }], + ); + return completedAgentRun({ + text: "I used the child result.", + piMessages, + diagnostics: { + assistantMessageCount: 1, + modelId: "test-model", + outcome: "success", + toolCalls: [], + toolErrorCount: 0, + toolResultCount: 0, + usedPrimaryText: true, + }, + }); + }, + }, + }, + }, + }); + + const thread = createTestThread({ id: parentConversationId }); + const message = createTestMessage({ + id: inbound.inboundMessageId, + text: inbound.input.text, + isMention: false, + threadId: thread.id, + author: { + userId: "UJRNAGENT", + userName: "junior-agent", + fullName: "Junior agent", + isBot: true, + }, + raw: metadata.message.raw, + }); + + await slackRuntime.handleSubscribedMessage(thread, message, { + destination: createTestDestination(thread), + }); + + expect(replyContexts).toEqual([ + expect.objectContaining({ + conversationId: parentConversationId, + actor: parentActor, + credentialContext: { + actor: { type: "user", userId: "U0PARENT" }, + }, + }), + ]); + expect( + (replyContexts[0] as { policy?: { disabledFeatures?: unknown } }) + ?.policy?.disabledFeatures, + ).toBeUndefined(); + expect(thread.posts).toHaveLength(1); + expect(JSON.stringify(thread.posts[0])).toContain( + "I used the child result.", + ); + } finally { + await fixture.close(); + } + }); +}); diff --git a/packages/junior/tests/integration/agent-invocation-work.test.ts b/packages/junior/tests/integration/agent-invocation-work.test.ts index 2ab44c3822..e08c67022a 100644 --- a/packages/junior/tests/integration/agent-invocation-work.test.ts +++ b/packages/junior/tests/integration/agent-invocation-work.test.ts @@ -5,6 +5,7 @@ import { completeAgentInvocation, createAgentInvocation, getAgentInvocation, + getAgentInvocationParentResultMessageId, getAgentInvocationTurnId, markAgentInvocationRunning, } from "@/chat/agent-invocations/store"; @@ -23,7 +24,10 @@ import { disconnectStateAdapter, getStateAdapter } from "@/chat/state/adapter"; import { completedAgentRun } from "@/chat/runtime/agent-run-outcome"; import { processConversationQueueMessage } from "@/chat/task-execution/vercel-callback"; import { recoverPendingAgentInvocationMailboxAppends } from "@/chat/agent-dispatch/heartbeat"; -import { CONVERSATION_WORK_MAX_DELIVERY_ATTEMPTS } from "@/chat/task-execution/store"; +import { + CONVERSATION_WORK_MAX_DELIVERY_ATTEMPTS, + getConversationWorkState, +} from "@/chat/task-execution/store"; import type { ConversationWorkerContext } from "@/chat/task-execution/worker"; import { getAgentTurnSessionRecord, @@ -300,6 +304,9 @@ describe("agent invocation conversation work", () => { fallbackWorker, invocationWorker: createAgentInvocationWorker({ agentRunner: { run }, + conversationStore, + queue, + state, }), }); const queueMessage = queue.takeMessage(); @@ -327,10 +334,30 @@ describe("agent invocation conversation work", () => { getAgentInvocation(created.invocationId), ).resolves.toMatchObject({ mailboxStatus: "appended", + parentNotificationStatus: "notified", result: "Durable child result", status: "completed", terminalAtMs: expect.any(Number), }); + const parentWork = await getConversationWorkState({ + conversationId: parentConversationId, + state, + }); + expect(parentWork?.messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + inboundMessageId: getAgentInvocationParentResultMessageId( + created.invocationId, + ), + source: "internal", + }), + ]), + ); + expect( + queue.sentRecords().some( + (record) => record.conversationId === parentConversationId, + ), + ).toBe(true); const completed = await getAgentInvocation(created.invocationId); await completeAgentInvocation({ errorMessage: "late conflicting failure", @@ -387,6 +414,9 @@ describe("agent invocation conversation work", () => { }); }), }, + conversationStore, + queue, + state, }); const route = routeAgentInvocationWork({ fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), @@ -417,6 +447,7 @@ describe("agent invocation conversation work", () => { await expect( getAgentInvocation(created.invocationId), ).resolves.toMatchObject({ + parentNotificationStatus: "notified", result: "Resumed child result", status: "completed", }); @@ -515,6 +546,7 @@ describe("agent invocation conversation work", () => { }); const worker = createAgentInvocationWorker({ agentRunner: { run }, + // No queue: this path only asserts child terminalization, not parent delivery. }); // Empty resume wakes set isFinalAttempt false, so unrecoverable stranded // recovery must terminalize without relying on final-attempt handling. @@ -620,6 +652,9 @@ describe("agent invocation conversation work", () => { fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), invocationWorker: createAgentInvocationWorker({ agentRunner: { run }, + conversationStore, + queue, + state, }), }); @@ -701,6 +736,9 @@ describe("agent invocation conversation work", () => { fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), invocationWorker: createAgentInvocationWorker({ agentRunner: { run }, + conversationStore, + queue, + state, }), }); @@ -744,6 +782,9 @@ describe("agent invocation conversation work", () => { const deliveryAttempts: Array = []; const invocationWorker = createAgentInvocationWorker({ agentRunner: { run }, + conversationStore, + queue, + state, }); const route = routeAgentInvocationWork({ fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), @@ -781,6 +822,7 @@ describe("agent invocation conversation work", () => { getAgentInvocation(created.invocationId), ).resolves.toMatchObject({ errorMessage: "model unavailable", + parentNotificationStatus: "notified", status: "failed", }); } finally { @@ -798,7 +840,10 @@ describe("agent invocation conversation work", () => { }); const run = vi.fn(); const ack = vi.fn(); - const worker = createAgentInvocationWorker({ agentRunner: { run } }); + const worker = createAgentInvocationWorker({ + agentRunner: { run }, + // No queue: invariant failure path only needs child terminalization. + }); const context = { attempt: { ack,