mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 07:06:45 +03:00
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:
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user