feat(implement-loop): adaptive plan extraction — batch small plans, guarded

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
This commit is contained in:
2026-07-17 15:30:50 +02:00
parent 76c090c848
commit 092bca554a
+95 -16
View File
@@ -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