@tenex-chat/backend 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +194 -0
- package/dist/backend-wrapper.cjs +3 -0
- package/dist/src/index.js +331928 -0
- package/package.json +103 -0
- package/src/agents/AgentRegistry.ts +418 -0
- package/src/agents/AgentStorage.ts +1133 -0
- package/src/agents/ConfigResolver.ts +229 -0
- package/src/agents/agent-installer.ts +236 -0
- package/src/agents/agent-loader.ts +241 -0
- package/src/agents/constants.ts +82 -0
- package/src/agents/errors.ts +48 -0
- package/src/agents/execution/AgentExecutor.ts +561 -0
- package/src/agents/execution/ExecutionContextFactory.ts +112 -0
- package/src/agents/execution/MessageCompiler.ts +597 -0
- package/src/agents/execution/MessageSyncer.ts +100 -0
- package/src/agents/execution/PostCompletionChecker.ts +278 -0
- package/src/agents/execution/ProgressMonitor.ts +50 -0
- package/src/agents/execution/RALResolver.ts +177 -0
- package/src/agents/execution/SessionManager.ts +181 -0
- package/src/agents/execution/StreamCallbacks.ts +312 -0
- package/src/agents/execution/StreamExecutionHandler.ts +579 -0
- package/src/agents/execution/StreamSetup.ts +313 -0
- package/src/agents/execution/ToolEventHandlers.ts +239 -0
- package/src/agents/execution/ToolExecutionTracker.ts +498 -0
- package/src/agents/execution/ToolResultUtils.ts +97 -0
- package/src/agents/execution/ToolSupervisionWrapper.ts +174 -0
- package/src/agents/execution/constants.ts +16 -0
- package/src/agents/execution/index.ts +3 -0
- package/src/agents/execution/types.ts +96 -0
- package/src/agents/execution/utils.ts +26 -0
- package/src/agents/index.ts +4 -0
- package/src/agents/script-installer.ts +266 -0
- package/src/agents/supervision/SupervisorLLMService.ts +253 -0
- package/src/agents/supervision/SupervisorOrchestrator.ts +471 -0
- package/src/agents/supervision/heuristics/ConsecutiveToolsWithoutTodoHeuristic.ts +73 -0
- package/src/agents/supervision/heuristics/DelegationClaimHeuristic.ts +80 -0
- package/src/agents/supervision/heuristics/HeuristicRegistry.ts +114 -0
- package/src/agents/supervision/heuristics/PendingTodosHeuristic.ts +93 -0
- package/src/agents/supervision/heuristics/SilentAgentHeuristic.ts +54 -0
- package/src/agents/supervision/heuristics/index.ts +5 -0
- package/src/agents/supervision/index.ts +28 -0
- package/src/agents/supervision/registerHeuristics.ts +110 -0
- package/src/agents/supervision/supervisionHealthCheck.ts +123 -0
- package/src/agents/supervision/types.ts +171 -0
- package/src/agents/tool-names.ts +46 -0
- package/src/agents/tool-normalization.ts +184 -0
- package/src/agents/types/index.ts +2 -0
- package/src/agents/types/runtime.ts +74 -0
- package/src/agents/types/storage.ts +145 -0
- package/src/commands/agent/import/index.ts +6 -0
- package/src/commands/agent/import/openclaw-distiller.ts +57 -0
- package/src/commands/agent/import/openclaw-reader.ts +141 -0
- package/src/commands/agent/import/openclaw.ts +154 -0
- package/src/commands/agent/index.ts +6 -0
- package/src/commands/agent.ts +215 -0
- package/src/commands/daemon.ts +198 -0
- package/src/commands/doctor.ts +134 -0
- package/src/commands/setup/embed.ts +228 -0
- package/src/commands/setup/global-system-prompt.ts +223 -0
- package/src/commands/setup/image.ts +179 -0
- package/src/commands/setup/index.ts +16 -0
- package/src/commands/setup/interactive.ts +95 -0
- package/src/commands/setup/llm.ts +38 -0
- package/src/commands/setup/onboarding.ts +294 -0
- package/src/commands/setup/providers.ts +27 -0
- package/src/constants.ts +34 -0
- package/src/conversations/ConversationDiskReader.ts +148 -0
- package/src/conversations/ConversationRegistry.ts +728 -0
- package/src/conversations/ConversationStore.ts +868 -0
- package/src/conversations/MessageBuilder.ts +866 -0
- package/src/conversations/executionTime.ts +62 -0
- package/src/conversations/formatters/DelegationXmlFormatter.ts +64 -0
- package/src/conversations/formatters/ThreadedConversationFormatter.ts +303 -0
- package/src/conversations/formatters/index.ts +9 -0
- package/src/conversations/formatters/utils/MessageFormatter.ts +46 -0
- package/src/conversations/formatters/utils/TimestampFormatter.ts +56 -0
- package/src/conversations/formatters/utils/TreeBuilder.ts +131 -0
- package/src/conversations/formatters/utils/TreeRenderer.ts +49 -0
- package/src/conversations/index.ts +2 -0
- package/src/conversations/persistence/ToolMessageStorage.ts +143 -0
- package/src/conversations/search/ConversationIndexManager.ts +393 -0
- package/src/conversations/search/QueryParser.ts +114 -0
- package/src/conversations/search/SearchEngine.ts +175 -0
- package/src/conversations/search/SnippetExtractor.ts +345 -0
- package/src/conversations/search/embeddings/ConversationEmbeddingService.ts +484 -0
- package/src/conversations/search/embeddings/ConversationIndexingJob.ts +320 -0
- package/src/conversations/search/embeddings/IndexingStateManager.ts +338 -0
- package/src/conversations/search/embeddings/index.ts +18 -0
- package/src/conversations/search/index.ts +49 -0
- package/src/conversations/search/types.ts +124 -0
- package/src/conversations/services/CategoryManager.ts +160 -0
- package/src/conversations/services/ConversationResolver.ts +296 -0
- package/src/conversations/services/ConversationSummarizer.ts +234 -0
- package/src/conversations/services/MetadataDebounceManager.ts +188 -0
- package/src/conversations/services/index.ts +2 -0
- package/src/conversations/types.ts +148 -0
- package/src/conversations/utils/content-utils.ts +69 -0
- package/src/conversations/utils/image-placeholder.ts +281 -0
- package/src/conversations/utils/image-url-utils.ts +171 -0
- package/src/conversations/utils/multimodal-content.ts +90 -0
- package/src/conversations/utils/tool-result-truncator.ts +159 -0
- package/src/daemon/Daemon.ts +1883 -0
- package/src/daemon/ProjectRuntime.ts +657 -0
- package/src/daemon/RestartState.ts +152 -0
- package/src/daemon/RuntimeLifecycle.ts +268 -0
- package/src/daemon/SubscriptionManager.ts +305 -0
- package/src/daemon/UnixSocketTransport.ts +318 -0
- package/src/daemon/filters/SubscriptionFilterBuilder.ts +119 -0
- package/src/daemon/index.ts +9 -0
- package/src/daemon/routing/DaemonRouter.ts +491 -0
- package/src/daemon/types.ts +150 -0
- package/src/daemon/utils/routing-log.ts +76 -0
- package/src/daemon/utils/telemetry.ts +173 -0
- package/src/event-handler/agentDeletion.ts +383 -0
- package/src/event-handler/index.ts +749 -0
- package/src/event-handler/newConversation.ts +165 -0
- package/src/event-handler/project.ts +166 -0
- package/src/event-handler/reply.ts +18 -0
- package/src/events/NDKAgentDefinition.ts +292 -0
- package/src/events/NDKAgentLesson.ts +106 -0
- package/src/events/NDKEventMetadata.ts +34 -0
- package/src/events/NDKMCPTool.ts +60 -0
- package/src/events/NDKProjectStatus.ts +384 -0
- package/src/events/index.ts +4 -0
- package/src/index.ts +126 -0
- package/src/lib/agent-home.ts +334 -0
- package/src/lib/error-formatter.ts +200 -0
- package/src/lib/fs/filesystem.ts +128 -0
- package/src/lib/fs/index.ts +1 -0
- package/src/lib/json-parser.ts +30 -0
- package/src/lib/string.ts +15 -0
- package/src/lib/time.ts +74 -0
- package/src/llm/ChunkHandler.ts +277 -0
- package/src/llm/FinishHandler.ts +250 -0
- package/src/llm/LLMConfigEditor.ts +154 -0
- package/src/llm/LLMServiceFactory.ts +230 -0
- package/src/llm/MessageProcessor.ts +90 -0
- package/src/llm/RecordingState.ts +37 -0
- package/src/llm/StreamPublisher.ts +40 -0
- package/src/llm/TracingUtils.ts +77 -0
- package/src/llm/chunk-validators.ts +57 -0
- package/src/llm/constants.ts +6 -0
- package/src/llm/index.ts +12 -0
- package/src/llm/meta/MetaModelResolver.ts +352 -0
- package/src/llm/meta/index.ts +11 -0
- package/src/llm/middleware/flight-recorder.ts +188 -0
- package/src/llm/providers/MockProvider.ts +332 -0
- package/src/llm/providers/agent/ClaudeCodeProvider.ts +343 -0
- package/src/llm/providers/agent/ClaudeCodeToolsAdapter.ts +203 -0
- package/src/llm/providers/agent/CodexAppServerProvider.ts +214 -0
- package/src/llm/providers/agent/CodexAppServerToolsAdapter.ts +91 -0
- package/src/llm/providers/agent/index.ts +10 -0
- package/src/llm/providers/base/AgentProvider.ts +107 -0
- package/src/llm/providers/base/BaseProvider.ts +114 -0
- package/src/llm/providers/base/StandardProvider.ts +38 -0
- package/src/llm/providers/base/index.ts +9 -0
- package/src/llm/providers/index.ts +106 -0
- package/src/llm/providers/key-manager.ts +238 -0
- package/src/llm/providers/ollama-models.ts +105 -0
- package/src/llm/providers/openrouter-models.ts +102 -0
- package/src/llm/providers/provider-ids.ts +18 -0
- package/src/llm/providers/registry/ProviderRegistry.ts +414 -0
- package/src/llm/providers/registry/index.ts +7 -0
- package/src/llm/providers/standard/AnthropicProvider.ts +71 -0
- package/src/llm/providers/standard/OllamaProvider.ts +59 -0
- package/src/llm/providers/standard/OpenAIProvider.ts +44 -0
- package/src/llm/providers/standard/OpenRouterProvider.ts +103 -0
- package/src/llm/providers/standard/index.ts +10 -0
- package/src/llm/providers/types.ts +194 -0
- package/src/llm/providers/usage-metadata.ts +78 -0
- package/src/llm/service.ts +713 -0
- package/src/llm/types.ts +167 -0
- package/src/llm/utils/ConfigurationManager.ts +650 -0
- package/src/llm/utils/ConfigurationTester.ts +229 -0
- package/src/llm/utils/ModelSelector.ts +212 -0
- package/src/llm/utils/ProviderConfigUI.ts +177 -0
- package/src/llm/utils/claudeCodePromptCompiler.ts +141 -0
- package/src/llm/utils/codex-models.ts +53 -0
- package/src/llm/utils/context-window-cache.ts +30 -0
- package/src/llm/utils/models-dev-cache.ts +267 -0
- package/src/llm/utils/provider-setup.ts +50 -0
- package/src/llm/utils/tool-errors.ts +78 -0
- package/src/llm/utils/usage.ts +74 -0
- package/src/logging/EventRoutingLogger.ts +205 -0
- package/src/nostr/AgentEventDecoder.ts +357 -0
- package/src/nostr/AgentEventEncoder.ts +677 -0
- package/src/nostr/AgentProfilePublisher.ts +657 -0
- package/src/nostr/AgentPublisher.ts +437 -0
- package/src/nostr/BlossomService.ts +226 -0
- package/src/nostr/InterventionPublisher.ts +132 -0
- package/src/nostr/TagExtractor.ts +228 -0
- package/src/nostr/collectEvents.ts +83 -0
- package/src/nostr/constants.ts +38 -0
- package/src/nostr/encryption.ts +26 -0
- package/src/nostr/index.ts +31 -0
- package/src/nostr/keys.ts +17 -0
- package/src/nostr/kinds.ts +37 -0
- package/src/nostr/ndkClient.ts +72 -0
- package/src/nostr/relays.ts +43 -0
- package/src/nostr/trace-context.ts +39 -0
- package/src/nostr/types.ts +227 -0
- package/src/nostr/utils.ts +84 -0
- package/src/prompts/core/FragmentRegistry.ts +30 -0
- package/src/prompts/core/PromptBuilder.ts +98 -0
- package/src/prompts/core/index.ts +3 -0
- package/src/prompts/core/types.ts +13 -0
- package/src/prompts/fragments/00-global-system-prompt.ts +44 -0
- package/src/prompts/fragments/01-agent-identity.ts +69 -0
- package/src/prompts/fragments/02-agent-home-directory.ts +114 -0
- package/src/prompts/fragments/03-system-reminders-explanation.ts +14 -0
- package/src/prompts/fragments/04-relay-configuration.ts +38 -0
- package/src/prompts/fragments/05-delegation-chain.ts +45 -0
- package/src/prompts/fragments/06-agent-todos.ts +74 -0
- package/src/prompts/fragments/06-todo-usage-guidance.ts +34 -0
- package/src/prompts/fragments/07-meta-project-context.ts +234 -0
- package/src/prompts/fragments/08-active-conversations.ts +382 -0
- package/src/prompts/fragments/09-recent-conversations.ts +153 -0
- package/src/prompts/fragments/10-referenced-article.ts +21 -0
- package/src/prompts/fragments/11-nudges.ts +134 -0
- package/src/prompts/fragments/12-skills.ts +127 -0
- package/src/prompts/fragments/13-available-nudges.ts +122 -0
- package/src/prompts/fragments/15-available-agents.ts +53 -0
- package/src/prompts/fragments/16-stay-in-your-lane.ts +41 -0
- package/src/prompts/fragments/17-todo-before-delegation.ts +39 -0
- package/src/prompts/fragments/20-voice-mode.ts +62 -0
- package/src/prompts/fragments/22-scheduled-tasks.ts +175 -0
- package/src/prompts/fragments/24-retrieved-lessons.ts +26 -0
- package/src/prompts/fragments/25-rag-instructions.ts +333 -0
- package/src/prompts/fragments/26-mcp-resources.ts +237 -0
- package/src/prompts/fragments/27-memorized-reports.ts +77 -0
- package/src/prompts/fragments/28-agent-directed-monitoring.ts +32 -0
- package/src/prompts/fragments/29-rag-collections.ts +50 -0
- package/src/prompts/fragments/30-worktree-context.ts +98 -0
- package/src/prompts/fragments/31-agents-md-guidance.ts +96 -0
- package/src/prompts/fragments/32-process-metrics.ts +72 -0
- package/src/prompts/fragments/debug-mode.ts +48 -0
- package/src/prompts/fragments/delegation-completion.ts +44 -0
- package/src/prompts/fragments/index.ts +91 -0
- package/src/prompts/index.ts +21 -0
- package/src/prompts/utils/systemPromptBuilder.ts +777 -0
- package/src/scripts/migrate-prefix-index.ts +157 -0
- package/src/services/AgentDefinitionMonitor.ts +701 -0
- package/src/services/ConfigService.ts +723 -0
- package/src/services/CooldownRegistry.ts +199 -0
- package/src/services/LLMOperationsRegistry.ts +424 -0
- package/src/services/OwnerAgentListService.ts +354 -0
- package/src/services/PubkeyService.ts +308 -0
- package/src/services/agents/AgentMetadataStore.ts +72 -0
- package/src/services/agents/AgentResolution.ts +59 -0
- package/src/services/agents/EscalationService.ts +281 -0
- package/src/services/agents/NDKAgentDiscovery.ts +95 -0
- package/src/services/agents/index.ts +7 -0
- package/src/services/agents-md/AgentsMdService.ts +184 -0
- package/src/services/agents-md/SystemReminderInjector.ts +238 -0
- package/src/services/agents-md/index.ts +11 -0
- package/src/services/apns/APNsClient.ts +203 -0
- package/src/services/apns/APNsService.ts +358 -0
- package/src/services/apns/index.ts +11 -0
- package/src/services/apns/types.ts +80 -0
- package/src/services/compression/CompressionService.ts +445 -0
- package/src/services/compression/compression-schema.ts +28 -0
- package/src/services/compression/compression-types.ts +74 -0
- package/src/services/compression/compression-utils.ts +587 -0
- package/src/services/config/types.ts +394 -0
- package/src/services/dispatch/AgentDispatchService.ts +937 -0
- package/src/services/dispatch/AgentRouter.ts +181 -0
- package/src/services/dispatch/DelegationCompletionHandler.ts +232 -0
- package/src/services/embedding/EmbeddingProvider.ts +188 -0
- package/src/services/embedding/index.ts +5 -0
- package/src/services/event-context/EventContextService.ts +108 -0
- package/src/services/event-context/index.ts +2 -0
- package/src/services/heuristics/ContextBuilder.ts +106 -0
- package/src/services/heuristics/HeuristicEngine.ts +200 -0
- package/src/services/heuristics/formatters.ts +58 -0
- package/src/services/heuristics/index.ts +12 -0
- package/src/services/heuristics/rules/index.ts +25 -0
- package/src/services/heuristics/rules/todoBeforeDelegation.ts +69 -0
- package/src/services/heuristics/rules/todoReminderOnToolUse.ts +63 -0
- package/src/services/heuristics/types.ts +144 -0
- package/src/services/image/ImageGenerationService.ts +389 -0
- package/src/services/image/index.ts +12 -0
- package/src/services/intervention/InterventionService.ts +1352 -0
- package/src/services/intervention/index.ts +7 -0
- package/src/services/mcp/MCPManager.ts +683 -0
- package/src/services/mcp/McpNotificationDelivery.ts +139 -0
- package/src/services/mcp/McpSubscriptionService.ts +653 -0
- package/src/services/mcp/mcpInstaller.ts +130 -0
- package/src/services/nip46/Nip46SigningLog.ts +81 -0
- package/src/services/nip46/Nip46SigningService.ts +467 -0
- package/src/services/nip46/index.ts +4 -0
- package/src/services/nudge/NudgeService.ts +224 -0
- package/src/services/nudge/NudgeWhitelistService.ts +382 -0
- package/src/services/nudge/index.ts +5 -0
- package/src/services/nudge/types.ts +83 -0
- package/src/services/projects/ProjectContext.ts +672 -0
- package/src/services/projects/ProjectContextStore.ts +102 -0
- package/src/services/projects/index.ts +6 -0
- package/src/services/prompt-compiler/index.ts +15 -0
- package/src/services/prompt-compiler/prompt-compiler-service.ts +1143 -0
- package/src/services/pubkey-gate/PubkeyGateService.ts +93 -0
- package/src/services/pubkey-gate/index.ts +1 -0
- package/src/services/rag/EmbeddingProviderFactory.ts +292 -0
- package/src/services/rag/LanceDBMaintenanceService.ts +211 -0
- package/src/services/rag/RAGDatabaseService.ts +173 -0
- package/src/services/rag/RAGOperations.ts +682 -0
- package/src/services/rag/RAGService.ts +240 -0
- package/src/services/rag/RagSubscriptionService.ts +618 -0
- package/src/services/rag/rag-utils.ts +174 -0
- package/src/services/ral/PendingDelegationsRegistry.ts +168 -0
- package/src/services/ral/RALRegistry.ts +2782 -0
- package/src/services/ral/index.ts +4 -0
- package/src/services/ral/types.ts +292 -0
- package/src/services/reports/LocalReportStore.ts +380 -0
- package/src/services/reports/ReportEmbeddingService.ts +430 -0
- package/src/services/reports/ReportService.ts +440 -0
- package/src/services/reports/articleUtils.ts +52 -0
- package/src/services/reports/index.ts +7 -0
- package/src/services/scheduling/SchedulerService.ts +1057 -0
- package/src/services/scheduling/errors.ts +14 -0
- package/src/services/scheduling/index.ts +7 -0
- package/src/services/scheduling/utils.ts +77 -0
- package/src/services/search/SearchProviderRegistry.ts +78 -0
- package/src/services/search/UnifiedSearchService.ts +218 -0
- package/src/services/search/index.ts +47 -0
- package/src/services/search/projectFilter.ts +22 -0
- package/src/services/search/providers/ConversationSearchProvider.ts +48 -0
- package/src/services/search/providers/LessonSearchProvider.ts +75 -0
- package/src/services/search/providers/ReportSearchProvider.ts +49 -0
- package/src/services/search/types.ts +144 -0
- package/src/services/skill/SkillService.ts +482 -0
- package/src/services/skill/index.ts +2 -0
- package/src/services/skill/types.ts +70 -0
- package/src/services/status/OperationsStatusService.ts +276 -0
- package/src/services/status/ProjectStatusService.ts +522 -0
- package/src/services/status/index.ts +11 -0
- package/src/services/storage/PrefixKVStore.ts +242 -0
- package/src/services/storage/index.ts +1 -0
- package/src/services/system-reminder/SystemReminderUtils.ts +96 -0
- package/src/services/system-reminder/index.ts +7 -0
- package/src/services/trust-pubkeys/TrustPubkeyService.ts +325 -0
- package/src/services/trust-pubkeys/index.ts +2 -0
- package/src/telemetry/ConversationSpanManager.ts +111 -0
- package/src/telemetry/EventLoopMonitor.ts +206 -0
- package/src/telemetry/LLMSpanRegistry.ts +20 -0
- package/src/telemetry/NostrSpanProcessor.ts +89 -0
- package/src/telemetry/ToolCallSpanProcessor.ts +66 -0
- package/src/telemetry/diagnostics.ts +27 -0
- package/src/telemetry/setup.ts +120 -0
- package/src/tools/implementations/agents_discover.ts +121 -0
- package/src/tools/implementations/agents_hire.ts +127 -0
- package/src/tools/implementations/agents_list.ts +96 -0
- package/src/tools/implementations/agents_publish.ts +611 -0
- package/src/tools/implementations/agents_read.ts +173 -0
- package/src/tools/implementations/agents_write.ts +200 -0
- package/src/tools/implementations/ask.ts +411 -0
- package/src/tools/implementations/change_model.ts +141 -0
- package/src/tools/implementations/conversation_get.ts +661 -0
- package/src/tools/implementations/conversation_list.ts +377 -0
- package/src/tools/implementations/conversation_search.ts +370 -0
- package/src/tools/implementations/delegate.ts +327 -0
- package/src/tools/implementations/delegate_crossproject.ts +209 -0
- package/src/tools/implementations/delegate_followup.ts +300 -0
- package/src/tools/implementations/fs_edit.ts +162 -0
- package/src/tools/implementations/fs_glob.ts +182 -0
- package/src/tools/implementations/fs_grep.ts +513 -0
- package/src/tools/implementations/fs_read.ts +332 -0
- package/src/tools/implementations/fs_write.ts +113 -0
- package/src/tools/implementations/generate_image.ts +259 -0
- package/src/tools/implementations/home_fs.ts +515 -0
- package/src/tools/implementations/kill.ts +651 -0
- package/src/tools/implementations/learn.ts +166 -0
- package/src/tools/implementations/lesson-formatter.ts +38 -0
- package/src/tools/implementations/lesson_delete.ts +164 -0
- package/src/tools/implementations/lesson_get.ts +105 -0
- package/src/tools/implementations/lessons_list.ts +153 -0
- package/src/tools/implementations/mcp_resource_read.ts +161 -0
- package/src/tools/implementations/mcp_subscribe.ts +158 -0
- package/src/tools/implementations/mcp_subscription_stop.ts +85 -0
- package/src/tools/implementations/nostr_fetch.ts +149 -0
- package/src/tools/implementations/nostr_publish_as_user.ts +353 -0
- package/src/tools/implementations/project_list.ts +146 -0
- package/src/tools/implementations/rag_add_documents.ts +573 -0
- package/src/tools/implementations/rag_create_collection.ts +65 -0
- package/src/tools/implementations/rag_delete_collection.ts +68 -0
- package/src/tools/implementations/rag_list_collections.ts +77 -0
- package/src/tools/implementations/rag_query.ts +107 -0
- package/src/tools/implementations/rag_subscription_create.ts +105 -0
- package/src/tools/implementations/rag_subscription_delete.ts +80 -0
- package/src/tools/implementations/rag_subscription_get.ts +123 -0
- package/src/tools/implementations/rag_subscription_list.ts +128 -0
- package/src/tools/implementations/report_delete.ts +79 -0
- package/src/tools/implementations/report_read.ts +160 -0
- package/src/tools/implementations/report_write.ts +278 -0
- package/src/tools/implementations/reports_list.ts +77 -0
- package/src/tools/implementations/schedule_task.ts +104 -0
- package/src/tools/implementations/schedule_task_cancel.ts +62 -0
- package/src/tools/implementations/schedule_task_once.ts +128 -0
- package/src/tools/implementations/schedule_tasks_list.ts +79 -0
- package/src/tools/implementations/search.ts +160 -0
- package/src/tools/implementations/shell.ts +553 -0
- package/src/tools/implementations/todo.ts +260 -0
- package/src/tools/implementations/upload_blob.ts +381 -0
- package/src/tools/implementations/web_fetch.ts +153 -0
- package/src/tools/implementations/web_search.ts +250 -0
- package/src/tools/registry.ts +670 -0
- package/src/tools/types.ts +177 -0
- package/src/tools/utils.ts +256 -0
- package/src/types/event-ids.ts +320 -0
- package/src/types/index.ts +46 -0
- package/src/utils/agentFetcher.ts +107 -0
- package/src/utils/cli-error.ts +29 -0
- package/src/utils/conversation-id.ts +27 -0
- package/src/utils/conversation-utils.ts +1 -0
- package/src/utils/delegation-chain.ts +357 -0
- package/src/utils/error-handler.ts +42 -0
- package/src/utils/git/gitignore.ts +69 -0
- package/src/utils/git/index.ts +2 -0
- package/src/utils/git/initializeGitRepo.ts +204 -0
- package/src/utils/git/worktree.ts +260 -0
- package/src/utils/lessonFormatter.ts +70 -0
- package/src/utils/lessonTrust.ts +24 -0
- package/src/utils/lockfile.ts +123 -0
- package/src/utils/logger.ts +149 -0
- package/src/utils/nostr-entity-parser.ts +365 -0
- package/src/utils/process.ts +49 -0
- package/src/wrapper.ts +262 -0
- package/tsconfig.json +41 -0
|
@@ -0,0 +1,1352 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { InterventionPublisher } from "@/nostr/InterventionPublisher";
|
|
4
|
+
import { config } from "@/services/ConfigService";
|
|
5
|
+
import { PubkeyService } from "@/services/PubkeyService";
|
|
6
|
+
import { getTrustPubkeyService } from "@/services/trust-pubkeys/TrustPubkeyService";
|
|
7
|
+
import { logger } from "@/utils/logger";
|
|
8
|
+
import { trace } from "@opentelemetry/api";
|
|
9
|
+
|
|
10
|
+
/** Default timeout for user response: 5 minutes */
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
12
|
+
|
|
13
|
+
/** Default conversation inactivity timeout: 2 minutes (in seconds) */
|
|
14
|
+
const DEFAULT_CONVERSATION_INACTIVITY_TIMEOUT_SECONDS = 120;
|
|
15
|
+
|
|
16
|
+
/** Default retry interval for failed publish attempts: 30 seconds */
|
|
17
|
+
const DEFAULT_RETRY_INTERVAL_MS = 30_000;
|
|
18
|
+
|
|
19
|
+
/** Maximum retry attempts before giving up */
|
|
20
|
+
const MAX_RETRY_ATTEMPTS = 5;
|
|
21
|
+
|
|
22
|
+
/** TTL for notified conversation entries: 24 hours */
|
|
23
|
+
const NOTIFIED_TTL_MS = 24 * 60 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Result of attempting to resolve an agent for a project.
|
|
27
|
+
*/
|
|
28
|
+
export type AgentResolutionResult =
|
|
29
|
+
| { status: "resolved"; pubkey: string }
|
|
30
|
+
| { status: "runtime_unavailable" } // Transient: runtime not active yet
|
|
31
|
+
| { status: "agent_not_found" }; // Permanent: agent slug doesn't exist
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Function type for resolving an agent slug to a pubkey for a given project.
|
|
35
|
+
* Returns resolution result indicating success, transient failure, or permanent failure.
|
|
36
|
+
*
|
|
37
|
+
* This abstraction allows InterventionService (Layer 3) to resolve agents
|
|
38
|
+
* without directly depending on @/daemon (Layer 4).
|
|
39
|
+
*/
|
|
40
|
+
export type AgentResolverFn = (projectId: string, agentSlug: string) => AgentResolutionResult;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Function type for checking if a conversation has active outgoing delegations.
|
|
44
|
+
* Returns true if there are pending delegations that haven't completed yet.
|
|
45
|
+
*
|
|
46
|
+
* This abstraction allows InterventionService (Layer 3) to check delegation state
|
|
47
|
+
* without directly depending on RALRegistry internals.
|
|
48
|
+
*
|
|
49
|
+
* @param agentPubkey - The pubkey of the agent that completed
|
|
50
|
+
* @param conversationId - The conversation where the agent completed
|
|
51
|
+
* @returns true if there are active outgoing delegations
|
|
52
|
+
*/
|
|
53
|
+
export type ActiveDelegationCheckerFn = (agentPubkey: string, conversationId: string) => boolean;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Represents a pending intervention - an agent completed work
|
|
57
|
+
* and we're waiting for the user to respond.
|
|
58
|
+
*/
|
|
59
|
+
export interface PendingIntervention {
|
|
60
|
+
conversationId: string;
|
|
61
|
+
completedAt: number; // timestamp of completion event (ms)
|
|
62
|
+
agentPubkey: string; // completing agent
|
|
63
|
+
userPubkey: string; // root event author (who we're waiting on)
|
|
64
|
+
projectId: string; // for state scoping (replaces projectPubkey)
|
|
65
|
+
retryCount?: number; // number of retry attempts for failed publishes
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Entry tracking a conversation that has already been notified.
|
|
70
|
+
* Used to prevent duplicate notifications on event re-delivery.
|
|
71
|
+
*/
|
|
72
|
+
interface NotifiedEntry {
|
|
73
|
+
conversationId: string;
|
|
74
|
+
notifiedAt: number; // timestamp (ms)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Persisted state for InterventionService.
|
|
79
|
+
* Stored in ~/.tenex/intervention_state_<projectId>.json (project-scoped)
|
|
80
|
+
*/
|
|
81
|
+
interface InterventionState {
|
|
82
|
+
pending: PendingIntervention[];
|
|
83
|
+
notified?: NotifiedEntry[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Pending state write operation for serialization
|
|
88
|
+
*/
|
|
89
|
+
interface WriteOperation {
|
|
90
|
+
resolve: () => void;
|
|
91
|
+
reject: (error: Error) => void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* InterventionService monitors for agent work completions and triggers
|
|
96
|
+
* a human-replica review if the user doesn't respond within the configured timeout.
|
|
97
|
+
*
|
|
98
|
+
* Completion detection:
|
|
99
|
+
* - An event is considered a "completion" when a kind:1 event from an agent
|
|
100
|
+
* p-tags the user who authored the root event of the conversation.
|
|
101
|
+
*
|
|
102
|
+
* User response detection:
|
|
103
|
+
* - Only counts if the response is AFTER the completion timestamp.
|
|
104
|
+
* - Cancels the pending intervention timer.
|
|
105
|
+
*
|
|
106
|
+
* When timeout expires:
|
|
107
|
+
* - Publishes a review request event to the configured intervention agent.
|
|
108
|
+
*/
|
|
109
|
+
export class InterventionService {
|
|
110
|
+
private static instance: InterventionService | null = null;
|
|
111
|
+
|
|
112
|
+
private publisher: InterventionPublisher;
|
|
113
|
+
private pendingInterventions: Map<string, PendingIntervention> = new Map();
|
|
114
|
+
private timers: Map<string, NodeJS.Timeout> = new Map();
|
|
115
|
+
private configDir: string;
|
|
116
|
+
private currentProjectId: string | null = null;
|
|
117
|
+
|
|
118
|
+
// Tracks conversations that have already been notified (prevents duplicates)
|
|
119
|
+
private notifiedConversations: Map<string, number> = new Map();
|
|
120
|
+
|
|
121
|
+
// Guards against concurrent triggerIntervention() calls for the same conversation
|
|
122
|
+
private triggeringConversations: Set<string> = new Set();
|
|
123
|
+
|
|
124
|
+
// Agent slug for resolution (resolved per-project at trigger time)
|
|
125
|
+
private interventionAgentSlug: string | null = null;
|
|
126
|
+
|
|
127
|
+
// Injected resolver function for Layer 3/4 decoupling
|
|
128
|
+
private agentResolver: AgentResolverFn | null = null;
|
|
129
|
+
|
|
130
|
+
// Injected checker for active delegations (to prevent premature intervention notifications)
|
|
131
|
+
private activeDelegationChecker: ActiveDelegationCheckerFn | null = null;
|
|
132
|
+
|
|
133
|
+
private timeoutMs: number = DEFAULT_TIMEOUT_MS;
|
|
134
|
+
private conversationInactivityTimeoutSeconds: number = DEFAULT_CONVERSATION_INACTIVITY_TIMEOUT_SECONDS;
|
|
135
|
+
private enabled = false;
|
|
136
|
+
private initialized = false;
|
|
137
|
+
|
|
138
|
+
// Serialized write queue for state persistence
|
|
139
|
+
private writeQueue: WriteOperation[] = [];
|
|
140
|
+
private isWriting = false;
|
|
141
|
+
|
|
142
|
+
// Guards against loadState() racing with onAgentCompletion()
|
|
143
|
+
private stateLoadPromise: Promise<void> | null = null;
|
|
144
|
+
// Pending completion operations queued during state load
|
|
145
|
+
private pendingCompletionOps: Array<() => void> = [];
|
|
146
|
+
|
|
147
|
+
private constructor() {
|
|
148
|
+
this.publisher = new InterventionPublisher();
|
|
149
|
+
this.configDir = config.getConfigPath();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Set the agent resolver function.
|
|
154
|
+
* This allows Layer 4 (daemon) to inject its resolver without
|
|
155
|
+
* creating a compile-time dependency from Layer 3 to Layer 4.
|
|
156
|
+
*
|
|
157
|
+
* Must be called before processing any completions.
|
|
158
|
+
* Typically called during daemon initialization.
|
|
159
|
+
*/
|
|
160
|
+
public setAgentResolver(resolver: AgentResolverFn): void {
|
|
161
|
+
this.agentResolver = resolver;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Set the active delegation checker function.
|
|
166
|
+
* This allows checking if a conversation has outgoing delegations that are still running.
|
|
167
|
+
*
|
|
168
|
+
* When an agent completes work but has active delegations, we should NOT trigger
|
|
169
|
+
* an intervention notification - the delegation tree is still in progress.
|
|
170
|
+
*
|
|
171
|
+
* @param checker - Function that checks for active delegations
|
|
172
|
+
*/
|
|
173
|
+
public setActiveDelegationChecker(checker: ActiveDelegationCheckerFn): void {
|
|
174
|
+
this.activeDelegationChecker = checker;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Get the state file path for a given project.
|
|
179
|
+
* State files are scoped by project ID.
|
|
180
|
+
*/
|
|
181
|
+
private getStateFilePath(projectId: string): string {
|
|
182
|
+
return path.join(this.configDir, `intervention_state_${projectId}.json`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Get the singleton instance of InterventionService.
|
|
187
|
+
*/
|
|
188
|
+
public static getInstance(): InterventionService {
|
|
189
|
+
if (!InterventionService.instance) {
|
|
190
|
+
InterventionService.instance = new InterventionService();
|
|
191
|
+
}
|
|
192
|
+
return InterventionService.instance;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Reset the singleton instance (useful for testing).
|
|
197
|
+
*/
|
|
198
|
+
public static async resetInstance(): Promise<void> {
|
|
199
|
+
if (InterventionService.instance) {
|
|
200
|
+
await InterventionService.instance.shutdown();
|
|
201
|
+
}
|
|
202
|
+
InterventionService.instance = null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Initialize the service.
|
|
207
|
+
* - Loads configuration
|
|
208
|
+
* - Stores the agent slug for lazy resolution (resolved on first completion event)
|
|
209
|
+
* - Note: State loading is deferred until setProject() is called
|
|
210
|
+
*/
|
|
211
|
+
public async initialize(): Promise<void> {
|
|
212
|
+
if (this.initialized) {
|
|
213
|
+
logger.warn("InterventionService already initialized");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const tenexConfig = config.getConfig();
|
|
218
|
+
const interventionConfig = tenexConfig.intervention;
|
|
219
|
+
|
|
220
|
+
// Check if intervention is enabled
|
|
221
|
+
if (!interventionConfig?.enabled) {
|
|
222
|
+
logger.debug("InterventionService disabled (intervention.enabled is false or not set)");
|
|
223
|
+
this.enabled = false;
|
|
224
|
+
this.initialized = true;
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Agent slug is required if enabled
|
|
229
|
+
const agentSlug = interventionConfig.agent?.trim();
|
|
230
|
+
if (!agentSlug) {
|
|
231
|
+
logger.error("InterventionService enabled but no agent slug configured (intervention.agent)");
|
|
232
|
+
this.enabled = false;
|
|
233
|
+
this.initialized = true;
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Store the trimmed slug for lazy resolution (don't resolve yet - ProjectContext may not exist)
|
|
238
|
+
this.interventionAgentSlug = agentSlug;
|
|
239
|
+
this.timeoutMs = interventionConfig.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
240
|
+
|
|
241
|
+
// Clamp negative values to 0 and warn
|
|
242
|
+
const rawInactivityTimeout = interventionConfig.conversationInactivityTimeoutSeconds ?? DEFAULT_CONVERSATION_INACTIVITY_TIMEOUT_SECONDS;
|
|
243
|
+
if (rawInactivityTimeout < 0) {
|
|
244
|
+
logger.warn("InterventionService: conversationInactivityTimeoutSeconds is negative, clamping to 0", {
|
|
245
|
+
configuredValue: rawInactivityTimeout,
|
|
246
|
+
});
|
|
247
|
+
this.conversationInactivityTimeoutSeconds = 0;
|
|
248
|
+
} else {
|
|
249
|
+
this.conversationInactivityTimeoutSeconds = rawInactivityTimeout;
|
|
250
|
+
}
|
|
251
|
+
this.enabled = true;
|
|
252
|
+
|
|
253
|
+
// Initialize the publisher
|
|
254
|
+
await this.publisher.initialize();
|
|
255
|
+
|
|
256
|
+
// Ensure config directory exists
|
|
257
|
+
await fs.mkdir(this.configDir, { recursive: true });
|
|
258
|
+
|
|
259
|
+
this.initialized = true;
|
|
260
|
+
|
|
261
|
+
logger.info("InterventionService initialized (agent resolution deferred)", {
|
|
262
|
+
agentSlug,
|
|
263
|
+
timeoutMs: this.timeoutMs,
|
|
264
|
+
conversationInactivityTimeoutSeconds: this.conversationInactivityTimeoutSeconds,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
trace.getActiveSpan()?.addEvent("intervention.service_initialized", {
|
|
268
|
+
"intervention.agent_slug": agentSlug,
|
|
269
|
+
"intervention.timeout_ms": this.timeoutMs,
|
|
270
|
+
"intervention.conversation_inactivity_timeout_seconds": this.conversationInactivityTimeoutSeconds,
|
|
271
|
+
"intervention.resolution_deferred": true,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Set the current project and load its state.
|
|
277
|
+
* Must be called before the service will process events.
|
|
278
|
+
* This is called when a project context becomes available.
|
|
279
|
+
*/
|
|
280
|
+
public async setProject(projectId: string): Promise<void> {
|
|
281
|
+
if (!this.enabled) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// If switching projects, save current state first
|
|
286
|
+
if (this.currentProjectId && this.currentProjectId !== projectId) {
|
|
287
|
+
await this.flushWriteQueue();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (this.currentProjectId === projectId) {
|
|
291
|
+
if (this.stateLoadPromise) {
|
|
292
|
+
await this.stateLoadPromise;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
logger.debug("InterventionService project set", {
|
|
296
|
+
projectId: projectId.substring(0, 12),
|
|
297
|
+
pendingCount: this.pendingInterventions.size,
|
|
298
|
+
});
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
this.currentProjectId = projectId;
|
|
303
|
+
this.beginStateLoad(projectId);
|
|
304
|
+
if (this.stateLoadPromise) {
|
|
305
|
+
await this.stateLoadPromise;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
logger.debug("InterventionService project set", {
|
|
309
|
+
projectId: projectId.substring(0, 12),
|
|
310
|
+
pendingCount: this.pendingInterventions.size,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Begin loading project state if no load is already in progress.
|
|
316
|
+
* Ensures queued completion operations are flushed after load completes.
|
|
317
|
+
*/
|
|
318
|
+
private beginStateLoad(projectId: string): void {
|
|
319
|
+
if (this.stateLoadPromise) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
this.stateLoadPromise = this.loadState(projectId)
|
|
324
|
+
.then(() => {
|
|
325
|
+
this.setupCatchUpTimers();
|
|
326
|
+
this.flushPendingCompletionOps();
|
|
327
|
+
})
|
|
328
|
+
.finally(() => {
|
|
329
|
+
this.stateLoadPromise = null;
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Resolve the agent slug to a pubkey for a specific project.
|
|
335
|
+
* Called at trigger time using the target project's agent registry.
|
|
336
|
+
* Returns a resolution result indicating success, transient failure, or permanent failure.
|
|
337
|
+
*
|
|
338
|
+
* This method resolves the agent fresh each time, using the project's
|
|
339
|
+
* own agent registry. This ensures interventions target the correct
|
|
340
|
+
* agent even when different projects have different agent configurations.
|
|
341
|
+
*
|
|
342
|
+
* @param projectId - The project ID to resolve the agent for
|
|
343
|
+
*/
|
|
344
|
+
private resolveAgentPubkeyForProject(projectId: string): AgentResolutionResult {
|
|
345
|
+
if (!this.interventionAgentSlug) {
|
|
346
|
+
return { status: "agent_not_found" };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!this.agentResolver) {
|
|
350
|
+
logger.warn("InterventionService: no agent resolver configured, cannot resolve agent", {
|
|
351
|
+
projectId: projectId.substring(0, 12),
|
|
352
|
+
slug: this.interventionAgentSlug,
|
|
353
|
+
});
|
|
354
|
+
return { status: "runtime_unavailable" };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Wrap resolver call in try/catch to handle exceptions from the injected resolver
|
|
358
|
+
let result: AgentResolutionResult;
|
|
359
|
+
try {
|
|
360
|
+
result = this.agentResolver(projectId, this.interventionAgentSlug);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
logger.error("InterventionService: agent resolver threw an exception", {
|
|
363
|
+
projectId: projectId.substring(0, 12),
|
|
364
|
+
slug: this.interventionAgentSlug,
|
|
365
|
+
error: error instanceof Error ? error.message : String(error),
|
|
366
|
+
});
|
|
367
|
+
// Map exception to transient failure - runtime may be in a bad state
|
|
368
|
+
return { status: "runtime_unavailable" };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (result.status === "resolved") {
|
|
372
|
+
logger.debug("InterventionService: resolved agent for project", {
|
|
373
|
+
projectId: projectId.substring(0, 12),
|
|
374
|
+
slug: this.interventionAgentSlug,
|
|
375
|
+
pubkey: result.pubkey.substring(0, 8),
|
|
376
|
+
});
|
|
377
|
+
} else if (result.status === "runtime_unavailable") {
|
|
378
|
+
logger.warn("InterventionService: runtime temporarily unavailable for agent resolution", {
|
|
379
|
+
projectId: projectId.substring(0, 12),
|
|
380
|
+
slug: this.interventionAgentSlug,
|
|
381
|
+
});
|
|
382
|
+
} else {
|
|
383
|
+
logger.warn("InterventionService: agent slug not found in project", {
|
|
384
|
+
projectId: projectId.substring(0, 12),
|
|
385
|
+
slug: this.interventionAgentSlug,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return result;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Check if the service is enabled and ready.
|
|
394
|
+
*/
|
|
395
|
+
public isEnabled(): boolean {
|
|
396
|
+
return this.enabled && this.initialized;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Called when an agent completes work on a conversation.
|
|
401
|
+
*
|
|
402
|
+
* Completion is detected when:
|
|
403
|
+
* - Event is kind:1
|
|
404
|
+
* - Event author is an agent (not a whitelisted user)
|
|
405
|
+
* - Event p-tags a whitelisted pubkey
|
|
406
|
+
* - That whitelisted pubkey is the author of the root event
|
|
407
|
+
*
|
|
408
|
+
* If the user was recently active in the conversation (within conversationInactivityTimeoutSeconds),
|
|
409
|
+
* the intervention is skipped entirely. This prevents interventions when the user is
|
|
410
|
+
* actively engaged in the conversation.
|
|
411
|
+
*
|
|
412
|
+
* @param conversationId - The conversation ID
|
|
413
|
+
* @param completedAt - Timestamp of the completion event (ms)
|
|
414
|
+
* @param agentPubkey - Pubkey of the completing agent
|
|
415
|
+
* @param userPubkey - Pubkey of the root event author
|
|
416
|
+
* @param projectId - Project ID for state scoping
|
|
417
|
+
* @param lastUserMessageTime - Timestamp of the last user message in the conversation (ms, optional)
|
|
418
|
+
*/
|
|
419
|
+
public onAgentCompletion(
|
|
420
|
+
conversationId: string,
|
|
421
|
+
completedAt: number,
|
|
422
|
+
agentPubkey: string,
|
|
423
|
+
userPubkey: string,
|
|
424
|
+
projectId: string,
|
|
425
|
+
lastUserMessageTime?: number
|
|
426
|
+
): void {
|
|
427
|
+
if (!this.isEnabled()) {
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Distinguish between transient (runtime unavailable) and permanent (agent not found) failures
|
|
432
|
+
const resolution = this.resolveAgentPubkeyForProject(projectId);
|
|
433
|
+
|
|
434
|
+
if (resolution.status === "agent_not_found") {
|
|
435
|
+
// Permanent failure: agent slug doesn't exist in project
|
|
436
|
+
logger.warn("InterventionService: skipping completion, agent not found in project", {
|
|
437
|
+
projectId: projectId.substring(0, 12),
|
|
438
|
+
slug: this.interventionAgentSlug,
|
|
439
|
+
});
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Skip if the completing agent IS the intervention agent (prevents feedback loop)
|
|
444
|
+
if (resolution.status === "resolved" && agentPubkey === resolution.pubkey) {
|
|
445
|
+
logger.debug("InterventionService: skipping intervention, completing agent is the intervention agent", {
|
|
446
|
+
conversationId: conversationId.substring(0, 12),
|
|
447
|
+
agentPubkey: agentPubkey.substring(0, 8),
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
trace.getActiveSpan()?.addEvent("intervention.skipped_intervention_agent_completion", {
|
|
451
|
+
"conversation.id": conversationId,
|
|
452
|
+
"agent.pubkey": agentPubkey.substring(0, 8),
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Note: For "runtime_unavailable" (transient), we proceed with queuing.
|
|
459
|
+
// The timer will attempt resolution again at trigger time.
|
|
460
|
+
// This handles startup/restart scenarios where runtime is briefly unavailable.
|
|
461
|
+
|
|
462
|
+
// CRITICAL: Only trigger interventions for whitelisted user pubkeys
|
|
463
|
+
// Skip if the "user" is actually an agent (agent-to-agent completion)
|
|
464
|
+
const trustService = getTrustPubkeyService();
|
|
465
|
+
const trustResult = trustService.isTrustedSync(userPubkey);
|
|
466
|
+
|
|
467
|
+
if (!trustResult.trusted || trustResult.reason !== "whitelisted") {
|
|
468
|
+
// The "user" is not a whitelisted human user - it's an agent or unknown
|
|
469
|
+
logger.debug("InterventionService: skipping intervention, user is not whitelisted", {
|
|
470
|
+
conversationId: conversationId.substring(0, 12),
|
|
471
|
+
userPubkey: userPubkey.substring(0, 8),
|
|
472
|
+
trustReason: trustResult.reason ?? "not-trusted",
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
trace.getActiveSpan()?.addEvent("intervention.skipped_not_whitelisted_user", {
|
|
476
|
+
"conversation.id": conversationId,
|
|
477
|
+
"user.pubkey": userPubkey.substring(0, 8),
|
|
478
|
+
"trust.reason": trustResult.reason ?? "not-trusted",
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Check conversation inactivity: if user was recently active, skip intervention
|
|
485
|
+
if (lastUserMessageTime !== undefined && this.conversationInactivityTimeoutSeconds > 0) {
|
|
486
|
+
const timeSinceLastUserMessageMs = completedAt - lastUserMessageTime;
|
|
487
|
+
const thresholdMs = this.conversationInactivityTimeoutSeconds * 1000;
|
|
488
|
+
|
|
489
|
+
if (timeSinceLastUserMessageMs < thresholdMs) {
|
|
490
|
+
logger.debug("InterventionService: skipping intervention, user was recently active", {
|
|
491
|
+
conversationId: conversationId.substring(0, 12),
|
|
492
|
+
timeSinceLastUserMessageMs,
|
|
493
|
+
thresholdMs,
|
|
494
|
+
lastUserMessageTime,
|
|
495
|
+
completedAt,
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
trace.getActiveSpan()?.addEvent("intervention.skipped_recent_user_activity", {
|
|
499
|
+
"conversation.id": conversationId,
|
|
500
|
+
"time_since_last_user_message_ms": timeSinceLastUserMessageMs,
|
|
501
|
+
"threshold_ms": thresholdMs,
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Skip if agent has active outgoing delegations (work not yet complete)
|
|
509
|
+
if (this.activeDelegationChecker) {
|
|
510
|
+
const hasActiveDelegations = this.activeDelegationChecker(agentPubkey, conversationId);
|
|
511
|
+
if (hasActiveDelegations) {
|
|
512
|
+
logger.debug("InterventionService: skipping intervention, agent has active delegations", {
|
|
513
|
+
conversationId: conversationId.substring(0, 12),
|
|
514
|
+
agentPubkey: agentPubkey.substring(0, 8),
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
trace.getActiveSpan()?.addEvent("intervention.skipped_active_delegations", {
|
|
518
|
+
"conversation.id": conversationId,
|
|
519
|
+
"agent.pubkey": agentPubkey.substring(0, 8),
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Ensure project is set
|
|
527
|
+
if (!this.currentProjectId) {
|
|
528
|
+
this.currentProjectId = projectId;
|
|
529
|
+
// Note: setProject() should have been called first in normal operation.
|
|
530
|
+
// If we reach here, load state and queue this completion to run after.
|
|
531
|
+
this.beginStateLoad(projectId);
|
|
532
|
+
} else if (this.currentProjectId !== projectId) {
|
|
533
|
+
// Different project - this shouldn't happen in normal operation
|
|
534
|
+
logger.warn("InterventionService: completion from different project", {
|
|
535
|
+
expected: this.currentProjectId.substring(0, 12),
|
|
536
|
+
actual: projectId.substring(0, 12),
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// If state is still loading, queue this operation to run after
|
|
541
|
+
if (this.stateLoadPromise) {
|
|
542
|
+
this.pendingCompletionOps.push(() => {
|
|
543
|
+
this.addPendingIntervention(conversationId, completedAt, agentPubkey, userPubkey, projectId);
|
|
544
|
+
});
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
this.addPendingIntervention(conversationId, completedAt, agentPubkey, userPubkey, projectId);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Add a pending intervention entry.
|
|
553
|
+
* Extracted to support queuing during state load.
|
|
554
|
+
*/
|
|
555
|
+
private addPendingIntervention(
|
|
556
|
+
conversationId: string,
|
|
557
|
+
completedAt: number,
|
|
558
|
+
agentPubkey: string,
|
|
559
|
+
userPubkey: string,
|
|
560
|
+
projectId: string
|
|
561
|
+
): void {
|
|
562
|
+
// Prune stale entries from notifiedConversations (prevents unbounded growth
|
|
563
|
+
// and ensures entries older than TTL no longer block new notifications)
|
|
564
|
+
this.pruneStaleNotifications();
|
|
565
|
+
|
|
566
|
+
// Check if this conversation was already notified (deduplication guard)
|
|
567
|
+
const notifiedAt = this.notifiedConversations.get(conversationId);
|
|
568
|
+
if (notifiedAt !== undefined) {
|
|
569
|
+
logger.debug("InterventionService: skipping already-notified conversation", {
|
|
570
|
+
conversationId: conversationId.substring(0, 12),
|
|
571
|
+
notifiedAt,
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
trace.getActiveSpan()?.addEvent("intervention.skipped_already_notified", {
|
|
575
|
+
"conversation.id": conversationId,
|
|
576
|
+
"notified_at": notifiedAt,
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Check if we already have a pending intervention for this conversation
|
|
583
|
+
const existing = this.pendingInterventions.get(conversationId);
|
|
584
|
+
if (existing) {
|
|
585
|
+
logger.debug("Updating existing pending intervention", {
|
|
586
|
+
conversationId: conversationId.substring(0, 12),
|
|
587
|
+
previousCompletedAt: existing.completedAt,
|
|
588
|
+
newCompletedAt: completedAt,
|
|
589
|
+
});
|
|
590
|
+
// Clear the old timer
|
|
591
|
+
this.clearTimer(conversationId);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const pending: PendingIntervention = {
|
|
595
|
+
conversationId,
|
|
596
|
+
completedAt,
|
|
597
|
+
agentPubkey,
|
|
598
|
+
userPubkey,
|
|
599
|
+
projectId,
|
|
600
|
+
retryCount: 0,
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
this.pendingInterventions.set(conversationId, pending);
|
|
604
|
+
this.startTimer(pending);
|
|
605
|
+
this.saveState();
|
|
606
|
+
|
|
607
|
+
logger.info("Agent completion detected, starting intervention timer", {
|
|
608
|
+
conversationId: conversationId.substring(0, 12),
|
|
609
|
+
agentPubkey: agentPubkey.substring(0, 8),
|
|
610
|
+
userPubkey: userPubkey.substring(0, 8),
|
|
611
|
+
timeoutMs: this.timeoutMs,
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
trace.getActiveSpan()?.addEvent("intervention.timer_started", {
|
|
615
|
+
"conversation.id": conversationId,
|
|
616
|
+
"agent.pubkey": agentPubkey.substring(0, 8),
|
|
617
|
+
"user.pubkey": userPubkey.substring(0, 8),
|
|
618
|
+
"timeout.ms": this.timeoutMs,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Flush queued completion operations after state load completes.
|
|
624
|
+
*/
|
|
625
|
+
private flushPendingCompletionOps(): void {
|
|
626
|
+
const ops = this.pendingCompletionOps;
|
|
627
|
+
this.pendingCompletionOps = [];
|
|
628
|
+
for (const op of ops) {
|
|
629
|
+
op();
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Called when a user responds in a conversation.
|
|
635
|
+
*
|
|
636
|
+
* Only cancels the timer if:
|
|
637
|
+
* 1. The response is AFTER the completion timestamp
|
|
638
|
+
* 2. The response is BEFORE the timeout window expires
|
|
639
|
+
*
|
|
640
|
+
* This prevents event loop delays from allowing late responses to cancel timers.
|
|
641
|
+
*
|
|
642
|
+
* @param conversationId - The conversation ID
|
|
643
|
+
* @param responseAt - Timestamp of the user response (ms)
|
|
644
|
+
* @param userPubkey - Pubkey of the responding user
|
|
645
|
+
*/
|
|
646
|
+
public onUserResponse(
|
|
647
|
+
conversationId: string,
|
|
648
|
+
responseAt: number,
|
|
649
|
+
userPubkey: string
|
|
650
|
+
): void {
|
|
651
|
+
if (!this.isEnabled()) {
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// If state is still loading, queue this operation to run after
|
|
656
|
+
// This prevents race conditions where a response arrives during state load
|
|
657
|
+
// and fails to properly cancel a pending intervention
|
|
658
|
+
if (this.stateLoadPromise) {
|
|
659
|
+
this.pendingCompletionOps.push(() => {
|
|
660
|
+
this.processUserResponse(conversationId, responseAt, userPubkey);
|
|
661
|
+
});
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
this.processUserResponse(conversationId, responseAt, userPubkey);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Process a user response after ensuring state is loaded.
|
|
670
|
+
* Extracted to support queuing during state load.
|
|
671
|
+
*/
|
|
672
|
+
private processUserResponse(
|
|
673
|
+
conversationId: string,
|
|
674
|
+
responseAt: number,
|
|
675
|
+
userPubkey: string
|
|
676
|
+
): void {
|
|
677
|
+
const pending = this.pendingInterventions.get(conversationId);
|
|
678
|
+
if (!pending) {
|
|
679
|
+
// No pending intervention for this conversation
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Only cancel if response is AFTER completion
|
|
684
|
+
if (responseAt <= pending.completedAt) {
|
|
685
|
+
logger.debug("User response before completion, not cancelling timer", {
|
|
686
|
+
conversationId: conversationId.substring(0, 12),
|
|
687
|
+
responseAt,
|
|
688
|
+
completedAt: pending.completedAt,
|
|
689
|
+
});
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Verify response is strictly BEFORE the timeout window expires
|
|
694
|
+
// Responses at exactly completedAt + timeoutMs do NOT cancel (strict "before" semantics)
|
|
695
|
+
const timeoutExpiry = pending.completedAt + this.timeoutMs;
|
|
696
|
+
if (responseAt >= timeoutExpiry) {
|
|
697
|
+
logger.debug("User response at or after timeout window, not cancelling timer", {
|
|
698
|
+
conversationId: conversationId.substring(0, 12),
|
|
699
|
+
responseAt,
|
|
700
|
+
timeoutExpiry,
|
|
701
|
+
delayMs: responseAt - timeoutExpiry,
|
|
702
|
+
});
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Verify it's the same user we're waiting on
|
|
707
|
+
if (userPubkey !== pending.userPubkey) {
|
|
708
|
+
logger.debug("Response from different user, not cancelling timer", {
|
|
709
|
+
conversationId: conversationId.substring(0, 12),
|
|
710
|
+
responsePubkey: userPubkey.substring(0, 8),
|
|
711
|
+
expectedPubkey: pending.userPubkey.substring(0, 8),
|
|
712
|
+
});
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Cancel the timer - user responded within the timeout window
|
|
717
|
+
this.clearTimer(conversationId);
|
|
718
|
+
this.pendingInterventions.delete(conversationId);
|
|
719
|
+
this.saveState();
|
|
720
|
+
|
|
721
|
+
logger.info("User responded, cancelled intervention timer", {
|
|
722
|
+
conversationId: conversationId.substring(0, 12),
|
|
723
|
+
userPubkey: userPubkey.substring(0, 8),
|
|
724
|
+
responseDelayMs: responseAt - pending.completedAt,
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
trace.getActiveSpan()?.addEvent("intervention.timer_cancelled", {
|
|
728
|
+
"conversation.id": conversationId,
|
|
729
|
+
"user.pubkey": userPubkey.substring(0, 8),
|
|
730
|
+
"response.delay_ms": responseAt - pending.completedAt,
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Trigger an intervention for a pending conversation.
|
|
736
|
+
* Called when the timer expires.
|
|
737
|
+
* Includes retry logic with exponential backoff on publish failure
|
|
738
|
+
* and transient runtime unavailability.
|
|
739
|
+
* Guarded against concurrent execution for the same conversationId.
|
|
740
|
+
*/
|
|
741
|
+
private async triggerIntervention(pending: PendingIntervention): Promise<void> {
|
|
742
|
+
// Guard against stale timers firing after a project switch.
|
|
743
|
+
// If the pending intervention belongs to a different project than what's
|
|
744
|
+
// currently loaded, it's a leftover timer that should be silently discarded.
|
|
745
|
+
if (pending.projectId !== this.currentProjectId) {
|
|
746
|
+
logger.debug("InterventionService: stale timer from different project, skipping", {
|
|
747
|
+
conversationId: pending.conversationId.substring(0, 12),
|
|
748
|
+
pendingProjectId: pending.projectId.substring(0, 12),
|
|
749
|
+
currentProjectId: this.currentProjectId?.substring(0, 12) ?? "none",
|
|
750
|
+
});
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Guard against concurrent triggerIntervention for the same conversation
|
|
755
|
+
if (this.triggeringConversations.has(pending.conversationId)) {
|
|
756
|
+
logger.debug("InterventionService: triggerIntervention already in progress, skipping", {
|
|
757
|
+
conversationId: pending.conversationId.substring(0, 12),
|
|
758
|
+
});
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Check if already notified (race between timer expiry and re-delivered event)
|
|
763
|
+
// Also verify TTL — expired entries should not block triggers
|
|
764
|
+
const notifiedAt = this.notifiedConversations.get(pending.conversationId);
|
|
765
|
+
if (notifiedAt !== undefined && (Date.now() - notifiedAt) < NOTIFIED_TTL_MS) {
|
|
766
|
+
logger.debug("InterventionService: conversation already notified, skipping trigger", {
|
|
767
|
+
conversationId: pending.conversationId.substring(0, 12),
|
|
768
|
+
});
|
|
769
|
+
this.pendingInterventions.delete(pending.conversationId);
|
|
770
|
+
this.saveState();
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
// If the entry existed but was stale, remove it so we proceed with the trigger
|
|
774
|
+
if (notifiedAt !== undefined) {
|
|
775
|
+
this.notifiedConversations.delete(pending.conversationId);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
this.triggeringConversations.add(pending.conversationId);
|
|
779
|
+
|
|
780
|
+
try {
|
|
781
|
+
await this.executeTrigger(pending);
|
|
782
|
+
} finally {
|
|
783
|
+
this.triggeringConversations.delete(pending.conversationId);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Execute the actual trigger logic (separated for concurrency guard).
|
|
789
|
+
*/
|
|
790
|
+
private async executeTrigger(pending: PendingIntervention): Promise<void> {
|
|
791
|
+
// Resolve the intervention agent pubkey for this specific project
|
|
792
|
+
// This ensures we target the correct agent even when different projects
|
|
793
|
+
// have different agent configurations
|
|
794
|
+
const resolution = this.resolveAgentPubkeyForProject(pending.projectId);
|
|
795
|
+
|
|
796
|
+
if (resolution.status === "runtime_unavailable") {
|
|
797
|
+
// Transient failure: runtime temporarily unavailable
|
|
798
|
+
// Schedule a retry with backoff
|
|
799
|
+
const retryCount = pending.retryCount ?? 0;
|
|
800
|
+
|
|
801
|
+
if (retryCount < MAX_RETRY_ATTEMPTS) {
|
|
802
|
+
pending.retryCount = retryCount + 1;
|
|
803
|
+
this.pendingInterventions.set(pending.conversationId, pending);
|
|
804
|
+
this.saveState();
|
|
805
|
+
|
|
806
|
+
const backoffMs = DEFAULT_RETRY_INTERVAL_MS * Math.pow(2, retryCount);
|
|
807
|
+
this.scheduleRetry(pending, backoffMs);
|
|
808
|
+
|
|
809
|
+
logger.info("Runtime unavailable, scheduled retry for intervention", {
|
|
810
|
+
conversationId: pending.conversationId.substring(0, 12),
|
|
811
|
+
projectId: pending.projectId.substring(0, 12),
|
|
812
|
+
retryCount: pending.retryCount,
|
|
813
|
+
nextRetryMs: backoffMs,
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
trace.getActiveSpan()?.addEvent("intervention.retry_scheduled_runtime_unavailable", {
|
|
817
|
+
"conversation.id": pending.conversationId,
|
|
818
|
+
"project.id": pending.projectId.substring(0, 12),
|
|
819
|
+
"retry_count": pending.retryCount,
|
|
820
|
+
"next_retry_ms": backoffMs,
|
|
821
|
+
});
|
|
822
|
+
} else {
|
|
823
|
+
logger.error("Max retry attempts reached for intervention (runtime unavailable)", {
|
|
824
|
+
conversationId: pending.conversationId.substring(0, 12),
|
|
825
|
+
projectId: pending.projectId.substring(0, 12),
|
|
826
|
+
maxRetries: MAX_RETRY_ATTEMPTS,
|
|
827
|
+
});
|
|
828
|
+
// Remove from pending - we've exhausted retries
|
|
829
|
+
this.pendingInterventions.delete(pending.conversationId);
|
|
830
|
+
this.saveState();
|
|
831
|
+
}
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (resolution.status === "agent_not_found") {
|
|
836
|
+
logger.error("Cannot trigger intervention: agent not found in project", {
|
|
837
|
+
projectId: pending.projectId.substring(0, 12),
|
|
838
|
+
slug: this.interventionAgentSlug,
|
|
839
|
+
conversationId: pending.conversationId.substring(0, 12),
|
|
840
|
+
});
|
|
841
|
+
// Permanent failure - remove from pending
|
|
842
|
+
this.pendingInterventions.delete(pending.conversationId);
|
|
843
|
+
this.saveState();
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const interventionAgentPubkey = resolution.pubkey;
|
|
848
|
+
|
|
849
|
+
const retryCount = pending.retryCount ?? 0;
|
|
850
|
+
|
|
851
|
+
logger.info("Triggering intervention review", {
|
|
852
|
+
conversationId: pending.conversationId.substring(0, 12),
|
|
853
|
+
userPubkey: pending.userPubkey.substring(0, 8),
|
|
854
|
+
agentPubkey: pending.agentPubkey.substring(0, 8),
|
|
855
|
+
interventionAgentPubkey: interventionAgentPubkey.substring(0, 8),
|
|
856
|
+
projectId: pending.projectId.substring(0, 12),
|
|
857
|
+
timeElapsedMs: Date.now() - pending.completedAt,
|
|
858
|
+
retryCount,
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
trace.getActiveSpan()?.addEvent("intervention.triggered", {
|
|
862
|
+
"conversation.id": pending.conversationId,
|
|
863
|
+
"user.pubkey": pending.userPubkey.substring(0, 8),
|
|
864
|
+
"agent.pubkey": pending.agentPubkey.substring(0, 8),
|
|
865
|
+
"intervention_agent.pubkey": interventionAgentPubkey.substring(0, 8),
|
|
866
|
+
"project.id": pending.projectId.substring(0, 12),
|
|
867
|
+
"time_elapsed_ms": Date.now() - pending.completedAt,
|
|
868
|
+
"retry_count": retryCount,
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
try {
|
|
872
|
+
// Resolve human-readable names before calling the publisher
|
|
873
|
+
// This keeps name resolution in the services layer, avoiding circular dependencies
|
|
874
|
+
const pubkeyService = PubkeyService.getInstance();
|
|
875
|
+
const userName = pubkeyService.getNameSync(pending.userPubkey);
|
|
876
|
+
const agentName = pubkeyService.getNameSync(pending.agentPubkey);
|
|
877
|
+
|
|
878
|
+
const eventId = await this.publisher.publishReviewRequest(
|
|
879
|
+
interventionAgentPubkey,
|
|
880
|
+
pending.conversationId,
|
|
881
|
+
userName,
|
|
882
|
+
agentName
|
|
883
|
+
);
|
|
884
|
+
|
|
885
|
+
logger.info("Intervention review request published", {
|
|
886
|
+
eventId: eventId.substring(0, 8),
|
|
887
|
+
conversationId: pending.conversationId.substring(0, 12),
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
// Record as notified to prevent duplicate notifications
|
|
891
|
+
this.notifiedConversations.set(pending.conversationId, Date.now());
|
|
892
|
+
|
|
893
|
+
// Remove from pending after successful publish
|
|
894
|
+
this.pendingInterventions.delete(pending.conversationId);
|
|
895
|
+
this.saveState();
|
|
896
|
+
} catch (error) {
|
|
897
|
+
logger.error("Failed to publish intervention review request", {
|
|
898
|
+
error,
|
|
899
|
+
conversationId: pending.conversationId.substring(0, 12),
|
|
900
|
+
retryCount,
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
// Retry logic with backoff
|
|
904
|
+
if (retryCount < MAX_RETRY_ATTEMPTS) {
|
|
905
|
+
// Update retry count
|
|
906
|
+
pending.retryCount = retryCount + 1;
|
|
907
|
+
this.pendingInterventions.set(pending.conversationId, pending);
|
|
908
|
+
this.saveState();
|
|
909
|
+
|
|
910
|
+
// Re-arm timer with exponential backoff
|
|
911
|
+
const backoffMs = DEFAULT_RETRY_INTERVAL_MS * Math.pow(2, retryCount);
|
|
912
|
+
this.scheduleRetry(pending, backoffMs);
|
|
913
|
+
|
|
914
|
+
logger.info("Scheduled retry for failed intervention", {
|
|
915
|
+
conversationId: pending.conversationId.substring(0, 12),
|
|
916
|
+
retryCount: pending.retryCount,
|
|
917
|
+
nextRetryMs: backoffMs,
|
|
918
|
+
});
|
|
919
|
+
} else {
|
|
920
|
+
logger.error("Max retry attempts reached for intervention", {
|
|
921
|
+
conversationId: pending.conversationId.substring(0, 12),
|
|
922
|
+
maxRetries: MAX_RETRY_ATTEMPTS,
|
|
923
|
+
});
|
|
924
|
+
// Remove from pending - we've exhausted retries
|
|
925
|
+
this.pendingInterventions.delete(pending.conversationId);
|
|
926
|
+
this.saveState();
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Schedule a retry for a failed intervention publish.
|
|
933
|
+
*/
|
|
934
|
+
private scheduleRetry(pending: PendingIntervention, delayMs: number): void {
|
|
935
|
+
const timer = setTimeout(() => {
|
|
936
|
+
this.timers.delete(pending.conversationId);
|
|
937
|
+
this.triggerIntervention(pending);
|
|
938
|
+
}, delayMs);
|
|
939
|
+
|
|
940
|
+
this.timers.set(pending.conversationId, timer);
|
|
941
|
+
|
|
942
|
+
logger.debug("Intervention retry scheduled", {
|
|
943
|
+
conversationId: pending.conversationId.substring(0, 12),
|
|
944
|
+
delayMs,
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Start a timer for a pending intervention.
|
|
950
|
+
*/
|
|
951
|
+
private startTimer(pending: PendingIntervention): void {
|
|
952
|
+
const now = Date.now();
|
|
953
|
+
const elapsed = now - pending.completedAt;
|
|
954
|
+
const remaining = Math.max(0, this.timeoutMs - elapsed);
|
|
955
|
+
|
|
956
|
+
if (remaining === 0) {
|
|
957
|
+
// Timer already expired, trigger immediately
|
|
958
|
+
this.triggerIntervention(pending);
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const timer = setTimeout(() => {
|
|
963
|
+
this.timers.delete(pending.conversationId);
|
|
964
|
+
this.triggerIntervention(pending);
|
|
965
|
+
}, remaining);
|
|
966
|
+
|
|
967
|
+
this.timers.set(pending.conversationId, timer);
|
|
968
|
+
|
|
969
|
+
logger.debug("Intervention timer started", {
|
|
970
|
+
conversationId: pending.conversationId.substring(0, 12),
|
|
971
|
+
remainingMs: remaining,
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Clear a timer for a conversation.
|
|
977
|
+
*/
|
|
978
|
+
private clearTimer(conversationId: string): void {
|
|
979
|
+
const timer = this.timers.get(conversationId);
|
|
980
|
+
if (timer) {
|
|
981
|
+
clearTimeout(timer);
|
|
982
|
+
this.timers.delete(conversationId);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Setup catch-up timers for pending interventions loaded from state.
|
|
988
|
+
*/
|
|
989
|
+
private setupCatchUpTimers(): void {
|
|
990
|
+
for (const pending of this.pendingInterventions.values()) {
|
|
991
|
+
this.startTimer(pending);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Load persisted state from disk for the given project.
|
|
997
|
+
*/
|
|
998
|
+
private async loadState(projectId: string): Promise<void> {
|
|
999
|
+
const stateFilePath = this.getStateFilePath(projectId);
|
|
1000
|
+
|
|
1001
|
+
// Cancel all active timers from the previous project before clearing state.
|
|
1002
|
+
// Without this, timers from a previous project continue running and fire
|
|
1003
|
+
// against the new project's data, causing duplicate notifications and
|
|
1004
|
+
// cross-project state corruption.
|
|
1005
|
+
for (const timer of this.timers.values()) {
|
|
1006
|
+
clearTimeout(timer);
|
|
1007
|
+
}
|
|
1008
|
+
this.timers.clear();
|
|
1009
|
+
|
|
1010
|
+
// Clear all project-scoped state before loading new project
|
|
1011
|
+
// (must happen unconditionally, even if state file doesn't exist)
|
|
1012
|
+
this.pendingInterventions.clear();
|
|
1013
|
+
this.notifiedConversations.clear();
|
|
1014
|
+
this.triggeringConversations.clear();
|
|
1015
|
+
|
|
1016
|
+
try {
|
|
1017
|
+
const data = await fs.readFile(stateFilePath, "utf-8");
|
|
1018
|
+
const state = JSON.parse(data) as InterventionState;
|
|
1019
|
+
|
|
1020
|
+
for (const pending of state.pending) {
|
|
1021
|
+
// Migrate old projectPubkey field to projectId if present
|
|
1022
|
+
if ("projectPubkey" in pending && !pending.projectId) {
|
|
1023
|
+
(pending as PendingIntervention).projectId = projectId;
|
|
1024
|
+
}
|
|
1025
|
+
this.pendingInterventions.set(pending.conversationId, pending);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Load notified conversations, evicting entries older than 24h
|
|
1029
|
+
if (state.notified) {
|
|
1030
|
+
const cutoff = Date.now() - NOTIFIED_TTL_MS;
|
|
1031
|
+
for (const entry of state.notified) {
|
|
1032
|
+
if (entry.notifiedAt >= cutoff) {
|
|
1033
|
+
this.notifiedConversations.set(entry.conversationId, entry.notifiedAt);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
logger.debug("Loaded intervention state", {
|
|
1039
|
+
projectId: projectId.substring(0, 12),
|
|
1040
|
+
pendingCount: this.pendingInterventions.size,
|
|
1041
|
+
notifiedCount: this.notifiedConversations.size,
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
trace.getActiveSpan()?.addEvent("intervention.state_loaded", {
|
|
1045
|
+
"intervention.project_id": projectId.substring(0, 12),
|
|
1046
|
+
"intervention.pending_count": this.pendingInterventions.size,
|
|
1047
|
+
"intervention.notified_count": this.notifiedConversations.size,
|
|
1048
|
+
});
|
|
1049
|
+
} catch (error: unknown) {
|
|
1050
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
1051
|
+
// No existing file, starting fresh - this is expected
|
|
1052
|
+
logger.debug("No existing intervention state file, starting fresh", {
|
|
1053
|
+
projectId: projectId.substring(0, 12),
|
|
1054
|
+
});
|
|
1055
|
+
} else {
|
|
1056
|
+
logger.error("Failed to load intervention state:", error);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* Save state to disk atomically using a write queue.
|
|
1063
|
+
* Writes are serialized to prevent race conditions from back-to-back saves.
|
|
1064
|
+
*/
|
|
1065
|
+
private saveState(): void {
|
|
1066
|
+
if (!this.currentProjectId) {
|
|
1067
|
+
logger.warn("Cannot save state: no project ID set");
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// Queue the write operation (we don't await it - fire-and-forget for normal operation)
|
|
1072
|
+
this.writeQueue.push({
|
|
1073
|
+
resolve: () => { /* resolved when write completes */ },
|
|
1074
|
+
reject: () => { /* errors are logged in processWriteQueue */ },
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
// Start processing if not already
|
|
1078
|
+
if (!this.isWriting) {
|
|
1079
|
+
this.processWriteQueue();
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Process the write queue, serializing all state writes.
|
|
1085
|
+
*/
|
|
1086
|
+
private async processWriteQueue(): Promise<void> {
|
|
1087
|
+
if (this.isWriting || this.writeQueue.length === 0) {
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
this.isWriting = true;
|
|
1092
|
+
|
|
1093
|
+
while (this.writeQueue.length > 0) {
|
|
1094
|
+
// Take all pending operations and coalesce them into one write
|
|
1095
|
+
const operations = [...this.writeQueue];
|
|
1096
|
+
this.writeQueue = [];
|
|
1097
|
+
|
|
1098
|
+
try {
|
|
1099
|
+
await this.writeStateAtomically();
|
|
1100
|
+
|
|
1101
|
+
// Resolve all coalesced operations
|
|
1102
|
+
for (const op of operations) {
|
|
1103
|
+
op.resolve();
|
|
1104
|
+
}
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
// Reject all coalesced operations
|
|
1107
|
+
for (const op of operations) {
|
|
1108
|
+
op.reject(error instanceof Error ? error : new Error(String(error)));
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
this.isWriting = false;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Remove entries from notifiedConversations that are older than NOTIFIED_TTL_MS.
|
|
1118
|
+
* Prevents unbounded map growth and ensures stale entries stop blocking notifications.
|
|
1119
|
+
*/
|
|
1120
|
+
private pruneStaleNotifications(): void {
|
|
1121
|
+
const cutoff = Date.now() - NOTIFIED_TTL_MS;
|
|
1122
|
+
for (const [conversationId, notifiedAt] of this.notifiedConversations) {
|
|
1123
|
+
if (notifiedAt < cutoff) {
|
|
1124
|
+
this.notifiedConversations.delete(conversationId);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Atomically write state to disk using temp file and rename.
|
|
1131
|
+
*/
|
|
1132
|
+
private async writeStateAtomically(): Promise<void> {
|
|
1133
|
+
if (!this.currentProjectId) {
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const stateFilePath = this.getStateFilePath(this.currentProjectId);
|
|
1138
|
+
const tempFilePath = `${stateFilePath}.tmp.${Date.now()}`;
|
|
1139
|
+
|
|
1140
|
+
try {
|
|
1141
|
+
// Evict expired notified entries from in-memory map and serialize valid ones
|
|
1142
|
+
this.pruneStaleNotifications();
|
|
1143
|
+
const notifiedEntries: NotifiedEntry[] = [];
|
|
1144
|
+
for (const [conversationId, notifiedAt] of this.notifiedConversations) {
|
|
1145
|
+
notifiedEntries.push({ conversationId, notifiedAt });
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
const state: InterventionState = {
|
|
1149
|
+
pending: Array.from(this.pendingInterventions.values()),
|
|
1150
|
+
notified: notifiedEntries.length > 0 ? notifiedEntries : undefined,
|
|
1151
|
+
};
|
|
1152
|
+
|
|
1153
|
+
// Ensure directory exists before writing
|
|
1154
|
+
try {
|
|
1155
|
+
await fs.mkdir(path.dirname(stateFilePath), { recursive: true });
|
|
1156
|
+
} catch (mkdirError: unknown) {
|
|
1157
|
+
// Directory may have been deleted (e.g., during test cleanup)
|
|
1158
|
+
if (mkdirError && typeof mkdirError === "object" && "code" in mkdirError) {
|
|
1159
|
+
const code = (mkdirError as { code: string }).code;
|
|
1160
|
+
if (code === "ENOENT" || code === "EINVAL") {
|
|
1161
|
+
logger.debug("State directory unavailable, skipping write");
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
throw mkdirError;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// Write to temp file first
|
|
1169
|
+
await fs.writeFile(tempFilePath, JSON.stringify(state, null, 2));
|
|
1170
|
+
|
|
1171
|
+
// Atomic rename
|
|
1172
|
+
await fs.rename(tempFilePath, stateFilePath);
|
|
1173
|
+
|
|
1174
|
+
logger.debug("Saved intervention state atomically", {
|
|
1175
|
+
projectId: this.currentProjectId.substring(0, 12),
|
|
1176
|
+
pendingCount: state.pending.length,
|
|
1177
|
+
});
|
|
1178
|
+
} catch (error: unknown) {
|
|
1179
|
+
// If directory was deleted during write, log and continue
|
|
1180
|
+
if (error && typeof error === "object" && "code" in error) {
|
|
1181
|
+
const code = (error as { code: string }).code;
|
|
1182
|
+
if (code === "ENOENT" || code === "EINVAL") {
|
|
1183
|
+
logger.debug("State write failed (directory unavailable), skipping");
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
logger.error("Failed to save intervention state:", error);
|
|
1189
|
+
|
|
1190
|
+
// Clean up temp file if it exists
|
|
1191
|
+
try {
|
|
1192
|
+
await fs.unlink(tempFilePath);
|
|
1193
|
+
} catch {
|
|
1194
|
+
// Ignore cleanup errors
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
throw error;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* Flush the write queue, waiting for all pending writes to complete.
|
|
1203
|
+
* Useful for shutdown and project switch.
|
|
1204
|
+
*/
|
|
1205
|
+
private async flushWriteQueue(): Promise<void> {
|
|
1206
|
+
// Force a final write if there's any pending data
|
|
1207
|
+
if (this.pendingInterventions.size > 0 && this.currentProjectId) {
|
|
1208
|
+
await this.writeStateAtomically();
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// Wait for queue to drain
|
|
1212
|
+
while (this.writeQueue.length > 0 || this.isWriting) {
|
|
1213
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* Shutdown the service.
|
|
1219
|
+
* - Clears all timers
|
|
1220
|
+
* - Flushes pending state writes
|
|
1221
|
+
*/
|
|
1222
|
+
public async shutdown(): Promise<void> {
|
|
1223
|
+
logger.info("InterventionService shutting down", {
|
|
1224
|
+
pendingCount: this.pendingInterventions.size,
|
|
1225
|
+
timerCount: this.timers.size,
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
trace.getActiveSpan()?.addEvent("intervention.shutting_down", {
|
|
1229
|
+
"intervention.pending_count": this.pendingInterventions.size,
|
|
1230
|
+
"intervention.timer_count": this.timers.size,
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
// Clear all timers
|
|
1234
|
+
for (const timer of this.timers.values()) {
|
|
1235
|
+
clearTimeout(timer);
|
|
1236
|
+
}
|
|
1237
|
+
this.timers.clear();
|
|
1238
|
+
|
|
1239
|
+
// Flush pending state writes
|
|
1240
|
+
try {
|
|
1241
|
+
await this.flushWriteQueue();
|
|
1242
|
+
} catch (error) {
|
|
1243
|
+
logger.error("Failed to flush state on shutdown:", error);
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
this.initialized = false;
|
|
1247
|
+
this.enabled = false;
|
|
1248
|
+
|
|
1249
|
+
trace.getActiveSpan()?.addEvent("intervention.shutdown_complete");
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Get pending interventions count (for diagnostics).
|
|
1254
|
+
*/
|
|
1255
|
+
public getPendingCount(): number {
|
|
1256
|
+
return this.pendingInterventions.size;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* Get count of already-notified conversations (for diagnostics).
|
|
1261
|
+
*/
|
|
1262
|
+
public getNotifiedCount(): number {
|
|
1263
|
+
return this.notifiedConversations.size;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* Check if a conversation has been notified (for testing).
|
|
1268
|
+
*/
|
|
1269
|
+
public isNotified(conversationId: string): boolean {
|
|
1270
|
+
return this.notifiedConversations.has(conversationId);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* Get a pending intervention by conversation ID (for testing).
|
|
1275
|
+
*/
|
|
1276
|
+
public getPending(conversationId: string): PendingIntervention | undefined {
|
|
1277
|
+
return this.pendingInterventions.get(conversationId);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* Get the current timeout value in milliseconds (for testing).
|
|
1282
|
+
*/
|
|
1283
|
+
public getTimeoutMs(): number {
|
|
1284
|
+
return this.timeoutMs;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* Get the conversation inactivity timeout in seconds (for testing).
|
|
1289
|
+
*/
|
|
1290
|
+
public getConversationInactivityTimeoutSeconds(): number {
|
|
1291
|
+
return this.conversationInactivityTimeoutSeconds;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/**
|
|
1295
|
+
* Get the current project ID (for testing).
|
|
1296
|
+
*/
|
|
1297
|
+
public getCurrentProjectId(): string | null {
|
|
1298
|
+
return this.currentProjectId;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* Force agent resolution for testing purposes.
|
|
1303
|
+
* Resolves the intervention agent for a specific project.
|
|
1304
|
+
*
|
|
1305
|
+
* @param projectId - The project ID to resolve the agent for
|
|
1306
|
+
* @returns The resolution result
|
|
1307
|
+
*/
|
|
1308
|
+
public forceAgentResolution(projectId: string): AgentResolutionResult {
|
|
1309
|
+
return this.resolveAgentPubkeyForProject(projectId);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/**
|
|
1313
|
+
* Wait for all pending writes to complete (for testing).
|
|
1314
|
+
*/
|
|
1315
|
+
public async waitForWrites(): Promise<void> {
|
|
1316
|
+
await this.flushWriteQueue();
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* Manually mark a conversation as notified with a given timestamp (for testing).
|
|
1321
|
+
* Allows tests to inject stale entries to verify pruning behavior.
|
|
1322
|
+
*/
|
|
1323
|
+
public setNotifiedForTesting(conversationId: string, notifiedAt: number): void {
|
|
1324
|
+
this.notifiedConversations.set(conversationId, notifiedAt);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/**
|
|
1328
|
+
* Check if a conversation ID is in the triggeringConversations set (for testing).
|
|
1329
|
+
*/
|
|
1330
|
+
public isTriggering(conversationId: string): boolean {
|
|
1331
|
+
return this.triggeringConversations.has(conversationId);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* Add a conversation to the triggeringConversations set (for testing).
|
|
1336
|
+
*/
|
|
1337
|
+
public setTriggeringForTesting(conversationId: string): void {
|
|
1338
|
+
this.triggeringConversations.add(conversationId);
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* Wait for pending state load and queued operations to complete (for testing).
|
|
1343
|
+
* This ensures all deferred completion operations have been processed.
|
|
1344
|
+
*/
|
|
1345
|
+
public async waitForPendingOps(): Promise<void> {
|
|
1346
|
+
if (this.stateLoadPromise) {
|
|
1347
|
+
await this.stateLoadPromise;
|
|
1348
|
+
}
|
|
1349
|
+
// Give microtasks a chance to flush
|
|
1350
|
+
await Promise.resolve();
|
|
1351
|
+
}
|
|
1352
|
+
}
|