agentxchain 2.154.5 → 2.154.8

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.5",
3
+ "version": "2.154.8",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -68,18 +68,12 @@ import {
68
68
  resolvePromptTransport,
69
69
  resolveStartupWatchdogMs,
70
70
  } from '../src/lib/adapters/local-cli-adapter.js';
71
+ import { CLAUDE_ENV_AUTH_KEYS } from '../src/lib/claude-local-auth.js';
71
72
 
72
73
  const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
73
74
  const REPO_ROOT_GUESS = resolve(SCRIPT_DIR, '..', '..');
74
75
 
75
76
  const DIAGNOSTIC_ENV_KEYS = ['PATH', 'HOME', 'PWD', 'SHELL', 'TMPDIR'];
76
- const AUTH_ENV_KEYS_TO_PROBE = [
77
- 'ANTHROPIC_API_KEY',
78
- 'CLAUDE_API_KEY',
79
- 'CLAUDE_CODE_OAUTH_TOKEN',
80
- 'CLAUDE_CODE_USE_VERTEX',
81
- 'CLAUDE_CODE_USE_BEDROCK',
82
- ];
83
77
 
84
78
  // ──────────────────────────────────────────────────────────────────────────────
85
79
  // Argv parsing — kept simple/explicit so tester can read what their flags do.
@@ -227,7 +221,7 @@ function snapshotEnv(env) {
227
221
  if (typeof env[k] === 'string' && env[k].length > 0) visible[k] = env[k];
228
222
  }
229
223
  const authProbe = {};
230
- for (const k of AUTH_ENV_KEYS_TO_PROBE) {
224
+ for (const k of CLAUDE_ENV_AUTH_KEYS) {
231
225
  authProbe[k] = typeof env[k] === 'string' && env[k].length > 0;
232
226
  }
233
227
  return { visible, auth_env_present: authProbe };
@@ -1,10 +1,25 @@
1
1
  import chalk from 'chalk';
2
2
  import { loadProjectContext, loadProjectState } from '../lib/config.js';
3
3
  import { approvePhaseTransition } from '../lib/governed-state.js';
4
+ import { getNextPhase } from '../lib/gate-evaluator.js';
4
5
  import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
5
6
  import { resolveGovernedRole } from '../lib/role-resolution.js';
6
7
  import { checkCleanBaseline } from '../lib/repo-observer.js';
7
8
 
9
+ function getStandingPendingHumanExitGate(state, config) {
10
+ const phase = state?.phase || null;
11
+ const gateId = phase ? config?.routing?.[phase]?.exit_gate || null : null;
12
+ if (!phase || !gateId) return null;
13
+ const gate = config?.gates?.[gateId] || null;
14
+ if (!gate?.requires_human_approval) return null;
15
+ if ((state?.phase_gate_status || {})[gateId] !== 'pending') return null;
16
+ return {
17
+ gateId,
18
+ from: phase,
19
+ to: getNextPhase(phase, config?.routing || {}) || null,
20
+ };
21
+ }
22
+
8
23
  export async function approveTransitionCommand(opts) {
9
24
  const context = loadProjectContext();
10
25
  if (!context) {
@@ -25,6 +40,16 @@ export async function approveTransitionCommand(opts) {
25
40
  if (state?.phase) {
26
41
  console.log(chalk.dim(` Current phase: ${state.phase}`));
27
42
  }
43
+ const standingGate = getStandingPendingHumanExitGate(state, config);
44
+ if (standingGate) {
45
+ console.log('');
46
+ console.log(chalk.yellow(` Gate "${standingGate.gateId}" is pending human approval, but no phase transition object is prepared.`));
47
+ if (standingGate.to) {
48
+ console.log(chalk.dim(` Expected transition: ${standingGate.from} → ${standingGate.to}`));
49
+ }
50
+ console.log(chalk.dim(' If this gate has an open human escalation, run: agentxchain unblock <hesc_id>'));
51
+ console.log(chalk.dim(` To inspect gate evidence, run: agentxchain gate show ${standingGate.gateId} --evaluate`));
52
+ }
28
53
  process.exit(1);
29
54
  }
30
55
 
@@ -312,4 +312,23 @@ function showGate(requestedGateId, { root, config, state, gateIds, opts }) {
312
312
  }
313
313
  console.log('');
314
314
  }
315
+
316
+ const standingHint = getStandingRecoveryHint(gate, state);
317
+ if (standingHint) {
318
+ console.log(chalk.dim(' Recovery:'));
319
+ console.log(chalk.dim(` No phase transition is prepared for "${standingHint.gateId}".`));
320
+ console.log(chalk.dim(' If a human escalation is open, resolve with: agentxchain unblock <hesc_id>'));
321
+ console.log(chalk.dim(' After resolution, run: agentxchain approve-transition'));
322
+ console.log('');
323
+ }
324
+ }
325
+
326
+ function getStandingRecoveryHint(gate, state) {
327
+ if (!gate) return null;
328
+ if (!gate.requires_human_approval) return null;
329
+ if (gate.status !== 'pending') return null;
330
+ const currentPhase = state?.phase || null;
331
+ if (!currentPhase || gate.linked_phase !== currentPhase) return null;
332
+ if (state?.pending_phase_transition) return null;
333
+ return { gateId: gate.id };
315
334
  }
@@ -50,8 +50,50 @@ function hasStandingPendingExitGate(state, config) {
50
50
  return Boolean(gateId && (state?.phase_gate_status || {})[gateId] === 'pending');
51
51
  }
52
52
 
53
- function latestCompletedTurnWantsPhaseContinuation(root, state, config) {
54
- const turnId = state?.last_completed_turn_id || state?.blocked_reason?.turn_id || null;
53
+ function getStandingPendingExitGate(state, config) {
54
+ const phase = state?.phase;
55
+ const gateId = phase ? config?.routing?.[phase]?.exit_gate : null;
56
+ if (!gateId || (state?.phase_gate_status || {})[gateId] !== 'pending') {
57
+ return null;
58
+ }
59
+ return config?.gates?.[gateId] || null;
60
+ }
61
+
62
+ function entrySatisfiesSyntheticGateVerification(gate, entry) {
63
+ if (!gate?.requires_verification_pass) return true;
64
+ const verificationStatus = entry?.verification?.status;
65
+ return verificationStatus === 'pass' || verificationStatus === 'attested_pass';
66
+ }
67
+
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).
75
+ const phase = state?.phase;
76
+ const gateId = phase ? config?.routing?.[phase]?.exit_gate : null;
77
+ if (!gateId) return false;
78
+ const gate = config?.gates?.[gateId];
79
+ if (!gate || !Array.isArray(gate.requires_files) || gate.requires_files.length === 0) {
80
+ return false;
81
+ }
82
+ if (!gate.requires_human_approval) return false;
83
+ const filesChanged = Array.isArray(entry?.files_changed) ? entry.files_changed : [];
84
+ const required = gate.requires_files.filter((p) => typeof p === 'string' && p.trim());
85
+ if (required.length === 0) return false;
86
+ const changedSet = new Set(filesChanged.filter((p) => typeof p === 'string'));
87
+ 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;
93
+ }
94
+
95
+ function latestCompletedTurnWantsPhaseContinuation(root, state, config, opts = {}) {
96
+ const turnId = opts?.turnId || state?.last_completed_turn_id || state?.blocked_reason?.turn_id || null;
55
97
  if (!turnId) return false;
56
98
  const historyPath = join(root, config?.files?.history || '.agentxchain/history.jsonl');
57
99
  if (!existsSync(historyPath)) return false;
@@ -65,8 +107,34 @@ function latestCompletedTurnWantsPhaseContinuation(root, state, config) {
65
107
  }
66
108
  if (entry?.turn_id !== turnId) continue;
67
109
  if (entry.phase_transition_request) return true;
110
+ const standingGate = getStandingPendingExitGate(state, config);
68
111
  const proposed = typeof entry.proposed_next_role === 'string' ? entry.proposed_next_role.trim() : '';
69
- return Boolean(proposed && proposed !== 'human');
112
+ if (proposed && proposed !== 'human') {
113
+ return entrySatisfiesSyntheticGateVerification(standingGate, entry);
114
+ }
115
+ // Turn 205 extension (refines DEC-BUG52-UNBLOCK-STANDING-GATE-DISCRIMINATOR-001):
116
+ // The realistic BUG-52 third-variant PM shape sets `status: 'needs_human'`,
117
+ // `phase_transition_request: null`, and `proposed_next_role: 'human'` — the
118
+ // PM is escalating specifically for the phase exit gate's human-approval
119
+ // check and has already written the gate's required artifacts to disk.
120
+ // When the current phase's exit gate requires human approval, all of its
121
+ // `requires_files` are present on disk, and any gate-level verification
122
+ // predicate was satisfied by the accepted turn, an `unblock` on that
123
+ // escalation IS the final gate approval and must run the standing-gate
124
+ // reconcile.
125
+ //
126
+ // Distinguished from generic schedule-daemon `needs_decision` escalations
127
+ // (which block BEFORE the agent writes gate artifacts, so `requires_files`
128
+ // are not yet on disk) — those correctly continue to re-dispatch the
129
+ // in-phase role rather than force-advancing the phase.
130
+ if (
131
+ entry.status === 'needs_human'
132
+ && entrySatisfiesSyntheticGateVerification(standingGate, entry)
133
+ && turnContributedToHumanApprovalGateArtifacts(root, state, config, entry)
134
+ ) {
135
+ return true;
136
+ }
137
+ return false;
70
138
  }
71
139
  return false;
72
140
  }
@@ -185,7 +253,7 @@ export async function resumeCommand(opts) {
185
253
  state.status === 'blocked'
186
254
  && resumeVia === 'operator_unblock'
187
255
  && hasStandingPendingExitGate(state, config)
188
- && latestCompletedTurnWantsPhaseContinuation(root, state, config)
256
+ && latestCompletedTurnWantsPhaseContinuation(root, state, config, { turnId: opts.turn || null })
189
257
  ) {
190
258
  const reactivated = reactivateGovernedRun(root, state, { via: resumeVia, notificationConfig: config });
191
259
  if (!reactivated.ok) {
@@ -204,6 +272,7 @@ export async function resumeCommand(opts) {
204
272
  const phaseReconciliation = reconcilePhaseAdvanceBeforeDispatch(root, config, state, {
205
273
  allow_active_turn_cleanup: true,
206
274
  allow_standing_gate: true,
275
+ standing_gate_turn_id: opts.turn || null,
207
276
  });
208
277
  if (!phaseReconciliation.ok && !phaseReconciliation.state) {
209
278
  console.log(chalk.red(`Failed to reconcile phase gate before dispatch: ${phaseReconciliation.error}`));
@@ -42,6 +42,7 @@ const DIAGNOSTIC_ENV_KEYS = [
42
42
  ];
43
43
  const DIAGNOSTIC_STDERR_EXCERPT_LIMIT = 800;
44
44
  const DEFAULT_STARTUP_WATCHDOG_MS = 180_000;
45
+ const DEFAULT_STARTUP_WATCHDOG_SIGKILL_GRACE_MS = 10_000;
45
46
 
46
47
  /**
47
48
  * Launch a local CLI subprocess for a governed turn.
@@ -89,6 +90,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
89
90
  return { ok: false, error: `Runtime "${runtimeId}" not found in config` };
90
91
  }
91
92
  const startupWatchdogMs = startupWatchdogOverrideMs ?? resolveStartupWatchdogMs(config, runtime);
93
+ const startupWatchdogKillGraceMs = resolveStartupWatchdogKillGraceMs(options.startupWatchdogKillGraceMs);
92
94
 
93
95
  // Read the dispatch bundle prompt
94
96
  const promptPath = join(root, getDispatchPromptPath(turn.turn_id));
@@ -188,6 +190,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
188
190
  let spawnConfirmedAtMs = null;
189
191
  let firstOutputLatencyMs = null;
190
192
  let startupWatchdog = null;
193
+ let startupSigkillHandle = null;
191
194
  let startupTimedOut = false;
192
195
  let startupFailureType = null;
193
196
  let stdoutBytes = 0;
@@ -205,6 +208,10 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
205
208
  clearTimeout(startupWatchdog);
206
209
  startupWatchdog = null;
207
210
  }
211
+ if (startupSigkillHandle) {
212
+ clearTimeout(startupSigkillHandle);
213
+ startupSigkillHandle = null;
214
+ }
208
215
  };
209
216
 
210
217
  const armStartupWatchdog = () => {
@@ -218,15 +225,31 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
218
225
  startupTimedOut = true;
219
226
  startupFailureType = 'no_subprocess_output';
220
227
  logs.push(`[adapter] Startup watchdog fired after ${Math.round(startupWatchdogMs / 1000)}s with no output.`);
221
- appendDiagnostic(logs, 'startup_watchdog_fired', {
222
- startup_watchdog_ms: startupWatchdogMs,
223
- pid: child.pid ?? null,
224
- spawn_confirmed_at: spawnConfirmedAt,
225
- elapsed_since_spawn_ms: spawnConfirmedAtMs == null ? null : Math.max(0, Date.now() - spawnConfirmedAtMs),
226
- });
227
- try {
228
- child.kill('SIGTERM');
228
+ appendDiagnostic(logs, 'startup_watchdog_fired', {
229
+ startup_watchdog_ms: startupWatchdogMs,
230
+ startup_watchdog_sigkill_grace_ms: startupWatchdogKillGraceMs,
231
+ pid: child.pid ?? null,
232
+ spawn_confirmed_at: spawnConfirmedAt,
233
+ elapsed_since_spawn_ms: spawnConfirmedAtMs == null ? null : Math.max(0, Date.now() - spawnConfirmedAtMs),
234
+ });
235
+ try {
236
+ child.kill('SIGTERM');
229
237
  } catch {}
238
+ if (startupWatchdogKillGraceMs > 0) {
239
+ startupSigkillHandle = setTimeout(() => {
240
+ logs.push('[adapter] Startup watchdog grace period expired. Sending SIGKILL.');
241
+ appendDiagnostic(logs, 'startup_watchdog_sigkill', {
242
+ startup_watchdog_ms: startupWatchdogMs,
243
+ startup_watchdog_sigkill_grace_ms: startupWatchdogKillGraceMs,
244
+ pid: child.pid ?? null,
245
+ spawn_confirmed_at: spawnConfirmedAt,
246
+ elapsed_since_spawn_ms: spawnConfirmedAtMs == null ? null : Math.max(0, Date.now() - spawnConfirmedAtMs),
247
+ });
248
+ try {
249
+ child.kill('SIGKILL');
250
+ } catch {}
251
+ }, startupWatchdogKillGraceMs);
252
+ }
230
253
  }, startupWatchdogMs);
231
254
  };
232
255
 
@@ -313,6 +336,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
313
336
  // Timeout handling per §20.4
314
337
  let timeoutHandle;
315
338
  let sigkillHandle;
339
+ let abortSigkillHandle;
316
340
 
317
341
  if (timeoutMs > 0 && timeoutMs < Infinity) {
318
342
  timeoutHandle = setTimeout(() => {
@@ -337,11 +361,13 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
337
361
  clearStartupWatchdog();
338
362
  clearTimeout(timeoutHandle);
339
363
  clearTimeout(sigkillHandle);
364
+ clearTimeout(abortSigkillHandle);
340
365
  try {
341
366
  child.kill('SIGTERM');
342
367
  } catch {}
343
368
  // Give it 5 seconds to exit gracefully
344
- setTimeout(() => {
369
+ abortSigkillHandle = setTimeout(() => {
370
+ abortSigkillHandle = null;
345
371
  try { child.kill('SIGKILL'); } catch {}
346
372
  }, 5000);
347
373
  };
@@ -355,6 +381,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
355
381
  clearStartupWatchdog();
356
382
  clearTimeout(timeoutHandle);
357
383
  clearTimeout(sigkillHandle);
384
+ clearTimeout(abortSigkillHandle);
358
385
  if (signal) signal.removeEventListener('abort', onAbort);
359
386
 
360
387
  if (signal?.aborted) {
@@ -457,6 +484,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
457
484
  clearStartupWatchdog();
458
485
  clearTimeout(timeoutHandle);
459
486
  clearTimeout(sigkillHandle);
487
+ clearTimeout(abortSigkillHandle);
460
488
  if (signal) signal.removeEventListener('abort', onAbort);
461
489
  // BUG-54 hypothesis #1 fix: explicitly release stdio streams on the
462
490
  // error path so Node reclaims pipe handles immediately instead of
@@ -583,6 +611,13 @@ function resolveStartupWatchdogMs(config, runtime) {
583
611
  return DEFAULT_STARTUP_WATCHDOG_MS;
584
612
  }
585
613
 
614
+ function resolveStartupWatchdogKillGraceMs(value) {
615
+ if (Number.isInteger(value) && value >= 0) {
616
+ return value;
617
+ }
618
+ return DEFAULT_STARTUP_WATCHDOG_SIGKILL_GRACE_MS;
619
+ }
620
+
586
621
  /**
587
622
  * Check if the staged result file exists and has meaningful content.
588
623
  * Delegates to the shared `hasMeaningfulStagedResult` helper so watchdog,
@@ -1605,7 +1605,7 @@ function resolvePhaseTransitionSource(historyEntries, gateFailure, fallbackTurnI
1605
1605
  return requestedSource;
1606
1606
  }
1607
1607
 
1608
- function buildStandingPhaseTransitionSource(state, config) {
1608
+ function buildStandingPhaseTransitionSource(state, config, opts = {}) {
1609
1609
  const phase = state?.phase;
1610
1610
  const routing = phase ? config?.routing?.[phase] : null;
1611
1611
  const gateId = routing?.exit_gate || null;
@@ -1617,7 +1617,7 @@ function buildStandingPhaseTransitionSource(state, config) {
1617
1617
  return null;
1618
1618
  }
1619
1619
  return {
1620
- turn_id: state?.last_completed_turn_id || state?.blocked_reason?.turn_id || null,
1620
+ turn_id: opts?.turn_id || state?.last_completed_turn_id || state?.blocked_reason?.turn_id || null,
1621
1621
  run_id: state?.run_id || null,
1622
1622
  role: null,
1623
1623
  assigned_role: null,
@@ -2746,7 +2746,9 @@ export function reconcilePhaseAdvanceBeforeDispatch(root, config, state = null,
2746
2746
  currentState.queued_phase_transition || null,
2747
2747
  );
2748
2748
  if (!phaseSource?.phase_transition_request && opts?.allow_standing_gate === true) {
2749
- phaseSource = buildStandingPhaseTransitionSource(currentState, config);
2749
+ phaseSource = buildStandingPhaseTransitionSource(currentState, config, {
2750
+ turn_id: opts?.standing_gate_turn_id || null,
2751
+ });
2750
2752
  }
2751
2753
  if (!phaseSource?.phase_transition_request) {
2752
2754
  return {
@@ -2801,6 +2803,7 @@ export function reconcilePhaseAdvanceBeforeDispatch(root, config, state = null,
2801
2803
  const cleanup = cleanupPhaseAdvanceArtifacts(root, nextState, config, prevPhase);
2802
2804
  nextState = cleanup.state;
2803
2805
  writeState(root, nextState);
2806
+ writeSessionCheckpoint(root, nextState, 'phase_reconciled');
2804
2807
  appendJsonl(root, LEDGER_PATH, {
2805
2808
  type: 'approval_policy',
2806
2809
  gate_type: 'phase_transition',
@@ -2919,6 +2922,7 @@ export function reconcilePhaseAdvanceBeforeDispatch(root, config, state = null,
2919
2922
  nextState = cleanup.state;
2920
2923
 
2921
2924
  writeState(root, nextState);
2925
+ writeSessionCheckpoint(root, nextState, 'phase_reconciled');
2922
2926
  const retiredIntentIds = retireApprovedPhaseScopedIntents(root, nextState, config, prevPhase, now);
2923
2927
  if (retiredIntentIds.length > 0) {
2924
2928
  emitRunEvent(root, 'intent_retired_by_phase_advance', {
@@ -6152,17 +6156,26 @@ export function approvePhaseTransition(root, config, opts = {}) {
6152
6156
  run_id: updatedState.run_id,
6153
6157
  phase: updatedState.phase,
6154
6158
  status: 'active',
6155
- payload: { gate_type: 'phase_transition', from: transition.from, to: transition.to },
6159
+ turn: transition.requested_by_turn ? { turn_id: transition.requested_by_turn, role_id: null } : undefined,
6160
+ payload: {
6161
+ gate_id: transition.gate || null,
6162
+ gate_type: 'phase_transition',
6163
+ from: transition.from,
6164
+ to: transition.to,
6165
+ requested_by_turn: transition.requested_by_turn || null,
6166
+ },
6156
6167
  });
6157
6168
  emitRunEvent(root, 'phase_entered', {
6158
6169
  run_id: updatedState.run_id,
6159
6170
  phase: updatedState.phase,
6160
6171
  status: 'active',
6172
+ turn: transition.requested_by_turn ? { turn_id: transition.requested_by_turn, role_id: null } : undefined,
6161
6173
  payload: {
6162
6174
  from: transition.from,
6163
6175
  to: transition.to,
6164
6176
  gate_id: transition.gate || 'no_gate',
6165
6177
  trigger: 'human_approved',
6178
+ requested_by_turn: transition.requested_by_turn || null,
6166
6179
  },
6167
6180
  });
6168
6181
  emitRunEvent(root, 'phase_cleanup', {