* Process one node end-to-end: run → review (retry on fail) → dry-merge → * commit. Mutates node.status. Returns when the node reaches a terminal * state (committed or failed).
(node: TaskNode)
| 147 | * state (committed or failed). |
| 148 | */ |
| 149 | private async processNode(node: TaskNode): Promise<void> { |
| 150 | node.status = 'running'; |
| 151 | this.emit({ type: 'node-start', id: node.id, role: node.role }); |
| 152 | |
| 153 | while (true) { |
| 154 | // RUN |
| 155 | let result: WorkerResult; |
| 156 | try { |
| 157 | result = await this.hooks.runNode(node, this.opts.signal); |
| 158 | } catch (e: any) { |
| 159 | node.status = 'failed'; |
| 160 | this.emit({ type: 'node-failed', id: node.id, error: e?.message ?? String(e) }); |
| 161 | return; |
| 162 | } |
| 163 | node.result = result; |
| 164 | if (!result.ok) { |
| 165 | if (node.attempts < this.opts.maxAttempts - 1) { |
| 166 | node.attempts++; |
| 167 | this.emit({ type: 'node-retry', id: node.id, attempt: node.attempts, blockers: [result.error ?? 'worker failed'] }); |
| 168 | node.instruction += `\n\n[Retry ${node.attempts}] Previous attempt failed: ${result.error ?? 'unknown'}. Fix and complete.`; |
| 169 | continue; |
| 170 | } |
| 171 | node.status = 'failed'; |
| 172 | this.emit({ type: 'node-failed', id: node.id, error: result.error ?? 'worker failed' }); |
| 173 | return; |
| 174 | } |
| 175 | |
| 176 | // REVIEW |
| 177 | node.status = 'review'; |
| 178 | const verdict = await this.hooks.review(node, result, this.opts.signal); |
| 179 | this.emit({ type: 'node-review', id: node.id, passed: verdict.passed }); |
| 180 | if (!verdict.passed) { |
| 181 | if (node.attempts < this.opts.maxAttempts - 1) { |
| 182 | node.attempts++; |
| 183 | this.emit({ type: 'node-retry', id: node.id, attempt: node.attempts, blockers: verdict.blockers }); |
| 184 | node.instruction += |
| 185 | `\n\n[Retry ${node.attempts}] QA found blockers:\n` + |
| 186 | verdict.blockers.map(b => `- ${b}`).join('\n') + |
| 187 | `\nAddress every blocker. Re-output the complete file(s).`; |
| 188 | node.status = 'running'; |
| 189 | this.emit({ type: 'node-start', id: node.id, role: node.role }); |
| 190 | continue; |
| 191 | } |
| 192 | node.status = 'failed'; |
| 193 | this.emit({ type: 'node-failed', id: node.id, error: `QA blockers: ${verdict.blockers.join('; ')}` }); |
| 194 | return; |
| 195 | } |
| 196 | |
| 197 | // DRY-RUN MERGE |
| 198 | node.status = 'staged'; |
| 199 | const conflicts = await this.hooks.dryRunMerge(node, result); |
| 200 | if (conflicts.length > 0) { |
| 201 | for (const c of conflicts) { this.conflicts.push(c); this.emit({ type: 'conflict', record: c }); } |
| 202 | // A logical/unresolvable conflict fails the node; serialized conflicts |
| 203 | // are handled by the file-lock guard so shouldn't reach here. |
| 204 | const fatal = conflicts.some(c => c.resolution !== 'serialized'); |
| 205 | if (fatal) { |
| 206 | node.status = 'failed'; |