agentxchain 2.129.0 → 2.130.1

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.
@@ -153,8 +153,21 @@ function buildRejectionValidation(root, state, config, opts) {
153
153
  [resolution.turn.turn_id]: resolution.turn,
154
154
  },
155
155
  };
156
+ const stagingPath = resolveStagingPath(root, resolution.turn.turn_id);
157
+ // BUG-22: If resolveStagingPath returns null, a stale result from another turn
158
+ // was detected. Reject with a clear diagnostic instead of consuming it.
159
+ if (stagingPath === null) {
160
+ return {
161
+ ok: true,
162
+ turn: resolution.turn,
163
+ validationResult: {
164
+ errors: [`Stale staging data: .agentxchain/staging/turn-result.json belongs to a different turn. Clean up with: rm .agentxchain/staging/turn-result.json`],
165
+ failed_stage: 'stale_staging',
166
+ },
167
+ };
168
+ }
156
169
  const validation = validateStagedTurnResult(root, projectedState, config, {
157
- stagingPath: resolveStagingPath(root, resolution.turn.turn_id),
170
+ stagingPath,
158
171
  });
159
172
  if (!validation.ok) {
160
173
  return {
@@ -213,8 +226,29 @@ function resolveTargetTurn(state, turnId) {
213
226
  }
214
227
 
215
228
  function resolveStagingPath(root, turnId) {
229
+ // BUG-22: Prefer turn-scoped staging path. Only fall back to legacy global
230
+ // staging if the global result's turn_id matches the active turn.
216
231
  const turnScopedPath = getTurnStagingResultPath(turnId);
217
- return existsSync(join(root, turnScopedPath)) ? turnScopedPath : '.agentxchain/staging/turn-result.json';
232
+ if (existsSync(join(root, turnScopedPath))) {
233
+ return turnScopedPath;
234
+ }
235
+
236
+ const legacyPath = '.agentxchain/staging/turn-result.json';
237
+ const legacyAbs = join(root, legacyPath);
238
+ if (existsSync(legacyAbs)) {
239
+ try {
240
+ const raw = JSON.parse(require('fs').readFileSync(legacyAbs, 'utf8'));
241
+ if (raw.turn_id && raw.turn_id !== turnId) {
242
+ // Stale result from a different turn — do not consume
243
+ return null;
244
+ }
245
+ } catch {
246
+ // Parse error — let the validator handle it
247
+ }
248
+ return legacyPath;
249
+ }
250
+
251
+ return legacyPath; // File doesn't exist — validator will report "not found"
218
252
  }
219
253
 
220
254
  function printDispatchBundleWarnings(bundleResult) {
@@ -19,10 +19,15 @@ import {
19
19
  getActiveTurns,
20
20
  getActiveTurnCount,
21
21
  reactivateGovernedRun,
22
+ detectStateBundleDesync,
22
23
  STATE_PATH,
23
24
  HISTORY_PATH,
24
25
  LEDGER_PATH,
25
26
  } from '../lib/governed-state.js';
27
+ import { writeDispatchBundle } from '../lib/dispatch-bundle.js';
28
+ import { getDispatchTurnDir } from '../lib/turn-paths.js';
29
+ import { consumeNextApprovedIntent } from '../lib/intake.js';
30
+ import { loadProjectState } from '../lib/config.js';
26
31
  import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
27
32
  import { deriveRecommendedContinuityAction } from '../lib/continuity-status.js';
28
33
  import { readSessionCheckpoint, writeSessionCheckpoint, captureBaselineRef, SESSION_PATH } from '../lib/session-checkpoint.js';
@@ -213,6 +218,25 @@ export async function restartCommand(opts) {
213
218
  process.exit(1);
214
219
  }
215
220
 
221
+ // ── BUG-18: State/bundle integrity check ─────────────────────────────────
222
+ const desync = detectStateBundleDesync(root, state);
223
+ if (!desync.ok) {
224
+ console.log(chalk.red('State/bundle integrity failure detected:'));
225
+ for (const entry of desync.desynced) {
226
+ console.log(chalk.red(` Active turn ${entry.turn_id} (${entry.role}) has no dispatch bundle at ${entry.expected_path}`));
227
+ }
228
+ console.log('');
229
+ console.log(chalk.dim('This is a ghost turn — state references an active turn but the dispatch files are missing.'));
230
+ console.log(chalk.dim('Recovery options:'));
231
+ for (const entry of desync.desynced) {
232
+ console.log(` ${chalk.cyan(`agentxchain reissue-turn --turn ${entry.turn_id} --reason "missing dispatch bundle"`)}`);
233
+ }
234
+ console.log(` ${chalk.cyan('agentxchain reject-turn --reason "ghost turn — missing dispatch bundle"')}`);
235
+ console.log('');
236
+ console.log(chalk.dim('Run `agentxchain doctor` for a full diagnostic.'));
237
+ process.exit(1);
238
+ }
239
+
216
240
  // ── Repo-drift detection ────────────────────────────────────────────────
217
241
  const driftWarnings = [];
218
242
  if (checkpoint?.baseline_ref) {
@@ -316,21 +340,61 @@ export async function restartCommand(opts) {
316
340
 
317
341
  // Assign next turn if no active turn exists
318
342
  if (activeTurnCount === 0) {
319
- const assignment = assignGovernedTurn(root, config, roleId);
320
- if (!assignment.ok) {
321
- console.log(chalk.red(`Failed to assign turn: ${assignment.error}`));
322
- process.exit(1);
343
+ // BUG-21 fix: consume approved intents (same as resume path) so intent_id
344
+ // propagates into turn metadata and all lifecycle events.
345
+ const consumed = consumeNextApprovedIntent(root, { role: roleId });
346
+ let assignedState;
347
+ let turnId;
348
+ let assignedRole = roleId;
349
+
350
+ if (consumed.ok) {
351
+ // Intake path handled the turn assignment with intakeContext
352
+ assignedState = loadProjectState(root, config);
353
+ if (!assignedState) {
354
+ console.log(chalk.red('Failed to reload governed state after intake binding.'));
355
+ process.exit(1);
356
+ }
357
+ turnId = consumed.turn_id;
358
+ assignedRole = consumed.role || roleId;
359
+ console.log(chalk.green(`Bound approved intent to next turn: ${consumed.intentId}`));
360
+ } else {
361
+ // No approved intents — plain assignment
362
+ const assignment = assignGovernedTurn(root, config, roleId);
363
+ if (!assignment.ok) {
364
+ console.log(chalk.red(`Failed to assign turn: ${assignment.error}`));
365
+ process.exit(1);
366
+ }
367
+ for (const warning of assignment.warnings || []) {
368
+ console.log(chalk.yellow(`Warning: ${warning}`));
369
+ }
370
+ assignedState = assignment.state;
371
+ turnId = assignment.turn?.turn_id || assignment.turn?.id;
372
+ assignedRole = assignment.turn?.assigned_role || roleId;
323
373
  }
324
- for (const warning of assignment.warnings || []) {
325
- console.log(chalk.yellow(`Warning: ${warning}`));
374
+
375
+ // BUG-17 fix: write dispatch bundle AFTER state assignment succeeds.
376
+ // The bundle must exist on disk before we report success, otherwise the
377
+ // operator sees a "ghost turn" in state with no dispatch directory.
378
+ if (turnId) {
379
+ const bundleResult = writeDispatchBundle(root, assignedState, config, { turnId });
380
+ if (!bundleResult.ok) {
381
+ console.log(chalk.red(`Turn assigned but dispatch bundle write failed: ${bundleResult.error}`));
382
+ console.log(chalk.dim('The turn is assigned in state but has no dispatch context.'));
383
+ console.log(chalk.dim('Run `agentxchain reissue-turn` to reissue with a fresh bundle.'));
384
+ process.exit(1);
385
+ }
386
+ for (const bw of bundleResult.warnings || []) {
387
+ console.log(chalk.yellow(`Dispatch bundle warning: ${bw}`));
388
+ }
326
389
  }
327
390
 
328
391
  // assignGovernedTurn already writes a checkpoint at turn_assigned
329
392
 
330
393
  console.log(chalk.green(`✓ Restarted run ${state.run_id}`));
331
394
  console.log(chalk.dim(` Phase: ${phase}`));
332
- console.log(chalk.dim(` Turn: ${assignment.turn?.id || 'assigned'}`));
333
- console.log(chalk.dim(` Role: ${assignment.turn?.role || roleId || 'routing default'}`));
395
+ console.log(chalk.dim(` Turn: ${turnId || 'assigned'}`));
396
+ console.log(chalk.dim(` Role: ${assignedRole || 'routing default'}`));
397
+ console.log(chalk.dim(` Dispatch: ${getDispatchTurnDir(turnId || 'unknown')}/`));
334
398
  if (checkpoint) {
335
399
  console.log(chalk.dim(` Last checkpoint: ${checkpoint.checkpoint_reason} at ${checkpoint.last_checkpoint_at}`));
336
400
  }
@@ -48,6 +48,7 @@ import { resolveChainOptions, executeChainedRun } from '../lib/run-chain.js';
48
48
  import { resolveContinuousOptions, executeContinuousRun } from '../lib/continuous-run.js';
49
49
  import { createDispatchProgressTracker } from '../lib/dispatch-progress.js';
50
50
  import { emitRunEvent } from '../lib/run-events.js';
51
+ import { checkpointAcceptedTurn } from '../lib/turn-checkpoint.js';
51
52
 
52
53
  export async function runCommand(opts) {
53
54
  const context = loadProjectContext();
@@ -148,6 +149,7 @@ export async function executeGovernedRun(context, opts = {}) {
148
149
 
149
150
  const maxTurns = opts.maxTurns || 50;
150
151
  const autoApprove = !!opts.autoApprove;
152
+ const autoCheckpoint = opts.autoCheckpoint === true;
151
153
  const verbose = !!opts.verbose;
152
154
  const overrideResolution = opts.role
153
155
  ? resolveGovernedRole({ override: opts.role, state: null, config })
@@ -499,6 +501,17 @@ export async function executeGovernedRun(context, opts = {}) {
499
501
  return approved;
500
502
  },
501
503
 
504
+ async afterAccept({ turn }) {
505
+ if (!autoCheckpoint) {
506
+ return { ok: true };
507
+ }
508
+ const checkpoint = checkpointAcceptedTurn(root, { turnId: turn.turn_id });
509
+ if (!checkpoint.ok) {
510
+ return { ok: false, error: checkpoint.error || `checkpoint failed for ${turn.turn_id}` };
511
+ }
512
+ return { ok: true };
513
+ },
514
+
502
515
  onEvent(event) {
503
516
  switch (event.type) {
504
517
  case 'turn_assigned':
@@ -8,7 +8,7 @@ import {
8
8
  deriveRecoveryDescriptor,
9
9
  deriveRuntimeBlockedGuidance,
10
10
  } from '../lib/blocked-state.js';
11
- import { getActiveTurn, getActiveTurnCount, getActiveTurns, detectActiveTurnBindingDrift } from '../lib/governed-state.js';
11
+ import { getActiveTurn, getActiveTurnCount, getActiveTurns, detectActiveTurnBindingDrift, detectStateBundleDesync } from '../lib/governed-state.js';
12
12
  import { getContinuityStatus } from '../lib/continuity-status.js';
13
13
  import { getConnectorHealth } from '../lib/connector-health.js';
14
14
  import { readRepoDecisions, summarizeRepoDecisions } from '../lib/repo-decisions.js';
@@ -182,6 +182,7 @@ function renderGovernedStatus(context, opts) {
182
182
  workflow_kit_artifacts: workflowKitArtifacts,
183
183
  dashboard_session: dashboardSessionObj,
184
184
  binding_drift: detectActiveTurnBindingDrift(state, config),
185
+ bundle_integrity: detectStateBundleDesync(root, state),
185
186
  }, null, 2));
186
187
  return;
187
188
  }
@@ -284,6 +285,17 @@ function renderGovernedStatus(context, opts) {
284
285
  renderConnectorHealthStatus(connectorHealth);
285
286
  renderRecentEventSummary(recentEventSummary);
286
287
 
288
+ // BUG-18: State/bundle integrity check
289
+ const desync = detectStateBundleDesync(root, state);
290
+ if (!desync.ok) {
291
+ console.log(chalk.red.bold(' ⚠ Ghost turn(s) detected — dispatch bundle missing'));
292
+ for (const entry of desync.desynced) {
293
+ console.log(chalk.red(` ${entry.turn_id} (${entry.role}): ${entry.expected_path} not found`));
294
+ }
295
+ console.log(chalk.dim(' Run `agentxchain reissue-turn` to recover, or `agentxchain doctor` for diagnostics.'));
296
+ console.log('');
297
+ }
298
+
287
299
  const activeTurnCount = getActiveTurnCount(state);
288
300
  const singleActiveTurn = getActiveTurn(state);
289
301
  const approvalPending = Boolean(state?.pending_phase_transition || state?.pending_run_completion);
@@ -1,10 +1,9 @@
1
- import { execFileSync } from 'node:child_process';
2
-
3
1
  import {
4
2
  buildProviderHeaders,
5
3
  buildProviderRequest,
6
4
  PROVIDER_ENDPOINTS,
7
5
  } from './adapters/api-proxy-adapter.js';
6
+ import { probeRuntimeSpawnContext } from './runtime-spawn-context.js';
8
7
 
9
8
  const PROBEABLE_RUNTIME_TYPES = new Set(['local_cli', 'api_proxy', 'mcp', 'remote_agent']);
10
9
  const DEFAULT_TIMEOUT_MS = 8_000;
@@ -35,8 +34,8 @@ const KNOWN_CLI_AUTHORITY_FLAGS = [
35
34
  * Maps binary name to expected transport.
36
35
  */
37
36
  const KNOWN_CLI_TRANSPORTS = {
38
- claude: 'stdin',
39
- codex: 'argv',
37
+ claude: ['stdin'],
38
+ codex: ['argv', 'stdin'],
40
39
  };
41
40
 
42
41
  function formatCommand(command, args = []) {
@@ -78,11 +77,6 @@ function commandHead(runtime) {
78
77
  return null;
79
78
  }
80
79
 
81
- function resolveBinary(command) {
82
- const resolver = process.platform === 'win32' ? 'where' : 'which';
83
- execFileSync(resolver, [command], { stdio: 'ignore' });
84
- }
85
-
86
80
  function resolveProviderEndpoint(runtime) {
87
81
  if (typeof runtime?.base_url === 'string' && runtime.base_url.trim()) {
88
82
  return runtime.base_url.trim();
@@ -153,7 +147,7 @@ async function probeHttp({ url, method = 'GET', headers = {}, body, timeoutMs })
153
147
  }
154
148
  }
155
149
 
156
- async function probeLocalCommand(runtimeId, runtime, probeKindLabel) {
150
+ async function probeLocalCommand(runtimeId, runtime, probeKindLabel, options = {}) {
157
151
  const head = commandHead(runtime);
158
152
  const base = {
159
153
  runtime_id: runtimeId,
@@ -170,22 +164,22 @@ async function probeLocalCommand(runtimeId, runtime, probeKindLabel) {
170
164
  };
171
165
  }
172
166
 
173
- try {
174
- resolveBinary(head);
167
+ const spawnProbe = probeRuntimeSpawnContext(options.root || process.cwd(), runtime, { runtimeId });
168
+ if (spawnProbe.ok) {
175
169
  return {
176
170
  ...base,
177
171
  level: 'pass',
178
- command: head,
179
- detail: `${head} is available on PATH`,
180
- };
181
- } catch {
182
- return {
183
- ...base,
184
- level: 'fail',
185
- command: head,
186
- detail: `${head} was not found on PATH`,
172
+ command: spawnProbe.command || head,
173
+ detail: spawnProbe.detail,
187
174
  };
188
175
  }
176
+
177
+ return {
178
+ ...base,
179
+ level: 'fail',
180
+ command: spawnProbe.command || head,
181
+ detail: spawnProbe.detail,
182
+ };
189
183
  }
190
184
 
191
185
  async function probeApiProxy(runtimeId, runtime, timeoutMs) {
@@ -331,6 +325,27 @@ function analyzeLocalCliAuthorityIntent(runtimeId, runtime, roles) {
331
325
  if (boundRoles.length === 0) return { warnings };
332
326
 
333
327
  const authoritativeRoles = boundRoles.filter((r) => r.write_authority === 'authoritative');
328
+ const isCodex = binaryName === 'codex' || binaryName.endsWith('/codex');
329
+
330
+ if (isCodex) {
331
+ if (commandTokens[1] !== 'exec') {
332
+ warnings.push({
333
+ probe_kind: 'command_intent',
334
+ level: 'warn',
335
+ detail: 'OpenAI Codex CLI governed local runs should use the non-interactive "exec" subcommand. Top-level "codex" is the interactive entrypoint.',
336
+ fix: 'Use ["codex", "exec", "--dangerously-bypass-approvals-and-sandbox", "{prompt}"]',
337
+ });
338
+ }
339
+
340
+ if (commandTokens.includes('--quiet')) {
341
+ warnings.push({
342
+ probe_kind: 'command_intent',
343
+ level: 'warn',
344
+ detail: 'OpenAI Codex CLI rejects "--quiet" in governed local_cli commands on the current CLI. The command exits before the turn starts.',
345
+ fix: 'Remove "--quiet" and use ["codex", "exec", "--dangerously-bypass-approvals-and-sandbox", "{prompt}"]',
346
+ });
347
+ }
348
+ }
334
349
 
335
350
  // Check known CLI authority flags
336
351
  const knownCli = KNOWN_CLI_AUTHORITY_FLAGS.find((entry) => binaryName === entry.binary || binaryName.endsWith(`/${entry.binary}`));
@@ -359,7 +374,7 @@ function analyzeLocalCliAuthorityIntent(runtimeId, runtime, roles) {
359
374
 
360
375
  // Prompt transport validation
361
376
  const transport = runtime.prompt_transport || 'dispatch_bundle_only';
362
- const knownTransport = KNOWN_CLI_TRANSPORTS[binaryName];
377
+ const knownTransports = KNOWN_CLI_TRANSPORTS[binaryName];
363
378
 
364
379
  if (transport === 'argv' && !commandTokens.some((token) => token.includes('{prompt}'))) {
365
380
  warnings.push({
@@ -370,13 +385,13 @@ function analyzeLocalCliAuthorityIntent(runtimeId, runtime, roles) {
370
385
  });
371
386
  }
372
387
 
373
- if (knownTransport && transport !== knownTransport && transport !== 'dispatch_bundle_only') {
388
+ if (knownTransports && !knownTransports.includes(transport) && transport !== 'dispatch_bundle_only') {
374
389
  const transportLabel = knownCli ? knownCli.label : binaryName;
375
390
  warnings.push({
376
391
  probe_kind: 'transport_intent',
377
392
  level: 'warn',
378
- detail: `${transportLabel} typically uses "${knownTransport}" transport, but this runtime is configured with "${transport}"`,
379
- fix: `Set prompt_transport to "${knownTransport}" or "dispatch_bundle_only"`,
393
+ detail: `${transportLabel} typically uses ${knownTransports.map((value) => `"${value}"`).join(' or ')} transport, but this runtime is configured with "${transport}"`,
394
+ fix: `Set prompt_transport to ${knownTransports.map((value) => `"${value}"`).join(' or ')} or "dispatch_bundle_only"`,
380
395
  });
381
396
  }
382
397
 
@@ -414,7 +429,7 @@ export async function probeConnectorRuntime(runtimeId, runtime, options = {}) {
414
429
  }
415
430
 
416
431
  if (runtime.type === 'local_cli') {
417
- const result = await probeLocalCommand(runtimeId, runtime, 'command_presence');
432
+ const result = await probeLocalCommand(runtimeId, runtime, 'command_presence', options);
418
433
  // Add authority-intent and transport analysis when roles are available
419
434
  if (roles) {
420
435
  const { warnings } = analyzeLocalCliAuthorityIntent(runtimeId, runtime, roles);
@@ -437,7 +452,7 @@ export async function probeConnectorRuntime(runtimeId, runtime, options = {}) {
437
452
  if (runtime.transport === 'streamable_http') {
438
453
  return probeHttpRuntime(runtimeId, runtime, timeoutMs);
439
454
  }
440
- return probeLocalCommand(runtimeId, runtime, 'command_presence');
455
+ return probeLocalCommand(runtimeId, runtime, 'command_presence', options);
441
456
  }
442
457
 
443
458
  return probeHttpRuntime(runtimeId, runtime, timeoutMs);
@@ -22,6 +22,7 @@ import { dispatchMcp } from './adapters/mcp-adapter.js';
22
22
  import { dispatchRemoteAgent } from './adapters/remote-agent-adapter.js';
23
23
  import { getDispatchPromptPath, getTurnStagingResultPath } from './turn-paths.js';
24
24
  import { validateStagedTurnResult } from './turn-result-validator.js';
25
+ import { probeRuntimeSpawnContext } from './runtime-spawn-context.js';
25
26
 
26
27
  const VALIDATABLE_RUNTIME_TYPES = new Set(['local_cli', 'api_proxy', 'mcp', 'remote_agent']);
27
28
  const DEFAULT_VALIDATE_TIMEOUT_MS = 120_000;
@@ -130,6 +131,26 @@ export async function validateConfiguredConnector(sourceRoot, options = {}) {
130
131
  };
131
132
  }
132
133
 
134
+ if (runtime.type === 'local_cli' || (runtime.type === 'mcp' && (runtime.transport || 'stdio') !== 'streamable_http')) {
135
+ const spawnProbe = probeRuntimeSpawnContext(scratchRoot, scratchContext.config.runtimes[runtimeId], { runtimeId });
136
+ if (!spawnProbe.ok) {
137
+ return {
138
+ ok: false,
139
+ exitCode: 1,
140
+ overall: 'fail',
141
+ runtime_id: runtimeId,
142
+ runtime_type: runtime.type,
143
+ role_id: roleSelection.roleId,
144
+ timeout_ms: timeoutMs,
145
+ warnings,
146
+ dispatch: null,
147
+ validation: null,
148
+ error: spawnProbe.detail,
149
+ scratch_root: scratchRoot,
150
+ };
151
+ }
152
+ }
153
+
133
154
  const initResult = initializeGovernedRun(scratchRoot, scratchContext.config, {
134
155
  provenance: {
135
156
  trigger: 'connector_validate',
@@ -239,6 +239,7 @@ export function resolveContinuousOptions(opts, config) {
239
239
  triageApproval: opts.triageApproval ?? configCont.triage_approval ?? 'auto',
240
240
  cooldownSeconds: opts.cooldownSeconds ?? configCont.cooldown_seconds ?? 5,
241
241
  perSessionMaxUsd: opts.sessionBudget ?? configCont.per_session_max_usd ?? null,
242
+ autoCheckpoint: opts.autoCheckpoint ?? configCont.auto_checkpoint ?? true,
242
243
  };
243
244
  }
244
245
 
@@ -307,7 +308,12 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
307
308
 
308
309
  let execution;
309
310
  try {
310
- execution = await executeGovernedRun(context, { autoApprove: true, report: true, log });
311
+ execution = await executeGovernedRun(context, {
312
+ autoApprove: true,
313
+ autoCheckpoint: contOpts.autoCheckpoint,
314
+ report: true,
315
+ log,
316
+ });
311
317
  } catch (err) {
312
318
  session.status = 'failed';
313
319
  writeContinuousSession(root, session);
@@ -413,6 +419,7 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
413
419
  try {
414
420
  execution = await executeGovernedRun(context, {
415
421
  autoApprove: true,
422
+ autoCheckpoint: contOpts.autoCheckpoint,
416
423
  report: true,
417
424
  log,
418
425
  });
@@ -213,6 +213,31 @@ export function selectNextAssignment(workspacePath, state, config) {
213
213
  return firstFailure || { ok: false, reason: 'no_assignable_workstream', detail: 'No workstream is assignable in the current phase' };
214
214
  }
215
215
 
216
+ export function selectAssignmentForWorkstream(workspacePath, state, config, workstreamId) {
217
+ const workstream = config.workstreams?.[workstreamId];
218
+ if (!workstream) {
219
+ return {
220
+ ok: false,
221
+ reason: 'workstream_missing',
222
+ detail: `Unknown workstream "${workstreamId}"`,
223
+ workstream_id: workstreamId,
224
+ };
225
+ }
226
+
227
+ if (workstream.phase !== state.phase) {
228
+ return {
229
+ ok: false,
230
+ reason: 'phase_mismatch',
231
+ detail: `Workstream "${workstreamId}" is in phase "${workstream.phase}", but coordinator is currently in phase "${state.phase}"`,
232
+ workstream_id: workstreamId,
233
+ };
234
+ }
235
+
236
+ const history = readCoordinatorHistory(workspacePath);
237
+ const barriers = readBarriers(workspacePath);
238
+ return evaluateWorkstream(workspacePath, state, config, workstreamId, history, barriers);
239
+ }
240
+
216
241
  export function dispatchCoordinatorTurn(workspacePath, state, config, assignment) {
217
242
  if (!assignment?.ok) {
218
243
  return { ok: false, error: 'Assignment is required before dispatch' };
@@ -62,6 +62,7 @@ import {
62
62
  summarizeVerificationReplay,
63
63
  } from './verification-replay.js';
64
64
  import { executeGateActions } from './gate-actions.js';
65
+ import { detectPendingCheckpoint } from './turn-checkpoint.js';
65
66
 
66
67
  // ── Constants ────────────────────────────────────────────────────────────────
67
68
 
@@ -405,6 +406,32 @@ export function getActiveTurn(state) {
405
406
  return turns.length === 1 ? turns[0] : null;
406
407
  }
407
408
 
409
+ /**
410
+ * BUG-18: Detect state/bundle desync — every active turn referenced in
411
+ * state.json must have a corresponding dispatch bundle directory on disk.
412
+ *
413
+ * @param {string} root - project root
414
+ * @param {object} state - governed state
415
+ * @returns {{ ok: boolean, desynced: Array<{ turn_id: string, role: string, expected_path: string }> }}
416
+ */
417
+ export function detectStateBundleDesync(root, state) {
418
+ const activeTurns = getActiveTurns(state);
419
+ const desynced = [];
420
+
421
+ for (const [turnId, turn] of Object.entries(activeTurns)) {
422
+ const bundleDir = join(root, '.agentxchain', 'dispatch', 'turns', turnId);
423
+ if (!existsSync(bundleDir)) {
424
+ desynced.push({
425
+ turn_id: turnId,
426
+ role: turn.assigned_role || 'unknown',
427
+ expected_path: `.agentxchain/dispatch/turns/${turnId}`,
428
+ });
429
+ }
430
+ }
431
+
432
+ return { ok: desynced.length === 0, desynced };
433
+ }
434
+
408
435
  function resolveRecoveryTurnId(state, preferredTurnId = null) {
409
436
  const activeTurns = getActiveTurns(state);
410
437
  if (preferredTurnId && activeTurns[preferredTurnId]) {
@@ -2163,6 +2190,10 @@ export function assignGovernedTurn(root, config, roleId, options = {}) {
2163
2190
  const writeAuthority = role.write_authority || 'review_only';
2164
2191
  const cleanCheck = checkCleanBaseline(root, writeAuthority);
2165
2192
  if (!cleanCheck.clean) {
2193
+ const pendingCheckpoint = detectPendingCheckpoint(root, cleanCheck.dirty_files || []);
2194
+ if (pendingCheckpoint.required) {
2195
+ return { ok: false, error: pendingCheckpoint.message, error_code: 'checkpoint_required' };
2196
+ }
2166
2197
  return { ok: false, error: cleanCheck.reason };
2167
2198
  }
2168
2199
 
@@ -2457,7 +2488,8 @@ export function reissueTurn(root, config, opts = {}) {
2457
2488
  const oldRuntimeId = oldTurn.runtime_id;
2458
2489
 
2459
2490
  // Resolve current runtime binding (may have changed in config)
2460
- const currentRuntimeId = role.runtime;
2491
+ // BUG-25 fix: normalized config uses runtime_id, raw config uses runtime
2492
+ const currentRuntimeId = role.runtime_id || role.runtime;
2461
2493
  const currentRuntime = config.runtimes?.[currentRuntimeId];
2462
2494
  if (!currentRuntime) {
2463
2495
  return { ok: false, error: `Runtime "${currentRuntimeId}" not found in config for role "${roleId}"` };
@@ -2754,7 +2786,25 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2754
2786
  }
2755
2787
 
2756
2788
  const turnStagingPath = getTurnStagingResultPath(currentTurn.turn_id);
2757
- const resolvedStagingPath = existsSync(join(root, turnStagingPath)) ? turnStagingPath : STAGING_PATH;
2789
+ let resolvedStagingPath = existsSync(join(root, turnStagingPath)) ? turnStagingPath : STAGING_PATH;
2790
+ // BUG-22: verify legacy staging file belongs to the active turn before consuming
2791
+ if (resolvedStagingPath === STAGING_PATH) {
2792
+ try {
2793
+ const legacyAbs = join(root, STAGING_PATH);
2794
+ if (existsSync(legacyAbs)) {
2795
+ const raw = JSON.parse(readFileSync(legacyAbs, 'utf8'));
2796
+ if (raw.turn_id && raw.turn_id !== currentTurn.turn_id) {
2797
+ return {
2798
+ ok: false,
2799
+ error: `Stale staging data: ${STAGING_PATH} contains turn_id "${raw.turn_id}" but active turn is "${currentTurn.turn_id}". Remove the stale file or use the turn-scoped staging path.`,
2800
+ error_code: 'stale_staging',
2801
+ };
2802
+ }
2803
+ }
2804
+ } catch {
2805
+ // Parse error handled by downstream validation
2806
+ }
2807
+ }
2758
2808
  const stagedTurn = loadHookStagedTurn(root, resolvedStagingPath);
2759
2809
  const validationState = attachLegacyCurrentTurnAlias({
2760
2810
  ...state,
@@ -3838,6 +3888,106 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3838
3888
  }
3839
3889
  }
3840
3890
 
3891
+ // ── BUG-19: Post-acceptance gate reconciliation ────────────────────────
3892
+ // If a previous gate failure is cached in last_gate_failure, re-evaluate
3893
+ // whether the conditions are now satisfied after this turn's artifacts.
3894
+ // This prevents stale gate failures from surviving after a turn fixes them.
3895
+ // Only clear if ALL failure conditions are now resolved.
3896
+ if (updatedState.last_gate_failure && updatedState.status !== 'completed' && updatedState.status !== 'blocked') {
3897
+ const staleGate = updatedState.last_gate_failure;
3898
+ let allConditionsResolved = true;
3899
+
3900
+ // Check if missing_files are now present
3901
+ if (Array.isArray(staleGate.missing_files) && staleGate.missing_files.length > 0) {
3902
+ const stillMissing = staleGate.missing_files.filter(f => !existsSync(join(root, f)));
3903
+ if (stillMissing.length > 0) {
3904
+ allConditionsResolved = false;
3905
+ }
3906
+ }
3907
+
3908
+ // Check if missing_verification is still an issue
3909
+ // Verification failures can only be resolved by the specific gate re-evaluation
3910
+ // during a phase_transition_request, not by post-acceptance reconciliation,
3911
+ // because verification is turn-specific — the prior turn's verification status
3912
+ // is what the gate evaluated, and a different turn's pass doesn't retroactively
3913
+ // fix the prior turn's failure.
3914
+ if (staleGate.missing_verification) {
3915
+ allConditionsResolved = false;
3916
+ }
3917
+
3918
+ // Only clear if there were resolvable conditions and they are all resolved
3919
+ const hadResolvableConditions = Array.isArray(staleGate.missing_files) && staleGate.missing_files.length > 0;
3920
+ if (allConditionsResolved && hadResolvableConditions) {
3921
+ updatedState.last_gate_failure = null;
3922
+ if (staleGate.gate_id) {
3923
+ updatedState.phase_gate_status = {
3924
+ ...(updatedState.phase_gate_status || {}),
3925
+ [staleGate.gate_id]: 'cleared_by_reconciliation',
3926
+ };
3927
+ }
3928
+ ledgerEntries.push({
3929
+ type: 'gate_reconciliation',
3930
+ gate_id: staleGate.gate_id,
3931
+ gate_type: staleGate.gate_type,
3932
+ phase: updatedState.phase,
3933
+ reason: 'post_acceptance_reconciliation',
3934
+ previously_missing_files: staleGate.missing_files || [],
3935
+ reconciled_at: now,
3936
+ reconciled_by_turn: currentTurn.turn_id,
3937
+ });
3938
+ }
3939
+ }
3940
+
3941
+ // ── BUG-20: Post-acceptance intent satisfaction ─────────────────────────
3942
+ // When a turn bound to an injected intent is accepted successfully, transition
3943
+ // the intent to 'completed' so it disappears from the pending queue.
3944
+ if (currentTurn.intake_context?.intent_id) {
3945
+ const intentId = currentTurn.intake_context.intent_id;
3946
+ try {
3947
+ const intentPath = join(root, '.agentxchain', 'intake', 'intents', `${intentId}.json`);
3948
+ if (existsSync(intentPath)) {
3949
+ const intent = JSON.parse(readFileSync(intentPath, 'utf8'));
3950
+ if (intent.status === 'executing') {
3951
+ intent.status = 'completed';
3952
+ intent.completed_at = now;
3953
+ intent.run_completed_at = updatedState.completed_at || now;
3954
+ intent.run_final_turn = currentTurn.turn_id;
3955
+ intent.updated_at = now;
3956
+ intent.satisfying_turn = currentTurn.turn_id;
3957
+ if (!Array.isArray(intent.history)) intent.history = [];
3958
+ intent.history.push({
3959
+ from: 'executing',
3960
+ to: 'completed',
3961
+ at: now,
3962
+ turn_id: currentTurn.turn_id,
3963
+ role: currentTurn.assigned_role,
3964
+ run_id: updatedState.run_id,
3965
+ reason: 'turn accepted — acceptance contract satisfied',
3966
+ });
3967
+ writeFileSync(intentPath, JSON.stringify(intent, null, 2));
3968
+
3969
+ // Create observation scaffold (same as resolve path)
3970
+ const obsDir = join(root, '.agentxchain', 'intake', 'observations', intentId);
3971
+ mkdirSync(obsDir, { recursive: true });
3972
+
3973
+ // Emit intent_satisfied event
3974
+ emitRunEvent(root, 'intent_satisfied', {
3975
+ run_id: updatedState.run_id,
3976
+ phase: updatedState.phase,
3977
+ status: updatedState.status,
3978
+ turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
3979
+ intent_id: intentId,
3980
+ payload: {
3981
+ satisfying_turn: currentTurn.turn_id,
3982
+ },
3983
+ });
3984
+ }
3985
+ }
3986
+ } catch {
3987
+ // Non-fatal — intent satisfaction is advisory
3988
+ }
3989
+ }
3990
+
3841
3991
  // ── Transaction journal: prepare before committing writes ──────────────
3842
3992
  const transactionId = generateId('txn');
3843
3993
  const journal = {