Skip to content

Commit 6bf6e34

Browse files
committed
perf(store): evict idle adjacency layers, budget them, own memory class
The daemon keeps adjacency layers (store/graph_mem.h) resident across requests -- 237 MB for the kernel -- with no end of life but LRU pressure. - Idle eviction: a layer nobody has held for CBM_GRAPH_MEM_IDLE_EVICT_MS (10 minutes, measured from its last release) is evicted on the next request: cbm_mcp_handle_tool sweeps after releasing the request store, and every acquire sweeps too. No timer thread. Ten minutes outlasts the gaps between an agent's graph calls within one task, while a session that moved on stops pinning the memory; a wrong guess costs one rebuild (1.2-1.5 s on the 17 GB kernel index, tens of ms on ordinary repos). The clock is a test seam (cbm_graph_mem_set_clock_for_testing), so tests move time instead of waiting. - Worker budget: application_worker_memory_slice_locked hands index workers the aggregate minus the resident layer bytes (cbm_graph_mem_cached_bytes), at least one byte per active job so a slice never reads as "no cap". - Memory class: layers are charged to the new CBM_MEM_CLASS_GRAPH_CACHE ("graph_cache" in census and class logs) instead of STORE. Responses stay byte-identical to the baseline on django and redis (tools and Cypher) and on the kernel trace/hub and extra BFS/degree cases. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
1 parent 8b4d5e8 commit 6bf6e34

9 files changed

Lines changed: 299 additions & 24 deletions

File tree

‎src/daemon/application.c‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
#include "mcp/mcp.h"
2222
#include "mcp/mcp_internal.h"
2323
#include "pipeline/pipeline.h"
24+
#include "store/graph_mem.h"
2425
#include "ui/config.h"
2526
#include "watcher/watcher.h"
2627

@@ -1893,7 +1894,13 @@ static size_t application_active_job_count_locked(cbm_daemon_application_t *appl
18931894
/* Decision 2 (#1997 #832): a worker's memory slice is the aggregate budget
18941895
* divided by the jobs active at spawn time. The job being spawned is already
18951896
* in the table, so the divisor never drops below one; a zero aggregate means
1896-
* "no cap" and the worker falls back to its own RAM-fraction budget. */
1897+
* "no cap" and the worker falls back to its own RAM-fraction budget.
1898+
*
1899+
* The daemon's resident adjacency layers (store/graph_mem.h) are part of the
1900+
* same aggregate, so workers split what is left after them. They are capped at
1901+
* a quarter of the daemon budget, which the aggregate defaults to; only a
1902+
* smaller configured aggregate can be exceeded, and then every job still gets
1903+
* at least one byte, as above — a zero slice would read as "no cap". */
18971904
static size_t application_worker_memory_slice_locked(cbm_daemon_application_t *application,
18981905
size_t *active_jobs_out) {
18991906
size_t active = application_active_job_count_locked(application);
@@ -1903,7 +1910,16 @@ static size_t application_worker_memory_slice_locked(cbm_daemon_application_t *a
19031910
if (active_jobs_out) {
19041911
*active_jobs_out = active;
19051912
}
1906-
return application->aggregate_memory_budget_bytes / active;
1913+
size_t aggregate = application->aggregate_memory_budget_bytes;
1914+
if (aggregate == 0) {
1915+
return 0;
1916+
}
1917+
size_t held = cbm_graph_mem_cached_bytes();
1918+
size_t available = held < aggregate ? aggregate - held : 0;
1919+
if (available < active) {
1920+
available = active;
1921+
}
1922+
return available / active;
19071923
}
19081924

19091925
/* Compare the effective index request, not its JSON spelling. yyjson's deep

‎src/foundation/mem_core.c‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ static mem_class_stats_t g_classes[CBM_MEM_CLASS_COUNT];
6767

6868
static const char *const g_class_names[CBM_MEM_CLASS_COUNT] = {
6969
"other", "gbuf_node", "gbuf_edge", "gbuf_string", "gbuf_index", "extract", "arena",
70-
"ts_tree", "semantic", "dump", "store", "hash_table", "dyn_array",
70+
"ts_tree", "semantic", "dump", "store", "hash_table", "dyn_array", "graph_cache",
7171
};
7272

7373
const char *cbm_mem_class_name(cbm_mem_class_t cls) {

‎src/foundation/mem_core.h‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ typedef enum {
6666
CBM_MEM_CLASS_STORE, /* SQLite (bound mem methods) + store batches and row buffers */
6767
CBM_MEM_CLASS_HASH_TABLE, /* CBMHashTable buckets/entries not claimed by an owner class */
6868
CBM_MEM_CLASS_DYN_ARRAY, /* CBM_DYN_ARRAY item storage (every cbm_da_* user) */
69+
/* The daemon's in-memory adjacency layers (store/graph_mem.h): resident
70+
* across requests, so they are subtracted from index-worker budgets and
71+
* must read apart from SQLite's request-scoped memory (STORE). */
72+
CBM_MEM_CLASS_GRAPH_CACHE,
6973
CBM_MEM_CLASS_COUNT
7074
} cbm_mem_class_t;
7175

‎src/mcp/mcp.c‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ enum {
5959
#include "mcp/architecture_jobs.h"
6060
#include "mcp/test_impact.h"
6161
#include "mcp/test_impact_engine.h"
62+
#include "store/graph_mem.h"
6263
#include "store/store.h"
6364
#include "store/store_impact.h"
6465
#include <sqlite3.h>
@@ -20618,6 +20619,9 @@ char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const ch
2061820619
}
2061920620
cbm_mem_phase_mark("request.release_store");
2062020621
release_request_store(srv);
20622+
/* Adjacency layers nobody used for a while go now: the next request is
20623+
* the daemon's clock, so no timer thread is needed (store/graph_mem.h). */
20624+
cbm_graph_mem_evict_expired();
2062120625
cbm_mem_phase_mark("idle");
2062220626
/* One census per completed request, so growth can be attributed to a POOL
2062320627
* rather than inferred from a process total (#581). Emitted after the

‎src/store/graph_mem.c‎

Lines changed: 78 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ struct cbm_graph_mem {
3939
bool ready; /* built; false while its builder runs */
4040
bool detached; /* unlinked from the registry; freed at the last release */
4141
uint64_t last_use;
42-
size_t bytes; /* reserved estimate while building, actual once ready */
42+
uint64_t idle_since_ms; /* when the last reference went away */
43+
size_t bytes; /* reserved estimate while building, actual once ready */
4344

4445
/* The graph, immutable once ready. Offsets cover node ids 0..n_ids-1:
4546
* the out-edges of id u are out_nbr/out_type[out_off[u] .. out_off[u+1]). */
@@ -67,12 +68,30 @@ static sqlite3_mutex *gm_registry_mutex(void) {
6768
return sqlite3_mutex_alloc(SQLITE_MUTEX_STATIC_APP2);
6869
}
6970

71+
#ifdef CBM_ENABLE_TEST_SEAMS
72+
static uint64_t (*g_clock_seam)(void);
73+
static size_t g_cached_bytes_seam;
74+
void cbm_graph_mem_set_clock_for_testing(uint64_t (*now_ms)(void)) {
75+
g_clock_seam = now_ms;
76+
}
77+
void cbm_graph_mem_set_cached_bytes_for_testing(size_t bytes) {
78+
g_cached_bytes_seam = bytes;
79+
}
80+
static uint64_t gm_now_ms(void) {
81+
return g_clock_seam ? g_clock_seam() : cbm_now_ms();
82+
}
83+
#else
84+
static uint64_t gm_now_ms(void) {
85+
return cbm_now_ms();
86+
}
87+
#endif
88+
7089
static void *gm_alloc(size_t bytes) {
71-
return cbm_alloc(CBM_MEM_CLASS_STORE, bytes ? bytes : 1);
90+
return cbm_alloc(CBM_MEM_CLASS_GRAPH_CACHE, bytes ? bytes : 1);
7291
}
7392

7493
static void gm_free_ptr(void *p) {
75-
cbm_free(CBM_MEM_CLASS_STORE, p);
94+
cbm_free(CBM_MEM_CLASS_GRAPH_CACHE, p);
7695
}
7796

7897
static void gm_free_graph(cbm_graph_mem_t *g) {
@@ -166,6 +185,31 @@ static bool gm_make_room(size_t bytes) {
166185
return cbm_mem_charged() + bytes <= budget;
167186
}
168187

188+
/* Retire every layer no request has held for CBM_GRAPH_MEM_IDLE_EVICT_MS.
189+
* Returns the bytes given back. */
190+
static size_t gm_evict_expired_locked(uint64_t now) {
191+
size_t freed = 0;
192+
cbm_graph_mem_t *g = g_layers;
193+
while (g) {
194+
cbm_graph_mem_t *next = g->next;
195+
if (g->ready && g->refs == 0 && now >= g->idle_since_ms &&
196+
now - g->idle_since_ms >= CBM_GRAPH_MEM_IDLE_EVICT_MS) {
197+
freed += g->bytes;
198+
gm_retire(g);
199+
}
200+
g = next;
201+
}
202+
return freed;
203+
}
204+
205+
static void gm_log_evicted(size_t freed) {
206+
if (freed > 0) {
207+
char bytes[CBM_SZ_32];
208+
(void)snprintf(bytes, sizeof(bytes), "%zu", freed);
209+
cbm_log_info("graph_mem.evict", "reason", "idle", "bytes", bytes);
210+
}
211+
}
212+
169213
/* ── Build ──────────────────────────────────────────────────────── */
170214

171215
static bool gm_bounds(sqlite3 *db, int64_t *max_node, int64_t *max_edge) {
@@ -226,7 +270,7 @@ static bool gm_grow_u32(uint32_t **arr, size_t *cap, size_t need) {
226270
while (next < need) {
227271
next *= GM_GROWTH;
228272
}
229-
uint32_t *grown = cbm_realloc(CBM_MEM_CLASS_STORE, *arr, next * sizeof(uint32_t));
273+
uint32_t *grown = cbm_realloc(CBM_MEM_CLASS_GRAPH_CACHE, *arr, next * sizeof(uint32_t));
230274
if (!grown) {
231275
return false;
232276
}
@@ -264,12 +308,12 @@ static bool gm_scan_row(cbm_graph_mem_t *g, gm_scan_t *sc, sqlite3_stmt *st) {
264308
}
265309
if (g->n_edges >= sc->edge_cap) {
266310
size_t next = sc->edge_cap ? sc->edge_cap * GM_GROWTH : 1;
267-
uint32_t *nbr = cbm_realloc(CBM_MEM_CLASS_STORE, g->out_nbr, next * sizeof(uint32_t));
311+
uint32_t *nbr = cbm_realloc(CBM_MEM_CLASS_GRAPH_CACHE, g->out_nbr, next * sizeof(uint32_t));
268312
if (!nbr) {
269313
return false;
270314
}
271315
g->out_nbr = nbr;
272-
uint8_t *types = cbm_realloc(CBM_MEM_CLASS_STORE, g->out_type, next);
316+
uint8_t *types = cbm_realloc(CBM_MEM_CLASS_GRAPH_CACHE, g->out_type, next);
273317
if (!types) {
274318
return false;
275319
}
@@ -294,7 +338,7 @@ static bool gm_scan_row(cbm_graph_mem_t *g, gm_scan_t *sc, sqlite3_stmt *st) {
294338
* back. Iterating sources in id order keeps every in-list sorted by source. */
295339
static bool gm_build_inbound(cbm_graph_mem_t *g) {
296340
size_t n_ids = g->n_ids;
297-
g->in_off = cbm_calloc(CBM_MEM_CLASS_STORE, (n_ids + 1) * sizeof(uint32_t));
341+
g->in_off = cbm_calloc(CBM_MEM_CLASS_GRAPH_CACHE, (n_ids + 1) * sizeof(uint32_t));
298342
g->in_nbr = gm_alloc((size_t)g->n_edges * sizeof(uint32_t));
299343
g->in_type = gm_alloc(g->n_edges);
300344
if (!g->in_off || !g->in_nbr || !g->in_type) {
@@ -361,8 +405,8 @@ static bool gm_build(cbm_graph_mem_t *g, sqlite3 *db, int64_t max_node, int64_t
361405
/* max(edges.id) over-counts after deletions; give the slack back. */
362406
if (g->n_edges < sc.edge_cap) {
363407
size_t keep = g->n_edges ? g->n_edges : 1;
364-
uint32_t *nbr = cbm_realloc(CBM_MEM_CLASS_STORE, g->out_nbr, keep * sizeof(uint32_t));
365-
uint8_t *types = nbr ? cbm_realloc(CBM_MEM_CLASS_STORE, g->out_type, keep) : NULL;
408+
uint32_t *nbr = cbm_realloc(CBM_MEM_CLASS_GRAPH_CACHE, g->out_nbr, keep * sizeof(uint32_t));
409+
uint8_t *types = nbr ? cbm_realloc(CBM_MEM_CLASS_GRAPH_CACHE, g->out_type, keep) : NULL;
366410
if (nbr) {
367411
g->out_nbr = nbr;
368412
}
@@ -397,11 +441,11 @@ static cbm_graph_mem_t *gm_take(cbm_graph_mem_t *hit) {
397441
}
398442

399443
static cbm_graph_mem_t *gm_new_entry(const char *db_path, const char *version, size_t est) {
400-
cbm_graph_mem_t *g = cbm_calloc(CBM_MEM_CLASS_STORE, sizeof(*g));
444+
cbm_graph_mem_t *g = cbm_calloc(CBM_MEM_CLASS_GRAPH_CACHE, sizeof(*g));
401445
if (!g) {
402446
return NULL;
403447
}
404-
g->path = cbm_mem_strdup(CBM_MEM_CLASS_STORE, db_path);
448+
g->path = cbm_mem_strdup(CBM_MEM_CLASS_GRAPH_CACHE, db_path);
405449
if (!g->path) {
406450
gm_free_ptr(g);
407451
return NULL;
@@ -416,11 +460,14 @@ cbm_graph_mem_t *cbm_graph_mem_acquire(sqlite3 *db, const char *db_path, const c
416460
if (!db || !db_path || !version || !version[0] || strlen(version) >= GM_VERSION_MAX) {
417461
return NULL;
418462
}
463+
uint64_t now = gm_now_ms();
419464
sqlite3_mutex *mu = gm_registry_mutex();
420465
sqlite3_mutex_enter(mu);
466+
size_t freed = gm_evict_expired_locked(now);
421467
cbm_graph_mem_t *hit = gm_find(db_path, version);
422468
cbm_graph_mem_t *taken = hit ? gm_take(hit) : NULL;
423469
sqlite3_mutex_leave(mu);
470+
gm_log_evicted(freed);
424471
if (hit) {
425472
return taken;
426473
}
@@ -496,16 +543,29 @@ void cbm_graph_mem_release(cbm_graph_mem_t *g) {
496543
if (!g) {
497544
return;
498545
}
546+
uint64_t now = gm_now_ms();
499547
sqlite3_mutex *mu = gm_registry_mutex();
500548
sqlite3_mutex_enter(mu);
501549
g->refs--;
550+
if (g->refs == 0) {
551+
g->idle_since_ms = now;
552+
}
502553
if (g->refs == 0 && g->detached) {
503554
g_total_bytes -= g->bytes;
504555
gm_destroy(g);
505556
}
506557
sqlite3_mutex_leave(mu);
507558
}
508559

560+
void cbm_graph_mem_evict_expired(void) {
561+
uint64_t now = gm_now_ms();
562+
sqlite3_mutex *mu = gm_registry_mutex();
563+
sqlite3_mutex_enter(mu);
564+
size_t freed = gm_evict_expired_locked(now);
565+
sqlite3_mutex_leave(mu);
566+
gm_log_evicted(freed);
567+
}
568+
509569
/* ── Queries ────────────────────────────────────────────────────── */
510570

511571
cbm_graph_mem_types_t cbm_graph_mem_type_mask(const cbm_graph_mem_t *g, const char *const *types,
@@ -557,7 +617,7 @@ static size_t gm_set_home(uint32_t id, size_t mask) {
557617

558618
static bool gm_set_grow(gm_set_t *s) {
559619
size_t cap = s->cap ? s->cap * GM_GROWTH : GM_SET_MIN_CAP;
560-
uint32_t *slots = cbm_calloc(CBM_MEM_CLASS_STORE, cap * sizeof(uint32_t));
620+
uint32_t *slots = cbm_calloc(CBM_MEM_CLASS_GRAPH_CACHE, cap * sizeof(uint32_t));
561621
if (!slots) {
562622
return false;
563623
}
@@ -619,7 +679,7 @@ static bool gm_hops_push(gm_hops_t *h, uint32_t id, int hop) {
619679
if (h->count == h->cap) {
620680
size_t next = h->cap ? h->cap * GM_GROWTH : GM_SET_MIN_CAP;
621681
cbm_graph_mem_hop_t *rows =
622-
cbm_realloc(CBM_MEM_CLASS_STORE, h->rows, next * sizeof(cbm_graph_mem_hop_t));
682+
cbm_realloc(CBM_MEM_CLASS_GRAPH_CACHE, h->rows, next * sizeof(cbm_graph_mem_hop_t));
623683
if (!rows) {
624684
return false;
625685
}
@@ -742,6 +802,11 @@ int cbm_graph_mem_cached_count(void) {
742802
}
743803

744804
size_t cbm_graph_mem_cached_bytes(void) {
805+
#ifdef CBM_ENABLE_TEST_SEAMS
806+
if (g_cached_bytes_seam) {
807+
return g_cached_bytes_seam;
808+
}
809+
#endif
745810
sqlite3_mutex *mu = gm_registry_mutex();
746811
sqlite3_mutex_enter(mu);
747812
size_t bytes = g_total_bytes;

‎src/store/graph_mem.h‎

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,15 @@
1919
*
2020
* Lifetime: the daemon closes its query stores after every request, so layers
2121
* live in a process-wide registry, shared by refcount across request threads.
22-
* Every byte is allocated through the memory core (CBM_MEM_CLASS_STORE) and
23-
* the registry is bounded: the layers together stay under a quarter of the
24-
* memory budget, idle ones are evicted oldest-first to make room, and a layer
25-
* that still would not fit (or that would push the process over its budget) is
26-
* not built — the caller keeps the SQL path. A build is all-or-nothing: a
27-
* failed or refused build leaves no partial layer behind.
22+
* Every byte is allocated through the memory core (CBM_MEM_CLASS_GRAPH_CACHE)
23+
* and the registry is bounded: the layers together stay under a quarter of
24+
* the memory budget, unheld ones are evicted oldest-first to make room, and a
25+
* layer that still would not fit (or that would push the process over its
26+
* budget) is not built — the caller keeps the SQL path. A build is
27+
* all-or-nothing: a failed or refused build leaves no partial layer behind.
28+
* A layer nobody held for CBM_GRAPH_MEM_IDLE_EVICT_MS is evicted on the next
29+
* request (no timer thread), and the daemon subtracts the resident bytes from
30+
* what it hands index workers (cbm_graph_mem_cached_bytes).
2831
*/
2932
#ifndef CBM_GRAPH_MEM_H
3033
#define CBM_GRAPH_MEM_H
@@ -81,15 +84,39 @@ int cbm_graph_mem_degree(const cbm_graph_mem_t *g, int64_t id, bool inbound,
8184
/* Bytes this layer holds (all through the memory core). */
8285
size_t cbm_graph_mem_bytes(const cbm_graph_mem_t *g);
8386

87+
/* ── Idle eviction ──────────────────────────────────────────────── */
88+
89+
/* How long a layer may sit unheld before it goes. Ten minutes outlasts the
90+
* gaps between an agent's graph calls within one task (seconds to a few
91+
* minutes), so a working session keeps its layer, while a session that moved
92+
* on stops pinning it (237 MB for the kernel). A wrong guess costs one rebuild
93+
* — about 1.2-1.5 s on the 17 GB kernel index, tens of ms on ordinary repos —
94+
* against ten minutes of held memory. */
95+
enum { CBM_GRAPH_MEM_IDLE_EVICT_MS = 10 * 60 * 1000 };
96+
97+
/* Evict every layer unheld for CBM_GRAPH_MEM_IDLE_EVICT_MS. The daemon calls
98+
* it at the end of every request and acquire runs it too: a daemon that stops
99+
* asking stops paying, with no timer thread. */
100+
void cbm_graph_mem_evict_expired(void);
101+
84102
/* ── Registry introspection (tests, diagnostics) ─────────────────── */
85103

86-
/* Layers currently held by the registry, and their total bytes. */
104+
/* Layers currently held by the registry, and their total bytes — every
105+
* resident layer, held or not, which is what index-worker budgets give up. */
87106
int cbm_graph_mem_cached_count(void);
88107
size_t cbm_graph_mem_cached_bytes(void);
89108
/* Builds completed since process start: a test's proof that a call hit the
90109
* cache (unchanged) or built (incremented). */
91110
uint64_t cbm_graph_mem_build_count(void);
92-
/* Drop every idle layer (refs == 0). Held layers stay until released. */
111+
/* Drop every unheld layer (refs == 0), however recently used. */
93112
void cbm_graph_mem_drop_idle(void);
94113

114+
#ifdef CBM_ENABLE_TEST_SEAMS
115+
/* The clock idle eviction reads; NULL restores cbm_now_ms. Tests move time
116+
* instead of waiting for it. */
117+
void cbm_graph_mem_set_clock_for_testing(uint64_t (*now_ms)(void));
118+
/* Pin cbm_graph_mem_cached_bytes to `bytes`; 0 restores the live reading. */
119+
void cbm_graph_mem_set_cached_bytes_for_testing(size_t bytes);
120+
#endif
121+
95122
#endif /* CBM_GRAPH_MEM_H */

‎tests/test_daemon_application.c‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
#include "mcp/mcp.h"
2020
#include "mcp/mcp_internal.h"
2121
#include "pipeline/pipeline.h"
22+
#include "store/graph_mem.h"
2223
#include "store/store.h"
2324
#include "ui/config.h"
2425
#include "watcher/watcher.h"
@@ -5889,6 +5890,32 @@ TEST(daemon_application_worker_slice_is_aggregate_over_active_jobs) {
58895890
PASS();
58905891
}
58915892

5893+
/* The daemon's resident adjacency layers are its own memory: a worker's slice
5894+
* is the aggregate minus them, and never "no cap" (0) when they exceed it. The
5895+
* held bytes come through the graph_mem seam, so no layer has to be built. */
5896+
TEST(daemon_application_worker_slice_leaves_room_for_graph_cache) {
5897+
const size_t mib = (size_t)1024U * 1024U;
5898+
const size_t aggregate = 1000U * mib;
5899+
cbm_daemon_application_config_t config = {.aggregate_memory_budget_bytes = aggregate};
5900+
cbm_graph_mem_drop_idle();
5901+
cbm_daemon_application_t *application = cbm_daemon_application_new(&config);
5902+
size_t without_layers = cbm_daemon_application_worker_memory_budget_bytes(application);
5903+
cbm_graph_mem_set_cached_bytes_for_testing(200U * mib);
5904+
size_t with_layers = cbm_daemon_application_worker_memory_budget_bytes(application);
5905+
cbm_graph_mem_set_cached_bytes_for_testing(aggregate + 5U);
5906+
size_t layers_over_aggregate = cbm_daemon_application_worker_memory_budget_bytes(application);
5907+
cbm_graph_mem_set_cached_bytes_for_testing(0);
5908+
bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS);
5909+
cbm_daemon_application_free(application);
5910+
5911+
ASSERT_NOT_NULL(application);
5912+
ASSERT_TRUE(stopped);
5913+
ASSERT_EQ(without_layers, aggregate);
5914+
ASSERT_EQ(with_layers, aggregate - 200U * mib);
5915+
ASSERT_EQ(layers_over_aggregate, 1);
5916+
PASS();
5917+
}
5918+
58925919
/* Decision A (#1997 #832): the over-budget verdict travels as a CLEAN worker
58935920
* exit carrying an error response. The daemon passes that response through
58945921
* verbatim on the first attempt — no crash/hang recovery loop, no quarantine
@@ -6960,6 +6987,7 @@ SUITE(daemon_application) {
69606987
RUN_TEST(daemon_application_queues_explicit_index_behind_physical_job_limit);
69616988
RUN_TEST(daemon_application_default_limit_admits_four_and_rejects_fifth);
69626989
RUN_TEST(daemon_application_worker_slice_is_aggregate_over_active_jobs);
6990+
RUN_TEST(daemon_application_worker_slice_leaves_room_for_graph_cache);
69636991
RUN_TEST(daemon_application_over_budget_response_passes_through_without_recovery);
69646992
RUN_TEST(daemon_application_free_reports_retained_live_ownership);
69656993
RUN_TEST(daemon_application_tool_error_is_failed_job_even_after_clean_worker_exit);

0 commit comments

Comments
 (0)