agentxchain 2.154.1 → 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 +1 -1
- package/src/lib/continuous-run.js +29 -0
- package/src/lib/ghost-retry.js +76 -0
package/package.json
CHANGED
|
@@ -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',
|
package/src/lib/ghost-retry.js
CHANGED
|
@@ -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
|