agentxchain 2.154.1 → 2.154.5

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.1",
3
+ "version": "2.154.5",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,6 +44,33 @@ import { summarizeRunProvenance } from '../lib/run-provenance.js';
44
44
  import { consumeNextApprovedIntent } from '../lib/intake.js';
45
45
  import { reconcileStaleTurns } from '../lib/stale-turn-watchdog.js';
46
46
 
47
+ function hasStandingPendingExitGate(state, config) {
48
+ const phase = state?.phase;
49
+ const gateId = phase ? config?.routing?.[phase]?.exit_gate : null;
50
+ return Boolean(gateId && (state?.phase_gate_status || {})[gateId] === 'pending');
51
+ }
52
+
53
+ function latestCompletedTurnWantsPhaseContinuation(root, state, config) {
54
+ const turnId = state?.last_completed_turn_id || state?.blocked_reason?.turn_id || null;
55
+ if (!turnId) return false;
56
+ const historyPath = join(root, config?.files?.history || '.agentxchain/history.jsonl');
57
+ if (!existsSync(historyPath)) return false;
58
+ const lines = readFileSync(historyPath, 'utf8').trim().split('\n').filter(Boolean);
59
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
60
+ let entry = null;
61
+ try {
62
+ entry = JSON.parse(lines[index]);
63
+ } catch {
64
+ continue;
65
+ }
66
+ if (entry?.turn_id !== turnId) continue;
67
+ if (entry.phase_transition_request) return true;
68
+ const proposed = typeof entry.proposed_next_role === 'string' ? entry.proposed_next_role.trim() : '';
69
+ return Boolean(proposed && proposed !== 'human');
70
+ }
71
+ return false;
72
+ }
73
+
47
74
  export async function resumeCommand(opts) {
48
75
  const context = loadProjectContext();
49
76
  if (!context) {
@@ -143,7 +170,23 @@ export async function resumeCommand(opts) {
143
170
  // patched defensively) once the schema citation + migration citation are
144
171
  // documented in code and the coverage matrix.
145
172
 
146
- if (state.status === 'blocked' && activeCount > 0 && resumeVia === 'operator_unblock') {
173
+ // BUG-52 third variant (Turn 203/204): operator_unblock must run the
174
+ // standing-gate reconcile path regardless of `activeCount`, but only when the
175
+ // current phase actually has a standing pending exit gate and the blocked
176
+ // turn was trying to continue into a non-human phase role. The tester's
177
+ // v2.151.0 lights-out repro on `tusq.dev` left `active_turns: {}` with
178
+ // `phase_gate_status.planning_signoff: "pending"`, so the old
179
+ // `activeCount > 0` guard skipped the only path that could synthesize the
180
+ // missing transition source. Non-gate human escalations (OAuth, external
181
+ // decisions, schedule recovery with `proposed_next_role: "human"`) must keep
182
+ // the normal unblock/resume behavior instead of being forced through a
183
+ // phase-transition materialization check.
184
+ if (
185
+ state.status === 'blocked'
186
+ && resumeVia === 'operator_unblock'
187
+ && hasStandingPendingExitGate(state, config)
188
+ && latestCompletedTurnWantsPhaseContinuation(root, state, config)
189
+ ) {
147
190
  const reactivated = reactivateGovernedRun(root, state, { via: resumeVia, notificationConfig: config });
148
191
  if (!reactivated.ok) {
149
192
  console.log(chalk.red(`Failed to reactivate blocked run: ${reactivated.error}`));
@@ -32,7 +32,9 @@ import {
32
32
  buildGhostRetryDiagnosticBundle,
33
33
  buildGhostRetryExhaustionMirror,
34
34
  classifyGhostRetryDecision,
35
+ extractLatestStderrDiagnostic,
35
36
  } from './ghost-retry.js';
37
+ import { getDispatchLogPath } from './turn-paths.js';
36
38
  import { reconcileOperatorHead } from './operator-commit-reconcile.js';
37
39
  import { getContinuityStatus } from './continuity-status.js';
38
40
  import {
@@ -153,6 +155,25 @@ function clearGhostBlockerAfterReissue(root, state) {
153
155
  return nextState;
154
156
  }
155
157
 
158
+ /**
159
+ * Slice 2d (Turn 201): read the per-turn adapter dispatch log and return the
160
+ * most recent stderr excerpt + exit code + signal from `process_exit` or
161
+ * `spawn_error` lines. Best-effort — when the log is missing, unreadable, or
162
+ * contains only spawn_prepare diagnostics, returns a null record so the caller
163
+ * can still record the attempt with the runtime/role/timing fields.
164
+ */
165
+ function readLatestDispatchDiagnostic(root, turnId) {
166
+ if (!turnId) return { stderr_excerpt: null, exit_code: null, exit_signal: null };
167
+ try {
168
+ const p = join(root, getDispatchLogPath(turnId));
169
+ if (!existsSync(p)) return { stderr_excerpt: null, exit_code: null, exit_signal: null };
170
+ const content = readFileSync(p, 'utf8');
171
+ return extractLatestStderrDiagnostic(content);
172
+ } catch {
173
+ return { stderr_excerpt: null, exit_code: null, exit_signal: null };
174
+ }
175
+ }
176
+
156
177
  async function maybeAutoRetryGhostBlocker(context, session, contOpts, blockedState, log = console.log) {
157
178
  const { root, config } = context;
158
179
  const decision = classifyGhostRetryDecision({
@@ -184,6 +205,11 @@ async function maybeAutoRetryGhostBlocker(context, session, contOpts, blockedSta
184
205
  const oldRoleId = oldTurn.assigned_role || reissued.newTurn.assigned_role || null;
185
206
  const oldRunningMs = oldTurn.failed_start_running_ms ?? null;
186
207
  const oldThresholdMs = oldTurn.failed_start_threshold_ms ?? null;
208
+ // Slice 2d: pull the adapter's process_exit / spawn_error diagnostic for
209
+ // the ghost turn so the per-attempt log entry is self-contained. Reads
210
+ // the dispatch stdout.log for the OLD turn id; the NEW reissued turn
211
+ // hasn't run yet so has nothing to surface.
212
+ const oldDiag = readLatestDispatchDiagnostic(root, oldTurnId);
187
213
  const nextSession = applyGhostRetryAttempt(session, {
188
214
  runId,
189
215
  oldTurnId,
@@ -195,6 +221,9 @@ async function maybeAutoRetryGhostBlocker(context, session, contOpts, blockedSta
195
221
  roleId: oldRoleId,
196
222
  runningMs: oldRunningMs,
197
223
  thresholdMs: oldThresholdMs,
224
+ stderrExcerpt: oldDiag.stderr_excerpt,
225
+ exitCode: oldDiag.exit_code,
226
+ exitSignal: oldDiag.exit_signal,
198
227
  });
199
228
  Object.assign(session, nextSession, {
200
229
  status: 'running',
@@ -42,6 +42,12 @@ export const GHOST_FAILURE_TYPES = Object.freeze([
42
42
  * through a new DEC rather than silently widening.
43
43
  */
44
44
  export const SIGNATURE_REPEAT_THRESHOLD = 2;
45
+ export const ATTEMPT_STDERR_EXCERPT_LIMIT = 800;
46
+
47
+ function normalizeAttemptStderrExcerpt(value) {
48
+ if (typeof value !== 'string' || value.length === 0) return null;
49
+ return value.slice(0, ATTEMPT_STDERR_EXCERPT_LIMIT);
50
+ }
45
51
 
46
52
  /**
47
53
  * Read (or default) the ghost_retry state object from a continuous session.
@@ -328,6 +334,9 @@ export function applyGhostRetryAttempt(session, {
328
334
  roleId = null,
329
335
  runningMs = null,
330
336
  thresholdMs = null,
337
+ stderrExcerpt = null,
338
+ exitCode = null,
339
+ exitSignal = null,
331
340
  }) {
332
341
  const base = resetGhostRetryForRun(session, runId);
333
342
  const at = nowIso || new Date().toISOString();
@@ -336,6 +345,18 @@ export function applyGhostRetryAttempt(session, {
336
345
  // diagnostic bundle. We cap its size to 10 entries to prevent unbounded
337
346
  // growth on misbehaving projects — the tail is what matters for pattern
338
347
  // detection.
348
+ //
349
+ // Slice 2d (Turn 201): fold the per-attempt adapter `process_exit` /
350
+ // `spawn_error` diagnostic fields (stderr_excerpt, exit_code, exit_signal)
351
+ // into the log entry. This makes `ghost_retry_exhausted.diagnostic_bundle`
352
+ // self-contained — an operator reading `continuous-session.json` or the
353
+ // event payload no longer has to cross-reference
354
+ // `.agentxchain/dispatch/turns/<turnId>/stdout.log` to see WHY the last few
355
+ // spawns failed. Bounded by the adapter's own 800-byte stderr excerpt cap
356
+ // (DIAGNOSTIC_STDERR_EXCERPT_LIMIT at local-cli-adapter.js:43) so session
357
+ // state does not grow unbounded on a noisy-stderr runtime. The local cap
358
+ // mirrors that adapter cap so direct callers cannot accidentally persist
359
+ // larger excerpts.
339
360
  const nextEntry = {
340
361
  attempt: base.attempts + 1,
341
362
  old_turn_id: oldTurnId ?? null,
@@ -345,6 +366,9 @@ export function applyGhostRetryAttempt(session, {
345
366
  failure_type: failureType ?? null,
346
367
  running_ms: runningMs ?? null,
347
368
  threshold_ms: thresholdMs ?? null,
369
+ stderr_excerpt: normalizeAttemptStderrExcerpt(stderrExcerpt),
370
+ exit_code: Number.isInteger(exitCode) ? exitCode : null,
371
+ exit_signal: typeof exitSignal === 'string' && exitSignal.length > 0 ? exitSignal : null,
348
372
  retried_at: at,
349
373
  };
350
374
  const attemptsLog = [...base.attempts_log, nextEntry].slice(-10);
@@ -413,6 +437,58 @@ export function buildGhostRetryExhaustionMirror({
413
437
  return `Auto-retry exhausted after ${count} attempts (${ft}).${suffix}`;
414
438
  }
415
439
 
440
+ /**
441
+ * Slice 2d (Turn 201): extract the most recent `process_exit` / `spawn_error`
442
+ * adapter diagnostic from a rendered dispatch log. The adapter writes lines of
443
+ * the form `[adapter:diag] <label> <json>\n` (see
444
+ * `cli/src/lib/adapters/local-cli-adapter.js::appendDiagnostic`); ghost-turn
445
+ * failures always emit one of `process_exit` or `spawn_error` before settling.
446
+ *
447
+ * Returns `{ stderr_excerpt, exit_code, exit_signal }`. Each field is `null`
448
+ * when the log is empty, malformed, or did not surface that particular field.
449
+ * Purity: takes a string, does not read the filesystem; the caller is
450
+ * responsible for loading the log.
451
+ */
452
+ export function extractLatestStderrDiagnostic(dispatchLogContent) {
453
+ if (typeof dispatchLogContent !== 'string' || dispatchLogContent.length === 0) {
454
+ return { stderr_excerpt: null, exit_code: null, exit_signal: null };
455
+ }
456
+ const LABELS = new Set(['process_exit', 'spawn_error']);
457
+ const lines = dispatchLogContent.split(/\n+/);
458
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
459
+ const line = lines[i];
460
+ if (!line || !line.startsWith('[adapter:diag] ')) continue;
461
+ const rest = line.slice('[adapter:diag] '.length);
462
+ const spaceIdx = rest.indexOf(' ');
463
+ if (spaceIdx <= 0) continue;
464
+ const label = rest.slice(0, spaceIdx);
465
+ if (!LABELS.has(label)) continue;
466
+ const jsonText = rest.slice(spaceIdx + 1);
467
+ let payload;
468
+ try {
469
+ payload = JSON.parse(jsonText);
470
+ } catch {
471
+ continue;
472
+ }
473
+ if (!payload || typeof payload !== 'object') continue;
474
+ const stderr = typeof payload.stderr_excerpt === 'string' && payload.stderr_excerpt.length > 0
475
+ ? payload.stderr_excerpt
476
+ : null;
477
+ const rawExit = payload.exit_code;
478
+ const exitCode = Number.isInteger(rawExit) ? rawExit : null;
479
+ const rawSignal = payload.signal ?? payload.exit_signal ?? null;
480
+ const exitSignal = typeof rawSignal === 'string' && rawSignal.length > 0 ? rawSignal : null;
481
+ if (stderr === null && exitCode === null && exitSignal === null && label === 'process_exit') {
482
+ // Nothing useful on this line — keep looking for an earlier entry that
483
+ // had stderr or an exit code. Prevents a final benign process_exit from
484
+ // masking a prior spawn_error with real evidence.
485
+ continue;
486
+ }
487
+ return { stderr_excerpt: stderr, exit_code: exitCode, exit_signal: exitSignal };
488
+ }
489
+ return { stderr_excerpt: null, exit_code: null, exit_signal: null };
490
+ }
491
+
416
492
  /**
417
493
  * Slice 2c: build the per-attempt diagnostic bundle that rides on the
418
494
  * `ghost_retry_exhausted` event payload AND gets surfaced in CLI status so