fix: hold the session to its wall-clock budget - #1171
Conversation
61bdcdb to
62c84e9
Compare
|
Pushed two commits that extend this PR at the head end. The field data on #1146 — now two independent sessions — showed that stopping on time is necessary and not sufficient, and one of them showed that the enforcement layers here make an existing crash easier to hit.
|
|
/retest The e2e run for Verified the two new commits locally: 331 passed across |
|
Blocking items only. Everything below is verified at PR head against the code, not read off the description. 1. Layer 4 cancels the coroutine; the work keeps runningEvery benchmark executor blocks in proc = await asyncio.to_thread(
run_with_session_kill,
cmd,
env=env,
cwd=str(output_dir),
timeout=timeout_sec,Same shape at The whole cancellation suite passes because 2. Layer 3 is dropped on the Ray serving path, which is the single-node default — and the clamp then manufactures false timeouts
return serving_lease.run_session_kill(
cmd,
env=env,
cwd=cwd,
timeout=timeout_sec,
soft_deadline_sec=soft_deadline_sec,
The failure is worse than "no reaper", because the clamp still applies. After a warmup that overran, status="failed",
error=f"timeout: {exc}",
error_class="magpie_timeout",That is the "ledger full of spurious timeouts" the clamp's own docstring exists to prevent. The test pinning the clamp ( 3.
|
CI E2E report — ✅ Succeeded
|
…still merges The hook grew a progress report, so "pulse" stopped describing all of it and it was renamed to match. But the name is a merge surface: #1171 adds a call site of its own, in the branch that records a variant reaped by the session budget as skipped rather than failed. Renaming here deletes the definition that call site resolves to, in a region #1171 never touches, so git reports no conflict and the merge lands a NameError on the reaped-variant path -- found only by lint or by running it. Keep the name and say what it does in the docstring instead. Costs an imprecise word; buys a merge that cannot break silently in either order. Co-authored-by: Cursor <cursoragent@cursor.com>
|
All three blocking items are fixed, plus a fourth defect that surfaced while verifying them and would have been the worst of the set. Both coordination notes are accepted; one of them turns out to be understated. 1. Layer 4 now stops the work, not just the coroutine —
|
* fix(observability): let a long task say it is still working A composite action — an explore grid, a baseline pair, a profile and its roofline — is one task row that internally completes many units over hours. Between dispatch and return it emitted nothing durable, so a healthy 80-minute run and a wedged one left identical evidence. Field sessions read that as "orchestration silent 8097s" while the run was in fact progressing normally, and as a health probe demanding a restart of a server no phase had booted yet. Executors now report each unit through an ambient reporter the runner binds to the task, landing on the row as a heartbeat that moves ``updated_at``. Stall detection consults it and withholds the accusation while dispatched work is still moving; the server probe holds its fire when no server process is up. Emitters: per variant in the grid runner, after the baseline warmup round, and per roofline sub-step. Refs #1145, #1168 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(observability): bound and attribute the progress suppression The heartbeat's consumer suppressed too much. A single running task that had reported recently returned no symptoms at all, before the per-agent loop ever ran: one busy explore grid vouched for the Coordinator, the critic and the kernel agent alike, and a genuinely hung agent went unreported for as long as anything on the box kept moving. Nothing was logged, so the near-miss left no trace an operator could read afterwards. Freshness also came from ``MAX(updated_at)`` over running rows, which a task merely entering ``running`` advances — the suppression could fire without a single heartbeat behind it. Progress notes now name their owning agent, the probe reads them out of the task history instead of trusting a column that moves for other reasons, and the judgement moved into the per-agent branch: only an agent's own work speaks for it. Work buys time, not immunity — past ``severity_high_after_s`` the accusation fires regardless of how busy the machine looks — and a withheld accusation is downgraded to LOW with its counter-evidence attached rather than discarded, so "we would have accused X, but its work reported Ns ago" is visible in the session. Attribution is as narrow as the data supports: the ``tasks`` table has no requester column and every row is dispatched by the Coordinator's orchestration loop, so a heartbeat is stamped for that agent alone. Refs #1145 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(observability): report grid and baseline units before they block The completion reports only fire once a unit has landed, so the very failure this heartbeat targets - a first variant or a warmup round that hangs for tens of minutes - still produced no evidence at all. Announce each unit on entry, before the launch that can block, and keep the completion reports as they were. Entry reporting also covers the branches that reach neither the completion report nor a finally, so `_after_variant` is left alone rather than moved into a finally that would double-report. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(observability): route every roofline sub-step through one reporter Three of the five sub-step call sites were bare: the N26 re-analysis and both halves of the multi-node compute-bound re-profile ran silently, and each of them can take tens of minutes. Rather than paste the same report block at each site, funnel every call through a single closure next to the local imports, so a site added later cannot be the silent one. Each call site keeps its own try/except and fail-soft semantics unchanged; only the await is wrapped. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(observability): let the probe see a busy ray worker ``ray::IDLE`` is the name of a parked worker only; once a worker starts running a serving actor Ray renames it after the actor class, so ``ray::ServingActor`` matched no pattern and the process disappeared from the probe entirely. Presence still says nothing about busy-versus-hung — it only stops the probe reporting an empty machine while work is on it. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(observability): let a long step prove it is alive from its own output A start-of-unit report covers the first 300 seconds and nothing after, so the 55-minute TraceLens analysis - one await, no units - still went stale halfway through. A timer would have "fixed" it by vouching for wedged processes, which is the exact failure the stall signal exists to catch. Drive the heartbeat from the child's own output instead: the subprocess reader already walks stdout/stderr line by line, so it now tallies what it reads, and a driver on the loop reports once per interval in which that tally moved. A silent child produces no heartbeat and stays accusable. The kernel-tool child is unbuffered so its lines arrive while it works rather than in eight-kilobyte lumps. Covers the TraceLens analysis (via every kernel tool subprocess) and the profile / baseline benchmark round. The Ray-managed round runs its child in a remote worker with no channel back for the callback, and is left on the ceiling from the first commit in this series. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(observability): stop the progress note from moving updated_at ``updated_at`` marks when a task entered ``running``, and three consumers measure elapsed runtime from it: the R6 lease watchdog turned into an inactivity timeout that a heartbeating task could never trip, the ``extend_lease`` remaining-budget math read time-since-last-note as time-already-spent and handed lanes and GPUs more than the budget allowed, and the Coordinator's own "Tasks in flight" line rendered an 80-minute task as seconds old — this PR's goal running backwards in the one surface the Coordinator reads. ``running()`` gets its least-recently-updated-first meaning back for free. The write had no reader left: the probe already reads freshness from the progress notes on ``history`` rather than from the column. The docstring argued for the behaviour and is rewritten to state the invariant instead, matching the one ``extend_lease`` documents a few lines below. Removing the bump restores the pre-PR reaper behaviour rather than changing it. It does not close the pre-existing gap between the catalogue's ``lease_ttl_sec`` and observed composite runtimes (roofline 2700s vs a 3106s TraceLens step, baseline 4200s vs a 3941s warmup round plus its measured round); that needs a TTL or extension policy decision and is reported on the PR rather than papered over here. Refs #1145 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(observability): keep the variant hook's name so a sibling change still merges The hook grew a progress report, so "pulse" stopped describing all of it and it was renamed to match. But the name is a merge surface: #1171 adds a call site of its own, in the branch that records a variant reaped by the session budget as skipped rather than failed. Renaming here deletes the definition that call site resolves to, in a region #1171 never touches, so git reports no conflict and the merge lands a NameError on the reaped-variant path -- found only by lint or by running it. Keep the name and say what it does in the docstring instead. Costs an imprecise word; buys a merge that cannot break silently in either order. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(storage): roll back a transaction the caller was cancelled out of ``asyncio.CancelledError`` derives from ``BaseException``, so the ``except Exception`` guard around ``transaction()`` never saw it: a cancel landing on one of the ``to_thread`` hops after ``BEGIN IMMEDIATE`` had already run left the shared connection inside a transaction for the rest of the session, and every later registry write failed with "cannot start a transaction within a transaction". The heartbeat this branch adds is the first path that routinely cancels a coroutine mid-write, once per long subprocess step. Catch ``BaseException``, and roll back inline rather than on a worker thread — the exception being handled is usually this task's own cancellation, and an ``await`` there can be cancelled in turn. A rollback that itself fails is logged, never allowed to mask the original exception. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(observability): stop the heartbeat driver cooperatively Teardown used to hard-cancel the driver and swallow the result. Both halves were wrong. The cancel could land while the driver was inside a ``tasks`` row write, which is exactly the case that leaves the coordinator's connection wedged. The driver now polls a stop flag between ticks, so teardown asks it to finish the note it is in and only cancels if it overruns a bounded grace — a wedged sink costs the executor the grace window and nothing more. ``suppress(CancelledError)`` around the await could not tell the driver's own cancellation from the enclosing task being cancelled during teardown, so an outer cancel was absorbed and the step returned as though it had finished. Nothing catches ``CancelledError`` on that path any more. ``interval_s`` now resolves its default at call time so the timescale can be compressed under test. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(observability): report liveness from the three long paths still missing it ``_run_magpie`` is the longest single block in a session — up to a 7800s variant timeout against a 300s suppression window — and it ran the benchmark with no liveness callback, so a grid announced each variant on entry and then went dark. Same shape in the baseline's discarded multi-node warmup pass. Both now run under the same output-driven heartbeat the measured baseline round already uses. In the grid all three Magpie passes (auto-warmup, multi-node warmup, benchmark) go through one helper, so a pass added later cannot be the one that reports nothing. The Ray serving-lease branch is left as an explicit gap in both files: the round executes inside an actor in another process and only its final ``(rc, stdout, stderr)`` crosses back, so there is no local per-line event to hang a callback on. A Ray-backed round reports on entry and stays quiet until it returns. Co-authored-by: Cursor <cursoragent@cursor.com> * test(observability): bound how long a path may go unreported The suite asserted that notes are emitted, never that consecutive notes on a path stay closer together than the window a consumer waits before calling the owning agent silent — which is how a benchmark with no liveness callback survived a change whose whole purpose was to close that gap. ``ProgressCadence`` records each note against a compressed clock, so a test second stands for ten production minutes and the assertions still read in the numbers the window is configured with. Every long path now has a test that fails if its callback is dropped: the grid variant benchmark, the baseline round, the multi-node warmup pass and the kernel tool subprocess. Two existing tests could not fail for the reason they existed: ``test_run_subprocess_counts_the_lines_its_child_emits`` counted nothing, only that a callable had been passed, and now asserts one call per line the child emits; ``test_a_variant_reports_before_it_blocks`` covered the entry marker and nothing after it, so a cadence test now sits beside it. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(robustness): let the configured server patterns decide what a server is ``is_server`` was computed against the module constant while the match list came from ``config.server_process_patterns``, so a framework an operator added to the documented knob was matched as a process yet flagged as not-a-server — which silently disabled ``local_server_unreachable`` for exactly the deployment that configured it. The probe config now carries the server subset and the flag follows it. An empty process list was also doing double duty: "nothing is running" and "we could not find out". ``_sample_processes`` returns ``None`` for the second and ``SourceData`` carries ``local_processes_known``, so a missing or wedged ``ps`` no longer mutes an unrelated finding. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(kernel): default the child's buffering instead of dictating it Unbuffering a kernel tool keeps the heartbeat above it honest, but assigning PYTHONUNBUFFERED overrode an operator who had set it deliberately. The three launcher sites in multi_node/scripts already use setdefault; match them. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(robustness): keep a pathological JSON blob inside its own sub-probe _json_loads_or_none caught the decode errors but not RecursionError, so a deeply nested history blob written by another process aborted the entire probe tick instead of costing only the sub-probe that read it. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(robustness): document the withheld agent_stall as its own row The symptom table described agent_stall as medium/high only; a stall whose dispatched work is still reporting is LOW and routes to send_message (observation), which the ladder test now locks in. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(robustness): bound stall suppression by evidence freshness, not the clock The 900s ceiling on a withheld agent_stall assumed a suppressed accusation should eventually fire anyway. Measured work units break that assumption: a single warmup runs 3941s and a TraceLens pass 3106s, so every healthy long phase crossed the ceiling and alerted exactly as if nothing had reported. Suppression now lasts as long as the counter-evidence stays fresh — the tick after the work stops reporting accuses at full severity. Long waits are still visible: the withheld note itself rises from LOW to MEDIUM past the threshold, which is now operator-configurable via agent_stall_high_after_s. Co-authored-by: Cursor <cursoragent@cursor.com> * test(observability): measure the cadence on a simulated clock, not the wall The four cadence assertions compressed a 300s suppression window into 0.5s of real time and then compared a real wall-clock gap against it, so every scheduling delay in the test was multiplied by 600 and charged to the path under test. The dominant term was not the heartbeat at all but the run-up before the first note -- fixture setup, imports, the fake magpie workspace -- which on two cores under eight-way contention grew to 753/888/1035 production seconds against a 300 budget. Advance the clock only when the simulated child does a chunk of the work it stands in for, so the timeline is a pure function of the lines emitted. The run-up and the tail collapse to what they represent, the steady-state spacing stays at the production ratio, and the assertion keeps failing when a liveness callback is dropped or the tick is widened past the window. Fake the kernel tool's child with the same helper the other three use: with a real subprocess there is nothing in the test that can advance the simulated clock. The real pump's line counting keeps its own coverage in test_run_subprocess_counts_the_lines_its_child_emits. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(robustness): let a benchmark client vouch that a server was wanted The process guard read "probed successfully, saw no server process" as "no server was supposed to be running", which is true of the gap between two variants and false of a server that crashed while its own load generator was still sending requests into the closed port. That second snapshot is exactly the one worth alerting on, and it was the one being suppressed: all targets unreachable plus one is_server=False benchmark_serving process produced no symptoms at all where base emitted a HIGH local_server_unreachable. Suppress only when the probe answered, no server process is up and no benchmark client is running. The client patterns are deliberately narrower than the harness patterns the process probe matches -- the outer Magpie/InferenceX harness is up across server launch and teardown too, so it stays evidence of nothing, which is what keeps the idle-stretch guard intact. Record benchmark_client_seen next to server_process_seen so the branch that decided is legible in the alert, and give the suppressed case its own row in the symptom table, which still promised that any target down always alerts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(robustness): keep the in-flight unit a fresher sibling was hiding The snapshot kept only the newest heartbeat per agent, so with two units dispatched concurrently -- one reporting, one quiet for hours -- the quiet one left no trace at all and the withheld accusation rested entirely on the healthy sibling. Only ``running`` hinted that a second unit existed, and nothing read it. Keep both ends of the range and name the quiet one in the evidence. Not the oldest alone, and not "decline to withhold when some unit is quiet": a quiet unit is not an agent fault, and quiet units are expected today -- a Ray-backed baseline round has no local process to call a liveness callback from, so it reports on entry and then not again until it returns, which either of those readings would turn into an accusation on a healthy run. The stuck unit's own backstop is the lease watchdog. What was missing was visibility, not severity. Co-authored-by: Cursor <cursoragent@cursor.com> * test(robustness): cover the quiet fallback's admission that nothing looked Deleting local_processes_known=False from _QuietFallback left test_factory fully green, which made the one line standing between "nothing looked" and "nothing is running" the only unprotected decision in the module. Assert it through the consumer it defends -- the guard that suppresses local_server_unreachable when the process probe saw no server -- so the test says why the flag exists rather than restating the assignment. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(storage): keep the loop running while a cancelled write rolls back The rollback added for cancelled ``transaction()`` bodies took ``_sync_lock`` on the event-loop thread, and in the cancellation case it queued behind the very worker the cancel had abandoned: that worker is parked inside its own ``BEGIN IMMEDIATE``, holding the lock, for as long as another writer holds the database — up to ``PRAGMA busy_timeout = 30000``. With a 3s external writer, the whole orchestrator stopped for 2.55s, and it stopped during the shutdown or budget-exhaustion window that issued the cancel, so a stalled loop ate the cooperative-stop window it was supposed to be spending. Run the rollback on a worker thread under ``asyncio.shield``. The worker queues behind the abandoned one on ``_sync_lock``, which is the ordering the rollback already relied on, and the shield keeps it uncancellable — a bare ``await`` in a handler for this task's own cancellation is exactly how the connection stayed wedged in a transaction. Awaited, not fired and forgotten: ``_async_lock`` stays held until the connection is clean, so no later ``BEGIN IMMEDIATE`` can find a transaction still open. Anything that goes wrong with the rollback itself — a failing statement, a shut-down executor during teardown, a second cancel landing on the wait — is logged and never masks the caller's original exception. Co-authored-by: Cursor <cursoragent@cursor.com> * test(observability): pace the heartbeat tests on the driver, not the clock Four of these tests asserted a note count reached after sleeping a few tick intervals, which only holds if the loop was scheduled during that sleep. On the 2-vCPU CI runner, with two xdist workers and coverage tracing, it is not: under comparable oversubscription three of them failed in every run, because the driver had not yet taken the tick the assertion assumed. Wait for the driver's own notes instead. A note is proof the driver got that tick, so the wait is as long as the machine needs and the counts become exact rather than lower bounds. A driver that is supposed to stay silent — the one watching a child that went quiet, and the one whose step already returned — is paced by a second heartbeat kept deliberately noisy, so the window it would have reported in is intervals it actually got rather than intervals the test hoped had gone by, and the assertion becomes the ordered sequence of who spoke. Every wait is bounded so a driver that stops reporting fails in seconds. Also drop ``assert elapsed < 1.0`` from the wedged-sink test: a teardown that waited on the sink instead of cancelling it would still be inside the ``async with``, never reaching the assertion, so the measured interval was carrying no coverage that ``events == ["cancelled"]`` does not. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(storage): wait out the rollback a second cancel used to abandon ``asyncio.shield`` protects the rollback, not the wait on it. A cancel landing on that wait raised straight through the ``except BaseException`` written to keep the caller's exception, and ``transaction()`` then ran on to its ``raise`` and released ``_async_lock`` with the rollback still queued on ``_sync_lock`` behind the worker the first cancel abandoned -- fire and forget with the lock already gone, which is the shape this rollback was moved off the loop to avoid rather than to adopt. The next writer finds the connection inside a transaction nobody will close and every write in the session fails with "cannot start a transaction within a transaction". The same handler lost the cancellation outright. One cancel is enough: a ``record_progress`` body raises on its own -- a ``BEGIN IMMEDIATE`` past ``busy_timeout``, a ``history`` column that will not parse -- and the swallowed cancel left the caller with the body's exception, which ``report_progress`` drops. An action cancelled by the stop path then runs on as though nothing had happened while the dispatcher's ``gather`` waits for it. Keep the shielded future and resume the wait after every cancel, so the connection is always clean before ``_async_lock`` is released, then re-raise the cancel that arrived. Narrow the ``except`` to what a rollback can actually fail with -- a statement error, or an executor refusing work during teardown -- so control flow is no longer swallowed as though it were a database error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(observability): report a step alive on its own output, not the server's log ``grew`` means "one of the resolved logs got longer", from any writer, and feeding it to the liveness callback made the heartbeat the bare timer ``heartbeat_while_output_flows`` documents itself as never being. A child that printed nothing for 3s was reported alive 5 times by a ``server.log`` an unrelated thread appended to. In a real round that writer is the inference server, which keeps logging while the benchmark client is wedged, and both vLLM and sglang write an access line per request -- including the health probe the robustness agent issues every standalone tick against the server it is monitoring. That closes a loop where the monitor's own probe manufactures the evidence suppressing its own stall accusation, and the wall-clock ceiling that used to backstop the case is gone. Report liveness on evidence about the round instead: a generation-progress marker, which says tokens were flowing whoever logged them, or growth in the benchmark body's own redirected stderr, which is output of this very child that never reaches its pipe. Keeping the second is what stops the narrowing from silencing a working round: the scriptable and bypass paths redirect the body's stderr to ``benchmark_stderr.log``, so for a whole round the pipe is empty and that file is all there is. The stall watchdog keeps the broad "any new bytes" signal, which is the right one for a gate that kills. The scan now returns a named tuple rather than a fourth positional bool nobody could read, which is also how the throwaway ``_saw_progress`` binding at the call site goes away. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(observability): name the variant that ran in its own progress note ``_pulse_after_variant`` inferred which variant it was reporting from the tail of ``results`` rather than from the index it was handed. A stop cause that ends the batch records the round it stopped and then a not-run row for every later variant, so the tail is the last variant in the grid and the terminal note for variant 1 of 3 reads ``label='c2', index=1, total=3``. The log line one frame away is right and the ledger is right; only the progress note -- the artefact this heartbeat exists to make honest, and the one a stall signal reads -- is wrong. Locate the row by index instead, in a helper that says why the tail is not it, and take the label from the grid entry where it cannot disagree. Extracting the note also takes it out of a ``run_grid`` that is already far past the length worth reading, and leaves ``_pulse_after_variant``'s signature alone so no call site has to change. Co-authored-by: Cursor <cursoragent@cursor.com> * test(observability): bound the wedged-sink step so a regression fails, not hangs The sink sleeps for an hour, so a teardown that waited on it instead of cancelling it never leaves the ``async with`` and never reaches an assertion. With no timeout plugin in this suite that blocks until the CI job is killed -- the failure mode the grace window under test was added to prevent, which the test for it should be the last place to reproduce. Run the step under ``asyncio.wait_for``: the regression now fails in ten seconds where it previously hung indefinitely. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(observability): take the rate, not the line, as proof of tokens Narrowing the liveness callback to generation-progress markers left one writer still able to vouch for a wedged child: some vLLM builds print ``Avg generation throughput: 0.0 tokens/s`` on an idle engine, and an engine goes idle precisely when the client driving it wedges. The most likely production shape of the defect therefore survived the fix -- a server logging zeros suppresses the stall accusation its own client has earned. Read the value instead of the marker, reusing the parse the post-mortem throughput estimate already runs over the same two frameworks' lines: the third copy of that knowledge goes away, and a framework added to one parser can no longer drift from the other. A line whose rate cannot be read counts as no progress -- this evidence only ever suppresses an accusation, and one suppressed by mistake is invisible, where a missing one is visible and still answerable from the child's own redirected stderr. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(observability): end the variant a failed remote restart abandoned Every variant outcome reports a terminal note except one: a multi-node round whose server never came back recorded its row and left, so the row a stall signal reads stayed at ``started`` for the rest of the session while the variant was already over. Two behaviour-lock tests pinned that omission as though it were a rule, which is how it outlived the boundary the other thirteen paths share -- and unlike a reaped round, nothing moves the task afterwards to make the stale row harmless. Reach the boundary like every sibling does, so the note comes from ``_variant_progress_note`` and names the variant that ended rather than the tail of the batch, and the failed restart gets the same bounded robustness tick as every other variant boundary. The loop's 27 early exits were enumerated to confirm this was the only gap: the one remaining exit that reports nothing is the session-budget skip, which breaks before the variant is announced and so has no row to close. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(robustness): let the evidence, not the wait, grade a withheld stall The withheld note rose to MEDIUM once the agent had been silent past severity_high_after_s, which is the wall-clock ceiling the previous commit set out to remove, one rung lower: MEDIUM routes to alert(medium), so the measured healthy runs it cited -- a 3941s warmup, a 3106s TraceLens pass -- alerted while reporting a unit every tick. Elapsed silence now grades only the accusation. A phase whose work keeps reporting stays on the observation tier however long it runs, and the tick after the reports stop accuses at full severity, which is what "bounded by evidence freshness" was supposed to mean. The withheld case gets its own symptom name so RCA can tell a healthy long phase from an agent that really went quiet, and the evidence records the freshness window that holds the accusation back. The ladder tier a withheld note must not reach is pinned by a test of its own. Co-authored-by: Cursor <cursoragent@cursor.com> * test(robustness): visit the progress rows in both orders The shipped test inserted the quiet unit first, so the freshest note always arrived second and the branch that records the quiet end never ran: deleting it left the whole suite green. Which order the rows arrived in was also SQLite's choice, the running-task select having no ORDER BY, so the assertion rested on unspecified behaviour either way. Order the select by task_id and let the test choose which unit's note is folded in first, so both directions of the fold are covered by the same case. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(state): bound the progress notes a task's history retains Every note re-read the whole history blob, appended to it and rewrote it inside a BEGIN IMMEDIATE on the connection every other writer serialises behind, with no cap. 720 notes -- 12 hours at the 60s heartbeat -- left a blob north of 100 KB and tens of MB of cumulative row rewrites, and the robustness probe re-parses that blob for every running task on every tick. The cost scaled with session length, which is what the heartbeat exists for. Retain the newest 120 notes, two hours of trail at that cadence and longer than the longest measured work unit. Transitions are never dropped: consumers read those positionally -- the last entry for a failure class, the newest queued -> cancelled for a policy denial -- so only progress notes are retired. Measured on this branch, 720 notes: blob 103 KB -> 17 KB, cumulative rewrites 35.6 MiB -> 10.9 MiB, and flat per note from there instead of growing. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(robustness): tie a vouching benchmark client to this session The client that overrides "no server process, so none was wanted" was found in a whole-host ps snapshot, so on a shared node a co-tenant's benchmark_serving vouched that this session's port should be answering, and an idle stretch came back as a HIGH alert on somebody else's traffic. The process probe now records each matched process's working directory, which is the anchor a session already has: the harness is launched with its cwd inside the session directory and its children inherit it. A client counts as this session's when its cwd is under that directory, or when it names a path there on its command line, for the launch paths that chdir elsewhere. With no session directory configured there is nothing to compare against and the check stays host-wide as before. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(robustness): say where the stall thresholds are actually set The escalation knob was described as operator-configurable, but discover() reads a fixed list of deployment-shape variables and this is not one of them, so it is set in code like every other threshold on Config -- gpu_temp_warn_c, the disk and shm percentages, its own sibling agent_stall_timeout_s. Correct the claim rather than wire these two into discover(): none of the ~60 thresholds is environment-settable, and making the stall pair the exception would buy one deployment knob at the price of an env variable per threshold and a doc table that has to keep up. Where the boundary runs is now written down next to the thresholds and under the environment table. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(robustness): run a grid variant inside the session that owns it A benchmark client only overrides "no server process, so none was wanted" when it can be tied to this session, and the tie is the working directory the harness's whole subtree inherits. The baseline arm anchors Magpie to its own output dir for that reason; the grid runner defaulted to the system temp directory, so an explore / sweep / conc_sweep / integrate_patch client carried no anchor of its own and a server that died mid-variant read as the idle gap between two variants -- the one snapshot the vouching rule exists to catch. Grid variants are where a server most often dies mid-benchmark. Each pass now runs from output_root, the per-task workspace under the session, exactly as the baseline does. Nothing that reads a benchmark's results depended on that directory being /tmp: Magpie re-roots the server itself, and workspace selection scans the per-variant output dir while the leak harvest scans /workspace plus $INFERENCE_OPTIMIZER_LEAK_ROOTS -- never the cwd. A relative result_dir now resolves under the task workspace, which is where the baseline already resolves one and where the salvage path can find what it produced. The cwd override goes with it. No caller set it, and an override is the one knob that could let the anchor drift back out of the session. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(robustness): tie a client to the session by directory, not by prefix The cwd reading guarded the directory boundary; the command-line reading on the next line was a bare substring test. A co-tenant benchmarking out of <session>-retry -- a retry, a backup, or any sibling an operator names after ours -- contains this session's path, so it still vouched that this session's port should be answering. That is the false alarm the previous fix set out to remove, narrowed from host-wide to prefix-wide rather than eliminated, and it is reachable whenever the session directory is operator-supplied. Both readings now go through one helper that compares a path a component at a time, so the two cannot drift apart again, and the command line is read as tokens (plus the value of a key=value token, so --result-dir=<path> still counts) rather than searched for a substring, which is what makes a boundary applicable to it at all. The launch-path matrix now also holds the case that hid the missing grid anchor: a client with neither cwd nor command line under the session is indistinguishable from a co-tenant's and must not vouch. Co-authored-by: Cursor <cursoragent@cursor.com> * test(robustness): hold both spellings of --result-dir to the session boundary Co-authored-by: Cursor <cursoragent@cursor.com> * docs(grid): say why the session-rooted cwd is created up front Co-authored-by: Cursor <cursoragent@cursor.com> * Make the heartbeat cancel tests name the exception they wait for CodeQL read `await cancelled_task` inside pytest.raises as a statement with no effect, and the log assertions after a raise it did not model as unreachable. Catch CancelledError and ValueError explicitly so the control flow is the test. Co-authored-by: Cursor <cursoragent@cursor.com> * Satisfy CodeQL on the heartbeat cancel tests without empty excepts The previous try/except/else left an unreachable else, and a bare `except CancelledError: pass` is an empty handler. One helper now documents the success path, and the rollback-failure test goes back to pytest.raises with the log assertions inside that block. Co-authored-by: Cursor <cursoragent@cursor.com> * Stop CodeQL treating the rollback-failure asserts as dead pytest.raises is not modelled as catching the raise, so the log assertions after it were unreachable. Catch ValueError by assignment instead, with no else-clause and no context manager for CodeQL to misread. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
An action was admitted on its own merits and only met the wall clock once it was already running, so a 60-minute explore could start with 20 minutes left and be reaped half-done -- spending the budget and yielding no measurement. Worse, the reaped attempt landed in the failure ledgers, teaching the KB that the action fails when all that happened was the run ran out of time. Gate it before a task row exists, so nothing records a refusal as a failure. Admission judges on expected cost (p50) rather than the p75 tail: judging on the tail abandons minutes that would have been used more than half the time, and the overruns are what the later defences are for. The closing actions stay startable on an empty budget -- refusing those would strand the session with nothing to show. ``SharedState.session_budget_usable_sec`` becomes the one number admission and the grid deadline both read, so the two cannot disagree about how much budget is left. A queued task re-runs the gate immediately before dispatch, since a task can wait for a busy lane long enough for the budget to drain underneath it. Co-authored-by: Cursor <cursoragent@cursor.com>
``_run_magpie`` returns ``AGENTX_PREFLIGHT_RETURNCODE`` when the execution boundary fails preflight and, thirty lines on, whatever the serving lease's actor returned -- including ``_ACTOR_TIMEOUT_RC``. Both were -912, and both reach their consumer as a bare returncode carrying no other tag, so a hung Ray actor was recorded as a missing aiperf: an infrastructure timeout filed under a setup mistake, in the ledgers the next run reads. Move the Ray code rather than the AgentX one. Downstream and every record written so far already read -912 as the preflight failure, so moving that side would reinterpret history a second time. The space is shared by two modules that were coordinating through a comment, which is how the overlap survived. Say so where the codes are declared, and name the guard test that now enumerates both modules and fails on reuse. Co-authored-by: Cursor <cursoragent@cursor.com>
Two holes let one action outlast the whole run. Action timeouts were derived from the action alone, so explore could be granted four hours inside a three-hour session; and once a subprocess was running, the only clocks watching it were the soft deadline -- which retires at eval start, by design, because it judges whether a variant is abnormally slow -- and the hard cap, which was the unclamped grant. An accuracy eval starting a minute before the session ends therefore ran to completion. Clamp the grants to the budget that is left, and give ``run_with_session_kill`` a session deadline on its own channel. The separate channel is the point: the eval-start boundary is meaningful for "is this variant abnormally slow" and meaningless for "is the run out of time", so the session deadline is checked in every phase and never suspended. It reaps the tree with a returncode of its own rather than reusing the overtime kill, which would have taught the KB that a variant is slow whenever a session happened to end during it. The stack rebench never consulted the budget at all -- the same hole, second instance -- and a budget skip was filed as a failure with no cause attached. Both now carry the session_time_exhausted attribution. Co-authored-by: Cursor <cursoragent@cursor.com>
The earlier defences all act before or around a running action: admission refuses what cannot fit, the clamp bounds the grant, and the subprocess reaper stops the child trees that were handed a session deadline. Nothing reached an action already under way whose executor takes no deadline, or that is not spending its time in a subprocess at all -- and nothing reached any of them on SIGTERM, which only set the stop event. Since the pump does not return until everything it dispatched has finished, shutdown latency was the remaining runtime of the longest action, and teardown then closed the database out from under work still using it. The handles were the obstacle: they lived only in the pump's frame, so the sole caller able to cancel an action was the frame already blocked awaiting it. Keep them on the dispatcher instead, retiring each as its action ends, and the budget guard, the stop event and ``Coordinator.stop`` can all reach them. Two details worth naming. A spent budget spares the closing actions, because the reserve it trips on is held back precisely so they can run, and it does not stop the queue scan -- that scan is what cancels the rows it can no longer fit. And the runner now lands a cancelled task on a terminal state before re-raising: ``CancelledError`` is not an ``Exception``, so it skipped the handler that records failures, and the row would sit at ``running`` holding its lanes and reading as live work to every phase gate until the TTL sweep. Co-authored-by: Cursor <cursoragent@cursor.com>
``run_action_now`` hands the coroutine to the coordinator loop, waits out the inline timeout, and then abandons the future -- the action keeps running by design, so the agent's turn is not held hostage by it. But abandoning the future abandoned the only handle on it, so nothing could stop it afterwards: not the spent budget, not shutdown, and not the teardown that closes the database it is still using. The blast radius was bounded (the inline whitelist admits only lane-light actions, and admission still gates them) but the task row would sit at ``running`` until the TTL sweep, and the run kept spending wall clock that was already accounted for elsewhere. Publish the coroutine's own task while the action runs, the same way the dispatched path does, and the existing cancellation reaches it unchanged. The sync bridge now names cancellation before its generic handler. It was already caught there -- but reported as ``errored``, which reads as a fault in the action rather than a deliberate stop. Both cancellation classes are caught because whether ``concurrent.futures.CancelledError`` and ``asyncio.CancelledError`` are the same class varies by Python version, and on the versions where they are, it is a ``BaseException`` that would escape the bridge and take the agent's turn down with it. Co-authored-by: Cursor <cursoragent@cursor.com>
CodeQL read ``TIME_BUDGET_EXEMPT_ACTIONS`` as unused. It is not -- the dispatcher imports it for both the admission gate and the in-flight carve-out -- but the module publishes an ``__all__`` that did not list it, so the interface the module declares disagreed with the one it actually has, and a reader (human or tool) that trusts the declaration concludes nobody uses it. List both new names. The test constant ``_EXPENSIVE_COST_MIN`` was genuinely unused: the assertion it was written for spelled the cost out as a literal instead. Use it, so the documented cost and the asserted message cannot drift apart. Co-authored-by: Cursor <cursoragent@cursor.com>
CLOSE reached for a report task by asking "was one enqueued?" when it needed
"is one runnable?". Both reuse paths -- the ``closing_report_task_id`` the
wall-clock deadline path writes, and ``create_or_return_existing`` -- read
``task.state`` only to log it, so a cancelled row came straight back and
``run_task``'s ``queued -> running`` failed the registry's terminal check:
IllegalTransition: cannot transition ... from 'cancelled' to 'running'
The session that hits this is the one whose report matters most, and it lost
``final.md`` entirely (#1167).
This lands ahead of the enforcement layers on purpose. Cancelling in-flight
work at the deadline is the point of those layers, and it widens the set of
ways a report task can be sitting in ``cancelled`` when CLOSE arrives -- so
without the guard the crash gets easier to hit, not harder.
A dead row now falls through to a fresh enqueue under a suffixed idempotency
key, shared by the report and session_breakdown steps rather than written
twice, and ``_run_close_task`` reports a terminal row instead of running it
so no path can hand one to ``run_task`` again.
Also adds the test that would have caught the AttributeError a field run hit
on ``_record_explore_variant_failures``: every ``_DELEGATED`` entry must
resolve on its collaborator. It found two stale entries
(``_resolve_issue_canonical``, ``_wait_for_task_terminal``), both naming
methods that no longer exist anywhere; removed.
Co-authored-by: Cursor <cursoragent@cursor.com>
PRELUDE's budget share is 3%, and nothing enforced it: exit_normal_prelude tests whether a baseline landed and nothing else, so the phase ran to whatever its contents happened to cost. Two sessions on unrelated models -- different quantization, different PRELUDE composition, one baseline-dominated and one split with a TraceLens roofline -- spent 73.8% and 72.8% of a three-hour budget in it, and both reached FRAMEWORK_AGENT with ~47 minutes against its 108-minute entry threshold. Landing on time would not have saved either run: they were unrecoverable the moment PRELUDE ended. So PRELUDE now decides before it spends. ``prelude_can_afford`` bounds an optional arm by the tighter of its own share and what the optimization phases have reserved, and two arms consult it: - the initial roofline/profile, which in one run took 81 minutes -- 45% of the session -- and was already known-degraded at second zero because the vLLM version patch had failed; - the baseline's measured round, which cost the other run 59 minutes to move the anchor 0.2%, and whose absence leaves the single-round baseline the codebase already supports rather than a new half-measured state. Both estimates come from what this session measured, not from the action catalog: the catalog's p50s (baseline 5 min, roofline 8 min) are calibrated on small models, and a guard reading them would have admitted both arms. ``time_exhausted_during_prelude`` finally gets a producer. It was in the terminal-reason vocabulary and in the report's glossary, but nothing ever assigned it -- the state machine had a word for this failure and no way to reach it. And PRELUDE's normal exit now states whether the budget it leaves behind still funds one benchmark round, which is the plain sentence neither field session ever got. Extracts ``append_phase_evidence_row`` so the dropped-arm record and the CLOSE step record share one definition of "append to the current phase record". Co-authored-by: Cursor <cursoragent@cursor.com>
On a single node the Ray execution backend is the production default, and the only reason every test on this branch exercised the local subprocess path is the pytest guard inside ``_should_use_ray_backend``. So the session reaper -- the defence that makes a run out of time say so -- was absent exactly where the work actually runs. Handing the reaper's ``session_deadline_sec`` straight to the actor would have been worse than omitting it: it is a ``time.monotonic()`` instant, and the actor is another process whose clock counts from its own unrelated origin, so the number names an arbitrary moment there -- an immediate kill or one that never fires, with nothing to distinguish the two. The deadline therefore crosses as the seconds it has left and is re-anchored on the far side, with a name that cannot be mistaken for the absolute form. Without it the hard timeout is the last line, and it is deliberately set slightly past the session deadline so the watchdog trips first; whichever one fires writes the ledger's account of why the run stopped. On the Ray path the cap won that race and the ledger blamed the variant for the session running out of time, which is the attribution this branch exists to correct. The accuracy eval made it worse: ``soft_deadline_sec`` retires at eval start by design, so an eval starting near the end had nothing bounding it but that same misattributing cap. baseline.py is left alone. It has neither defence -- no clamp on its timeout and no deadline on its subprocess -- and it also has no branch for the session sentinel, so plumbing one in would only turn running out of time into an unclassified crash. That needs its own design, not a keyword argument. Co-authored-by: Cursor <cursoragent@cursor.com>
… find out The post-warmup gate keeps the session honest but not thrifty: it can only ask its question once the warmup has already paid for the boot, the compile and the graph capture. On a first baseline that is unavoidable -- there is nothing to predict from, so a gate in front would either refuse every one of them or wave every one through. From the second round on the figures exist, and so does an anchor, which is what makes refusing free: the session keeps the number it already had instead of spending a cold pass to be told it cannot keep the new one. So the round now faces a gate before the lease is taken, judged on a lower bound built only from measurements rather than on an estimate, which is what makes refusing on it sound: if the part the session can prove does not fit, the whole round does not. The two passes are added separately because they buy different things, and a session can hold the cold figure without the hot one -- a round whose measured pass was dropped for budget promotes its cold number and has no hot number to write. That session is the one most in need of the gate, so its cold pass is still priced and only the unpriceable pass is left to the gate after the warmup. A refusal is the run stopping work, not the model failing a measurement, so it carries ``session_time_exhausted`` and no returncode, and it finally gives ``StoppedByTheRun.never_started`` the consumer it was written for -- until now every stop went through the ``interrupted`` wording, including the ones that had not started. Seven cases pin it, and each of five mutations -- the gate waved through, the double run priced on one pass, the gate going inert on a half-measured session, and the two result-shape slips -- is caught by exactly one of them. Co-authored-by: Cursor <cursoragent@cursor.com>
…th one safe use The gate in front of a round called its figure a lower bound and justified refusing on it as sound. It is neither. The figure is an earlier round's full wall-clock, and that round paid a one-time JIT compile on a cold kernel cache which a later pass on the same signature does not pay again -- the executor's own cache probe is built on exactly that difference and picks a cap 20 minutes shorter when it finds the cache warm. So the figure over-predicts, and a gate built on it refuses rounds that would have fitted. Which is tolerable here, but only here, and for a reason worth writing down: the two outcomes are not symmetric. Igniting a round that cannot finish costs a boot, a compile and a capture for a number that must then be marked cold. Refusing one that would have fitted costs a fresh anchor the session did not need, because the anchor it measured earlier still stands and every later comparison is made against that one. A caller that would end a session on this figure would be trading the rest of the run against a systematic over-prediction, and the docstring now says so rather than leaving the next reader to borrow the justification. Renamed accordingly, and the survival property the argument rests on is now asserted instead of assumed: a refused round comes back with no throughput and no warning, so there is nothing on it that promotion could put over the anchor. Co-authored-by: Cursor <cursoragent@cursor.com>
… cold anchor Two paths end a round after its warmup has already paid for the boot, the compile and the capture, and both owe the same answer: keep the number, mark it cold. Only one of them exists today. Naming it now means the second arrives as a call rather than as a copy of these four lines. Co-authored-by: Cursor <cursoragent@cursor.com>
…pent benchmarking A round's wall-clock is two things bought by different spenders. Every explore variant boots its own server, so a variant costs the sum; a pass that re-attaches to a server already up costs only the benchmark. Reported as one number, the total is the only price available, and pricing a re-attaching pass at it refuses work that fits comfortably. The instant is recorded where it is already known -- the gate loop that latches the ready marker for the from-ready soft deadline -- into a stamp beside the round's server.log. A file rather than a wider return value because the reader is not always the writer: on the Ray path the round runs inside an actor, whose monotonic clock has its own origin and whose return would have to be widened through every degraded branch to carry this. The caller already reads that same directory's server.log to classify server deaths, so the channel is not new. A stamp older than the round it is read for is reported as unknown rather than clamped, which would price a cold round as though it had never booted. Co-authored-by: Cursor <cursoragent@cursor.com>
…ther it is a cold figure The round's wall-clock alone cannot price later work. What a variant will spend is a boot and a benchmark, and only the difference between the round's total and the part of it that ran after the server was ready says what the boot costs. Both figures are kept, because subtracting a stale one from a fresh total would report a boot that never happened; the split is cleared the same way the warm figure is when a later baseline does not carry one. The marker is the other half. A baseline that had to keep its cold figure has a denominator inflated by the boot, the first request's kernel compile and the graph capture, and every variant read against it looks like an improvement over a baseline that was never the baseline. That is a decision for the session, not the round, so it is carried on the session -- and cleared by the next baseline that does land a hot figure, so a leg resumed with a fresh clock is not held to the earlier one's shortfall. Co-authored-by: Cursor <cursoragent@cursor.com>
…g can use the result Every budget gate priced its work at the baseline's whole cold round, which is a boot plus a benchmark that also paid the first request's kernel compile. A variant does not pay that compile and a re-attaching pass does not pay the boot at all, so the session refused work that fitted: with a 900s cold round it declined variants it had 750s of work left for. The pricing now names the parts -- boot, benchmark, one further measured variant -- and every caller reads the same three functions, so the executor cannot refuse a round the phase machine had just called affordable. What the tighter numbers make safe is the harder rule they carry. A PRELUDE baseline is not a result; it is the denominator later results are read against and the anchor their overtime kill uses, so the clock must cover the round and one variant to follow it. Refusing before ignition costs nothing, since the anchor the session already has still stands. Refusing after the cold pass keeps that pass as a marked cold anchor -- its GPU time is spent either way -- and a run's clock that takes the hot pass mid-flight lands in the same place, rather than throwing away a pass that ran to completion. A cold anchor is then not a finished preparation: the session either measures a comparable baseline or stops with the figure marked, instead of optimizing against a denominator that was never the baseline. A re-baseline in a later phase is asked the narrower question. Its measurement is the deliverable, so requiring a successor would refuse the round that validates the run's own answer at the point in the budget where it is most likely to be the last thing left. Co-authored-by: Cursor <cursoragent@cursor.com>
…an be spent CLOSE is the machine's only terminal phase, and a resumed leg loaded whatever phase was persisted. Loading CLOSE meant staying there: the machine has no transition out, and the run loop stops on a stop_reason rather than on the phase, so the leg ticked to the end in a phase admitting only report, session_breakdown and recover. Every stop taken on the promise of "resume with more budget" was answered by a leg that spent the budget on none of the work it was resumed for. Reopened at PRELUDE, the phase that works out where a session belongs: it exits on its first evaluation when the anchor the run needs is already measured, and measures one when it is not. A session that still cannot fund the work is not held open by this -- the PRELUDE exits price the new clock and route it back, this time against the budget it actually has. The earlier leg's close_sequence_done goes with it. The flag means "the sequencer already wrote the breakdown", and carried into a leg that then never reaches CLOSE it silences the safety net that would have written one. Co-authored-by: Cursor <cursoragent@cursor.com>
…d let a hot pass correct a cold anchor Findings from an adversarial read of the cost-based gates. A cold anchor holds PRELUDE open until a hot pass replaces it, and the rule that keeps a later, lower re-baseline from displacing the anchor rejected exactly that replacement -- so a retry that did land a hot pass, but measured lower because the "cold" warmup was not really cold, left the marker set and the session re-measured whole baseline rounds until the clock killed it. A hot figure over a marked cold one is a correction, not a regression: the two are not comparable, which is what the marker says. The round price was rebuilt as boot-plus-hot-benchmark, dropping the compile the cold pass pays and under-pricing the round by that much, while the gate after the cold pass priced the same second pass at the post-ready segment. A band of budgets was therefore admitted before ignition and refused for certain afterwards, at the cost of a whole cold pass. Both now read what the session measured: the round's first pass whole, and the hot pass from a hot pass. Reading the measured total also gives a price to rounds with no boot boundary to reconstruct from. Multi-node had lost both gates -- the shared pre-warmup refusal was deleted and neither replacement reached it -- so a pair of passes launched on a budget for one and the measured pass was reaped, leaving no anchor. It is gated again, priced as the two passes it is. Also: a prior attempt's nested workspace could latch this round's ready marker and report a fifteen-minute boot as zero; the ready timestamp was tied to an unrelated stall watchdog whose knob silently withdrew it; a missing timestamp made the post-warmup gate demand two whole cold rounds at the one site where refusing ends the session; and the exit glossary still described a share mechanism this branch removed. Co-authored-by: Cursor <cursoragent@cursor.com>
…or be re-measured Two holes in the cold-anchor path, both on the resume leg it exists for. The boot was derived by subtracting the ready stamp's wall-clock instant from the caller's own. On the Ray path those are two clocks, possibly on two hosts, and the difference between them was charged to the boot -- inflating it, and making the budget gates refuse rounds that fit. The stamp now carries the boot as a duration measured end to end inside the process that spawned the child, and keeps the instant only to tell this round's stamp from an earlier one's. A stamp missing the duration reads as no stamp at all: zero is a legitimate boot, so it cannot also stand for "not recorded". The singleton rule refuses a repeat baseline because it "re-measures a reference the run already has". A marked cold anchor is the case where the run does not have one, and it is a positive tput, so the rule refused the single round that clears the mark. A session resumed on a fresh clock therefore reopened at PRELUDE, declined to finish while the mark was set, declined to close while the clock was healthy, and had no admissible way forward. The rule now exempts a marked anchor -- after the authoring-round check, which is a reason to wait whatever the anchor says. Co-authored-by: Cursor <cursoragent@cursor.com>
The stamp's two fields answer two questions, and only one of them has a safe default. A caller that omitted the boot would write a well-formed stamp claiming the round came up instantly, which prices the whole round as benchmark -- the one wrong reading the two-field format was added to make impossible. Dropping the default turns that into a call-site error. Co-authored-by: Cursor <cursoragent@cursor.com>
A multi-node variant runs two client passes, so reserving one leaves a pass unfunded. Teaching the phase machine's pricing what shape the cluster is costs more than the gap, which needs an enablement round holding PRELUDE open past an anchor to reach at all. Noted where the fallback is, so it reads as a bound rather than an oversight. Co-authored-by: Cursor <cursoragent@cursor.com>
Two live session_budget_usable_sec reads differ by microseconds, which failed exact equality on py3.11 shard 4. Freeze elapsed time instead. Also drop an unused started_unix and close the ServingLease fixture with `with`, which is how the class is meant to be released. Co-authored-by: Cursor <cursoragent@cursor.com>
Recover is not a closing action: it takes the server-lifecycle lane and can hold CLOSE open past the wall clock, which is the overrun this branch exists to stop. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…ll close. Co-authored-by: Cursor <cursoragent@cursor.com>
…reactor turn on a spent budget. Co-authored-by: Cursor <cursoragent@cursor.com>
_run_reported_round from main and _mn_warmup_pass from this branch were left on one line, which is a SyntaxError.
Admission already refused the sweep, but last_conc_sweep stayed empty, so the LLM's skip_to_close was labelled robustness_escalated and CI exited 1 on a successful run.
5cec95b to
57c7b66
Compare
…ed NamedTuple import.
…lore-opt-17 ~130 commits, dominated by the cooperative-cancellation and session-clock work. It reaches the same conclusions this branch did from the other side -- "a round the run stopped is not a baseline that failed", "a variant the run reaped is not a variant that failed" -- so most of it merged clean. Five conflicts. Reversed here, deliberately: the EXPLORE hours force-exit is back fdadb65 removed DEFAULT_EXPLORE_FORCE_EXIT_HOURS_REMAINING because any run of 3h or less force-exited EXPLORE on its first tick, and argued the gate was redundant once charge-back reserves the later phases' share. 07e23b9 found the same defect and fixed it instead of removing it: _explore_hours_leavebehind_ applies disables the gate when the leave-behind is not strictly smaller than the session, with disabled_leavebehind_covers_session in the evidence and 56 lines of new tests. Main's version is taken. It closes the failure this branch removed it for, it belongs to a 130-commit effort by the owner of that machinery, and the removal rested on redundancy rather than incorrectness -- so keeping it costs a redundant check, while dropping it would delete another team's tested work mid-merge. should_force_exit_explore and exit_normal_explore carry the parameter again; a non-positive threshold still disables the gate. The other two fixes in fdadb65 are untouched: normalize_budget_pct still keeps an explicit 0.0, and the FRAMEWORK force-exit stays removed. Other conflicts: - _grid_runner __all__ takes main's three new stop-attribution exports; MULTI_NODE_DEFAULT_KEEP_THRESHOLD_PCT is not among them because 27ad677 deleted the constant, and exporting it would break the module interface. - request_handlers keeps main's vendor-playbook deploy-blocked guard and reads the stable ledger through _entry_by_kernel_id rather than the ordinal kernel_opt_attempts dict (2cb82ad). - machine_state also takes main's SWEEP closeout helper, so skip_to_close no longer maps a refused conc_sweep onto robustness_escalated. - test_phase_force_exit takes main's cases; one had spliced this branch's variable name onto main's new assertions. - CHANGELOG keeps both sides' entries. Also drops a duplicate _update_cumulative_gain_validated key this branch had left in the Coordinator delegation table (same value, so inert, but ruff F601). Not addressed here, and worth an issue against #1171: integrate_patch does not consult stop_attribution on either side of this merge, so a session-budget stop during the GEMM E2E validator files its remaining candidates as REVERT -- a verdict about tuners that were never measured. Extending that subsystem into integrate is the author's call, not a merge resolution. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(observability): let a long task say it is still working A composite action — an explore grid, a baseline pair, a profile and its roofline — is one task row that internally completes many units over hours. Between dispatch and return it emitted nothing durable, so a healthy 80-minute run and a wedged one left identical evidence. Field sessions read that as "orchestration silent 8097s" while the run was in fact progressing normally, and as a health probe demanding a restart of a server no phase had booted yet. Executors now report each unit through an ambient reporter the runner binds to the task, landing on the row as a heartbeat that moves ``updated_at``. Stall detection consults it and withholds the accusation while dispatched work is still moving; the server probe holds its fire when no server process is up. Emitters: per variant in the grid runner, after the baseline warmup round, and per roofline sub-step. Refs #1145, #1168 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(observability): bound and attribute the progress suppression The heartbeat's consumer suppressed too much. A single running task that had reported recently returned no symptoms at all, before the per-agent loop ever ran: one busy explore grid vouched for the Coordinator, the critic and the kernel agent alike, and a genuinely hung agent went unreported for as long as anything on the box kept moving. Nothing was logged, so the near-miss left no trace an operator could read afterwards. Freshness also came from ``MAX(updated_at)`` over running rows, which a task merely entering ``running`` advances — the suppression could fire without a single heartbeat behind it. Progress notes now name their owning agent, the probe reads them out of the task history instead of trusting a column that moves for other reasons, and the judgement moved into the per-agent branch: only an agent's own work speaks for it. Work buys time, not immunity — past ``severity_high_after_s`` the accusation fires regardless of how busy the machine looks — and a withheld accusation is downgraded to LOW with its counter-evidence attached rather than discarded, so "we would have accused X, but its work reported Ns ago" is visible in the session. Attribution is as narrow as the data supports: the ``tasks`` table has no requester column and every row is dispatched by the Coordinator's orchestration loop, so a heartbeat is stamped for that agent alone. Refs #1145 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(observability): report grid and baseline units before they block The completion reports only fire once a unit has landed, so the very failure this heartbeat targets - a first variant or a warmup round that hangs for tens of minutes - still produced no evidence at all. Announce each unit on entry, before the launch that can block, and keep the completion reports as they were. Entry reporting also covers the branches that reach neither the completion report nor a finally, so `_after_variant` is left alone rather than moved into a finally that would double-report. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(observability): route every roofline sub-step through one reporter Three of the five sub-step call sites were bare: the N26 re-analysis and both halves of the multi-node compute-bound re-profile ran silently, and each of them can take tens of minutes. Rather than paste the same report block at each site, funnel every call through a single closure next to the local imports, so a site added later cannot be the silent one. Each call site keeps its own try/except and fail-soft semantics unchanged; only the await is wrapped. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(observability): let the probe see a busy ray worker ``ray::IDLE`` is the name of a parked worker only; once a worker starts running a serving actor Ray renames it after the actor class, so ``ray::ServingActor`` matched no pattern and the process disappeared from the probe entirely. Presence still says nothing about busy-versus-hung — it only stops the probe reporting an empty machine while work is on it. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(observability): let a long step prove it is alive from its own output A start-of-unit report covers the first 300 seconds and nothing after, so the 55-minute TraceLens analysis - one await, no units - still went stale halfway through. A timer would have "fixed" it by vouching for wedged processes, which is the exact failure the stall signal exists to catch. Drive the heartbeat from the child's own output instead: the subprocess reader already walks stdout/stderr line by line, so it now tallies what it reads, and a driver on the loop reports once per interval in which that tally moved. A silent child produces no heartbeat and stays accusable. The kernel-tool child is unbuffered so its lines arrive while it works rather than in eight-kilobyte lumps. Covers the TraceLens analysis (via every kernel tool subprocess) and the profile / baseline benchmark round. The Ray-managed round runs its child in a remote worker with no channel back for the callback, and is left on the ceiling from the first commit in this series. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(observability): stop the progress note from moving updated_at ``updated_at`` marks when a task entered ``running``, and three consumers measure elapsed runtime from it: the R6 lease watchdog turned into an inactivity timeout that a heartbeating task could never trip, the ``extend_lease`` remaining-budget math read time-since-last-note as time-already-spent and handed lanes and GPUs more than the budget allowed, and the Coordinator's own "Tasks in flight" line rendered an 80-minute task as seconds old — this PR's goal running backwards in the one surface the Coordinator reads. ``running()`` gets its least-recently-updated-first meaning back for free. The write had no reader left: the probe already reads freshness from the progress notes on ``history`` rather than from the column. The docstring argued for the behaviour and is rewritten to state the invariant instead, matching the one ``extend_lease`` documents a few lines below. Removing the bump restores the pre-PR reaper behaviour rather than changing it. It does not close the pre-existing gap between the catalogue's ``lease_ttl_sec`` and observed composite runtimes (roofline 2700s vs a 3106s TraceLens step, baseline 4200s vs a 3941s warmup round plus its measured round); that needs a TTL or extension policy decision and is reported on the PR rather than papered over here. Refs #1145 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(observability): keep the variant hook's name so a sibling change still merges The hook grew a progress report, so "pulse" stopped describing all of it and it was renamed to match. But the name is a merge surface: #1171 adds a call site of its own, in the branch that records a variant reaped by the session budget as skipped rather than failed. Renaming here deletes the definition that call site resolves to, in a region #1171 never touches, so git reports no conflict and the merge lands a NameError on the reaped-variant path -- found only by lint or by running it. Keep the name and say what it does in the docstring instead. Costs an imprecise word; buys a merge that cannot break silently in either order. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(storage): roll back a transaction the caller was cancelled out of ``asyncio.CancelledError`` derives from ``BaseException``, so the ``except Exception`` guard around ``transaction()`` never saw it: a cancel landing on one of the ``to_thread`` hops after ``BEGIN IMMEDIATE`` had already run left the shared connection inside a transaction for the rest of the session, and every later registry write failed with "cannot start a transaction within a transaction". The heartbeat this branch adds is the first path that routinely cancels a coroutine mid-write, once per long subprocess step. Catch ``BaseException``, and roll back inline rather than on a worker thread — the exception being handled is usually this task's own cancellation, and an ``await`` there can be cancelled in turn. A rollback that itself fails is logged, never allowed to mask the original exception. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(observability): stop the heartbeat driver cooperatively Teardown used to hard-cancel the driver and swallow the result. Both halves were wrong. The cancel could land while the driver was inside a ``tasks`` row write, which is exactly the case that leaves the coordinator's connection wedged. The driver now polls a stop flag between ticks, so teardown asks it to finish the note it is in and only cancels if it overruns a bounded grace — a wedged sink costs the executor the grace window and nothing more. ``suppress(CancelledError)`` around the await could not tell the driver's own cancellation from the enclosing task being cancelled during teardown, so an outer cancel was absorbed and the step returned as though it had finished. Nothing catches ``CancelledError`` on that path any more. ``interval_s`` now resolves its default at call time so the timescale can be compressed under test. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(observability): report liveness from the three long paths still missing it ``_run_magpie`` is the longest single block in a session — up to a 7800s variant timeout against a 300s suppression window — and it ran the benchmark with no liveness callback, so a grid announced each variant on entry and then went dark. Same shape in the baseline's discarded multi-node warmup pass. Both now run under the same output-driven heartbeat the measured baseline round already uses. In the grid all three Magpie passes (auto-warmup, multi-node warmup, benchmark) go through one helper, so a pass added later cannot be the one that reports nothing. The Ray serving-lease branch is left as an explicit gap in both files: the round executes inside an actor in another process and only its final ``(rc, stdout, stderr)`` crosses back, so there is no local per-line event to hang a callback on. A Ray-backed round reports on entry and stays quiet until it returns. Co-authored-by: Cursor <cursoragent@cursor.com> * test(observability): bound how long a path may go unreported The suite asserted that notes are emitted, never that consecutive notes on a path stay closer together than the window a consumer waits before calling the owning agent silent — which is how a benchmark with no liveness callback survived a change whose whole purpose was to close that gap. ``ProgressCadence`` records each note against a compressed clock, so a test second stands for ten production minutes and the assertions still read in the numbers the window is configured with. Every long path now has a test that fails if its callback is dropped: the grid variant benchmark, the baseline round, the multi-node warmup pass and the kernel tool subprocess. Two existing tests could not fail for the reason they existed: ``test_run_subprocess_counts_the_lines_its_child_emits`` counted nothing, only that a callable had been passed, and now asserts one call per line the child emits; ``test_a_variant_reports_before_it_blocks`` covered the entry marker and nothing after it, so a cadence test now sits beside it. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(robustness): let the configured server patterns decide what a server is ``is_server`` was computed against the module constant while the match list came from ``config.server_process_patterns``, so a framework an operator added to the documented knob was matched as a process yet flagged as not-a-server — which silently disabled ``local_server_unreachable`` for exactly the deployment that configured it. The probe config now carries the server subset and the flag follows it. An empty process list was also doing double duty: "nothing is running" and "we could not find out". ``_sample_processes`` returns ``None`` for the second and ``SourceData`` carries ``local_processes_known``, so a missing or wedged ``ps`` no longer mutes an unrelated finding. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(kernel): default the child's buffering instead of dictating it Unbuffering a kernel tool keeps the heartbeat above it honest, but assigning PYTHONUNBUFFERED overrode an operator who had set it deliberately. The three launcher sites in multi_node/scripts already use setdefault; match them. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(robustness): keep a pathological JSON blob inside its own sub-probe _json_loads_or_none caught the decode errors but not RecursionError, so a deeply nested history blob written by another process aborted the entire probe tick instead of costing only the sub-probe that read it. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(robustness): document the withheld agent_stall as its own row The symptom table described agent_stall as medium/high only; a stall whose dispatched work is still reporting is LOW and routes to send_message (observation), which the ladder test now locks in. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(robustness): bound stall suppression by evidence freshness, not the clock The 900s ceiling on a withheld agent_stall assumed a suppressed accusation should eventually fire anyway. Measured work units break that assumption: a single warmup runs 3941s and a TraceLens pass 3106s, so every healthy long phase crossed the ceiling and alerted exactly as if nothing had reported. Suppression now lasts as long as the counter-evidence stays fresh — the tick after the work stops reporting accuses at full severity. Long waits are still visible: the withheld note itself rises from LOW to MEDIUM past the threshold, which is now operator-configurable via agent_stall_high_after_s. Co-authored-by: Cursor <cursoragent@cursor.com> * test(observability): measure the cadence on a simulated clock, not the wall The four cadence assertions compressed a 300s suppression window into 0.5s of real time and then compared a real wall-clock gap against it, so every scheduling delay in the test was multiplied by 600 and charged to the path under test. The dominant term was not the heartbeat at all but the run-up before the first note -- fixture setup, imports, the fake magpie workspace -- which on two cores under eight-way contention grew to 753/888/1035 production seconds against a 300 budget. Advance the clock only when the simulated child does a chunk of the work it stands in for, so the timeline is a pure function of the lines emitted. The run-up and the tail collapse to what they represent, the steady-state spacing stays at the production ratio, and the assertion keeps failing when a liveness callback is dropped or the tick is widened past the window. Fake the kernel tool's child with the same helper the other three use: with a real subprocess there is nothing in the test that can advance the simulated clock. The real pump's line counting keeps its own coverage in test_run_subprocess_counts_the_lines_its_child_emits. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(robustness): let a benchmark client vouch that a server was wanted The process guard read "probed successfully, saw no server process" as "no server was supposed to be running", which is true of the gap between two variants and false of a server that crashed while its own load generator was still sending requests into the closed port. That second snapshot is exactly the one worth alerting on, and it was the one being suppressed: all targets unreachable plus one is_server=False benchmark_serving process produced no symptoms at all where base emitted a HIGH local_server_unreachable. Suppress only when the probe answered, no server process is up and no benchmark client is running. The client patterns are deliberately narrower than the harness patterns the process probe matches -- the outer Magpie/InferenceX harness is up across server launch and teardown too, so it stays evidence of nothing, which is what keeps the idle-stretch guard intact. Record benchmark_client_seen next to server_process_seen so the branch that decided is legible in the alert, and give the suppressed case its own row in the symptom table, which still promised that any target down always alerts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(robustness): keep the in-flight unit a fresher sibling was hiding The snapshot kept only the newest heartbeat per agent, so with two units dispatched concurrently -- one reporting, one quiet for hours -- the quiet one left no trace at all and the withheld accusation rested entirely on the healthy sibling. Only ``running`` hinted that a second unit existed, and nothing read it. Keep both ends of the range and name the quiet one in the evidence. Not the oldest alone, and not "decline to withhold when some unit is quiet": a quiet unit is not an agent fault, and quiet units are expected today -- a Ray-backed baseline round has no local process to call a liveness callback from, so it reports on entry and then not again until it returns, which either of those readings would turn into an accusation on a healthy run. The stuck unit's own backstop is the lease watchdog. What was missing was visibility, not severity. Co-authored-by: Cursor <cursoragent@cursor.com> * test(robustness): cover the quiet fallback's admission that nothing looked Deleting local_processes_known=False from _QuietFallback left test_factory fully green, which made the one line standing between "nothing looked" and "nothing is running" the only unprotected decision in the module. Assert it through the consumer it defends -- the guard that suppresses local_server_unreachable when the process probe saw no server -- so the test says why the flag exists rather than restating the assignment. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(storage): keep the loop running while a cancelled write rolls back The rollback added for cancelled ``transaction()`` bodies took ``_sync_lock`` on the event-loop thread, and in the cancellation case it queued behind the very worker the cancel had abandoned: that worker is parked inside its own ``BEGIN IMMEDIATE``, holding the lock, for as long as another writer holds the database — up to ``PRAGMA busy_timeout = 30000``. With a 3s external writer, the whole orchestrator stopped for 2.55s, and it stopped during the shutdown or budget-exhaustion window that issued the cancel, so a stalled loop ate the cooperative-stop window it was supposed to be spending. Run the rollback on a worker thread under ``asyncio.shield``. The worker queues behind the abandoned one on ``_sync_lock``, which is the ordering the rollback already relied on, and the shield keeps it uncancellable — a bare ``await`` in a handler for this task's own cancellation is exactly how the connection stayed wedged in a transaction. Awaited, not fired and forgotten: ``_async_lock`` stays held until the connection is clean, so no later ``BEGIN IMMEDIATE`` can find a transaction still open. Anything that goes wrong with the rollback itself — a failing statement, a shut-down executor during teardown, a second cancel landing on the wait — is logged and never masks the caller's original exception. Co-authored-by: Cursor <cursoragent@cursor.com> * test(observability): pace the heartbeat tests on the driver, not the clock Four of these tests asserted a note count reached after sleeping a few tick intervals, which only holds if the loop was scheduled during that sleep. On the 2-vCPU CI runner, with two xdist workers and coverage tracing, it is not: under comparable oversubscription three of them failed in every run, because the driver had not yet taken the tick the assertion assumed. Wait for the driver's own notes instead. A note is proof the driver got that tick, so the wait is as long as the machine needs and the counts become exact rather than lower bounds. A driver that is supposed to stay silent — the one watching a child that went quiet, and the one whose step already returned — is paced by a second heartbeat kept deliberately noisy, so the window it would have reported in is intervals it actually got rather than intervals the test hoped had gone by, and the assertion becomes the ordered sequence of who spoke. Every wait is bounded so a driver that stops reporting fails in seconds. Also drop ``assert elapsed < 1.0`` from the wedged-sink test: a teardown that waited on the sink instead of cancelling it would still be inside the ``async with``, never reaching the assertion, so the measured interval was carrying no coverage that ``events == ["cancelled"]`` does not. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(storage): wait out the rollback a second cancel used to abandon ``asyncio.shield`` protects the rollback, not the wait on it. A cancel landing on that wait raised straight through the ``except BaseException`` written to keep the caller's exception, and ``transaction()`` then ran on to its ``raise`` and released ``_async_lock`` with the rollback still queued on ``_sync_lock`` behind the worker the first cancel abandoned -- fire and forget with the lock already gone, which is the shape this rollback was moved off the loop to avoid rather than to adopt. The next writer finds the connection inside a transaction nobody will close and every write in the session fails with "cannot start a transaction within a transaction". The same handler lost the cancellation outright. One cancel is enough: a ``record_progress`` body raises on its own -- a ``BEGIN IMMEDIATE`` past ``busy_timeout``, a ``history`` column that will not parse -- and the swallowed cancel left the caller with the body's exception, which ``report_progress`` drops. An action cancelled by the stop path then runs on as though nothing had happened while the dispatcher's ``gather`` waits for it. Keep the shielded future and resume the wait after every cancel, so the connection is always clean before ``_async_lock`` is released, then re-raise the cancel that arrived. Narrow the ``except`` to what a rollback can actually fail with -- a statement error, or an executor refusing work during teardown -- so control flow is no longer swallowed as though it were a database error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(observability): report a step alive on its own output, not the server's log ``grew`` means "one of the resolved logs got longer", from any writer, and feeding it to the liveness callback made the heartbeat the bare timer ``heartbeat_while_output_flows`` documents itself as never being. A child that printed nothing for 3s was reported alive 5 times by a ``server.log`` an unrelated thread appended to. In a real round that writer is the inference server, which keeps logging while the benchmark client is wedged, and both vLLM and sglang write an access line per request -- including the health probe the robustness agent issues every standalone tick against the server it is monitoring. That closes a loop where the monitor's own probe manufactures the evidence suppressing its own stall accusation, and the wall-clock ceiling that used to backstop the case is gone. Report liveness on evidence about the round instead: a generation-progress marker, which says tokens were flowing whoever logged them, or growth in the benchmark body's own redirected stderr, which is output of this very child that never reaches its pipe. Keeping the second is what stops the narrowing from silencing a working round: the scriptable and bypass paths redirect the body's stderr to ``benchmark_stderr.log``, so for a whole round the pipe is empty and that file is all there is. The stall watchdog keeps the broad "any new bytes" signal, which is the right one for a gate that kills. The scan now returns a named tuple rather than a fourth positional bool nobody could read, which is also how the throwaway ``_saw_progress`` binding at the call site goes away. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(observability): name the variant that ran in its own progress note ``_pulse_after_variant`` inferred which variant it was reporting from the tail of ``results`` rather than from the index it was handed. A stop cause that ends the batch records the round it stopped and then a not-run row for every later variant, so the tail is the last variant in the grid and the terminal note for variant 1 of 3 reads ``label='c2', index=1, total=3``. The log line one frame away is right and the ledger is right; only the progress note -- the artefact this heartbeat exists to make honest, and the one a stall signal reads -- is wrong. Locate the row by index instead, in a helper that says why the tail is not it, and take the label from the grid entry where it cannot disagree. Extracting the note also takes it out of a ``run_grid`` that is already far past the length worth reading, and leaves ``_pulse_after_variant``'s signature alone so no call site has to change. Co-authored-by: Cursor <cursoragent@cursor.com> * test(observability): bound the wedged-sink step so a regression fails, not hangs The sink sleeps for an hour, so a teardown that waited on it instead of cancelling it never leaves the ``async with`` and never reaches an assertion. With no timeout plugin in this suite that blocks until the CI job is killed -- the failure mode the grace window under test was added to prevent, which the test for it should be the last place to reproduce. Run the step under ``asyncio.wait_for``: the regression now fails in ten seconds where it previously hung indefinitely. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(observability): take the rate, not the line, as proof of tokens Narrowing the liveness callback to generation-progress markers left one writer still able to vouch for a wedged child: some vLLM builds print ``Avg generation throughput: 0.0 tokens/s`` on an idle engine, and an engine goes idle precisely when the client driving it wedges. The most likely production shape of the defect therefore survived the fix -- a server logging zeros suppresses the stall accusation its own client has earned. Read the value instead of the marker, reusing the parse the post-mortem throughput estimate already runs over the same two frameworks' lines: the third copy of that knowledge goes away, and a framework added to one parser can no longer drift from the other. A line whose rate cannot be read counts as no progress -- this evidence only ever suppresses an accusation, and one suppressed by mistake is invisible, where a missing one is visible and still answerable from the child's own redirected stderr. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(observability): end the variant a failed remote restart abandoned Every variant outcome reports a terminal note except one: a multi-node round whose server never came back recorded its row and left, so the row a stall signal reads stayed at ``started`` for the rest of the session while the variant was already over. Two behaviour-lock tests pinned that omission as though it were a rule, which is how it outlived the boundary the other thirteen paths share -- and unlike a reaped round, nothing moves the task afterwards to make the stale row harmless. Reach the boundary like every sibling does, so the note comes from ``_variant_progress_note`` and names the variant that ended rather than the tail of the batch, and the failed restart gets the same bounded robustness tick as every other variant boundary. The loop's 27 early exits were enumerated to confirm this was the only gap: the one remaining exit that reports nothing is the session-budget skip, which breaks before the variant is announced and so has no row to close. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(robustness): let the evidence, not the wait, grade a withheld stall The withheld note rose to MEDIUM once the agent had been silent past severity_high_after_s, which is the wall-clock ceiling the previous commit set out to remove, one rung lower: MEDIUM routes to alert(medium), so the measured healthy runs it cited -- a 3941s warmup, a 3106s TraceLens pass -- alerted while reporting a unit every tick. Elapsed silence now grades only the accusation. A phase whose work keeps reporting stays on the observation tier however long it runs, and the tick after the reports stop accuses at full severity, which is what "bounded by evidence freshness" was supposed to mean. The withheld case gets its own symptom name so RCA can tell a healthy long phase from an agent that really went quiet, and the evidence records the freshness window that holds the accusation back. The ladder tier a withheld note must not reach is pinned by a test of its own. Co-authored-by: Cursor <cursoragent@cursor.com> * test(robustness): visit the progress rows in both orders The shipped test inserted the quiet unit first, so the freshest note always arrived second and the branch that records the quiet end never ran: deleting it left the whole suite green. Which order the rows arrived in was also SQLite's choice, the running-task select having no ORDER BY, so the assertion rested on unspecified behaviour either way. Order the select by task_id and let the test choose which unit's note is folded in first, so both directions of the fold are covered by the same case. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(state): bound the progress notes a task's history retains Every note re-read the whole history blob, appended to it and rewrote it inside a BEGIN IMMEDIATE on the connection every other writer serialises behind, with no cap. 720 notes -- 12 hours at the 60s heartbeat -- left a blob north of 100 KB and tens of MB of cumulative row rewrites, and the robustness probe re-parses that blob for every running task on every tick. The cost scaled with session length, which is what the heartbeat exists for. Retain the newest 120 notes, two hours of trail at that cadence and longer than the longest measured work unit. Transitions are never dropped: consumers read those positionally -- the last entry for a failure class, the newest queued -> cancelled for a policy denial -- so only progress notes are retired. Measured on this branch, 720 notes: blob 103 KB -> 17 KB, cumulative rewrites 35.6 MiB -> 10.9 MiB, and flat per note from there instead of growing. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(robustness): tie a vouching benchmark client to this session The client that overrides "no server process, so none was wanted" was found in a whole-host ps snapshot, so on a shared node a co-tenant's benchmark_serving vouched that this session's port should be answering, and an idle stretch came back as a HIGH alert on somebody else's traffic. The process probe now records each matched process's working directory, which is the anchor a session already has: the harness is launched with its cwd inside the session directory and its children inherit it. A client counts as this session's when its cwd is under that directory, or when it names a path there on its command line, for the launch paths that chdir elsewhere. With no session directory configured there is nothing to compare against and the check stays host-wide as before. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(robustness): say where the stall thresholds are actually set The escalation knob was described as operator-configurable, but discover() reads a fixed list of deployment-shape variables and this is not one of them, so it is set in code like every other threshold on Config -- gpu_temp_warn_c, the disk and shm percentages, its own sibling agent_stall_timeout_s. Correct the claim rather than wire these two into discover(): none of the ~60 thresholds is environment-settable, and making the stall pair the exception would buy one deployment knob at the price of an env variable per threshold and a doc table that has to keep up. Where the boundary runs is now written down next to the thresholds and under the environment table. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(robustness): run a grid variant inside the session that owns it A benchmark client only overrides "no server process, so none was wanted" when it can be tied to this session, and the tie is the working directory the harness's whole subtree inherits. The baseline arm anchors Magpie to its own output dir for that reason; the grid runner defaulted to the system temp directory, so an explore / sweep / conc_sweep / integrate_patch client carried no anchor of its own and a server that died mid-variant read as the idle gap between two variants -- the one snapshot the vouching rule exists to catch. Grid variants are where a server most often dies mid-benchmark. Each pass now runs from output_root, the per-task workspace under the session, exactly as the baseline does. Nothing that reads a benchmark's results depended on that directory being /tmp: Magpie re-roots the server itself, and workspace selection scans the per-variant output dir while the leak harvest scans /workspace plus $INFERENCE_OPTIMIZER_LEAK_ROOTS -- never the cwd. A relative result_dir now resolves under the task workspace, which is where the baseline already resolves one and where the salvage path can find what it produced. The cwd override goes with it. No caller set it, and an override is the one knob that could let the anchor drift back out of the session. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(robustness): tie a client to the session by directory, not by prefix The cwd reading guarded the directory boundary; the command-line reading on the next line was a bare substring test. A co-tenant benchmarking out of <session>-retry -- a retry, a backup, or any sibling an operator names after ours -- contains this session's path, so it still vouched that this session's port should be answering. That is the false alarm the previous fix set out to remove, narrowed from host-wide to prefix-wide rather than eliminated, and it is reachable whenever the session directory is operator-supplied. Both readings now go through one helper that compares a path a component at a time, so the two cannot drift apart again, and the command line is read as tokens (plus the value of a key=value token, so --result-dir=<path> still counts) rather than searched for a substring, which is what makes a boundary applicable to it at all. The launch-path matrix now also holds the case that hid the missing grid anchor: a client with neither cwd nor command line under the session is indistinguishable from a co-tenant's and must not vouch. Co-authored-by: Cursor <cursoragent@cursor.com> * test(robustness): hold both spellings of --result-dir to the session boundary Co-authored-by: Cursor <cursoragent@cursor.com> * docs(grid): say why the session-rooted cwd is created up front Co-authored-by: Cursor <cursoragent@cursor.com> * Make the heartbeat cancel tests name the exception they wait for CodeQL read `await cancelled_task` inside pytest.raises as a statement with no effect, and the log assertions after a raise it did not model as unreachable. Catch CancelledError and ValueError explicitly so the control flow is the test. Co-authored-by: Cursor <cursoragent@cursor.com> * Satisfy CodeQL on the heartbeat cancel tests without empty excepts The previous try/except/else left an unreachable else, and a bare `except CancelledError: pass` is an empty handler. One helper now documents the success path, and the rollback-failure test goes back to pytest.raises with the log assertions inside that block. Co-authored-by: Cursor <cursoragent@cursor.com> * Stop CodeQL treating the rollback-failure asserts as dead pytest.raises is not modelled as catching the raise, so the log assertions after it were unreachable. Catch ValueError by assignment instead, with no else-clause and no context manager for CodeQL to misread. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
fix: hold the session to its wall-clock budget
Refs #1146. Also fixes #1167.
Scoped to #1146's item 3 (in-flight enforcement) plus the admission gate; items 1, 2, 4 and 5 of that issue are not all closed here, so this does not auto-close it.
A run given a 3h budget spent 3h55. The budget was consulted between ticks and
nowhere else, so the only question ever asked was "should the next tick start?"
-- never "may this action start", "how long may it run", or "is it still worth
waiting for". An
explorecould be granted four hours inside a three-hoursession and nothing would stop it.
Four defences, each covering what the one before it cannot.
Admission. An action whose expected cost cannot fit the budget that is left
never starts. Refused before a task row exists, so the refusal stays out of the
failure ledgers -- a reaped attempt used to teach the KB that the action fails
when all that happened was the run ran out of time. Fit is judged on expected
cost (p50), not the p75 tail: judging on the tail abandons minutes that would
have been used more than half the time, and overruns are what the later layers
are for. The closing actions stay startable on an empty budget.
Timeout clamping. Action and variant grants are clamped to the budget that
is left, so no single grant can exceed the session. The stack rebench never
consulted the budget at all -- the same hole, second instance.
Session reaper.
run_with_session_killgains a session deadline on its ownchannel, checked in every phase and never suspended. The separate channel is
the point: the soft deadline retires at eval start by design, because it judges
whether a variant is abnormally slow, and that boundary is meaningless for "is
the run out of time". An accuracy eval starting a minute before the session
ends used to run to completion.
In-flight cancellation. The backstop for an action already running whose
executor takes no deadline, or that is not spending its time in a subprocess.
The handles lived only in the pump's frame, so the sole caller able to cancel an
action was the frame already blocked awaiting it; they now live on the
dispatcher. This is also what makes SIGTERM work: it only set the stop event
before, and since the pump does not return until everything it dispatched has
finished, shutdown latency was the remaining runtime of the longest action --
after which teardown closed the database out from under work still using it.
Spending the budget early is the other way to lose it
Field data from two sessions on unrelated models -- different quantization,
different PRELUDE composition, one baseline-dominated and one split with a
TraceLens roofline -- showed the four layers above would not have saved either
run. Neither overran at the tail. Both spent roughly three quarters of a
three-hour budget (73.8% and 72.8%) in PRELUDE, whose declared share is 3%, and
both reached FRAMEWORK_AGENT with about 47 minutes against its 108-minute entry
threshold. They were unrecoverable the moment preparation ended.
Nothing enforced the share because
exit_normal_preludetests whether abaseline landed and nothing else, so the phase ran to whatever its contents
happened to cost. PRELUDE now decides before it spends:
prelude_can_affordbounds an optional arm by the tighter of its own share and what the
optimization phases have reserved, and two arms consult it -- the initial
roofline/profile (81 minutes in one run, 45% of the session, and known-degraded
at second zero because the vLLM version patch had failed) and the baseline's
measured round (59 minutes in the other, to move the anchor 0.2%; dropping it
leaves the single-round baseline the codebase already supports rather than a
new half-measured state). Both estimates come from what the session itself
measured, because the catalog's p50s are calibrated on small models and a guard
reading them would have admitted both arms.
time_exhausted_during_preludealso finally gets a producer: it was in theterminal-reason vocabulary and in the report's glossary, but nothing ever
assigned it. And PRELUDE's normal exit now states whether the budget it leaves
behind still funds one benchmark round.
A report the session can still write (#1167)
CLOSE reached for a report task by asking "was one enqueued?" when it needed
"is one runnable?", so a cancelled row came straight back and
run_task'squeued -> runningfailed the registry's terminal check withIllegalTransition.The session that hits this is the one whose report matters most, and it lost
final.mdentirely.This lands with the enforcement layers on purpose: cancelling in-flight work at
the deadline is the point of those layers, and it widens the set of ways a
report task can be sitting in
cancelledwhen CLOSE arrives. A dead row nowfalls through to a fresh enqueue under a suffixed idempotency key, and
_run_close_taskreports a terminal row instead of handing it torun_taskagain.
Two bugs found on the way
The new sentinel returncode first landed on
_RAY_ACTOR_DIED_RC, which wouldhave had every Ray actor death read as a spent budget. The guard test written
to catch that then found a pre-existing overlap:
AGENTX_PREFLIGHT_RETURNCODEand
_ACTOR_TIMEOUT_RCwere both -912 and both leave_run_magpiethrough thesame return channel, so a hung actor was recorded as a missing aiperf. Fixed
here as well -- it is the same attribution problem these layers exist to solve.
The Ray code moved rather than the AgentX one, because downstream and every
record written so far already read -912 as the preflight failure.
A field run also crashed on
AttributeError: _record_explore_variant_failures,a
_DELEGATEDentry naming a method that no longer exists. The test added hererequires every delegated name to resolve on its collaborator; it found two more
stale entries (
_resolve_issue_canonical,_wait_for_task_terminal), bothremoved. These are now caught at test time rather than mid-session.
Test plan
test_session_time_budget.py-- 43 new tests: the fit rule, the usablebudget accessor, the dispatcher gate, all three intent paths, the
pre-dispatch backstop, the in-flight handles, the cancellation and its
closing-action carve-out, the lane released on cancel, the task row
landing terminal, and the pump / shutdown /
Coordinator.stoptriggers.test_kill_spawned_server.py; clamping intest_explore_executor.py,test_grid_runner.py,test_framework_agent_executor.pyandtest_integrate_patch_coverage_unit.py.test_phase_state_machine.py(affordabilitybounds, the time-exhausted exit, a landed baseline outranking the guard,
and the viability sentence on normal exit), the analysis arm in
test_warm_replay.py, and the measured round intest_baseline_warmup_double_run.py.test_close_phase_sequencer.pyandtest_coordinator_async_batch2_unit.py.a clean tree (subprocess tests needing an installed package) or pass in
isolation (cross-test pollution).
Inline actions
run_action_nowabandons its future once the inline wait elapses -- the actionkeeps running by design so the agent's turn is not held hostage -- which
abandoned the only handle on it. It now publishes its own task for the duration,
so the same cancellation reaches it. Its sync bridge also names cancellation
before its generic handler, which had been filing a deliberate stop as
errored.