diff --git a/CHANGELOG.md b/CHANGELOG.md index 372f6802..03c5be34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,28 +8,31 @@ loosely while pre-1.0 (breaking changes can land on minor bumps). ## [Unreleased] ### Fixed -- **`/schedule` time math fail-closed.** `every hour` / `hourly` now use the - same overflow-checked adder as `every N hours` instead of `now + 3600`. - `mktime` failure no longer stores `next_fire_at = -1` (always due on - `list_due`); parse and `next_fire_for_recur` return 0 / an error so a - recurring recompute cannot tight-loop. -- **Remote TUI: recoverable SSE `error` is not a failed turn.** `RemoteSseTurnConsumer::finish` copied accumulated `error` event text even when the terminal `done` event had `ok: true` (e.g. catalog skip of a stored agent whose JSON failed validation). `done` is authoritative: success clears the result error; failure still prefers `done.error` and falls back to prior `error` events when that field is empty. -- **Unreadable TUI sessions are not empty.** `session_json_is_empty` no -- **Advise-gate cancel is not a bad bearer.** `POST /v1/advise/gate` -- **MCP string JSON-RPC ids.** `parse_response` now accepts a decimal-string +- **Dispatch of stored agents past newest-200.** `GET /v1/agents/:id` already + used `get_agent_record`. `/v1/orchestrate`, `/v1/agents/:id/chat`, A2A + `message/send`/`message/stream`, and the scheduler installed only + `list_agent_records(200)` (newest `updated_at`), so a targeted id that + fell off that page returned `agent not found` while GET succeeded. + Dispatch now extra-fetches the targeted id. Sibling `/agent`/`/parallel` + still resolve from the newest-200 page. - **Reconcile rollback no longer wipes the workspace on a failed restore.** - **LaTeX math recursion depth.** `latex_math_to_plain` now stops converting - **Secret key/token writes do not follow a planted dest symlink.** - **Remote `--connect` base URL query/userinfo.** `normalize_api_base_url` -- **JSON numbers require a complete fraction and exponent.** `json_parse` -- **MCP registry `env` overrides parent keys.** Subprocess spawn skipped - **Intent reconcile Phase B (JIT ΔS waves).** `POST /v1/reconcile` `mode=ensure` - **A2A unary HTTP errors stay bounded.** `Client::rpc` no longer concatenates - **Remote `--connect` DELETE/PATCH body cap.** Conversation delete and - **Intent LLM prompt text cap.** `build_llm_user_prompt` now truncates +- **Remote TUI: recoverable SSE `error` is not a failed turn.** `RemoteSseTurnConsumer::finish` copied accumulated `error` event text even when the terminal `done` event had `ok: true` (e.g. catalog skip of a stored agent whose JSON failed validation). `done` is authoritative: success clears the result error; failure still prefers `done.error` and falls back to prior `error` events when that field is empty. +- **Unreadable TUI sessions are not empty.** `session_json_is_empty` no +- **Advise-gate cancel is not a bad bearer.** `POST /v1/advise/gate` +- **MCP string JSON-RPC ids.** `parse_response` now accepts a decimal-string - **MCP registry writes do not follow a planted `.tmp` symlink.** - **`/schedule` calendar dates.** `on YYYY-MM-DD` now rejects impossible +- **MCP registry `env` overrides parent keys.** Subprocess spawn skipped - **`/fetch` uses the same SSRF hostname preflight as `/browse`.** +- **`/schedule` time math fail-closed.** `every hour` / `hourly` now use the +- **JSON numbers require a complete fraction and exponent.** `json_parse` ## [0.13.7] — 2026-09-21 diff --git a/include/api_server.h b/include/api_server.h index 80d0b95b..d81171af 100644 --- a/include/api_server.h +++ b/include/api_server.h @@ -207,12 +207,16 @@ struct ApiServerOptions { // background Scheduler. The returned Orchestrator is fully self-contained // and tenant-scoped; the caller invokes `orch->send(agent_id, prompt)` to // run one turn. On failure, returns null and populates `err_out`. +// +// `target_agent_id` is extra-fetched when it fell off the newest-200 +// catalog page so a stored agent GET /v1/agents/:id still finds can run. std::unique_ptr build_blocking_orchestrator(const ApiServerOptions& opts, TenantStore& tenants, const Tenant& tenant, std::string& err_out, - int64_t conversation_id = 0); + int64_t conversation_id = 0, + const std::string& target_agent_id = ""); // HTTP-visible conversation rows (excludes TUI-only sidebar threads). bool is_http_scoped_conversation(TenantStore& tenants, diff --git a/include/tenant_store.h b/include/tenant_store.h index 0da73aa2..fcc1a77d 100644 --- a/include/tenant_store.h +++ b/include/tenant_store.h @@ -498,6 +498,14 @@ class TenantStore { std::vector list_agent_records_for_routing( int64_t tenant_id) const; + // Newest-200 catalog plus a targeted id that fell off that page. + // GET /v1/agents/:id uses get_agent_record; orchestrate / A2A / + // scheduler used to install only the REST list page and 404 a stored + // agent GET still found. Empty / "index" skip the extra fetch. + // Sibling /agent and /parallel still resolve from the newest-200 page. + std::vector list_agent_records_for_dispatch( + int64_t tenant_id, const std::string& target_agent_id) const; + // Wholesale replace. Bumps updated_at. Returns false if the row // doesn't exist for this tenant. bool update_agent_record(int64_t tenant_id, diff --git a/src/api_server.cpp b/src/api_server.cpp index ac76e699..a201bd15 100644 --- a/src/api_server.cpp +++ b/src/api_server.cpp @@ -8088,7 +8088,8 @@ std::unique_ptr build_a2a_orchestrator(const ApiServerOptions& opts, TenantStore& tenants, const Tenant& tenant, std::string& err_out, - int64_t conversation_id = 0) { + int64_t conversation_id = 0, + const std::string& target_agent_id = "") { std::unique_ptr orch; try { orch = std::make_unique(opts.api_keys); @@ -8100,7 +8101,11 @@ build_a2a_orchestrator(const ApiServerOptions& opts, orch->client().set_circuit_breaker(opts.circuit_breaker); orch->client().set_metrics(opts.metrics); - const auto records = tenants.list_agent_records(tenant.id, /*limit=*/200); + // Newest-200 plus the targeted id when it fell off that page (GET + // /v1/agents/:id already uses get_agent_record). Sibling /agent + // and /parallel still resolve from the REST list page. + const auto records = tenants.list_agent_records_for_dispatch( + tenant.id, target_agent_id); for (const auto& rec : records) { try { auto cfg = Constitution::from_json(rec.agent_def_json); @@ -8172,7 +8177,8 @@ void handle_a2a_message_send(int fd, const std::string context_id = resolve_a2a_context_id(user_msg); std::string init_err; - auto orch = build_a2a_orchestrator(opts, tenants, tenant, init_err); + auto orch = build_a2a_orchestrator(opts, tenants, tenant, init_err, + /*conversation_id=*/0, agent_id); if (!orch) { write_a2a_rpc(fd, a2a::make_error_response( rpc_id, a2a::RPC_INTERNAL_ERROR, init_err)); @@ -8345,7 +8351,8 @@ void handle_a2a_message_stream(int fd, const std::string context_id = resolve_a2a_context_id(user_msg); std::string init_err; - auto orch = build_a2a_orchestrator(opts, tenants, tenant, init_err); + auto orch = build_a2a_orchestrator(opts, tenants, tenant, init_err, + /*conversation_id=*/0, agent_id); if (!orch) { write_a2a_rpc(fd, a2a::make_error_response( rpc_id, a2a::RPC_INTERNAL_ERROR, init_err)); @@ -9834,8 +9841,11 @@ void handle_orchestrate(int fd, const HttpRequest& req, // can resolve sibling ids during this turn. A blob whose JSON has // gone bad (schema drift after an upgrade, manual DB poke) gets // skipped with a log line — the rest of the catalog still loads. + // Extra-fetch the targeted id when it fell off the newest-200 page + // (same gap GET /v1/agents/:id does not have). { - const auto records = tenants.list_agent_records(tenant.id, /*limit=*/200); + const auto records = tenants.list_agent_records_for_dispatch( + tenant.id, agent_id); for (const auto& rec : records) { try { auto cfg = Constitution::from_json(rec.agent_def_json); @@ -10549,9 +10559,10 @@ build_blocking_orchestrator(const ApiServerOptions& opts, TenantStore& tenants, const Tenant& tenant, std::string& err_out, - int64_t conversation_id) { + int64_t conversation_id, + const std::string& target_agent_id) { return build_a2a_orchestrator(opts, tenants, tenant, err_out, - conversation_id); + conversation_id, target_agent_id); } bool is_http_scoped_conversation(TenantStore& tenants, diff --git a/src/scheduler.cpp b/src/scheduler.cpp index 66d3d40c..ea565e28 100644 --- a/src/scheduler.cpp +++ b/src/scheduler.cpp @@ -265,7 +265,8 @@ bool Scheduler::fire_task(const TenantStore::ScheduledTask& task) { std::string init_err; auto orch = build_blocking_orchestrator(*opts_, *tenants_, *tenant_opt, - init_err, task.conversation_id); + init_err, task.conversation_id, + task.agent_id); if (!orch) { const int64_t completed_at = now_epoch(); diff --git a/src/tenant_store.cpp b/src/tenant_store.cpp index de4567a1..4d70592f 100644 --- a/src/tenant_store.cpp +++ b/src/tenant_store.cpp @@ -3051,6 +3051,20 @@ TenantStore::list_agent_records_for_routing(int64_t tenant_id) const { return out; } +std::vector +TenantStore::list_agent_records_for_dispatch( + int64_t tenant_id, const std::string& target_agent_id) const { + auto out = list_agent_records(tenant_id, 200); + if (target_agent_id.empty() || target_agent_id == "index") return out; + for (const auto& rec : out) { + if (rec.agent_id == target_agent_id) return out; + } + if (auto extra = get_agent_record(tenant_id, target_agent_id)) { + out.push_back(std::move(*extra)); + } + return out; +} + bool TenantStore::update_agent_record(int64_t tenant_id, const std::string& agent_id, const std::string& name, diff --git a/tests/test_tenant_agents.cpp b/tests/test_tenant_agents.cpp index 24dc0a03..4af0d0d9 100644 --- a/tests/test_tenant_agents.cpp +++ b/tests/test_tenant_agents.cpp @@ -7,7 +7,8 @@ // DELETE /v1/agents/:id // and the per-request orchestrator's "install stored agents on every // /v1/orchestrate call so /agent and /parallel can resolve siblings by -// id" wiring. +// id" wiring — plus extra-fetch of a targeted id that fell off the +// newest-200 list page (list_agent_records_for_dispatch). // // HTTP-layer concerns (Constitution::from_json validation, agent_id // safety, "index" being reserved) are enforced inside api_server.cpp @@ -20,6 +21,7 @@ #include #include #include +#include #include namespace fs = std::filesystem; @@ -231,3 +233,58 @@ TEST_CASE("list_agent_records_for_routing returns all agents by agent_id") { CHECK(all.front().agent_id == "agent-000"); CHECK(all.back().agent_id == "agent-200"); } + +TEST_CASE("list_agent_records_for_dispatch extra-fetches a targeted id past newest-200") { + TempDb db; + TenantStore s; + s.open(db.path.string()); + const int64_t tid = make_tenant(s, "acme"); + const int64_t other = make_tenant(s, "other"); + + for (int i = 0; i < 201; ++i) { + char id[32]; + std::snprintf(id, sizeof(id), "agent-%03d", i); + REQUIRE(s.create_agent_record(tid, id, id, "r", "m", kBlobV1).has_value()); + } + REQUIRE(s.create_agent_record(other, "agent-000", "x", "r", "m", kBlobV1).has_value()); + REQUIRE(s.create_agent_record(other, "secret", "s", "r", "m", kBlobV1).has_value()); + + auto page = s.list_agent_records(tid, 200); + REQUIRE(page.size() == 200); + std::set on_page; + for (const auto& rec : page) on_page.insert(rec.agent_id); + std::string missing; + for (int i = 0; i < 201; ++i) { + char id[32]; + std::snprintf(id, sizeof(id), "agent-%03d", i); + if (!on_page.count(id)) { + missing = id; + break; + } + } + REQUIRE_FALSE(missing.empty()); + CHECK(s.get_agent_record(tid, missing).has_value()); + + auto dispatched = s.list_agent_records_for_dispatch(tid, missing); + REQUIRE(dispatched.size() == 201); + bool saw_missing = false; + for (const auto& rec : dispatched) { + if (rec.agent_id == missing) saw_missing = true; + } + CHECK(saw_missing); + + auto already_on_page = s.list_agent_records_for_dispatch(tid, page[0].agent_id); + CHECK(already_on_page.size() == 200); + int copies = 0; + for (const auto& rec : already_on_page) { + if (rec.agent_id == page[0].agent_id) ++copies; + } + CHECK(copies == 1); + + CHECK(s.list_agent_records_for_dispatch(tid, "").size() == 200); + CHECK(s.list_agent_records_for_dispatch(tid, "index").size() == 200); + CHECK(s.list_agent_records_for_dispatch(tid, "does-not-exist").size() == 200); + // Extra-fetch is tenant-scoped: another tenant's id is not appended. + CHECK(s.list_agent_records_for_dispatch(tid, "secret").size() == 200); + CHECK(s.get_agent_record(other, "secret").has_value()); +}