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.
@@ -99,6 +99,9 @@ function describeContinuousTerminalStep(step, contOpts) {
99
99
  return `Continuous loop failed: ${reason}. Check "agentxchain status" for details.`;
100
100
  }
101
101
  if (step.status === 'blocked') {
102
+ if (step.recovery_action) {
103
+ return `Continuous loop paused on blocker. Recovery: ${step.recovery_action}`;
104
+ }
102
105
  return 'Continuous loop paused on blocker. Use "agentxchain unblock <id>" to resume.';
103
106
  }
104
107
  return null;
@@ -116,6 +119,14 @@ function isBlockedContinuousExecution(execution) {
116
119
  || stopReason === 'reject_exhausted';
117
120
  }
118
121
 
122
+ function getBlockedRecoveryAction(state) {
123
+ return state?.blocked_reason?.recovery?.recovery_action || null;
124
+ }
125
+
126
+ function getBlockedCategory(state) {
127
+ return state?.blocked_reason?.category || null;
128
+ }
129
+
119
130
  // ---------------------------------------------------------------------------
120
131
  // Intake queue check
121
132
  // ---------------------------------------------------------------------------
@@ -361,7 +372,14 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
361
372
  if (governedState?.status === 'blocked') {
362
373
  // Still blocked — stay paused, do not attempt new work
363
374
  writeContinuousSession(root, session);
364
- return { ok: true, status: 'blocked', action: 'still_blocked', run_id: session.current_run_id };
375
+ return {
376
+ ok: true,
377
+ status: 'blocked',
378
+ action: 'still_blocked',
379
+ run_id: session.current_run_id,
380
+ recovery_action: getBlockedRecoveryAction(governedState),
381
+ blocked_category: getBlockedCategory(governedState),
382
+ };
365
383
  }
366
384
  // Unblocked — resume by continuing the existing governed run directly.
367
385
  // Skip the intake pipeline: the run is already in progress, and startIntent
@@ -388,10 +406,20 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
388
406
  const resumeStopReason = execution.result?.stop_reason;
389
407
 
390
408
  if (isBlockedContinuousExecution(execution)) {
409
+ const blockedRecoveryAction = getBlockedRecoveryAction(execution?.result?.state || loadProjectState(root, context.config));
391
410
  session.status = 'paused';
392
- log('Resumed run blocked again — continuous loop re-paused.');
411
+ log(blockedRecoveryAction
412
+ ? `Resumed run blocked again — continuous loop re-paused. Recovery: ${blockedRecoveryAction}`
413
+ : 'Resumed run blocked again — continuous loop re-paused.');
393
414
  writeContinuousSession(root, session);
394
- return { ok: true, status: 'blocked', action: 'run_blocked', run_id: session.current_run_id };
415
+ return {
416
+ ok: true,
417
+ status: 'blocked',
418
+ action: 'run_blocked',
419
+ run_id: session.current_run_id,
420
+ recovery_action: blockedRecoveryAction,
421
+ blocked_category: getBlockedCategory(execution?.result?.state || loadProjectState(root, context.config)),
422
+ };
395
423
  }
396
424
 
397
425
  if (execution.exitCode !== 0 || !execution.result) {
@@ -473,9 +501,35 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
473
501
  return { ok: false, status: 'failed', action: 'prepare_failed', stop_reason: preparedIntent.error, intent_id: targetIntentId };
474
502
  }
475
503
 
504
+ // BUG-53: Auto-chain audit trail. When this advance step seeds a NEXT run
505
+ // (i.e., at least one prior run already completed in this session), emit a
506
+ // `session_continuation` event so operators have a visible record that the
507
+ // loop auto-derived the next vision objective without intervention. Event
508
+ // is emitted BEFORE we overwrite session.current_run_id so previous_run_id
509
+ // reflects the just-completed run and next_run_id reflects the newly
510
+ // prepared one. See HUMAN-ROADMAP BUG-53 fix #4.
511
+ const previousRunId = session.current_run_id;
512
+ const nextObjective = visionObjective || preparedIntent.intent?.charter || null;
513
+ if ((session.runs_completed || 0) >= 1 && previousRunId && previousRunId !== preparedIntent.run_id) {
514
+ emitRunEvent(root, 'session_continuation', {
515
+ run_id: preparedIntent.run_id,
516
+ phase: null,
517
+ status: 'active',
518
+ payload: {
519
+ session_id: session.session_id,
520
+ previous_run_id: previousRunId,
521
+ next_run_id: preparedIntent.run_id,
522
+ next_objective: nextObjective,
523
+ next_intent_id: targetIntentId,
524
+ runs_completed: session.runs_completed || 0,
525
+ trigger: visionObjective ? 'vision_scan' : 'intake',
526
+ },
527
+ });
528
+ }
529
+
476
530
  // Execute the governed run
477
531
  session.current_run_id = preparedIntent.run_id;
478
- session.current_vision_objective = visionObjective || preparedIntent.intent?.charter || null;
532
+ session.current_vision_objective = nextObjective;
479
533
  session.status = 'running';
480
534
  writeContinuousSession(root, session);
481
535
 
@@ -519,6 +573,7 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
519
573
  }
520
574
 
521
575
  if (isBlockedContinuousExecution(execution)) {
576
+ const blockedRecoveryAction = getBlockedRecoveryAction(execution?.result?.state || loadProjectState(root, context.config));
522
577
  const resolved = resolveIntent(root, targetIntentId);
523
578
  if (!resolved.ok) {
524
579
  log(`Continuous resolve error: ${resolved.error}`);
@@ -527,9 +582,19 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
527
582
  return { ok: false, status: 'failed', action: 'resolve_failed', stop_reason: resolved.error, intent_id: targetIntentId };
528
583
  }
529
584
  session.status = 'paused';
530
- log('Run blocked — continuous loop paused. Use `agentxchain unblock <id>` to resume.');
585
+ log(blockedRecoveryAction
586
+ ? `Run blocked — continuous loop paused. Recovery: ${blockedRecoveryAction}`
587
+ : 'Run blocked — continuous loop paused. Use `agentxchain unblock <id>` to resume.');
531
588
  writeContinuousSession(root, session);
532
- return { ok: true, status: 'blocked', action: 'run_blocked', run_id: session.current_run_id, intent_id: targetIntentId };
589
+ return {
590
+ ok: true,
591
+ status: 'blocked',
592
+ action: 'run_blocked',
593
+ run_id: session.current_run_id,
594
+ intent_id: targetIntentId,
595
+ recovery_action: blockedRecoveryAction,
596
+ blocked_category: getBlockedCategory(execution?.result?.state || loadProjectState(root, context.config)),
597
+ };
533
598
  }
534
599
 
535
600
  if (stopReason === 'caller_stopped') {
@@ -55,7 +55,7 @@ const RESERVED_PATHS = [
55
55
  * Write a dispatch bundle for the currently assigned turn.
56
56
  *
57
57
  * @param {string} root - project root directory
58
- * @param {object} state - current governed state (must have current_turn)
58
+ * @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)
59
59
  * @param {object} config - normalized config
60
60
  * @param {object} [opts]
61
61
  * @param {string} [opts.turnId]
@@ -71,9 +71,10 @@ export function createDispatchProgressTracker(root, turn, options = {}) {
71
71
  runtime_id: turn.runtime_id || null,
72
72
  adapter_type,
73
73
  started_at: null,
74
+ first_output_at: null,
74
75
  last_activity_at: null,
75
- activity_type: 'output',
76
- activity_summary: 'Dispatch starting',
76
+ activity_type: 'starting',
77
+ activity_summary: 'Waiting for first output',
77
78
  output_lines: 0,
78
79
  stderr_lines: 0,
79
80
  silent_since: null,
@@ -123,7 +124,7 @@ export function createDispatchProgressTracker(root, turn, options = {}) {
123
124
  const now = new Date().toISOString();
124
125
  state.started_at = now;
125
126
  state.last_activity_at = now;
126
- state.activity_type = 'output';
127
+ state.activity_type = 'starting';
127
128
  state.activity_summary = 'Subprocess started';
128
129
  dirty = true;
129
130
  writeProgress();
@@ -137,6 +138,7 @@ export function createDispatchProgressTracker(root, turn, options = {}) {
137
138
  const now = new Date().toISOString();
138
139
  const wasSilent = state.activity_type === 'silent';
139
140
  state.last_activity_at = now;
141
+ state.first_output_at = state.first_output_at || now;
140
142
  state.activity_type = 'output';
141
143
  state.silent_since = null;
142
144
  if (stream === 'stderr') {
@@ -955,6 +955,67 @@ function writeState(root, state) {
955
955
  safeWriteJson(join(root, STATE_PATH), stripLegacyCurrentTurn(state));
956
956
  }
957
957
 
958
+ export function transitionActiveTurnLifecycle(root, turnId, nextStatus, options = {}) {
959
+ const state = readState(root);
960
+ if (!state) {
961
+ return { ok: false, error: 'No governed state found' };
962
+ }
963
+
964
+ const activeTurns = { ...(state.active_turns || {}) };
965
+ const turn = activeTurns[turnId];
966
+ if (!turn) {
967
+ return { ok: false, error: `Turn ${turnId} not found in active turns` };
968
+ }
969
+
970
+ const nowIso = options.at || new Date().toISOString();
971
+ const nextTurn = { ...turn };
972
+
973
+ if (nextStatus === 'dispatched') {
974
+ nextTurn.status = 'dispatched';
975
+ nextTurn.dispatched_at = nowIso;
976
+ delete nextTurn.started_at;
977
+ delete nextTurn.worker_attached_at;
978
+ delete nextTurn.worker_pid;
979
+ delete nextTurn.first_output_at;
980
+ delete nextTurn.first_output_stream;
981
+ emitRunEvent(root, 'turn_dispatched', {
982
+ run_id: state.run_id,
983
+ phase: state.phase,
984
+ status: state.status,
985
+ turn: { turn_id: turnId, role_id: turn.assigned_role },
986
+ intent_id: turn.intake_context?.intent_id || null,
987
+ });
988
+ } else if (nextStatus === 'starting') {
989
+ nextTurn.status = 'starting';
990
+ nextTurn.started_at = nowIso;
991
+ nextTurn.worker_attached_at = nowIso;
992
+ if (options.pid != null) {
993
+ nextTurn.worker_pid = options.pid;
994
+ }
995
+ } else if (nextStatus === 'running') {
996
+ nextTurn.status = 'running';
997
+ nextTurn.started_at = nextTurn.started_at || nowIso;
998
+ nextTurn.first_output_at = nextTurn.first_output_at || nowIso;
999
+ if (options.stream) {
1000
+ nextTurn.first_output_stream = nextTurn.first_output_stream || options.stream;
1001
+ }
1002
+ } else {
1003
+ return { ok: false, error: `Unsupported turn lifecycle status: ${nextStatus}` };
1004
+ }
1005
+
1006
+ activeTurns[turnId] = nextTurn;
1007
+ const nextState = {
1008
+ ...state,
1009
+ active_turns: activeTurns,
1010
+ };
1011
+ writeState(root, nextState);
1012
+ return {
1013
+ ok: true,
1014
+ state: attachLegacyCurrentTurnAlias(nextState),
1015
+ turn: attachLegacyCurrentTurnAlias(nextState).active_turns[turnId],
1016
+ };
1017
+ }
1018
+
958
1019
  function appendJsonl(root, relPath, entry) {
959
1020
  const filePath = join(root, relPath);
960
1021
  mkdirSync(dirname(filePath), { recursive: true });
@@ -2459,6 +2520,144 @@ export function reactivateGovernedRun(root, state, details = {}) {
2459
2520
  };
2460
2521
  }
2461
2522
 
2523
+ export function reconcilePhaseAdvanceBeforeDispatch(root, config, state = null) {
2524
+ const currentState = state && typeof state === 'object' ? state : readState(root);
2525
+ if (!currentState) {
2526
+ return { ok: false, error: 'No governed state.json found' };
2527
+ }
2528
+
2529
+ if (currentState.status !== 'active' || getActiveTurnCount(currentState) > 0) {
2530
+ return {
2531
+ ok: true,
2532
+ state: attachLegacyCurrentTurnAlias(currentState),
2533
+ advanced: false,
2534
+ };
2535
+ }
2536
+
2537
+ const gateFailure = currentState.last_gate_failure;
2538
+ if (gateFailure?.gate_type !== 'phase_transition') {
2539
+ return {
2540
+ ok: true,
2541
+ state: attachLegacyCurrentTurnAlias(currentState),
2542
+ advanced: false,
2543
+ };
2544
+ }
2545
+
2546
+ const historyEntries = readJsonlEntries(root, HISTORY_PATH);
2547
+ const phaseSource = findHistoryTurnRequest(
2548
+ historyEntries,
2549
+ gateFailure.requested_by_turn || currentState.last_completed_turn_id || null,
2550
+ 'phase_transition',
2551
+ );
2552
+ if (!phaseSource?.phase_transition_request) {
2553
+ return {
2554
+ ok: true,
2555
+ state: attachLegacyCurrentTurnAlias(currentState),
2556
+ advanced: false,
2557
+ };
2558
+ }
2559
+
2560
+ const gateResult = evaluatePhaseExit({
2561
+ state: { ...currentState, history: historyEntries },
2562
+ config,
2563
+ acceptedTurn: phaseSource,
2564
+ root,
2565
+ });
2566
+
2567
+ if (gateResult.action === 'awaiting_human_approval') {
2568
+ const pausedState = {
2569
+ ...currentState,
2570
+ status: 'paused',
2571
+ blocked_on: `human_approval:${gateResult.gate_id}`,
2572
+ blocked_reason: null,
2573
+ pending_phase_transition: {
2574
+ from: currentState.phase,
2575
+ to: gateResult.next_phase,
2576
+ gate: gateResult.gate_id,
2577
+ requested_by_turn: phaseSource.turn_id,
2578
+ requested_at: new Date().toISOString(),
2579
+ },
2580
+ };
2581
+ writeState(root, pausedState);
2582
+ const approved = approvePhaseTransition(root, config);
2583
+ return {
2584
+ ok: approved.ok,
2585
+ error: approved.error,
2586
+ state: approved.state || attachLegacyCurrentTurnAlias(readState(root)),
2587
+ advanced: approved.ok,
2588
+ from_phase: currentState.phase,
2589
+ to_phase: approved.state?.phase || gateResult.next_phase || null,
2590
+ gate_id: gateResult.gate_id || null,
2591
+ gateResult,
2592
+ };
2593
+ }
2594
+
2595
+ if (gateResult.action !== 'advance') {
2596
+ return {
2597
+ ok: true,
2598
+ state: attachLegacyCurrentTurnAlias(currentState),
2599
+ advanced: false,
2600
+ gateResult,
2601
+ };
2602
+ }
2603
+
2604
+ const now = new Date().toISOString();
2605
+ const prevPhase = currentState.phase;
2606
+ const nextState = {
2607
+ ...currentState,
2608
+ phase: gateResult.next_phase,
2609
+ phase_entered_at: now,
2610
+ blocked_on: null,
2611
+ blocked_reason: null,
2612
+ last_gate_failure: null,
2613
+ pending_phase_transition: null,
2614
+ queued_phase_transition: null,
2615
+ phase_gate_status: {
2616
+ ...(currentState.phase_gate_status || {}),
2617
+ [gateResult.gate_id || 'no_gate']: 'passed',
2618
+ },
2619
+ };
2620
+
2621
+ writeState(root, nextState);
2622
+ const retiredIntentIds = retireApprovedPhaseScopedIntents(root, nextState, config, prevPhase, now);
2623
+ if (retiredIntentIds.length > 0) {
2624
+ emitRunEvent(root, 'intent_retired_by_phase_advance', {
2625
+ run_id: nextState.run_id,
2626
+ phase: nextState.phase,
2627
+ status: nextState.status,
2628
+ turn: phaseSource.turn_id ? { turn_id: phaseSource.turn_id, role_id: phaseSource.role || phaseSource.assigned_role || null } : undefined,
2629
+ payload: {
2630
+ exited_phase: prevPhase,
2631
+ entered_phase: gateResult.next_phase,
2632
+ retired_count: retiredIntentIds.length,
2633
+ retired_intent_ids: retiredIntentIds,
2634
+ },
2635
+ });
2636
+ }
2637
+ emitRunEvent(root, 'phase_entered', {
2638
+ run_id: nextState.run_id,
2639
+ phase: nextState.phase,
2640
+ status: nextState.status,
2641
+ turn: phaseSource.turn_id ? { turn_id: phaseSource.turn_id, role_id: phaseSource.role || phaseSource.assigned_role || null } : undefined,
2642
+ payload: {
2643
+ from: prevPhase,
2644
+ to: gateResult.next_phase,
2645
+ gate_id: gateResult.gate_id || 'no_gate',
2646
+ trigger: 'reconciled_before_dispatch',
2647
+ },
2648
+ });
2649
+
2650
+ return {
2651
+ ok: true,
2652
+ state: attachLegacyCurrentTurnAlias(nextState),
2653
+ advanced: true,
2654
+ from_phase: prevPhase,
2655
+ to_phase: gateResult.next_phase,
2656
+ gate_id: gateResult.gate_id || null,
2657
+ gateResult,
2658
+ };
2659
+ }
2660
+
2462
2661
  // ── Core Operations ──────────────────────────────────────────────────────────
2463
2662
 
2464
2663
  /**
@@ -2760,9 +2959,9 @@ export function assignGovernedTurn(root, config, roleId, options = {}) {
2760
2959
  const newTurn = {
2761
2960
  turn_id: turnId,
2762
2961
  assigned_role: roleId,
2763
- status: 'running',
2962
+ status: 'assigned',
2764
2963
  attempt: 1,
2765
- started_at: now,
2964
+ assigned_at: now,
2766
2965
  deadline_at: new Date(Date.now() + timeoutMinutes * 60 * 1000).toISOString(),
2767
2966
  runtime_id: runtimeId,
2768
2967
  baseline,
@@ -2812,14 +3011,6 @@ export function assignGovernedTurn(root, config, roleId, options = {}) {
2812
3011
 
2813
3012
  writeState(root, updatedState);
2814
3013
 
2815
- emitRunEvent(root, 'turn_dispatched', {
2816
- run_id: updatedState.run_id,
2817
- phase: updatedState.phase,
2818
- status: updatedState.status,
2819
- turn: { turn_id: turnId, role_id: roleId },
2820
- intent_id: options.intakeContext?.intent_id || null,
2821
- });
2822
-
2823
3014
  // Session checkpoint — non-fatal, written after every successful turn assignment.
2824
3015
  // Pass the captured baseline so session.json agrees with state.json (BUG-2 fix).
2825
3016
  writeSessionCheckpoint(root, updatedState, 'turn_assigned', {
@@ -3032,9 +3223,9 @@ export function reissueTurn(root, config, opts = {}) {
3032
3223
  const newTurn = {
3033
3224
  turn_id: newTurnId,
3034
3225
  assigned_role: roleId,
3035
- status: 'running',
3226
+ status: 'assigned',
3036
3227
  attempt: (oldTurn.attempt || 1) + 1,
3037
- started_at: now,
3228
+ assigned_at: now,
3038
3229
  deadline_at: new Date(Date.now() + timeoutMinutes * 60 * 1000).toISOString(),
3039
3230
  runtime_id: currentRuntimeId,
3040
3231
  baseline: newBaseline,
@@ -3053,10 +3244,30 @@ export function reissueTurn(root, config, opts = {}) {
3053
3244
  delete newActiveTurns[turnId];
3054
3245
  newActiveTurns[newTurnId] = newTurn;
3055
3246
 
3247
+ // BUG-51 fix #6 (reissue path): release the old turn's reservation and create
3248
+ // a fresh reservation for the new turn. Without this, an operator who runs
3249
+ // `reissue-turn` before the watchdog fires (e.g., drift recovery, or
3250
+ // operator-initiated reissue) leaves the old turn's reservation lingering in
3251
+ // budget_reservations and the new turn carries no budget tracking at all.
3252
+ // The watchdog paths (reconcileStaleTurns / failTurnStartup) already release
3253
+ // on stalled/failed_start; this closes the same hole on the reissue surface.
3254
+ const newReservations = { ...(state.budget_reservations || {}) };
3255
+ delete newReservations[turnId];
3256
+ const reissueEstimate = estimateTurnBudget(config, roleId);
3257
+ if (reissueEstimate > 0) {
3258
+ newReservations[newTurnId] = {
3259
+ reserved_usd: reissueEstimate,
3260
+ role_id: roleId,
3261
+ created_at: now,
3262
+ reissued_from: oldTurn.turn_id,
3263
+ };
3264
+ }
3265
+
3056
3266
  const updatedState = {
3057
3267
  ...state,
3058
3268
  turn_sequence: nextSequence,
3059
3269
  active_turns: newActiveTurns,
3270
+ budget_reservations: newReservations,
3060
3271
  };
3061
3272
 
3062
3273
  writeState(root, updatedState);
@@ -5175,7 +5386,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
5175
5386
  * Reject a governed turn.
5176
5387
  *
5177
5388
  * 1. Preserve the invalid staged artifact under .agentxchain/dispatch/rejected/
5178
- * 2. Increment current_turn.attempt or escalate if retries exhausted
5389
+ * 2. Increment the active turn's attempt counter or escalate if retries exhausted
5179
5390
  * 3. Clear staging file
5180
5391
  *
5181
5392
  * Does NOT append to history.jsonl or decision-ledger.jsonl.
package/src/lib/intake.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  assignGovernedTurn,
9
9
  getActiveTurns,
10
10
  getActiveTurnCount,
11
+ transitionActiveTurnLifecycle,
11
12
  STATE_PATH,
12
13
  } from './governed-state.js';
13
14
  import { loadProjectContext, loadProjectState } from './config.js';
@@ -1090,10 +1091,18 @@ export function startIntent(root, intentId, options = {}) {
1090
1091
  return { ok: false, error: `dispatch bundle failed: ${bundleResult.error}`, exitCode: 1 };
1091
1092
  }
1092
1093
 
1093
- finalizeDispatchManifest(root, assignedTurn.turn_id, {
1094
+ const manifestResult = finalizeDispatchManifest(root, assignedTurn.turn_id, {
1094
1095
  run_id: state.run_id,
1095
1096
  role: assignedTurn.assigned_role,
1096
1097
  });
1098
+ if (!manifestResult.ok) {
1099
+ return { ok: false, error: `dispatch manifest failed: ${manifestResult.error}`, exitCode: 1 };
1100
+ }
1101
+ const dispatched = transitionActiveTurnLifecycle(root, assignedTurn.turn_id, 'dispatched');
1102
+ if (!dispatched.ok) {
1103
+ return { ok: false, error: `dispatch lifecycle transition failed: ${dispatched.error}`, exitCode: 1 };
1104
+ }
1105
+ state = dispatched.state;
1097
1106
  }
1098
1107
 
1099
1108
  // Update intent: planned → executing
@@ -395,6 +395,11 @@ export function validateV4Config(data, projectRoot) {
395
395
  } else {
396
396
  if (typeof data.project.id !== 'string' || !data.project.id.trim()) errors.push('project.id must be a non-empty string');
397
397
  if (typeof data.project.name !== 'string' || !data.project.name.trim()) errors.push('project.name must be a non-empty string');
398
+ if ('default_branch' in data.project) {
399
+ if (typeof data.project.default_branch !== 'string' || !data.project.default_branch.trim()) {
400
+ errors.push('project.default_branch must be a non-empty string when provided');
401
+ }
402
+ }
398
403
  // Optional project.goal field
399
404
  if (data.project.goal !== undefined && data.project.goal !== null) {
400
405
  if (typeof data.project.goal !== 'string') {
@@ -480,6 +485,17 @@ export function validateV4Config(data, projectRoot) {
480
485
  }
481
486
  }
482
487
  }
488
+ // Schema publishes max_output_tokens as `integer, minimum: 1`. The
489
+ // api-proxy adapter silently falls back to 4096 on `0` / null /
490
+ // undefined and passes negative/non-integer values straight through
491
+ // to the provider, which is the same silent-fallback defect class
492
+ // the run_loop watchdog knobs had (DEC-SILENT-FALLBACK-DEFECT-CLASS-001).
493
+ // Reject at write time so the operator sees the bad value immediately.
494
+ if ('max_output_tokens' in rt) {
495
+ if (!Number.isInteger(rt.max_output_tokens) || rt.max_output_tokens < 1) {
496
+ errors.push(`Runtime "${id}": max_output_tokens must be a positive integer`);
497
+ }
498
+ }
483
499
  if ('retry_policy' in rt) {
484
500
  validateApiProxyRetryPolicy(id, rt.retry_policy, errors);
485
501
  }
@@ -597,6 +613,14 @@ export function validateV4Config(data, projectRoot) {
597
613
  errors.push(...timeoutValidation.errors);
598
614
  }
599
615
 
616
+ // Run-loop watchdog knobs (BUG-47 / BUG-51). Schema publishes both as
617
+ // positive integers; runtime silently falls back to defaults on bad input,
618
+ // which misleads operators into thinking their config --set took effect.
619
+ // Reject at config-write / validate time so the operator sees the problem.
620
+ if (data.run_loop !== undefined) {
621
+ errors.push(...validateRunLoopConfig(data.run_loop));
622
+ }
623
+
600
624
  // Admission control (ADM-001..004) is handled by the validate, doctor, and
601
625
  // run-loop paths which call runAdmissionControl() directly. Config schema
602
626
  // validation here should not duplicate that surface.
@@ -604,6 +628,30 @@ export function validateV4Config(data, projectRoot) {
604
628
  return { ok: errors.length === 0, errors, warnings };
605
629
  }
606
630
 
631
+ export function validateRunLoopConfig(runLoop) {
632
+ const errors = [];
633
+ if (runLoop === null || typeof runLoop !== 'object' || Array.isArray(runLoop)) {
634
+ errors.push('run_loop must be an object');
635
+ return errors;
636
+ }
637
+ validateRunLoopPositiveInteger('run_loop.startup_watchdog_ms', runLoop.startup_watchdog_ms, errors);
638
+ validateRunLoopPositiveInteger('run_loop.stale_turn_threshold_ms', runLoop.stale_turn_threshold_ms, errors);
639
+ return errors;
640
+ }
641
+
642
+ function validateRunLoopPositiveInteger(path, value, errors) {
643
+ if (value === undefined || value === null) {
644
+ return;
645
+ }
646
+ if (typeof value !== 'number' || !Number.isInteger(value)) {
647
+ errors.push(`${path} must be a positive integer (milliseconds)`);
648
+ return;
649
+ }
650
+ if (value < 1) {
651
+ errors.push(`${path} must be a positive integer (milliseconds)`);
652
+ }
653
+ }
654
+
607
655
  export function validateBudgetConfig(budget) {
608
656
  const errors = [];
609
657
 
@@ -1145,7 +1193,9 @@ export function normalizeV4(raw) {
1145
1193
  id: raw.project?.id || 'unknown',
1146
1194
  name: raw.project?.name || 'Unknown',
1147
1195
  ...(typeof raw.project?.goal === 'string' && raw.project.goal.trim() ? { goal: raw.project.goal.trim() } : {}),
1148
- default_branch: raw.project?.default_branch || 'main',
1196
+ default_branch: typeof raw.project?.default_branch === 'string' && raw.project.default_branch.trim()
1197
+ ? raw.project.default_branch.trim()
1198
+ : 'main',
1149
1199
  },
1150
1200
  roles,
1151
1201
  runtimes: raw.runtimes || {},
@@ -55,6 +55,8 @@ function describeEvent(eventType, entry) {
55
55
  case 'turn_checkpointed':
56
56
  case 'turn_stalled':
57
57
  case 'turn_start_failed':
58
+ case 'runtime_spawn_failed':
59
+ case 'stdout_attach_failed':
58
60
  return `${prefix}${eventType}${roleId ? ` [${roleId}]` : ''}`;
59
61
  case 'dispatch_progress':
60
62
  return `${prefix}${eventType}${roleId ? ` [${roleId}]` : ''}`;
@@ -77,6 +79,15 @@ function describeEvent(eventType, entry) {
77
79
  case 'escalation_resolved':
78
80
  case 'budget_exceeded_warn':
79
81
  return `${prefix}${eventType}`;
82
+ case 'session_continuation': {
83
+ const prev = trimToNull(entry.payload?.previous_run_id);
84
+ const next = trimToNull(entry.payload?.next_run_id);
85
+ const objective = trimToNull(entry.payload?.next_objective);
86
+ if (prev && next) {
87
+ return `${prefix}${eventType} ${prev} -> ${next}${objective ? ` (${objective})` : ''}`;
88
+ }
89
+ return `${prefix}${eventType}`;
90
+ }
80
91
  default:
81
92
  if (trimToNull(entry.summary)) return entry.summary.trim();
82
93
  return `${prefix}${eventType || 'unknown_event'}`;
@@ -25,6 +25,9 @@ export const VALID_RUN_EVENTS = [
25
25
  'acceptance_failed',
26
26
  'turn_reissued',
27
27
  'turn_stalled',
28
+ 'turn_start_failed',
29
+ 'runtime_spawn_failed',
30
+ 'stdout_attach_failed',
28
31
  'turn_checkpointed',
29
32
  'coordinator_retry',
30
33
  'coordinator_retry_projection_warning',
@@ -39,6 +42,7 @@ export const VALID_RUN_EVENTS = [
39
42
  'human_escalation_raised',
40
43
  'human_escalation_resolved',
41
44
  'dispatch_progress',
45
+ 'session_continuation',
42
46
  ];
43
47
 
44
48
  /**