From 5ffbd1a86afff60a445de1ae72d1ecad4e29baba Mon Sep 17 00:00:00 2001 From: Fini Date: Tue, 5 May 2026 01:09:23 +0800 Subject: [PATCH] fix(ai): partial JSONL failures hard-rollback so retry actually fires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../services/ai/element-tools-dispatcher.ts | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/apps/web/src/services/ai/element-tools-dispatcher.ts b/apps/web/src/services/ai/element-tools-dispatcher.ts index 31de6d14e..8b8a5a4ca 100644 --- a/apps/web/src/services/ai/element-tools-dispatcher.ts +++ b/apps/web/src/services/ai/element-tools-dispatcher.ts @@ -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);