Skip to content

Commit d420117

Browse files
fix(scheduler): serve in_progress SHORTEST-REMAINING first — turn effort into completions
Within a stage the matrix workers took the STALEST project. But working on a project REFRESHES its `updated_at` and sends it to the back of the queue — so in_progress was a strict ROUND-ROBIN over all 435 projects. Draining one takes several ticks (~38 tasks at ~10/tick), so each project had to wait for the other 434 between touches. Every project crept forward and NONE ever finished: classic processor-sharing starvation, and a direct reason zero projects had EVER crossed research_complete even when the pipeline was healthy. Meanwhile 68 projects were already close: 8 with <=5 tasks left, 54 with 16-30. They were never prioritised, so they never landed. FIX: implement stages (in_progress / paper_in_progress) are served shortest-remaining-first — the project closest to done goes first (optimal for mean completion time). Staleness stays the tiebreak. It cannot starve the long projects: a finished project LEAVES the stage, so the queue steadily advances to them. Everywhere else keeps oldest-waiting-first (no "remaining work" to be short of). tasks.md is resolved via the SSoT `feature_dir_for`; an unreadable one sorts LAST, so "no tasks file" can never masquerade as "almost finished". Live effect (12 workers): the 8 in_progress workers now land on the projects that can actually cross — five with 0 tasks remaining (straight to the execution gate), then 1, 1, 2 remaining — instead of three arbitrary 60-task projects. Also fixes a bug in the new apportionment: MAX_STAGE_WORKER_SHARE could IDLE a worker when only one stage was occupied (nothing else to spend the capped slot on). The cap exists to stop one stage crowding out others, never to waste a worker — leftover slots are now filled ignoring the cap, bounded by pile size. Full offline suite: 6147 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b544e23 commit d420117

2 files changed

Lines changed: 131 additions & 1 deletion

File tree

src/llmxive/pipeline/scheduler.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -440,12 +440,75 @@ def pick_for_worker(
440440
return None
441441
stage = slots[worker_index]
442442
depth = slots[:worker_index].count(stage)
443-
in_stage = sorted(by_stage[stage], key=lambda p: (p.updated_at, p.id))
443+
in_stage = _order_within_stage(stage, by_stage[stage], repo_root=repo_root)
444444
if depth >= len(in_stage):
445445
return None
446446
return in_stage[depth]
447447

448448

449+
#: Stages whose completion needs MANY sequential ticks (the implementer drains a
450+
#: batch of tasks per tick, and a project carries 30-70 tasks).
451+
_IMPLEMENT_STAGES: frozenset[Stage] = frozenset({
452+
Stage.IN_PROGRESS, Stage.PAPER_IN_PROGRESS,
453+
})
454+
455+
456+
def _remaining_tasks(project: Project, *, repo_root: Path | None) -> int:
457+
"""Open (``[ ]``) + under-review (``[~]``) task count for an implement project.
458+
459+
Resolved through the SSoT ``feature_dir_for`` (honours the ``speckit_*_dir``
460+
pointer) so a stale lower-numbered ``specs/*`` dir can never shadow the real one.
461+
An unreadable/absent tasks.md sorts LAST (a large sentinel) rather than first —
462+
"no tasks file" must not masquerade as "almost finished".
463+
"""
464+
rr = Path(repo_root) if repo_root is not None else Path.cwd()
465+
track = "paper" if project.current_stage == Stage.PAPER_IN_PROGRESS else "research"
466+
try:
467+
fdir = project_store.feature_dir_for(
468+
rr / "projects" / project.id, track=track
469+
)
470+
if fdir is None:
471+
return _NO_TASKS_SENTINEL
472+
text = (fdir / "tasks.md").read_text(encoding="utf-8")
473+
except OSError:
474+
return _NO_TASKS_SENTINEL
475+
return text.count("- [ ]") + text.count("- [~]")
476+
477+
478+
#: Sorts a project with no readable tasks.md to the BACK of its implement queue.
479+
_NO_TASKS_SENTINEL = 1 << 30
480+
481+
482+
def _order_within_stage(
483+
stage: Stage, projects: list[Project], *, repo_root: Path | None
484+
) -> list[Project]:
485+
"""Order a stage's queue for the matrix workers.
486+
487+
Implement stages are served SHORTEST-REMAINING-FIRST: the project closest to
488+
done goes first. Everywhere else, oldest-waiting first (staleness) — there is no
489+
"remaining work" to be short of.
490+
491+
Why implement is different: draining a project takes SEVERAL ticks (~38 tasks at
492+
~10/tick). Ordering by staleness made that a strict ROUND-ROBIN — working on a
493+
project refreshes its ``updated_at`` and sends it to the back of the queue, so
494+
each project waited for all 434 others between touches. Every project crept
495+
forward and NONE ever finished: classic processor-sharing starvation, and the
496+
reason zero projects had EVER crossed research_complete. Serving the shortest
497+
remaining queue first turns the same compute into COMPLETED projects (it is
498+
optimal for mean completion time), and it cannot starve the long ones: a finished
499+
project LEAVES the stage, so the queue steadily advances to them.
500+
501+
Staleness remains the tiebreak, so equal-remaining projects still go
502+
oldest-first.
503+
"""
504+
if stage in _IMPLEMENT_STAGES:
505+
return sorted(
506+
projects,
507+
key=lambda p: (_remaining_tasks(p, repo_root=repo_root), p.updated_at, p.id),
508+
)
509+
return sorted(projects, key=lambda p: (p.updated_at, p.id))
510+
511+
449512
def _apportion_workers(
450513
ranked_stages: list[Stage],
451514
by_stage: dict[Stage, list[Project]],
@@ -531,6 +594,22 @@ def _take(stage: Stage) -> None:
531594
if best is None:
532595
break # every pile exhausted / capped
533596
_take(best)
597+
# The share cap exists to stop one stage crowding OUT the others — never to idle
598+
# a worker. If slots remain because every stage is at its cap (e.g. in_progress is
599+
# the only occupied stage), hand them out ignoring the cap, bounded only by pile
600+
# size. Otherwise a 1-stage repo would leave a third of the matrix doing nothing.
601+
while len(slots) < n_workers:
602+
best = None
603+
best_score = -1.0
604+
for s in ranked_stages:
605+
if counts[s] >= len(by_stage[s]):
606+
continue # a worker here would pick nothing
607+
score = weights[s] / (counts[s] + 1)
608+
if score > best_score:
609+
best_score, best = score, s
610+
if best is None:
611+
break # genuinely fewer projects than workers
612+
_take(best)
534613
return slots
535614

536615

tests/unit/test_scheduler_worker.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,54 @@ def test_sparse_transient_stage_is_always_drained(tmp_path: Path) -> None:
241241
picks = [scheduler.pick_for_worker(i, 6, repo_root=repo) for i in range(6)]
242242
ids = [p.id for p in picks if p is not None]
243243
assert len(ids) == len(set(ids)), f"workers double-picked: {ids}"
244+
245+
246+
# --- in_progress is served SHORTEST-REMAINING first (completions, not sharing) ---
247+
#
248+
# Within a stage, projects were ordered stalest-`updated_at`-first. But working on a
249+
# project REFRESHES its updated_at and sends it to the back — so in_progress became a
250+
# strict round-robin across all 435 projects. Draining a project takes several ticks
251+
# (~38 tasks at ~10/tick), so each one had to wait for the other 434 between touches
252+
# and NOTHING ever completed: processor-sharing starvation, and the reason 0 projects
253+
# had ever crossed research_complete. Implement stages now serve the project CLOSEST
254+
# to done first, which converts the same effort into finished projects. It cannot
255+
# starve the big ones: a completed project LEAVES the stage, so the queue advances.
256+
257+
258+
def _impl_project(repo: Path, pid: str, *, open_tasks: int, age_days: float) -> None:
259+
_make(repo, pid, Stage.IN_PROGRESS, age_days=age_days)
260+
d = repo / "projects" / pid / "specs" / "001-t"
261+
d.mkdir(parents=True, exist_ok=True)
262+
lines = ["# Tasks", ""]
263+
lines += [f"- [X] T{i:03d} done work" for i in range(3)]
264+
lines += [f"- [ ] T{100 + i:03d} remaining work" for i in range(open_tasks)]
265+
(d / "tasks.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
266+
267+
268+
def test_in_progress_serves_the_closest_to_done_first(tmp_path: Path) -> None:
269+
for sub in ("projects", "run-log", "locks"):
270+
(tmp_path / "state" / sub).mkdir(parents=True, exist_ok=True)
271+
# The near-done project is the FRESHEST (worked on most recently) — under the old
272+
# stalest-first order it would be picked LAST, which is exactly the bug.
273+
_impl_project(tmp_path, "PROJ-9001-almost", open_tasks=2, age_days=1)
274+
_impl_project(tmp_path, "PROJ-9002-midway", open_tasks=20, age_days=10)
275+
_impl_project(tmp_path, "PROJ-9003-barely", open_tasks=60, age_days=30)
276+
277+
p0 = scheduler.pick_for_worker(0, 1, repo_root=tmp_path)
278+
assert p0 is not None and p0.id == "PROJ-9001-almost", p0
279+
280+
# Successive workers walk outward: fewest-remaining first, then the rest.
281+
picks = [scheduler.pick_for_worker(i, 3, repo_root=tmp_path) for i in range(3)]
282+
got = [p.id for p in picks if p is not None]
283+
assert got == ["PROJ-9001-almost", "PROJ-9002-midway", "PROJ-9003-barely"], got
284+
285+
286+
def test_non_implement_stages_still_ordered_by_staleness(tmp_path: Path) -> None:
287+
"""The change is scoped to the implement stages — elsewhere oldest-waiting still
288+
wins (there is no 'remaining work' to be short of)."""
289+
for sub in ("projects", "run-log", "locks"):
290+
(tmp_path / "state" / sub).mkdir(parents=True, exist_ok=True)
291+
_make(tmp_path, "PROJ-9101-fresh", Stage.BRAINSTORMED, age_days=1)
292+
_make(tmp_path, "PROJ-9102-stale", Stage.BRAINSTORMED, age_days=40)
293+
p0 = scheduler.pick_for_worker(0, 1, repo_root=tmp_path)
294+
assert p0 is not None and p0.id == "PROJ-9102-stale", p0

0 commit comments

Comments
 (0)