From 56c621713d792b8bd2c08ec066c2957098d25cf2 Mon Sep 17 00:00:00 2001 From: Paul O'Reilly Date: Mon, 3 Aug 2026 07:29:52 +1200 Subject: [PATCH] idle-draft: fill all free worker slots, not one per completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit try_submit() returned after a single submission and was only called at startup and once per completion, capping real concurrency at 1 task regardless of the parallel setting — the ThreadPoolExecutor pool was sized but never filled. Loop until every free slot is filled or no eligible candidate remains, per spec §10 (wording sharpened to make the whole-pool semantics explicit). No stub-claude harness exists yet to test dispatch concurrency end-to-end; verified live against the real queue (36 candidates, parallel=20). Claude-Session: https://claude.ai/code/session_01Lgv4Qn82boNFC1jn8QXSNw --- scripts/idle-draft | 35 +++++++++++++++++++---------------- specs/idle-draft.spec.md | 6 ++++-- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/scripts/idle-draft b/scripts/idle-draft index 3928531..7ef37b4 100755 --- a/scripts/idle-draft +++ b/scripts/idle-draft @@ -1369,23 +1369,26 @@ def run_dispatch(args, config: dict, repo: Path, state_path: Path) -> int: def try_submit(): nonlocal dispatched_count - if args.once and dispatched_count >= 1: - return nonlocal queue - queue = build_ready_queue(repo, config, state, in_flight) - for cand in queue: - provider, _ = select_provider(cand["work_type"], config, gates, credential_ok) - if provider is None: - continue - cand = dict(cand) - cand["provider"] = provider - cand["before_pct"] = gates.get(provider, {}).get("seven_day_pct") - in_flight.add(cand["item_key"]) - dispatched_count += 1 - log_event(repo, f"dispatch: {cand['item_key']} work_type={cand['work_type']} provider={provider}") - fut = executor.submit(run_task, repo, config, cand, defaults["max_turns"], defaults["task_timeout"]) - futures[fut] = cand - return + while len(futures) < max(1, parallel): + if args.once and dispatched_count >= 1: + return + queue = build_ready_queue(repo, config, state, in_flight) + for cand in queue: + provider, _ = select_provider(cand["work_type"], config, gates, credential_ok) + if provider is None: + continue + cand = dict(cand) + cand["provider"] = provider + cand["before_pct"] = gates.get(provider, {}).get("seven_day_pct") + in_flight.add(cand["item_key"]) + dispatched_count += 1 + log_event(repo, f"dispatch: {cand['item_key']} work_type={cand['work_type']} provider={provider}") + fut = executor.submit(run_task, repo, config, cand, defaults["max_turns"], defaults["task_timeout"]) + futures[fut] = cand + break + else: + return try_submit() diff --git a/specs/idle-draft.spec.md b/specs/idle-draft.spec.md index b212803..ca7e562 100644 --- a/specs/idle-draft.spec.md +++ b/specs/idle-draft.spec.md @@ -218,8 +218,10 @@ next cycle); the walk continues to the next candidate. Up to `parallel` tasks run concurrently (`concurrent.futures.ThreadPoolExecutor`). Each completion is handled **serially** in the main thread (state updates and git commits never race). On every completion — and before the very first dispatch — the -gates (§6) are recomputed from a fresh probe (§5); a task is submitted to fill a free -worker slot only while an eligible `(item, work_type, provider)` triple remains. The +gates (§6) are recomputed from a fresh probe (§5); tasks are then submitted until +**every** free worker slot is filled or no eligible `(item, work_type, provider)` +triple remains — one completion may therefore trigger multiple submissions, and the +initial call before the first dispatch fills the whole pool, not one slot. The loop exits (dispatch mode, non-dryrun) when no eligible candidate remains. `--once` dispatches at most one task total, then exits without waiting for further slots.