agentxchain 2.91.0 → 2.92.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.91.0",
3
+ "version": "2.92.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,6 +5,7 @@ import chalk from 'chalk';
5
5
  import { loadConfig, loadLock, findProjectRoot } from '../lib/config.js';
6
6
  import { validateProject } from '../lib/validation.js';
7
7
  import { getWatchPid } from './watch.js';
8
+ import { getDashboardPid, getDashboardSession } from './dashboard.js';
8
9
  import { loadNormalizedConfig, detectConfigVersion } from '../lib/normalized-config.js';
9
10
  import { readDaemonState, evaluateDaemonStatus } from '../lib/run-schedule.js';
10
11
  import { getGovernedVersionSurface, formatGovernedVersionLabel } from '../lib/protocol-version.js';
@@ -219,6 +220,21 @@ function governedDoctor(root, rawConfig, opts) {
219
220
  }
220
221
  }
221
222
 
223
+ // 9. Dashboard session health (unconditional — dashboard is a general operator surface)
224
+ {
225
+ const dashPid = getDashboardPid(root);
226
+ const dashSession = getDashboardSession(root);
227
+ if (dashPid && dashSession) {
228
+ checks.push({ id: 'dashboard_session', name: 'Dashboard session', level: 'pass', detail: `Dashboard running at ${dashSession.url} (PID: ${dashPid})` });
229
+ } else if (dashPid && !dashSession) {
230
+ checks.push({ id: 'dashboard_session', name: 'Dashboard session', level: 'warn', detail: `Dashboard PID ${dashPid} alive but session file missing` });
231
+ } else if (!dashPid && dashSession) {
232
+ checks.push({ id: 'dashboard_session', name: 'Dashboard session', level: 'warn', detail: `Stale dashboard session files (PID ${dashSession.pid || '?'} not running)` });
233
+ } else {
234
+ checks.push({ id: 'dashboard_session', name: 'Dashboard session', level: 'info', detail: 'No dashboard session' });
235
+ }
236
+ }
237
+
222
238
  // Compute summary
223
239
  const failCount = checks.filter(c => c.level === 'fail').length;
224
240
  const warnCount = checks.filter(c => c.level === 'warn').length;
@@ -253,7 +269,9 @@ function governedDoctor(root, rawConfig, opts) {
253
269
  ? chalk.green('PASS')
254
270
  : c.level === 'warn'
255
271
  ? chalk.yellow('WARN')
256
- : chalk.red('FAIL');
272
+ : c.level === 'info'
273
+ ? chalk.dim('INFO')
274
+ : chalk.red('FAIL');
257
275
  console.log(` ${badge} ${c.name.padEnd(24)} ${chalk.dim(c.detail)}`);
258
276
  }
259
277
 
@@ -9,6 +9,7 @@ import { getConnectorHealth } from '../lib/connector-health.js';
9
9
  import { deriveWorkflowKitArtifacts } from '../lib/workflow-kit-artifacts.js';
10
10
  import { evaluateTimeouts } from '../lib/timeout-evaluator.js';
11
11
  import { summarizeRunProvenance } from '../lib/run-provenance.js';
12
+ import { getDashboardPid, getDashboardSession } from './dashboard.js';
12
13
 
13
14
  export async function statusCommand(opts) {
14
15
  const context = loadProjectContext();
@@ -87,6 +88,14 @@ function renderGovernedStatus(context, opts) {
87
88
  const workflowKitArtifacts = deriveWorkflowKitArtifacts(root, config, state);
88
89
 
89
90
  if (opts.json) {
91
+ const dashPid = getDashboardPid(root);
92
+ const dashSession = getDashboardSession(root);
93
+ const dashboardSessionObj = dashPid
94
+ ? { status: 'running', pid: dashPid, url: dashSession?.url || null, started_at: dashSession?.started_at || null }
95
+ : dashSession
96
+ ? { status: 'stale', pid: dashSession.pid || null, url: dashSession.url || null, started_at: dashSession.started_at || null }
97
+ : { status: 'not_running', pid: null, url: null, started_at: null };
98
+
90
99
  console.log(JSON.stringify({
91
100
  version,
92
101
  protocol_mode: config.protocol_mode,
@@ -99,6 +108,7 @@ function renderGovernedStatus(context, opts) {
99
108
  continuity,
100
109
  connector_health: connectorHealth,
101
110
  workflow_kit_artifacts: workflowKitArtifacts,
111
+ dashboard_session: dashboardSessionObj,
102
112
  }, null, 2));
103
113
  return;
104
114
  }
@@ -115,7 +115,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
115
115
  child = spawn(command, args, {
116
116
  cwd: runtime.cwd ? join(root, runtime.cwd) : root,
117
117
  stdio: ['pipe', 'pipe', 'pipe'],
118
- env: { ...process.env },
118
+ env: { ...process.env, AGENTXCHAIN_TURN_ID: turn.turn_id },
119
119
  });
120
120
  } catch (err) {
121
121
  resolve({ ok: false, error: `Failed to spawn "${command}": ${err.message}`, logs });
package/src/lib/export.js CHANGED
@@ -7,6 +7,7 @@ import { loadProjectContext, loadProjectState } from './config.js';
7
7
  import { loadCoordinatorConfig, COORDINATOR_CONFIG_FILE } from './coordinator-config.js';
8
8
  import { loadCoordinatorState } from './coordinator-state.js';
9
9
  import { normalizeRunProvenance } from './run-provenance.js';
10
+ import { getDashboardPid, getDashboardSession } from '../commands/dashboard.js';
10
11
 
11
12
  const EXPORT_SCHEMA_VERSION = '0.3';
12
13
 
@@ -23,6 +24,8 @@ const COORDINATOR_INCLUDED_ROOTS = [
23
24
  export const RUN_EXPORT_INCLUDED_ROOTS = [
24
25
  'agentxchain.json',
25
26
  'TALK.md',
27
+ '.agentxchain-dashboard.pid',
28
+ '.agentxchain-dashboard.json',
26
29
  '.agentxchain/state.json',
27
30
  '.agentxchain/session.json',
28
31
  '.agentxchain/history.jsonl',
@@ -166,6 +169,45 @@ function countDirectoryFiles(files, prefix) {
166
169
  return Object.keys(files).filter((path) => path.startsWith(`${prefix}/`)).length;
167
170
  }
168
171
 
172
+ function buildDashboardSessionSummary(root) {
173
+ const dashPid = getDashboardPid(root);
174
+ const dashSession = getDashboardSession(root);
175
+
176
+ if (dashPid && dashSession) {
177
+ return {
178
+ status: 'running',
179
+ pid: dashPid,
180
+ url: dashSession.url || null,
181
+ started_at: dashSession.started_at || null,
182
+ };
183
+ }
184
+
185
+ if (dashPid && !dashSession) {
186
+ return {
187
+ status: 'pid_only',
188
+ pid: dashPid,
189
+ url: null,
190
+ started_at: null,
191
+ };
192
+ }
193
+
194
+ if (!dashPid && dashSession) {
195
+ return {
196
+ status: 'stale',
197
+ pid: dashSession.pid || null,
198
+ url: dashSession.url || null,
199
+ started_at: dashSession.started_at || null,
200
+ };
201
+ }
202
+
203
+ return {
204
+ status: 'not_running',
205
+ pid: null,
206
+ url: null,
207
+ started_at: null,
208
+ };
209
+ }
210
+
169
211
  export function buildDelegationSummary(files) {
170
212
  const historyData = files['.agentxchain/history.jsonl']?.data;
171
213
  if (!Array.isArray(historyData)) {
@@ -404,6 +446,7 @@ export function buildRunExport(startDir = process.cwd()) {
404
446
  staging_artifact_files: countDirectoryFiles(files, '.agentxchain/staging'),
405
447
  intake_present: Object.keys(files).some((path) => path.startsWith('.agentxchain/intake/')),
406
448
  coordinator_present: Object.keys(files).some((path) => path.startsWith('.agentxchain/multirepo/')),
449
+ dashboard_session: buildDashboardSessionSummary(root),
407
450
  delegation_summary: buildDelegationSummary(files),
408
451
  },
409
452
  workspace: buildRunWorkspaceMetadata(root),
@@ -799,6 +799,61 @@ function readJsonlEntries(root, relPath) {
799
799
  .filter(Boolean);
800
800
  }
801
801
 
802
+ function collectPendingConcurrentSiblingDeclarations(root, state, currentTurn, historyEntries = []) {
803
+ const concurrentIds = new Set(
804
+ Array.isArray(currentTurn?.concurrent_with)
805
+ ? currentTurn.concurrent_with.filter((id) => typeof id === 'string' && id.length > 0)
806
+ : [],
807
+ );
808
+ const activeTurns = getActiveTurns(state);
809
+ for (const turn of Object.values(activeTurns)) {
810
+ if (
811
+ turn?.turn_id !== currentTurn?.turn_id
812
+ && Array.isArray(turn?.concurrent_with)
813
+ && turn.concurrent_with.includes(currentTurn?.turn_id)
814
+ ) {
815
+ concurrentIds.add(turn.turn_id);
816
+ }
817
+ }
818
+ if (concurrentIds.size === 0) {
819
+ return [];
820
+ }
821
+
822
+ const acceptedIds = new Set(
823
+ (Array.isArray(historyEntries) ? historyEntries : [])
824
+ .map((entry) => entry?.turn_id)
825
+ .filter((turnId) => typeof turnId === 'string' && turnId.length > 0),
826
+ );
827
+ const declarations = [];
828
+
829
+ for (const siblingTurnId of concurrentIds) {
830
+ if (siblingTurnId === currentTurn?.turn_id || acceptedIds.has(siblingTurnId) || !activeTurns[siblingTurnId]) {
831
+ continue;
832
+ }
833
+
834
+ const stagedSibling = loadHookStagedTurn(root, getTurnStagingResultPath(siblingTurnId));
835
+ const siblingResult = stagedSibling.turnResult;
836
+ if (!siblingResult || siblingResult.turn_id !== siblingTurnId) {
837
+ continue;
838
+ }
839
+
840
+ const siblingFiles = [...new Set(
841
+ (Array.isArray(siblingResult.files_changed) ? siblingResult.files_changed : [])
842
+ .filter((filePath) => typeof filePath === 'string' && filePath.length > 0),
843
+ )];
844
+ if (siblingFiles.length === 0) {
845
+ continue;
846
+ }
847
+
848
+ declarations.push({
849
+ turn_id: siblingTurnId,
850
+ files_changed: siblingFiles,
851
+ });
852
+ }
853
+
854
+ return declarations;
855
+ }
856
+
802
857
  function getObservedFiles(entry) {
803
858
  if (Array.isArray(entry?.observed_artifact?.files_changed)) {
804
859
  return entry.observed_artifact.files_changed;
@@ -2373,7 +2428,17 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2373
2428
  const baseline = currentTurn.baseline || null;
2374
2429
  const rawObservation = observeChanges(root, baseline);
2375
2430
  const historyEntries = readJsonlEntries(root, HISTORY_PATH);
2376
- const observation = attributeObservedChangesToTurn(rawObservation, currentTurn, historyEntries);
2431
+ const pendingConcurrentSiblingDeclarations = collectPendingConcurrentSiblingDeclarations(
2432
+ root,
2433
+ state,
2434
+ currentTurn,
2435
+ historyEntries,
2436
+ );
2437
+ const observation = attributeObservedChangesToTurn(rawObservation, currentTurn, historyEntries, {
2438
+ currentDeclaredFiles: turnResult.files_changed || [],
2439
+ concurrentSiblingIds: pendingConcurrentSiblingDeclarations.map((entry) => entry.turn_id),
2440
+ pendingConcurrentSiblingDeclarations,
2441
+ });
2377
2442
  const role = config.roles?.[turnResult.role];
2378
2443
  const runtimeId = turnResult.runtime_id;
2379
2444
  const runtime = config.runtimes?.[runtimeId];
@@ -2381,11 +2446,28 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2381
2446
  materializeDerivedReviewArtifact(root, turnResult, state, runtimeType, baseline);
2382
2447
  materializeDerivedProposalArtifact(root, turnResult, state, runtimeType);
2383
2448
  const writeAuthority = role?.write_authority || 'review_only';
2449
+
2450
+ // When concurrent siblings exist but have not yet been accepted, the
2451
+ // observation includes their file changes too. The attribution system
2452
+ // (attributeObservedChangesToTurn) only removes sibling files for
2453
+ // *later*-accepted turns. For the *first*-accepted concurrent turn,
2454
+ // undeclared files are expected noise from concurrency — downgrade to
2455
+ // warnings so the governance contract is not broken by turn-acceptance
2456
+ // ordering.
2457
+ const concurrentIds = new Set(
2458
+ Array.isArray(currentTurn.concurrent_with) ? currentTurn.concurrent_with : [],
2459
+ );
2460
+ const acceptedTurnIds = new Set(historyEntries.map(h => h.turn_id));
2461
+ const hasUnacceptedConcurrentSiblings = [...concurrentIds].some(id => !acceptedTurnIds.has(id));
2462
+
2384
2463
  const diffComparison = compareDeclaredVsObserved(
2385
2464
  turnResult.files_changed || [],
2386
2465
  observation.files_changed,
2387
2466
  writeAuthority,
2388
- { observation_available: observation.observation_available },
2467
+ {
2468
+ observation_available: observation.observation_available,
2469
+ has_unaccepted_concurrent_siblings: hasUnacceptedConcurrentSiblings,
2470
+ },
2389
2471
  );
2390
2472
  if (diffComparison.errors.length > 0) {
2391
2473
  return {
@@ -175,7 +175,7 @@ export function observeChanges(root, baseline) {
175
175
  };
176
176
  }
177
177
 
178
- export function attributeObservedChangesToTurn(observation, currentTurn, historyEntries = []) {
178
+ export function attributeObservedChangesToTurn(observation, currentTurn, historyEntries = [], options = {}) {
179
179
  const observedFiles = Array.isArray(observation?.files_changed) ? observation.files_changed : [];
180
180
  if (observedFiles.length === 0) {
181
181
  return observation;
@@ -184,6 +184,11 @@ export function attributeObservedChangesToTurn(observation, currentTurn, history
184
184
  const concurrentIds = new Set(
185
185
  Array.isArray(currentTurn?.concurrent_with) ? currentTurn.concurrent_with : [],
186
186
  );
187
+ for (const siblingId of Array.isArray(options.concurrentSiblingIds) ? options.concurrentSiblingIds : []) {
188
+ if (typeof siblingId === 'string' && siblingId.length > 0) {
189
+ concurrentIds.add(siblingId);
190
+ }
191
+ }
187
192
  if (concurrentIds.size === 0) {
188
193
  return observation;
189
194
  }
@@ -195,11 +200,17 @@ export function attributeObservedChangesToTurn(observation, currentTurn, history
195
200
  .filter((entry) => (
196
201
  Number.isInteger(entry?.accepted_sequence)
197
202
  && entry.accepted_sequence > assignedSequence
198
- && concurrentIds.has(entry.turn_id)
203
+ && (
204
+ concurrentIds.has(entry.turn_id)
205
+ || (Array.isArray(entry?.concurrent_with) && entry.concurrent_with.includes(currentTurn?.turn_id))
206
+ )
199
207
  ))
200
208
  .sort((left, right) => left.accepted_sequence - right.accepted_sequence);
201
209
 
202
- if (acceptedConcurrentSiblings.length === 0) {
210
+ const pendingConcurrentSiblingDeclarations = Array.isArray(options.pendingConcurrentSiblingDeclarations)
211
+ ? options.pendingConcurrentSiblingDeclarations
212
+ : [];
213
+ if (acceptedConcurrentSiblings.length === 0 && pendingConcurrentSiblingDeclarations.length === 0) {
203
214
  return observation;
204
215
  }
205
216
 
@@ -221,7 +232,20 @@ export function attributeObservedChangesToTurn(observation, currentTurn, history
221
232
  }
222
233
  }
223
234
 
224
- if (siblingMarkersByFile.size === 0) {
235
+ const currentDeclaredFiles = new Set(
236
+ Array.isArray(options.currentDeclaredFiles) ? options.currentDeclaredFiles : [],
237
+ );
238
+ const pendingConcurrentSiblingFiles = new Set();
239
+ for (const declaration of pendingConcurrentSiblingDeclarations) {
240
+ const siblingFiles = Array.isArray(declaration?.files_changed) ? declaration.files_changed : [];
241
+ for (const filePath of siblingFiles) {
242
+ if (typeof filePath === 'string' && filePath.length > 0 && !currentDeclaredFiles.has(filePath)) {
243
+ pendingConcurrentSiblingFiles.add(filePath);
244
+ }
245
+ }
246
+ }
247
+
248
+ if (siblingMarkersByFile.size === 0 && pendingConcurrentSiblingFiles.size === 0) {
225
249
  return observation;
226
250
  }
227
251
 
@@ -239,6 +263,10 @@ export function attributeObservedChangesToTurn(observation, currentTurn, history
239
263
  attributedToConcurrentSiblings.push(filePath);
240
264
  continue;
241
265
  }
266
+ if (pendingConcurrentSiblingFiles.has(filePath)) {
267
+ attributedToConcurrentSiblings.push(filePath);
268
+ continue;
269
+ }
242
270
  nextFiles.push(filePath);
243
271
  if (typeof currentMarker === 'string') {
244
272
  nextMarkers[filePath] = currentMarker;
@@ -503,7 +531,13 @@ export function compareDeclaredVsObserved(declared, observed, writeAuthority, op
503
531
 
504
532
  if (writeAuthority === 'authoritative') {
505
533
  if (undeclared.length > 0) {
506
- errors.push(`Undeclared file changes detected (observed but not in files_changed): ${undeclared.join(', ')}`);
534
+ if (options.has_unaccepted_concurrent_siblings) {
535
+ // Concurrent siblings may have written these files; downgrade to warning.
536
+ // The attribution system will handle later-accepted siblings correctly.
537
+ warnings.push(`Undeclared file changes detected (likely from concurrent sibling turns): ${undeclared.join(', ')}`);
538
+ } else {
539
+ errors.push(`Undeclared file changes detected (observed but not in files_changed): ${undeclared.join(', ')}`);
540
+ }
507
541
  }
508
542
  if (phantom.length > 0) {
509
543
  warnings.push(`Declared files not observed in actual diff: ${phantom.join(', ')}`);
package/src/lib/report.js CHANGED
@@ -5,6 +5,7 @@ import { normalizeRunProvenance, summarizeRunProvenance } from './run-provenance
5
5
  export const GOVERNANCE_REPORT_VERSION = '0.1';
6
6
 
7
7
  const VALID_DELEGATION_OUTCOMES = new Set(['completed', 'failed', 'mixed', 'pending']);
8
+ const VALID_DASHBOARD_SESSION_STATUSES = new Set(['running', 'pid_only', 'stale', 'not_running']);
8
9
 
9
10
  function normalizeDelegationSummary(summary) {
10
11
  if (!summary || typeof summary !== 'object' || Array.isArray(summary)) return null;
@@ -58,6 +59,37 @@ function extractDelegationSummary(artifact) {
58
59
  return normalizeDelegationSummary(buildDelegationSummary(artifact.files || {}));
59
60
  }
60
61
 
62
+ function normalizeDashboardSessionSummary(summary) {
63
+ if (!summary || typeof summary !== 'object' || Array.isArray(summary)) return null;
64
+ if (!VALID_DASHBOARD_SESSION_STATUSES.has(summary.status)) return null;
65
+ return {
66
+ status: summary.status,
67
+ pid: Number.isInteger(summary.pid) ? summary.pid : null,
68
+ url: typeof summary.url === 'string' && summary.url.length > 0 ? summary.url : null,
69
+ started_at: typeof summary.started_at === 'string' && summary.started_at.length > 0 ? summary.started_at : null,
70
+ };
71
+ }
72
+
73
+ function extractDashboardSessionSummary(artifact) {
74
+ return normalizeDashboardSessionSummary(artifact.summary?.dashboard_session);
75
+ }
76
+
77
+ function formatDashboardSessionLine(session) {
78
+ if (!session) return null;
79
+ switch (session.status) {
80
+ case 'running':
81
+ return `running at ${session.url || 'unknown url'} (PID: ${session.pid || '?'})`;
82
+ case 'pid_only':
83
+ return `pid_only (PID: ${session.pid || '?'}, session metadata missing)`;
84
+ case 'stale':
85
+ return `stale session files${session.pid ? ` (PID: ${session.pid})` : ''}${session.url ? ` at ${session.url}` : ''}`;
86
+ case 'not_running':
87
+ return 'not_running';
88
+ default:
89
+ return null;
90
+ }
91
+ }
92
+
61
93
  function yesNo(value) {
62
94
  return value ? 'yes' : 'no';
63
95
  }
@@ -916,6 +948,7 @@ function buildRunSubject(artifact) {
916
948
  const continuity = extractContinuityMetadata(artifact);
917
949
  const governanceEvents = extractGovernanceEventDigest(artifact);
918
950
  const delegationSummary = extractDelegationSummary(artifact);
951
+ const dashboardSession = extractDashboardSessionSummary(artifact);
919
952
 
920
953
  return {
921
954
  kind: 'governed_run',
@@ -942,6 +975,7 @@ function buildRunSubject(artifact) {
942
975
  active_roles: activeRoles,
943
976
  budget_status: normalizeBudgetStatus(artifact.state?.budget_status),
944
977
  cost_summary: computeCostSummary(turns),
978
+ dashboard_session: dashboardSession,
945
979
  created_at: timing.created_at,
946
980
  completed_at: timing.completed_at,
947
981
  duration_seconds: timing.duration_seconds,
@@ -1209,6 +1243,9 @@ export function formatGovernanceReportText(report) {
1209
1243
  if (run.inherited_context?.parent_run_id) {
1210
1244
  lines.push(`Inherited from: ${run.inherited_context.parent_run_id} (${run.inherited_context.parent_status || 'unknown'})`);
1211
1245
  }
1246
+ if (run.dashboard_session) {
1247
+ lines.push(`Dashboard session: ${formatDashboardSessionLine(run.dashboard_session)}`);
1248
+ }
1212
1249
 
1213
1250
  lines.push(
1214
1251
  `History entries: ${artifacts.history_entries}`,
@@ -1688,6 +1725,9 @@ export function formatGovernanceReportMarkdown(report) {
1688
1725
  if (run.inherited_context?.parent_run_id) {
1689
1726
  lines.push(`- Inherited from: \`${run.inherited_context.parent_run_id}\` (${run.inherited_context.parent_status || 'unknown'})`);
1690
1727
  }
1728
+ if (run.dashboard_session) {
1729
+ lines.push(`- Dashboard session: \`${formatDashboardSessionLine(run.dashboard_session)}\``);
1730
+ }
1691
1731
 
1692
1732
  lines.push(
1693
1733
  `- History entries: ${artifacts.history_entries}`,
@@ -217,7 +217,16 @@ async function executeParallelTurns(root, config, state, maxConcurrent, callback
217
217
 
218
218
  // If selectRole returns a role we already tried (or assigned), try
219
219
  // other eligible roles from the routing before giving up.
220
+ // Exception: when delegation queue is driving resolution, do not fill
221
+ // extra slots with non-delegation roles via the fallback — those roles
222
+ // would execute without delegation context and corrupt the lifecycle.
220
223
  if (triedRoles.has(roleId)) {
224
+ const hasPendingDelegations = Array.isArray(state?.delegation_queue) &&
225
+ state.delegation_queue.some(d => d.status === 'pending' || d.status === 'active');
226
+ const hasPendingReview = !!state?.pending_delegation_review;
227
+ if (hasPendingDelegations || hasPendingReview) {
228
+ break;
229
+ }
221
230
  const phase = state.phase;
222
231
  const allowed = config?.routing?.[phase]?.allowed_next_roles || [];
223
232
  const alternateFound = allowed.some((alt) => {
@@ -248,6 +257,13 @@ async function executeParallelTurns(root, config, state, maxConcurrent, callback
248
257
  turnsToDispatch.push({ turn: assignResult.turn, state: assignResult.state });
249
258
  emit({ type: 'turn_assigned', turn: assignResult.turn, role: roleId, state: assignResult.state });
250
259
 
260
+ // Delegation review is a coordination checkpoint — do not fill additional
261
+ // slots alongside it. The review must execute alone so it can assess all
262
+ // delegation results before the run continues.
263
+ if (assignResult.turn.delegation_review) {
264
+ break;
265
+ }
266
+
251
267
  // Reload state after assignment to get accurate active count
252
268
  state = loadState(root, config);
253
269
  activeCount = getActiveTurnCount(state);