From 092bca554a4d264ed9ba1010fe5cb1012e7e658f Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 17 Jul 2026 15:30:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(implement-loop):=20adaptive=20plan=20extra?= =?UTF-8?q?ction=20=E2=80=94=20batch=20small=20plans,=20guarded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-task plan-extract fan-out re-primes a fresh context per task (~59k real tokens each; 244 dispatches / 14.4M in the 206-run corpus) to re-read the same plan. Small plans now extract in ONE plan-extract-all call, gated by per-task byte estimates the index reports (ceiling 20k bytes — on BYTES, never task count: the #22 truncation is output-byte-driven). The #22 protection is strengthened, not traded: the batched result must carry EXACTLY the expected id set (count match, every id present, no empty text) or it is discarded wholesale and the per-task fan-out runs instead — a prefix-truncated batch drops trailing tasks and fails the count-match closed. No single-shot retry; the fan-out is the retry. Estimates are a routing heuristic only, never load-bearing for correctness. refs #29, refs #22 --- implement/workflows/implement-loop.js | 111 ++++++++++++++++++++++---- 1 file changed, 95 insertions(+), 16 deletions(-) diff --git a/implement/workflows/implement-loop.js b/implement/workflows/implement-loop.js index 7c8272e..c149464 100644 --- a/implement/workflows/implement-loop.js +++ b/implement/workflows/implement-loop.js @@ -230,10 +230,41 @@ const TASK_INDEX_SCHEMA = { items: { type: 'object', additionalProperties: false, - required: ['id', 'title'], + required: ['id', 'title', 'approx_bytes'], properties: { id: { type: 'integer' }, title: { type: 'string', description: 'one-line title ONLY — never the task body' }, + approx_bytes: { + type: 'integer', + description: + "rough byte length of this task's verbatim body in the plan (headers + prose + code blocks). An " + + 'ESTIMATE for choosing the extraction strategy only — never load-bearing for correctness; when in ' + + 'doubt, estimate HIGH (an overestimate merely keeps the per-task fan-out).', + }, + }, + }, + }, + }, +} +// Batched extraction carrier (issue #29): every requested task's verbatim +// block in ONE response — used only under the byte ceiling below, with a +// deterministic completeness guard and a fan-out fallback (issue #22 is the +// reason this is adaptive, not unconditional). +const TASK_TEXTS_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['tasks'], + properties: { + tasks: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['id', 'title', 'text'], + properties: { + id: { type: 'integer' }, + title: { type: 'string' }, + text: { type: 'string', description: 'verbatim task block from the plan (this one task only)' }, }, }, }, @@ -567,21 +598,69 @@ if (mode === 'mini') { expectedIds = enumeratedIds } - // Per-task verbatim extraction — read-only and order-independent, so fan out - // via parallel(). The requested id is captured in the closure and returned - // alongside the result, so association is by id, never by resolution order. - // parallel() yields null for a thunk that threw / whose agent died; agent() - // itself returns null on a terminal error — both surface as a missing id below. - const extractedTasks = await parallel( - expectedIds.map((id) => () => - agent( - `${STANDING}\n\nRead the plan at ${plan_path}. Extract EXACTLY task ${id} (1-based) as its VERBATIM block — ` + - 'the full task text as written, including any code blocks. Do not summarise, truncate, reorder, or merge in ' + - 'any other task. Return that one task only. Do not edit anything.', - { model: 'sonnet', effort: 'medium', label: `plan-extract:${id}`, phase: 'Per-task loop', schema: TASK_TEXT_SCHEMA }, - ).then((t) => ({ reqId: id, t })), - ), - ) + // Extraction strategy (issue #29, guarded against issue #22): the per-task + // fan-out re-primes a fresh context per task (~59k real tokens each in the + // 206-run corpus) to re-read the SAME plan — for a small plan that is pure + // priming overhead. Extraction is therefore adaptive on the index's byte + // estimates: + // • total estimate <= EXTRACT_ALL_BYTE_CEILING → ONE extract-all call + // carries every requested task's verbatim block. The ceiling is on + // BYTES, never task count — the #22 truncation (a schema-bound response + // self-limits to a well-formed PREFIX) is output-byte-driven: a 5-task + // plan with long code blocks can truncate where 10 one-liner tasks would + // not. The estimates are agent-reported, so they are a ROUTING heuristic + // only; correctness rests on the deterministic completeness guard below: + // the batched result must carry EXACTLY the expected id set (count + // match, every id present, no empty text) or it is discarded WHOLESALE + // and the fan-out runs instead — one bounded wasted call, never a + // truncated run. (A prefix-truncated batch drops trailing tasks, so the + // count-match catches it; association keys on the agent's self-reported + // ids, and the exact-set requirement makes a mislabelled response fail + // closed.) No single-shot retry: the fan-out IS the retry, and it is the + // truncation-proof path. + // • above the ceiling, estimates missing/implausible, or a failed + // extract-all → the per-task fan-out, unchanged: read-only and + // order-independent, requested id bound in the closure and returned + // alongside the result, so association is by id, never by resolution + // order. parallel() yields null for a thunk that threw / whose agent + // died; agent() itself returns null on a terminal error — both surface + // as a missing id below. + const EXTRACT_ALL_BYTE_CEILING = 20000 + const approxById = new Map() + for (const t of index.task_ids) if (!approxById.has(t.id)) approxById.set(t.id, t.approx_bytes) + const estimates = expectedIds.map((id) => approxById.get(id)) + const totalApprox = estimates.every((b) => Number.isInteger(b) && b >= 0) + ? estimates.reduce((s, b) => s + b, 0) + : null + let extractedTasks = null + if (expectedIds.length > 1 && totalApprox !== null && totalApprox <= EXTRACT_ALL_BYTE_CEILING) { + const all = await agent( + `${STANDING}\n\nRead the plan at ${plan_path}. Extract EXACTLY these tasks (1-based ids): ${expectedIds.join(', ')} — ` + + 'EACH as its VERBATIM block: the full task text as written, including any code blocks. Do not summarise, ' + + 'truncate, reorder, or merge tasks into each other. Return exactly those tasks, nothing else. Do not edit anything.', + { model: 'sonnet', effort: 'medium', label: 'plan-extract-all', phase: 'Per-task loop', schema: TASK_TEXTS_SCHEMA }, + ) + const returned = all && Array.isArray(all.tasks) ? all.tasks : [] + const byId = new Map() + for (const t of returned) { + if (t && Number.isInteger(t.id) && typeof t.text === 'string' && t.text.trim() && !byId.has(t.id)) byId.set(t.id, t) + } + if (returned.length === expectedIds.length && expectedIds.every((id) => byId.has(id))) { + extractedTasks = expectedIds.map((id) => ({ reqId: id, t: byId.get(id) })) + } + } + if (!extractedTasks) { + extractedTasks = await parallel( + expectedIds.map((id) => () => + agent( + `${STANDING}\n\nRead the plan at ${plan_path}. Extract EXACTLY task ${id} (1-based) as its VERBATIM block — ` + + 'the full task text as written, including any code blocks. Do not summarise, truncate, reorder, or merge in ' + + 'any other task. Return that one task only. Do not edit anything.', + { model: 'sonnet', effort: 'medium', label: `plan-extract:${id}`, phase: 'Per-task loop', schema: TASK_TEXT_SCHEMA }, + ).then((t) => ({ reqId: id, t })), + ), + ) + } // Guard (c) — every expected id must have produced non-empty verbatim text. // A null result (thunk threw, or agent died) or empty text is the truncation