agentxchain 2.154.0 → 2.154.3

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.0",
3
+ "version": "2.154.3",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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',
@@ -383,6 +412,7 @@ export function maybeAutoReconcileOperatorCommits(context, session, contOpts, lo
383
412
  const detail = detailLines.join(' ');
384
413
 
385
414
  if (state) {
415
+ const blockedAt = new Date().toISOString();
386
416
  const nextState = {
387
417
  ...state,
388
418
  status: 'blocked',
@@ -390,10 +420,15 @@ export function maybeAutoReconcileOperatorCommits(context, session, contOpts, lo
390
420
  blocked_reason: {
391
421
  ...(state.blocked_reason || {}),
392
422
  category: 'operator_commit_reconcile_refused',
423
+ blocked_at: blockedAt,
424
+ turn_id: null,
393
425
  error_class: errorClass,
394
426
  recovery: {
395
427
  ...((state.blocked_reason || {}).recovery || {}),
428
+ typed_reason: 'operator_commit_reconcile_refused',
429
+ owner: 'human',
396
430
  recovery_action: 'agentxchain reconcile-state --accept-operator-head',
431
+ turn_retained: false,
397
432
  detail,
398
433
  },
399
434
  },
@@ -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