Repository navigation
feat(async): L-traj resume — a straggler is re-run against a newer head, not just counted - #198
Conversation
Birfy
left a comment
There was a problem hiding this comment.
The queue needs a guard against repeatedly re-queuing the same slow task. A resumed task goes through the same elapsed-time check as a fresh task, so if it again exceeds straggler_factor * predicted, it is pushed back and can be selected indefinitely, starving fresh tasks in that worker's shard. Also, this code queues only after run() has returned; it does not abandon/cancel the original slow rollout. Please define a bounded retry policy (or exclude resumed items from requeue) and add a test showing a repeatedly slow task cannot monopolize the worker.
|
Addressed the review: re-queue is now bounded. The problem: a resumed task goes through the same straggler check as a fresh one, so without a cap a task that is always slow is pushed, popped, re-run, pushed again, and starves the worker's fresh tasks. The fix: Tests:
On the second point (abandoning the original slow rollout): this port's 8 ltraj tests pass; scheduler/resource/duration/async suites pass (43 total). |
Birfy
left a comment
There was a problem hiding this comment.
Re-reviewed ca69d7e. The normal push -> pop_for -> re-push loop is now bounded, and 72 focused tests passed independently (L-traj, duration, resource limits, async semantics/parity, worker resilience, metrics, API docs). Hosted tests/docs also pass on this exact head.
One remaining bounded-retry gap is reproducible through the public shared-queue API: multiple pending checkpoints for one task bypass max_attempts. With q = ResumeQueue(max_attempts=1) and one ResumeItem(task_id='a', turn=0, conversation=[], version_at_checkpoint={'art': 1}), call q.push(item) three times before any pop. All three calls return True; three subsequent q.pop_for(['a']) calls all return the task, with dropped still 0. The admission check only considers past pops, and pop_for does not check the cap before handing out already-queued duplicates. This needs no concurrency race. Passing such a preloaded queue to async_evolve also reproduced the breach: eight tasks, held_out_frac=0.5, shuffle=False, one worker, and three entries per task yielded result.resumed=12 (three for each training task), although max_attempts=1.
Please enforce the per-task bound at dequeue/admission consistently, or deduplicate pending task checkpoints, and add a regression test for repeated pushes before the first pop. The legacy public pop() also does not update the attempt count, so please define its accounting consistently with the new bound. Synchronize the bound/accounting with the queue operations rather than checking outside the queue lock.
Separately, the author reply correctly describes after-return detection and task re-execution. Please align the remaining 'abandons a rollout' wording in the changelog/API comments with that behavior; this does not cancel a running rollout or restore a turn-level checkpoint. The test named 'against_the_newer_head' currently proposes None, so it exercises re-execution without establishing that a newer version was used.
GitHub additionally reports mergeable_state=dirty against current main; that integration conflict is separate from the queue finding.
ca69d7e to
a144231
Compare
Birfy
left a comment
There was a problem hiding this comment.
Re-reviewed a144231 (rewritten history since the previous review of ca69d7e). The push→pop→re-push loop is still bounded via _resumed_count on pop_for, and the chronically-slow-task tests cover that path. The remaining public-API gap from the previous review is not fixed.
Duplicate pending checkpoints still bypass max_attempts. Admission only consults past pops. With q = ResumeQueue(max_attempts=1) and one ResumeItem(task_id='a', ...), calling q.push(item) three times before any pop all return True; three subsequent q.pop_for(['a']) all return the task and dropped stays 0. pop_for also does not re-check the cap before handing out already-queued duplicates. Passing such a preloaded queue into async_evolve can still overshoot max_attempts (previously reproduced as result.resumed=12 with three entries per training task). Please enforce the per-task bound at dequeue/admission consistently, or deduplicate pending task checkpoints, and add a regression for repeated pushes before the first pop. Legacy pop() still does not update _resumed_count — please define its accounting with the new bound under the same lock.
CI is red on Python 3.9–3.12 test jobs on this head, so even after the queue fix this cannot merge until the suite is green again against current main.
Changelog/API wording alignment (after-return detection vs “abandons a rollout”) and the against_the_newer_head test still proposing None remain secondary follow-ups from the prior review; the queue bound and CI are the blockers.
a144231 to
cddb1fc
Compare
… resumes PR Birfy#198 review: push() checked only the popped count, so a burst of pushes for the same task before any pop was all accepted and all handed out, and pop() never fed the cap. Count popped + currently-queued per task at push time, and count a pop()/pop_for() resume under the same lock.
|
Rebased onto the latest upstream
|
… resumes PR Birfy#198 review: push() checked only the popped count, so a burst of pushes for the same task before any pop was all accepted and all handed out, and pop() never fed the cap. Count popped + currently-queued per task at push time, and count a pop()/pop_for() resume under the same lock.
cddb1fc to
04711a0
Compare
|
Re-reviewed current Two follow-ups remain:
|
…ad, not just counted The async runtime abandons a rollout that overruns its predicted cost and counts it (result.stragglers). concepts.md calls the resume half not implemented: run(rendered, task) -> output is opaque, so there is no turn-level state to checkpoint. What *can* be saved is the task and the version it measured. With a shared ResumeQueue passed as resume_queue=, a straggler is recorded (task, version_at_checkpoint) instead of dropped, and the next idle worker whose shard owns that task re-runs it against the current head. The straggler measured version N, the re-run measures N+k — a free cross-version A/B signal, which is the design's L-traj note. ResumeQueue.pop_for(task_ids) hands a queued resume only to a worker whose shard owns it, so no two workers re-run the same task. result.resumed reports the re-runs. resume_queue=None (default) is the old behaviour exactly. 5 tests in tests/test_ltraj_resume.py. Async + genesis regression exit 0.
…tale docs Three fixes the review would have caught, plus doc drift: - resume_queue was added to async_evolve's signature without a Parameters entry, so test_api_docs (which fails on any undocumented parameter) was red. - EvolutionResult.resumed was counted but never serialised: save()/load() dropped it, so a written result lost the A/B signal it exists to report. - ResumeQueue's docstring still claimed it was 'unwired' after the engine started pushing and popping it; concepts.md, duration-scheduling.md, execution.md, modules.md and async.md all still said the resume half was 'not implemented' / 'nothing pops the queue'. They now describe task-level re-run as implemented and turn-level continuation as the remaining gap.
…t monopolise a worker A resumed task goes through the same straggler check as a fresh one, so without a cap a task that is always slow is pushed, popped, re-run, pushed again, and starves the worker's fresh tasks. ResumeQueue now counts how many times each task has been resumed (in pop_for), and push refuses a task past ResumeQueue(max_attempts=...) (default 1): the A/B signal needs one re-run, and a task that is still a straggler after that is dropped (still counted in result.stragglers, just never re-queued). Tests: queue-level (push refuses past the cap; a chronically slow task gets exactly max_attempts re-runs then nothing is queued) and end-to-end (all tasks always slow -> resumed is bounded by max_attempts * n_tasks).
… resumes PR Birfy#198 review: push() checked only the popped count, so a burst of pushes for the same task before any pop was all accepted and all handed out, and pop() never fed the cap. Count popped + currently-queued per task at push time, and count a pop()/pop_for() resume under the same lock.
…r-head re-run Two secondary follow-ups from the queue review. 1. 'Abandons a rollout' overstated what detection does: run(rendered, task) -> output is opaque, so a rollout that overruns its estimate is noticed once it has returned, not cancelled mid-flight. Aligned the async_evolve docstring, docs/api.md and the changelog entry with that behaviour. 2. The against_the_newer_head test proposed None, so it exercised re-execution without establishing that a newer version was used. It now records the rendered artifact per rollout: t0 is a chronic straggler while another shard commits a clue, and the test asserts t0's re-run renders the post-clue head.
The api-reference sync check (test_the_api_reference_is_in_sync_with_the_package) failed because the generated docs had no row for ResumeQueue.pop(), which now documents its role in the max_attempts bound. Regenerate docs/api.md.
The regression passed even when the resumed call rendered the old state, because a later ordinary rollout of the same task supplied the clue-bearing render -- it proved re-execution happened, not that the resume used the newer head. Each pop_for hand-off now flags the worker call that follows it (thread-local, consumed by run), so the test asserts the resumed call itself rendered the post-clue head. A negative control forces the resumed call to render the stale state and proves the assertion fails.
04711a0 to
6c94da6
Compare
|
Both follow-ups from the re-review are addressed on the new head 6c94da6:
|
|
Re-reviewed 6c94da6. The resumed-call coverage gap is addressed: independently forcing the resumed t0 call to see stale content makes the positive test fail even when later ordinary t0 calls see the clue. The endpoint change is confined to the test file; 31 focused offline tests passed across resume, resource limits, async semantics and the slow-gate check. Exact-head hosted tests, wheel builds and smoke checks passed on Python 3.9–3.13: https://github.com/Birfy/agentdescent/actions/runs/38401699892. Docs passed: https://github.com/Birfy/agentdescent/actions/runs/38401699887. The earlier unchanged-head rerun also passed, but a passing rerun does not by itself establish why that earlier timing assertion failed. No further finding surfaced in this bounded follow-up. |
Birfy
left a comment
There was a problem hiding this comment.
Re-reviewed 6c94da6 (rewritten history since the previous review of a144231). The outstanding public-API gap is fixed.
Duplicate pending checkpoints no longer bypass max_attempts. Independently reproduced the prior scenario against this head: ResumeQueue(max_attempts=1) with one ResumeItem(task_id='a', ...) — first push returns True, the second and third return False with dropped=2, len(q)=1; subsequent pop_for(['a']) returns the task once then None. Admission now counts _resumed_count + _queued_count under the queue lock (scheduler.py push/pop/pop_for). Legacy pop() also increments _resumed_count and decrements _queued_count, so it feeds the same bound. Regression test_resume_queue_duplicate_pushes_before_any_pop_are_bounded covers the push-burst path; test_resume_queue_pop_counts_toward_max_attempts covers pop() accounting.
Secondary follow-ups from the prior review are also addressed: changelog/API wording aligns with after-return detection (not mid-flight cancel), and against_the_newer_head now asserts the resumed call itself rendered the post-clue head (with a negative control).
Independently ran tests/test_ltraj_resume.py: 11 passed. Hosted tests (3.9–3.13) and docs build are green on this exact head. Approving.
What this does
The L-traj resume half. The async runtime already detects stragglers — a rollout that overruns its predicted cost is counted (
result.stragglers). This PR makes it also re-run them: the abandoned task and the version it measured are recorded, and the next idle worker whose shard owns that task re-runs it against the current head.A straggler measured version N; its re-run measures version N+k — so the pair is the design's free cross-version A/B signal, now behaviour instead of a note.
What changed
async_evolve(resume_queue=...)— pass a sharedResumeQueueand stragglers are queued (task id + version at checkpoint) instead of dropped.ResumeQueue.pop_for(task_ids)— hands a queued resume only to a worker whose shard owns the task, so no two workers re-run the same one.result.resumed— newEvolutionResultfield counting the re-runs.None/no queue keeps the old behaviour exactly: stragglers counted and dropped.ResumeItemreordered so the optionalexternal_handletrails the required fields (all call sites use keywords).Reviewed and fixed before opening
resume_queuewas added without a docstringParametersentry →test_api_docswas red (that test fails on any undocumented parameter). Documented.result.resumedwas counted but not serialised:EvolutionResult.save()/load()dropped it. Added to both, with.getdefaults so files written before it load cleanly.ResumeQueue's own docstring,concepts.md,duration-scheduling.md,execution.md,modules.md,async.md. They now describe task-level re-run as implemented and turn-level continuation as the remaining gap (the recorded item carries no conversation state, becauserun(rendered, task) -> outputis opaque).Tests
tests/test_ltraj_resume.py— 5 tests:pop_forshard routing, no-handout-when-shard-empty, no-duplicate-handout, end-to-end re-run against a newer head, and old behaviour preserved without a queue. Run 5×, stable. Related suites pass (api docs, duration, async semantics/parity, worker resilience, metrics, evolution).