fix(ai): partial JSONL failures hard-rollback so retry actually fires

Previous "failed with non-empty insertedNodes" combination still bypassed
retry. orchestrator-sub-agent.ts gates retry on `result.nodes.length === 0`
— the partial inserts surfaced through DispatchResult.insertedNodes
flowed through to the subtask's `nodes` field, made it look non-empty,
and skipped the retry / minimal-skills / batch_design fallback chain.

Hard-rollback partial inserts on JSONL fallback failure: call
`store.removeNode(id)` for every root that did land, then return
`failed` with `insertedNodes: []`. The dispatcher's surrounding
history-batch wrapper absorbs both the addNode and removeNode calls so
the user-visible undo entry is a net no-op, and the retry condition
upstream now sees a genuinely empty result and re-runs the subtask
cleanly.

Three outcomes after this:
- All roots land → `applied` with full insertedNodes.
- Partial / total failure → `failed` with `insertedNodes: []` (any
  partial successes rolled back) so retry fires and the doc returns to
  its pre-dispatch state.
This commit is contained in:
Fini 2026-05-05 01:09:23 +08:00
parent 4043e9915f
commit 5ffbd1a86a

View file

@ -502,16 +502,28 @@ function applyBatchDesignAsJsonl(jsonl: string, ctx: DispatchContext): DispatchR
}
}
// Any failure to land a root is a hard failure — even a single missing
// root usually means the defaultParentId is stale, the model emitted a
// dangling _parent reference, or the doc state has drifted. Reporting
// `applied` for partial inserts would lie to the orchestrator: it
// checks `status === 'applied'` to decide whether to skip retry, so a
// partial would silently degrade the design without ever exercising
// the orchestrator's retry / minimal-skills / batch_design fallback
// chain. We still surface the partial-success roots in `insertedNodes`
// so the caller can clean them up (or roll back through the history
// batch wrapper) instead of leaving orphans.
// root usually means defaultParentId is stale, the model emitted a
// dangling _parent reference, or doc state has drifted. Returning
// `applied` for partial inserts would lie to the orchestrator (it
// checks `status === 'applied'` to decide whether to retry); returning
// `failed` with non-empty `insertedNodes` would also bypass retry,
// because the caller in orchestrator-sub-agent.ts gates retry on
// `result.nodes.length === 0` — the partial inserts get reported as
// the subtask's `nodes` and look "non-empty enough" to skip the retry
// / minimal-skills fallback chain. Hard-rollback the partial inserts
// so the doc returns to its pre-dispatch state, the surrounding
// history batch nets to zero, and the retry condition fires.
if (failedIds.length > 0) {
if (inserted.length > 0) {
const rollbackStore = useDocumentStore.getState();
for (const node of inserted) {
try {
rollbackStore.removeNode(node.id);
} catch {
/* already removed by an upstream cleanup — ignore */
}
}
}
const failedList = `${failedIds.slice(0, 4).join(', ')}${failedIds.length > 4 ? '…' : ''}`;
if (inserted.length === 0) {
return {
@ -532,8 +544,9 @@ function applyBatchDesignAsJsonl(jsonl: string, ctx: DispatchContext): DispatchR
message:
`batch_design.operations partial failure: ${inserted.length}/${tree.length} root(s) ` +
`landed, ${failedIds.length} did not (parent id '${parentId ?? 'page-root'}'). ` +
`Rolled back partial inserts so retry can re-run the subtask cleanly. ` +
`Failed root ids: ${failedList}`,
insertedNodes: inserted,
insertedNodes: [],
};
}
const totalLive = countDescendants(inserted);