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