fix(subagent): price the startup memory reserve at the learned cost - #13294
Conversation
|
Intent: Make the per-spawn memory reserve price a pending dedicated start at the learned per-run cost the cap is already sized from, so a burst of admissions inside the pre-sample window can no longer over-commit host memory. |
GPT 5.6 Review — ✅ human override acceptedHuman judgment by @bolichen97 overrides the GPT 5.6 finding for [GPT-OVERRIDE] da66f84 This comment is updated in place on each push. The model was not re-run because an authorized human decision supersedes it. False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of First-Principles-Verdict: PASS Verify deferred starts re-attempt from the durable queue: a stale learned p90 now holds unmeasured spawns until the 30-day horizon or a manual log reset. What this change shipsInventory (10 items) — 10 justifiedIntent: FIX — stop a subagent fan-out from OOMing the host by pricing the spawn guard's startup reserve at the learned per-run cost instead of the 0.5 GB first-boot fallback (provenance: the added gate test fails on base,
[FIRST-PRINCIPLES-REVIEWED] da66f84 |
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS A stale learned price can defer every spawn on a memory-tight host for up to 30 days, and deferred runs record no samples to correct it. WatchSelf-locking deferral: the docstring claims "over-reserving here only defers a start until the next sample," but when the learned p90 outlives its workload (the description's own "deferred runs record no new samples to correct it"), no next sample exists — every start defers until the 30-day horizon or a manual [DESIGN-REVIEWED] da66f84 |
Opus 5 Review — ✅ no blocking findingsReviewed Review detailsAn uninspectable cost-log identity takes the replace branch the adjacent comment says is "additive at most", wiping held learned prices. FINDING — src/kiro_crew/subagent_manager/monitoring.py:751 — when [OPUS-REVIEWED] da66f84 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
28d1f96 to
6ff2bdd
Compare
|
|
|
|
|
6ff2bdd to
b5306db
Compare
|
|
|
|
|
|
|
The spawn guard's `_startup_memory_reserve_gb` is the only thing that prices a dedicated start between admission and the reaper's first RSS sample (60 s), and a runtime takes tens of seconds to reach its resident size. It read the first-boot fallback `subagent_cost_gb` (0.5 GB) even when the cost store already held a learned p90 of ~6 GB -- the figure `compute_max_subagents` sizes the cap from -- so a burst of starts each cleared the raw free-memory check and then grew into the same headroom together, taking a 64 GB host from 25 GB free to 0.4 GB in under two minutes. Two prices now apply. A WARMING start (the next one, a claim awaiting registration, a dedicated worker fewer than two sweeps have measured -- one reading can land mid-growth) is priced by `_effective_next_start_gb` at the larger of the configured fallback, the learned p90 for the agent being spawned (its own history when it has one, else the heaviest known: `learned_cost_for`) and any live dedicated peak, less the RSS it already holds. A SETTLED worker owes only the gap between the larger of the configured cost and its OWN peak and its observed RSS, so a learned p90 above what that worker needed never becomes a reserve no later sample can retire. The learned figures reach the gate as `SubagentManager._learned_costs_gb` (per agent) and their max, published by the reaper sweep's off-loop `_refresh_learned_cost` (once at reaper start, then every sweep); the gate does arithmetic only and never opens the cost log on the event loop. A held figure is dropped only when the log is ABSENT (`cost_log_present`: FileNotFoundError, the operator's documented reset); a present log that yields nothing keeps it. A low-memory deferral names the effective per-start price, which figure set it (live peak / learned p90 / configured pin), and the reset path, in the log line, the SEL record and the deferral reason.
5c598a3 to
da66f84
Compare
|
|
|
|
|
|
|
|
|
|
/ai-review override gpt da66f84: The merge path is reachable only via a record the log's writer cannot emit (>128 MiB or non-UTF-8 line) or an EIO mid-read, and must then clear the configured floor and every live dedicated peak before it can under-reserve; the proposed max(previous, parsed) would freeze buckets at their historical maximum on a permanently-unreadable log. |
Human judgment recorded@bolichen97 marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
Problem / Motivation
A fan-out of dedicated sub-agents can take a 64 GB host from ~25 GB free to under 1 GB in about 90 seconds, after which the gateway's event loop stalls and the watchdog hard-exits it. Every one of those starts passed the spawn memory guard: the guard priced each pending start at the 0.5 GB first-boot fallback (
agent.subagent_cost_gb) while the host's own cost store already held a learned p90 of ~6 GB per run.Why it matters
The cap (
compute_max_subagents) is already sized from the learned cost, but the cap is a count, not a memory guard. The per-spawn reserve is the only thing that prices a start between admission and the reaper's first RSS sample 60 s later, and a runtime takes tens of seconds to reach its resident size. Under-pricing that window by ~12x lets several starts each clear a raw free-memory check and then grow into the same headroom together. That is an OOM, which is unrecoverable, on any host whose real per-run cost is well above 0.5 GB (a heavy MCP roster, a non-shared backend).What changed (motivation → approach → change)
Root cause:
spawn_impl's memory guard insubagent_manager/admission/gate.pyreadstartup_cost = memory_cfg.subagent_cost_gband never consulted the learned cost.Two prices, in
subagent.py. A WARMING start — the next one, a claim awaiting registration, a dedicated worker fewer than_RSS_SAMPLES_TO_SETTLE(2) sweeps have measured — owes_effective_next_start_gb(the larger of the configured fallback, the learned figure and every live dedicated peak) less the RSS it holds. A SETTLED worker owes only the gap between the larger of the configured cost and its OWN peak and its observed RSS, so a high learned p90 never becomes a reserve no sample can retire._startup_cost_gbsupplies the learned half asmax(configured, learned).SubagentInfo._rss_samplescounts the sweeps that measured a run (subagent_manager/monitoring.py); the cancel-recovery respawn insubagent_manager/cancellation.pyresets that count and the last reading and bumps_rss_generation, which the sweep re-checks after its off-loop/procread so a dead process's reading cannot settle its replacement (the peak stays); those fields are registered inDELIVERY_ROUTING_FIELDS.The learned figure is per cost bucket and dedicated-only.
_cost_bucket— the explicit agent, else the inherited template — is the one key the sample write and the gate's lookup share. Insubagent_cost.py, a session-shared run recordsshared: true,read_learned_costs(dedicated_only=True)leaves those per-session shares out, compaction keeps one window per(agent, shared)so shared runs cannot evict dedicated history, andlearned_cost_forprices a spawn at its own bucket only — a bucket without dedicated history answers None and the configured cost plus live peaks prices it, never another agent's figure, since on a sharing-default backend a share-eligible agent's own bucket never forms. The reserve's read leaves out samples older than 30 days (_SAMPLE_MAX_AGE_SECS), so a price learned under a removed workload expires on its own; the cap's reader applies no horizon. The log is streamed, never held whole (_iter_samples, also under compaction): a bounded deque per bucket, a parse ceiling on distinct buckets, keys over_BUCKET_KEY_CAPdropped, and the heaviest_MAX_BUCKETSreturned and held (cap_buckets). Records written before thesharedfield existed read as dedicated until their window turns over. The map reaches the gate asSubagentManager._learned_costs_gb, published off-loop by the reaper sweep; it is cleared when the log is absent; a complete read is authoritative and replaces it, so a bucket that expired or fell belowmin_samplesretires on the running gateway (read_learned_costs_checked), which also serves a replaced log (cost_log_identity: new inode or shrunk size; the identity is read on both sides of the parse and a mismatch keeps the prior state); only an incomplete read — a refused record ended the parse early, or the present log could not be opened or inspected — is merged, so an unreached bucket is not silently lowered.A low-memory deferral names the effective per-start price, its source and the reset path in the log line, the SEL record (
startup_cost_gb,learned_cost_gb) and the deferral reason; the session-memorysampledflag reads a counted sweep or a live reading, not the kept peak.docs/system-specs/modules/subagent.md,docs/system-specs/modules/adaptive-concurrency.md,src/kiro_crew/docs/dynamic-subagent-sizing.mdandsrc/kiro_crew/docs/subagents.mdstate all of this.Backwards compatibility
Compatible: no config key, API or file format changes;
_startup_memory_reserve_gb's new argument defaults to the old behaviour,read_learned_costkeeps its signature and result (no horizon; the heaviest bucket is always among the_MAX_BUCKETSreturned, so the cap's input is unchanged for any real log), and every existing pin on the reserve still holds. A host with no learned cost yet behaves exactly as before. A host with a learned cost above the fallback defers an unmeasured start to the durable queue until the reserve is satisfied and says so in the deferral.Tests
test/test_admission_gate.py::TestSpawnAdmissionGate::test_startup_reserve_prices_the_pending_start_at_the_learned_cost— through the realspawnpath, the guard'smin_gbis floor + learned cost when the manager carries one (10.0, not 4.5), floor + fallback when it carries none, and keeps an operator pin above a lower learned value. Proven: reverting only the gate line fails it with[4.5] == [10.0]....::test_a_lightweight_agent_is_priced_by_its_own_history— with{kirocrew: 9.0, light: 1.0}learned, spawninglightreserves 5.0 and an agent with no history reserves the configured 4.5, never the heavy agent's figure;...::test_an_agentless_spawn_is_priced_by_the_template_it_inherits— an agent-less spawn under an inheritedheavytemplate is priced from theheavybucket, not the default one.test/test_adaptive_startup_memory.py::test_cost_samples_are_written_under_the_bucket_the_gate_reads— samples land under the same key the gate reads: inherited template, named agent, or the default....::test_low_memory_deferral_names_the_learned_price,...::test_low_memory_deferral_names_the_configured_price_when_nothing_is_learned,...::test_low_memory_deferral_reports_the_live_peak_that_set_the_price— the SEL record carries the effectivestartup_cost_gb/learned_cost_gband the durable row'sdeferredevent names the price and its real source (learned p90, configured pin, or a 7.5 GB live peak above the learned figure).test/test_adaptive_startup_memory.py::test_startup_reserve_prices_unmeasured_starts_at_the_learned_cost— the reserve arithmetic: warming starts and once-sampled workers at the learned price less held RSS, a settled worker at its own gap only (a heavy sibling raises the next start, not the light worker's gap), an observed peak above the learned figure raising the next start, shared sessions adding nothing;::test_effective_next_start_price_folds_in_live_peaks.test/test_adaptive_startup_memory.py::test_dedicated_pricing_leaves_shared_session_shares_out,::test_compaction_keeps_dedicated_history_under_a_flood_of_shared_runs,::test_a_reset_recreated_within_one_sweep_is_read_fresh,::test_a_sweep_that_straddles_a_respawn_does_not_settle_the_new_process,::test_cost_log_identity_tells_absent_from_uninspectable— shared shares excluded from pricing and from evicting dedicated history, a re-created log read fresh, a straddling sweep discarded, and the identity probe; the refresh test also covers a partial read merging into the held map.test/test_adaptive_startup_memory.py::test_expired_samples_do_not_price_a_start,::test_an_expired_bucket_retires_on_a_long_lived_gateway,::test_the_held_map_is_bounded_against_an_agent_writable_log,::test_a_log_replaced_during_the_read_keeps_the_prior_map— the 30-day horizon (legacy records withouttsstill count) and its taking effect on a running gateway, the parse-time bounds, and a replacement landing mid-read keeping the prior map; the refresh test pins complete-read-replaces vs incomplete-read-merges.test/test_adaptive_startup_memory.py::test_startup_cost_is_the_larger_of_configured_and_learned,::test_learned_cost_for_prices_a_spawn_by_its_own_agent,::test_reaper_sweep_publishes_the_learned_cost_off_loop— the price helper, the per-agent lookup with its heaviest-known fallback, the presence probe on a failedstat, and the sweep publishing the per-bucket map while a raising read and a present-but-empty read both keep the previous values and a deleted log drops them.test/test_subagent_reap_race.py::test_recovery_respawn_is_priced_as_a_fresh_process— a respawned run starts over as warming (samples 0, last reading 0, peak kept) and the guard reserves the full learned price for it again. Proven: removing the reset fails it._startup_memory_reserve_gb(defaultnext_start_gb) is unchanged and passes.Manual verification
N/A — unit coverage sufficient: the reserve arithmetic is exercised through the real
SubagentManager.spawnpath with the memory reader stubbed, and the refresh is exercised against a real cost log.Related Issues
no linked issue: found while investigating an internal report (Kiro Crew 0.7.0.8, a sub-agent fan-out exhausting a 64 GB host followed by a gateway watchdog restart); the report lives in an internal tracker, not a GitHub issue.
Pattern harvest
Rule candidate: review-prompt
Pattern: "two guards for the same resource read the estimate from different sources" — a sizing path consults the learned store while the admission path reads the static fallback for the same quantity.
Checklist
feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert: ...)