agentxchain 2.152.0 → 2.154.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.
@@ -73,6 +73,7 @@ import { injectCommand } from '../src/commands/inject.js';
73
73
  import { escalateCommand } from '../src/commands/escalate.js';
74
74
  import { acceptTurnCommand } from '../src/commands/accept-turn.js';
75
75
  import { checkpointTurnCommand } from '../src/commands/checkpoint-turn.js';
76
+ import { reconcileStateCommand } from '../src/commands/reconcile-state.js';
76
77
  import { rejectTurnCommand } from '../src/commands/reject-turn.js';
77
78
  import { reissueTurnCommand } from '../src/commands/reissue-turn.js';
78
79
  import { proposalListCommand, proposalDiffCommand, proposalApplyCommand, proposalRejectCommand } from '../src/commands/proposal.js';
@@ -701,6 +702,12 @@ program
701
702
  .option('--turn <id>', 'Checkpoint a specific accepted turn from history')
702
703
  .action(checkpointTurnCommand);
703
704
 
705
+ program
706
+ .command('reconcile-state')
707
+ .description('Reconcile safe operator commits into governed run state')
708
+ .option('--accept-operator-head', 'Accept safe fast-forward operator commits as the new governed baseline')
709
+ .action(reconcileStateCommand);
710
+
704
711
  program
705
712
  .command('reject-turn')
706
713
  .description('Reject the current governed turn result and retry or escalate')
@@ -753,6 +760,11 @@ program
753
760
  .option('--triage-approval <mode>', 'Triage policy for vision-derived intents: auto or human (default: config or auto)')
754
761
  .option('--max-idle-cycles <n>', 'Stop after N consecutive idle cycles with no derivable work (default: 3)', parseInt)
755
762
  .option('--session-budget <usd>', 'Cumulative session-level budget cap in USD for continuous mode', parseFloat)
763
+ .option('--auto-retry-on-ghost', 'Enable bounded automatic retry for continuous-mode startup ghost turns')
764
+ .option('--no-auto-retry-on-ghost', 'Disable bounded automatic retry for continuous-mode startup ghost turns')
765
+ .option('--auto-retry-on-ghost-max-retries <n>', 'Maximum startup ghost retries per continuous run (default: config or 3)', parseInt)
766
+ .option('--auto-retry-on-ghost-cooldown-seconds <n>', 'Seconds to wait between startup ghost retries (default: config or 5)', parseInt)
767
+ .option('--reconcile-operator-commits <mode>', 'Continuous reconcile posture for operator commits: manual, auto_safe_only, or disabled (default: config or manual; auto_safe_only under full-auto approval policy)')
756
768
  .option('--auto-checkpoint', 'Auto-commit accepted writable turns after acceptance')
757
769
  .option('--no-auto-checkpoint', 'Disable automatic checkpointing after accepted writable turns')
758
770
  .action(runCommand);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.152.0",
3
+ "version": "2.154.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,49 @@
1
+ import chalk from 'chalk';
2
+ import { loadProjectContext } from '../lib/config.js';
3
+ import { reconcileOperatorHead } from '../lib/operator-commit-reconcile.js';
4
+
5
+ export async function reconcileStateCommand(opts = {}) {
6
+ const context = loadProjectContext();
7
+ if (!context) {
8
+ console.log(chalk.red('No agentxchain.json found. Run `agentxchain init` first.'));
9
+ process.exit(1);
10
+ }
11
+
12
+ const { root, config } = context;
13
+ if (config.protocol_mode !== 'governed') {
14
+ console.log(chalk.red('The reconcile-state command is only available for governed projects.'));
15
+ process.exit(1);
16
+ }
17
+
18
+ if (!opts.acceptOperatorHead) {
19
+ console.log(chalk.red('No reconciliation action selected.'));
20
+ console.log(chalk.dim('Run `agentxchain reconcile-state --accept-operator-head` to accept safe operator commits on top of the last checkpoint.'));
21
+ process.exit(1);
22
+ }
23
+
24
+ const result = reconcileOperatorHead(root);
25
+ if (!result.ok) {
26
+ console.log(chalk.red(`Reconcile refused (${result.error_class || 'unknown'}).`));
27
+ console.log(chalk.red(result.error || 'Unable to reconcile operator commits.'));
28
+ if (result.offending_path) {
29
+ console.log(chalk.dim(`Offending path: ${result.offending_path}`));
30
+ }
31
+ if (result.offending_commit) {
32
+ console.log(chalk.dim(`Offending commit: ${result.offending_commit}`));
33
+ }
34
+ console.log(chalk.dim('Manual recovery: inspect the commit range, restore governed state artifacts if needed, then restart from an explicit checkpoint.'));
35
+ process.exit(1);
36
+ }
37
+
38
+ if (result.no_op) {
39
+ console.log(chalk.green(`State already reconciled at ${result.accepted_head.slice(0, 8)}.`));
40
+ return;
41
+ }
42
+
43
+ console.log(chalk.green(`Reconciled ${result.accepted_commits.length} operator commit(s).`));
44
+ console.log(chalk.dim(`Previous baseline: ${result.previous_baseline}`));
45
+ console.log(chalk.dim(`Accepted HEAD: ${result.accepted_head}`));
46
+ if (result.paths_touched.length > 0) {
47
+ console.log(chalk.dim(`Paths touched: ${result.paths_touched.join(', ')}`));
48
+ }
49
+ }
@@ -129,8 +129,15 @@ export function getContinuityStatus(root, state) {
129
129
  && checkpoint.run_id !== state.run_id
130
130
  );
131
131
 
132
- const action = deriveRecommendedContinuityAction(state);
133
132
  const drift = deriveCheckpointDrift(root, checkpoint, staleCheckpoint);
133
+ const action = drift.drift_detected === true
134
+ ? {
135
+ recommended_command: 'agentxchain reconcile-state --accept-operator-head',
136
+ recommended_reason: 'operator_commit_drift',
137
+ recommended_detail: 'accept safe fast-forward operator commits as the new baseline',
138
+ restart_recommended: false,
139
+ }
140
+ : deriveRecommendedContinuityAction(state);
134
141
 
135
142
  return {
136
143
  checkpoint,
@@ -25,6 +25,16 @@ import {
25
25
  import { loadProjectState } from './config.js';
26
26
  import { safeWriteJson } from './safe-write.js';
27
27
  import { emitRunEvent } from './run-events.js';
28
+ import { reissueTurn } from './governed-state.js';
29
+ import {
30
+ applyGhostRetryAttempt,
31
+ applyGhostRetryExhaustion,
32
+ buildGhostRetryDiagnosticBundle,
33
+ buildGhostRetryExhaustionMirror,
34
+ classifyGhostRetryDecision,
35
+ } from './ghost-retry.js';
36
+ import { reconcileOperatorHead } from './operator-commit-reconcile.js';
37
+ import { getContinuityStatus } from './continuity-status.js';
28
38
  import {
29
39
  archiveStaleIntentsForRun,
30
40
  formatLegacyIntentMigrationNotice,
@@ -127,6 +137,178 @@ function getBlockedCategory(state) {
127
137
  return state?.blocked_reason?.category || null;
128
138
  }
129
139
 
140
+ function writeGovernedState(root, state) {
141
+ safeWriteJson(join(root, '.agentxchain', 'state.json'), state);
142
+ }
143
+
144
+ function clearGhostBlockerAfterReissue(root, state) {
145
+ const nextState = {
146
+ ...state,
147
+ status: 'active',
148
+ blocked_on: null,
149
+ blocked_reason: null,
150
+ escalation: null,
151
+ };
152
+ writeGovernedState(root, nextState);
153
+ return nextState;
154
+ }
155
+
156
+ async function maybeAutoRetryGhostBlocker(context, session, contOpts, blockedState, log = console.log) {
157
+ const { root, config } = context;
158
+ const decision = classifyGhostRetryDecision({
159
+ state: blockedState,
160
+ session,
161
+ autoRetryOnGhost: contOpts.autoRetryOnGhost,
162
+ runId: session.current_run_id || blockedState?.run_id || null,
163
+ });
164
+
165
+ if (decision.decision === 'retry') {
166
+ const oldTurnId = decision.ghost.turn_id;
167
+ const oldTurn = blockedState?.active_turns?.[oldTurnId] || {};
168
+ const reissued = reissueTurn(root, config, {
169
+ turnId: oldTurnId,
170
+ reason: 'auto_retry_ghost',
171
+ });
172
+ if (!reissued.ok) {
173
+ log(`Ghost auto-retry skipped: ${reissued.error}`);
174
+ return null;
175
+ }
176
+
177
+ const runId = session.current_run_id || blockedState?.run_id || reissued.state?.run_id || null;
178
+ const attempt = decision.attempts + 1;
179
+ const nowIso = new Date().toISOString();
180
+ const nextState = clearGhostBlockerAfterReissue(root, reissued.state);
181
+ // Slice 2c: pass runtime/role/timing fields so the fingerprint log can
182
+ // drive same-signature early-stop detection on subsequent invocations.
183
+ const oldRuntimeId = oldTurn.runtime_id || reissued.newTurn.runtime_id || null;
184
+ const oldRoleId = oldTurn.assigned_role || reissued.newTurn.assigned_role || null;
185
+ const oldRunningMs = oldTurn.failed_start_running_ms ?? null;
186
+ const oldThresholdMs = oldTurn.failed_start_threshold_ms ?? null;
187
+ const nextSession = applyGhostRetryAttempt(session, {
188
+ runId,
189
+ oldTurnId,
190
+ newTurnId: reissued.newTurn.turn_id,
191
+ failureType: decision.ghost.failure_type,
192
+ maxRetries: decision.maxRetries,
193
+ nowIso,
194
+ runtimeId: oldRuntimeId,
195
+ roleId: oldRoleId,
196
+ runningMs: oldRunningMs,
197
+ thresholdMs: oldThresholdMs,
198
+ });
199
+ Object.assign(session, nextSession, {
200
+ status: 'running',
201
+ current_run_id: runId,
202
+ });
203
+ writeContinuousSession(root, session);
204
+
205
+ emitRunEvent(root, 'auto_retried_ghost', {
206
+ run_id: runId,
207
+ phase: nextState.phase || blockedState?.phase || null,
208
+ status: 'active',
209
+ turn: { turn_id: reissued.newTurn.turn_id, role_id: reissued.newTurn.assigned_role },
210
+ intent_id: oldTurn.intake_context?.intent_id || null,
211
+ payload: {
212
+ old_turn_id: oldTurnId,
213
+ new_turn_id: reissued.newTurn.turn_id,
214
+ failure_type: decision.ghost.failure_type,
215
+ attempt,
216
+ max_retries_per_run: decision.maxRetries,
217
+ runtime_id: oldTurn.runtime_id || reissued.newTurn.runtime_id || null,
218
+ running_ms: oldTurn.failed_start_running_ms ?? null,
219
+ threshold_ms: oldTurn.failed_start_threshold_ms ?? null,
220
+ },
221
+ });
222
+
223
+ log(`Ghost turn auto-retried (${attempt}/${decision.maxRetries}): ${oldTurnId} -> ${reissued.newTurn.turn_id}`);
224
+ if ((contOpts.autoRetryOnGhost?.cooldownSeconds ?? 0) > 0) {
225
+ await new Promise((resolve) => setTimeout(resolve, contOpts.autoRetryOnGhost.cooldownSeconds * 1000));
226
+ }
227
+ return {
228
+ ok: true,
229
+ status: 'running',
230
+ action: 'auto_retried_ghost',
231
+ run_id: runId,
232
+ old_turn_id: oldTurnId,
233
+ new_turn_id: reissued.newTurn.turn_id,
234
+ attempt,
235
+ max_retries_per_run: decision.maxRetries,
236
+ };
237
+ }
238
+
239
+ if (decision.decision === 'exhausted') {
240
+ const runId = session.current_run_id || blockedState?.run_id || null;
241
+ const oldTurnId = decision.ghost.turn_id;
242
+ const oldTurn = blockedState?.active_turns?.[oldTurnId] || {};
243
+ const manualDetail = blockedState?.blocked_reason?.recovery?.detail
244
+ || blockedState?.blocked_reason?.recovery?.recovery_action
245
+ || null;
246
+ // Slice 2c: build the per-attempt diagnostic bundle from the session's
247
+ // recorded attempts_log. This is the payload the operator needs to
248
+ // decide their next move (bump retries, change runtime, raise watchdog,
249
+ // or file a new bug). Also pass signatureRepeat into the mirror so the
250
+ // status surface distinguishes raw exhaustion from pattern-based early
251
+ // stop.
252
+ const diagnosticBundle = buildGhostRetryDiagnosticBundle(session);
253
+ const signatureRepeat = decision.signatureRepeat || null;
254
+ const detail = buildGhostRetryExhaustionMirror({
255
+ attempts: decision.attempts,
256
+ maxRetries: decision.maxRetries,
257
+ failureType: decision.ghost.failure_type,
258
+ manualRecoveryDetail: manualDetail,
259
+ signatureRepeat,
260
+ });
261
+ const nextState = {
262
+ ...blockedState,
263
+ blocked_reason: {
264
+ ...(blockedState.blocked_reason || {}),
265
+ recovery: {
266
+ ...(blockedState.blocked_reason?.recovery || {}),
267
+ detail,
268
+ },
269
+ },
270
+ };
271
+ writeGovernedState(root, nextState);
272
+ const nextSession = applyGhostRetryExhaustion(session, {
273
+ runId,
274
+ failureType: decision.ghost.failure_type,
275
+ turnId: oldTurnId,
276
+ maxRetries: decision.maxRetries,
277
+ nowIso: new Date().toISOString(),
278
+ });
279
+ Object.assign(session, nextSession, { status: 'paused' });
280
+ writeContinuousSession(root, session);
281
+
282
+ emitRunEvent(root, 'ghost_retry_exhausted', {
283
+ run_id: runId,
284
+ phase: blockedState?.phase || null,
285
+ status: 'blocked',
286
+ turn: { turn_id: oldTurnId, role_id: oldTurn.assigned_role || null },
287
+ intent_id: oldTurn.intake_context?.intent_id || null,
288
+ payload: {
289
+ turn_id: oldTurnId,
290
+ attempts: decision.attempts,
291
+ max_retries_per_run: decision.maxRetries,
292
+ failure_type: decision.ghost.failure_type,
293
+ runtime_id: oldTurn.runtime_id || null,
294
+ exhaustion_reason: signatureRepeat ? 'same_signature_repeat' : 'retry_budget_exhausted',
295
+ signature_repeat: signatureRepeat,
296
+ diagnostic_bundle: diagnosticBundle,
297
+ diagnostic_refs: {
298
+ recovery_action: blockedState?.blocked_reason?.recovery?.recovery_action || null,
299
+ },
300
+ },
301
+ });
302
+ const tag = signatureRepeat
303
+ ? `same_signature_repeat [${signatureRepeat.signature}] after ${signatureRepeat.consecutive} attempts`
304
+ : `${decision.attempts}/${decision.maxRetries}`;
305
+ log(`Ghost auto-retry exhausted (${tag}) for ${oldTurnId}.`);
306
+ return null;
307
+ }
308
+
309
+ return null;
310
+ }
311
+
130
312
  // ---------------------------------------------------------------------------
131
313
  // Intake queue check
132
314
  // ---------------------------------------------------------------------------
@@ -155,6 +337,99 @@ export function findNextQueuedIntent(root, options = {}) {
155
337
  return findNextDispatchableIntent(root, { run_id: options.run_id || null });
156
338
  }
157
339
 
340
+ /**
341
+ * BUG-62 slice 2: when `run_loop.continuous.reconcile_operator_commits` is
342
+ * `auto_safe_only`, the continuous loop consults the session-checkpoint /
343
+ * governed-state baseline vs current git HEAD before dispatch. If operator
344
+ * commits landed on top of the baseline and the Turn 184 safety primitive
345
+ * accepts them, the baseline is auto-rolled forward so the next dispatch
346
+ * proceeds without manual `agentxchain reconcile-state` intervention. If the
347
+ * safety primitive refuses the commits (governed-state edits or history
348
+ * rewrite), the continuous loop pauses with the refusal class mirrored into
349
+ * `blocked_reason.recovery.detail`, preserving the manual primitive as the
350
+ * operator's single audited safety function per the BUG-62 spec.
351
+ */
352
+ export function maybeAutoReconcileOperatorCommits(context, session, contOpts, log = console.log) {
353
+ const mode = contOpts.reconcileOperatorCommits || 'manual';
354
+ if (mode !== 'auto_safe_only') {
355
+ return null;
356
+ }
357
+ const { root } = context;
358
+ const state = loadProjectState(root, context.config);
359
+ const continuity = getContinuityStatus(root, state);
360
+ if (!continuity || continuity.drift_detected !== true) {
361
+ return null;
362
+ }
363
+
364
+ const result = reconcileOperatorHead(root, { safetyMode: 'auto_safe_only' });
365
+ if (result.ok) {
366
+ if (result.no_op) {
367
+ return null;
368
+ }
369
+ const acceptedCount = result.accepted_commits?.length || 0;
370
+ log(
371
+ `Operator-commit auto-reconcile accepted ${acceptedCount} commit${acceptedCount === 1 ? '' : 's'} `
372
+ + `(${result.previous_baseline.slice(0, 8)} -> ${result.accepted_head.slice(0, 8)}).`
373
+ );
374
+ return null;
375
+ }
376
+
377
+ const errorClass = result.error_class || 'reconcile_refused';
378
+ const detailLines = [
379
+ `Operator-commit auto-reconcile refused (${errorClass}).`,
380
+ result.error || 'Unsafe operator commits detected; manual recovery required.',
381
+ 'Run: agentxchain reconcile-state --accept-operator-head once the unsafe changes are resolved, or revert them.',
382
+ ];
383
+ const detail = detailLines.join(' ');
384
+
385
+ if (state) {
386
+ const nextState = {
387
+ ...state,
388
+ status: 'blocked',
389
+ blocked_on: state.blocked_on || 'operator_commit_reconcile_refused',
390
+ blocked_reason: {
391
+ ...(state.blocked_reason || {}),
392
+ category: 'operator_commit_reconcile_refused',
393
+ error_class: errorClass,
394
+ recovery: {
395
+ ...((state.blocked_reason || {}).recovery || {}),
396
+ recovery_action: 'agentxchain reconcile-state --accept-operator-head',
397
+ detail,
398
+ },
399
+ },
400
+ };
401
+ safeWriteJson(join(root, '.agentxchain', 'state.json'), nextState);
402
+ }
403
+
404
+ emitRunEvent(root, 'operator_commit_reconcile_refused', {
405
+ run_id: state?.run_id || session.current_run_id || null,
406
+ phase: state?.phase || state?.current_phase || null,
407
+ status: 'blocked',
408
+ payload: {
409
+ error_class: errorClass,
410
+ message: result.error || null,
411
+ previous_baseline: result.previous_baseline || null,
412
+ current_head: result.current_head || null,
413
+ offending_commit: result.offending_commit || null,
414
+ offending_path: result.offending_path || null,
415
+ safety_mode: 'auto_safe_only',
416
+ },
417
+ });
418
+
419
+ session.status = 'paused';
420
+ writeContinuousSession(root, session);
421
+ log(detail);
422
+ return {
423
+ ok: true,
424
+ status: 'blocked',
425
+ action: 'operator_commit_reconcile_refused',
426
+ run_id: session.current_run_id,
427
+ recovery_action: 'agentxchain reconcile-state --accept-operator-head',
428
+ blocked_category: 'operator_commit_reconcile_refused',
429
+ error_class: errorClass,
430
+ };
431
+ }
432
+
158
433
  function reconcileContinuousStartupState(context, session, contOpts, log) {
159
434
  const { root, config } = context;
160
435
  const governedState = loadProjectState(root, config);
@@ -301,6 +576,25 @@ export function seedFromVision(root, visionPath, options = {}) {
301
576
 
302
577
  export function resolveContinuousOptions(opts, config) {
303
578
  const configCont = config?.run_loop?.continuous || {};
579
+ const configGhostRetry = configCont.auto_retry_on_ghost || {};
580
+ const explicitConfigGhostEnabled = Object.prototype.hasOwnProperty.call(configGhostRetry, 'enabled');
581
+ const fullAuto = Boolean((opts.continuous ?? configCont.enabled ?? false) && isFullAutoApprovalPolicy(config));
582
+ const fullAutoGhostDefault = fullAuto;
583
+ const resolvedGhostEnabled = opts.autoRetryOnGhost
584
+ ?? (explicitConfigGhostEnabled ? configGhostRetry.enabled : fullAutoGhostDefault);
585
+
586
+ const validReconcileModes = new Set(['manual', 'auto_safe_only', 'disabled']);
587
+ const configuredReconcile = typeof configCont.reconcile_operator_commits === 'string'
588
+ && validReconcileModes.has(configCont.reconcile_operator_commits)
589
+ ? configCont.reconcile_operator_commits
590
+ : null;
591
+ const cliReconcile = typeof opts.reconcileOperatorCommits === 'string'
592
+ && validReconcileModes.has(opts.reconcileOperatorCommits)
593
+ ? opts.reconcileOperatorCommits
594
+ : null;
595
+ const reconcileOperatorCommits = cliReconcile
596
+ ?? configuredReconcile
597
+ ?? (fullAuto ? 'auto_safe_only' : 'manual');
304
598
 
305
599
  return {
306
600
  enabled: opts.continuous ?? configCont.enabled ?? false,
@@ -313,9 +607,26 @@ export function resolveContinuousOptions(opts, config) {
313
607
  cooldownSeconds: opts.cooldownSeconds ?? configCont.cooldown_seconds ?? 5,
314
608
  perSessionMaxUsd: opts.sessionBudget ?? configCont.per_session_max_usd ?? null,
315
609
  autoCheckpoint: opts.autoCheckpoint ?? configCont.auto_checkpoint ?? true,
610
+ autoRetryOnGhost: {
611
+ enabled: resolvedGhostEnabled ?? false,
612
+ maxRetriesPerRun: opts.autoRetryOnGhostMaxRetries
613
+ ?? configGhostRetry.max_retries_per_run
614
+ ?? 3,
615
+ cooldownSeconds: opts.autoRetryOnGhostCooldownSeconds
616
+ ?? configGhostRetry.cooldown_seconds
617
+ ?? 5,
618
+ },
619
+ reconcileOperatorCommits,
316
620
  };
317
621
  }
318
622
 
623
+ export function isFullAutoApprovalPolicy(config) {
624
+ const policy = config?.approval_policy;
625
+ if (!policy || typeof policy !== 'object') return false;
626
+ return policy.phase_transitions?.default === 'auto_approve'
627
+ && policy.run_completion?.action === 'auto_approve';
628
+ }
629
+
319
630
  // ---------------------------------------------------------------------------
320
631
  // Single-step continuous advancement primitive
321
632
  // ---------------------------------------------------------------------------
@@ -363,6 +674,9 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
363
674
 
364
675
  reconcileContinuousStartupState(context, session, contOpts, log);
365
676
 
677
+ const reconcileBlock = maybeAutoReconcileOperatorCommits(context, session, contOpts, log);
678
+ if (reconcileBlock) return reconcileBlock;
679
+
366
680
  // Paused-session guard: if session is paused (blocked run awaiting unblock),
367
681
  // check governed state before attempting to advance. Without this guard, the
368
682
  // loop would try to startIntent() on a blocked project, hit the blocked-state
@@ -370,6 +684,8 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
370
684
  if (session.status === 'paused') {
371
685
  const governedState = loadProjectState(root, context.config);
372
686
  if (governedState?.status === 'blocked') {
687
+ const retried = await maybeAutoRetryGhostBlocker(context, session, contOpts, governedState, log);
688
+ if (retried) return retried;
373
689
  // Still blocked — stay paused, do not attempt new work
374
690
  writeContinuousSession(root, session);
375
691
  return {
@@ -406,7 +722,10 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
406
722
  const resumeStopReason = execution.result?.stop_reason;
407
723
 
408
724
  if (isBlockedContinuousExecution(execution)) {
409
- const blockedRecoveryAction = getBlockedRecoveryAction(execution?.result?.state || loadProjectState(root, context.config));
725
+ const blockedState = execution?.result?.state || loadProjectState(root, context.config);
726
+ const retried = await maybeAutoRetryGhostBlocker(context, session, contOpts, blockedState, log);
727
+ if (retried) return retried;
728
+ const blockedRecoveryAction = getBlockedRecoveryAction(blockedState);
410
729
  session.status = 'paused';
411
730
  log(blockedRecoveryAction
412
731
  ? `Resumed run blocked again — continuous loop re-paused. Recovery: ${blockedRecoveryAction}`
@@ -418,7 +737,7 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
418
737
  action: 'run_blocked',
419
738
  run_id: session.current_run_id,
420
739
  recovery_action: blockedRecoveryAction,
421
- blocked_category: getBlockedCategory(execution?.result?.state || loadProjectState(root, context.config)),
740
+ blocked_category: getBlockedCategory(blockedState),
422
741
  };
423
742
  }
424
743
 
@@ -435,6 +754,64 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
435
754
  return { ok: true, status: 'running', action: 'resumed_after_unblock', run_id: session.current_run_id };
436
755
  }
437
756
 
757
+ const activeGovernedState = loadProjectState(root, context.config);
758
+ if (
759
+ session.current_run_id
760
+ && activeGovernedState?.status === 'active'
761
+ && activeGovernedState.run_id === session.current_run_id
762
+ && Object.keys(activeGovernedState.active_turns || {}).length > 0
763
+ ) {
764
+ log('Continuing active governed run.');
765
+ let execution;
766
+ try {
767
+ execution = await executeGovernedRun(context, {
768
+ autoApprove: true,
769
+ autoCheckpoint: contOpts.autoCheckpoint,
770
+ report: true,
771
+ log,
772
+ });
773
+ } catch (err) {
774
+ session.status = 'failed';
775
+ writeContinuousSession(root, session);
776
+ return { ok: false, status: 'failed', action: 'run_failed', stop_reason: err.message, run_id: session.current_run_id };
777
+ }
778
+
779
+ session.cumulative_spent_usd = (session.cumulative_spent_usd || 0) + getExecutionRunSpentUsd(execution);
780
+ const resumeStopReason = execution.result?.stop_reason;
781
+
782
+ if (isBlockedContinuousExecution(execution)) {
783
+ const blockedState = execution?.result?.state || loadProjectState(root, context.config);
784
+ const retried = await maybeAutoRetryGhostBlocker(context, session, contOpts, blockedState, log);
785
+ if (retried) return retried;
786
+ const blockedRecoveryAction = getBlockedRecoveryAction(blockedState);
787
+ session.status = 'paused';
788
+ log(blockedRecoveryAction
789
+ ? `Active run blocked — continuous loop paused. Recovery: ${blockedRecoveryAction}`
790
+ : 'Active run blocked — continuous loop paused.');
791
+ writeContinuousSession(root, session);
792
+ return {
793
+ ok: true,
794
+ status: 'blocked',
795
+ action: 'run_blocked',
796
+ run_id: session.current_run_id,
797
+ recovery_action: blockedRecoveryAction,
798
+ blocked_category: getBlockedCategory(blockedState),
799
+ };
800
+ }
801
+
802
+ if (execution.exitCode !== 0 || !execution.result) {
803
+ session.status = 'failed';
804
+ writeContinuousSession(root, session);
805
+ return { ok: false, status: 'failed', action: 'run_failed', stop_reason: resumeStopReason || `exit_code_${execution.exitCode}`, run_id: session.current_run_id };
806
+ }
807
+
808
+ session.runs_completed += 1;
809
+ session.current_run_id = execution.result?.state?.run_id || session.current_run_id;
810
+ log(`Active run completed (${session.runs_completed}/${contOpts.maxRuns}): ${resumeStopReason || 'completed'}`);
811
+ writeContinuousSession(root, session);
812
+ return { ok: true, status: 'running', action: 'continued_active_run', run_id: session.current_run_id };
813
+ }
814
+
438
815
  // Validate vision file
439
816
  if (!existsSync(absVisionPath)) {
440
817
  session.status = 'failed';
@@ -573,7 +950,10 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
573
950
  }
574
951
 
575
952
  if (isBlockedContinuousExecution(execution)) {
576
- const blockedRecoveryAction = getBlockedRecoveryAction(execution?.result?.state || loadProjectState(root, context.config));
953
+ const blockedState = execution?.result?.state || loadProjectState(root, context.config);
954
+ const retried = await maybeAutoRetryGhostBlocker(context, session, contOpts, blockedState, log);
955
+ if (retried) return retried;
956
+ const blockedRecoveryAction = getBlockedRecoveryAction(blockedState);
577
957
  const resolved = resolveIntent(root, targetIntentId);
578
958
  if (!resolved.ok) {
579
959
  log(`Continuous resolve error: ${resolved.error}`);
@@ -593,7 +973,7 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
593
973
  run_id: session.current_run_id,
594
974
  intent_id: targetIntentId,
595
975
  recovery_action: blockedRecoveryAction,
596
- blocked_category: getBlockedCategory(execution?.result?.state || loadProjectState(root, context.config)),
976
+ blocked_category: getBlockedCategory(blockedState),
597
977
  };
598
978
  }
599
979