From 88c1c0143c7b4a182794e07ca286d624e425f626 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 11:15:10 +0000 Subject: [PATCH] fix(providers): retry the local probe after a startup miss Init marks llama-server unreachable once. The default chain puts local last, so router recovery never clears that flag and offline chat stays dead until restart. The next chat probes again before refusing. Co-authored-by: esadrianno --- src/providers/local.c | 9 +++++++ src/providers/provider.h | 2 +- tests/test_local_provider.c | 47 +++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/providers/local.c b/src/providers/local.c index 5a3f537..2510277 100644 --- a/src/providers/local.c +++ b/src/providers/local.c @@ -1,6 +1,7 @@ /** * @file local.c * @brief OpenAI-compatible Chat Completions client for llama-server; init probe marks unreachable without failing startup. + * A later chat probes again so a server that was down only at startup can still serve. */ #define _POSIX_C_SOURCE 200809L @@ -184,6 +185,14 @@ static int local_chat(const provider_message_t *messages, size_t message_count, provider_set_error(response, "Local provider not initialized"); return -1; } + /* Init probes once, often before llama-server has loaded the model. + * Default fallback order puts local last, so router recovery (which + * only probes backends before the active index) never clears the flag. */ + if (s_local_unavailable) { + const char *endpoint = config_provider_local_endpoint(s_local_cfg); + if (endpoint && endpoint[0] != '\0' && provider_local_endpoint_reachable(endpoint)) + provider_local_recovery_probe_succeeded(); + } if (s_local_unavailable) { provider_set_error(response, "Local inference server unreachable"); return -1; diff --git a/src/providers/provider.h b/src/providers/provider.h index 6b8fa1a..63141e7 100644 --- a/src/providers/provider.h +++ b/src/providers/provider.h @@ -134,7 +134,7 @@ void provider_openai_set_live_config(const config_t *cfg); const provider_t *provider_local_get(void); /** After SIGHUP reload: refresh live config reads without re-init (PRD ยง9 Q4). */ void provider_local_set_live_config(const config_t *cfg); -/** Lightweight GET probe via `/v1/models` or `/health`; used by init and router recovery. */ +/** Lightweight GET probe via `/v1/models` or `/health`; used by init, chat retry, and router recovery. */ int provider_local_endpoint_reachable(const char *endpoint); /** After a successful recovery GET probe on local; clears init-time unreachable flag. */ void provider_local_recovery_probe_succeeded(void); diff --git a/tests/test_local_provider.c b/tests/test_local_provider.c index 859f679..aafaf3b 100644 --- a/tests/test_local_provider.c +++ b/tests/test_local_provider.c @@ -75,6 +75,52 @@ static int test_init_unreachable_sets_unavailable_then_chat_errors(void) return 0; } +/** + * Init probe failure must not stick for the process lifetime. llama-server + * often is not listening yet at startup; the next chat has to probe again. + */ +static int test_chat_reprobes_when_server_comes_up(void) +{ + FILE *f = fopen(TMP_CFG, "w"); + config_t *cfg = NULL; + char errbuf[256]; + const provider_t *p; + provider_message_t msg = { .role = "user", .content = "ping", .tool_calls = NULL, + .tool_calls_count = 0, .tool_use_id = NULL }; + provider_response_t response = {0}; + ASSERT(f); + local_provider_test_reset(); + unsetenv("SHELLCLAW_FALLBACK_CHAIN"); + unsetenv("SHELLCLAW_LOCAL_ENDPOINT"); + unsetenv("SHELLCLAW_LOCAL_MODEL"); + fprintf(f, "[agent]\nmodel = \"test-model\"\n"); + fprintf(f, "[providers.local]\n"); + fprintf(f, "endpoint = \"http://127.0.0.1:1/v1/chat/completions\"\nmodel = \"tiny\"\n"); + fclose(f); + ASSERT(config_load(TMP_CFG, &cfg, errbuf, sizeof(errbuf)) == 0); + p = provider_local_get(); + ASSERT(p->init(cfg) == 0); +#ifdef SHELLCLAW_TEST + ASSERT(local_provider_is_unavailable_for_test() != 0); +#endif + local_provider_test_set_skip_probe(1); + local_provider_test_set_http_response( + "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"back online\"}}]}"); + ASSERT(p->chat(&msg, 1, NULL, 0, &response) == 0); + ASSERT(response.error == 0); + ASSERT(response.content != NULL); + ASSERT(strstr(response.content, "back online") != NULL); +#ifdef SHELLCLAW_TEST + ASSERT(local_provider_is_unavailable_for_test() == 0); +#endif + p->cleanup(); + provider_response_clear(&response); + local_provider_test_reset(); + config_free(cfg); + remove(TMP_CFG); + return 0; +} + static int test_chat_mock_http_happy_path(void) { FILE *f = fopen(TMP_CFG, "w"); @@ -113,6 +159,7 @@ int main(void) RUN(test_local_provider_vtable()); RUN(test_init_requires_config()); RUN(test_init_unreachable_sets_unavailable_then_chat_errors()); + RUN(test_chat_reprobes_when_server_comes_up()); RUN(test_chat_mock_http_happy_path()); curl_global_cleanup(); printf("test_local_provider: all tests passed\n");