From 9097fafbd4634e0468c719feaff2beb73a5043a3 Mon Sep 17 00:00:00 2001 From: lvhaiyu Date: Sun, 19 Apr 2026 14:11:15 +0800 Subject: [PATCH] feat(core): Enhance the cancellation mechanism --- application/basic_demo/CMakeLists.txt | 2 +- application/basic_demo/main/Kconfig.projbuild | 400 ++++---- application/basic_demo/main/app_claw.c | 5 +- .../basic_demo/main/basic_demo_settings.h | 1 - application/basic_demo/main/web/app.js | 13 +- application/basic_demo/main/web/index.html | 5 +- application/basic_demo/main/web/styles.css | 7 + .../claw_capabilities/cap_lua/CMakeLists.txt | 1 + .../cap_lua/include/cap_lua.h | 57 +- .../claw_capabilities/cap_lua/src/cap_lua.c | 880 +++++++++++------- .../cap_lua/src/cap_lua_async.c | 766 +++++++++++++-- .../cap_lua/src/cap_lua_internal.h | 93 +- .../cap_lua/src/cap_lua_runtime.c | 41 +- .../cap_lua/src/cmd_cap_lua.c | 41 +- .../claw_modules/claw_cap/src/claw_cap.c | 38 +- .../claw_core/include/claw_core.h | 35 + .../claw_modules/claw_core/src/claw_core.c | 166 +++- .../src/llm/claw_llm_http_transport.c | 51 +- .../src/llm/claw_llm_http_transport.h | 19 + .../include/claw_event_router.h | 19 + .../claw_event_router/src/claw_event_router.c | 222 ++++- .../lua_module_display/src/display_hal.c | 42 +- 22 files changed, 2242 insertions(+), 662 deletions(-) diff --git a/application/basic_demo/CMakeLists.txt b/application/basic_demo/CMakeLists.txt index cb88c04..b70379d 100644 --- a/application/basic_demo/CMakeLists.txt +++ b/application/basic_demo/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.16) -include("${CMAKE_SOURCE_DIR}/tools/cmake/esp_idf_patch.cmake") +# include("${CMAKE_SOURCE_DIR}/tools/cmake/esp_idf_patch.cmake") include("${CMAKE_SOURCE_DIR}/tools/cmake/flash_partition_defaults.cmake") include($ENV{IDF_PATH}/tools/cmake/project.cmake) diff --git a/application/basic_demo/main/Kconfig.projbuild b/application/basic_demo/main/Kconfig.projbuild index b0352d5..02b6487 100644 --- a/application/basic_demo/main/Kconfig.projbuild +++ b/application/basic_demo/main/Kconfig.projbuild @@ -1,199 +1,219 @@ menu "Basic Demo Config" -choice BASIC_DEMO_MEMORY_MODE - prompt "Memory management mode" - default BASIC_DEMO_MEMORY_MODE_FULL - help - Select whether the demo uses the current structured memory module - or a lightweight master-compatible memory flow. +menu "Feature Toggles" -config BASIC_DEMO_MEMORY_MODE_FULL - bool "Structured memory management" - help - Enable structured memory skills and the full memory capability group. + choice BASIC_DEMO_MEMORY_MODE + prompt "Memory management mode" + default BASIC_DEMO_MEMORY_MODE_FULL + help + Select whether the demo uses the current structured memory module + or a lightweight master-compatible memory flow. -config BASIC_DEMO_MEMORY_MODE_LIGHTWEIGHT - bool "Lightweight memory" - help - Keep profile memory support, but switch long-term/session memory to a - lightweight flow without structured auto extraction. + config BASIC_DEMO_MEMORY_MODE_FULL + bool "Structured memory management" + help + Enable structured memory skills and the full memory capability group. -endchoice + config BASIC_DEMO_MEMORY_MODE_LIGHTWEIGHT + bool "Lightweight memory" + help + Keep profile memory support, but switch long-term/session memory to a + lightweight flow without structured auto extraction. -config BASIC_DEMO_ENABLE_EMOTE - bool "Enable emote" - depends on ESP_BOARD_DEV_DISPLAY_LCD_SUPPORT - default y - help - Enable expression emote startup and emote asset packaging for boards with LCD display support. + endchoice -config BASIC_DEMO_WIFI_SSID - string "Default Wi-Fi SSID" - default "" - help - Default Wi-Fi SSID used to populate basic demo settings. - -config BASIC_DEMO_WIFI_PASSWORD - string "Default Wi-Fi password" - default "" - help - Default Wi-Fi password used to populate basic demo settings. - -choice BASIC_DEMO_LLM_PROVIDER - prompt "Default LLM provider preset" - default BASIC_DEMO_LLM_PROVIDER_QWEN - help - Select the provider preset used to populate the shared LLM settings below. - OpenAI, Qwen Compatible, and Anthropic all reuse the same API key, model, - backend, profile, base URL, and auth fields. - -config BASIC_DEMO_LLM_PROVIDER_OPENAI - bool "OpenAI" - -config BASIC_DEMO_LLM_PROVIDER_QWEN - bool "Qwen Compatible" - -config BASIC_DEMO_LLM_PROVIDER_ANTHROPIC - bool "Anthropic" - -config BASIC_DEMO_LLM_PROVIDER_CUSTOM - bool "Custom" - -endchoice - -config BASIC_DEMO_LLM_API_KEY - string "Default LLM API key" - default "" - help - Default API key used to populate the shared LLM settings. - Anthropic reuses this field instead of having a dedicated provider-specific key. - -config BASIC_DEMO_LLM_BACKEND_TYPE - string "Default LLM backend type" - default "openai_compatible" if BASIC_DEMO_LLM_PROVIDER_OPENAI - default "openai_compatible" if BASIC_DEMO_LLM_PROVIDER_QWEN - default "anthropic" if BASIC_DEMO_LLM_PROVIDER_ANTHROPIC - default "custom" if BASIC_DEMO_LLM_PROVIDER_CUSTOM - help - Default backend type for claw_core. - The provider preset above already fills this for standard providers. - -config BASIC_DEMO_LLM_PROFILE - string "Default LLM profile" - default "openai" if BASIC_DEMO_LLM_PROVIDER_OPENAI - default "qwen_compatible" if BASIC_DEMO_LLM_PROVIDER_QWEN - default "anthropic" if BASIC_DEMO_LLM_PROVIDER_ANTHROPIC - default "custom_openai_compatible" if BASIC_DEMO_LLM_PROVIDER_CUSTOM - help - Default runtime profile for claw_core. - The provider preset above already fills this for standard providers. - -config BASIC_DEMO_LLM_MODEL - string "Default LLM model" - default "" if BASIC_DEMO_LLM_PROVIDER_OPENAI - default "qwen3-max-2026-01-23" if BASIC_DEMO_LLM_PROVIDER_QWEN - default "" if BASIC_DEMO_LLM_PROVIDER_ANTHROPIC - default "" if BASIC_DEMO_LLM_PROVIDER_CUSTOM - help - Default model name for claw_core requests. - Set this explicitly for the provider you selected above. - -config BASIC_DEMO_LLM_BASE_URL - string "Default LLM base URL" - default "https://api.openai.com" if BASIC_DEMO_LLM_PROVIDER_OPENAI - default "https://dashscope.aliyuncs.com" if BASIC_DEMO_LLM_PROVIDER_QWEN - default "https://api.anthropic.com" if BASIC_DEMO_LLM_PROVIDER_ANTHROPIC - default "" if BASIC_DEMO_LLM_PROVIDER_CUSTOM - help - Default base URL for the selected provider preset. - Leave the provider preset as Custom if you need to override this manually. - -config BASIC_DEMO_LLM_AUTH_TYPE - string "Default LLM auth type" - default "bearer" if BASIC_DEMO_LLM_PROVIDER_OPENAI - default "bearer" if BASIC_DEMO_LLM_PROVIDER_QWEN - default "none" if BASIC_DEMO_LLM_PROVIDER_ANTHROPIC - default "bearer" if BASIC_DEMO_LLM_PROVIDER_CUSTOM - help - Auth mode for the LLM backend, for example bearer, api-key, or none. - The provider preset above already fills this for standard providers. - -config BASIC_DEMO_LLM_TIMEOUT_MS - string "Default LLM timeout ms" - default "120000" - help - Timeout in milliseconds for outbound LLM HTTP requests. - -config BASIC_DEMO_QQ_APP_ID - string "Default QQ app ID" - default "" - help - Default QQ app ID used by the IM cap. - -config BASIC_DEMO_QQ_APP_SECRET - string "Default QQ app secret" - default "" - help - Default QQ app secret used by the IM cap. - -config BASIC_DEMO_FEISHU_APP_ID - string "Default Feishu app ID" - default "" - help - Default Feishu app ID used by the IM cap. - -config BASIC_DEMO_FEISHU_APP_SECRET - string "Default Feishu app secret" - default "" - help - Default Feishu app secret used by the IM cap. - -config BASIC_DEMO_TG_BOT_TOKEN - string "Default Telegram bot token" - default "" - help - Default Telegram bot token used by the IM cap. - -config BASIC_DEMO_WECHAT_TOKEN - string "Default WeChat token" - default "" - help - Default WeChat bot token used by the IM cap. - -config BASIC_DEMO_WECHAT_BASE_URL - string "Default WeChat base URL" - default "https://ilinkai.weixin.qq.com" - help - Default WeChat API base URL used by the IM cap. - -config BASIC_DEMO_WECHAT_CDN_BASE_URL - string "Default WeChat CDN base URL" - default "https://novac2c.cdn.weixin.qq.com/c2c" - help - Default WeChat CDN base URL used for media upload and download. - -config BASIC_DEMO_WECHAT_ACCOUNT_ID - string "Default WeChat account ID" - default "default" - help - Default logical account ID for local WeChat channel state. - -config BASIC_DEMO_SEARCH_BRAVE_KEY - string "Default Brave Search API key" - default "" - help - Default Brave Search API key for the web search cap. - -config BASIC_DEMO_SEARCH_TAVILY_KEY - string "Default Tavily API key" - default "" - help - Default Tavily API key for the web search cap. - -config BASIC_DEMO_TIME_TIMEZONE - string "Default time cap timezone" - default "CST-8" - help - POSIX-style timezone string used by the time cap. + config BASIC_DEMO_ENABLE_EMOTE + bool "Enable emote" + depends on ESP_BOARD_DEV_DISPLAY_LCD_SUPPORT + default y + help + Enable expression emote startup and emote asset packaging for boards with LCD display support. + +endmenu + +menu "Default Wi-Fi Settings" + + config BASIC_DEMO_WIFI_SSID + string "Default Wi-Fi SSID" + default "" + help + Default Wi-Fi SSID used to populate basic demo settings. + + config BASIC_DEMO_WIFI_PASSWORD + string "Default Wi-Fi password" + default "" + help + Default Wi-Fi password used to populate basic demo settings. + +endmenu + +menu "Default LLM Settings" + + choice BASIC_DEMO_LLM_PROVIDER + prompt "Default LLM provider preset" + default BASIC_DEMO_LLM_PROVIDER_QWEN + help + Select the provider preset used to populate the shared LLM settings below. + OpenAI, Qwen Compatible, and Anthropic all reuse the same API key, model, + backend, profile, base URL, and auth fields. + + config BASIC_DEMO_LLM_PROVIDER_OPENAI + bool "OpenAI" + + config BASIC_DEMO_LLM_PROVIDER_QWEN + bool "Qwen Compatible" + + config BASIC_DEMO_LLM_PROVIDER_ANTHROPIC + bool "Anthropic" + + config BASIC_DEMO_LLM_PROVIDER_CUSTOM + bool "Custom" + + endchoice + + config BASIC_DEMO_LLM_API_KEY + string "Default LLM API key" + default "" + help + Default API key used to populate the shared LLM settings. + Anthropic reuses this field instead of having a dedicated provider-specific key. + + config BASIC_DEMO_LLM_BACKEND_TYPE + string "Default LLM backend type" + default "openai_compatible" if BASIC_DEMO_LLM_PROVIDER_OPENAI + default "openai_compatible" if BASIC_DEMO_LLM_PROVIDER_QWEN + default "anthropic" if BASIC_DEMO_LLM_PROVIDER_ANTHROPIC + default "custom" if BASIC_DEMO_LLM_PROVIDER_CUSTOM + help + Default backend type for claw_core. + The provider preset above already fills this for standard providers. + + config BASIC_DEMO_LLM_PROFILE + string "Default LLM profile" + default "openai" if BASIC_DEMO_LLM_PROVIDER_OPENAI + default "qwen_compatible" if BASIC_DEMO_LLM_PROVIDER_QWEN + default "anthropic" if BASIC_DEMO_LLM_PROVIDER_ANTHROPIC + default "custom_openai_compatible" if BASIC_DEMO_LLM_PROVIDER_CUSTOM + help + Default runtime profile for claw_core. + The provider preset above already fills this for standard providers. + + config BASIC_DEMO_LLM_MODEL + string "Default LLM model" + default "" if BASIC_DEMO_LLM_PROVIDER_OPENAI + default "qwen3-max-2026-01-23" if BASIC_DEMO_LLM_PROVIDER_QWEN + default "" if BASIC_DEMO_LLM_PROVIDER_ANTHROPIC + default "" if BASIC_DEMO_LLM_PROVIDER_CUSTOM + help + Default model name for claw_core requests. + Set this explicitly for the provider you selected above. + + config BASIC_DEMO_LLM_BASE_URL + string "Default LLM base URL" + default "https://api.openai.com" if BASIC_DEMO_LLM_PROVIDER_OPENAI + default "https://dashscope.aliyuncs.com" if BASIC_DEMO_LLM_PROVIDER_QWEN + default "https://api.anthropic.com" if BASIC_DEMO_LLM_PROVIDER_ANTHROPIC + default "" if BASIC_DEMO_LLM_PROVIDER_CUSTOM + help + Default base URL for the selected provider preset. + Leave the provider preset as Custom if you need to override this manually. + + config BASIC_DEMO_LLM_AUTH_TYPE + string "Default LLM auth type" + default "bearer" if BASIC_DEMO_LLM_PROVIDER_OPENAI + default "bearer" if BASIC_DEMO_LLM_PROVIDER_QWEN + default "none" if BASIC_DEMO_LLM_PROVIDER_ANTHROPIC + default "bearer" if BASIC_DEMO_LLM_PROVIDER_CUSTOM + help + Auth mode for the LLM backend, for example bearer, api-key, or none. + The provider preset above already fills this for standard providers. + + config BASIC_DEMO_LLM_TIMEOUT_MS + string "Default LLM timeout ms" + default "120000" + help + Timeout in milliseconds for outbound LLM HTTP requests. + +endmenu + +menu "Default IM Settings" + + config BASIC_DEMO_QQ_APP_ID + string "Default QQ app ID" + default "" + help + Default QQ app ID used by the IM cap. + + config BASIC_DEMO_QQ_APP_SECRET + string "Default QQ app secret" + default "" + help + Default QQ app secret used by the IM cap. + + config BASIC_DEMO_FEISHU_APP_ID + string "Default Feishu app ID" + default "" + help + Default Feishu app ID used by the IM cap. + + config BASIC_DEMO_FEISHU_APP_SECRET + string "Default Feishu app secret" + default "" + help + Default Feishu app secret used by the IM cap. + + config BASIC_DEMO_TG_BOT_TOKEN + string "Default Telegram bot token" + default "" + help + Default Telegram bot token used by the IM cap. + + config BASIC_DEMO_WECHAT_TOKEN + string "Default WeChat token" + default "" + help + Default WeChat bot token used by the IM cap. + + config BASIC_DEMO_WECHAT_BASE_URL + string "Default WeChat base URL" + default "https://ilinkai.weixin.qq.com" + help + Default WeChat API base URL used by the IM cap. + + config BASIC_DEMO_WECHAT_CDN_BASE_URL + string "Default WeChat CDN base URL" + default "https://novac2c.cdn.weixin.qq.com/c2c" + help + Default WeChat CDN base URL used for media upload and download. + + config BASIC_DEMO_WECHAT_ACCOUNT_ID + string "Default WeChat account ID" + default "default" + help + Default logical account ID for local WeChat channel state. + +endmenu + +menu "Default Search And Time Settings" + + config BASIC_DEMO_SEARCH_BRAVE_KEY + string "Default Brave Search API key" + default "" + help + Default Brave Search API key for the web search cap. + + config BASIC_DEMO_SEARCH_TAVILY_KEY + string "Default Tavily API key" + default "" + help + Default Tavily API key for the web search cap. + + config BASIC_DEMO_TIME_TIMEZONE + string "Default time cap timezone" + default "CST-8" + help + POSIX-style timezone string used by the time cap. + +endmenu endmenu diff --git a/application/basic_demo/main/app_claw.c b/application/basic_demo/main/app_claw.c index c7b199e..238af4a 100644 --- a/application/basic_demo/main/app_claw.c +++ b/application/basic_demo/main/app_claw.c @@ -388,7 +388,7 @@ esp_err_t app_claw_start(const basic_demo_settings_t *settings) core_config.max_tool_iterations = 20; core_config.request_queue_len = 4; core_config.response_queue_len = 4; - core_config.max_context_providers = 6; + core_config.max_context_providers = 8; if (!llm_enabled) { ESP_LOGW(TAG, "LLM is not fully configured. Provider=%s profile=%s model=%s. " @@ -411,6 +411,9 @@ ESP_RETURN_ON_ERROR(claw_core_add_context_provider(&claw_memory_session_history_ ESP_RETURN_ON_ERROR(claw_core_add_context_provider(&claw_skill_skills_list_provider), TAG, "Failed to add skills list provider"); ESP_RETURN_ON_ERROR(claw_core_add_context_provider(&claw_skill_active_skill_docs_provider), TAG, "Failed to add active skill docs provider"); ESP_RETURN_ON_ERROR(claw_core_add_context_provider(&claw_cap_tools_provider), TAG, "Failed to add cap tools provider"); +ESP_RETURN_ON_ERROR(claw_core_add_context_provider(&cap_lua_async_jobs_provider), TAG, "Failed to add Lua async jobs provider"); + ESP_RETURN_ON_ERROR(claw_core_add_completion_observer(cap_lua_honesty_observe_completion, NULL), + TAG, "Failed to install Lua honesty observer"); ESP_RETURN_ON_ERROR(claw_core_start(), TAG, "Failed to start claw_core"); } diff --git a/application/basic_demo/main/basic_demo_settings.h b/application/basic_demo/main/basic_demo_settings.h index 875c29f..b2b790a 100644 --- a/application/basic_demo/main/basic_demo_settings.h +++ b/application/basic_demo/main/basic_demo_settings.h @@ -8,7 +8,6 @@ #include "esp_err.h" #define BASIC_DEMO_SETTINGS_STR_LEN 320 -#define BASIC_DEMO_SETTINGS_LUA_DIR_LEN 64 #define BASIC_DEMO_SETTINGS_TIMEZONE_LEN 32 #define BASIC_DEMO_WIFI_SSID CONFIG_BASIC_DEMO_WIFI_SSID diff --git a/application/basic_demo/main/web/app.js b/application/basic_demo/main/web/app.js index cea58f6..32e5d5b 100644 --- a/application/basic_demo/main/web/app.js +++ b/application/basic_demo/main/web/app.js @@ -79,9 +79,9 @@ const strings = { sectionAdvanced: "Advanced Settings", timezone: "Timezone", - timezonePlaceholder: "e.g. UTC0", - luaBaseDir: "Lua Base Directory", - + timezonePlaceholder: "e.g. CST-8 (Beijing) or UTC0", + timezoneHelp: + "POSIX TZ string only. The sign is INVERTED relative to common UTC notation: Beijing (UTC+8) must be written as 'CST-8'; New York (UTC-5) is 'EST5'. Names like 'Asia/Shanghai' will NOT work — please convert manually.", saveBtn: "Save Changes", saveSuccess: "Settings saved", saveError: "Failed to save settings", @@ -210,9 +210,9 @@ const strings = { sectionAdvanced: "高级设置", timezone: "时区", - timezonePlaceholder: "例如 UTC0", - luaBaseDir: "Lua 基本目录", - + timezonePlaceholder: "例如 CST-8(北京时间)或 UTC0", + timezoneHelp: + "仅接受 POSIX TZ 字符串,符号与日常 UTC 表示相反。北京时间(UTC+8)应写作 “CST-8”,纽约(UTC-5)写作 “EST5”。不支持 “Asia/Shanghai” 这种 IANA 名称,请自行换算。", saveBtn: "保存更改", saveSuccess: "设置已保存", saveError: "保存设置失败", @@ -405,7 +405,6 @@ const configFields = [ "wechat_account_id", "search_brave_key", "search_tavily_key", - "lua_base_dir", "time_timezone", ]; diff --git a/application/basic_demo/main/web/index.html b/application/basic_demo/main/web/index.html index 0a22e3e..ea6478c 100644 --- a/application/basic_demo/main/web/index.html +++ b/application/basic_demo/main/web/index.html @@ -315,10 +315,7 @@
-
-
- - + POSIX TZ string only. Sign is inverted: Beijing (UTC+8) must be written as 'CST-8'.
diff --git a/application/basic_demo/main/web/styles.css b/application/basic_demo/main/web/styles.css index 472f07c..5305c96 100644 --- a/application/basic_demo/main/web/styles.css +++ b/application/basic_demo/main/web/styles.css @@ -464,6 +464,13 @@ main { grid-column: 1 / -1; } +.form-hint { + font-size: 0.72rem; + color: var(--text-secondary); + line-height: 1.35; + opacity: 0.85; +} + label { font-size: 0.8rem; color: var(--text-secondary); diff --git a/components/claw_capabilities/cap_lua/CMakeLists.txt b/components/claw_capabilities/cap_lua/CMakeLists.txt index 8de499e..cf8c892 100644 --- a/components/claw_capabilities/cap_lua/CMakeLists.txt +++ b/components/claw_capabilities/cap_lua/CMakeLists.txt @@ -9,6 +9,7 @@ idf_component_register( "src" REQUIRES claw_cap + claw_core console esp_timer freertos diff --git a/components/claw_capabilities/cap_lua/include/cap_lua.h b/components/claw_capabilities/cap_lua/include/cap_lua.h index 94ed264..67934e7 100644 --- a/components/claw_capabilities/cap_lua/include/cap_lua.h +++ b/components/claw_capabilities/cap_lua/include/cap_lua.h @@ -9,6 +9,7 @@ #include #include +#include "claw_core.h" #include "esp_err.h" #include "lua.h" @@ -30,11 +31,59 @@ esp_err_t cap_lua_register_module(const char *name, lua_CFunction open_fn); esp_err_t cap_lua_register_modules(const cap_lua_module_t *modules, size_t count); esp_err_t cap_lua_register_runtime_cleanup(cap_lua_runtime_cleanup_fn_t cleanup_fn); esp_err_t cap_lua_list_scripts(const char *prefix, char *output, size_t output_size); -esp_err_t cap_lua_write_script(const char *path, const char *content, bool overwrite, char *output, size_t output_size); -esp_err_t cap_lua_run_script(const char *path, const char *args_json, uint32_t timeout_ms, char *output, size_t output_size); -esp_err_t cap_lua_run_script_async(const char *path, const char *args_json, uint32_t timeout_ms, char *output, size_t output_size); +esp_err_t cap_lua_write_script(const char *path, + const char *content, + bool overwrite, + char *output, + size_t output_size); +esp_err_t cap_lua_run_script(const char *path, + const char *args_json, + uint32_t timeout_ms, + char *output, + size_t output_size); + +/* + * Submit a managed Lua script to run asynchronously. + * + * - timeout_ms == 0 : no wall-clock deadline (cancel-only). + * - name : optional handle (defaults to script basename). + * - exclusive : optional mutex group (e.g. "display"); same group can + * have at most one running job. + * - replace : if true, conflicting jobs (same name OR exclusive) are + * stopped before this one starts. + */ +esp_err_t cap_lua_run_script_async(const char *path, + const char *args_json, + uint32_t timeout_ms, + const char *name, + const char *exclusive, + bool replace, + char *output, + size_t output_size); esp_err_t cap_lua_list_jobs(const char *status, char *output, size_t output_size); -esp_err_t cap_lua_get_job(const char *job_id, char *output, size_t output_size); +esp_err_t cap_lua_get_job(const char *id_or_name, char *output, size_t output_size); +esp_err_t cap_lua_stop_job(const char *id_or_name, + uint32_t wait_ms, + char *output, + size_t output_size); +esp_err_t cap_lua_stop_all_jobs(const char *exclusive_filter, + uint32_t wait_ms, + char *output, + size_t output_size); + +/* Context provider that injects a snapshot of active async jobs into LLM context. */ +extern const claw_core_context_provider_t cap_lua_async_jobs_provider; + +/* + * Diagnostic completion observer (matches claw_core_completion_observer_fn): + * detects assistant replies that claim to have stopped / cancelled / cleared + * an async script while the active-jobs context was injected and no matching + * stop tool was invoked this turn. Emits ESP_LOGW only — no behaviour change. + * + * Wire this once during boot via claw_core_add_completion_observer(). + */ +void cap_lua_honesty_observe_completion(const claw_core_completion_summary_t *summary, + void *user_ctx); #ifdef __cplusplus } diff --git a/components/claw_capabilities/cap_lua/src/cap_lua.c b/components/claw_capabilities/cap_lua/src/cap_lua.c index 0ab79c8..03e1d4e 100644 --- a/components/claw_capabilities/cap_lua/src/cap_lua.c +++ b/components/claw_capabilities/cap_lua/src/cap_lua.c @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -15,6 +16,7 @@ #include "cJSON.h" #include "claw_cap.h" +#include "claw_core.h" #include "esp_check.h" #include "esp_log.h" @@ -27,7 +29,7 @@ typedef struct cap_lua_runtime_cleanup_node { struct cap_lua_runtime_cleanup_node *next; } cap_lua_runtime_cleanup_node_t; -static char s_lua_base_dir[128] = {0}; +static char s_lua_base_dir[128] = CAP_LUA_DEFAULT_BASE_DIR; static cap_lua_module_t s_modules[CAP_LUA_MAX_MODULES]; static size_t s_module_count; static cap_lua_runtime_cleanup_node_t *s_runtime_cleanups; @@ -35,210 +37,17 @@ static size_t s_runtime_cleanup_count; static bool s_builtin_modules_registered; static bool s_module_registration_locked; -static bool cap_lua_base_dir_is_set(void) -{ - return s_lua_base_dir[0] != '\0'; -} - -static bool cap_lua_script_path_has_ext(const char *path) -{ - size_t path_len; - - if (!path) { - return false; - } - - path_len = strlen(path); - return path_len > 4 && strcmp(path + path_len - 4, ".lua") == 0; -} - -static esp_err_t cap_lua_path_to_relative(const char *path, char *relative, size_t relative_size) -{ - size_t base_len; - size_t copied; - - if (!cap_lua_path_is_valid(path) || !relative || relative_size == 0) { - return ESP_ERR_INVALID_ARG; - } - - base_len = strlen(s_lua_base_dir); - if (path[base_len] != '/') { - return ESP_ERR_INVALID_ARG; - } - - copied = strlcpy(relative, path + base_len + 1, relative_size); - if (copied >= relative_size) { - return ESP_ERR_INVALID_SIZE; - } - - return ESP_OK; -} - -static esp_err_t cap_lua_resolve_under_base_dir(const char *path, char *resolved, size_t resolved_size, bool require_script) -{ - int written; - - if (!path || !path[0] || !resolved || resolved_size == 0) { - return ESP_ERR_INVALID_ARG; - } - if (!cap_lua_base_dir_is_set()) { - return ESP_ERR_INVALID_STATE; - } - if (path[0] == '/' || strstr(path, "..") != NULL) { - return ESP_ERR_INVALID_ARG; - } - - written = snprintf(resolved, resolved_size, "%s/%s", s_lua_base_dir, path); - if (written < 0 || (size_t)written >= resolved_size) { - return ESP_ERR_INVALID_SIZE; - } - if (!cap_lua_path_is_valid(resolved)) { - return ESP_ERR_INVALID_ARG; - } - if (require_script && !cap_lua_script_path_has_ext(resolved)) { - return ESP_ERR_INVALID_ARG; - } - - return ESP_OK; -} - -static esp_err_t cap_lua_ensure_dir(const char *path) -{ - struct stat st = {0}; - - if (stat(path, &st) == 0) { - return S_ISDIR(st.st_mode) ? ESP_OK : ESP_FAIL; - } - - if (mkdir(path, 0755) != 0 && errno != EEXIST) { - ESP_LOGE(TAG, "mkdir failed for %s: errno=%d", path, errno); - return ESP_FAIL; - } - - return ESP_OK; -} - -static esp_err_t cap_lua_ensure_parent_dirs(const char *path) -{ - char dir[256]; - char *slash = NULL; - char *cursor = NULL; - size_t base_len; - - if (!cap_lua_path_is_valid(path)) { - return ESP_ERR_INVALID_ARG; - } - - strlcpy(dir, path, sizeof(dir)); - slash = strrchr(dir, '/'); - if (!slash) { - return ESP_OK; - } - - base_len = strlen(s_lua_base_dir); - if ((size_t)(slash - dir) <= base_len) { - return cap_lua_ensure_dir(s_lua_base_dir); - } - *slash = '\0'; - - if (cap_lua_ensure_dir(s_lua_base_dir) != ESP_OK) { - return ESP_FAIL; - } - - /* Create each missing path segment under the configured Lua base directory. */ - cursor = dir + base_len + 1; - while (*cursor) { - if (*cursor == '/') { - *cursor = '\0'; - if (cap_lua_ensure_dir(dir) != ESP_OK) { - *cursor = '/'; - return ESP_FAIL; - } - *cursor = '/'; - } - cursor++; - } - - return cap_lua_ensure_dir(dir); -} - -static esp_err_t cap_lua_list_scripts_recursive(const char *dir_path, - const char *prefix, - char *output, - size_t output_size, - size_t *offset, - int *count) -{ - DIR *dir = NULL; - struct dirent *entry = NULL; - - dir = opendir(dir_path); - if (!dir) { - return ESP_FAIL; - } - - while ((entry = readdir(dir)) != NULL) { - char full_path[384]; - char relative_path[256]; - struct stat st = {0}; - esp_err_t err; - - if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) { - continue; - } - - if (snprintf(full_path, sizeof(full_path), "%s/%s", dir_path, entry->d_name) >= (int)sizeof(full_path)) { - closedir(dir); - return ESP_ERR_INVALID_SIZE; - } - if (!cap_lua_path_is_valid(full_path) || stat(full_path, &st) != 0) { - continue; - } - - if (S_ISDIR(st.st_mode)) { - err = cap_lua_list_scripts_recursive(full_path, prefix, output, output_size, offset, count); - if (err != ESP_OK) { - closedir(dir); - return err; - } - continue; - } - if (!cap_lua_script_path_has_ext(full_path)) { - continue; - } - - err = cap_lua_path_to_relative(full_path, relative_path, sizeof(relative_path)); - if (err != ESP_OK) { - closedir(dir); - return err; - } - if (prefix && strncmp(relative_path, prefix, strlen(prefix)) != 0) { - continue; - } - - if (*offset < output_size - 1) { - int written = snprintf(output + *offset, output_size - *offset, "%s\n", relative_path); - if (written < 0) { - closedir(dir); - return ESP_FAIL; - } - if ((size_t)written >= output_size - *offset) { - *offset = output_size - 1; - } else { - *offset += (size_t)written; - } - } - (*count)++; - } - - closedir(dir); - return ESP_OK; -} - -static esp_err_t cap_lua_build_simple_request(const char *string_key, const char *string_value, const char *string_key2, - const char *string_value2, bool has_bool, const char *bool_key, - bool bool_value, bool has_number, const char *number_key, - uint32_t number_value, char **json_out) +static esp_err_t cap_lua_build_simple_request(const char *string_key, + const char *string_value, + const char *string_key2, + const char *string_value2, + bool has_bool, + const char *bool_key, + bool bool_value, + bool has_number, + const char *number_key, + uint32_t number_value, + char **json_out) { cJSON *root = NULL; @@ -256,7 +65,8 @@ static esp_err_t cap_lua_build_simple_request(const char *string_key, const char cJSON_Delete(root); return ESP_ERR_NO_MEM; } - if (string_key2 && string_value2 && !cJSON_AddStringToObject(root, string_key2, string_value2)) { + if (string_key2 && string_value2 && + !cJSON_AddStringToObject(root, string_key2, string_value2)) { cJSON_Delete(root); return ESP_ERR_NO_MEM; } @@ -264,7 +74,8 @@ static esp_err_t cap_lua_build_simple_request(const char *string_key, const char cJSON_Delete(root); return ESP_ERR_NO_MEM; } - if (has_number && number_key && !cJSON_AddNumberToObject(root, number_key, (double)number_value)) { + if (has_number && number_key && + !cJSON_AddNumberToObject(root, number_key, (double)number_value)) { cJSON_Delete(root); return ESP_ERR_NO_MEM; } @@ -282,37 +93,58 @@ const char *cap_lua_get_base_dir(void) bool cap_lua_path_is_valid(const char *path) { size_t base_len; + size_t path_len; - if (!path || !path[0]) { + if (!path) { return false; } - if (!cap_lua_base_dir_is_set()) { + + base_len = strlen(s_lua_base_dir); + if (strncmp(path, s_lua_base_dir, base_len) != 0 || path[base_len] != '/') { return false; } if (strstr(path, "..") != NULL) { return false; } - base_len = strlen(s_lua_base_dir); - if (strncmp(path, s_lua_base_dir, base_len) != 0) { - return false; - } - - return path[base_len] == '\0' || path[base_len] == '/'; + path_len = strlen(path); + return path_len > 4 && strcmp(path + path_len - 4, ".lua") == 0; } esp_err_t cap_lua_resolve_path(const char *path, char *resolved, size_t resolved_size) { - return cap_lua_resolve_under_base_dir(path, resolved, resolved_size, true); + int written; + + if (!path || !path[0] || !resolved || resolved_size == 0) { + return ESP_ERR_INVALID_ARG; + } + + if (path[0] == '/') { + if (!cap_lua_path_is_valid(path)) { + return ESP_ERR_INVALID_ARG; + } + strlcpy(resolved, path, resolved_size); + return ESP_OK; + } + + if (strstr(path, "..") != NULL || strchr(path, '/') != NULL) { + return ESP_ERR_INVALID_ARG; + } + + written = snprintf(resolved, resolved_size, "%s/%s", s_lua_base_dir, path); + if (written < 0 || (size_t)written >= resolved_size) { + return ESP_ERR_INVALID_SIZE; + } + if (!cap_lua_path_is_valid(resolved)) { + return ESP_ERR_INVALID_ARG; + } + + return ESP_OK; } esp_err_t cap_lua_ensure_base_dir(void) { - if (!cap_lua_base_dir_is_set()) { - ESP_LOGE(TAG, "Lua base dir is not configured"); - return ESP_ERR_INVALID_STATE; - } - if (cap_lua_ensure_dir(s_lua_base_dir) != ESP_OK) { + if (mkdir(s_lua_base_dir, 0755) != 0 && errno != EEXIST) { ESP_LOGE(TAG, "Failed to create Lua base dir %s", s_lua_base_dir); return ESP_FAIL; } @@ -388,7 +220,9 @@ static esp_err_t cap_lua_build_args_json(cJSON *root, const claw_cap_call_contex static esp_err_t cap_lua_group_init(void) { - ESP_RETURN_ON_ERROR(cap_lua_register_builtin_modules(), TAG, "Failed to register builtin Lua modules"); + ESP_RETURN_ON_ERROR(cap_lua_register_builtin_modules(), + TAG, + "Failed to register builtin Lua modules"); s_module_registration_locked = true; ESP_RETURN_ON_ERROR(cap_lua_ensure_base_dir(), TAG, "Failed to create base dir"); ESP_RETURN_ON_ERROR(cap_lua_runtime_init(), TAG, "Failed to init runtime"); @@ -401,15 +235,17 @@ static esp_err_t cap_lua_group_start(void) return cap_lua_async_start(); } -static esp_err_t cap_lua_list_scripts_execute(const char *input_json, const claw_cap_call_context_t *ctx, char *output, +static esp_err_t cap_lua_list_scripts_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, size_t output_size) { cJSON *root = NULL; const char *prefix = NULL; - char resolved_prefix[192]; + DIR *dir = NULL; + struct dirent *entry = NULL; size_t offset = 0; int count = 0; - esp_err_t err; (void)ctx; @@ -426,35 +262,49 @@ static esp_err_t cap_lua_list_scripts_execute(const char *input_json, const claw } } - if (!cap_lua_base_dir_is_set()) { + if (prefix && strncmp(prefix, s_lua_base_dir, strlen(s_lua_base_dir)) != 0) { cJSON_Delete(root); - snprintf(output, output_size, "Error: Lua base dir is not configured"); - return ESP_ERR_INVALID_STATE; - } - if (prefix && cap_lua_resolve_under_base_dir(prefix, resolved_prefix, sizeof(resolved_prefix), false) != ESP_OK) { - cJSON_Delete(root); - snprintf(output, output_size, "Error: prefix must be a relative path under %s", s_lua_base_dir); + snprintf(output, output_size, "Error: prefix must stay under %s", s_lua_base_dir); return ESP_ERR_INVALID_ARG; } - if (cap_lua_ensure_base_dir() != ESP_OK) { + + dir = opendir(s_lua_base_dir); + if (!dir) { cJSON_Delete(root); - snprintf(output, output_size, "Error: failed to ensure Lua base dir"); + snprintf(output, output_size, "Error: cannot open %s", s_lua_base_dir); return ESP_FAIL; } - err = cap_lua_list_scripts_recursive(s_lua_base_dir, prefix, output, output_size, &offset, &count); - cJSON_Delete(root); - if (err != ESP_OK) { - snprintf(output, output_size, "Error: cannot scan %s", s_lua_base_dir); - return err; + while ((entry = readdir(dir)) != NULL && offset < output_size - 1) { + char full_path[384]; + + if (entry->d_name[0] == '.') { + continue; + } + + snprintf(full_path, sizeof(full_path), "%s/%s", s_lua_base_dir, entry->d_name); + if (!cap_lua_path_is_valid(full_path)) { + continue; + } + if (prefix && strncmp(full_path, prefix, strlen(prefix)) != 0) { + continue; + } + + offset += snprintf(output + offset, output_size - offset, "%s\n", full_path); + count++; } + + closedir(dir); + cJSON_Delete(root); if (count == 0) { snprintf(output, output_size, "(no Lua scripts found)"); } return ESP_OK; } -static esp_err_t cap_lua_write_script_execute(const char *input_json, const claw_cap_call_context_t *ctx, char *output, +static esp_err_t cap_lua_write_script_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, size_t output_size) { cJSON *root = NULL; @@ -466,7 +316,6 @@ static esp_err_t cap_lua_write_script_execute(const char *input_json, const claw struct stat st = {0}; FILE *file = NULL; size_t content_len = 0; - esp_err_t err; (void)ctx; @@ -483,15 +332,10 @@ static esp_err_t cap_lua_write_script_execute(const char *input_json, const claw overwrite = cJSON_IsTrue(overwrite_item); } - err = cap_lua_resolve_path(path, resolved_path, sizeof(resolved_path)); - if (err != ESP_OK) { + if (cap_lua_resolve_path(path, resolved_path, sizeof(resolved_path)) != ESP_OK) { cJSON_Delete(root); - if (err == ESP_ERR_INVALID_STATE) { - snprintf(output, output_size, "Error: Lua base dir is not configured"); - return err; - } - snprintf(output, output_size, "Error: path must be a relative .lua path under %s", s_lua_base_dir); - return err; + snprintf(output, output_size, "Error: path must be a .lua file under %s", s_lua_base_dir); + return ESP_ERR_INVALID_ARG; } if (!content) { cJSON_Delete(root); @@ -516,11 +360,6 @@ static esp_err_t cap_lua_write_script_execute(const char *input_json, const claw snprintf(output, output_size, "Error: failed to ensure Lua base dir"); return ESP_FAIL; } - if (cap_lua_ensure_parent_dirs(resolved_path) != ESP_OK) { - cJSON_Delete(root); - snprintf(output, output_size, "Error: failed to create parent directories for %s", resolved_path); - return ESP_FAIL; - } file = fopen(resolved_path, "w"); if (!file) { @@ -541,7 +380,9 @@ static esp_err_t cap_lua_write_script_execute(const char *input_json, const claw return ESP_OK; } -static esp_err_t cap_lua_run_script_execute(const char *input_json, const claw_cap_call_context_t *ctx, char *output, +static esp_err_t cap_lua_run_script_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, size_t output_size) { cJSON *root = NULL; @@ -559,15 +400,10 @@ static esp_err_t cap_lua_run_script_execute(const char *input_json, const claw_c } path = cJSON_GetStringValue(cJSON_GetObjectItem(root, "path")); - err = cap_lua_resolve_path(path, resolved_path, sizeof(resolved_path)); - if (err != ESP_OK) { + if (cap_lua_resolve_path(path, resolved_path, sizeof(resolved_path)) != ESP_OK) { cJSON_Delete(root); - if (err == ESP_ERR_INVALID_STATE) { - snprintf(output, output_size, "Error: Lua base dir is not configured"); - return err; - } - snprintf(output, output_size, "Error: path must be a relative .lua path under %s", s_lua_base_dir); - return err; + snprintf(output, output_size, "Error: path must be a .lua file under %s", s_lua_base_dir); + return ESP_ERR_INVALID_ARG; } timeout_item = cJSON_GetObjectItem(root, "timeout_ms"); @@ -588,23 +424,49 @@ static esp_err_t cap_lua_run_script_execute(const char *input_json, const claw_c return err; } - err = cap_lua_runtime_execute_file(resolved_path, args_json, timeout_ms, output, output_size); + if (timeout_ms == 0) { + timeout_ms = CAP_LUA_SYNC_DEFAULT_TIMEOUT_MS; + } + err = cap_lua_runtime_execute_file(resolved_path, + args_json, + timeout_ms, + NULL, + output, + output_size); free(args_json); return err; } -static esp_err_t cap_lua_run_script_async_execute(const char *input_json, const claw_cap_call_context_t *ctx, - char *output, size_t output_size) +static const char *cap_lua_basename_from_path(const char *path) +{ + const char *slash = NULL; + + if (!path) { + return NULL; + } + slash = strrchr(path, '/'); + return slash ? slash + 1 : path; +} + +static esp_err_t cap_lua_run_script_async_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) { cJSON *root = NULL; const char *path = NULL; + const char *name = NULL; + const char *exclusive = NULL; char resolved_path[192]; cJSON *timeout_item = NULL; + cJSON *replace_item = NULL; char *args_json = NULL; char request_path[192] = {0}; - uint32_t timeout_ms = 0; + uint32_t timeout_ms = CAP_LUA_ASYNC_DEFAULT_TIMEOUT_MS; cap_lua_async_job_t job = {0}; - char job_id[16] = {0}; + char job_id[CAP_LUA_JOB_ID_LEN] = {0}; + char err_buf[256] = {0}; + bool replace = false; esp_err_t err; root = cJSON_Parse(input_json); @@ -614,35 +476,61 @@ static esp_err_t cap_lua_run_script_async_execute(const char *input_json, const } path = cJSON_GetStringValue(cJSON_GetObjectItem(root, "path")); - err = cap_lua_resolve_path(path, resolved_path, sizeof(resolved_path)); - if (err != ESP_OK) { + if (cap_lua_resolve_path(path, resolved_path, sizeof(resolved_path)) != ESP_OK) { cJSON_Delete(root); - if (err == ESP_ERR_INVALID_STATE) { - snprintf(output, output_size, "Error: Lua base dir is not configured"); - return err; - } - snprintf(output, output_size, "Error: path must be a relative .lua path under %s", s_lua_base_dir); - return err; + snprintf(output, output_size, "Error: path must be a .lua file under %s", s_lua_base_dir); + return ESP_ERR_INVALID_ARG; } - if (path) { - size_t copied = strlcpy(request_path, path, sizeof(request_path)); - if (copied >= sizeof(request_path)) { - snprintf(output, output_size, "Error: path is too long"); + strlcpy(request_path, path ? path : resolved_path, sizeof(request_path)); + + /* Pre-check that the script actually exists before queuing. Without this + * the job is happily accepted, the async task starts, immediately fails + * to load, and the LLM has already reported success to the user. */ + { + struct stat script_stat; + if (stat(resolved_path, &script_stat) != 0) { cJSON_Delete(root); - return ESP_ERR_INVALID_SIZE; + snprintf(output, output_size, + "Error: script not found: %s (resolved=%s, errno=%d)", + request_path, resolved_path, errno); + return ESP_ERR_NOT_FOUND; } } timeout_item = cJSON_GetObjectItem(root, "timeout_ms"); - if (timeout_item && (!cJSON_IsNumber(timeout_item) || timeout_item->valueint <= 0)) { + if (timeout_item && (!cJSON_IsNumber(timeout_item) || timeout_item->valueint < 0)) { cJSON_Delete(root); - snprintf(output, output_size, "Error: timeout_ms must be a positive integer"); + snprintf(output, output_size, + "Error: timeout_ms must be a non-negative integer (0 = until cancelled)"); return ESP_ERR_INVALID_ARG; } if (cJSON_IsNumber(timeout_item)) { timeout_ms = (uint32_t)timeout_item->valueint; } + name = cJSON_GetStringValue(cJSON_GetObjectItem(root, "name")); + exclusive = cJSON_GetStringValue(cJSON_GetObjectItem(root, "exclusive")); + replace_item = cJSON_GetObjectItem(root, "replace"); + if (cJSON_IsBool(replace_item)) { + replace = cJSON_IsTrue(replace_item); + } + + /* Copy strings owned by `root` into the job struct BEFORE cJSON_Delete / + * cap_lua_build_args_json — both can recycle the freed string buffers and + * leave us holding garbage. */ + strlcpy(job.path, resolved_path, sizeof(job.path)); + if (name && name[0]) { + strlcpy(job.name, name, sizeof(job.name)); + } else { + const char *base = cap_lua_basename_from_path(resolved_path); + if (base) { + strlcpy(job.name, base, sizeof(job.name)); + } + } + if (exclusive && exclusive[0]) { + strlcpy(job.exclusive, exclusive, sizeof(job.exclusive)); + } + err = cap_lua_build_args_json(root, ctx, &args_json); cJSON_Delete(root); if (err != ESP_OK) { @@ -651,15 +539,15 @@ static esp_err_t cap_lua_run_script_async_execute(const char *input_json, const return err; } - strlcpy(job.path, resolved_path, sizeof(job.path)); job.args_json = args_json; job.timeout_ms = timeout_ms; + job.replace = replace; job.created_at = time(NULL); - err = cap_lua_async_submit(&job, job_id, sizeof(job_id)); + err = cap_lua_async_submit(&job, job_id, sizeof(job_id), err_buf, sizeof(err_buf)); free(args_json); if (err != ESP_OK) { - if (err == ESP_ERR_NO_MEM) { - snprintf(output, output_size, "Error: Lua async concurrency limit reached"); + if (err_buf[0]) { + snprintf(output, output_size, "Error: %s", err_buf); } else if (err == ESP_ERR_INVALID_STATE) { snprintf(output, output_size, "Error: Lua async runner is not ready"); } else { @@ -669,12 +557,115 @@ static esp_err_t cap_lua_run_script_async_execute(const char *input_json, const return err; } - snprintf(output, output_size, "Queued Lua job %s for %s", job_id, request_path); + /* Block briefly so the tool result reflects whether the job actually + * launched or already failed during script load. Wakes immediately on + * terminal transition; otherwise reports the live status (typically + * RUNNING) once the budget elapses. */ + cap_lua_job_status_t settle_status = CAP_LUA_JOB_RUNNING; + char settle_summary[128] = {0}; + cap_lua_async_wait_settle(job_id, 150, &settle_status, settle_summary, sizeof(settle_summary)); + + const char *status_label = cap_lua_job_status_name(settle_status); + + if (settle_status == CAP_LUA_JOB_FAILED || settle_status == CAP_LUA_JOB_TIMEOUT || + settle_status == CAP_LUA_JOB_STOPPED) { + snprintf(output, output_size, + "Lua job %s (name=%s) ended early with status=%s. summary: %s", + job_id, + job.name[0] ? job.name : "(unnamed)", + status_label, + settle_summary[0] ? settle_summary : "(none)"); + return ESP_OK; + } + + snprintf(output, output_size, + "Started Lua job %s (name=%s, exclusive=%s, timeout_ms=%u%s, status=%s) for %s", + job_id, + job.name[0] ? job.name : "(unnamed)", + job.exclusive[0] ? job.exclusive : "none", + (unsigned)timeout_ms, + timeout_ms == 0 ? " [until cancelled]" : "", + status_label, + request_path); return ESP_OK; } -static esp_err_t cap_lua_list_async_jobs_execute(const char *input_json, const claw_cap_call_context_t *ctx, - char *output, size_t output_size) +static esp_err_t cap_lua_stop_async_job_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + cJSON *root = NULL; + const char *target = NULL; + cJSON *wait_item = NULL; + uint32_t wait_ms = 0; + esp_err_t err; + + (void)ctx; + + root = cJSON_Parse(input_json); + if (!root) { + snprintf(output, output_size, "Error: invalid JSON input"); + return ESP_ERR_INVALID_ARG; + } + target = cJSON_GetStringValue(cJSON_GetObjectItem(root, "job_id")); + if (!target || !target[0]) { + target = cJSON_GetStringValue(cJSON_GetObjectItem(root, "name")); + } + if (!target || !target[0]) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: provide either 'job_id' or 'name'"); + return ESP_ERR_INVALID_ARG; + } + wait_item = cJSON_GetObjectItem(root, "wait_ms"); + if (cJSON_IsNumber(wait_item) && wait_item->valueint > 0) { + wait_ms = (uint32_t)wait_item->valueint; + } + + err = cap_lua_async_stop_job(target, wait_ms, output, output_size); + cJSON_Delete(root); + return err; +} + +static esp_err_t cap_lua_stop_all_async_jobs_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) +{ + cJSON *root = NULL; + const char *exclusive = NULL; + cJSON *wait_item = NULL; + uint32_t wait_ms = 0; + esp_err_t err; + + (void)ctx; + + /* Distinguish "no args -> stop everything" from "garbage JSON -> reject". + * The latter must be a hard error because this API has destructive side + * effects on every running async job, and silently ignoring a malformed + * payload would mass-stop jobs that the caller never intended to touch. */ + if (input_json && input_json[0]) { + root = cJSON_Parse(input_json); + if (!root) { + snprintf(output, output_size, "Error: invalid JSON"); + return ESP_ERR_INVALID_ARG; + } + exclusive = cJSON_GetStringValue(cJSON_GetObjectItem(root, "exclusive")); + wait_item = cJSON_GetObjectItem(root, "wait_ms"); + if (cJSON_IsNumber(wait_item) && wait_item->valueint > 0) { + wait_ms = (uint32_t)wait_item->valueint; + } + } + + err = cap_lua_async_stop_all_jobs(exclusive, wait_ms, output, output_size); + cJSON_Delete(root); + return err; +} + +static esp_err_t cap_lua_list_async_jobs_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, + size_t output_size) { cJSON *root = NULL; const char *status = NULL; @@ -685,10 +676,18 @@ static esp_err_t cap_lua_list_async_jobs_execute(const char *input_json, const c root = cJSON_Parse(input_json); if (root) { status = cJSON_GetStringValue(cJSON_GetObjectItem(root, "status")); - if (status && strcmp(status, "all") != 0 && strcmp(status, "queued") != 0 && strcmp(status, "running") != 0 && - strcmp(status, "done") != 0 && strcmp(status, "failed") != 0 && strcmp(status, "timeout") != 0) { + if (status && + strcmp(status, "all") != 0 && + strcmp(status, "queued") != 0 && + strcmp(status, "running") != 0 && + strcmp(status, "done") != 0 && + strcmp(status, "failed") != 0 && + strcmp(status, "timeout") != 0 && + strcmp(status, "stopped") != 0) { cJSON_Delete(root); - snprintf(output, output_size, "Error: status must be one of all, queued, running, done, failed, timeout"); + snprintf(output, + output_size, + "Error: status must be one of all, queued, running, done, failed, timeout, stopped"); return ESP_ERR_INVALID_ARG; } } @@ -698,7 +697,9 @@ static esp_err_t cap_lua_list_async_jobs_execute(const char *input_json, const c return err; } -static esp_err_t cap_lua_get_async_job_execute(const char *input_json, const claw_cap_call_context_t *ctx, char *output, +static esp_err_t cap_lua_get_async_job_execute(const char *input_json, + const claw_cap_call_context_t *ctx, + char *output, size_t output_size) { cJSON *root = NULL; @@ -714,9 +715,12 @@ static esp_err_t cap_lua_get_async_job_execute(const char *input_json, const cla } job_id = cJSON_GetStringValue(cJSON_GetObjectItem(root, "job_id")); + if (!job_id || !job_id[0]) { + job_id = cJSON_GetStringValue(cJSON_GetObjectItem(root, "name")); + } if (!job_id || !job_id[0]) { cJSON_Delete(root); - snprintf(output, output_size, "Error: missing job_id"); + snprintf(output, output_size, "Error: provide either 'job_id' or 'name'"); return ESP_ERR_INVALID_ARG; } @@ -744,8 +748,8 @@ static const claw_cap_descriptor_t s_lua_descriptors[] = { .description = "Write a managed Lua script under the configured Lua base directory.", .kind = CLAW_CAP_KIND_CALLABLE, .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, - .input_schema_json = "{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"},\"content\":{\"type\":\"string\"}," - "\"overwrite\":{\"type\":\"boolean\"}},\"required\":[\"path\",\"content\"]}", + .input_schema_json = + "{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"},\"content\":{\"type\":\"string\"},\"overwrite\":{\"type\":\"boolean\"}},\"required\":[\"path\",\"content\"]}", .execute = cap_lua_write_script_execute, }, { @@ -755,21 +759,23 @@ static const claw_cap_descriptor_t s_lua_descriptors[] = { .description = "Run a managed Lua script synchronously with optional args and timeout.", .kind = CLAW_CAP_KIND_CALLABLE, .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, - .input_schema_json = "{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"},\"args\":{\"anyOf\":[" - "{\"type\":\"object\",\"properties\":{}},{\"type\":\"array\",\"items\":{}}]}," - "\"timeout_ms\":{\"type\":\"integer\"}},\"required\":[\"path\"]}", + .input_schema_json = + "{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"},\"args\":{\"anyOf\":[{\"type\":\"object\",\"properties\":{}},{\"type\":\"array\",\"items\":{}}]},\"timeout_ms\":{\"type\":\"integer\"}},\"required\":[\"path\"]}", .execute = cap_lua_run_script_execute, }, { .id = "lua_run_script_async", .name = "lua_run_script_async", .family = "automation", - .description = "Run a managed Lua script asynchronously and return a job identifier.", + .description = + "Run a managed Lua script asynchronously, returns a job id. timeout_ms=0 " + "means run until cancelled (default). Use 'name' to label, 'exclusive' " + "for mutex groups (e.g. 'display'), 'replace':true to take over a " + "conflicting slot.", .kind = CLAW_CAP_KIND_CALLABLE, .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, - .input_schema_json = "{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"},\"args\":{\"anyOf\":[" - "{\"type\":\"object\",\"properties\":{}},{\"type\":\"array\",\"items\":{}}]}," - "\"timeout_ms\":{\"type\":\"integer\"}},\"required\":[\"path\"]}", + .input_schema_json = + "{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"},\"args\":{\"anyOf\":[{\"type\":\"object\",\"properties\":{}},{\"type\":\"array\",\"items\":{}}]},\"timeout_ms\":{\"type\":\"integer\",\"minimum\":0},\"name\":{\"type\":\"string\"},\"exclusive\":{\"type\":\"string\"},\"replace\":{\"type\":\"boolean\"}},\"required\":[\"path\"]}", .execute = cap_lua_run_script_async_execute, }, { @@ -780,20 +786,48 @@ static const claw_cap_descriptor_t s_lua_descriptors[] = { .kind = CLAW_CAP_KIND_CALLABLE, .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, .input_schema_json = - "{\"type\":\"object\",\"properties\":{\"status\":{\"type\":\"string\"}}}", + "{\"type\":\"object\",\"properties\":{\"status\":{\"type\":\"string\",\"enum\":[\"all\",\"queued\",\"running\",\"done\",\"failed\",\"timeout\",\"stopped\"]}}}", .execute = cap_lua_list_async_jobs_execute, }, { .id = "lua_get_async_job", .name = "lua_get_async_job", .family = "automation", - .description = "Get the status and summary for a specific Lua async job.", + .description = "Get the status and summary for a Lua async job by job_id or name.", .kind = CLAW_CAP_KIND_CALLABLE, .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, .input_schema_json = - "{\"type\":\"object\",\"properties\":{\"job_id\":{\"type\":\"string\"}},\"required\":[\"job_id\"]}", + "{\"type\":\"object\",\"properties\":{\"job_id\":{\"type\":\"string\"},\"name\":{\"type\":\"string\"}}}", .execute = cap_lua_get_async_job_execute, }, + { + .id = "lua_stop_async_job", + .name = "lua_stop_async_job", + .family = "automation", + .description = + "Stop a running Lua async job by job_id or name. MUST be called whenever the user asks " + "to stop, cancel, quit or close an async script; replying without calling this leaves " + "the job running. Cooperative; default wait 2000 ms.", + .kind = CLAW_CAP_KIND_CALLABLE, + .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, + .input_schema_json = + "{\"type\":\"object\",\"properties\":{\"job_id\":{\"type\":\"string\"},\"name\":{\"type\":\"string\"},\"wait_ms\":{\"type\":\"integer\",\"minimum\":1}}}", + .execute = cap_lua_stop_async_job_execute, + }, + { + .id = "lua_stop_all_async_jobs", + .name = "lua_stop_all_async_jobs", + .family = "automation", + .description = + "Stop all running Lua async jobs, optionally filtered by exclusive group " + "(e.g. exclusive='display'). MUST be called when the user asks to clear the screen, " + "stop everything or cancel all background scripts.", + .kind = CLAW_CAP_KIND_CALLABLE, + .cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM, + .input_schema_json = + "{\"type\":\"object\",\"properties\":{\"exclusive\":{\"type\":\"string\"},\"wait_ms\":{\"type\":\"integer\",\"minimum\":1}}}", + .execute = cap_lua_stop_all_async_jobs_execute, + }, }; static const claw_cap_group_t s_lua_group = { @@ -818,17 +852,34 @@ esp_err_t cap_lua_list_scripts(const char *prefix, char *output, size_t output_s char *input_json = NULL; esp_err_t err; - err = cap_lua_build_simple_request("prefix", prefix, NULL, NULL, false, NULL, false, false, NULL, 0, &input_json); + err = cap_lua_build_simple_request("prefix", + prefix, + NULL, + NULL, + false, + NULL, + false, + false, + NULL, + 0, + &input_json); if (err != ESP_OK) { return err; } - err = cap_lua_list_scripts_execute(input_json ? input_json : "{}", NULL, output, output_size); + err = cap_lua_list_scripts_execute(input_json ? input_json : "{}", + NULL, + output, + output_size); free(input_json); return err; } -esp_err_t cap_lua_write_script(const char *path, const char *content, bool overwrite, char *output, size_t output_size) +esp_err_t cap_lua_write_script(const char *path, + const char *content, + bool overwrite, + char *output, + size_t output_size) { cJSON *root = NULL; char *input_json = NULL; @@ -842,7 +893,8 @@ esp_err_t cap_lua_write_script(const char *path, const char *content, bool overw if (!root) { return ESP_ERR_NO_MEM; } - if (!cJSON_AddStringToObject(root, "path", path) || !cJSON_AddStringToObject(root, "content", content) || + if (!cJSON_AddStringToObject(root, "path", path) || + !cJSON_AddStringToObject(root, "content", content) || !cJSON_AddBoolToObject(root, "overwrite", overwrite)) { cJSON_Delete(root); return ESP_ERR_NO_MEM; @@ -859,7 +911,10 @@ esp_err_t cap_lua_write_script(const char *path, const char *content, bool overw return err; } -esp_err_t cap_lua_run_script(const char *path, const char *args_json, uint32_t timeout_ms, char *output, +esp_err_t cap_lua_run_script(const char *path, + const char *args_json, + uint32_t timeout_ms, + char *output, size_t output_size) { cJSON *root = NULL; @@ -907,6 +962,9 @@ esp_err_t cap_lua_run_script(const char *path, const char *args_json, uint32_t t esp_err_t cap_lua_run_script_async(const char *path, const char *args_json, uint32_t timeout_ms, + const char *name, + const char *exclusive, + bool replace, char *output, size_t output_size) { @@ -936,7 +994,19 @@ esp_err_t cap_lua_run_script_async(const char *path, } cJSON_AddItemToObject(root, "args", args); } - if (timeout_ms > 0 && !cJSON_AddNumberToObject(root, "timeout_ms", (double)timeout_ms)) { + if (!cJSON_AddNumberToObject(root, "timeout_ms", (double)timeout_ms)) { + cJSON_Delete(root); + return ESP_ERR_NO_MEM; + } + if (name && name[0] && !cJSON_AddStringToObject(root, "name", name)) { + cJSON_Delete(root); + return ESP_ERR_NO_MEM; + } + if (exclusive && exclusive[0] && !cJSON_AddStringToObject(root, "exclusive", exclusive)) { + cJSON_Delete(root); + return ESP_ERR_NO_MEM; + } + if (replace && !cJSON_AddBoolToObject(root, "replace", true)) { cJSON_Delete(root); return ESP_ERR_NO_MEM; } @@ -952,6 +1022,171 @@ esp_err_t cap_lua_run_script_async(const char *path, return err; } +esp_err_t cap_lua_stop_job(const char *id_or_name, + uint32_t wait_ms, + char *output, + size_t output_size) +{ + return cap_lua_async_stop_job(id_or_name, wait_ms, output, output_size); +} + +esp_err_t cap_lua_stop_all_jobs(const char *exclusive_filter, + uint32_t wait_ms, + char *output, + size_t output_size) +{ + return cap_lua_async_stop_all_jobs(exclusive_filter, wait_ms, output, output_size); +} + +static esp_err_t cap_lua_async_jobs_collect(const claw_core_request_t *request, + claw_core_context_t *out_context, + void *user_ctx) +{ + cap_lua_async_job_snapshot_t snapshots[CAP_LUA_ASYNC_MAX_CONCURRENT]; + size_t count; + char *content = NULL; + size_t cap = 1024; + size_t off = 0; + time_t now; + + (void)request; + (void)user_ctx; + + if (!out_context) { + return ESP_ERR_INVALID_ARG; + } + + count = cap_lua_async_collect_active_snapshots(snapshots, + sizeof(snapshots) / sizeof(snapshots[0])); + if (count == 0) { + return ESP_ERR_NOT_FOUND; + } + + content = calloc(1, cap); + if (!content) { + return ESP_ERR_NO_MEM; + } + + now = time(NULL); + off += snprintf(content + off, cap - off, + "Active Lua async jobs (%u, max %u):\n", + (unsigned)count, + (unsigned)CAP_LUA_ASYNC_MAX_CONCURRENT); + for (size_t i = 0; i < count && off < cap - 1; i++) { + long runtime_s = (long)(now - (snapshots[i].started_at ? snapshots[i].started_at + : snapshots[i].created_at)); + if (runtime_s < 0) { + runtime_s = 0; + } + const char *status_name = NULL; + switch (snapshots[i].status) { + case CAP_LUA_JOB_QUEUED: status_name = "queued"; break; + case CAP_LUA_JOB_RUNNING: status_name = "running"; break; + default: status_name = "active"; break; + } + int written = snprintf(content + off, cap - off, + "- id=%s name=%s exclusive=%s status=%s runtime=%lds path=%s\n", + snapshots[i].job_id, + snapshots[i].name[0] ? snapshots[i].name : "(unnamed)", + snapshots[i].exclusive[0] ? snapshots[i].exclusive : "none", + status_name, + runtime_s, + snapshots[i].path); + if (written < 0 || (size_t)written >= cap - off) { + break; + } + off += (size_t)written; + } + + if (off < cap - 1) { + off += snprintf(content + off, cap - off, + "Listing only; jobs keep running until you call lua_stop_async_job, " + "lua_stop_all_async_jobs, or lua_run_script_async with replace:true. " + "Never claim a job is stopped/switched without calling one of these.\n"); + } + + memset(out_context, 0, sizeof(*out_context)); + out_context->kind = CLAW_CORE_CONTEXT_KIND_SYSTEM_PROMPT; + out_context->content = content; + return ESP_OK; +} + +const claw_core_context_provider_t cap_lua_async_jobs_provider = { + .name = "Lua Async Jobs", + .collect = cap_lua_async_jobs_collect, + .user_ctx = NULL, +}; + +/* Case-insensitive substring search (the LLM's casing is not stable). */ +static bool cap_lua_text_contains_ci(const char *haystack, const char *needle) +{ + if (!haystack || !needle || !needle[0]) { + return false; + } + size_t hlen = strlen(haystack); + size_t nlen = strlen(needle); + if (nlen > hlen) { + return false; + } + for (size_t i = 0; i + nlen <= hlen; i++) { + if (strncasecmp(haystack + i, needle, nlen) == 0) { + return true; + } + } + return false; +} + +void cap_lua_honesty_observe_completion(const claw_core_completion_summary_t *summary, + void *user_ctx) +{ + (void)user_ctx; + if (!summary || !summary->final_text || !summary->final_text[0]) { + return; + } + /* Honesty check is meaningful only when the model could see active jobs. */ + const char *providers = summary->context_providers_csv ? summary->context_providers_csv : ""; + if (!strstr(providers, "Lua Async Jobs")) { + return; + } + /* If a stop / replace tool was actually invoked this turn, the reply is + * truthful regardless of phrasing. */ + const char *tools = summary->tool_calls_csv ? summary->tool_calls_csv : ""; + if (strstr(tools, "lua_stop_async_job") || + strstr(tools, "lua_stop_all_async_jobs") || + strstr(tools, "lua_run_script_async")) { + return; + } + /* Pattern-match the assistant's reply against typical "I stopped it" + * phrases in both English and Chinese. Keep this list small and explicit + * to avoid false positives like "if you want to cancel, tell me". */ + static const char *const claim_keywords[] = { + "已取消", "已停止", "已关闭", "已清除", "取消了", "停止了", "关掉了", "关闭了", + "stopped", "cancelled", "canceled", "cleared", + }; + bool claims_stop = false; + for (size_t i = 0; i < sizeof(claim_keywords) / sizeof(claim_keywords[0]); i++) { + if (cap_lua_text_contains_ci(summary->final_text, claim_keywords[i])) { + claims_stop = true; + break; + } + } + if (!claims_stop) { + return; + } + /* Truncate the reply so the warning stays readable in the log. */ + char snippet[96] = {0}; + strlcpy(snippet, summary->final_text, sizeof(snippet)); + ESP_LOGW(TAG, + "honesty: request=%" PRIu32 + " reply claims stop/cancel but no lua_stop_* tool was called this turn" + " (providers=[%s] tools=[%s] reply=%.80s%s)", + summary->request_id, + providers, + tools[0] ? tools : "(none)", + snippet, + strlen(summary->final_text) > sizeof(snippet) - 1 ? "..." : ""); +} + esp_err_t cap_lua_list_jobs(const char *status, char *output, size_t output_size) { char *input_json = NULL; @@ -1010,28 +1245,11 @@ esp_err_t cap_lua_get_job(const char *job_id, char *output, size_t output_size) esp_err_t cap_lua_set_base_dir(const char *base_dir) { - size_t copied; - size_t base_len; - if (!base_dir || !base_dir[0]) { return ESP_ERR_INVALID_ARG; } - if (base_dir[0] != '/' || strstr(base_dir, "..") != NULL) { - ESP_LOGE(TAG, "Lua base dir must be an absolute safe path: %s", base_dir); - return ESP_ERR_INVALID_ARG; - } - - copied = strlcpy(s_lua_base_dir, base_dir, sizeof(s_lua_base_dir)); - if (copied >= sizeof(s_lua_base_dir)) { - ESP_LOGE(TAG, "Lua base dir is too long: %s", base_dir); - return ESP_ERR_INVALID_ARG; - } - base_len = strlen(s_lua_base_dir); - while (base_len > 1 && s_lua_base_dir[base_len - 1] == '/') { - s_lua_base_dir[base_len - 1] = '\0'; - base_len--; - } + strlcpy(s_lua_base_dir, base_dir, sizeof(s_lua_base_dir)); return ESP_OK; } diff --git a/components/claw_capabilities/cap_lua/src/cap_lua_async.c b/components/claw_capabilities/cap_lua/src/cap_lua_async.c index 926984d..3a12064 100644 --- a/components/claw_capabilities/cap_lua/src/cap_lua_async.c +++ b/components/claw_capabilities/cap_lua/src/cap_lua_async.c @@ -23,8 +23,10 @@ static const char *TAG = "cap_lua_async"; typedef struct { bool used; cap_lua_job_status_t status; - char job_id[9]; - char path[192]; + char job_id[CAP_LUA_JOB_ID_LEN]; + char name[CAP_LUA_JOB_NAME_MAX]; + char exclusive[CAP_LUA_JOB_EXCLUSIVE_MAX]; + char path[CAP_LUA_JOB_PATH_MAX]; char *args_json; char *summary; uint32_t timeout_ms; @@ -32,18 +34,26 @@ typedef struct { time_t started_at; time_t finished_at; TaskHandle_t task_handle; + /* Cooperative cancellation flag, polled from the Lua hook. */ + volatile bool stop_requested; } cap_lua_job_record_t; typedef struct { int slot; - char job_id[9]; - char path[192]; + char job_id[CAP_LUA_JOB_ID_LEN]; + char path[CAP_LUA_JOB_PATH_MAX]; char *args_json; uint32_t timeout_ms; + volatile bool *stop_requested; } cap_lua_job_ctx_t; static SemaphoreHandle_t s_job_lock; static cap_lua_job_record_t s_jobs[CAP_LUA_ASYNC_MAX_JOBS]; +/* Per-slot binary semaphore signalled when the slot's job reaches a terminal + * state. Lives outside the record struct because the struct is memset on slot + * recycle; the sem itself is created once at init and reused for every job + * that lands in this slot, drained before each new submit. */ +static SemaphoreHandle_t s_slot_terminal_sem[CAP_LUA_ASYNC_MAX_JOBS]; static size_t s_running_jobs; static bool s_runner_started; @@ -53,30 +63,32 @@ static UBaseType_t cap_lua_task_memory_caps(void) return MALLOC_CAP_INTERNAL; } -static const char *cap_lua_job_status_name(cap_lua_job_status_t status) +const char *cap_lua_job_status_name(cap_lua_job_status_t status) { switch (status) { - case CAP_LUA_JOB_QUEUED: - return "queued"; - case CAP_LUA_JOB_RUNNING: - return "running"; - case CAP_LUA_JOB_DONE: - return "done"; - case CAP_LUA_JOB_FAILED: - return "failed"; - case CAP_LUA_JOB_TIMEOUT: - return "timeout"; - default: - return "unknown"; + case CAP_LUA_JOB_QUEUED: return "queued"; + case CAP_LUA_JOB_RUNNING: return "running"; + case CAP_LUA_JOB_DONE: return "done"; + case CAP_LUA_JOB_FAILED: return "failed"; + case CAP_LUA_JOB_TIMEOUT: return "timeout"; + case CAP_LUA_JOB_STOPPED: return "stopped"; + default: return "unknown"; } } +static bool cap_lua_status_is_terminal(cap_lua_job_status_t status) +{ + return status == CAP_LUA_JOB_DONE || + status == CAP_LUA_JOB_FAILED || + status == CAP_LUA_JOB_TIMEOUT || + status == CAP_LUA_JOB_STOPPED; +} + static bool cap_lua_job_status_matches(cap_lua_job_status_t status, const char *filter) { if (!filter || !filter[0] || strcmp(filter, "all") == 0) { return true; } - return strcmp(cap_lua_job_status_name(status), filter) == 0; } @@ -88,16 +100,14 @@ static void cap_lua_generate_job_id(char *job_id, size_t size) static int cap_lua_find_reusable_slot_locked(void) { int oldest_terminal = -1; - int i; - for (i = 0; i < CAP_LUA_ASYNC_MAX_JOBS; i++) { + for (int i = 0; i < CAP_LUA_ASYNC_MAX_JOBS; i++) { if (!s_jobs[i].used) { return i; } - if (s_jobs[i].status == CAP_LUA_JOB_DONE || - s_jobs[i].status == CAP_LUA_JOB_FAILED || - s_jobs[i].status == CAP_LUA_JOB_TIMEOUT) { - if (oldest_terminal < 0 || s_jobs[i].finished_at < s_jobs[oldest_terminal].finished_at) { + if (cap_lua_status_is_terminal(s_jobs[i].status)) { + if (oldest_terminal < 0 || + s_jobs[i].finished_at < s_jobs[oldest_terminal].finished_at) { oldest_terminal = i; } } @@ -106,16 +116,58 @@ static int cap_lua_find_reusable_slot_locked(void) return oldest_terminal; } -static int cap_lua_find_slot_by_id_locked(const char *job_id) +static int cap_lua_find_slot_by_id_or_name_locked(const char *needle) { - int i; + /* Prefer exact id match; fall back to active-job name match. */ + if (!needle || !needle[0]) { + return -1; + } - for (i = 0; i < CAP_LUA_ASYNC_MAX_JOBS; i++) { - if (s_jobs[i].used && strcmp(s_jobs[i].job_id, job_id) == 0) { + for (int i = 0; i < CAP_LUA_ASYNC_MAX_JOBS; i++) { + if (s_jobs[i].used && strcmp(s_jobs[i].job_id, needle) == 0) { return i; } } + for (int i = 0; i < CAP_LUA_ASYNC_MAX_JOBS; i++) { + if (s_jobs[i].used && + !cap_lua_status_is_terminal(s_jobs[i].status) && + s_jobs[i].name[0] && + strcmp(s_jobs[i].name, needle) == 0) { + return i; + } + } + return -1; +} +static int cap_lua_find_active_by_name_locked(const char *name) +{ + if (!name || !name[0]) { + return -1; + } + for (int i = 0; i < CAP_LUA_ASYNC_MAX_JOBS; i++) { + if (s_jobs[i].used && + !cap_lua_status_is_terminal(s_jobs[i].status) && + s_jobs[i].name[0] && + strcmp(s_jobs[i].name, name) == 0) { + return i; + } + } + return -1; +} + +static int cap_lua_find_active_by_exclusive_locked(const char *exclusive) +{ + if (!exclusive || !exclusive[0]) { + return -1; + } + for (int i = 0; i < CAP_LUA_ASYNC_MAX_JOBS; i++) { + if (s_jobs[i].used && + !cap_lua_status_is_terminal(s_jobs[i].status) && + s_jobs[i].exclusive[0] && + strcmp(s_jobs[i].exclusive, exclusive) == 0) { + return i; + } + } return -1; } @@ -131,9 +183,13 @@ static void cap_lua_clear_slot(cap_lua_job_record_t *job) static void cap_lua_finish_job(cap_lua_job_ctx_t *ctx, bool ok, - bool timed_out, + const char *error_msg, const char *summary) { + bool stopped = false; + bool timed_out = false; + bool became_terminal = false; + if (xSemaphoreTake(s_job_lock, pdMS_TO_TICKS(1000)) != pdTRUE) { return; } @@ -142,14 +198,26 @@ static void cap_lua_finish_job(cap_lua_job_ctx_t *ctx, ctx->slot < CAP_LUA_ASYNC_MAX_JOBS && s_jobs[ctx->slot].used && strcmp(s_jobs[ctx->slot].job_id, ctx->job_id) == 0) { - s_jobs[ctx->slot].status = timed_out ? CAP_LUA_JOB_TIMEOUT : - (ok ? CAP_LUA_JOB_DONE : CAP_LUA_JOB_FAILED); + /* Order matters: stop request takes precedence over timeout, so a + * race between the two is reported as STOPPED. */ + stopped = s_jobs[ctx->slot].stop_requested; + if (!stopped && error_msg && strstr(error_msg, "execution timed out") != NULL) { + timed_out = true; + } + if (stopped) { + s_jobs[ctx->slot].status = CAP_LUA_JOB_STOPPED; + } else if (timed_out) { + s_jobs[ctx->slot].status = CAP_LUA_JOB_TIMEOUT; + } else { + s_jobs[ctx->slot].status = ok ? CAP_LUA_JOB_DONE : CAP_LUA_JOB_FAILED; + } s_jobs[ctx->slot].finished_at = time(NULL); s_jobs[ctx->slot].task_handle = NULL; if (summary && summary[0]) { free(s_jobs[ctx->slot].summary); s_jobs[ctx->slot].summary = strdup(summary); } + became_terminal = true; } if (s_running_jobs > 0) { @@ -157,6 +225,10 @@ static void cap_lua_finish_job(cap_lua_job_ctx_t *ctx, } xSemaphoreGive(s_job_lock); + + if (became_terminal && s_slot_terminal_sem[ctx->slot]) { + xSemaphoreGive(s_slot_terminal_sem[ctx->slot]); + } } static void cap_lua_job_task(void *arg) @@ -164,7 +236,6 @@ static void cap_lua_job_task(void *arg) cap_lua_job_ctx_t *ctx = (cap_lua_job_ctx_t *)arg; char *output = NULL; esp_err_t err; - bool timed_out = false; if (!ctx) { vTaskDeleteWithCaps(NULL); @@ -173,7 +244,7 @@ static void cap_lua_job_task(void *arg) output = calloc(1, CAP_LUA_OUTPUT_SIZE); if (!output) { - cap_lua_finish_job(ctx, false, false, "failed to allocate output buffer"); + cap_lua_finish_job(ctx, false, NULL, "failed to allocate output buffer"); free(ctx->args_json); free(ctx); vTaskDeleteWithCaps(NULL); @@ -183,13 +254,10 @@ static void cap_lua_job_task(void *arg) err = cap_lua_runtime_execute_file(ctx->path, ctx->args_json, ctx->timeout_ms, + ctx->stop_requested, output, CAP_LUA_OUTPUT_SIZE); - if (err != ESP_OK && strstr(output, "execution timed out") != NULL) { - timed_out = true; - } - - cap_lua_finish_job(ctx, err == ESP_OK, timed_out, output); + cap_lua_finish_job(ctx, err == ESP_OK, output, output); free(output); free(ctx->args_json); free(ctx); @@ -198,8 +266,6 @@ static void cap_lua_job_task(void *arg) esp_err_t cap_lua_async_init(void) { - int i; - if (!s_job_lock) { s_job_lock = xSemaphoreCreateMutex(); } @@ -207,8 +273,14 @@ esp_err_t cap_lua_async_init(void) return ESP_ERR_NO_MEM; } - for (i = 0; i < CAP_LUA_ASYNC_MAX_JOBS; i++) { + for (int i = 0; i < CAP_LUA_ASYNC_MAX_JOBS; i++) { cap_lua_clear_slot(&s_jobs[i]); + if (!s_slot_terminal_sem[i]) { + s_slot_terminal_sem[i] = xSemaphoreCreateBinary(); + if (!s_slot_terminal_sem[i]) { + return ESP_ERR_NO_MEM; + } + } } memset(s_jobs, 0, sizeof(s_jobs)); s_running_jobs = 0; @@ -221,21 +293,151 @@ esp_err_t cap_lua_async_start(void) if (!s_job_lock) { return ESP_ERR_INVALID_STATE; } - s_runner_started = true; return ESP_OK; } +static void cap_lua_format_active_jobs_locked(char *out, size_t size) +{ + size_t off = 0; + int shown = 0; + + if (!out || size == 0) { + return; + } + out[0] = '\0'; + + for (int i = 0; i < CAP_LUA_ASYNC_MAX_JOBS && off < size - 1; i++) { + if (!s_jobs[i].used || cap_lua_status_is_terminal(s_jobs[i].status)) { + continue; + } + int written = snprintf(out + off, size - off, + "%s%s(id=%s,exclusive=%s)", + shown == 0 ? "" : ", ", + s_jobs[i].name[0] ? s_jobs[i].name : "(unnamed)", + s_jobs[i].job_id, + s_jobs[i].exclusive[0] ? s_jobs[i].exclusive : "none"); + if (written < 0 || (size_t)written >= size - off) { + break; + } + off += (size_t)written; + shown++; + } + if (shown == 0) { + snprintf(out, size, "(none)"); + } +} + +/* Wait for a slot to leave RUNNING/QUEUED. Returns true if terminal in time. */ +static bool cap_lua_slot_is_terminal(int slot) +{ + bool terminal = false; + if (xSemaphoreTake(s_job_lock, pdMS_TO_TICKS(1000)) != pdTRUE) { + return false; + } + if (slot >= 0 && slot < CAP_LUA_ASYNC_MAX_JOBS && + (!s_jobs[slot].used || cap_lua_status_is_terminal(s_jobs[slot].status))) { + terminal = true; + } + xSemaphoreGive(s_job_lock); + return terminal; +} + +static bool cap_lua_wait_for_terminal(int slot, uint32_t wait_ms) +{ + if (slot < 0 || slot >= CAP_LUA_ASYNC_MAX_JOBS) { + return false; + } + if (cap_lua_slot_is_terminal(slot)) { + return true; + } + SemaphoreHandle_t sem = s_slot_terminal_sem[slot]; + if (!sem) { + return false; + } + /* The sem give happens after finish_job releases s_job_lock, so a fast + * concurrent submit can drain the slot's status back to QUEUED before + * the give lands. Always verify against the live status; the sem only + * shortens the wait. */ + xSemaphoreTake(sem, pdMS_TO_TICKS(wait_ms)); + return cap_lua_slot_is_terminal(slot); +} + +/* Mark slot stopped under lock, then wait outside lock for terminal status. */ +static esp_err_t cap_lua_stop_slot_and_wait(int slot, uint32_t wait_ms, bool *out_was_running) +{ + bool was_running = false; + + if (xSemaphoreTake(s_job_lock, pdMS_TO_TICKS(1000)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + if (slot < 0 || slot >= CAP_LUA_ASYNC_MAX_JOBS || !s_jobs[slot].used) { + xSemaphoreGive(s_job_lock); + return ESP_ERR_NOT_FOUND; + } + if (!cap_lua_status_is_terminal(s_jobs[slot].status)) { + s_jobs[slot].stop_requested = true; + was_running = true; + ESP_LOGI(TAG, "Stop requested for job %s (name=%s)", + s_jobs[slot].job_id, + s_jobs[slot].name[0] ? s_jobs[slot].name : "(unnamed)"); + } + xSemaphoreGive(s_job_lock); + + if (out_was_running) { + *out_was_running = was_running; + } + if (!was_running) { + return ESP_OK; + } + + bool terminal = cap_lua_wait_for_terminal(slot, wait_ms); + if (!terminal) { + ESP_LOGW(TAG, "Stop wait timed out for slot %d after %u ms; job may still unwind", + slot, (unsigned)wait_ms); + return ESP_ERR_TIMEOUT; + } + return ESP_OK; +} + +/* Internal worker holding the actual submit logic. cap_lua_async_submit is a + * thin wrapper that calls this twice when the post-lock recheck loses a race + * against another submitter, so the caller never sees a transient EBUSY. */ +static esp_err_t cap_lua_async_submit_once(const cap_lua_async_job_t *job, + char *job_id_out, + size_t job_id_out_size, + char *err_out, + size_t err_out_size, + bool *out_recheck_lost_race); + esp_err_t cap_lua_async_submit(const cap_lua_async_job_t *job, char *job_id_out, - size_t job_id_out_size) + size_t job_id_out_size, + char *err_out, + size_t err_out_size) { - cap_lua_job_ctx_t *ctx = NULL; - char submitted_job_id[sizeof(ctx->job_id)] = {0}; - char submitted_path[sizeof(ctx->path)] = {0}; - int slot = -1; - time_t now = time(NULL); + bool retry = false; + esp_err_t err = cap_lua_async_submit_once(job, job_id_out, job_id_out_size, + err_out, err_out_size, &retry); + if (err == ESP_ERR_INVALID_STATE && retry) { + ESP_LOGI(TAG, "Recheck race lost; retrying submit once"); + vTaskDelay(pdMS_TO_TICKS(20)); + err = cap_lua_async_submit_once(job, job_id_out, job_id_out_size, + err_out, err_out_size, NULL); + } + return err; +} +static esp_err_t cap_lua_async_submit_once(const cap_lua_async_job_t *job, + char *job_id_out, + size_t job_id_out_size, + char *err_out, + size_t err_out_size, + bool *out_recheck_lost_race) +{ + if (out_recheck_lost_race) { + *out_recheck_lost_race = false; + } if (!job || !job->path[0]) { return ESP_ERR_INVALID_ARG; } @@ -243,15 +445,110 @@ esp_err_t cap_lua_async_submit(const cap_lua_async_job_t *job, return ESP_ERR_INVALID_STATE; } - ctx = calloc(1, sizeof(*ctx)); + /* --- Pre-flight: detect conflicts; honor replace=true by stopping them --- */ + while (true) { + int conflict_slot = -1; + char conflict_reason[64] = {0}; + char conflict_id[CAP_LUA_JOB_ID_LEN] = {0}; + char conflict_name[CAP_LUA_JOB_NAME_MAX] = {0}; + char active_dump[256] = {0}; + bool over_concurrency = false; + + if (xSemaphoreTake(s_job_lock, pdMS_TO_TICKS(1000)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + + if (job->name[0]) { + int s = cap_lua_find_active_by_name_locked(job->name); + if (s >= 0) { + conflict_slot = s; + snprintf(conflict_reason, sizeof(conflict_reason), "name '%s'", job->name); + } + } + if (conflict_slot < 0 && job->exclusive[0]) { + int s = cap_lua_find_active_by_exclusive_locked(job->exclusive); + if (s >= 0) { + conflict_slot = s; + snprintf(conflict_reason, sizeof(conflict_reason), + "exclusive group '%s'", job->exclusive); + } + } + if (conflict_slot >= 0) { + strlcpy(conflict_id, s_jobs[conflict_slot].job_id, sizeof(conflict_id)); + strlcpy(conflict_name, s_jobs[conflict_slot].name, sizeof(conflict_name)); + } else if (s_running_jobs >= CAP_LUA_ASYNC_MAX_CONCURRENT) { + over_concurrency = true; + cap_lua_format_active_jobs_locked(active_dump, sizeof(active_dump)); + } + + xSemaphoreGive(s_job_lock); + + if (conflict_slot < 0 && !over_concurrency) { + break; /* clear to submit */ + } + + if (over_concurrency) { + if (err_out && err_out_size > 0) { + snprintf(err_out, err_out_size, + "Concurrency limit reached (%u/%u). Running: %s. " + "Stop one with lua_stop_async_job before retrying.", + (unsigned)CAP_LUA_ASYNC_MAX_CONCURRENT, + (unsigned)CAP_LUA_ASYNC_MAX_CONCURRENT, + active_dump); + } + return ESP_ERR_NO_MEM; + } + + if (!job->replace) { + if (err_out && err_out_size > 0) { + snprintf(err_out, err_out_size, + "Conflict with %s held by job '%s' (id=%s). " + "Pass replace=true to take over, or stop it first.", + conflict_reason, + conflict_name[0] ? conflict_name : "(unnamed)", + conflict_id); + } + return ESP_ERR_INVALID_STATE; + } + + /* replace=true: stop conflicting slot and wait, then re-check loop */ + ESP_LOGI(TAG, "Replacing conflicting job '%s' (id=%s) due to %s", + conflict_name[0] ? conflict_name : "(unnamed)", + conflict_id, conflict_reason); + bool was_running = false; + esp_err_t stop_err = cap_lua_stop_slot_and_wait(conflict_slot, + CAP_LUA_STOP_WAIT_DEFAULT_MS, + &was_running); + if (stop_err == ESP_ERR_TIMEOUT) { + if (err_out && err_out_size > 0) { + snprintf(err_out, err_out_size, + "Conflicting job '%s' (id=%s) did not stop within %u ms; " + "try again or stop it manually.", + conflict_name[0] ? conflict_name : "(unnamed)", + conflict_id, + (unsigned)CAP_LUA_STOP_WAIT_DEFAULT_MS); + } + return ESP_ERR_TIMEOUT; + } + if (stop_err != ESP_OK && stop_err != ESP_ERR_NOT_FOUND) { + if (err_out && err_out_size > 0) { + snprintf(err_out, err_out_size, + "Failed to stop conflicting job '%s': %s", + conflict_name[0] ? conflict_name : "(unnamed)", + esp_err_to_name(stop_err)); + } + return stop_err; + } + /* loop again to re-detect; another conflict might exist (e.g. name AND exclusive) */ + } + + /* --- Allocate ctx and slot --- */ + cap_lua_job_ctx_t *ctx = calloc(1, sizeof(*ctx)); if (!ctx) { return ESP_ERR_NO_MEM; } - cap_lua_generate_job_id(ctx->job_id, sizeof(ctx->job_id)); strlcpy(ctx->path, job->path, sizeof(ctx->path)); - strlcpy(submitted_job_id, ctx->job_id, sizeof(submitted_job_id)); - strlcpy(submitted_path, ctx->path, sizeof(submitted_path)); ctx->timeout_ms = job->timeout_ms; if (job->args_json) { ctx->args_json = strdup(job->args_json); @@ -261,20 +558,79 @@ esp_err_t cap_lua_async_submit(const cap_lua_async_job_t *job, } } + char submitted_job_id[CAP_LUA_JOB_ID_LEN] = {0}; + char submitted_path[CAP_LUA_JOB_PATH_MAX] = {0}; + strlcpy(submitted_job_id, ctx->job_id, sizeof(submitted_job_id)); + strlcpy(submitted_path, ctx->path, sizeof(submitted_path)); + if (xSemaphoreTake(s_job_lock, pdMS_TO_TICKS(1000)) != pdTRUE) { free(ctx->args_json); free(ctx); return ESP_ERR_TIMEOUT; } + /* Race window: between releasing the pre-flight lock and re-acquiring it + * here, another submitter may have filled a slot under the same name or + * exclusive group. Re-check ALL invariants under lock so the + * single-instance / exclusive-group guarantee can never be broken by + * concurrent submits from different tasks (LLM, CLI, event router). */ if (s_running_jobs >= CAP_LUA_ASYNC_MAX_CONCURRENT) { xSemaphoreGive(s_job_lock); free(ctx->args_json); free(ctx); + if (err_out && err_out_size > 0) { + snprintf(err_out, err_out_size, + "Concurrency limit reached after pre-flight (%u/%u); please retry.", + (unsigned)CAP_LUA_ASYNC_MAX_CONCURRENT, + (unsigned)CAP_LUA_ASYNC_MAX_CONCURRENT); + } return ESP_ERR_NO_MEM; } - slot = cap_lua_find_reusable_slot_locked(); + int recheck_conflict = -1; + char recheck_reason[64] = {0}; + char recheck_id[CAP_LUA_JOB_ID_LEN] = {0}; + char recheck_name[CAP_LUA_JOB_NAME_MAX] = {0}; + if (job->name[0]) { + int s = cap_lua_find_active_by_name_locked(job->name); + if (s >= 0) { + recheck_conflict = s; + snprintf(recheck_reason, sizeof(recheck_reason), "name '%s'", job->name); + } + } + if (recheck_conflict < 0 && job->exclusive[0]) { + int s = cap_lua_find_active_by_exclusive_locked(job->exclusive); + if (s >= 0) { + recheck_conflict = s; + snprintf(recheck_reason, sizeof(recheck_reason), + "exclusive group '%s'", job->exclusive); + } + } + if (recheck_conflict >= 0) { + strlcpy(recheck_id, s_jobs[recheck_conflict].job_id, sizeof(recheck_id)); + strlcpy(recheck_name, s_jobs[recheck_conflict].name, sizeof(recheck_name)); + xSemaphoreGive(s_job_lock); + free(ctx->args_json); + free(ctx); + /* Signal the wrapper to retry once: the inner pre-flight already + * did the stop-and-wait, but a different submitter slipped in. A + * single bounded retry absorbs the race without ever asking the + * LLM to redo it. The wrapper drops this flag on the second pass + * so we cannot oscillate indefinitely under sustained contention. */ + if (out_recheck_lost_race) { + *out_recheck_lost_race = true; + } + if (err_out && err_out_size > 0) { + snprintf(err_out, err_out_size, + "Slot for %s was taken by job '%s' (id=%s) before submit committed.", + recheck_reason, + recheck_name[0] ? recheck_name : "(unnamed)", + recheck_id); + } + return ESP_ERR_INVALID_STATE; + } + + int slot = cap_lua_find_reusable_slot_locked(); if (slot < 0) { xSemaphoreGive(s_job_lock); free(ctx->args_json); @@ -283,11 +639,22 @@ esp_err_t cap_lua_async_submit(const cap_lua_async_job_t *job, } cap_lua_clear_slot(&s_jobs[slot]); + /* Drain any leftover terminal signal from the previous occupant so that + * the next waiter only sees this job's transition. */ + if (s_slot_terminal_sem[slot]) { + xSemaphoreTake(s_slot_terminal_sem[slot], 0); + } s_jobs[slot].used = true; s_jobs[slot].status = CAP_LUA_JOB_QUEUED; - s_jobs[slot].created_at = job->created_at ? job->created_at : now; + s_jobs[slot].created_at = job->created_at ? job->created_at : time(NULL); strlcpy(s_jobs[slot].job_id, ctx->job_id, sizeof(s_jobs[slot].job_id)); strlcpy(s_jobs[slot].path, ctx->path, sizeof(s_jobs[slot].path)); + if (job->name[0]) { + strlcpy(s_jobs[slot].name, job->name, sizeof(s_jobs[slot].name)); + } + if (job->exclusive[0]) { + strlcpy(s_jobs[slot].exclusive, job->exclusive, sizeof(s_jobs[slot].exclusive)); + } if (ctx->args_json) { s_jobs[slot].args_json = strdup(ctx->args_json); if (!s_jobs[slot].args_json) { @@ -299,7 +666,9 @@ esp_err_t cap_lua_async_submit(const cap_lua_async_job_t *job, } } s_jobs[slot].timeout_ms = job->timeout_ms; + s_jobs[slot].stop_requested = false; ctx->slot = slot; + ctx->stop_requested = &s_jobs[slot].stop_requested; s_running_jobs++; xSemaphoreGive(s_job_lock); @@ -311,6 +680,22 @@ esp_err_t cap_lua_async_submit(const cap_lua_async_job_t *job, &s_jobs[slot].task_handle, tskNO_AFFINITY, cap_lua_task_memory_caps()) != pdPASS) { + UBaseType_t caps = cap_lua_task_memory_caps(); + size_t free_bytes = heap_caps_get_free_size(caps); + size_t largest = heap_caps_get_largest_free_block(caps); + ESP_LOGE(TAG, "Failed to spawn task (stack=%u, free=%u, largest_block=%u)", + (unsigned)CAP_LUA_ASYNC_STACK, + (unsigned)free_bytes, + (unsigned)largest); + if (err_out && err_out_size > 0) { + snprintf(err_out, err_out_size, + "Out of internal RAM for Lua task stack " + "(need %u bytes, largest free block %u bytes, total free %u bytes). " + "Stop another job and retry.", + (unsigned)CAP_LUA_ASYNC_STACK, + (unsigned)largest, + (unsigned)free_bytes); + } if (xSemaphoreTake(s_job_lock, pdMS_TO_TICKS(1000)) == pdTRUE) { cap_lua_clear_slot(&s_jobs[slot]); if (s_running_jobs > 0) { @@ -333,18 +718,28 @@ esp_err_t cap_lua_async_submit(const cap_lua_async_job_t *job, strlcpy(job_id_out, submitted_job_id, job_id_out_size); } - ESP_LOGI(TAG, "Queued Lua async job %s for %s", submitted_job_id, submitted_path); + ESP_LOGI(TAG, "Queued Lua async job %s name=%s exclusive=%s timeout_ms=%u path=%s", + submitted_job_id, + job->name[0] ? job->name : "(unnamed)", + job->exclusive[0] ? job->exclusive : "none", + (unsigned)job->timeout_ms, + submitted_path); return ESP_OK; } +static int cap_lua_format_runtime_seconds(time_t now, time_t since) +{ + if (since <= 0) { + return 0; + } + long diff = (long)(now - since); + return diff < 0 ? 0 : (int)diff; +} + esp_err_t cap_lua_async_list_jobs(const char *status_filter, char *output, size_t output_size) { - size_t offset = 0; - int i; - int shown = 0; - if (!output || output_size == 0) { return ESP_ERR_INVALID_ARG; } @@ -354,23 +749,28 @@ esp_err_t cap_lua_async_list_jobs(const char *status_filter, return ESP_ERR_TIMEOUT; } - for (i = 0; i < CAP_LUA_ASYNC_MAX_JOBS && offset < output_size - 1; i++) { - int written; + time_t now = time(NULL); + size_t offset = 0; + int shown = 0; + for (int i = 0; i < CAP_LUA_ASYNC_MAX_JOBS && offset < output_size - 1; i++) { if (!s_jobs[i].used || !cap_lua_job_status_matches(s_jobs[i].status, status_filter)) { continue; } - - written = snprintf(output + offset, - output_size - offset, - "%s | %s | %s\n", - s_jobs[i].job_id, - cap_lua_job_status_name(s_jobs[i].status), - s_jobs[i].path); + int runtime_s = cap_lua_format_runtime_seconds( + now, + s_jobs[i].started_at ? s_jobs[i].started_at : s_jobs[i].created_at); + int written = snprintf(output + offset, output_size - offset, + "%s | %s | name=%s | exclusive=%s | runtime=%ds | path=%s\n", + s_jobs[i].job_id, + cap_lua_job_status_name(s_jobs[i].status), + s_jobs[i].name[0] ? s_jobs[i].name : "(unnamed)", + s_jobs[i].exclusive[0] ? s_jobs[i].exclusive : "none", + runtime_s, + s_jobs[i].path); if (written < 0 || (size_t)written >= output_size - offset) { break; } - offset += (size_t)written; shown++; } @@ -382,13 +782,11 @@ esp_err_t cap_lua_async_list_jobs(const char *status_filter, return ESP_OK; } -esp_err_t cap_lua_async_get_job(const char *job_id, +esp_err_t cap_lua_async_get_job(const char *id_or_name, char *output, size_t output_size) { - int slot = -1; - - if (!job_id || !job_id[0] || !output || output_size == 0) { + if (!id_or_name || !id_or_name[0] || !output || output_size == 0) { return ESP_ERR_INVALID_ARG; } output[0] = '\0'; @@ -397,20 +795,232 @@ esp_err_t cap_lua_async_get_job(const char *job_id, return ESP_ERR_TIMEOUT; } - slot = cap_lua_find_slot_by_id_locked(job_id); + int slot = cap_lua_find_slot_by_id_or_name_locked(id_or_name); if (slot < 0) { xSemaphoreGive(s_job_lock); - snprintf(output, output_size, "Error: Lua async job not found: %s", job_id); + snprintf(output, output_size, "Error: Lua async job not found: %s", id_or_name); return ESP_ERR_NOT_FOUND; } - snprintf(output, - output_size, - "job_id=%s\nstatus=%s\npath=%s\nsummary=%s", + time_t now = time(NULL); + int runtime_s = cap_lua_format_runtime_seconds( + now, + s_jobs[slot].started_at ? s_jobs[slot].started_at : s_jobs[slot].created_at); + + snprintf(output, output_size, + "job_id=%s\nname=%s\nstatus=%s\nexclusive=%s\nruntime_s=%d\npath=%s\nargs=%s\nsummary=%s", s_jobs[slot].job_id, + s_jobs[slot].name[0] ? s_jobs[slot].name : "(unnamed)", cap_lua_job_status_name(s_jobs[slot].status), + s_jobs[slot].exclusive[0] ? s_jobs[slot].exclusive : "none", + runtime_s, s_jobs[slot].path, + (s_jobs[slot].args_json && s_jobs[slot].args_json[0]) ? s_jobs[slot].args_json : "(none)", (s_jobs[slot].summary && s_jobs[slot].summary[0]) ? s_jobs[slot].summary : "(empty)"); xSemaphoreGive(s_job_lock); return ESP_OK; } + +esp_err_t cap_lua_async_stop_job(const char *id_or_name, + uint32_t wait_ms, + char *output, + size_t output_size) +{ + if (!id_or_name || !id_or_name[0]) { + if (output && output_size > 0) { + snprintf(output, output_size, "Error: missing job_id or name"); + } + return ESP_ERR_INVALID_ARG; + } + + if (xSemaphoreTake(s_job_lock, pdMS_TO_TICKS(1000)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + int slot = cap_lua_find_slot_by_id_or_name_locked(id_or_name); + if (slot < 0) { + xSemaphoreGive(s_job_lock); + if (output && output_size > 0) { + snprintf(output, output_size, "Error: Lua async job not found: %s", id_or_name); + } + return ESP_ERR_NOT_FOUND; + } + char job_id[CAP_LUA_JOB_ID_LEN]; + strlcpy(job_id, s_jobs[slot].job_id, sizeof(job_id)); + xSemaphoreGive(s_job_lock); + + if (wait_ms == 0) { + wait_ms = CAP_LUA_STOP_WAIT_DEFAULT_MS; + } + bool was_running = false; + esp_err_t err = cap_lua_stop_slot_and_wait(slot, wait_ms, &was_running); + + if (xSemaphoreTake(s_job_lock, pdMS_TO_TICKS(1000)) == pdTRUE) { + const char *status_name = cap_lua_job_status_name(s_jobs[slot].status); + if (err == ESP_OK && was_running) { + snprintf(output, output_size, "OK: stopped job %s (status=%s)", + job_id, status_name); + } else if (err == ESP_OK && !was_running) { + snprintf(output, output_size, "OK: job %s already terminal (status=%s)", + job_id, status_name); + } else if (err == ESP_ERR_TIMEOUT) { + snprintf(output, output_size, + "WARN: stop requested for job %s but task did not exit within %u ms (status=%s)", + job_id, (unsigned)wait_ms, status_name); + } else { + snprintf(output, output_size, "Error stopping job %s: %s", + job_id, esp_err_to_name(err)); + } + xSemaphoreGive(s_job_lock); + } + return err; +} + +esp_err_t cap_lua_async_stop_all_jobs(const char *exclusive_filter, + uint32_t wait_ms, + char *output, + size_t output_size) +{ + int targets[CAP_LUA_ASYNC_MAX_JOBS]; + int target_count = 0; + + if (xSemaphoreTake(s_job_lock, pdMS_TO_TICKS(1000)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + for (int i = 0; i < CAP_LUA_ASYNC_MAX_JOBS; i++) { + if (!s_jobs[i].used || cap_lua_status_is_terminal(s_jobs[i].status)) { + continue; + } + if (exclusive_filter && exclusive_filter[0]) { + if (strcmp(s_jobs[i].exclusive, exclusive_filter) != 0) { + continue; + } + } + targets[target_count++] = i; + } + xSemaphoreGive(s_job_lock); + + if (wait_ms == 0) { + wait_ms = CAP_LUA_STOP_WAIT_DEFAULT_MS; + } + + int stopped = 0; + int timed_out = 0; + for (int t = 0; t < target_count; t++) { + bool was_running = false; + esp_err_t err = cap_lua_stop_slot_and_wait(targets[t], wait_ms, &was_running); + if (err == ESP_OK) { + stopped++; + } else if (err == ESP_ERR_TIMEOUT) { + timed_out++; + } + } + + if (output && output_size > 0) { + const char *filter_label = (exclusive_filter && exclusive_filter[0]) ? exclusive_filter : "all"; + if (target_count == 0) { + snprintf(output, output_size, "No matching jobs (filter=%s)", filter_label); + } else { + snprintf(output, output_size, + "Stopped %d job(s), %d still unwinding (filter=%s)", + stopped, timed_out, filter_label); + } + } + return ESP_OK; +} + +esp_err_t cap_lua_async_wait_settle(const char *job_id, + uint32_t timeout_ms, + cap_lua_job_status_t *out_status, + char *summary_out, + size_t summary_out_size) +{ + int slot = -1; + + if (!job_id || !job_id[0] || !out_status) { + return ESP_ERR_INVALID_ARG; + } + if (summary_out && summary_out_size > 0) { + summary_out[0] = '\0'; + } + if (!s_job_lock) { + return ESP_ERR_INVALID_STATE; + } + + if (xSemaphoreTake(s_job_lock, pdMS_TO_TICKS(200)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + slot = cap_lua_find_slot_by_id_or_name_locked(job_id); + if (slot < 0) { + xSemaphoreGive(s_job_lock); + return ESP_ERR_NOT_FOUND; + } + *out_status = s_jobs[slot].status; + bool already_terminal = cap_lua_status_is_terminal(*out_status); + xSemaphoreGive(s_job_lock); + + if (!already_terminal && timeout_ms > 0) { + cap_lua_wait_for_terminal(slot, timeout_ms); + } + + return cap_lua_async_get_status(job_id, out_status, summary_out, summary_out_size); +} + +esp_err_t cap_lua_async_get_status(const char *job_id, + cap_lua_job_status_t *out_status, + char *summary_out, + size_t summary_out_size) +{ + if (!job_id || !job_id[0] || !out_status) { + return ESP_ERR_INVALID_ARG; + } + if (summary_out && summary_out_size > 0) { + summary_out[0] = '\0'; + } + if (!s_job_lock) { + return ESP_ERR_INVALID_STATE; + } + if (xSemaphoreTake(s_job_lock, pdMS_TO_TICKS(200)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + int slot = cap_lua_find_slot_by_id_or_name_locked(job_id); + if (slot < 0) { + xSemaphoreGive(s_job_lock); + return ESP_ERR_NOT_FOUND; + } + *out_status = s_jobs[slot].status; + if (summary_out && summary_out_size > 0 && s_jobs[slot].summary && s_jobs[slot].summary[0]) { + strlcpy(summary_out, s_jobs[slot].summary, summary_out_size); + } + xSemaphoreGive(s_job_lock); + return ESP_OK; +} + +size_t cap_lua_async_collect_active_snapshots(cap_lua_async_job_snapshot_t *out, + size_t max) +{ + size_t count = 0; + + if (!out || max == 0 || !s_job_lock) { + return 0; + } + if (xSemaphoreTake(s_job_lock, pdMS_TO_TICKS(200)) != pdTRUE) { + return 0; + } + for (int i = 0; i < CAP_LUA_ASYNC_MAX_JOBS && count < max; i++) { + if (!s_jobs[i].used || cap_lua_status_is_terminal(s_jobs[i].status)) { + continue; + } + memset(&out[count], 0, sizeof(out[count])); + strlcpy(out[count].job_id, s_jobs[i].job_id, sizeof(out[count].job_id)); + strlcpy(out[count].name, s_jobs[i].name, sizeof(out[count].name)); + strlcpy(out[count].exclusive, s_jobs[i].exclusive, sizeof(out[count].exclusive)); + strlcpy(out[count].path, s_jobs[i].path, sizeof(out[count].path)); + out[count].status = s_jobs[i].status; + out[count].created_at = s_jobs[i].created_at; + out[count].started_at = s_jobs[i].started_at; + out[count].finished_at = s_jobs[i].finished_at; + count++; + } + xSemaphoreGive(s_job_lock); + return count; +} diff --git a/components/claw_capabilities/cap_lua/src/cap_lua_internal.h b/components/claw_capabilities/cap_lua/src/cap_lua_internal.h index ec9108d..a878ebe 100644 --- a/components/claw_capabilities/cap_lua/src/cap_lua_internal.h +++ b/components/claw_capabilities/cap_lua/src/cap_lua_internal.h @@ -13,19 +13,41 @@ #include "cap_lua.h" #include "esp_err.h" -#define CAP_LUA_MAX_SCRIPT_SIZE (16 * 1024) -#define CAP_LUA_OUTPUT_SIZE (4 * 1024) -#define CAP_LUA_MAX_EXEC_MS 60000 -#define CAP_LUA_ASYNC_MAX_JOBS 16 -#define CAP_LUA_ASYNC_MAX_CONCURRENT 4 -#define CAP_LUA_ASYNC_STACK (16 * 1024) -#define CAP_LUA_ASYNC_PRIO 4 -#define CAP_LUA_MAX_MODULES 16 +#define CAP_LUA_DEFAULT_BASE_DIR "/spiffs/lua" +#define CAP_LUA_MAX_SCRIPT_SIZE (16 * 1024) +#define CAP_LUA_OUTPUT_SIZE (4 * 1024) +#define CAP_LUA_SYNC_DEFAULT_TIMEOUT_MS 60000 +/* + * Async jobs default to "until cancelled" so long-running clocks, watchers, + * and games can run indefinitely. Use lua_stop_async_job to terminate them. + */ +#define CAP_LUA_ASYNC_DEFAULT_TIMEOUT_MS 0 +#define CAP_LUA_ASYNC_MAX_JOBS 16 +#define CAP_LUA_ASYNC_MAX_CONCURRENT 4 +/* + * Stack lives in internal DRAM (FATFS / display drivers expect it). 12 KB is + * the empirically-tuned minimum that still fits typical Lua scripts with a + * comfortable margin; reducing further increases the risk of stack overflow + * in deeper Lua call chains, while raising it makes allocation more likely to + * fail once the heap is fragmented after boot. + */ +#define CAP_LUA_ASYNC_STACK (12 * 1024) +#define CAP_LUA_ASYNC_PRIO 4 +#define CAP_LUA_MAX_MODULES 16 + +#define CAP_LUA_JOB_NAME_MAX 32 +#define CAP_LUA_JOB_EXCLUSIVE_MAX 16 +#define CAP_LUA_JOB_PATH_MAX 192 +#define CAP_LUA_JOB_ID_LEN 9 /* 8 hex + NUL */ +#define CAP_LUA_STOP_WAIT_DEFAULT_MS 2000 /* sync wait window for stop */ typedef struct { - char path[192]; + char path[CAP_LUA_JOB_PATH_MAX]; + char name[CAP_LUA_JOB_NAME_MAX]; + char exclusive[CAP_LUA_JOB_EXCLUSIVE_MAX]; char *args_json; uint32_t timeout_ms; + bool replace; time_t created_at; } cap_lua_async_job_t; @@ -35,17 +57,39 @@ typedef enum { CAP_LUA_JOB_DONE, CAP_LUA_JOB_FAILED, CAP_LUA_JOB_TIMEOUT, + CAP_LUA_JOB_STOPPED, } cap_lua_job_status_t; +typedef struct { + char job_id[CAP_LUA_JOB_ID_LEN]; + char name[CAP_LUA_JOB_NAME_MAX]; + char exclusive[CAP_LUA_JOB_EXCLUSIVE_MAX]; + char path[CAP_LUA_JOB_PATH_MAX]; + cap_lua_job_status_t status; + time_t created_at; + time_t started_at; + time_t finished_at; +} cap_lua_async_job_snapshot_t; + const char *cap_lua_get_base_dir(void); bool cap_lua_path_is_valid(const char *path); esp_err_t cap_lua_resolve_path(const char *path, char *resolved, size_t resolved_size); esp_err_t cap_lua_ensure_base_dir(void); esp_err_t cap_lua_runtime_init(void); + +/* + * Execute a Lua script synchronously. + * + * stop_requested may be NULL; when non-NULL the runtime hook will raise + * a Lua error tagged "stopped by user" once the flag becomes true. + * + * timeout_ms == 0 means "no wall-clock deadline". + */ esp_err_t cap_lua_runtime_execute_file(const char *path, const char *args_json, uint32_t timeout_ms, + volatile bool *stop_requested, char *output, size_t output_size); esp_err_t cap_lua_register_builtin_modules(void); @@ -58,10 +102,37 @@ esp_err_t cap_lua_async_init(void); esp_err_t cap_lua_async_start(void); esp_err_t cap_lua_async_submit(const cap_lua_async_job_t *job, char *job_id_out, - size_t job_id_out_size); + size_t job_id_out_size, + char *err_out, + size_t err_out_size); esp_err_t cap_lua_async_list_jobs(const char *status_filter, char *output, size_t output_size); -esp_err_t cap_lua_async_get_job(const char *job_id, +esp_err_t cap_lua_async_get_job(const char *id_or_name, char *output, size_t output_size); +esp_err_t cap_lua_async_stop_job(const char *id_or_name, + uint32_t wait_ms, + char *output, + size_t output_size); +esp_err_t cap_lua_async_stop_all_jobs(const char *exclusive_filter, + uint32_t wait_ms, + char *output, + size_t output_size); +size_t cap_lua_async_collect_active_snapshots(cap_lua_async_job_snapshot_t *out, + size_t max); +const char *cap_lua_job_status_name(cap_lua_job_status_t status); +/* Lightweight single-job status probe. Returns ESP_ERR_NOT_FOUND if the slot + * has already been recycled. summary_out may be NULL. */ +esp_err_t cap_lua_async_get_status(const char *job_id, + cap_lua_job_status_t *out_status, + char *summary_out, + size_t summary_out_size); +/* Block up to timeout_ms waiting for the job to reach a terminal state. If + * the job is already terminal, returns immediately. Returns the latest status + * snapshot regardless of whether the wait timed out. */ +esp_err_t cap_lua_async_wait_settle(const char *job_id, + uint32_t timeout_ms, + cap_lua_job_status_t *out_status, + char *summary_out, + size_t summary_out_size); diff --git a/components/claw_capabilities/cap_lua/src/cap_lua_runtime.c b/components/claw_capabilities/cap_lua/src/cap_lua_runtime.c index e0cc57a..24ad91f 100644 --- a/components/claw_capabilities/cap_lua/src/cap_lua_runtime.c +++ b/components/claw_capabilities/cap_lua/src/cap_lua_runtime.c @@ -29,10 +29,14 @@ typedef struct { size_t size; size_t len; bool truncated; + /* deadline_us == 0 means "no wall-clock deadline" (cancel-only mode). */ int64_t deadline_us; + volatile bool *stop_requested; } cap_lua_exec_ctx_t; -static void cap_lua_output_append(cap_lua_exec_ctx_t *ctx, const char *text, size_t len) +static void cap_lua_output_append(cap_lua_exec_ctx_t *ctx, + const char *text, + size_t len) { size_t room; size_t copy; @@ -109,7 +113,8 @@ static void cap_lua_push_json_value(lua_State *L, const cJSON *item) static int cap_lua_print_capture(lua_State *L) { - cap_lua_exec_ctx_t *ctx = (cap_lua_exec_ctx_t *)lua_touserdata(L, lua_upvalueindex(1)); + cap_lua_exec_ctx_t *ctx = (cap_lua_exec_ctx_t *)lua_touserdata( + L, lua_upvalueindex(1)); int top = lua_gettop(L); int i; @@ -145,7 +150,12 @@ static void cap_lua_timeout_hook(lua_State *L, lua_Debug *ar) return; } - if (esp_timer_get_time() > ctx->deadline_us) { + /* Cooperative cancellation always wins over deadline reporting. */ + if (ctx->stop_requested && *ctx->stop_requested) { + luaL_error(L, "stopped by user"); + } + + if (ctx->deadline_us != 0 && esp_timer_get_time() > ctx->deadline_us) { luaL_error(L, "execution timed out"); } /* Yield so tight Lua loops cannot starve IDLE / trigger the task WDT (see report P2-13). */ @@ -201,21 +211,29 @@ static void cap_lua_run_runtime_cleanups(void) esp_err_t cap_lua_runtime_init(void) { - ESP_LOGI(TAG, "Lua runtime ready: scripts=%s registered_modules=%u", cap_lua_get_base_dir(), + ESP_LOGI(TAG, + "Lua runtime ready: scripts=%s registered_modules=%u", + cap_lua_get_base_dir(), (unsigned int)cap_lua_get_module_count()); return ESP_OK; } -esp_err_t cap_lua_runtime_execute_file(const char *path, const char *args_json, uint32_t timeout_ms, char *output, +esp_err_t cap_lua_runtime_execute_file(const char *path, + const char *args_json, + uint32_t timeout_ms, + volatile bool *stop_requested, + char *output, size_t output_size) { struct stat st = {0}; lua_State *L = NULL; - uint32_t effective_timeout_ms = timeout_ms ? timeout_ms : CAP_LUA_MAX_EXEC_MS; cap_lua_exec_ctx_t ctx = { .buf = output, .size = output_size, - .deadline_us = esp_timer_get_time() + ((int64_t)effective_timeout_ms * 1000), + .deadline_us = (timeout_ms == 0) + ? 0 + : esp_timer_get_time() + ((int64_t)timeout_ms * 1000), + .stop_requested = stop_requested, }; int status; @@ -225,7 +243,10 @@ esp_err_t cap_lua_runtime_execute_file(const char *path, const char *args_json, output[0] = '\0'; if (!cap_lua_path_is_valid(path)) { - snprintf(output, output_size, "Error: Lua path must be under %s and end with .lua", cap_lua_get_base_dir()); + snprintf(output, + output_size, + "Error: Lua path must be under %s and end with .lua", + cap_lua_get_base_dir()); return ESP_ERR_INVALID_ARG; } @@ -261,7 +282,9 @@ esp_err_t cap_lua_runtime_execute_file(const char *path, const char *args_json, if (ctx.len > 0) { cap_lua_output_append(&ctx, "ERROR: ", 7); } - cap_lua_output_append(&ctx, msg ? msg : "unknown Lua error", strlen(msg ? msg : "unknown Lua error")); + cap_lua_output_append(&ctx, + msg ? msg : "unknown Lua error", + strlen(msg ? msg : "unknown Lua error")); cap_lua_output_append(&ctx, "\n", 1); lua_close(L); return ESP_FAIL; diff --git a/components/claw_capabilities/cap_lua/src/cmd_cap_lua.c b/components/claw_capabilities/cap_lua/src/cmd_cap_lua.c index ea9a2af..061c972 100644 --- a/components/claw_capabilities/cap_lua/src/cmd_cap_lua.c +++ b/components/claw_capabilities/cap_lua/src/cmd_cap_lua.c @@ -117,8 +117,9 @@ static int lua_func(int argc, char **argv) return 1; } - operation_count = lua_args.show_base_dir->count + lua_args.list->count + lua_args.write->count + lua_args.run->count + - lua_args.run_async->count + lua_args.jobs->count + lua_args.job->count; + operation_count = lua_args.show_base_dir->count + lua_args.list->count + lua_args.write->count + + lua_args.run->count + lua_args.run_async->count + lua_args.jobs->count + + lua_args.job->count; if (operation_count != 1) { printf("Exactly one operation must be specified\n"); return 1; @@ -146,15 +147,20 @@ static int lua_func(int argc, char **argv) } if (lua_args.list->count) { - err = cap_lua_list_scripts(lua_args.prefix->count ? lua_args.prefix->sval[0] : NULL, result, 4096); + err = cap_lua_list_scripts(lua_args.prefix->count ? lua_args.prefix->sval[0] : NULL, + result, + 4096); } else if (lua_args.write->count) { if (!lua_args.path->count || !lua_args.content->count) { printf("'--write' requires '--path' and '--content'\n"); free(result); return 1; } - err = cap_lua_write_script(lua_args.path->sval[0], lua_args.content->sval[0], lua_args.no_overwrite->count == 0, - result, 4096); + err = cap_lua_write_script(lua_args.path->sval[0], + lua_args.content->sval[0], + lua_args.no_overwrite->count == 0, + result, + 4096); } else if (lua_args.run->count) { sync_run = true; if (!lua_args.path->count) { @@ -162,18 +168,29 @@ static int lua_func(int argc, char **argv) free(result); return 1; } - err = cap_lua_run_script(lua_args.path->sval[0], lua_args.args_json->count ? lua_args.args_json->sval[0] : NULL, - timeout_ms, result, 4096); + err = cap_lua_run_script(lua_args.path->sval[0], + lua_args.args_json->count ? lua_args.args_json->sval[0] : NULL, + timeout_ms, + result, + 4096); } else if (lua_args.run_async->count) { if (!lua_args.path->count) { printf("'--run-async' requires '--path'\n"); free(result); return 1; } - err = cap_lua_run_script_async(lua_args.path->sval[0], lua_args.args_json->count ? lua_args.args_json->sval[0] : NULL, - timeout_ms, result, 4096); + err = cap_lua_run_script_async(lua_args.path->sval[0], + lua_args.args_json->count ? lua_args.args_json->sval[0] : NULL, + timeout_ms, + NULL, + NULL, + false, + result, + 4096); } else if (lua_args.jobs->count) { - err = cap_lua_list_jobs(lua_args.status->count ? lua_args.status->sval[0] : NULL, result, 4096); + err = cap_lua_list_jobs(lua_args.status->count ? lua_args.status->sval[0] : NULL, + result, + 4096); } else { err = cap_lua_get_job(lua_args.job->sval[0], result, 4096); } @@ -207,9 +224,9 @@ void register_cap_lua(void) lua_args.run_async = arg_lit0(NULL, "run-async", "Run a managed Lua script asynchronously"); lua_args.jobs = arg_lit0(NULL, "jobs", "List async Lua jobs"); lua_args.job = arg_str0(NULL, "job", "", "Show one async Lua job"); - lua_args.path = arg_str0("p", "path", "", "Lua file path relative to the configured base dir"); + lua_args.path = arg_str0("p", "path", "", "Lua file path relative to base dir or absolute"); lua_args.content = arg_str0("c", "content", "", "Lua script content for write"); - lua_args.prefix = arg_str0(NULL, "prefix", "", "Optional relative prefix filter for list"); + lua_args.prefix = arg_str0(NULL, "prefix", "", "Optional absolute prefix filter for list"); lua_args.args_json = arg_str0(NULL, "args-json", "", "JSON object/array passed to the script"); lua_args.status = arg_str0(NULL, "status", "", "Job status filter: all|queued|running|done|failed|timeout"); lua_args.timeout_ms = arg_int0("t", "timeout-ms", "", "Execution timeout in milliseconds"); diff --git a/components/claw_modules/claw_cap/src/claw_cap.c b/components/claw_modules/claw_cap/src/claw_cap.c index 99a7cc8..ecef728 100644 --- a/components/claw_modules/claw_cap/src/claw_cap.c +++ b/components/claw_modules/claw_cap/src/claw_cap.c @@ -22,6 +22,12 @@ static const char *TAG = "claw_cap"; #define CLAW_CAP_DEFAULT_MAX_GROUPS 4 #define CLAW_CAP_UNLOAD_POLL_MS 20 +/* Soft cap on the description string published to the LLM tools array. Keeps + * the request body bounded and protects against accidental verbose copy that + * could push the body past upstream parser limits (observed as HTTP 400 on + * some gateways). Tune cautiously: shrinking too much hurts tool selection. */ +#define CLAW_CAP_TOOL_DESCRIPTION_MAX 256 + typedef struct { bool occupied; const claw_cap_group_t *group; @@ -205,6 +211,35 @@ static const claw_cap_session_visibility_t *claw_cap_get_session_visibility_lock return &s_runtime.session_visibilities[index]; } +static void claw_cap_add_capped_description(cJSON *obj, const char *desc, const char *cap_name) +{ + const char *src = desc ? desc : ""; + size_t len = strlen(src); + + if (len <= CLAW_CAP_TOOL_DESCRIPTION_MAX) { + cJSON_AddStringToObject(obj, "description", src); + return; + } + + ESP_LOGW(TAG, "cap '%s' description %u bytes exceeds %u, truncating", + cap_name ? cap_name : "?", (unsigned)len, + (unsigned)CLAW_CAP_TOOL_DESCRIPTION_MAX); + + char buf[CLAW_CAP_TOOL_DESCRIPTION_MAX + 1]; + memcpy(buf, src, CLAW_CAP_TOOL_DESCRIPTION_MAX); + buf[CLAW_CAP_TOOL_DESCRIPTION_MAX] = '\0'; + + /* Trim a half-cut UTF-8 codepoint at the end so cJSON never emits invalid bytes. */ + size_t out = CLAW_CAP_TOOL_DESCRIPTION_MAX; + while (out > 0 && ((unsigned char)buf[out - 1] & 0xC0) == 0x80) { + buf[--out] = '\0'; + } + if (out > 0 && ((unsigned char)buf[out - 1] & 0xC0) == 0xC0) { + buf[--out] = '\0'; + } + cJSON_AddStringToObject(obj, "description", buf); +} + char *claw_cap_build_llm_tools_json(const claw_cap_call_context_t *ctx, bool wrap_for_responses_api) { @@ -242,8 +277,7 @@ char *claw_cap_build_llm_tools_json(const claw_cap_call_context_t *ctx, } cJSON_AddStringToObject(item, "name", slot->descriptor.name); - cJSON_AddStringToObject(item, "description", - slot->descriptor.description ? slot->descriptor.description : ""); + claw_cap_add_capped_description(item, slot->descriptor.description, slot->descriptor.name); schema = cJSON_Parse(slot->descriptor.input_schema_json ? slot->descriptor.input_schema_json : "{\"type\":\"object\",\"properties\":{}}"); if (!schema) { diff --git a/components/claw_modules/claw_core/include/claw_core.h b/components/claw_modules/claw_core/include/claw_core.h index fb2656b..4d40c59 100644 --- a/components/claw_modules/claw_core/include/claw_core.h +++ b/components/claw_modules/claw_core/include/claw_core.h @@ -122,14 +122,49 @@ struct claw_core_response { char *error_message; }; +/* + * Diagnostic snapshot of one fully completed claw_core request, fired once + * per request after the LLM produces a final assistant message (success path + * only). Observers are intended for telemetry / honesty checks; they MUST NOT + * mutate state or block the worker beyond a few milliseconds. + * + * Both CSV strings are comma-separated, never NULL, always NUL-terminated + * (may be empty). Names in tool_calls_csv preserve invocation order and are + * NOT deduped. Names in context_providers_csv are deduped. + */ +typedef struct { + uint32_t request_id; + const char *session_id; /* may be NULL */ + const char *final_text; /* may be NULL or empty */ + const char *context_providers_csv; /* providers that injected non-empty content */ + const char *tool_calls_csv; /* tool calls invoked across all rounds */ +} claw_core_completion_summary_t; + +typedef void (*claw_core_completion_observer_fn)(const claw_core_completion_summary_t *summary, + void *user_ctx); + esp_err_t claw_core_init(const claw_core_config_t *config); esp_err_t claw_core_start(void); esp_err_t claw_core_add_context_provider(const claw_core_context_provider_t *provider); +esp_err_t claw_core_add_completion_observer(claw_core_completion_observer_fn observer, + void *user_ctx); esp_err_t claw_core_call_cap(const char *cap_name, const char *input_json, const claw_core_request_t *request, char **out_output); esp_err_t claw_core_submit(const claw_core_request_t *request, uint32_t timeout_ms); +/* + * Cooperatively abort the in-flight LLM request held by the worker task. + * + * `request_id == 0` cancels whatever is currently in flight (panic-stop). + * Otherwise the cancel only fires when the in-flight id matches, which lets + * callers safely retry from a stale id without disturbing newer rounds. + * + * Returns ESP_OK if a cancel was armed, ESP_ERR_NOT_FOUND if no matching + * request is in flight. The actual unwind is asynchronous; the worker + * publishes a normal response with error_message="request cancelled". + */ +esp_err_t claw_core_cancel_request(uint32_t request_id); esp_err_t claw_core_receive(claw_core_response_t *response, uint32_t timeout_ms); esp_err_t claw_core_receive_for(uint32_t request_id, claw_core_response_t *response, diff --git a/components/claw_modules/claw_core/src/claw_core.c b/components/claw_modules/claw_core/src/claw_core.c index b2803db..d819742 100644 --- a/components/claw_modules/claw_core/src/claw_core.c +++ b/components/claw_modules/claw_core/src/claw_core.c @@ -21,6 +21,7 @@ #include "claw_core_llm.h" #include "claw_event_publisher.h" +#include "llm/claw_llm_http_transport.h" #include "sdkconfig.h" static const char *TAG = "claw_core"; @@ -81,6 +82,18 @@ typedef struct { SemaphoreHandle_t response_lock; claw_core_pending_response_t *pending_head; claw_core_pending_response_t *pending_tail; + /* Cooperative-cancel slot for the request currently held by the worker. + * `inflight_request_id` is non-zero only between request dequeue and + * worker completion; protected by `inflight_lock`. */ + SemaphoreHandle_t inflight_lock; + uint32_t inflight_request_id; + volatile bool inflight_abort; +#define CLAW_CORE_MAX_COMPLETION_OBSERVERS 4 + struct { + claw_core_completion_observer_fn fn; + void *user_ctx; + } completion_observers[CLAW_CORE_MAX_COMPLETION_OBSERVERS]; + size_t completion_observer_count; } claw_core_state_t; static claw_core_state_t s_core = {0}; @@ -170,6 +183,55 @@ static char *build_session_assistant_text(const char *tool_summary, const char * return combined; } +/* CSV utilities for the per-request observer summary. Both buffers are + * stack-allocated in claw_core_task; the helpers below append a token and + * silently drop on overflow (diagnostic data is best-effort). */ +#define CLAW_CORE_OBS_CSV_MAX 384 + +static bool obs_csv_contains(const char *csv, const char *name) +{ + const char *needle = name; + const char *p = csv; + size_t need_len; + + if (!csv || !name) { + return false; + } + need_len = strlen(needle); + while (*p) { + if (strncmp(p, needle, need_len) == 0 && (p[need_len] == ',' || p[need_len] == '\0')) { + return true; + } + const char *next = strchr(p, ','); + if (!next) { + break; + } + p = next + 1; + } + return false; +} + +static void obs_csv_append(char *csv, size_t csv_size, const char *name, bool dedup) +{ + size_t cur; + int written; + + if (!csv || csv_size == 0 || !name || !name[0]) { + return; + } + if (dedup && obs_csv_contains(csv, name)) { + return; + } + cur = strlen(csv); + if (cur >= csv_size - 1) { + return; + } + written = snprintf(csv + cur, csv_size - cur, "%s%s", cur == 0 ? "" : ",", name); + if (written < 0 || (size_t)written >= csv_size - cur) { + csv[csv_size - 1] = '\0'; + } +} + static void log_tool_call_names(uint32_t request_id, const claw_core_llm_response_t *response) { char buf[192] = {0}; @@ -925,7 +987,9 @@ static esp_err_t build_iteration_context(const claw_core_request_item_t *request const cJSON *runtime_messages, char **out_system_prompt, cJSON **out_messages, - char **out_tools_json) + char **out_tools_json, + char *obs_providers_csv, + size_t obs_providers_csv_size) { char *system_prompt = NULL; char *turn_prompt = NULL; @@ -985,6 +1049,7 @@ static esp_err_t build_iteration_context(const claw_core_request_item_t *request provider->name, context_kind_to_string(context.kind), (unsigned)context_len); + obs_csv_append(obs_providers_csv, obs_providers_csv_size, provider->name, true); switch (context.kind) { case CLAW_CORE_CONTEXT_KIND_SYSTEM_PROMPT: @@ -1069,11 +1134,22 @@ static void claw_core_task(void *arg) claw_core_llm_response_t llm_response = {0}; uint32_t iteration = 0; esp_err_t err = ESP_OK; + char obs_providers_csv[CLAW_CORE_OBS_CSV_MAX] = {0}; + char obs_tool_calls_csv[CLAW_CORE_OBS_CSV_MAX] = {0}; if (xQueueReceive(s_core.request_queue, &request, portMAX_DELAY) != pdTRUE) { continue; } + /* Publish the in-flight request id so claw_core_cancel_request can + * target it. inflight_abort starts cleared each round. */ + if (xSemaphoreTake(s_core.inflight_lock, portMAX_DELAY) == pdTRUE) { + s_core.inflight_request_id = request.view.request_id; + s_core.inflight_abort = false; + xSemaphoreGive(s_core.inflight_lock); + } + claw_llm_http_arm_abort(&s_core.inflight_abort); + response.view.request_id = request.view.request_id; response.view.status = CLAW_CORE_RESPONSE_STATUS_ERROR; response.view.completion_type = CLAW_CORE_COMPLETION_DONE; @@ -1115,7 +1191,9 @@ static void claw_core_task(void *arg) runtime_messages, &system_prompt, &messages, - &tools_json); + &tools_json, + obs_providers_csv, + sizeof(obs_providers_csv)); if (err != ESP_OK) { response.view.error_message = dup_string(esp_err_to_name(err)); goto finish_request; @@ -1141,6 +1219,12 @@ static void claw_core_task(void *arg) log_tool_call_names(request.view.request_id, &llm_response); publish_stage_tool_calls(&request.view, &llm_response, iteration); + for (size_t tc = 0; tc < llm_response.tool_call_count; tc++) { + obs_csv_append(obs_tool_calls_csv, + sizeof(obs_tool_calls_csv), + llm_response.tool_calls[tc].name, + false); + } err = append_assistant_tool_calls(runtime_messages, &llm_response); if (err != ESP_OK) { @@ -1186,11 +1270,39 @@ static void claw_core_task(void *arg) } } free(session_assistant_text); + if (s_core.completion_observer_count > 0) { + claw_core_completion_summary_t summary = { + .request_id = request.view.request_id, + .session_id = request.view.session_id, + .final_text = response.view.text, + .context_providers_csv = obs_providers_csv, + .tool_calls_csv = obs_tool_calls_csv, + }; + for (size_t i = 0; i < s_core.completion_observer_count; i++) { + s_core.completion_observers[i].fn(&summary, + s_core.completion_observers[i].user_ctx); + } + } } else if (!response.view.error_message) { response.view.error_message = dup_string(esp_err_to_name(err)); } finish_request: + /* Disarm BEFORE pushing the response so a subsequent cancel for an + * unrelated request never trips a stale flag here. */ + claw_llm_http_disarm_abort(); + if (xSemaphoreTake(s_core.inflight_lock, portMAX_DELAY) == pdTRUE) { + bool was_cancelled = s_core.inflight_abort; + s_core.inflight_request_id = 0; + s_core.inflight_abort = false; + xSemaphoreGive(s_core.inflight_lock); + if (was_cancelled && err != ESP_OK && response.view.error_message) { + /* Replace the generic transport error with a clearer one. */ + free(response.view.error_message); + response.view.error_message = dup_string("request cancelled"); + } + } + if (err != ESP_OK) { ESP_LOGE(TAG, "request=%" PRIu32 " failed: %s", request.view.request_id, @@ -1267,7 +1379,9 @@ esp_err_t claw_core_init(const claw_core_config_t *config) s_core.request_queue = xQueueCreate(request_queue_len, sizeof(claw_core_request_item_t)); s_core.response_queue = xQueueCreate(response_queue_len, sizeof(claw_core_response_item_t)); s_core.response_lock = xSemaphoreCreateMutex(); - if (!s_core.request_queue || !s_core.response_queue || !s_core.response_lock) { + s_core.inflight_lock = xSemaphoreCreateMutex(); + if (!s_core.request_queue || !s_core.response_queue || + !s_core.response_lock || !s_core.inflight_lock) { free_context_provider_storage(); free(s_core.system_prompt); if (s_core.request_queue) { @@ -1279,6 +1393,9 @@ esp_err_t claw_core_init(const claw_core_config_t *config) if (s_core.response_lock) { vSemaphoreDelete(s_core.response_lock); } + if (s_core.inflight_lock) { + vSemaphoreDelete(s_core.inflight_lock); + } memset(&s_core, 0, sizeof(s_core)); return ESP_ERR_NO_MEM; } @@ -1301,6 +1418,7 @@ esp_err_t claw_core_init(const claw_core_config_t *config) vQueueDelete(s_core.request_queue); vQueueDelete(s_core.response_queue); vSemaphoreDelete(s_core.response_lock); + vSemaphoreDelete(s_core.inflight_lock); memset(&s_core, 0, sizeof(s_core)); return err; } @@ -1372,6 +1490,24 @@ esp_err_t claw_core_add_context_provider(const claw_core_context_provider_t *pro return ESP_OK; } +esp_err_t claw_core_add_completion_observer(claw_core_completion_observer_fn observer, + void *user_ctx) +{ + if (!s_core.initialized) { + return ESP_ERR_INVALID_STATE; + } + if (!observer) { + return ESP_ERR_INVALID_ARG; + } + if (s_core.completion_observer_count >= CLAW_CORE_MAX_COMPLETION_OBSERVERS) { + return ESP_ERR_NO_MEM; + } + s_core.completion_observers[s_core.completion_observer_count].fn = observer; + s_core.completion_observers[s_core.completion_observer_count].user_ctx = user_ctx; + s_core.completion_observer_count++; + return ESP_OK; +} + esp_err_t claw_core_call_cap(const char *cap_name, const char *input_json, const claw_core_request_t *request, @@ -1388,6 +1524,30 @@ esp_err_t claw_core_call_cap(const char *cap_name, s_core.cap_user_ctx); } +esp_err_t claw_core_cancel_request(uint32_t request_id) +{ + bool armed = false; + + if (!s_core.initialized) { + return ESP_ERR_INVALID_STATE; + } + /* request_id == 0 means "cancel whatever is in flight"; useful for a + * panic-stop hot key. Otherwise we only flip the flag when the in-flight + * id matches, so stale cancels are no-ops. */ + if (xSemaphoreTake(s_core.inflight_lock, pdMS_TO_TICKS(200)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + if (s_core.inflight_request_id != 0 && + (request_id == 0 || s_core.inflight_request_id == request_id)) { + s_core.inflight_abort = true; + armed = true; + ESP_LOGI(TAG, "Cancel armed for in-flight request=%" PRIu32, + s_core.inflight_request_id); + } + xSemaphoreGive(s_core.inflight_lock); + return armed ? ESP_OK : ESP_ERR_NOT_FOUND; +} + esp_err_t claw_core_submit(const claw_core_request_t *request, uint32_t timeout_ms) { claw_core_request_item_t item = {0}; diff --git a/components/claw_modules/claw_core/src/llm/claw_llm_http_transport.c b/components/claw_modules/claw_core/src/llm/claw_llm_http_transport.c index 7b20a5c..eb2fe9b 100644 --- a/components/claw_modules/claw_core/src/llm/claw_llm_http_transport.c +++ b/components/claw_modules/claw_core/src/llm/claw_llm_http_transport.c @@ -5,6 +5,7 @@ */ #include "llm/claw_llm_http_transport.h" +#include #include #include #include @@ -13,11 +14,44 @@ #include "esp_crt_bundle.h" #include "esp_http_client.h" #include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" static const char *TAG = "llm_http"; #define CLAW_LLM_HTTP_RB_INITIAL_CAP 4096 +/* Cooperative-abort slot. Owner is set so that a stale flag from a different + * task is ignored (defensive — claw_core serializes LLM calls already). */ +static volatile bool *s_abort_flag = NULL; +static TaskHandle_t s_abort_owner = NULL; + +void claw_llm_http_arm_abort(volatile bool *flag) +{ + /* Single-owner invariant: claw_core serializes LLM calls. Catching a + * concurrent arm here turns a silent abort-slot overwrite into a fatal + * panic so the assumption can't quietly rot. */ + TaskHandle_t self = xTaskGetCurrentTaskHandle(); + assert(s_abort_flag == NULL || s_abort_owner == self); + s_abort_flag = flag; + s_abort_owner = self; +} + +void claw_llm_http_disarm_abort(void) +{ + if (s_abort_owner == xTaskGetCurrentTaskHandle()) { + s_abort_flag = NULL; + s_abort_owner = NULL; + } +} + +static inline bool abort_requested(void) +{ + return s_abort_flag && + s_abort_owner == xTaskGetCurrentTaskHandle() && + *s_abort_flag; +} + typedef struct { char *data; size_t len; @@ -110,6 +144,13 @@ static esp_err_t http_event_handler(esp_http_client_event_t *evt) { response_buffer_t *buffer = (response_buffer_t *)evt->user_data; + /* Poll on every callback so abort latency is bounded by the next event + * (header parsed, chunk arrived, finish, etc.). Returning a non-OK status + * forces esp_http_client_perform to bail out. */ + if (abort_requested()) { + return ESP_FAIL; + } + if (evt->event_id == HTTP_EVENT_ON_DATA) { return response_buffer_append(buffer, (const char *)evt->data, evt->data_len); } @@ -246,8 +287,14 @@ esp_err_t claw_llm_http_post_json(const claw_llm_http_json_request_t *request, ESP_LOGD(TAG, "POST %s", request->url); err = esp_http_client_perform(client); if (err != ESP_OK) { - *out_error_message = dup_printf("HTTP request failed: %s", esp_err_to_name(err)); - ESP_LOGE(TAG, "HTTP perform failed: %s", esp_err_to_name(err)); + if (abort_requested()) { + *out_error_message = dup_printf("HTTP request aborted by caller"); + ESP_LOGW(TAG, "HTTP perform aborted: %s", esp_err_to_name(err)); + err = ESP_ERR_INVALID_STATE; + } else { + *out_error_message = dup_printf("HTTP request failed: %s", esp_err_to_name(err)); + ESP_LOGE(TAG, "HTTP perform failed: %s", esp_err_to_name(err)); + } goto cleanup; } diff --git a/components/claw_modules/claw_core/src/llm/claw_llm_http_transport.h b/components/claw_modules/claw_core/src/llm/claw_llm_http_transport.h index 304ed26..93a257a 100644 --- a/components/claw_modules/claw_core/src/llm/claw_llm_http_transport.h +++ b/components/claw_modules/claw_core/src/llm/claw_llm_http_transport.h @@ -5,9 +5,28 @@ */ #pragma once +#include + #include "llm/claw_llm_types.h" esp_err_t claw_llm_http_post_json(const claw_llm_http_json_request_t *request, claw_llm_http_response_t *out_response, char **out_error_message); void claw_llm_http_response_free(claw_llm_http_response_t *response); + +/* + * Cooperative abort for the calling task's in-flight HTTP request. + * + * `arm` records (flag, current task handle) in a file-static slot. While armed, + * the HTTP event handler polls *flag on every callback (HEADER/DATA/...) and + * returns ESP_FAIL when the flag is true, causing esp_http_client_perform to + * exit with an error and claw_llm_http_post_json to return ESP_FAIL. + * + * The arm/disarm pair MUST be called from the same task that will run + * claw_llm_http_post_json. The flag must outlive the HTTP call. Only one + * task may have an armed flag at a time; arming again from a different task + * silently overwrites the previous slot, which is fine because the LLM + * runtime serializes requests in claw_core_task. + */ +void claw_llm_http_arm_abort(volatile bool *flag); +void claw_llm_http_disarm_abort(void); diff --git a/components/claw_modules/claw_event_router/include/claw_event_router.h b/components/claw_modules/claw_event_router/include/claw_event_router.h index 5dd5821..b4ba2aa 100644 --- a/components/claw_modules/claw_event_router/include/claw_event_router.h +++ b/components/claw_modules/claw_event_router/include/claw_event_router.h @@ -104,6 +104,25 @@ esp_err_t claw_event_router_init(const claw_event_router_config_t *config); esp_err_t claw_event_router_start(void); esp_err_t claw_event_router_stop(void); esp_err_t claw_event_router_reload(void); +/* + * Mark a queued event as cancelled. The router task will discard it on + * dequeue without invoking any actions. Returns ESP_OK if the event was + * found and tagged, ESP_ERR_NOT_FOUND if no such pending event exists + * (already dispatched, never queued, or evicted from the tracking table). + * + * Cancellation is queue-only: events that have already entered process_event + * cannot be cancelled here — use claw_core_cancel_request for the LLM round + * or lua_stop_async_job for an async script that the router spawned. + */ +esp_err_t claw_event_router_cancel_event(const char *event_id); +/* + * Bulk-cancel all queued events that match the optional filters. Pass NULL + * (or empty string) for either filter to skip that constraint. Returns the + * number of pending entries marked, written to *out_cancelled when non-NULL. + */ +esp_err_t claw_event_router_purge_queue(const char *event_type_filter, + const char *source_cap_filter, + size_t *out_cancelled); esp_err_t claw_event_router_register_outbound_binding(const char *channel, const char *cap_name); esp_err_t claw_event_router_handle_event(const claw_event_t *event, diff --git a/components/claw_modules/claw_event_router/src/claw_event_router.c b/components/claw_modules/claw_event_router/src/claw_event_router.c index f8d6572..5924591 100644 --- a/components/claw_modules/claw_event_router/src/claw_event_router.c +++ b/components/claw_modules/claw_event_router/src/claw_event_router.c @@ -29,7 +29,7 @@ static const char *TAG = "claw_event_router"; #define CLAW_EVENT_ROUTER_DEFAULT_MAX_RULES 32 #define CLAW_EVENT_ROUTER_DEFAULT_MAX_ACTIONS 8 #define CLAW_EVENT_ROUTER_DEFAULT_OUTPUT_SIZE 2048 -#define CLAW_EVENT_ROUTER_DEFAULT_QUEUE_LEN 16 +#define CLAW_EVENT_ROUTER_DEFAULT_QUEUE_LEN 6 #define CLAW_EVENT_ROUTER_DEFAULT_STACK 8192 #define CLAW_EVENT_ROUTER_DEFAULT_PRIO 5 #define CLAW_EVENT_ROUTER_DEFAULT_SUBMIT 1000 @@ -40,6 +40,17 @@ static const char *TAG = "claw_event_router"; #define CLAW_EVENT_ROUTER_FIELD_SIZE 96 #define CLAW_EVENT_ROUTER_cap_SIZE 64 #define CLAW_EVENT_ROUTER_BINDING_SIZE 16 +#define CLAW_EVENT_ROUTER_PENDING_TABLE_SIZE 6 +_Static_assert(CLAW_EVENT_ROUTER_PENDING_TABLE_SIZE >= CLAW_EVENT_ROUTER_DEFAULT_QUEUE_LEN, + "pending table must cover the default event queue length"); + +typedef struct { + bool used; + bool cancelled; + char event_id[48]; + char event_type[32]; + char source_cap[32]; +} claw_event_router_pending_t; typedef struct { char channel[24]; @@ -64,6 +75,10 @@ typedef struct { size_t rule_count; claw_event_router_result_t last_result; claw_event_router_config_t config; + /* Tracks events that have been queued but not yet picked up by the + * router task. Protected by the existing recursive `mutex`. */ + claw_event_router_pending_t pending[CLAW_EVENT_ROUTER_PENDING_TABLE_SIZE]; + size_t pending_dropped; /* monotonic counter for diagnostics */ } claw_event_router_runtime_t; static claw_event_router_runtime_t s_runtime = { @@ -215,6 +230,121 @@ static void claw_event_router_unlock(void) xSemaphoreGiveRecursive(s_runtime.mutex); } +/* --- Pending-event tracking --------------------------------------------- + * The router queue holds claw_event_t copies but does not allow indexed + * access, so a parallel slot table records every event that has been + * enqueued but not yet picked up by the worker. Slots are reclaimed in + * FIFO order on dequeue or when the table is full and we have to make + * room for a fresh event (the oldest non-cancelled entry is evicted). + * + * All helpers here assume the caller already holds the runtime mutex, + * EXCEPT pending_track / pending_take_for_event_id / pending_filter, + * which acquire it themselves. + */ + +static int pending_find_slot_locked(const char *event_id) +{ + if (!event_id || !event_id[0]) { + return -1; + } + for (int i = 0; i < (int)CLAW_EVENT_ROUTER_PENDING_TABLE_SIZE; i++) { + if (s_runtime.pending[i].used && + strcmp(s_runtime.pending[i].event_id, event_id) == 0) { + return i; + } + } + return -1; +} + +static int pending_alloc_slot_locked(void) +{ + int oldest = -1; + + for (int i = 0; i < (int)CLAW_EVENT_ROUTER_PENDING_TABLE_SIZE; i++) { + if (!s_runtime.pending[i].used) { + return i; + } + } + /* Table full: evict the first non-cancelled entry. The router task + * will then process its event without a tracking record (still safe, + * just not cancellable). Cancelled entries are preserved so that the + * pending cancel still triggers when their event reaches the head. */ + for (int i = 0; i < (int)CLAW_EVENT_ROUTER_PENDING_TABLE_SIZE; i++) { + if (!s_runtime.pending[i].cancelled) { + oldest = i; + break; + } + } + if (oldest < 0) { + oldest = 0; + } + return oldest; +} + +static void pending_track(const claw_event_t *event) +{ + if (!event || !event->event_id[0]) { + return; + } + claw_event_router_lock(); + int slot = pending_find_slot_locked(event->event_id); + if (slot < 0) { + slot = pending_alloc_slot_locked(); + if (s_runtime.pending[slot].used) { + s_runtime.pending_dropped++; + ESP_LOGW(TAG, + "Pending table full, evicting %s to track %s (dropped=%u)", + s_runtime.pending[slot].event_id, + event->event_id, + (unsigned)s_runtime.pending_dropped); + } + } + memset(&s_runtime.pending[slot], 0, sizeof(s_runtime.pending[slot])); + s_runtime.pending[slot].used = true; + s_runtime.pending[slot].cancelled = false; + strlcpy(s_runtime.pending[slot].event_id, + event->event_id, sizeof(s_runtime.pending[slot].event_id)); + strlcpy(s_runtime.pending[slot].event_type, + event->event_type, sizeof(s_runtime.pending[slot].event_type)); + strlcpy(s_runtime.pending[slot].source_cap, + event->source_cap, sizeof(s_runtime.pending[slot].source_cap)); + claw_event_router_unlock(); +} + +/* Called by the router task right after dequeue. Returns true when the + * event was tagged cancelled and should be dropped. */ +static bool pending_take_for_event_id(const char *event_id) +{ + bool cancelled = false; + + if (!event_id || !event_id[0]) { + return false; + } + claw_event_router_lock(); + int slot = pending_find_slot_locked(event_id); + if (slot >= 0) { + cancelled = s_runtime.pending[slot].cancelled; + memset(&s_runtime.pending[slot], 0, sizeof(s_runtime.pending[slot])); + } + claw_event_router_unlock(); + return cancelled; +} + +static bool pending_match_filter(const claw_event_router_pending_t *entry, + const char *event_type_filter, + const char *source_cap_filter) +{ + if (event_type_filter && event_type_filter[0] && + strcmp(entry->event_type, event_type_filter) != 0) { + return false; + } + if (source_cap_filter && source_cap_filter[0] && + strcmp(entry->source_cap, source_cap_filter) != 0) { + return false; + } + return true; +} + static void claw_event_router_trim_copy(char *dst, size_t dst_size, const char *src) { const char *start = src; @@ -1902,6 +2032,14 @@ static void claw_event_router_task(void *arg) if (xQueueReceive(s_runtime.event_queue, &event, pdMS_TO_TICKS(250)) != pdTRUE) { continue; } + if (pending_take_for_event_id(event.event_id)) { + ESP_LOGI(TAG, "Skipping cancelled event %s (type=%s, source=%s)", + event.event_id, + event.event_type[0] ? event.event_type : "(none)", + event.source_cap[0] ? event.source_cap : "(none)"); + claw_event_free(&event); + continue; + } if (claw_event_router_process_event(&event, &result) != ESP_OK) { ESP_LOGW(TAG, "Failed to process event %s", event.event_id); } @@ -1945,9 +2083,18 @@ esp_err_t claw_event_router_init(const claw_event_router_config_t *config) s_runtime.cap_output_size = config->cap_output_size; } - s_runtime.event_queue = xQueueCreate( - config && config->event_queue_len ? config->event_queue_len : CLAW_EVENT_ROUTER_DEFAULT_QUEUE_LEN, - sizeof(claw_event_t)); + uint32_t queue_len = config && config->event_queue_len ? config->event_queue_len + : CLAW_EVENT_ROUTER_DEFAULT_QUEUE_LEN; + /* Pending tracking is fixed-size; if the queue can hold more in-flight + * events than the table can track, cancel requests start getting silently + * evicted. Fail fast at config time instead of at runtime. */ + if (queue_len > CLAW_EVENT_ROUTER_PENDING_TABLE_SIZE) { + ESP_LOGE(TAG, "event_queue_len=%u exceeds pending table size %u", + (unsigned)queue_len, + (unsigned)CLAW_EVENT_ROUTER_PENDING_TABLE_SIZE); + return ESP_ERR_INVALID_ARG; + } + s_runtime.event_queue = xQueueCreate(queue_len, sizeof(claw_event_t)); if (!s_runtime.event_queue) { return ESP_ERR_NO_MEM; } @@ -2052,6 +2199,68 @@ esp_err_t claw_event_router_reload(void) return ESP_OK; } +esp_err_t claw_event_router_cancel_event(const char *event_id) +{ + bool armed = false; + + if (!s_runtime.initialized) { + return ESP_ERR_INVALID_STATE; + } + if (!event_id || !event_id[0]) { + return ESP_ERR_INVALID_ARG; + } + + claw_event_router_lock(); + int slot = pending_find_slot_locked(event_id); + if (slot >= 0 && !s_runtime.pending[slot].cancelled) { + s_runtime.pending[slot].cancelled = true; + armed = true; + ESP_LOGI(TAG, "Cancel armed for queued event %s (type=%s, source=%s)", + s_runtime.pending[slot].event_id, + s_runtime.pending[slot].event_type, + s_runtime.pending[slot].source_cap); + } + claw_event_router_unlock(); + return armed ? ESP_OK : ESP_ERR_NOT_FOUND; +} + +esp_err_t claw_event_router_purge_queue(const char *event_type_filter, + const char *source_cap_filter, + size_t *out_cancelled) +{ + size_t armed = 0; + + if (!s_runtime.initialized) { + if (out_cancelled) { + *out_cancelled = 0; + } + return ESP_ERR_INVALID_STATE; + } + + claw_event_router_lock(); + for (int i = 0; i < (int)CLAW_EVENT_ROUTER_PENDING_TABLE_SIZE; i++) { + if (!s_runtime.pending[i].used || s_runtime.pending[i].cancelled) { + continue; + } + if (!pending_match_filter(&s_runtime.pending[i], + event_type_filter, source_cap_filter)) { + continue; + } + s_runtime.pending[i].cancelled = true; + armed++; + } + claw_event_router_unlock(); + + ESP_LOGI(TAG, "Purge queued events: type=%s source=%s armed=%u", + (event_type_filter && event_type_filter[0]) ? event_type_filter : "(any)", + (source_cap_filter && source_cap_filter[0]) ? source_cap_filter : "(any)", + (unsigned)armed); + if (out_cancelled) { + *out_cancelled = armed; + } + return ESP_OK; +} + esp_err_t claw_event_router_publish(const claw_event_t *event) { claw_event_t cloned = {0}; @@ -2065,7 +2274,12 @@ esp_err_t claw_event_router_publish(const claw_event_t *event) if (err != ESP_OK) { return err; } + /* Track BEFORE enqueue so the router task on the other core can never + * dequeue and process the event before we have a record of it. If + * xQueueSend fails we roll the slot back. */ + pending_track(&cloned); if (xQueueSend(s_runtime.event_queue, &cloned, pdMS_TO_TICKS(1000)) != pdTRUE) { + (void)pending_take_for_event_id(cloned.event_id); claw_event_free(&cloned); return ESP_ERR_TIMEOUT; } diff --git a/components/lua_modules/lua_module_display/src/display_hal.c b/components/lua_modules/lua_module_display/src/display_hal.c index 37059cc..d91ab00 100644 --- a/components/lua_modules/lua_module_display/src/display_hal.c +++ b/components/lua_modules/lua_module_display/src/display_hal.c @@ -156,6 +156,46 @@ esp_err_t display_hal_create(esp_lcd_panel_handle_t panel_handle, ESP_GOTO_ON_FALSE(lcd_width > 0 && lcd_height > 0, ESP_ERR_INVALID_ARG, fail, TAG, "invalid lcd size"); + /* Idempotent re-create: if a previous Lua script left the HAL fully + * initialized with identical handles and geometry, return success without + * churning resources. This is the happy path when the cap_lua + * exclusive("display") arbiter swaps scripts of the same UI stack. + * + * The "fully initialized" guard matters because the fail: epilogue below + * only rolls back the swap buffer, not the basic fields / semaphore / + * callbacks. Without this guard a partial-init failure (e.g. semaphore + * alloc failed in a previous call) would leave the basic fields matching + * and trick the next call into a no-op success while the underlying sync + * objects are missing. We therefore require every resource the HAL hands + * out to be present before declaring a no-op. */ + if (s_state.panel == panel_handle && + s_state.io == io_handle && + s_state.panel_if == panel_if && + s_state.width == lcd_width && + s_state.height == lcd_height && + s_state.display_flush_done != NULL && + s_state.display_callbacks_registered && + (!display_hal_panel_requires_swap() || s_state.submit_swap_buffer != NULL)) { + ESP_LOGD(TAG, "display_hal_create: already initialized with matching params, no-op"); + ret = ESP_OK; + goto fail; + } + + /* Defensive cleanup: if a prior session left a swap buffer behind (e.g. a + * Lua script crashed before display.deinit), free it before reallocating + * to avoid leaks that would accumulate across runs. */ + if (s_state.submit_swap_buffer) { + ESP_LOGW(TAG, "display_hal_create: freeing leftover swap buffer (%u px)", + (unsigned)s_state.submit_swap_buffer_pixels); + heap_caps_free(s_state.submit_swap_buffer); + s_state.submit_swap_buffer = NULL; + s_state.submit_swap_buffer_pixels = 0; + } + if (s_state.display_callbacks_registered) { + ESP_LOGW(TAG, "display_hal_create: clearing leftover display callbacks"); + (void)display_hal_clear_display_callbacks_locked(); + } + s_state.panel = panel_handle; s_state.io = io_handle; s_state.panel_if = panel_if; @@ -169,8 +209,6 @@ esp_err_t display_hal_create(esp_lcd_panel_handle_t panel_handle, s_state.frame_active = false; s_state.flush_in_flight = false; s_state.framebuffer_initialized = false; - s_state.submit_swap_buffer = NULL; - s_state.submit_swap_buffer_pixels = 0; if (display_hal_panel_requires_swap()) { s_state.submit_swap_buffer = heap_caps_aligned_alloc(16, (size_t)lcd_width * (size_t)lcd_height * sizeof(uint16_t), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); ESP_GOTO_ON_FALSE(s_state.submit_swap_buffer != NULL, ESP_ERR_NO_MEM, fail, TAG, "alloc submit swap buffer failed");