agentxchain 2.146.0 → 2.147.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.
@@ -382,6 +382,16 @@ function renderGovernedStatus(context, opts) {
382
382
  console.log(` ${chalk.dim('Recover:')} ${chalk.cyan(`agentxchain reject-turn --turn ${turn.turn_id}`)} — reject and retry`);
383
383
  console.log(` ${chalk.dim(' or:')} ${chalk.cyan(`agentxchain accept-turn --turn ${turn.turn_id}`)} — re-attempt acceptance`);
384
384
  }
385
+ if (turn.status === 'failed_start') {
386
+ console.log(` ${chalk.dim('Reason:')} ${turn.failed_start_reason || 'no_subprocess_output'}`);
387
+ const recover = turn.recovery_command || `agentxchain reissue-turn --turn ${turn.turn_id} --reason ghost`;
388
+ console.log(` ${chalk.dim('Recover:')} ${chalk.cyan(recover)}`);
389
+ }
390
+ if (turn.status === 'stalled') {
391
+ console.log(` ${chalk.dim('Reason:')} ${turn.stalled_reason || 'no_output_within_threshold'}`);
392
+ const recover = turn.recovery_command || `agentxchain reissue-turn --turn ${turn.turn_id} --reason stale`;
393
+ console.log(` ${chalk.dim('Recover:')} ${chalk.cyan(recover)}`);
394
+ }
385
395
  }
386
396
  } else if (singleActiveTurn) {
387
397
  console.log(` ${chalk.dim('Turn:')} ${singleActiveTurn.turn_id}`);
@@ -432,6 +442,16 @@ function renderGovernedStatus(context, opts) {
432
442
  console.log(` ${chalk.dim('Resolve:')} ${chalk.cyan(reassignAction.command)}`);
433
443
  console.log(` ${chalk.dim(' or:')} ${chalk.cyan(mergeAction.command)}`);
434
444
  }
445
+ if (singleActiveTurn.status === 'failed_start') {
446
+ console.log(` ${chalk.dim('Reason:')} ${singleActiveTurn.failed_start_reason || 'no_subprocess_output'}`);
447
+ const recover = singleActiveTurn.recovery_command || `agentxchain reissue-turn --turn ${singleActiveTurn.turn_id} --reason ghost`;
448
+ console.log(` ${chalk.dim('Recover:')} ${chalk.cyan(recover)}`);
449
+ }
450
+ if (singleActiveTurn.status === 'stalled') {
451
+ console.log(` ${chalk.dim('Reason:')} ${singleActiveTurn.stalled_reason || 'no_output_within_threshold'}`);
452
+ const recover = singleActiveTurn.recovery_command || `agentxchain reissue-turn --turn ${singleActiveTurn.turn_id} --reason stale`;
453
+ console.log(` ${chalk.dim('Recover:')} ${chalk.cyan(recover)}`);
454
+ }
435
455
  } else {
436
456
  console.log(` ${chalk.dim('Turn:')} ${chalk.yellow('No active turn')}`);
437
457
  }
@@ -35,7 +35,9 @@ import {
35
35
  getActiveTurnCount,
36
36
  getActiveTurns,
37
37
  reactivateGovernedRun,
38
+ reconcilePhaseAdvanceBeforeDispatch,
38
39
  refreshTurnBaselineSnapshot,
40
+ transitionActiveTurnLifecycle,
39
41
  STATE_PATH,
40
42
  } from '../lib/governed-state.js';
41
43
  import { getMaxConcurrentTurns } from '../lib/normalized-config.js';
@@ -70,7 +72,7 @@ import { resolveGovernedRole } from '../lib/role-resolution.js';
70
72
  import { shouldSuggestManualQaFallback } from '../lib/manual-qa-fallback.js';
71
73
  import { evaluateApprovalSlaReminders } from '../lib/notification-runner.js';
72
74
  import { consumeNextApprovedIntent } from '../lib/intake.js';
73
- import { reconcileStaleTurns } from '../lib/stale-turn-watchdog.js';
75
+ import { failTurnStartup, reconcileStaleTurns } from '../lib/stale-turn-watchdog.js';
74
76
 
75
77
  export async function stepCommand(opts) {
76
78
  const context = loadProjectContext();
@@ -260,39 +262,14 @@ export async function stepCommand(opts) {
260
262
  printDispatchBundleWarnings(bundleResult);
261
263
  }
262
264
 
263
- // Handle paused + failed/retrying turn → re-dispatch
264
- if (!skipAssignment && state.status === 'paused' && activeCount > 0) {
265
- const pausedTurn = targetTurn || Object.values(activeTurns)[0];
266
- const turnStatus = pausedTurn?.status;
267
- if (turnStatus === 'failed' || turnStatus === 'retrying') {
268
- console.log(chalk.yellow(`Re-dispatching failed turn: ${pausedTurn.turn_id}`));
269
- const reactivated = reactivateGovernedRun(root, state, { via: 'step --resume', notificationConfig: config });
270
- if (!reactivated.ok) {
271
- console.log(chalk.red(`Failed to reactivate run: ${reactivated.error}`));
272
- process.exit(1);
273
- }
274
- state = reactivated.state;
275
- if (reactivated.migration_notice) {
276
- console.log(chalk.yellow(reactivated.migration_notice));
277
- }
278
- if (reactivated.phantom_notice) {
279
- console.log(chalk.yellow(reactivated.phantom_notice));
280
- }
281
- skipAssignment = true;
282
-
283
- // BUG-1 fix: refresh baseline snapshot to capture files dirtied between assignment and dispatch
284
- refreshTurnBaselineSnapshot(root, pausedTurn.turn_id);
285
- state = JSON.parse(readFileSync(join(root, '.agentxchain/state.json'), 'utf8'));
286
-
287
- const bundleResult = writeDispatchBundle(root, state, config);
288
- if (!bundleResult.ok) {
289
- console.log(chalk.red(`Failed to write dispatch bundle: ${bundleResult.error}`));
290
- process.exit(1);
291
- }
292
- bundleWritten = true;
293
- printDispatchBundleWarnings(bundleResult);
294
- }
295
- }
265
+ // Removed (Turn 25): the `paused + failed/retrying retained turn → re-dispatch`
266
+ // branch is unreachable under the current schema. See the matching deletion in
267
+ // `cli/src/commands/resume.js` for the full citation chain (schema.js:184 +
268
+ // governed-state.js:2191-2204 + the line-187 short-circuit above). The reachable
269
+ // retained-turn re-dispatch path for `step --resume` is the `state.status ===
270
+ // 'blocked' && activeCount > 0` branch at line 193 above. Per
271
+ // `DEC-UNREACHABLE-BRANCH-COVERAGE-001`, dead branches are removed once the
272
+ // schema citation + migration citation are documented.
296
273
 
297
274
  // idle → initialize run
298
275
  if (!skipAssignment && state.status === 'idle' && !state.run_id) {
@@ -344,6 +321,27 @@ export async function stepCommand(opts) {
344
321
  }
345
322
  }
346
323
 
324
+ if (!skipAssignment) {
325
+ const phaseReconciliation = reconcilePhaseAdvanceBeforeDispatch(root, config, state);
326
+ if (!phaseReconciliation.ok && !phaseReconciliation.state) {
327
+ console.log(chalk.red(`Failed to reconcile phase gate before dispatch: ${phaseReconciliation.error}`));
328
+ process.exit(1);
329
+ }
330
+ state = phaseReconciliation.state || state;
331
+ if (phaseReconciliation.advanced) {
332
+ console.log(chalk.green(`Advanced phase before dispatch: ${phaseReconciliation.from_phase} → ${phaseReconciliation.to_phase}`));
333
+ }
334
+ if (state.pending_phase_transition || state.pending_run_completion) {
335
+ evaluateApprovalSlaReminders(root, config, state);
336
+ printRecoverySummary(state, 'This run is awaiting approval.', config);
337
+ process.exit(1);
338
+ }
339
+ if (state.status === 'blocked') {
340
+ printRecoverySummary(state, 'This run is blocked.', config);
341
+ process.exit(1);
342
+ }
343
+ }
344
+
347
345
  // Assign the turn
348
346
  if (!skipAssignment) {
349
347
  const roleId = resolveTargetRole(opts, state, config);
@@ -448,6 +446,10 @@ export async function stepCommand(opts) {
448
446
  console.log(chalk.red(`Failed to finalize dispatch manifest: ${manifestResult.error}`));
449
447
  process.exit(1);
450
448
  }
449
+ const dispatched = transitionActiveTurnLifecycle(root, turn.turn_id, 'dispatched');
450
+ if (dispatched.ok) {
451
+ state = dispatched.state;
452
+ }
451
453
  }
452
454
 
453
455
  const controller = new AbortController();
@@ -456,6 +458,13 @@ export async function stepCommand(opts) {
456
458
  });
457
459
 
458
460
  if (runtimeType === 'api_proxy') {
461
+ const running = transitionActiveTurnLifecycle(root, turn.turn_id, 'running', {
462
+ stream: 'request',
463
+ at: new Date().toISOString(),
464
+ });
465
+ if (running.ok) {
466
+ state = running.state;
467
+ }
459
468
  console.log(chalk.cyan(`Dispatching to API proxy: ${runtime?.provider || '(unknown)'} / ${runtime?.model || '(unknown)'}`));
460
469
  console.log(chalk.dim(`Turn: ${turn.turn_id} Role: ${roleId} Phase: ${state.phase}`));
461
470
 
@@ -535,6 +544,13 @@ export async function stepCommand(opts) {
535
544
  }
536
545
  console.log('');
537
546
  } else if (runtimeType === 'mcp') {
547
+ const running = transitionActiveTurnLifecycle(root, turn.turn_id, 'running', {
548
+ stream: 'request',
549
+ at: new Date().toISOString(),
550
+ });
551
+ if (running.ok) {
552
+ state = running.state;
553
+ }
538
554
  const mcpTransport = resolveMcpTransport(runtime);
539
555
  console.log(chalk.cyan(`Dispatching to MCP ${mcpTransport}: ${describeMcpRuntimeTarget(runtime)}`));
540
556
  console.log(chalk.dim(`Turn: ${turn.turn_id} Role: ${roleId} Phase: ${state.phase} Tool: ${runtime?.tool_name || 'agentxchain_turn'}`));
@@ -589,6 +605,13 @@ export async function stepCommand(opts) {
589
605
  console.log(chalk.green(`MCP tool completed${mcpResult.toolName ? ` (${mcpResult.toolName})` : ''}. Staged result detected.`));
590
606
  console.log('');
591
607
  } else if (runtimeType === 'remote_agent') {
608
+ const running = transitionActiveTurnLifecycle(root, turn.turn_id, 'running', {
609
+ stream: 'request',
610
+ at: new Date().toISOString(),
611
+ });
612
+ if (running.ok) {
613
+ state = running.state;
614
+ }
592
615
  console.log(chalk.cyan(`Dispatching to remote agent: ${describeRemoteAgentTarget(runtime)}`));
593
616
  console.log(chalk.dim(`Turn: ${turn.turn_id} Role: ${roleId} Phase: ${state.phase}`));
594
617
 
@@ -667,8 +690,25 @@ export async function stepCommand(opts) {
667
690
 
668
691
  // BUG-6: stream subprocess output by default (--stream or --verbose), suppress with --quiet
669
692
  const shouldStream = opts.stream || opts.verbose || false;
693
+ let runningMarked = false;
694
+ const ensureStartingState = (pid = null, at = new Date().toISOString()) => {
695
+ const starting = transitionActiveTurnLifecycle(root, turn.turn_id, 'starting', { pid, at });
696
+ if (starting.ok) {
697
+ state = starting.state;
698
+ }
699
+ };
700
+ const ensureRunningState = (stream = 'stdout', at = new Date().toISOString()) => {
701
+ if (runningMarked) return;
702
+ runningMarked = true;
703
+ const running = transitionActiveTurnLifecycle(root, turn.turn_id, 'running', { stream, at });
704
+ if (running.ok) {
705
+ state = running.state;
706
+ }
707
+ };
670
708
  const cliResult = await dispatchLocalCli(root, state, config, {
671
709
  signal: controller.signal,
710
+ onSpawnAttached: ({ pid, at }) => ensureStartingState(pid, at),
711
+ onFirstOutput: ({ at, stream }) => ensureRunningState(stream, at),
672
712
  onStdout: shouldStream ? (text) => process.stdout.write(chalk.dim(text)) : undefined,
673
713
  onStderr: shouldStream ? (text) => process.stderr.write(chalk.yellow(text)) : undefined,
674
714
  verifyManifest: true,
@@ -714,6 +754,28 @@ export async function stepCommand(opts) {
714
754
  process.exit(1);
715
755
  }
716
756
 
757
+ if (cliResult.startupFailure) {
758
+ const freshState = loadProjectState(root, config) || state;
759
+ const failed = failTurnStartup(root, freshState, config, turn.turn_id, {
760
+ failure_type: cliResult.startupFailureType || 'no_subprocess_output',
761
+ threshold_ms: config?.run_loop?.startup_watchdog_ms ?? 30_000,
762
+ running_ms: freshState?.active_turns?.[turn.turn_id]?.started_at
763
+ ? Math.max(0, Date.now() - new Date(freshState.active_turns[turn.turn_id].started_at).getTime())
764
+ : 0,
765
+ recommendation: `Turn ${turn.turn_id} failed to start within the startup watchdog window. Run \`agentxchain reissue-turn --turn ${turn.turn_id} --reason ghost\` to recover.`,
766
+ });
767
+ if (failed.ok) {
768
+ state = failed.state;
769
+ }
770
+
771
+ console.log('');
772
+ console.log(chalk.red(`Turn startup failed: ${cliResult.error}`));
773
+ console.log(chalk.dim('The turn was retained as failed_start. You can:'));
774
+ console.log(chalk.dim(` - Reissue immediately: agentxchain reissue-turn --turn ${turn.turn_id} --reason ghost`));
775
+ console.log(chalk.dim(' - Inspect status: agentxchain status'));
776
+ process.exit(1);
777
+ }
778
+
717
779
  if (!cliResult.ok) {
718
780
  const blocked = markRunBlocked(root, {
719
781
  blockedOn: `dispatch:${cliResult.exitCode != null ? `exit-${cliResult.exitCode}` : 'subprocess_failed'}`,
@@ -744,6 +806,10 @@ export async function stepCommand(opts) {
744
806
  process.exit(1);
745
807
  }
746
808
 
809
+ if (!runningMarked) {
810
+ ensureRunningState('staged_result', cliResult.firstOutputAt || new Date().toISOString());
811
+ }
812
+
747
813
  console.log(chalk.green('Subprocess completed. Staged result detected.'));
748
814
  console.log('');
749
815
  } else {
@@ -29,6 +29,7 @@
29
29
  import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from 'fs';
30
30
  import { join } from 'path';
31
31
  import { evaluateTokenBudget, SYSTEM_PROMPT, SEPARATOR } from '../token-budget.js';
32
+ import { hasMinimumTurnResultShape } from '../turn-result-shape.js';
32
33
  import {
33
34
  getDispatchApiRequestPath,
34
35
  getDispatchContextPath,
@@ -1072,6 +1073,13 @@ export async function dispatchApiProxy(root, state, config, options = {}) {
1072
1073
  turnResult.cost = { ...aggregateUsage };
1073
1074
  }
1074
1075
 
1076
+ if (!hasMinimumTurnResultShape(turnResult)) {
1077
+ return {
1078
+ ok: false,
1079
+ error: 'API response did not contain a valid turn result with the minimum governed turn-result fields',
1080
+ };
1081
+ }
1082
+
1075
1083
  // Stage the turn result
1076
1084
  try {
1077
1085
  writeFileSync(
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import { spawn } from 'child_process';
21
- import { existsSync, readFileSync, statSync, mkdirSync, writeFileSync } from 'fs';
21
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
22
22
  import { join } from 'path';
23
23
  import {
24
24
  getDispatchContextPath,
@@ -29,6 +29,7 @@ import {
29
29
  getTurnStagingResultPath,
30
30
  } from '../turn-paths.js';
31
31
  import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
32
+ import { hasMeaningfulStagedResult } from '../staged-result-proof.js';
32
33
 
33
34
  /**
34
35
  * Launch a local CLI subprocess for a governed turn.
@@ -37,7 +38,7 @@ import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
37
38
  * passes them as the prompt to the configured CLI command.
38
39
  *
39
40
  * @param {string} root - project root directory
40
- * @param {object} state - current governed state (must have current_turn)
41
+ * @param {object} state - current governed state (must expose an active turn via active_turns; current_turn is a non-enumerable compatibility alias re-attached on load, not a persisted schema field)
41
42
  * @param {object} config - normalized config
42
43
  * @param {object} [options]
43
44
  * @param {AbortSignal} [options.signal] - abort signal for cancellation
@@ -48,7 +49,15 @@ import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
48
49
  * @returns {Promise<{ ok: boolean, exitCode?: number, timedOut?: boolean, aborted?: boolean, error?: string, logs?: string[] }>}
49
50
  */
50
51
  export async function dispatchLocalCli(root, state, config, options = {}) {
51
- const { signal, onStdout, onStderr, turnId } = options;
52
+ const {
53
+ signal,
54
+ onStdout,
55
+ onStderr,
56
+ onSpawnAttached,
57
+ onFirstOutput,
58
+ startupWatchdogMs = config?.run_loop?.startup_watchdog_ms ?? 30_000,
59
+ turnId,
60
+ } = options;
52
61
 
53
62
  const turn = resolveTargetTurn(state, turnId);
54
63
  if (!turn) {
@@ -118,17 +127,74 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
118
127
  env: { ...process.env, AGENTXCHAIN_TURN_ID: turn.turn_id },
119
128
  });
120
129
  } catch (err) {
121
- resolve({ ok: false, error: `Failed to spawn "${command}": ${err.message}`, logs });
130
+ resolve({
131
+ ok: false,
132
+ startupFailure: true,
133
+ startupFailureType: 'runtime_spawn_failed',
134
+ error: `Failed to spawn "${command}": ${err.message}`,
135
+ logs,
136
+ });
122
137
  return;
123
138
  }
124
139
 
125
140
  let settled = false;
141
+ let firstOutputAt = null;
142
+ let spawnConfirmedAt = null;
143
+ let startupWatchdog = null;
144
+ let startupTimedOut = false;
145
+ let startupFailureType = null;
146
+
126
147
  const settle = (result) => {
127
148
  if (settled) return;
128
149
  settled = true;
129
150
  resolve(result);
130
151
  };
131
152
 
153
+ const clearStartupWatchdog = () => {
154
+ if (startupWatchdog) {
155
+ clearTimeout(startupWatchdog);
156
+ startupWatchdog = null;
157
+ }
158
+ };
159
+
160
+ const armStartupWatchdog = () => {
161
+ if (startupWatchdog || !(startupWatchdogMs > 0 && Number.isFinite(startupWatchdogMs))) {
162
+ return;
163
+ }
164
+ startupWatchdog = setTimeout(() => {
165
+ if (firstOutputAt || isStagedResultReady(join(root, getTurnStagingResultPath(turn.turn_id)))) {
166
+ return;
167
+ }
168
+ startupTimedOut = true;
169
+ startupFailureType = 'no_subprocess_output';
170
+ logs.push(`[adapter] Startup watchdog fired after ${Math.round(startupWatchdogMs / 1000)}s with no output.`);
171
+ try {
172
+ child.kill('SIGTERM');
173
+ } catch {}
174
+ }, startupWatchdogMs);
175
+ };
176
+
177
+ const recordFirstOutput = (stream) => {
178
+ if (firstOutputAt) return;
179
+ firstOutputAt = new Date().toISOString();
180
+ clearStartupWatchdog();
181
+ if (onFirstOutput) {
182
+ try {
183
+ onFirstOutput({ pid: child.pid ?? null, at: firstOutputAt, stream });
184
+ } catch {}
185
+ }
186
+ };
187
+
188
+ child.once('spawn', () => {
189
+ spawnConfirmedAt = new Date().toISOString();
190
+ if (onSpawnAttached) {
191
+ try {
192
+ onSpawnAttached({ pid: child.pid ?? null, at: spawnConfirmedAt });
193
+ } catch {}
194
+ }
195
+ armStartupWatchdog();
196
+ });
197
+
132
198
  // Deliver prompt via stdin if transport is "stdin"; otherwise close immediately
133
199
  if (child.stdin) {
134
200
  try {
@@ -143,6 +209,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
143
209
  if (child.stdout) {
144
210
  child.stdout.on('data', (chunk) => {
145
211
  const text = chunk.toString();
212
+ recordFirstOutput('stdout');
146
213
  logs.push(text);
147
214
  if (onStdout) onStdout(text);
148
215
  });
@@ -151,6 +218,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
151
218
  if (child.stderr) {
152
219
  child.stderr.on('data', (chunk) => {
153
220
  const text = chunk.toString();
221
+ recordFirstOutput('stderr');
154
222
  logs.push('[stderr] ' + text);
155
223
  if (onStderr) onStderr(text);
156
224
  });
@@ -180,6 +248,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
180
248
  // Abort signal handling
181
249
  const onAbort = () => {
182
250
  logs.push('[adapter] Abort signal received. Sending SIGTERM.');
251
+ clearStartupWatchdog();
183
252
  clearTimeout(timeoutHandle);
184
253
  clearTimeout(sigkillHandle);
185
254
  try {
@@ -197,6 +266,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
197
266
 
198
267
  // Process exit
199
268
  child.on('close', (exitCode, killSignal) => {
269
+ clearStartupWatchdog();
200
270
  clearTimeout(timeoutHandle);
201
271
  clearTimeout(sigkillHandle);
202
272
  if (signal) signal.removeEventListener('abort', onAbort);
@@ -210,17 +280,59 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
210
280
 
211
281
  // Check if staged result was written (regardless of exit code)
212
282
  const hasResult = isStagedResultReady(join(root, getTurnStagingResultPath(turn.turn_id)));
283
+ if (hasResult && !firstOutputAt) {
284
+ recordFirstOutput('staged_result');
285
+ }
213
286
 
214
287
  if (hasResult) {
215
- settle({ ok: true, exitCode, timedOut: false, aborted: false, logs });
288
+ settle({ ok: true, exitCode, timedOut: false, aborted: false, logs, firstOutputAt });
289
+ } else if (startupTimedOut) {
290
+ settle({
291
+ ok: false,
292
+ exitCode,
293
+ timedOut: false,
294
+ aborted: false,
295
+ startupFailure: true,
296
+ startupFailureType: startupFailureType || 'no_subprocess_output',
297
+ startupWatchdogMs,
298
+ firstOutputAt,
299
+ error: `Subprocess produced no output within ${Math.round(startupWatchdogMs / 1000)}s and did not stage a turn result.`,
300
+ logs,
301
+ });
302
+ } else if (!spawnConfirmedAt) {
303
+ settle({
304
+ ok: false,
305
+ exitCode,
306
+ timedOut: false,
307
+ aborted: false,
308
+ startupFailure: true,
309
+ startupFailureType: 'runtime_spawn_failed',
310
+ firstOutputAt,
311
+ error: `Subprocess exited (code ${exitCode}) before reporting a successful spawn or staging a turn result.`,
312
+ logs,
313
+ });
216
314
  } else if (timedOut) {
217
315
  settle({ ok: false, exitCode, timedOut: true, aborted: false, error: 'Turn timed out without producing a staged result.', logs });
316
+ } else if (!firstOutputAt) {
317
+ settle({
318
+ ok: false,
319
+ exitCode,
320
+ timedOut: false,
321
+ aborted: false,
322
+ startupFailure: true,
323
+ startupFailureType: 'no_subprocess_output',
324
+ startupWatchdogMs,
325
+ firstOutputAt,
326
+ error: `Subprocess exited (code ${exitCode}) before producing output or staging a turn result.`,
327
+ logs,
328
+ });
218
329
  } else {
219
330
  settle({
220
331
  ok: false,
221
332
  exitCode,
222
333
  timedOut: false,
223
334
  aborted: false,
335
+ firstOutputAt,
224
336
  error: `Subprocess exited (code ${exitCode}) without writing a staged turn result to ${getTurnStagingResultPath(turn.turn_id)}.`,
225
337
  logs,
226
338
  });
@@ -228,10 +340,18 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
228
340
  });
229
341
 
230
342
  child.on('error', (err) => {
343
+ clearStartupWatchdog();
231
344
  clearTimeout(timeoutHandle);
232
345
  clearTimeout(sigkillHandle);
233
346
  if (signal) signal.removeEventListener('abort', onAbort);
234
- settle({ ok: false, error: `Subprocess error: ${err.message}`, logs });
347
+ settle({
348
+ ok: false,
349
+ startupFailure: !firstOutputAt,
350
+ startupFailureType: !firstOutputAt ? 'runtime_spawn_failed' : null,
351
+ firstOutputAt,
352
+ error: `Subprocess error: ${err.message}`,
353
+ logs,
354
+ });
235
355
  });
236
356
  });
237
357
  }
@@ -322,15 +442,13 @@ function resolvePromptTransport(runtime) {
322
442
 
323
443
  /**
324
444
  * Check if the staged result file exists and has meaningful content.
445
+ * Delegates to the shared `hasMeaningfulStagedResult` helper so watchdog,
446
+ * manual adapter, and local-cli adapter all agree on what counts as proof.
447
+ * Per DEC-BUG51-STAGING-PLACEHOLDER-NOT-PROOF-001, placeholders (`{}`, blank,
448
+ * whitespace-only, or `{}\n`) are cleanup artifacts, not evidence.
325
449
  */
326
450
  function isStagedResultReady(filePath) {
327
- try {
328
- if (!existsSync(filePath)) return false;
329
- const stat = statSync(filePath);
330
- return stat.size > 2; // Must be more than just "{}" or empty
331
- } catch {
332
- return false;
333
- }
451
+ return hasMeaningfulStagedResult(filePath);
334
452
  }
335
453
 
336
454
  function resolveTargetTurn(state, turnId) {
@@ -10,17 +10,18 @@
10
10
  * auto-route, and does not pretend to be an orchestrator.
11
11
  */
12
12
 
13
- import { existsSync, readFileSync, statSync } from 'fs';
13
+ import { existsSync, readFileSync } from 'fs';
14
14
  import { join } from 'path';
15
15
  import {
16
16
  getDispatchPromptPath,
17
17
  getTurnStagingResultPath,
18
18
  } from '../turn-paths.js';
19
+ import { hasMeaningfulStagedResult } from '../staged-result-proof.js';
19
20
 
20
21
  /**
21
22
  * Print operator instructions for a manual turn.
22
23
  *
23
- * @param {object} state - current governed state (must have current_turn)
24
+ * @param {object} state - current governed state (must expose an active turn via active_turns; current_turn is a non-enumerable compatibility alias re-attached on load, not a persisted schema field)
24
25
  * @param {object} config - normalized config
25
26
  * @param {object} [options]
26
27
  * @param {string} [options.turnId]
@@ -282,16 +283,14 @@ export async function waitForStagedResult(root, options = {}) {
282
283
  }
283
284
 
284
285
  /**
285
- * Check if the staged result file exists and is non-empty.
286
+ * Check if the staged result file exists and has meaningful content.
287
+ * Delegates to the shared `hasMeaningfulStagedResult` helper so watchdog,
288
+ * manual adapter, and local-cli adapter all agree on what counts as proof.
289
+ * Per DEC-BUG51-STAGING-PLACEHOLDER-NOT-PROOF-001, placeholders (`{}`, blank,
290
+ * whitespace-only, or `{}\n`) are cleanup artifacts, not evidence.
286
291
  */
287
292
  function isStagedResultReady(filePath) {
288
- try {
289
- if (!existsSync(filePath)) return false;
290
- const stat = statSync(filePath);
291
- return stat.size > 2; // Must be more than just "{}" or empty
292
- } catch {
293
- return false;
294
- }
293
+ return hasMeaningfulStagedResult(filePath);
295
294
  }
296
295
 
297
296
  /**
@@ -12,6 +12,7 @@ import {
12
12
  getTurnStagingResultPath,
13
13
  } from '../turn-paths.js';
14
14
  import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
15
+ import { hasMinimumTurnResultShape } from '../turn-result-shape.js';
15
16
 
16
17
  export const DEFAULT_MCP_TOOL_NAME = 'agentxchain_turn';
17
18
  export const DEFAULT_MCP_TRANSPORT = 'stdio';
@@ -237,7 +238,7 @@ export function extractTurnResultFromMcpToolResult(toolResult) {
237
238
 
238
239
  for (const block of textBlocks) {
239
240
  const parsed = tryParseJson(block.text);
240
- if (looksLikeTurnResult(parsed) || isPlainObject(parsed)) {
241
+ if (looksLikeTurnResult(parsed)) {
241
242
  return { ok: true, result: parsed };
242
243
  }
243
244
  }
@@ -336,10 +337,7 @@ function isPlainObject(value) {
336
337
  }
337
338
 
338
339
  function looksLikeTurnResult(value) {
339
- if (!isPlainObject(value)) return false;
340
- const hasIdentity = 'run_id' in value || 'turn_id' in value;
341
- const hasLifecycle = 'status' in value || 'role' in value || 'runtime_id' in value;
342
- return hasIdentity && hasLifecycle;
340
+ return hasMinimumTurnResultShape(value);
343
341
  }
344
342
 
345
343
  function tryParseJson(value) {
@@ -24,6 +24,7 @@ import {
24
24
  getTurnStagingResultPath,
25
25
  } from '../turn-paths.js';
26
26
  import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
27
+ import { hasMinimumTurnResultShape } from '../turn-result-shape.js';
27
28
 
28
29
  /** Default timeout for remote agent requests (ms). */
29
30
  export const DEFAULT_REMOTE_AGENT_TIMEOUT_MS = 120_000;
@@ -195,7 +196,7 @@ export async function dispatchRemoteAgent(root, state, config, options = {}) {
195
196
 
196
197
  // Validate turn result structure (lightweight — full validation happens in the acceptance pipeline)
197
198
  if (!looksLikeTurnResult(responseData)) {
198
- logs.push('[remote] Response missing required turn-result fields (need at least run_id/turn_id + status/role)');
199
+ logs.push('[remote] Response missing minimum governed turn-result fields (need schema_version + identity + lifecycle fields)');
199
200
  return {
200
201
  ok: false,
201
202
  error: 'Remote agent response does not contain a valid turn result',
@@ -230,10 +231,7 @@ export async function dispatchRemoteAgent(root, state, config, options = {}) {
230
231
  * Full validation happens later via validateStagedTurnResult.
231
232
  */
232
233
  function looksLikeTurnResult(value) {
233
- if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
234
- const hasIdentity = 'run_id' in value || 'turn_id' in value;
235
- const hasLifecycle = 'status' in value || 'role' in value || 'runtime_id' in value;
236
- return hasIdentity && hasLifecycle;
234
+ return hasMinimumTurnResultShape(value);
237
235
  }
238
236
 
239
237
  function resolveTargetTurn(state, turnId) {