Classify failed accepts as errors, and fix parallel lane race/all misuse

Two of the three new findings, plus the bug that chasing them exposed in my own
earlier fix. The third is mitigated rather than broken; details below.

Failed accepts reported success:
live/completion.mjs only classifies a result as `error` when it carries
`mode: 'error'`. Everything else unhandled falls through to `agent_done` with an
ok ack, which is deliberate for the documented fallback paths (two tests pin it)
but wrong for a real failure. So `accept_receipt_conflict` reported success, and
reference/live.md's `handled: false` without `mode` bullet told the agent to
"read file, find markers, edit" — hand-applying a second accept on top of the one
the receipt already recorded.

The same hole swallowed `source_locked`, which is mine: the earlier commit made
lock contention return clean JSON so the agent could retry, but the classifier
turned that failure into agent_done/ok, so the accept was dequeued and silently
lost. Mark genuine failures with `mode: 'error'` through one `operationFailure`
helper, and give live.md a `mode: "error"` bullet with per-error guidance: retry
the same command on `source_locked`, never hand-edit, and on a receipt conflict
report what the session actually resolved to. The deliberate fallback and
markers-not-found handoffs stay untouched.

parallel-compact lane orchestration:
`Promise.race` settles on the first *settlement*, so one lane failing fast
rejected the whole first-variant step while two lanes were still on their way to
succeeding. `Promise.any` now takes the first success and only a total wipeout is
fatal, reporting every lane's reason. The tail step's `Promise.all` surfaced a
raw lane error non-deterministically; `Promise.allSettled` now reports how many
lanes failed and why. Added a `requestImpl` seam so lane orchestration is
testable without a provider key.

Not a defect: the browser releasing Accept before the source write. That is the
intended optimistic design, and it is safe because poll-lanes ranks accept at
priority 0 against generate at 2, so a queued accept is always leased before a
generate the user queues afterwards, even if the generate arrived first. Its
source write lands inside the poll script before the next generate preflights.
That invariant is load-bearing and had no tests at all; poll-lanes.mjs now has a
suite covering it plus lease and type filtering.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-17 16:10:57 -07:00
co-authored by Claude
parent 529184bbe4
commit 6cbb7ce8d1
6 changed files with 229 additions and 12 deletions
+34 -6
View File
@@ -163,17 +163,22 @@ export function resolveProviderSelection(providerNames, modelOverrides = {}) {
});
}
export function createProviderLiveAgent({ provider, model, strategy, liveSpec, onRecord = () => {} }) {
/**
* `requestImpl` overrides the per-lane model call. It exists so the lane
* orchestration (which lane wins, what happens when one fails) is testable
* without a provider key or a network round trip; production passes nothing.
*/
export function createProviderLiveAgent({ provider, model, strategy, liveSpec, onRecord = () => {}, requestImpl = null }) {
const strategyConfig = STRATEGIES[strategy];
if (!strategyConfig) throw new Error(`unknown strategy ${JSON.stringify(strategy)}`);
const languageModel = providerModel(provider, model);
const languageModel = requestImpl ? null : providerModel(provider, model);
const system = strategyConfig.promptMode === 'full-live-context'
? `${COMPACT_CONTRACT}\n\nFULL LIVE CONTEXT:\n${liveSpec}`
: COMPACT_CONTRACT;
const pendingParallel = new Map();
const pendingFirst = new Map();
const request = async ({ event, phase, lane = null, firstVariant = null }) => {
const request = requestImpl || (async ({ event, phase, lane = null, firstVariant = null }) => {
const startedAt = performance.now();
const expectedCount = Number(event.count);
const payload = benchmarkPayload(event, { phase, lane, firstVariant });
@@ -235,7 +240,7 @@ export function createProviderLiveAgent({ provider, model, strategy, liveSpec, o
}
}
throw lastError;
};
});
if (strategy === 'atomic-full') {
return {
@@ -253,15 +258,38 @@ export function createProviderLiveAgent({ provider, model, strategy, liveSpec, o
const laneEvent = { ...event, count: 1 };
return request({ event: laneEvent, phase: 'parallel-lane', lane }).then((output) => ({ lane, output }));
});
const first = await Promise.race(calls);
// Promise.any, not race: race settles on the first *settlement*, so one
// lane failing fast rejected the whole first-variant step while a slower
// lane was still on its way to succeeding. Only a total wipeout is fatal.
let first;
try {
first = await Promise.any(calls);
} catch (error) {
const reasons = (error?.errors || [error]).map((e) => e?.message || String(e));
throw new Error(`every parallel lane failed for ${event.id}: ${reasons.join('; ')}`);
}
pendingParallel.set(event.id, { calls, first });
return first.output;
},
async generateRemainingVariants(event) {
const pending = pendingParallel.get(event.id);
if (!pending) throw new Error(`parallel generation state missing for ${event.id}`);
const settled = await Promise.all(pending.calls);
// allSettled, not all: a lane that rejects after another already won the
// race must not throw its raw error from here. Collect every outcome and
// report the failures together, so the result does not depend on which
// lane happened to settle first.
const outcomes = await Promise.allSettled(pending.calls);
pendingParallel.delete(event.id);
const failures = outcomes
.filter((outcome) => outcome.status === 'rejected')
.map((outcome) => outcome.reason?.message || String(outcome.reason));
if (failures.length > 0) {
throw new Error(
`${failures.length} of ${pending.calls.length} parallel lanes failed for ${event.id}, `
+ `so the ${event.count}-variant set cannot be assembled: ${failures.join('; ')}`,
);
}
const settled = outcomes.map((outcome) => outcome.value);
const ordered = [pending.first, ...settled.filter((item) => item !== pending.first)];
const variants = ordered.map((item) => item.output.variants[0]);
const scopedCss = ordered.map((item, index) => remapSingleVariantCss(item.output.scopedCss, index + 1)).join('\n');
+1
View File
@@ -143,6 +143,7 @@ export const SUITES = {
'tests/live-insert-ui.test.mjs',
'tests/live-manual-edits-buffer.test.mjs',
'tests/live-poll.test.mjs',
'tests/live-poll-lanes.test.mjs',
'tests/live-poll-stream.test.mjs',
'tests/live-provider-benchmark.test.mjs',
'tests/live-recovery-commands.test.mjs',