agentxchain 2.107.0 → 2.108.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dashboard/app.js CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  } from './live-observer.js';
26
26
 
27
27
  const VIEWS = {
28
- timeline: { fetch: ['state', 'continuity', 'history', 'audit', 'annotations', 'connectors', 'coordinatorAudit', 'coordinatorAnnotations'], render: renderTimeline },
28
+ timeline: { fetch: ['state', 'continuity', 'history', 'events', 'audit', 'annotations', 'connectors', 'coordinatorAudit', 'coordinatorAnnotations'], render: renderTimeline },
29
29
  delegations: { fetch: ['state', 'history'], render: renderDelegations },
30
30
  ledger: { fetch: ['state', 'ledger', 'coordinatorState', 'coordinatorLedger', 'repoDecisionsSummary'], render: renderLedger },
31
31
  hooks: { fetch: ['audit', 'annotations', 'coordinatorAudit', 'coordinatorAnnotations'], render: renderHooks },
@@ -62,6 +62,7 @@ const API_MAP = {
62
62
  runHistory: '/api/run-history',
63
63
  timeouts: '/api/timeouts',
64
64
  coordinatorTimeouts: '/api/coordinator/timeouts',
65
+ events: '/api/events?type=turn_conflicted&limit=10',
65
66
  };
66
67
 
67
68
  const viewState = {
@@ -90,6 +90,11 @@ function formatTimestamp(iso) {
90
90
  }
91
91
  }
92
92
 
93
+ function formatPercent(value) {
94
+ if (typeof value !== 'number' || !Number.isFinite(value)) return null;
95
+ return `${Math.round(value * 100)}%`;
96
+ }
97
+
93
98
  function statusBadge(status) {
94
99
  const colors = {
95
100
  running: 'var(--green)',
@@ -206,6 +211,99 @@ function renderDelegationReview(review) {
206
211
  return `<div class="turn-detail"><span class="detail-label">Delegation Review:</span> <span class="mono">${esc(review.parent_turn_id || 'unknown')}</span> with ${esc(resultCount)} result${resultCount === 1 ? '' : 's'}</div>`;
207
212
  }
208
213
 
214
+ function collectConflictCards(state, events) {
215
+ const latestByTurn = new Map();
216
+
217
+ if (Array.isArray(events)) {
218
+ for (const event of [...events].reverse()) {
219
+ if (event?.event_type !== 'turn_conflicted') continue;
220
+ const turnId = event?.turn?.turn_id;
221
+ if (!turnId || latestByTurn.has(turnId)) continue;
222
+ latestByTurn.set(turnId, {
223
+ turn_id: turnId,
224
+ role_id: event?.turn?.role_id || 'unknown',
225
+ detected_at: event?.timestamp || null,
226
+ state_label: event?.status === 'blocked' ? 'conflict loop blocked run' : 'recent conflict',
227
+ detection_count: typeof event?.payload?.detection_count === 'number' ? event.payload.detection_count : null,
228
+ conflicting_files: Array.isArray(event?.payload?.conflicting_files) ? event.payload.conflicting_files : [],
229
+ accepted_since_turn_ids: Array.isArray(event?.payload?.accepted_since_turn_ids) ? event.payload.accepted_since_turn_ids : [],
230
+ overlap_ratio: typeof event?.payload?.overlap_ratio === 'number' ? event.payload.overlap_ratio : null,
231
+ });
232
+ }
233
+ }
234
+
235
+ const activeTurns = state?.active_turns ? Object.values(state.active_turns) : [];
236
+ for (const turn of activeTurns) {
237
+ if (turn?.status !== 'conflicted') continue;
238
+ if (latestByTurn.has(turn.turn_id)) {
239
+ const existing = latestByTurn.get(turn.turn_id);
240
+ latestByTurn.set(turn.turn_id, {
241
+ ...existing,
242
+ state_label: state?.blocked_reason?.category === 'conflict_loop' && state?.blocked_on?.includes(turn.turn_id)
243
+ ? 'conflict loop blocked run'
244
+ : 'active conflict',
245
+ });
246
+ continue;
247
+ }
248
+
249
+ const conflictError = turn?.conflict_state?.conflict_error || {};
250
+ latestByTurn.set(turn.turn_id, {
251
+ turn_id: turn.turn_id,
252
+ role_id: getRole(turn),
253
+ detected_at: turn?.conflict_state?.detected_at || null,
254
+ state_label: state?.blocked_reason?.category === 'conflict_loop' && state?.blocked_on?.includes(turn.turn_id)
255
+ ? 'conflict loop blocked run'
256
+ : 'active conflict',
257
+ detection_count: typeof turn?.conflict_state?.detection_count === 'number' ? turn.conflict_state.detection_count : null,
258
+ conflicting_files: Array.isArray(conflictError.conflicting_files) ? conflictError.conflicting_files : [],
259
+ accepted_since_turn_ids: Array.isArray(conflictError.accepted_since)
260
+ ? conflictError.accepted_since.map((entry) => entry?.turn_id).filter(Boolean)
261
+ : [],
262
+ overlap_ratio: typeof conflictError.overlap_ratio === 'number' ? conflictError.overlap_ratio : null,
263
+ });
264
+ }
265
+
266
+ return [...latestByTurn.values()].slice(0, 5);
267
+ }
268
+
269
+ function renderConflictPanel(state, events) {
270
+ const conflicts = collectConflictCards(state, events);
271
+ if (conflicts.length === 0) return '';
272
+
273
+ let html = `<div class="section"><h3>Conflicts</h3><div class="turn-list">`;
274
+ for (const conflict of conflicts) {
275
+ const detectedAt = formatTimestamp(conflict.detected_at);
276
+ const overlap = formatPercent(conflict.overlap_ratio);
277
+ html += `<div class="turn-card">
278
+ <div class="turn-header">
279
+ ${roleBadge(conflict.role_id)}
280
+ <span class="mono">${esc(conflict.turn_id)}</span>
281
+ ${statusBadge('conflicted')}
282
+ </div>
283
+ <div class="turn-detail"><span class="detail-label">Scope:</span> ${esc(conflict.state_label)}</div>`;
284
+
285
+ if (detectedAt) {
286
+ html += `<div class="turn-detail"><span class="detail-label">Detected:</span> ${esc(detectedAt)}</div>`;
287
+ }
288
+ if (conflict.conflicting_files.length > 0) {
289
+ html += `<div class="turn-detail"><span class="detail-label">Files:</span> <span class="mono">${conflict.conflicting_files.map((file) => esc(file)).join(', ')}</span></div>`;
290
+ }
291
+ if (conflict.accepted_since_turn_ids.length > 0) {
292
+ html += `<div class="turn-detail"><span class="detail-label">Accepted since:</span> <span class="mono">${conflict.accepted_since_turn_ids.map((turnId) => esc(turnId)).join(', ')}</span></div>`;
293
+ }
294
+ if (overlap) {
295
+ html += `<div class="turn-detail"><span class="detail-label">Overlap:</span> ${esc(overlap)}</div>`;
296
+ }
297
+ if (conflict.detection_count != null) {
298
+ html += `<div class="turn-detail"><span class="detail-label">Detection count:</span> ${esc(conflict.detection_count)}</div>`;
299
+ }
300
+
301
+ html += `</div>`;
302
+ }
303
+ html += `</div></div>`;
304
+ return html;
305
+ }
306
+
209
307
  function renderContinuityPanel(continuity) {
210
308
  if (!continuity) return '';
211
309
 
@@ -306,7 +404,7 @@ function renderConnectorHealthPanel(connectorsPayload) {
306
404
 
307
405
  export { formatDuration, computeElapsed, formatTimestamp };
308
406
 
309
- export function render({ state, continuity, history, annotations, audit, connectors, coordinatorAudit = null, coordinatorAnnotations = null, liveMeta = null }) {
407
+ export function render({ state, continuity, history, events = null, annotations, audit, connectors, coordinatorAudit = null, coordinatorAnnotations = null, liveMeta = null }) {
310
408
  if (!state) {
311
409
  return `<div class="placeholder"><h2>No Run</h2><p>No governed run found. Start one with <code class="mono">agentxchain init --governed</code></p></div>`;
312
410
  }
@@ -330,6 +428,7 @@ export function render({ state, continuity, history, annotations, audit, connect
330
428
 
331
429
  html += renderContinuityPanel(continuity);
332
430
  html += renderConnectorHealthPanel(connectors);
431
+ html += renderConflictPanel(state, events);
333
432
 
334
433
  // Active turns
335
434
  if (activeTurns.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.107.0",
3
+ "version": "2.108.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -61,6 +61,9 @@ function printEvent(evt) {
61
61
  const runId = evt.run_id ? evt.run_id.slice(0, 12) : '—';
62
62
  const phase = evt.phase || '—';
63
63
  const turnInfo = evt.turn?.role_id ? ` [${evt.turn.role_id}]` : '';
64
+ const conflictDetail = evt.event_type === 'turn_conflicted'
65
+ ? ` — ${formatConflictDetail(evt)}`
66
+ : '';
64
67
  const rejectionDetail = evt.event_type === 'turn_rejected' && evt.payload?.reason
65
68
  ? ` — ${evt.payload.reason}${evt.payload.failed_stage ? ` (${evt.payload.failed_stage})` : ''}`
66
69
  : '';
@@ -70,7 +73,34 @@ function printEvent(evt) {
70
73
  const gateFailedDetail = evt.event_type === 'gate_failed' && evt.payload?.from_phase
71
74
  ? ` ${evt.payload.from_phase} → ${evt.payload.to_phase || '?'}${evt.payload.reasons?.length ? ` — ${evt.payload.reasons[0]}` : ''}${evt.payload.gate_id ? ` (${evt.payload.gate_id})` : ''}`
72
75
  : '';
73
- console.log(`${chalk.dim(ts)} ${type} ${chalk.cyan(runId)} ${phase}${turnInfo}${rejectionDetail}${phaseTransitionDetail}${gateFailedDetail}`);
76
+ console.log(`${chalk.dim(ts)} ${type} ${chalk.cyan(runId)} ${phase}${turnInfo}${conflictDetail}${rejectionDetail}${phaseTransitionDetail}${gateFailedDetail}`);
77
+ }
78
+
79
+ function formatConflictDetail(evt) {
80
+ const payload = evt.payload || {};
81
+ const fileSummary = summarizeList(payload.conflicting_files, 3) || 'unknown files';
82
+ const overlapRatio = typeof payload.overlap_ratio === 'number'
83
+ ? `${Math.round(payload.overlap_ratio * 100)}% overlap`
84
+ : null;
85
+ const detectionCount = Number.isInteger(payload.detection_count)
86
+ ? `detection ${payload.detection_count}`
87
+ : null;
88
+ const turnSummary = summarizeList(payload.accepted_since_turn_ids, 2);
89
+ const parts = [fileSummary, overlapRatio, detectionCount];
90
+ if (turnSummary) {
91
+ parts.push(`accepted since ${turnSummary}`);
92
+ }
93
+ if (evt.status === 'blocked') {
94
+ parts.push('run blocked');
95
+ }
96
+ return parts.filter(Boolean).join(' | ');
97
+ }
98
+
99
+ function summarizeList(items, limit) {
100
+ if (!Array.isArray(items) || items.length === 0) return '';
101
+ const shown = items.slice(0, limit).join(', ');
102
+ if (items.length <= limit) return shown;
103
+ return `${shown} +${items.length - limit} more`;
74
104
  }
75
105
 
76
106
  function colorEventType(type) {
@@ -81,12 +111,14 @@ function colorEventType(type) {
81
111
  turn_dispatched: chalk.blue,
82
112
  turn_accepted: chalk.green,
83
113
  turn_rejected: chalk.yellow,
114
+ turn_conflicted: chalk.redBright,
84
115
  phase_entered: chalk.magenta,
85
116
  escalation_raised: chalk.red.bold,
86
117
  escalation_resolved: chalk.green,
87
118
  gate_pending: chalk.yellow,
88
119
  gate_approved: chalk.green,
89
120
  gate_failed: chalk.red,
121
+ budget_exceeded_warn: chalk.yellowBright,
90
122
  };
91
123
  const colorFn = colors[type] || chalk.white;
92
124
  return colorFn(pad(type, 22));
@@ -2652,11 +2652,32 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2652
2652
  },
2653
2653
  });
2654
2654
 
2655
+ // DEC-RUN-LOOP-CONFLICT-002: Persist turn_conflicted as a durable run event
2656
+ emitRunEvent(root, 'turn_conflicted', {
2657
+ run_id: state.run_id,
2658
+ phase: state.phase,
2659
+ status: updatedState.status,
2660
+ turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
2661
+ payload: {
2662
+ error_code: 'conflict',
2663
+ detection_count: detectionCount,
2664
+ conflicting_files: conflict.conflicting_files,
2665
+ accepted_since_turn_ids: conflict.accepted_since.map(entry => entry.turn_id),
2666
+ overlap_ratio: conflict.overlap_ratio,
2667
+ },
2668
+ });
2669
+
2655
2670
  writeState(root, updatedState);
2656
2671
 
2657
2672
  // DEC-RHTR-SPEC: Record conflict_loop blocked outcome in cross-run history (non-fatal)
2658
2673
  if (updatedState.status === 'blocked') {
2659
2674
  recordRunHistory(root, updatedState, config, 'blocked');
2675
+ // DEC-CONFLICT-NOTIFY-001: Emit run_blocked notification for conflict-loop exhaustion
2676
+ emitBlockedNotification(root, config, updatedState, {
2677
+ category: 'conflict_loop',
2678
+ blockedOn: updatedState.blocked_on,
2679
+ recovery: updatedState.blocked_reason?.recovery || null,
2680
+ }, currentTurn);
2660
2681
  }
2661
2682
 
2662
2683
  return {
package/src/lib/report.js CHANGED
@@ -119,6 +119,34 @@ function yesNo(value) {
119
119
  return value ? 'yes' : 'no';
120
120
  }
121
121
 
122
+ function normalizeConflictingFiles(conflict) {
123
+ if (!conflict || typeof conflict !== 'object' || Array.isArray(conflict)) return [];
124
+ if (Array.isArray(conflict.conflicting_files)) {
125
+ return conflict.conflicting_files.filter((entry) => typeof entry === 'string' && entry.length > 0);
126
+ }
127
+ if (Array.isArray(conflict.files)) {
128
+ return conflict.files.filter((entry) => typeof entry === 'string' && entry.length > 0);
129
+ }
130
+ return [];
131
+ }
132
+
133
+ function normalizeAcceptedSinceTurnIds(conflict) {
134
+ if (!conflict || typeof conflict !== 'object' || Array.isArray(conflict)) return [];
135
+ if (Array.isArray(conflict.accepted_since_turn_ids)) {
136
+ return conflict.accepted_since_turn_ids.filter((entry) => typeof entry === 'string' && entry.length > 0);
137
+ }
138
+ if (Array.isArray(conflict.accepted_since)) {
139
+ return conflict.accepted_since
140
+ .map((entry) => {
141
+ if (typeof entry === 'string') return entry;
142
+ if (entry && typeof entry === 'object' && typeof entry.turn_id === 'string') return entry.turn_id;
143
+ return null;
144
+ })
145
+ .filter(Boolean);
146
+ }
147
+ return [];
148
+ }
149
+
122
150
  function summarizeBlockedOn(blockedOn) {
123
151
  if (!blockedOn) return 'none';
124
152
  if (typeof blockedOn === 'string') return blockedOn;
@@ -143,6 +171,9 @@ function renderGovernanceEventDetailText(lines, evt, indent) {
143
171
  if (evt.conflicting_files?.length > 0) {
144
172
  lines.push(`${indent}files: ${evt.conflicting_files.join(', ')}`);
145
173
  }
174
+ if (evt.accepted_since_turn_ids?.length > 0) {
175
+ lines.push(`${indent}accepted since: ${evt.accepted_since_turn_ids.join(', ')}`);
176
+ }
146
177
  if (evt.overlap_ratio != null) {
147
178
  lines.push(`${indent}overlap: ${(evt.overlap_ratio * 100).toFixed(0)}%`);
148
179
  }
@@ -151,8 +182,23 @@ function renderGovernanceEventDetailText(lines, evt, indent) {
151
182
  if (evt.conflicting_files?.length > 0) {
152
183
  lines.push(`${indent}files: ${evt.conflicting_files.join(', ')}`);
153
184
  }
185
+ if (evt.accepted_since_turn_ids?.length > 0) {
186
+ lines.push(`${indent}accepted since: ${evt.accepted_since_turn_ids.join(', ')}`);
187
+ }
188
+ if (evt.operator_reason) {
189
+ lines.push(`${indent}operator reason: ${evt.operator_reason}`);
190
+ }
154
191
  break;
155
192
  case 'conflict_resolution_selected':
193
+ if (evt.conflicting_files?.length > 0) {
194
+ lines.push(`${indent}files: ${evt.conflicting_files.join(', ')}`);
195
+ }
196
+ if (evt.accepted_since_turn_ids?.length > 0) {
197
+ lines.push(`${indent}accepted since: ${evt.accepted_since_turn_ids.join(', ')}`);
198
+ }
199
+ if (evt.overlap_ratio != null) {
200
+ lines.push(`${indent}overlap: ${(evt.overlap_ratio * 100).toFixed(0)}%`);
201
+ }
156
202
  if (evt.resolution_method) {
157
203
  lines.push(`${indent}resolution: ${evt.resolution_method}`);
158
204
  }
@@ -179,6 +225,9 @@ function renderGovernanceEventDetailMarkdown(lines, evt) {
179
225
  if (evt.conflicting_files?.length > 0) {
180
226
  lines.push(` - Files: ${evt.conflicting_files.map((f) => `\`${f}\``).join(', ')}`);
181
227
  }
228
+ if (evt.accepted_since_turn_ids?.length > 0) {
229
+ lines.push(` - Accepted since: ${evt.accepted_since_turn_ids.map((turnId) => `\`${turnId}\``).join(', ')}`);
230
+ }
182
231
  if (evt.overlap_ratio != null) {
183
232
  lines.push(` - Overlap: ${(evt.overlap_ratio * 100).toFixed(0)}%`);
184
233
  }
@@ -187,8 +236,21 @@ function renderGovernanceEventDetailMarkdown(lines, evt) {
187
236
  if (evt.conflicting_files?.length > 0) {
188
237
  lines.push(` - Files: ${evt.conflicting_files.map((f) => `\`${f}\``).join(', ')}`);
189
238
  }
239
+ if (evt.accepted_since_turn_ids?.length > 0) {
240
+ lines.push(` - Accepted since: ${evt.accepted_since_turn_ids.map((turnId) => `\`${turnId}\``).join(', ')}`);
241
+ }
242
+ if (evt.operator_reason) lines.push(` - Operator reason: ${evt.operator_reason}`);
190
243
  break;
191
244
  case 'conflict_resolution_selected':
245
+ if (evt.conflicting_files?.length > 0) {
246
+ lines.push(` - Files: ${evt.conflicting_files.map((f) => `\`${f}\``).join(', ')}`);
247
+ }
248
+ if (evt.accepted_since_turn_ids?.length > 0) {
249
+ lines.push(` - Accepted since: ${evt.accepted_since_turn_ids.map((turnId) => `\`${turnId}\``).join(', ')}`);
250
+ }
251
+ if (evt.overlap_ratio != null) {
252
+ lines.push(` - Overlap: ${(evt.overlap_ratio * 100).toFixed(0)}%`);
253
+ }
192
254
  if (evt.resolution_method) lines.push(` - Resolution: \`${evt.resolution_method}\``);
193
255
  break;
194
256
  case 'operator_escalated':
@@ -480,14 +542,21 @@ function extractGovernanceEventDigest(artifact, relPath = '.agentxchain/decision
480
542
  })) : [];
481
543
  break;
482
544
  case 'conflict_detected':
483
- base.conflicting_files = Array.isArray(d.conflict?.files) ? d.conflict.files : [];
545
+ base.conflicting_files = normalizeConflictingFiles(d.conflict);
546
+ base.accepted_since_turn_ids = normalizeAcceptedSinceTurnIds(d.conflict);
484
547
  base.overlap_ratio = typeof d.conflict?.overlap_ratio === 'number' ? d.conflict.overlap_ratio : null;
485
548
  break;
486
549
  case 'conflict_rejected':
487
- base.conflicting_files = Array.isArray(d.conflict?.files) ? d.conflict.files : [];
550
+ base.conflicting_files = normalizeConflictingFiles(d.conflict);
551
+ base.accepted_since_turn_ids = normalizeAcceptedSinceTurnIds(d.conflict);
552
+ base.overlap_ratio = typeof d.conflict?.overlap_ratio === 'number' ? d.conflict.overlap_ratio : null;
553
+ base.operator_reason = d.operator_reason || null;
488
554
  break;
489
555
  case 'conflict_resolution_selected':
490
- base.resolution_method = d.conflict?.resolution || null;
556
+ base.conflicting_files = normalizeConflictingFiles(d.conflict);
557
+ base.accepted_since_turn_ids = normalizeAcceptedSinceTurnIds(d.conflict);
558
+ base.overlap_ratio = typeof d.conflict?.overlap_ratio === 'number' ? d.conflict.overlap_ratio : null;
559
+ base.resolution_method = d.resolution_chosen || d.conflict?.resolution || null;
491
560
  break;
492
561
  case 'operator_escalated':
493
562
  base.blocked_on = d.blocked_on || null;
@@ -2420,7 +2489,19 @@ function renderHtmlGovEventDetail(evt) {
2420
2489
  break;
2421
2490
  case 'conflict_detected':
2422
2491
  if (evt.conflicting_files?.length > 0) parts.push(`<li>Files: ${evt.conflicting_files.map((f) => `<code>${esc(f)}</code>`).join(', ')}</li>`);
2492
+ if (evt.accepted_since_turn_ids?.length > 0) parts.push(`<li>Accepted since: ${evt.accepted_since_turn_ids.map((turnId) => `<code>${esc(turnId)}</code>`).join(', ')}</li>`);
2493
+ if (evt.overlap_ratio != null) parts.push(`<li>Overlap: ${(evt.overlap_ratio * 100).toFixed(0)}%</li>`);
2494
+ break;
2495
+ case 'conflict_rejected':
2496
+ if (evt.conflicting_files?.length > 0) parts.push(`<li>Files: ${evt.conflicting_files.map((f) => `<code>${esc(f)}</code>`).join(', ')}</li>`);
2497
+ if (evt.accepted_since_turn_ids?.length > 0) parts.push(`<li>Accepted since: ${evt.accepted_since_turn_ids.map((turnId) => `<code>${esc(turnId)}</code>`).join(', ')}</li>`);
2498
+ if (evt.operator_reason) parts.push(`<li>Operator reason: ${esc(evt.operator_reason)}</li>`);
2499
+ break;
2500
+ case 'conflict_resolution_selected':
2501
+ if (evt.conflicting_files?.length > 0) parts.push(`<li>Files: ${evt.conflicting_files.map((f) => `<code>${esc(f)}</code>`).join(', ')}</li>`);
2502
+ if (evt.accepted_since_turn_ids?.length > 0) parts.push(`<li>Accepted since: ${evt.accepted_since_turn_ids.map((turnId) => `<code>${esc(turnId)}</code>`).join(', ')}</li>`);
2423
2503
  if (evt.overlap_ratio != null) parts.push(`<li>Overlap: ${(evt.overlap_ratio * 100).toFixed(0)}%</li>`);
2504
+ if (evt.resolution_method) parts.push(`<li>Resolution: <code>${esc(evt.resolution_method)}</code></li>`);
2424
2505
  break;
2425
2506
  case 'operator_escalated':
2426
2507
  if (evt.reason) parts.push(`<li>Reason: ${esc(evt.reason)}</li>`);
@@ -17,6 +17,7 @@ export const VALID_RUN_EVENTS = [
17
17
  'turn_dispatched',
18
18
  'turn_accepted',
19
19
  'turn_rejected',
20
+ 'turn_conflicted',
20
21
  'run_blocked',
21
22
  'run_completed',
22
23
  'escalation_raised',
@@ -340,6 +340,26 @@ async function executeParallelTurns(root, config, state, maxConcurrent, callback
340
340
  const acceptResult = acceptTurn(root, config, { turnId: turn.turn_id });
341
341
  if (!acceptResult.ok) {
342
342
  errors.push(`acceptTurn(${roleId}): ${acceptResult.error}`);
343
+
344
+ // Conflict-aware handling (DEC-RUN-LOOP-CONFLICT-001)
345
+ if (acceptResult.error_code === 'conflict') {
346
+ history.push({
347
+ role: roleId, turn_id: turn.turn_id, accepted: false,
348
+ error_code: 'conflict', accept_error: acceptResult.error,
349
+ conflict: acceptResult.conflict,
350
+ });
351
+ emit({
352
+ type: 'turn_conflicted', turn, role: roleId,
353
+ error_code: 'conflict', conflict: acceptResult.conflict,
354
+ state: acceptResult.state,
355
+ });
356
+ if (acceptResult.state?.status === 'blocked') {
357
+ emit({ type: 'blocked', state: acceptResult.state });
358
+ return { terminal: true, ok: false, stop_reason: 'conflict_loop', history, acceptedCount };
359
+ }
360
+ continue;
361
+ }
362
+
343
363
  // Record failure but try other turns
344
364
  history.push({ role: roleId, turn_id: turn.turn_id, accepted: false, accept_error: acceptResult.error });
345
365
  continue;
@@ -372,8 +392,10 @@ async function executeParallelTurns(root, config, state, maxConcurrent, callback
372
392
  if (acceptedCount === 0 && history.length > 0) {
373
393
  const allFailed = history.every(h => !h.accepted);
374
394
  if (allFailed) {
375
- errors.push('All parallel turns failed acceptance — stalled');
376
- return { terminal: true, ok: false, stop_reason: 'blocked', history, acceptedCount };
395
+ const allConflicts = history.every(h => h.error_code === 'conflict');
396
+ const stopReason = allConflicts ? 'conflict_stall' : 'blocked';
397
+ errors.push(`All parallel turns failed acceptance — ${stopReason}`);
398
+ return { terminal: true, ok: false, stop_reason: stopReason, history, acceptedCount };
377
399
  }
378
400
  }
379
401
 
@@ -419,6 +441,29 @@ async function dispatchAndProcess(root, config, turn, assignState, callbacks, em
419
441
  const acceptResult = acceptTurn(root, config);
420
442
  if (!acceptResult.ok) {
421
443
  errors.push(`acceptTurn(${roleId}): ${acceptResult.error}`);
444
+
445
+ // Conflict-aware handling (DEC-RUN-LOOP-CONFLICT-001)
446
+ if (acceptResult.error_code === 'conflict') {
447
+ history.push({
448
+ role: roleId, turn_id: turn.turn_id, accepted: false,
449
+ error_code: 'conflict', accept_error: acceptResult.error,
450
+ conflict: acceptResult.conflict,
451
+ });
452
+ emit({
453
+ type: 'turn_conflicted', turn, role: roleId,
454
+ error_code: 'conflict', conflict: acceptResult.conflict,
455
+ state: acceptResult.state,
456
+ });
457
+ // If the resulting state is blocked (conflict_loop), terminate
458
+ if (acceptResult.state?.status === 'blocked') {
459
+ emit({ type: 'blocked', state: acceptResult.state });
460
+ return { terminal: true, ok: false, stop_reason: 'conflict_loop', history };
461
+ }
462
+ // Otherwise the turn is conflicted but the run is still active — let the
463
+ // main loop re-enter and try another role or handle the paused state
464
+ return { terminal: false, accepted: false, history };
465
+ }
466
+
422
467
  return { terminal: true, ok: false, stop_reason: 'blocked', history };
423
468
  }
424
469