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
package/bin/agentxchain.js
CHANGED
|
@@ -73,6 +73,7 @@ import { injectCommand } from '../src/commands/inject.js';
|
|
|
73
73
|
import { escalateCommand } from '../src/commands/escalate.js';
|
|
74
74
|
import { acceptTurnCommand } from '../src/commands/accept-turn.js';
|
|
75
75
|
import { checkpointTurnCommand } from '../src/commands/checkpoint-turn.js';
|
|
76
|
+
import { reconcileStateCommand } from '../src/commands/reconcile-state.js';
|
|
76
77
|
import { rejectTurnCommand } from '../src/commands/reject-turn.js';
|
|
77
78
|
import { reissueTurnCommand } from '../src/commands/reissue-turn.js';
|
|
78
79
|
import { proposalListCommand, proposalDiffCommand, proposalApplyCommand, proposalRejectCommand } from '../src/commands/proposal.js';
|
|
@@ -701,6 +702,12 @@ program
|
|
|
701
702
|
.option('--turn <id>', 'Checkpoint a specific accepted turn from history')
|
|
702
703
|
.action(checkpointTurnCommand);
|
|
703
704
|
|
|
705
|
+
program
|
|
706
|
+
.command('reconcile-state')
|
|
707
|
+
.description('Reconcile safe operator commits into governed run state')
|
|
708
|
+
.option('--accept-operator-head', 'Accept safe fast-forward operator commits as the new governed baseline')
|
|
709
|
+
.action(reconcileStateCommand);
|
|
710
|
+
|
|
704
711
|
program
|
|
705
712
|
.command('reject-turn')
|
|
706
713
|
.description('Reject the current governed turn result and retry or escalate')
|
|
@@ -753,6 +760,11 @@ program
|
|
|
753
760
|
.option('--triage-approval <mode>', 'Triage policy for vision-derived intents: auto or human (default: config or auto)')
|
|
754
761
|
.option('--max-idle-cycles <n>', 'Stop after N consecutive idle cycles with no derivable work (default: 3)', parseInt)
|
|
755
762
|
.option('--session-budget <usd>', 'Cumulative session-level budget cap in USD for continuous mode', parseFloat)
|
|
763
|
+
.option('--auto-retry-on-ghost', 'Enable bounded automatic retry for continuous-mode startup ghost turns')
|
|
764
|
+
.option('--no-auto-retry-on-ghost', 'Disable bounded automatic retry for continuous-mode startup ghost turns')
|
|
765
|
+
.option('--auto-retry-on-ghost-max-retries <n>', 'Maximum startup ghost retries per continuous run (default: config or 3)', parseInt)
|
|
766
|
+
.option('--auto-retry-on-ghost-cooldown-seconds <n>', 'Seconds to wait between startup ghost retries (default: config or 5)', parseInt)
|
|
767
|
+
.option('--reconcile-operator-commits <mode>', 'Continuous reconcile posture for operator commits: manual, auto_safe_only, or disabled (default: config or manual; auto_safe_only under full-auto approval policy)')
|
|
756
768
|
.option('--auto-checkpoint', 'Auto-commit accepted writable turns after acceptance')
|
|
757
769
|
.option('--no-auto-checkpoint', 'Disable automatic checkpointing after accepted writable turns')
|
|
758
770
|
.action(runCommand);
|
package/package.json
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { loadProjectContext } from '../lib/config.js';
|
|
3
|
+
import { reconcileOperatorHead } from '../lib/operator-commit-reconcile.js';
|
|
4
|
+
|
|
5
|
+
export async function reconcileStateCommand(opts = {}) {
|
|
6
|
+
const context = loadProjectContext();
|
|
7
|
+
if (!context) {
|
|
8
|
+
console.log(chalk.red('No agentxchain.json found. Run `agentxchain init` first.'));
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { root, config } = context;
|
|
13
|
+
if (config.protocol_mode !== 'governed') {
|
|
14
|
+
console.log(chalk.red('The reconcile-state command is only available for governed projects.'));
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!opts.acceptOperatorHead) {
|
|
19
|
+
console.log(chalk.red('No reconciliation action selected.'));
|
|
20
|
+
console.log(chalk.dim('Run `agentxchain reconcile-state --accept-operator-head` to accept safe operator commits on top of the last checkpoint.'));
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const result = reconcileOperatorHead(root);
|
|
25
|
+
if (!result.ok) {
|
|
26
|
+
console.log(chalk.red(`Reconcile refused (${result.error_class || 'unknown'}).`));
|
|
27
|
+
console.log(chalk.red(result.error || 'Unable to reconcile operator commits.'));
|
|
28
|
+
if (result.offending_path) {
|
|
29
|
+
console.log(chalk.dim(`Offending path: ${result.offending_path}`));
|
|
30
|
+
}
|
|
31
|
+
if (result.offending_commit) {
|
|
32
|
+
console.log(chalk.dim(`Offending commit: ${result.offending_commit}`));
|
|
33
|
+
}
|
|
34
|
+
console.log(chalk.dim('Manual recovery: inspect the commit range, restore governed state artifacts if needed, then restart from an explicit checkpoint.'));
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (result.no_op) {
|
|
39
|
+
console.log(chalk.green(`State already reconciled at ${result.accepted_head.slice(0, 8)}.`));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
console.log(chalk.green(`Reconciled ${result.accepted_commits.length} operator commit(s).`));
|
|
44
|
+
console.log(chalk.dim(`Previous baseline: ${result.previous_baseline}`));
|
|
45
|
+
console.log(chalk.dim(`Accepted HEAD: ${result.accepted_head}`));
|
|
46
|
+
if (result.paths_touched.length > 0) {
|
|
47
|
+
console.log(chalk.dim(`Paths touched: ${result.paths_touched.join(', ')}`));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -129,8 +129,15 @@ export function getContinuityStatus(root, state) {
|
|
|
129
129
|
&& checkpoint.run_id !== state.run_id
|
|
130
130
|
);
|
|
131
131
|
|
|
132
|
-
const action = deriveRecommendedContinuityAction(state);
|
|
133
132
|
const drift = deriveCheckpointDrift(root, checkpoint, staleCheckpoint);
|
|
133
|
+
const action = drift.drift_detected === true
|
|
134
|
+
? {
|
|
135
|
+
recommended_command: 'agentxchain reconcile-state --accept-operator-head',
|
|
136
|
+
recommended_reason: 'operator_commit_drift',
|
|
137
|
+
recommended_detail: 'accept safe fast-forward operator commits as the new baseline',
|
|
138
|
+
restart_recommended: false,
|
|
139
|
+
}
|
|
140
|
+
: deriveRecommendedContinuityAction(state);
|
|
134
141
|
|
|
135
142
|
return {
|
|
136
143
|
checkpoint,
|
|
@@ -25,6 +25,16 @@ import {
|
|
|
25
25
|
import { loadProjectState } from './config.js';
|
|
26
26
|
import { safeWriteJson } from './safe-write.js';
|
|
27
27
|
import { emitRunEvent } from './run-events.js';
|
|
28
|
+
import { reissueTurn } from './governed-state.js';
|
|
29
|
+
import {
|
|
30
|
+
applyGhostRetryAttempt,
|
|
31
|
+
applyGhostRetryExhaustion,
|
|
32
|
+
buildGhostRetryDiagnosticBundle,
|
|
33
|
+
buildGhostRetryExhaustionMirror,
|
|
34
|
+
classifyGhostRetryDecision,
|
|
35
|
+
} from './ghost-retry.js';
|
|
36
|
+
import { reconcileOperatorHead } from './operator-commit-reconcile.js';
|
|
37
|
+
import { getContinuityStatus } from './continuity-status.js';
|
|
28
38
|
import {
|
|
29
39
|
archiveStaleIntentsForRun,
|
|
30
40
|
formatLegacyIntentMigrationNotice,
|
|
@@ -127,6 +137,178 @@ function getBlockedCategory(state) {
|
|
|
127
137
|
return state?.blocked_reason?.category || null;
|
|
128
138
|
}
|
|
129
139
|
|
|
140
|
+
function writeGovernedState(root, state) {
|
|
141
|
+
safeWriteJson(join(root, '.agentxchain', 'state.json'), state);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function clearGhostBlockerAfterReissue(root, state) {
|
|
145
|
+
const nextState = {
|
|
146
|
+
...state,
|
|
147
|
+
status: 'active',
|
|
148
|
+
blocked_on: null,
|
|
149
|
+
blocked_reason: null,
|
|
150
|
+
escalation: null,
|
|
151
|
+
};
|
|
152
|
+
writeGovernedState(root, nextState);
|
|
153
|
+
return nextState;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function maybeAutoRetryGhostBlocker(context, session, contOpts, blockedState, log = console.log) {
|
|
157
|
+
const { root, config } = context;
|
|
158
|
+
const decision = classifyGhostRetryDecision({
|
|
159
|
+
state: blockedState,
|
|
160
|
+
session,
|
|
161
|
+
autoRetryOnGhost: contOpts.autoRetryOnGhost,
|
|
162
|
+
runId: session.current_run_id || blockedState?.run_id || null,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
if (decision.decision === 'retry') {
|
|
166
|
+
const oldTurnId = decision.ghost.turn_id;
|
|
167
|
+
const oldTurn = blockedState?.active_turns?.[oldTurnId] || {};
|
|
168
|
+
const reissued = reissueTurn(root, config, {
|
|
169
|
+
turnId: oldTurnId,
|
|
170
|
+
reason: 'auto_retry_ghost',
|
|
171
|
+
});
|
|
172
|
+
if (!reissued.ok) {
|
|
173
|
+
log(`Ghost auto-retry skipped: ${reissued.error}`);
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const runId = session.current_run_id || blockedState?.run_id || reissued.state?.run_id || null;
|
|
178
|
+
const attempt = decision.attempts + 1;
|
|
179
|
+
const nowIso = new Date().toISOString();
|
|
180
|
+
const nextState = clearGhostBlockerAfterReissue(root, reissued.state);
|
|
181
|
+
// Slice 2c: pass runtime/role/timing fields so the fingerprint log can
|
|
182
|
+
// drive same-signature early-stop detection on subsequent invocations.
|
|
183
|
+
const oldRuntimeId = oldTurn.runtime_id || reissued.newTurn.runtime_id || null;
|
|
184
|
+
const oldRoleId = oldTurn.assigned_role || reissued.newTurn.assigned_role || null;
|
|
185
|
+
const oldRunningMs = oldTurn.failed_start_running_ms ?? null;
|
|
186
|
+
const oldThresholdMs = oldTurn.failed_start_threshold_ms ?? null;
|
|
187
|
+
const nextSession = applyGhostRetryAttempt(session, {
|
|
188
|
+
runId,
|
|
189
|
+
oldTurnId,
|
|
190
|
+
newTurnId: reissued.newTurn.turn_id,
|
|
191
|
+
failureType: decision.ghost.failure_type,
|
|
192
|
+
maxRetries: decision.maxRetries,
|
|
193
|
+
nowIso,
|
|
194
|
+
runtimeId: oldRuntimeId,
|
|
195
|
+
roleId: oldRoleId,
|
|
196
|
+
runningMs: oldRunningMs,
|
|
197
|
+
thresholdMs: oldThresholdMs,
|
|
198
|
+
});
|
|
199
|
+
Object.assign(session, nextSession, {
|
|
200
|
+
status: 'running',
|
|
201
|
+
current_run_id: runId,
|
|
202
|
+
});
|
|
203
|
+
writeContinuousSession(root, session);
|
|
204
|
+
|
|
205
|
+
emitRunEvent(root, 'auto_retried_ghost', {
|
|
206
|
+
run_id: runId,
|
|
207
|
+
phase: nextState.phase || blockedState?.phase || null,
|
|
208
|
+
status: 'active',
|
|
209
|
+
turn: { turn_id: reissued.newTurn.turn_id, role_id: reissued.newTurn.assigned_role },
|
|
210
|
+
intent_id: oldTurn.intake_context?.intent_id || null,
|
|
211
|
+
payload: {
|
|
212
|
+
old_turn_id: oldTurnId,
|
|
213
|
+
new_turn_id: reissued.newTurn.turn_id,
|
|
214
|
+
failure_type: decision.ghost.failure_type,
|
|
215
|
+
attempt,
|
|
216
|
+
max_retries_per_run: decision.maxRetries,
|
|
217
|
+
runtime_id: oldTurn.runtime_id || reissued.newTurn.runtime_id || null,
|
|
218
|
+
running_ms: oldTurn.failed_start_running_ms ?? null,
|
|
219
|
+
threshold_ms: oldTurn.failed_start_threshold_ms ?? null,
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
log(`Ghost turn auto-retried (${attempt}/${decision.maxRetries}): ${oldTurnId} -> ${reissued.newTurn.turn_id}`);
|
|
224
|
+
if ((contOpts.autoRetryOnGhost?.cooldownSeconds ?? 0) > 0) {
|
|
225
|
+
await new Promise((resolve) => setTimeout(resolve, contOpts.autoRetryOnGhost.cooldownSeconds * 1000));
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
ok: true,
|
|
229
|
+
status: 'running',
|
|
230
|
+
action: 'auto_retried_ghost',
|
|
231
|
+
run_id: runId,
|
|
232
|
+
old_turn_id: oldTurnId,
|
|
233
|
+
new_turn_id: reissued.newTurn.turn_id,
|
|
234
|
+
attempt,
|
|
235
|
+
max_retries_per_run: decision.maxRetries,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (decision.decision === 'exhausted') {
|
|
240
|
+
const runId = session.current_run_id || blockedState?.run_id || null;
|
|
241
|
+
const oldTurnId = decision.ghost.turn_id;
|
|
242
|
+
const oldTurn = blockedState?.active_turns?.[oldTurnId] || {};
|
|
243
|
+
const manualDetail = blockedState?.blocked_reason?.recovery?.detail
|
|
244
|
+
|| blockedState?.blocked_reason?.recovery?.recovery_action
|
|
245
|
+
|| null;
|
|
246
|
+
// Slice 2c: build the per-attempt diagnostic bundle from the session's
|
|
247
|
+
// recorded attempts_log. This is the payload the operator needs to
|
|
248
|
+
// decide their next move (bump retries, change runtime, raise watchdog,
|
|
249
|
+
// or file a new bug). Also pass signatureRepeat into the mirror so the
|
|
250
|
+
// status surface distinguishes raw exhaustion from pattern-based early
|
|
251
|
+
// stop.
|
|
252
|
+
const diagnosticBundle = buildGhostRetryDiagnosticBundle(session);
|
|
253
|
+
const signatureRepeat = decision.signatureRepeat || null;
|
|
254
|
+
const detail = buildGhostRetryExhaustionMirror({
|
|
255
|
+
attempts: decision.attempts,
|
|
256
|
+
maxRetries: decision.maxRetries,
|
|
257
|
+
failureType: decision.ghost.failure_type,
|
|
258
|
+
manualRecoveryDetail: manualDetail,
|
|
259
|
+
signatureRepeat,
|
|
260
|
+
});
|
|
261
|
+
const nextState = {
|
|
262
|
+
...blockedState,
|
|
263
|
+
blocked_reason: {
|
|
264
|
+
...(blockedState.blocked_reason || {}),
|
|
265
|
+
recovery: {
|
|
266
|
+
...(blockedState.blocked_reason?.recovery || {}),
|
|
267
|
+
detail,
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
writeGovernedState(root, nextState);
|
|
272
|
+
const nextSession = applyGhostRetryExhaustion(session, {
|
|
273
|
+
runId,
|
|
274
|
+
failureType: decision.ghost.failure_type,
|
|
275
|
+
turnId: oldTurnId,
|
|
276
|
+
maxRetries: decision.maxRetries,
|
|
277
|
+
nowIso: new Date().toISOString(),
|
|
278
|
+
});
|
|
279
|
+
Object.assign(session, nextSession, { status: 'paused' });
|
|
280
|
+
writeContinuousSession(root, session);
|
|
281
|
+
|
|
282
|
+
emitRunEvent(root, 'ghost_retry_exhausted', {
|
|
283
|
+
run_id: runId,
|
|
284
|
+
phase: blockedState?.phase || null,
|
|
285
|
+
status: 'blocked',
|
|
286
|
+
turn: { turn_id: oldTurnId, role_id: oldTurn.assigned_role || null },
|
|
287
|
+
intent_id: oldTurn.intake_context?.intent_id || null,
|
|
288
|
+
payload: {
|
|
289
|
+
turn_id: oldTurnId,
|
|
290
|
+
attempts: decision.attempts,
|
|
291
|
+
max_retries_per_run: decision.maxRetries,
|
|
292
|
+
failure_type: decision.ghost.failure_type,
|
|
293
|
+
runtime_id: oldTurn.runtime_id || null,
|
|
294
|
+
exhaustion_reason: signatureRepeat ? 'same_signature_repeat' : 'retry_budget_exhausted',
|
|
295
|
+
signature_repeat: signatureRepeat,
|
|
296
|
+
diagnostic_bundle: diagnosticBundle,
|
|
297
|
+
diagnostic_refs: {
|
|
298
|
+
recovery_action: blockedState?.blocked_reason?.recovery?.recovery_action || null,
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
const tag = signatureRepeat
|
|
303
|
+
? `same_signature_repeat [${signatureRepeat.signature}] after ${signatureRepeat.consecutive} attempts`
|
|
304
|
+
: `${decision.attempts}/${decision.maxRetries}`;
|
|
305
|
+
log(`Ghost auto-retry exhausted (${tag}) for ${oldTurnId}.`);
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
|
|
130
312
|
// ---------------------------------------------------------------------------
|
|
131
313
|
// Intake queue check
|
|
132
314
|
// ---------------------------------------------------------------------------
|
|
@@ -155,6 +337,99 @@ export function findNextQueuedIntent(root, options = {}) {
|
|
|
155
337
|
return findNextDispatchableIntent(root, { run_id: options.run_id || null });
|
|
156
338
|
}
|
|
157
339
|
|
|
340
|
+
/**
|
|
341
|
+
* BUG-62 slice 2: when `run_loop.continuous.reconcile_operator_commits` is
|
|
342
|
+
* `auto_safe_only`, the continuous loop consults the session-checkpoint /
|
|
343
|
+
* governed-state baseline vs current git HEAD before dispatch. If operator
|
|
344
|
+
* commits landed on top of the baseline and the Turn 184 safety primitive
|
|
345
|
+
* accepts them, the baseline is auto-rolled forward so the next dispatch
|
|
346
|
+
* proceeds without manual `agentxchain reconcile-state` intervention. If the
|
|
347
|
+
* safety primitive refuses the commits (governed-state edits or history
|
|
348
|
+
* rewrite), the continuous loop pauses with the refusal class mirrored into
|
|
349
|
+
* `blocked_reason.recovery.detail`, preserving the manual primitive as the
|
|
350
|
+
* operator's single audited safety function per the BUG-62 spec.
|
|
351
|
+
*/
|
|
352
|
+
export function maybeAutoReconcileOperatorCommits(context, session, contOpts, log = console.log) {
|
|
353
|
+
const mode = contOpts.reconcileOperatorCommits || 'manual';
|
|
354
|
+
if (mode !== 'auto_safe_only') {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
const { root } = context;
|
|
358
|
+
const state = loadProjectState(root, context.config);
|
|
359
|
+
const continuity = getContinuityStatus(root, state);
|
|
360
|
+
if (!continuity || continuity.drift_detected !== true) {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const result = reconcileOperatorHead(root, { safetyMode: 'auto_safe_only' });
|
|
365
|
+
if (result.ok) {
|
|
366
|
+
if (result.no_op) {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
const acceptedCount = result.accepted_commits?.length || 0;
|
|
370
|
+
log(
|
|
371
|
+
`Operator-commit auto-reconcile accepted ${acceptedCount} commit${acceptedCount === 1 ? '' : 's'} `
|
|
372
|
+
+ `(${result.previous_baseline.slice(0, 8)} -> ${result.accepted_head.slice(0, 8)}).`
|
|
373
|
+
);
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const errorClass = result.error_class || 'reconcile_refused';
|
|
378
|
+
const detailLines = [
|
|
379
|
+
`Operator-commit auto-reconcile refused (${errorClass}).`,
|
|
380
|
+
result.error || 'Unsafe operator commits detected; manual recovery required.',
|
|
381
|
+
'Run: agentxchain reconcile-state --accept-operator-head once the unsafe changes are resolved, or revert them.',
|
|
382
|
+
];
|
|
383
|
+
const detail = detailLines.join(' ');
|
|
384
|
+
|
|
385
|
+
if (state) {
|
|
386
|
+
const nextState = {
|
|
387
|
+
...state,
|
|
388
|
+
status: 'blocked',
|
|
389
|
+
blocked_on: state.blocked_on || 'operator_commit_reconcile_refused',
|
|
390
|
+
blocked_reason: {
|
|
391
|
+
...(state.blocked_reason || {}),
|
|
392
|
+
category: 'operator_commit_reconcile_refused',
|
|
393
|
+
error_class: errorClass,
|
|
394
|
+
recovery: {
|
|
395
|
+
...((state.blocked_reason || {}).recovery || {}),
|
|
396
|
+
recovery_action: 'agentxchain reconcile-state --accept-operator-head',
|
|
397
|
+
detail,
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
safeWriteJson(join(root, '.agentxchain', 'state.json'), nextState);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
emitRunEvent(root, 'operator_commit_reconcile_refused', {
|
|
405
|
+
run_id: state?.run_id || session.current_run_id || null,
|
|
406
|
+
phase: state?.phase || state?.current_phase || null,
|
|
407
|
+
status: 'blocked',
|
|
408
|
+
payload: {
|
|
409
|
+
error_class: errorClass,
|
|
410
|
+
message: result.error || null,
|
|
411
|
+
previous_baseline: result.previous_baseline || null,
|
|
412
|
+
current_head: result.current_head || null,
|
|
413
|
+
offending_commit: result.offending_commit || null,
|
|
414
|
+
offending_path: result.offending_path || null,
|
|
415
|
+
safety_mode: 'auto_safe_only',
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
session.status = 'paused';
|
|
420
|
+
writeContinuousSession(root, session);
|
|
421
|
+
log(detail);
|
|
422
|
+
return {
|
|
423
|
+
ok: true,
|
|
424
|
+
status: 'blocked',
|
|
425
|
+
action: 'operator_commit_reconcile_refused',
|
|
426
|
+
run_id: session.current_run_id,
|
|
427
|
+
recovery_action: 'agentxchain reconcile-state --accept-operator-head',
|
|
428
|
+
blocked_category: 'operator_commit_reconcile_refused',
|
|
429
|
+
error_class: errorClass,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
158
433
|
function reconcileContinuousStartupState(context, session, contOpts, log) {
|
|
159
434
|
const { root, config } = context;
|
|
160
435
|
const governedState = loadProjectState(root, config);
|
|
@@ -301,6 +576,25 @@ export function seedFromVision(root, visionPath, options = {}) {
|
|
|
301
576
|
|
|
302
577
|
export function resolveContinuousOptions(opts, config) {
|
|
303
578
|
const configCont = config?.run_loop?.continuous || {};
|
|
579
|
+
const configGhostRetry = configCont.auto_retry_on_ghost || {};
|
|
580
|
+
const explicitConfigGhostEnabled = Object.prototype.hasOwnProperty.call(configGhostRetry, 'enabled');
|
|
581
|
+
const fullAuto = Boolean((opts.continuous ?? configCont.enabled ?? false) && isFullAutoApprovalPolicy(config));
|
|
582
|
+
const fullAutoGhostDefault = fullAuto;
|
|
583
|
+
const resolvedGhostEnabled = opts.autoRetryOnGhost
|
|
584
|
+
?? (explicitConfigGhostEnabled ? configGhostRetry.enabled : fullAutoGhostDefault);
|
|
585
|
+
|
|
586
|
+
const validReconcileModes = new Set(['manual', 'auto_safe_only', 'disabled']);
|
|
587
|
+
const configuredReconcile = typeof configCont.reconcile_operator_commits === 'string'
|
|
588
|
+
&& validReconcileModes.has(configCont.reconcile_operator_commits)
|
|
589
|
+
? configCont.reconcile_operator_commits
|
|
590
|
+
: null;
|
|
591
|
+
const cliReconcile = typeof opts.reconcileOperatorCommits === 'string'
|
|
592
|
+
&& validReconcileModes.has(opts.reconcileOperatorCommits)
|
|
593
|
+
? opts.reconcileOperatorCommits
|
|
594
|
+
: null;
|
|
595
|
+
const reconcileOperatorCommits = cliReconcile
|
|
596
|
+
?? configuredReconcile
|
|
597
|
+
?? (fullAuto ? 'auto_safe_only' : 'manual');
|
|
304
598
|
|
|
305
599
|
return {
|
|
306
600
|
enabled: opts.continuous ?? configCont.enabled ?? false,
|
|
@@ -313,9 +607,26 @@ export function resolveContinuousOptions(opts, config) {
|
|
|
313
607
|
cooldownSeconds: opts.cooldownSeconds ?? configCont.cooldown_seconds ?? 5,
|
|
314
608
|
perSessionMaxUsd: opts.sessionBudget ?? configCont.per_session_max_usd ?? null,
|
|
315
609
|
autoCheckpoint: opts.autoCheckpoint ?? configCont.auto_checkpoint ?? true,
|
|
610
|
+
autoRetryOnGhost: {
|
|
611
|
+
enabled: resolvedGhostEnabled ?? false,
|
|
612
|
+
maxRetriesPerRun: opts.autoRetryOnGhostMaxRetries
|
|
613
|
+
?? configGhostRetry.max_retries_per_run
|
|
614
|
+
?? 3,
|
|
615
|
+
cooldownSeconds: opts.autoRetryOnGhostCooldownSeconds
|
|
616
|
+
?? configGhostRetry.cooldown_seconds
|
|
617
|
+
?? 5,
|
|
618
|
+
},
|
|
619
|
+
reconcileOperatorCommits,
|
|
316
620
|
};
|
|
317
621
|
}
|
|
318
622
|
|
|
623
|
+
export function isFullAutoApprovalPolicy(config) {
|
|
624
|
+
const policy = config?.approval_policy;
|
|
625
|
+
if (!policy || typeof policy !== 'object') return false;
|
|
626
|
+
return policy.phase_transitions?.default === 'auto_approve'
|
|
627
|
+
&& policy.run_completion?.action === 'auto_approve';
|
|
628
|
+
}
|
|
629
|
+
|
|
319
630
|
// ---------------------------------------------------------------------------
|
|
320
631
|
// Single-step continuous advancement primitive
|
|
321
632
|
// ---------------------------------------------------------------------------
|
|
@@ -363,6 +674,9 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
|
|
|
363
674
|
|
|
364
675
|
reconcileContinuousStartupState(context, session, contOpts, log);
|
|
365
676
|
|
|
677
|
+
const reconcileBlock = maybeAutoReconcileOperatorCommits(context, session, contOpts, log);
|
|
678
|
+
if (reconcileBlock) return reconcileBlock;
|
|
679
|
+
|
|
366
680
|
// Paused-session guard: if session is paused (blocked run awaiting unblock),
|
|
367
681
|
// check governed state before attempting to advance. Without this guard, the
|
|
368
682
|
// loop would try to startIntent() on a blocked project, hit the blocked-state
|
|
@@ -370,6 +684,8 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
|
|
|
370
684
|
if (session.status === 'paused') {
|
|
371
685
|
const governedState = loadProjectState(root, context.config);
|
|
372
686
|
if (governedState?.status === 'blocked') {
|
|
687
|
+
const retried = await maybeAutoRetryGhostBlocker(context, session, contOpts, governedState, log);
|
|
688
|
+
if (retried) return retried;
|
|
373
689
|
// Still blocked — stay paused, do not attempt new work
|
|
374
690
|
writeContinuousSession(root, session);
|
|
375
691
|
return {
|
|
@@ -406,7 +722,10 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
|
|
|
406
722
|
const resumeStopReason = execution.result?.stop_reason;
|
|
407
723
|
|
|
408
724
|
if (isBlockedContinuousExecution(execution)) {
|
|
409
|
-
const
|
|
725
|
+
const blockedState = execution?.result?.state || loadProjectState(root, context.config);
|
|
726
|
+
const retried = await maybeAutoRetryGhostBlocker(context, session, contOpts, blockedState, log);
|
|
727
|
+
if (retried) return retried;
|
|
728
|
+
const blockedRecoveryAction = getBlockedRecoveryAction(blockedState);
|
|
410
729
|
session.status = 'paused';
|
|
411
730
|
log(blockedRecoveryAction
|
|
412
731
|
? `Resumed run blocked again — continuous loop re-paused. Recovery: ${blockedRecoveryAction}`
|
|
@@ -418,7 +737,7 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
|
|
|
418
737
|
action: 'run_blocked',
|
|
419
738
|
run_id: session.current_run_id,
|
|
420
739
|
recovery_action: blockedRecoveryAction,
|
|
421
|
-
blocked_category: getBlockedCategory(
|
|
740
|
+
blocked_category: getBlockedCategory(blockedState),
|
|
422
741
|
};
|
|
423
742
|
}
|
|
424
743
|
|
|
@@ -435,6 +754,64 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
|
|
|
435
754
|
return { ok: true, status: 'running', action: 'resumed_after_unblock', run_id: session.current_run_id };
|
|
436
755
|
}
|
|
437
756
|
|
|
757
|
+
const activeGovernedState = loadProjectState(root, context.config);
|
|
758
|
+
if (
|
|
759
|
+
session.current_run_id
|
|
760
|
+
&& activeGovernedState?.status === 'active'
|
|
761
|
+
&& activeGovernedState.run_id === session.current_run_id
|
|
762
|
+
&& Object.keys(activeGovernedState.active_turns || {}).length > 0
|
|
763
|
+
) {
|
|
764
|
+
log('Continuing active governed run.');
|
|
765
|
+
let execution;
|
|
766
|
+
try {
|
|
767
|
+
execution = await executeGovernedRun(context, {
|
|
768
|
+
autoApprove: true,
|
|
769
|
+
autoCheckpoint: contOpts.autoCheckpoint,
|
|
770
|
+
report: true,
|
|
771
|
+
log,
|
|
772
|
+
});
|
|
773
|
+
} catch (err) {
|
|
774
|
+
session.status = 'failed';
|
|
775
|
+
writeContinuousSession(root, session);
|
|
776
|
+
return { ok: false, status: 'failed', action: 'run_failed', stop_reason: err.message, run_id: session.current_run_id };
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
session.cumulative_spent_usd = (session.cumulative_spent_usd || 0) + getExecutionRunSpentUsd(execution);
|
|
780
|
+
const resumeStopReason = execution.result?.stop_reason;
|
|
781
|
+
|
|
782
|
+
if (isBlockedContinuousExecution(execution)) {
|
|
783
|
+
const blockedState = execution?.result?.state || loadProjectState(root, context.config);
|
|
784
|
+
const retried = await maybeAutoRetryGhostBlocker(context, session, contOpts, blockedState, log);
|
|
785
|
+
if (retried) return retried;
|
|
786
|
+
const blockedRecoveryAction = getBlockedRecoveryAction(blockedState);
|
|
787
|
+
session.status = 'paused';
|
|
788
|
+
log(blockedRecoveryAction
|
|
789
|
+
? `Active run blocked — continuous loop paused. Recovery: ${blockedRecoveryAction}`
|
|
790
|
+
: 'Active run blocked — continuous loop paused.');
|
|
791
|
+
writeContinuousSession(root, session);
|
|
792
|
+
return {
|
|
793
|
+
ok: true,
|
|
794
|
+
status: 'blocked',
|
|
795
|
+
action: 'run_blocked',
|
|
796
|
+
run_id: session.current_run_id,
|
|
797
|
+
recovery_action: blockedRecoveryAction,
|
|
798
|
+
blocked_category: getBlockedCategory(blockedState),
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
if (execution.exitCode !== 0 || !execution.result) {
|
|
803
|
+
session.status = 'failed';
|
|
804
|
+
writeContinuousSession(root, session);
|
|
805
|
+
return { ok: false, status: 'failed', action: 'run_failed', stop_reason: resumeStopReason || `exit_code_${execution.exitCode}`, run_id: session.current_run_id };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
session.runs_completed += 1;
|
|
809
|
+
session.current_run_id = execution.result?.state?.run_id || session.current_run_id;
|
|
810
|
+
log(`Active run completed (${session.runs_completed}/${contOpts.maxRuns}): ${resumeStopReason || 'completed'}`);
|
|
811
|
+
writeContinuousSession(root, session);
|
|
812
|
+
return { ok: true, status: 'running', action: 'continued_active_run', run_id: session.current_run_id };
|
|
813
|
+
}
|
|
814
|
+
|
|
438
815
|
// Validate vision file
|
|
439
816
|
if (!existsSync(absVisionPath)) {
|
|
440
817
|
session.status = 'failed';
|
|
@@ -573,7 +950,10 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
|
|
|
573
950
|
}
|
|
574
951
|
|
|
575
952
|
if (isBlockedContinuousExecution(execution)) {
|
|
576
|
-
const
|
|
953
|
+
const blockedState = execution?.result?.state || loadProjectState(root, context.config);
|
|
954
|
+
const retried = await maybeAutoRetryGhostBlocker(context, session, contOpts, blockedState, log);
|
|
955
|
+
if (retried) return retried;
|
|
956
|
+
const blockedRecoveryAction = getBlockedRecoveryAction(blockedState);
|
|
577
957
|
const resolved = resolveIntent(root, targetIntentId);
|
|
578
958
|
if (!resolved.ok) {
|
|
579
959
|
log(`Continuous resolve error: ${resolved.error}`);
|
|
@@ -593,7 +973,7 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
|
|
|
593
973
|
run_id: session.current_run_id,
|
|
594
974
|
intent_id: targetIntentId,
|
|
595
975
|
recovery_action: blockedRecoveryAction,
|
|
596
|
-
blocked_category: getBlockedCategory(
|
|
976
|
+
blocked_category: getBlockedCategory(blockedState),
|
|
597
977
|
};
|
|
598
978
|
}
|
|
599
979
|
|