agentxchain 2.154.8 → 2.154.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.154.8",
3
+ "version": "2.154.10",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,7 @@ import {
27
27
  getActiveTurnCount,
28
28
  reactivateGovernedRun,
29
29
  reconcilePhaseAdvanceBeforeDispatch,
30
+ reconcileRunCompletionBeforeDispatch,
30
31
  transitionActiveTurnLifecycle,
31
32
  STATE_PATH,
32
33
  } from '../lib/governed-state.js';
@@ -59,42 +60,77 @@ function getStandingPendingExitGate(state, config) {
59
60
  return config?.gates?.[gateId] || null;
60
61
  }
61
62
 
63
+ function isFinalPhase(state, config) {
64
+ const phase = state?.phase;
65
+ if (!phase) return false;
66
+ const phases = Object.keys(config?.routing || {});
67
+ return phases.length > 0 && phases[phases.length - 1] === phase;
68
+ }
69
+
62
70
  function entrySatisfiesSyntheticGateVerification(gate, entry) {
63
71
  if (!gate?.requires_verification_pass) return true;
64
72
  const verificationStatus = entry?.verification?.status;
65
73
  return verificationStatus === 'pass' || verificationStatus === 'attested_pass';
66
74
  }
67
75
 
68
- function turnContributedToHumanApprovalGateArtifacts(root, state, config, entry) {
69
- // Returns true only when the accepted turn itself produced at least one of
70
- // the phase exit gate's required_files AND all required_files are present
71
- // on disk. This distinguishes a PM turn that finished phase work and
72
- // escalated for final sign-off (BUG-52 third variant) from a generic
73
- // escalation where the agent blocked BEFORE writing gate artifacts
74
- // (schedule-daemon `needs_decision` fixture).
76
+ function evaluateHumanApprovalGateArtifacts(root, state, config, entry) {
77
+ // Returns the artifact readiness shape for a human-required phase exit gate.
78
+ // A turn can be a valid standing-gate source either by producing one of the
79
+ // required files, or by re-verifying already-complete gate artifacts as the
80
+ // phase entry role before escalating to the human reviewer.
75
81
  const phase = state?.phase;
76
82
  const gateId = phase ? config?.routing?.[phase]?.exit_gate : null;
77
- if (!gateId) return false;
83
+ if (!gateId) return { ready: false, contributed: false, entryRole: false };
78
84
  const gate = config?.gates?.[gateId];
79
85
  if (!gate || !Array.isArray(gate.requires_files) || gate.requires_files.length === 0) {
80
- return false;
86
+ return { ready: false, contributed: false, entryRole: false };
81
87
  }
82
- if (!gate.requires_human_approval) return false;
88
+ if (!gate.requires_human_approval) return { ready: false, contributed: false, entryRole: false };
83
89
  const filesChanged = Array.isArray(entry?.files_changed) ? entry.files_changed : [];
84
90
  const required = gate.requires_files.filter((p) => typeof p === 'string' && p.trim());
85
- if (required.length === 0) return false;
91
+ if (required.length === 0) return { ready: false, contributed: false, entryRole: false };
86
92
  const changedSet = new Set(filesChanged.filter((p) => typeof p === 'string'));
87
93
  const contributed = required.some((relPath) => changedSet.has(relPath));
88
- if (!contributed) return false;
89
- for (const relPath of required) {
90
- if (!existsSync(join(root, relPath))) return false;
91
- }
92
- return true;
94
+ const ready = required.every((relPath) => existsSync(join(root, relPath)));
95
+ const entryRole = Boolean(config?.routing?.[phase]?.entry_role)
96
+ && (entry?.role === config.routing[phase].entry_role || entry?.assigned_role === config.routing[phase].entry_role);
97
+ return { ready, contributed, entryRole };
98
+ }
99
+
100
+ function turnCanApproveHumanGateFromEscalation(root, state, config, entry, proposed) {
101
+ const artifacts = evaluateHumanApprovalGateArtifacts(root, state, config, entry);
102
+ if (!artifacts.ready) return false;
103
+ if (artifacts.contributed) return true;
104
+ const phase = state?.phase;
105
+ const gateId = phase ? config?.routing?.[phase]?.exit_gate : null;
106
+ const gateApprovalText = [
107
+ entry?.summary,
108
+ entry?.needs_human_reason,
109
+ entry?.verification?.evidence_summary,
110
+ state?.blocked_on,
111
+ state?.blocked_reason?.recovery?.detail,
112
+ ...(Array.isArray(entry?.decisions)
113
+ ? entry.decisions.flatMap((decision) => [decision?.statement, decision?.rationale])
114
+ : []),
115
+ ]
116
+ .filter((value) => typeof value === 'string' && value.trim())
117
+ .join('\n')
118
+ .toLowerCase();
119
+ const referencesHumanGateApproval = Boolean(gateId)
120
+ && (
121
+ gateApprovalText.includes(gateId.toLowerCase())
122
+ || (gateApprovalText.includes('gate') && gateApprovalText.includes('human approval'))
123
+ || (gateApprovalText.includes('gate') && gateApprovalText.includes('human-required'))
124
+ );
125
+ // tusq.dev BUG-52 real shape: PM re-verified already-complete planning
126
+ // artifacts, changed no files by design, and escalated to human because the
127
+ // gate requires human approval. The human unblock is the gate approval.
128
+ return proposed === 'human' && artifacts.entryRole && referencesHumanGateApproval;
93
129
  }
94
130
 
95
131
  function latestCompletedTurnWantsPhaseContinuation(root, state, config, opts = {}) {
96
132
  const turnId = opts?.turnId || state?.last_completed_turn_id || state?.blocked_reason?.turn_id || null;
97
- if (!turnId) return false;
133
+ if (!turnId || isFinalPhase(state, config)) return false;
98
134
  const historyPath = join(root, config?.files?.history || '.agentxchain/history.jsonl');
99
135
  if (!existsSync(historyPath)) return false;
100
136
  const lines = readFileSync(historyPath, 'utf8').trim().split('\n').filter(Boolean);
@@ -130,7 +166,36 @@ function latestCompletedTurnWantsPhaseContinuation(root, state, config, opts = {
130
166
  if (
131
167
  entry.status === 'needs_human'
132
168
  && entrySatisfiesSyntheticGateVerification(standingGate, entry)
133
- && turnContributedToHumanApprovalGateArtifacts(root, state, config, entry)
169
+ && turnCanApproveHumanGateFromEscalation(root, state, config, entry, proposed)
170
+ ) {
171
+ return true;
172
+ }
173
+ return false;
174
+ }
175
+ return false;
176
+ }
177
+
178
+ function latestCompletedTurnWantsRunCompletion(root, state, config, opts = {}) {
179
+ const turnId = opts?.turnId || state?.last_completed_turn_id || state?.blocked_reason?.turn_id || null;
180
+ if (!turnId || !isFinalPhase(state, config)) return false;
181
+ const historyPath = join(root, config?.files?.history || '.agentxchain/history.jsonl');
182
+ if (!existsSync(historyPath)) return false;
183
+ const lines = readFileSync(historyPath, 'utf8').trim().split('\n').filter(Boolean);
184
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
185
+ let entry = null;
186
+ try {
187
+ entry = JSON.parse(lines[index]);
188
+ } catch {
189
+ continue;
190
+ }
191
+ if (entry?.turn_id !== turnId) continue;
192
+ if (entry.run_completion_request === true) return true;
193
+ const standingGate = getStandingPendingExitGate(state, config);
194
+ const proposed = typeof entry.proposed_next_role === 'string' ? entry.proposed_next_role.trim() : '';
195
+ if (
196
+ entry.status === 'needs_human'
197
+ && entrySatisfiesSyntheticGateVerification(standingGate, entry)
198
+ && turnCanApproveHumanGateFromEscalation(root, state, config, entry, proposed)
134
199
  ) {
135
200
  return true;
136
201
  }
@@ -301,6 +366,57 @@ export async function resumeCommand(opts) {
301
366
  }
302
367
  }
303
368
 
369
+ if (
370
+ state.status === 'blocked'
371
+ && resumeVia === 'operator_unblock'
372
+ && hasStandingPendingExitGate(state, config)
373
+ && latestCompletedTurnWantsRunCompletion(root, state, config, { turnId: opts.turn || null })
374
+ ) {
375
+ const reactivated = reactivateGovernedRun(root, state, { via: resumeVia, notificationConfig: config });
376
+ if (!reactivated.ok) {
377
+ console.log(chalk.red(`Failed to reactivate blocked run: ${reactivated.error}`));
378
+ process.exit(1);
379
+ }
380
+ state = reactivated.state;
381
+ console.log(chalk.green(`Resumed blocked run: ${state.run_id}`));
382
+ if (reactivated.migration_notice) {
383
+ console.log(chalk.yellow(reactivated.migration_notice));
384
+ }
385
+ if (reactivated.phantom_notice) {
386
+ console.log(chalk.yellow(reactivated.phantom_notice));
387
+ }
388
+
389
+ const completionReconciliation = reconcileRunCompletionBeforeDispatch(root, config, state, {
390
+ allow_active_turn_cleanup: true,
391
+ allow_standing_gate: true,
392
+ standing_gate_turn_id: opts.turn || null,
393
+ });
394
+ if (!completionReconciliation.ok && !completionReconciliation.state) {
395
+ console.log(chalk.red(`Failed to reconcile run completion before dispatch: ${completionReconciliation.error}`));
396
+ process.exit(1);
397
+ }
398
+ state = completionReconciliation.state || state;
399
+ if (completionReconciliation.completed) {
400
+ console.log(chalk.green(`Completed run before dispatch: ${state.run_id}`));
401
+ return;
402
+ }
403
+ markRunBlocked(root, {
404
+ category: 'needs_human',
405
+ blockedOn: state.blocked_on || 'human:unblock_completion_reconcile_failed',
406
+ recovery: {
407
+ typed_reason: 'needs_human',
408
+ owner: 'human',
409
+ recovery_action: 'agentxchain approve-completion',
410
+ turn_retained: true,
411
+ detail: 'Operator unblock resolved the escalation, but no run completion could be materialized from the current gate state.',
412
+ },
413
+ turnId: opts.turn || null,
414
+ notificationConfig: config,
415
+ });
416
+ console.log(chalk.red('Unblock did not materialize run completion; leaving the run blocked for manual recovery.'));
417
+ process.exit(1);
418
+ }
419
+
304
420
  if (state.status === 'blocked' && activeCount > 0 && !skipRetainedRedispatch) {
305
421
  let retainedTurn = null;
306
422
  if (opts.turn) {
@@ -790,11 +790,15 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
790
790
  }
791
791
 
792
792
  const activeGovernedState = loadProjectState(root, context.config);
793
+ const queuedIntent = session.current_run_id
794
+ ? findNextDispatchableIntent(root, { run_id: session.current_run_id })
795
+ : { ok: false };
793
796
  if (
794
797
  session.current_run_id
795
798
  && activeGovernedState?.status === 'active'
796
799
  && activeGovernedState.run_id === session.current_run_id
797
- && Object.keys(activeGovernedState.active_turns || {}).length > 0
800
+ && !contOpts.continueFrom
801
+ && !queuedIntent.ok
798
802
  ) {
799
803
  log('Continuing active governed run.');
800
804
  let execution;
@@ -855,7 +859,9 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
855
859
  }
856
860
 
857
861
  // Step 1: Check intake queue for pending work (BUG-34: scope to current run)
858
- const queued = findNextDispatchableIntent(root, { run_id: session.current_run_id });
862
+ const queued = queuedIntent.ok
863
+ ? queuedIntent
864
+ : findNextDispatchableIntent(root, { run_id: session.current_run_id });
859
865
  let targetIntentId = null;
860
866
  let visionObjective = null;
861
867
 
@@ -1629,6 +1629,30 @@ function buildStandingPhaseTransitionSource(state, config, opts = {}) {
1629
1629
  };
1630
1630
  }
1631
1631
 
1632
+ function buildStandingRunCompletionSource(state, config, opts = {}) {
1633
+ const phase = state?.phase;
1634
+ const routing = phase ? config?.routing?.[phase] : null;
1635
+ const gateId = routing?.exit_gate || null;
1636
+ const nextPhase = getNextPhase(phase, config?.routing || {});
1637
+ if (!phase || !gateId || nextPhase) {
1638
+ return null;
1639
+ }
1640
+ if ((state?.phase_gate_status || {})[gateId] !== 'pending') {
1641
+ return null;
1642
+ }
1643
+ return {
1644
+ turn_id: opts?.turn_id || state?.last_completed_turn_id || state?.blocked_reason?.turn_id || null,
1645
+ run_id: state?.run_id || null,
1646
+ role: null,
1647
+ assigned_role: null,
1648
+ phase,
1649
+ status: 'completed',
1650
+ run_completion_request: true,
1651
+ summary: `Synthetic ${gateId} completion source for operator-unblocked standing gate.`,
1652
+ verification: { status: 'pass' },
1653
+ };
1654
+ }
1655
+
1632
1656
  function getPhaseRoles(config, phase) {
1633
1657
  const routing = config?.routing?.[phase] || {};
1634
1658
  const roles = new Set();
@@ -2974,6 +2998,204 @@ export function reconcilePhaseAdvanceBeforeDispatch(root, config, state = null,
2974
2998
  };
2975
2999
  }
2976
3000
 
3001
+ export function reconcileRunCompletionBeforeDispatch(root, config, state = null, opts = {}) {
3002
+ const currentState = state && typeof state === 'object' ? state : readState(root);
3003
+ if (!currentState) {
3004
+ return { ok: false, error: 'No governed state.json found' };
3005
+ }
3006
+
3007
+ const activeTurnCount = getActiveTurnCount(currentState);
3008
+ const allowActiveTurnCleanup = opts?.allow_active_turn_cleanup === true;
3009
+ if (currentState.status !== 'active' || (activeTurnCount > 0 && !allowActiveTurnCleanup)) {
3010
+ return {
3011
+ ok: true,
3012
+ state: attachLegacyCurrentTurnAlias(currentState),
3013
+ completed: false,
3014
+ };
3015
+ }
3016
+
3017
+ const gateFailure = currentState.last_gate_failure;
3018
+ if (gateFailure && gateFailure.gate_type !== 'run_completion') {
3019
+ return {
3020
+ ok: true,
3021
+ state: attachLegacyCurrentTurnAlias(currentState),
3022
+ completed: false,
3023
+ };
3024
+ }
3025
+
3026
+ const historyEntries = readJsonlEntries(root, HISTORY_PATH);
3027
+ const requestedTurnId = gateFailure?.requested_by_turn
3028
+ || currentState.queued_run_completion?.requested_by_turn
3029
+ || currentState.last_completed_turn_id
3030
+ || null;
3031
+ let completionSource = findHistoryTurnRequest(historyEntries, requestedTurnId, 'run_completion');
3032
+ if (!completionSource?.run_completion_request && opts?.allow_standing_gate === true) {
3033
+ completionSource = buildStandingRunCompletionSource(currentState, config, {
3034
+ turn_id: opts?.standing_gate_turn_id || null,
3035
+ });
3036
+ }
3037
+ if (!completionSource?.run_completion_request) {
3038
+ return {
3039
+ ok: true,
3040
+ state: attachLegacyCurrentTurnAlias(currentState),
3041
+ completed: false,
3042
+ };
3043
+ }
3044
+
3045
+ const completionResult = evaluateRunCompletion({
3046
+ state: { ...currentState, history: historyEntries },
3047
+ config,
3048
+ acceptedTurn: completionSource,
3049
+ root,
3050
+ });
3051
+
3052
+ if (completionResult.action === 'awaiting_human_approval') {
3053
+ const approvalResult = evaluateApprovalPolicy({
3054
+ gateResult: completionResult,
3055
+ gateType: 'run_completion',
3056
+ state: { ...currentState, history: historyEntries },
3057
+ config,
3058
+ });
3059
+
3060
+ if (approvalResult.action === 'auto_approve') {
3061
+ const now = new Date().toISOString();
3062
+ const updatedState = {
3063
+ ...currentState,
3064
+ status: 'completed',
3065
+ completed_at: now,
3066
+ blocked_on: null,
3067
+ blocked_reason: null,
3068
+ last_gate_failure: null,
3069
+ pending_run_completion: null,
3070
+ queued_run_completion: null,
3071
+ queued_phase_transition: null,
3072
+ phase_gate_status: {
3073
+ ...(currentState.phase_gate_status || {}),
3074
+ [completionResult.gate_id || 'no_gate']: 'passed',
3075
+ },
3076
+ };
3077
+ writeState(root, updatedState);
3078
+ clearSlaReminders(root, 'pending_run_completion');
3079
+ appendJsonl(root, LEDGER_PATH, {
3080
+ type: 'approval_policy',
3081
+ gate_type: 'run_completion',
3082
+ action: 'auto_approve',
3083
+ matched_rule: approvalResult.matched_rule,
3084
+ reason: approvalResult.reason,
3085
+ gate_id: completionResult.gate_id || null,
3086
+ timestamp: now,
3087
+ });
3088
+ emitPendingLifecycleNotification(root, config, updatedState, 'run_completed', {
3089
+ completed_at: updatedState.completed_at,
3090
+ completed_via: 'approval_policy_reconciled_before_dispatch',
3091
+ gate: completionResult.gate_id || null,
3092
+ requested_by_turn: completionSource.turn_id || null,
3093
+ }, null);
3094
+ emitRunEvent(root, 'gate_approved', {
3095
+ run_id: updatedState.run_id,
3096
+ phase: updatedState.phase,
3097
+ status: 'completed',
3098
+ turn: completionSource.turn_id ? { turn_id: completionSource.turn_id, role_id: completionSource.role || completionSource.assigned_role || null } : undefined,
3099
+ payload: {
3100
+ gate_type: 'run_completion',
3101
+ gate_id: completionResult.gate_id || null,
3102
+ requested_by_turn: completionSource.turn_id || null,
3103
+ trigger: 'auto_approved',
3104
+ },
3105
+ });
3106
+ emitRunEvent(root, 'run_completed', {
3107
+ run_id: updatedState.run_id,
3108
+ phase: updatedState.phase,
3109
+ status: 'completed',
3110
+ payload: { completed_at: updatedState.completed_at },
3111
+ });
3112
+ writeSessionCheckpoint(root, updatedState, 'run_completed');
3113
+ recordRunHistory(root, updatedState, config, 'completed');
3114
+ return {
3115
+ ok: true,
3116
+ state: attachLegacyCurrentTurnAlias(updatedState),
3117
+ completed: true,
3118
+ gate_id: completionResult.gate_id || null,
3119
+ completionResult,
3120
+ approval_policy: approvalResult,
3121
+ };
3122
+ }
3123
+
3124
+ const pausedState = {
3125
+ ...currentState,
3126
+ status: 'paused',
3127
+ blocked_on: `human_approval:${completionResult.gate_id}`,
3128
+ blocked_reason: null,
3129
+ pending_run_completion: {
3130
+ gate: completionResult.gate_id,
3131
+ requested_by_turn: completionSource.turn_id,
3132
+ requested_at: new Date().toISOString(),
3133
+ },
3134
+ queued_run_completion: null,
3135
+ queued_phase_transition: null,
3136
+ };
3137
+ writeState(root, pausedState);
3138
+ const approved = approveRunCompletion(root, config);
3139
+ return {
3140
+ ok: approved.ok,
3141
+ error: approved.error,
3142
+ state: approved.state || attachLegacyCurrentTurnAlias(readState(root)),
3143
+ completed: approved.ok,
3144
+ gate_id: completionResult.gate_id || null,
3145
+ completionResult,
3146
+ approval_policy: approvalResult,
3147
+ };
3148
+ }
3149
+
3150
+ if (completionResult.action !== 'complete') {
3151
+ return {
3152
+ ok: true,
3153
+ state: attachLegacyCurrentTurnAlias(currentState),
3154
+ completed: false,
3155
+ completionResult,
3156
+ };
3157
+ }
3158
+
3159
+ const now = new Date().toISOString();
3160
+ const updatedState = {
3161
+ ...currentState,
3162
+ status: 'completed',
3163
+ completed_at: now,
3164
+ blocked_on: null,
3165
+ blocked_reason: null,
3166
+ last_gate_failure: null,
3167
+ pending_run_completion: null,
3168
+ queued_run_completion: null,
3169
+ queued_phase_transition: null,
3170
+ phase_gate_status: {
3171
+ ...(currentState.phase_gate_status || {}),
3172
+ [completionResult.gate_id || 'no_gate']: 'passed',
3173
+ },
3174
+ };
3175
+ writeState(root, updatedState);
3176
+ emitPendingLifecycleNotification(root, config, updatedState, 'run_completed', {
3177
+ completed_at: updatedState.completed_at,
3178
+ completed_via: 'reconciled_before_dispatch',
3179
+ gate: completionResult.gate_id || null,
3180
+ requested_by_turn: completionSource.turn_id || null,
3181
+ }, null);
3182
+ emitRunEvent(root, 'run_completed', {
3183
+ run_id: updatedState.run_id,
3184
+ phase: updatedState.phase,
3185
+ status: 'completed',
3186
+ payload: { completed_at: updatedState.completed_at },
3187
+ });
3188
+ writeSessionCheckpoint(root, updatedState, 'run_completed');
3189
+ recordRunHistory(root, updatedState, config, 'completed');
3190
+ return {
3191
+ ok: true,
3192
+ state: attachLegacyCurrentTurnAlias(updatedState),
3193
+ completed: true,
3194
+ gate_id: completionResult.gate_id || null,
3195
+ completionResult,
3196
+ };
3197
+ }
3198
+
2977
3199
  // ── Core Operations ──────────────────────────────────────────────────────────
2978
3200
 
2979
3201
  /**