agentxchain 2.152.0 → 2.154.0
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/bin/agentxchain.js +12 -0
- package/package.json +1 -1
- package/src/commands/reconcile-state.js +49 -0
- package/src/lib/continuity-status.js +8 -1
- package/src/lib/continuous-run.js +384 -4
- package/src/lib/ghost-retry.js +447 -0
- package/src/lib/governed-state.js +6 -1
- package/src/lib/normalized-config.js +53 -0
- package/src/lib/operator-commit-reconcile.js +260 -0
- package/src/lib/run-events.js +4 -0
- package/src/lib/schemas/agentxchain-config.schema.json +28 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ghost-retry.js — Pure decision helper for BUG-61 continuous-mode ghost-turn
|
|
3
|
+
* auto-recovery.
|
|
4
|
+
*
|
|
5
|
+
* This module is deliberately pure (no disk I/O, no subprocess spawn): it takes
|
|
6
|
+
* the blocked governed state plus the continuous session snapshot and returns a
|
|
7
|
+
* decision record the continuous loop can act on.
|
|
8
|
+
*
|
|
9
|
+
* Slice 2a ships the decision helper + state-shape primitives. Slice 2b wires
|
|
10
|
+
* it into `advanceContinuousRunOnce()` and covers `reissueTurn()` side-effects
|
|
11
|
+
* + cooldowns + command-chain beta scenarios.
|
|
12
|
+
*
|
|
13
|
+
* Contracts:
|
|
14
|
+
* - Retry is eligible ONLY when `blocked_reason.category === "ghost_turn"`
|
|
15
|
+
* AND an active turn exists with `status === "failed_start"` AND a typed
|
|
16
|
+
* BUG-51 startup failure (`runtime_spawn_failed` or `stdout_attach_failed`).
|
|
17
|
+
* - Retry budget is run-scoped: switching `run_id` resets the counter to 0.
|
|
18
|
+
* - Staged results on the ghost turn disqualify retry (defer to accept flow).
|
|
19
|
+
* - Exhaustion returns `decision: "exhausted"` — the caller is responsible
|
|
20
|
+
* for mirroring the outcome into governed state's
|
|
21
|
+
* `blocked_reason.recovery.detail` per DEC-BUG61-GHOST-RETRY-STATE-OWNERSHIP-001.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const GHOST_FAILURE_TYPES = Object.freeze([
|
|
25
|
+
'runtime_spawn_failed',
|
|
26
|
+
'stdout_attach_failed',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Slice 2c: same-signature early stop threshold.
|
|
31
|
+
*
|
|
32
|
+
* When N consecutive recorded attempts share the same fingerprint
|
|
33
|
+
* `(runtime_id, role_id, failure_type)`, the retry budget is NOT exhausted in
|
|
34
|
+
* raw count terms but the pattern signals a systematic failure that further
|
|
35
|
+
* retries will not clear. At that point the loop stops early with
|
|
36
|
+
* `decision: "exhausted"` and `reason: "same_signature_repeat"`. The threshold
|
|
37
|
+
* is deliberately low (2) because the BUG-61 contract is "retry transient
|
|
38
|
+
* ghosts" — a second identical signature is already non-transient evidence.
|
|
39
|
+
*
|
|
40
|
+
* Not configurable via `auto_retry_on_ghost` in v1; the value is a framework
|
|
41
|
+
* invariant. If evidence emerges that 2 is too aggressive, promote to config
|
|
42
|
+
* through a new DEC rather than silently widening.
|
|
43
|
+
*/
|
|
44
|
+
export const SIGNATURE_REPEAT_THRESHOLD = 2;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read (or default) the ghost_retry state object from a continuous session.
|
|
48
|
+
* Returns a plain object; callers should spread/clone before mutating.
|
|
49
|
+
*/
|
|
50
|
+
export function readGhostRetryState(session) {
|
|
51
|
+
const gr = session?.ghost_retry;
|
|
52
|
+
if (!gr || typeof gr !== 'object') {
|
|
53
|
+
return {
|
|
54
|
+
run_id: null,
|
|
55
|
+
attempts: 0,
|
|
56
|
+
max_retries_per_run: null,
|
|
57
|
+
last_old_turn_id: null,
|
|
58
|
+
last_new_turn_id: null,
|
|
59
|
+
last_failure_type: null,
|
|
60
|
+
last_retried_at: null,
|
|
61
|
+
exhausted: false,
|
|
62
|
+
attempts_log: [],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
run_id: gr.run_id ?? null,
|
|
67
|
+
attempts: Number.isInteger(gr.attempts) && gr.attempts >= 0 ? gr.attempts : 0,
|
|
68
|
+
max_retries_per_run: Number.isInteger(gr.max_retries_per_run) ? gr.max_retries_per_run : null,
|
|
69
|
+
last_old_turn_id: gr.last_old_turn_id ?? null,
|
|
70
|
+
last_new_turn_id: gr.last_new_turn_id ?? null,
|
|
71
|
+
last_failure_type: gr.last_failure_type ?? null,
|
|
72
|
+
last_retried_at: gr.last_retried_at ?? null,
|
|
73
|
+
exhausted: Boolean(gr.exhausted),
|
|
74
|
+
attempts_log: Array.isArray(gr.attempts_log) ? gr.attempts_log : [],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Reset the ghost_retry counter when the active run_id differs from the last
|
|
80
|
+
* recorded run_id. Returns the reset state (does not mutate input).
|
|
81
|
+
*/
|
|
82
|
+
export function resetGhostRetryForRun(session, runId) {
|
|
83
|
+
const current = readGhostRetryState(session);
|
|
84
|
+
if (current.run_id === runId) return current;
|
|
85
|
+
return {
|
|
86
|
+
run_id: runId ?? null,
|
|
87
|
+
attempts: 0,
|
|
88
|
+
max_retries_per_run: current.max_retries_per_run,
|
|
89
|
+
last_old_turn_id: null,
|
|
90
|
+
last_new_turn_id: null,
|
|
91
|
+
last_failure_type: null,
|
|
92
|
+
last_retried_at: null,
|
|
93
|
+
exhausted: false,
|
|
94
|
+
attempts_log: [],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build the fingerprint string for a recorded attempt. Same shape as the
|
|
100
|
+
* HUMAN-ROADMAP's "same runtime, same role, same prompt shape" guidance —
|
|
101
|
+
* we key on (runtime_id, role_id, failure_type). Prompt shape is implicitly
|
|
102
|
+
* stable across same-turn reissues because `reissueTurn()` re-renders the
|
|
103
|
+
* same dispatch bundle.
|
|
104
|
+
*
|
|
105
|
+
* `null`/missing fields are normalized to `?` so partial records compare
|
|
106
|
+
* consistently rather than silently matching.
|
|
107
|
+
*/
|
|
108
|
+
export function buildAttemptFingerprint(attempt) {
|
|
109
|
+
const runtime = attempt?.runtime_id ?? '?';
|
|
110
|
+
const role = attempt?.role_id ?? '?';
|
|
111
|
+
const failure = attempt?.failure_type ?? '?';
|
|
112
|
+
return `${runtime}|${role}|${failure}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Classify whether the tail of `attemptsLog` shows `threshold` consecutive
|
|
117
|
+
* identical fingerprints. Returns:
|
|
118
|
+
* - `{ triggered: false, signature: null, consecutive: 0 }` when not hit
|
|
119
|
+
* - `{ triggered: true, signature, consecutive }` when hit
|
|
120
|
+
*
|
|
121
|
+
* The caller decides what to do with the trigger (slice 2c routes it into
|
|
122
|
+
* `decision: "exhausted"` with `reason: "same_signature_repeat"`).
|
|
123
|
+
*/
|
|
124
|
+
export function classifySameSignatureExhaustion(attemptsLog, threshold = SIGNATURE_REPEAT_THRESHOLD) {
|
|
125
|
+
if (!Array.isArray(attemptsLog) || attemptsLog.length < threshold) {
|
|
126
|
+
return { triggered: false, signature: null, consecutive: 0 };
|
|
127
|
+
}
|
|
128
|
+
if (!Number.isInteger(threshold) || threshold < 2) {
|
|
129
|
+
return { triggered: false, signature: null, consecutive: 0 };
|
|
130
|
+
}
|
|
131
|
+
const tail = attemptsLog.slice(-threshold);
|
|
132
|
+
const signatures = tail.map(buildAttemptFingerprint);
|
|
133
|
+
const first = signatures[0];
|
|
134
|
+
if (!first || first === '?|?|?') {
|
|
135
|
+
return { triggered: false, signature: null, consecutive: 0 };
|
|
136
|
+
}
|
|
137
|
+
const allMatch = signatures.every((s) => s === first);
|
|
138
|
+
if (!allMatch) {
|
|
139
|
+
return { triggered: false, signature: null, consecutive: 0 };
|
|
140
|
+
}
|
|
141
|
+
return { triggered: true, signature: first, consecutive: threshold };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Locate the primary ghost turn from governed state.
|
|
146
|
+
*
|
|
147
|
+
* Inputs expected (matches shape written by `stale-turn-watchdog.js`):
|
|
148
|
+
* - `state.blocked_reason.category === "ghost_turn"`
|
|
149
|
+
* - `state.blocked_reason.turn_id`
|
|
150
|
+
* - `state.active_turns[turnId].status === "failed_start"`
|
|
151
|
+
* - `state.active_turns[turnId].failed_start_reason` is one of
|
|
152
|
+
* GHOST_FAILURE_TYPES
|
|
153
|
+
*
|
|
154
|
+
* Returns the turn object + failure type, or null when no eligible turn is
|
|
155
|
+
* found. Does NOT consult disk.
|
|
156
|
+
*/
|
|
157
|
+
export function findPrimaryGhostTurn(state) {
|
|
158
|
+
if (!state || typeof state !== 'object') return null;
|
|
159
|
+
const blockedReason = state.blocked_reason;
|
|
160
|
+
if (!blockedReason || blockedReason.category !== 'ghost_turn') return null;
|
|
161
|
+
|
|
162
|
+
const activeTurns = state.active_turns || {};
|
|
163
|
+
const hintedTurnId = blockedReason.turn_id;
|
|
164
|
+
const candidateIds = hintedTurnId && activeTurns[hintedTurnId]
|
|
165
|
+
? [hintedTurnId]
|
|
166
|
+
: Object.keys(activeTurns);
|
|
167
|
+
|
|
168
|
+
for (const turnId of candidateIds) {
|
|
169
|
+
const turn = activeTurns[turnId];
|
|
170
|
+
if (!turn) continue;
|
|
171
|
+
if (turn.status !== 'failed_start') continue;
|
|
172
|
+
const failureType = turn.failed_start_reason;
|
|
173
|
+
if (!GHOST_FAILURE_TYPES.includes(failureType)) continue;
|
|
174
|
+
if (hasMeaningfulStagedResult(turn)) continue;
|
|
175
|
+
return { turn_id: turnId, turn, failure_type: failureType };
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Best-effort detector for a meaningful staged result. If the turn has already
|
|
182
|
+
* produced a structured result the caller should NOT auto-retry — the accept
|
|
183
|
+
* pipeline owns that path.
|
|
184
|
+
*/
|
|
185
|
+
function hasMeaningfulStagedResult(turn) {
|
|
186
|
+
if (!turn) return false;
|
|
187
|
+
const staged = turn.staged_result ?? turn.result ?? null;
|
|
188
|
+
if (!staged) return false;
|
|
189
|
+
if (typeof staged !== 'object') return Boolean(staged);
|
|
190
|
+
// Ignore purely-null / empty shells the watchdog may leave behind.
|
|
191
|
+
for (const value of Object.values(staged)) {
|
|
192
|
+
if (value !== null && value !== undefined && value !== '') return true;
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Classify the retry decision given the current blocked state + session +
|
|
199
|
+
* resolved options.
|
|
200
|
+
*
|
|
201
|
+
* @param {object} params
|
|
202
|
+
* @param {object} params.state - governed state (has blocked_reason + active_turns)
|
|
203
|
+
* @param {object} params.session - continuous session (source of truth for retry counter)
|
|
204
|
+
* @param {object} params.autoRetryOnGhost - resolved continuous options block: { enabled, maxRetriesPerRun, cooldownSeconds }
|
|
205
|
+
* @param {string|null} [params.runId] - the run_id the continuous loop believes is active (defaults to state.run_id)
|
|
206
|
+
* @returns {{
|
|
207
|
+
* decision: 'retry' | 'exhausted' | 'skip_non_ghost' | 'missing_active_ghost' | 'disabled' | 'missing_run_id',
|
|
208
|
+
* reason: string,
|
|
209
|
+
* attempts: number,
|
|
210
|
+
* maxRetries: number,
|
|
211
|
+
* retryState: object,
|
|
212
|
+
* ghost?: { turn_id: string, failure_type: string },
|
|
213
|
+
* signatureRepeat?: { signature: string, consecutive: number }
|
|
214
|
+
* }}
|
|
215
|
+
*
|
|
216
|
+
* Exhaustion lanes (added in slice 2c):
|
|
217
|
+
* - `reason: "retry budget exhausted (N/N)"` — raw counter cap hit
|
|
218
|
+
* - `reason: "same_signature_repeat (<signature>)"` — N consecutive
|
|
219
|
+
* identical fingerprints recorded; continuing is unlikely to help. This
|
|
220
|
+
* lane can fire BEFORE the raw counter cap — we stop as soon as the
|
|
221
|
+
* pattern is visible.
|
|
222
|
+
*/
|
|
223
|
+
export function classifyGhostRetryDecision({ state, session, autoRetryOnGhost, runId } = {}) {
|
|
224
|
+
const opts = autoRetryOnGhost || {};
|
|
225
|
+
const enabled = Boolean(opts.enabled);
|
|
226
|
+
const maxRetries = Number.isInteger(opts.maxRetriesPerRun) && opts.maxRetriesPerRun > 0
|
|
227
|
+
? opts.maxRetriesPerRun
|
|
228
|
+
: 3;
|
|
229
|
+
|
|
230
|
+
if (!enabled) {
|
|
231
|
+
return {
|
|
232
|
+
decision: 'disabled',
|
|
233
|
+
reason: 'auto_retry_on_ghost.enabled is false',
|
|
234
|
+
attempts: 0,
|
|
235
|
+
maxRetries,
|
|
236
|
+
retryState: readGhostRetryState(session),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const category = state?.blocked_reason?.category;
|
|
241
|
+
if (category !== 'ghost_turn') {
|
|
242
|
+
return {
|
|
243
|
+
decision: 'skip_non_ghost',
|
|
244
|
+
reason: `blocked_reason.category=${category ?? 'null'} is not ghost_turn`,
|
|
245
|
+
attempts: 0,
|
|
246
|
+
maxRetries,
|
|
247
|
+
retryState: readGhostRetryState(session),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const ghost = findPrimaryGhostTurn(state);
|
|
252
|
+
if (!ghost) {
|
|
253
|
+
return {
|
|
254
|
+
decision: 'missing_active_ghost',
|
|
255
|
+
reason: 'blocked_reason names a ghost but no active turn has a typed BUG-51 failed_start',
|
|
256
|
+
attempts: 0,
|
|
257
|
+
maxRetries,
|
|
258
|
+
retryState: readGhostRetryState(session),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const effectiveRunId = runId ?? state?.run_id ?? null;
|
|
263
|
+
if (!effectiveRunId) {
|
|
264
|
+
return {
|
|
265
|
+
decision: 'missing_run_id',
|
|
266
|
+
reason: 'cannot scope retry counter without a run_id',
|
|
267
|
+
attempts: 0,
|
|
268
|
+
maxRetries,
|
|
269
|
+
retryState: readGhostRetryState(session),
|
|
270
|
+
ghost: { turn_id: ghost.turn_id, failure_type: ghost.failure_type },
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const resetState = resetGhostRetryForRun(session, effectiveRunId);
|
|
275
|
+
const attempts = resetState.attempts;
|
|
276
|
+
|
|
277
|
+
if (attempts >= maxRetries) {
|
|
278
|
+
return {
|
|
279
|
+
decision: 'exhausted',
|
|
280
|
+
reason: `retry budget exhausted (${attempts}/${maxRetries})`,
|
|
281
|
+
attempts,
|
|
282
|
+
maxRetries,
|
|
283
|
+
retryState: { ...resetState, max_retries_per_run: maxRetries, exhausted: true },
|
|
284
|
+
ghost: { turn_id: ghost.turn_id, failure_type: ghost.failure_type },
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Slice 2c: same-signature early stop. If the recorded attempts log shows
|
|
289
|
+
// SIGNATURE_REPEAT_THRESHOLD consecutive identical fingerprints, stop early
|
|
290
|
+
// with a distinct reason so the caller can surface "pattern detected, not
|
|
291
|
+
// transient" in the exhaustion bundle.
|
|
292
|
+
const sigCheck = classifySameSignatureExhaustion(resetState.attempts_log, SIGNATURE_REPEAT_THRESHOLD);
|
|
293
|
+
if (sigCheck.triggered) {
|
|
294
|
+
return {
|
|
295
|
+
decision: 'exhausted',
|
|
296
|
+
reason: `same_signature_repeat (${sigCheck.signature})`,
|
|
297
|
+
attempts,
|
|
298
|
+
maxRetries,
|
|
299
|
+
retryState: { ...resetState, max_retries_per_run: maxRetries, exhausted: true },
|
|
300
|
+
ghost: { turn_id: ghost.turn_id, failure_type: ghost.failure_type },
|
|
301
|
+
signatureRepeat: { signature: sigCheck.signature, consecutive: sigCheck.consecutive },
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
decision: 'retry',
|
|
307
|
+
reason: `retry budget available (${attempts}/${maxRetries})`,
|
|
308
|
+
attempts,
|
|
309
|
+
maxRetries,
|
|
310
|
+
retryState: { ...resetState, max_retries_per_run: maxRetries },
|
|
311
|
+
ghost: { turn_id: ghost.turn_id, failure_type: ghost.failure_type },
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Apply a successful auto-retry to a session snapshot. Returns a NEW session
|
|
317
|
+
* object with the ghost_retry counter incremented and last_* fields updated.
|
|
318
|
+
* Does not write to disk; the caller owns persistence.
|
|
319
|
+
*/
|
|
320
|
+
export function applyGhostRetryAttempt(session, {
|
|
321
|
+
runId,
|
|
322
|
+
oldTurnId,
|
|
323
|
+
newTurnId,
|
|
324
|
+
failureType,
|
|
325
|
+
maxRetries,
|
|
326
|
+
nowIso,
|
|
327
|
+
runtimeId = null,
|
|
328
|
+
roleId = null,
|
|
329
|
+
runningMs = null,
|
|
330
|
+
thresholdMs = null,
|
|
331
|
+
}) {
|
|
332
|
+
const base = resetGhostRetryForRun(session, runId);
|
|
333
|
+
const at = nowIso || new Date().toISOString();
|
|
334
|
+
// Slice 2c: append a per-attempt fingerprint record. The log is the source
|
|
335
|
+
// of truth for same-signature early-stop detection and the exhaustion
|
|
336
|
+
// diagnostic bundle. We cap its size to 10 entries to prevent unbounded
|
|
337
|
+
// growth on misbehaving projects — the tail is what matters for pattern
|
|
338
|
+
// detection.
|
|
339
|
+
const nextEntry = {
|
|
340
|
+
attempt: base.attempts + 1,
|
|
341
|
+
old_turn_id: oldTurnId ?? null,
|
|
342
|
+
new_turn_id: newTurnId ?? null,
|
|
343
|
+
runtime_id: runtimeId ?? null,
|
|
344
|
+
role_id: roleId ?? null,
|
|
345
|
+
failure_type: failureType ?? null,
|
|
346
|
+
running_ms: runningMs ?? null,
|
|
347
|
+
threshold_ms: thresholdMs ?? null,
|
|
348
|
+
retried_at: at,
|
|
349
|
+
};
|
|
350
|
+
const attemptsLog = [...base.attempts_log, nextEntry].slice(-10);
|
|
351
|
+
const ghost_retry = {
|
|
352
|
+
run_id: runId ?? null,
|
|
353
|
+
attempts: base.attempts + 1,
|
|
354
|
+
max_retries_per_run: Number.isInteger(maxRetries) ? maxRetries : base.max_retries_per_run,
|
|
355
|
+
last_old_turn_id: oldTurnId ?? null,
|
|
356
|
+
last_new_turn_id: newTurnId ?? null,
|
|
357
|
+
last_failure_type: failureType ?? null,
|
|
358
|
+
last_retried_at: at,
|
|
359
|
+
exhausted: false,
|
|
360
|
+
attempts_log: attemptsLog,
|
|
361
|
+
};
|
|
362
|
+
return { ...(session || {}), ghost_retry };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Apply an exhaustion outcome to a session snapshot. Returns a NEW session
|
|
367
|
+
* with the counter preserved, `exhausted: true`, and last-failure metadata.
|
|
368
|
+
*/
|
|
369
|
+
export function applyGhostRetryExhaustion(session, { runId, failureType, turnId, maxRetries, nowIso }) {
|
|
370
|
+
const base = resetGhostRetryForRun(session, runId);
|
|
371
|
+
const ghost_retry = {
|
|
372
|
+
run_id: runId ?? null,
|
|
373
|
+
attempts: base.attempts,
|
|
374
|
+
max_retries_per_run: Number.isInteger(maxRetries) ? maxRetries : base.max_retries_per_run,
|
|
375
|
+
last_old_turn_id: turnId ?? base.last_old_turn_id,
|
|
376
|
+
last_new_turn_id: null,
|
|
377
|
+
last_failure_type: failureType ?? base.last_failure_type,
|
|
378
|
+
last_retried_at: nowIso || base.last_retried_at,
|
|
379
|
+
exhausted: true,
|
|
380
|
+
// Slice 2c: preserve the per-attempt fingerprint log into the exhausted
|
|
381
|
+
// state so the operator-facing session.json still has the diagnostic
|
|
382
|
+
// payload after the loop pauses. Without this, the log would be dropped
|
|
383
|
+
// exactly when it is most useful.
|
|
384
|
+
attempts_log: Array.isArray(base.attempts_log) ? base.attempts_log : [],
|
|
385
|
+
};
|
|
386
|
+
return { ...(session || {}), ghost_retry };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Build the human-readable mirror string the continuous loop should write
|
|
391
|
+
* into governed state's `blocked_reason.recovery.detail` at exhaustion time.
|
|
392
|
+
* Matches the shape `stale-turn-watchdog.js` already uses for that field.
|
|
393
|
+
*
|
|
394
|
+
* Slice 2c: accepts optional `signatureRepeat` and adds a brief inline note
|
|
395
|
+
* so operators see the distinction between raw-budget exhaustion and
|
|
396
|
+
* pattern-based early stop in the status surface.
|
|
397
|
+
*/
|
|
398
|
+
export function buildGhostRetryExhaustionMirror({
|
|
399
|
+
attempts,
|
|
400
|
+
maxRetries,
|
|
401
|
+
failureType,
|
|
402
|
+
manualRecoveryDetail,
|
|
403
|
+
signatureRepeat = null,
|
|
404
|
+
}) {
|
|
405
|
+
const count = `${attempts}/${maxRetries}`;
|
|
406
|
+
const ft = failureType || 'ghost_turn';
|
|
407
|
+
const suffix = manualRecoveryDetail ? ` ${manualRecoveryDetail}` : '';
|
|
408
|
+
if (signatureRepeat && signatureRepeat.signature) {
|
|
409
|
+
const sig = signatureRepeat.signature;
|
|
410
|
+
const consec = signatureRepeat.consecutive || 2;
|
|
411
|
+
return `Auto-retry stopped early after ${consec} consecutive same-signature attempts [${sig}] (${ft}); last attempt ${count}.${suffix}`;
|
|
412
|
+
}
|
|
413
|
+
return `Auto-retry exhausted after ${count} attempts (${ft}).${suffix}`;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Slice 2c: build the per-attempt diagnostic bundle that rides on the
|
|
418
|
+
* `ghost_retry_exhausted` event payload AND gets surfaced in CLI status so
|
|
419
|
+
* the operator has enough evidence to decide between (a) bumping
|
|
420
|
+
* `max_retries_per_run`, (b) changing the runtime, (c) raising
|
|
421
|
+
* `startup_watchdog_ms`, or (d) filing a new BUG-54-class regression.
|
|
422
|
+
*
|
|
423
|
+
* Output shape:
|
|
424
|
+
* {
|
|
425
|
+
* attempts_log: [...per-attempt records, most recent last...],
|
|
426
|
+
* fingerprint_summary: [{ signature, count }, ...] sorted by count desc,
|
|
427
|
+
* final_signature: string | null
|
|
428
|
+
* }
|
|
429
|
+
*/
|
|
430
|
+
export function buildGhostRetryDiagnosticBundle(sessionOrState) {
|
|
431
|
+
const state = sessionOrState && typeof sessionOrState === 'object' && sessionOrState.ghost_retry
|
|
432
|
+
? readGhostRetryState(sessionOrState)
|
|
433
|
+
: (Array.isArray(sessionOrState?.attempts_log)
|
|
434
|
+
? { attempts_log: sessionOrState.attempts_log }
|
|
435
|
+
: { attempts_log: [] });
|
|
436
|
+
const log = Array.isArray(state.attempts_log) ? state.attempts_log : [];
|
|
437
|
+
const counts = new Map();
|
|
438
|
+
for (const entry of log) {
|
|
439
|
+
const sig = buildAttemptFingerprint(entry);
|
|
440
|
+
counts.set(sig, (counts.get(sig) || 0) + 1);
|
|
441
|
+
}
|
|
442
|
+
const fingerprint_summary = Array.from(counts.entries())
|
|
443
|
+
.map(([signature, count]) => ({ signature, count }))
|
|
444
|
+
.sort((a, b) => b.count - a.count);
|
|
445
|
+
const final_signature = log.length > 0 ? buildAttemptFingerprint(log[log.length - 1]) : null;
|
|
446
|
+
return { attempts_log: log, fingerprint_summary, final_signature };
|
|
447
|
+
}
|
|
@@ -1514,7 +1514,12 @@ function buildConflictDetail(conflict) {
|
|
|
1514
1514
|
}
|
|
1515
1515
|
|
|
1516
1516
|
function hasBlockingActiveTurn(activeTurns) {
|
|
1517
|
-
return Object.values(activeTurns || {}).some((turn) =>
|
|
1517
|
+
return Object.values(activeTurns || {}).some((turn) => [
|
|
1518
|
+
'failed',
|
|
1519
|
+
'conflicted',
|
|
1520
|
+
'failed_start',
|
|
1521
|
+
'stalled',
|
|
1522
|
+
].includes(turn?.status));
|
|
1518
1523
|
}
|
|
1519
1524
|
|
|
1520
1525
|
function findHistoryTurnRequest(historyEntries, turnId, kind) {
|
|
@@ -640,9 +640,53 @@ export function validateRunLoopConfig(runLoop) {
|
|
|
640
640
|
}
|
|
641
641
|
validateRunLoopPositiveInteger('run_loop.startup_watchdog_ms', runLoop.startup_watchdog_ms, errors);
|
|
642
642
|
validateRunLoopPositiveInteger('run_loop.stale_turn_threshold_ms', runLoop.stale_turn_threshold_ms, errors);
|
|
643
|
+
if (runLoop.continuous !== undefined && runLoop.continuous !== null) {
|
|
644
|
+
validateRunLoopContinuousConfig('run_loop.continuous', runLoop.continuous, errors);
|
|
645
|
+
}
|
|
643
646
|
return errors;
|
|
644
647
|
}
|
|
645
648
|
|
|
649
|
+
export const VALID_RECONCILE_OPERATOR_COMMITS = ['manual', 'auto_safe_only', 'disabled'];
|
|
650
|
+
|
|
651
|
+
function validateRunLoopContinuousConfig(path, continuous, errors) {
|
|
652
|
+
if (typeof continuous !== 'object' || Array.isArray(continuous)) {
|
|
653
|
+
errors.push(`${path} must be an object`);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (continuous.auto_retry_on_ghost !== undefined && continuous.auto_retry_on_ghost !== null) {
|
|
657
|
+
validateAutoRetryOnGhostConfig(`${path}.auto_retry_on_ghost`, continuous.auto_retry_on_ghost, errors);
|
|
658
|
+
}
|
|
659
|
+
if (
|
|
660
|
+
continuous.reconcile_operator_commits !== undefined
|
|
661
|
+
&& continuous.reconcile_operator_commits !== null
|
|
662
|
+
) {
|
|
663
|
+
if (
|
|
664
|
+
typeof continuous.reconcile_operator_commits !== 'string'
|
|
665
|
+
|| !VALID_RECONCILE_OPERATOR_COMMITS.includes(continuous.reconcile_operator_commits)
|
|
666
|
+
) {
|
|
667
|
+
errors.push(
|
|
668
|
+
`${path}.reconcile_operator_commits must be one of: ${VALID_RECONCILE_OPERATOR_COMMITS.join(', ')}`
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function validateAutoRetryOnGhostConfig(path, value, errors) {
|
|
675
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
676
|
+
errors.push(`${path} must be an object`);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if ('enabled' in value && typeof value.enabled !== 'boolean') {
|
|
680
|
+
errors.push(`${path}.enabled must be a boolean`);
|
|
681
|
+
}
|
|
682
|
+
if ('max_retries_per_run' in value) {
|
|
683
|
+
validatePositiveInteger(`${path}.max_retries_per_run`, value.max_retries_per_run, 'retry count', errors);
|
|
684
|
+
}
|
|
685
|
+
if ('cooldown_seconds' in value) {
|
|
686
|
+
validatePositiveInteger(`${path}.cooldown_seconds`, value.cooldown_seconds, 'seconds', errors);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
646
690
|
function validateRunLoopPositiveInteger(path, value, errors) {
|
|
647
691
|
if (value === undefined || value === null) {
|
|
648
692
|
return;
|
|
@@ -656,6 +700,15 @@ function validateRunLoopPositiveInteger(path, value, errors) {
|
|
|
656
700
|
}
|
|
657
701
|
}
|
|
658
702
|
|
|
703
|
+
function validatePositiveInteger(path, value, unitLabel, errors) {
|
|
704
|
+
if (value === undefined || value === null) {
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
|
|
708
|
+
errors.push(`${path} must be a positive integer (${unitLabel})`);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
659
712
|
function validateRuntimePositiveInteger(path, value, errors) {
|
|
660
713
|
if (value === undefined || value === null) {
|
|
661
714
|
return;
|