agentxchain 2.20.0 → 2.22.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.20.0",
3
+ "version": "2.22.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -114,8 +114,31 @@ else
114
114
  fi
115
115
  TEST_PASS="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^# pass / { print $3 }')"
116
116
  TEST_FAIL="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^# fail / { print $3 }')"
117
+ if [ -z "${TEST_PASS:-}" ]; then
118
+ VITEST_PASS="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^[[:space:]]*Tests[[:space:]]+[0-9]+[[:space:]]+passed/ { for (i = 1; i <= NF; i++) if ($i ~ /^[0-9]+$/) { print $i; exit } }')"
119
+ NODE_PASS="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^ℹ tests / { print $3; exit }')"
120
+ if [ -n "${VITEST_PASS:-}" ] && [ -n "${NODE_PASS:-}" ]; then
121
+ TEST_PASS="$((VITEST_PASS + NODE_PASS))"
122
+ elif [ -n "${NODE_PASS:-}" ]; then
123
+ TEST_PASS="${NODE_PASS}"
124
+ elif [ -n "${VITEST_PASS:-}" ]; then
125
+ TEST_PASS="${VITEST_PASS}"
126
+ fi
127
+ fi
128
+ if [ -z "${TEST_FAIL:-}" ]; then
129
+ NODE_FAIL="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^ℹ fail / { print $3; exit }')"
130
+ if [ -n "${NODE_FAIL:-}" ]; then
131
+ TEST_FAIL="${NODE_FAIL}"
132
+ elif printf '%s\n' "$TEST_OUTPUT" | grep -Eq '^[[:space:]]*Tests[[:space:]]+[0-9]+[[:space:]]+passed'; then
133
+ TEST_FAIL=0
134
+ fi
135
+ fi
117
136
  if [ "$TEST_STATUS" -eq 0 ] && [ "${TEST_FAIL:-0}" = "0" ]; then
118
- pass "${TEST_PASS} tests passed, 0 failures"
137
+ if [ -n "${TEST_PASS:-}" ]; then
138
+ pass "${TEST_PASS} tests passed, 0 failures"
139
+ else
140
+ pass "npm test passed, 0 failures"
141
+ fi
119
142
  else
120
143
  fail "npm test failed"
121
144
  printf '%s\n' "$TEST_OUTPUT" | tail -20
@@ -605,6 +605,7 @@ All acceptance criteria met. OBJ-002 (clock skew) noted for follow-up. OBJ-003 (
605
605
  console.log(chalk.dim(' ─'.repeat(26)));
606
606
  console.log('');
607
607
  console.log(` ${chalk.bold('Try it for real:')} agentxchain init --governed`);
608
+ console.log(` ${chalk.bold('Step by step:')} https://agentxchain.dev/docs/first-turn`);
608
609
  console.log(` ${chalk.bold('Read more:')} https://agentxchain.dev/docs/quickstart`);
609
610
  console.log('');
610
611
  }
@@ -35,7 +35,7 @@ export async function escalateCommand(opts) {
35
35
  process.exit(1);
36
36
  }
37
37
 
38
- const recovery = deriveRecoveryDescriptor(result.state);
38
+ const recovery = deriveRecoveryDescriptor(result.state, config);
39
39
 
40
40
  console.log('');
41
41
  console.log(chalk.yellow(' Run Escalated'));
@@ -168,7 +168,7 @@ function renderGovernedStatus(context, opts) {
168
168
  if (state?.blocked_on) {
169
169
  console.log('');
170
170
  if (state.status === 'blocked') {
171
- const recovery = deriveRecoveryDescriptor(state);
171
+ const recovery = deriveRecoveryDescriptor(state, config);
172
172
  const detail = recovery?.detail || state.blocked_on;
173
173
  console.log(` ${chalk.dim('Blocked:')} ${chalk.red.bold('BLOCKED')} — ${detail}`);
174
174
  } else if (state.blocked_on.startsWith('human_approval:')) {
@@ -179,7 +179,7 @@ function renderGovernedStatus(context, opts) {
179
179
  }
180
180
  }
181
181
 
182
- const recovery = deriveRecoveryDescriptor(state);
182
+ const recovery = deriveRecoveryDescriptor(state, config);
183
183
  if (recovery) {
184
184
  console.log('');
185
185
  console.log(` ${chalk.dim('Reason:')} ${recovery.typed_reason}`);
@@ -48,9 +48,19 @@ const PROVIDER_ENDPOINTS = {
48
48
 
49
49
  // Cost rates per million tokens (USD)
50
50
  const COST_RATES = {
51
+ // Anthropic
51
52
  'claude-sonnet-4-6': { input_per_1m: 3.00, output_per_1m: 15.00 },
52
53
  'claude-opus-4-6': { input_per_1m: 15.00, output_per_1m: 75.00 },
53
54
  'claude-haiku-4-5-20251001': { input_per_1m: 0.80, output_per_1m: 4.00 },
55
+ // OpenAI
56
+ 'gpt-4o': { input_per_1m: 2.50, output_per_1m: 10.00 },
57
+ 'gpt-4o-mini': { input_per_1m: 0.15, output_per_1m: 0.60 },
58
+ 'gpt-4.1': { input_per_1m: 2.00, output_per_1m: 8.00 },
59
+ 'gpt-4.1-mini': { input_per_1m: 0.40, output_per_1m: 1.60 },
60
+ 'gpt-4.1-nano': { input_per_1m: 0.10, output_per_1m: 0.40 },
61
+ 'o3': { input_per_1m: 2.00, output_per_1m: 8.00 },
62
+ 'o3-mini': { input_per_1m: 1.10, output_per_1m: 4.40 },
63
+ 'o4-mini': { input_per_1m: 1.10, output_per_1m: 4.40 },
54
64
  };
55
65
 
56
66
  const RETRYABLE_ERROR_CLASSES = [
@@ -1,6 +1,30 @@
1
- import { getActiveTurnCount } from './governed-state.js';
1
+ import { deriveEscalationRecoveryAction, getActiveTurnCount } from './governed-state.js';
2
2
 
3
- export function deriveRecoveryDescriptor(state) {
3
+ function isLegacyEscalationRecoveryAction(action) {
4
+ return action === 'Resolve the escalation, then run agentxchain step --resume'
5
+ || action === 'Resolve the escalation, then run agentxchain step';
6
+ }
7
+
8
+ function maybeRefreshEscalationAction(state, config, persistedRecovery, turnRetained) {
9
+ if (!config || !persistedRecovery || typeof persistedRecovery !== 'object') {
10
+ return null;
11
+ }
12
+
13
+ const typedReason = persistedRecovery.typed_reason;
14
+ const currentAction = persistedRecovery.recovery_action || null;
15
+ const shouldRefresh = typedReason === 'retries_exhausted'
16
+ || ((typedReason === 'operator_escalation') && isLegacyEscalationRecoveryAction(currentAction));
17
+ if (!shouldRefresh) {
18
+ return null;
19
+ }
20
+
21
+ return deriveEscalationRecoveryAction(state, config, {
22
+ turnRetained,
23
+ turnId: state?.blocked_reason?.turn_id ?? state?.escalation?.from_turn_id ?? null,
24
+ });
25
+ }
26
+
27
+ export function deriveRecoveryDescriptor(state, config = null) {
4
28
  if (!state || typeof state !== 'object') {
5
29
  return null;
6
30
  }
@@ -29,10 +53,13 @@ export function deriveRecoveryDescriptor(state) {
29
53
 
30
54
  const persistedRecovery = state.blocked_reason?.recovery;
31
55
  if (persistedRecovery && typeof persistedRecovery === 'object') {
56
+ const refreshedEscalationAction = maybeRefreshEscalationAction(state, config, persistedRecovery, turnRetained);
32
57
  return {
33
58
  typed_reason: persistedRecovery.typed_reason || 'unknown_block',
34
59
  owner: persistedRecovery.owner || 'human',
35
- recovery_action: persistedRecovery.recovery_action || 'Inspect state.json and resolve manually before rerunning agentxchain step',
60
+ recovery_action: refreshedEscalationAction
61
+ || persistedRecovery.recovery_action
62
+ || 'Inspect state.json and resolve manually before rerunning agentxchain step',
36
63
  turn_retained: typeof persistedRecovery.turn_retained === 'boolean'
37
64
  ? persistedRecovery.turn_retained
38
65
  : turnRetained,
@@ -66,9 +93,10 @@ export function deriveRecoveryDescriptor(state) {
66
93
 
67
94
  if (state.blocked_on.startsWith('escalation:')) {
68
95
  const isOperatorEscalation = state.blocked_on.startsWith('escalation:operator:') || state.escalation?.source === 'operator';
69
- const recoveryAction = turnRetained
70
- ? 'Resolve the escalation, then run agentxchain step --resume'
71
- : 'Resolve the escalation, then run agentxchain step';
96
+ const recoveryAction = deriveEscalationRecoveryAction(state, config, {
97
+ turnRetained,
98
+ turnId: state.blocked_reason?.turn_id ?? state.escalation?.from_turn_id ?? null,
99
+ });
72
100
  return {
73
101
  typed_reason: isOperatorEscalation ? 'operator_escalation' : 'retries_exhausted',
74
102
  owner: 'human',
package/src/lib/config.js CHANGED
@@ -3,7 +3,12 @@ import { join, parse as pathParse, resolve } from 'path';
3
3
  import { safeParseJson, validateConfigSchema, validateLockSchema, validateProjectStateSchema, validateStateSchema } from './schema.js';
4
4
  import { loadNormalizedConfig } from './normalized-config.js';
5
5
  import { safeWriteJson } from './safe-write.js';
6
- import { normalizeGovernedStateShape, getActiveTurn } from './governed-state.js';
6
+ import {
7
+ normalizeGovernedStateShape,
8
+ getActiveTurn,
9
+ reconcileBudgetStatusWithConfig,
10
+ reconcileEscalationRecoveryWithConfig,
11
+ } from './governed-state.js';
7
12
 
8
13
  function attachLegacyCurrentTurnAlias(state) {
9
14
  if (!state || typeof state !== 'object') {
@@ -148,7 +153,11 @@ export function loadProjectState(root, config) {
148
153
  if (config?.protocol_mode === 'governed') {
149
154
  const normalized = normalizeGovernedStateShape(stateData);
150
155
  stateData = normalized.state;
151
- if (normalized.changed) {
156
+ const reconciledBudget = reconcileBudgetStatusWithConfig(stateData, config);
157
+ stateData = reconciledBudget.state;
158
+ const reconciledRecovery = reconcileEscalationRecoveryWithConfig(stateData, config);
159
+ stateData = reconciledRecovery.state;
160
+ if (normalized.changed || reconciledBudget.changed || reconciledRecovery.changed) {
152
161
  safeWriteJson(filePath, stateData);
153
162
  }
154
163
  }
@@ -190,6 +190,68 @@ export function getActiveTurn(state) {
190
190
  return turns.length === 1 ? turns[0] : null;
191
191
  }
192
192
 
193
+ function resolveRecoveryTurnId(state, preferredTurnId = null) {
194
+ const activeTurns = getActiveTurns(state);
195
+ if (preferredTurnId && activeTurns[preferredTurnId]) {
196
+ return preferredTurnId;
197
+ }
198
+
199
+ const blockedTurnId = state?.blocked_reason?.turn_id;
200
+ if (blockedTurnId && activeTurns[blockedTurnId]) {
201
+ return blockedTurnId;
202
+ }
203
+
204
+ const escalationTurnId = state?.escalation?.from_turn_id;
205
+ if (escalationTurnId && activeTurns[escalationTurnId]) {
206
+ return escalationTurnId;
207
+ }
208
+
209
+ const turnIds = Object.keys(activeTurns);
210
+ return turnIds.length === 1 ? turnIds[0] : null;
211
+ }
212
+
213
+ export function deriveRetainedTurnRecoveryCommand(state, config, options = {}) {
214
+ const turnId = resolveRecoveryTurnId(state, options.turnId);
215
+ if (!turnId) {
216
+ return options.fallbackCommand || 'agentxchain step --resume';
217
+ }
218
+
219
+ const turn = getActiveTurns(state)[turnId];
220
+ const runtimeType = config?.runtimes?.[turn?.runtime_id]?.type || 'manual';
221
+ let command = runtimeType === 'manual'
222
+ ? (options.manualCommand || 'agentxchain resume')
223
+ : (options.automatedCommand || 'agentxchain step --resume');
224
+
225
+ if (getActiveTurnCount(state) > 1) {
226
+ command += ` --turn ${turnId}`;
227
+ }
228
+
229
+ return command;
230
+ }
231
+
232
+ function isLegacyEscalationRecoveryAction(action) {
233
+ return action === 'Resolve the escalation, then run agentxchain step --resume'
234
+ || action === 'Resolve the escalation, then run agentxchain step';
235
+ }
236
+
237
+ export function deriveEscalationRecoveryAction(state, config, options = {}) {
238
+ if (typeof options.overrideAction === 'string' && options.overrideAction.trim()) {
239
+ return options.overrideAction.trim();
240
+ }
241
+
242
+ const turnRetained = typeof options.turnRetained === 'boolean'
243
+ ? options.turnRetained
244
+ : getActiveTurnCount(state) > 0;
245
+ const command = turnRetained
246
+ ? deriveRetainedTurnRecoveryCommand(state, config, {
247
+ turnId: options.turnId,
248
+ fallbackCommand: 'agentxchain step --resume',
249
+ })
250
+ : 'agentxchain resume';
251
+
252
+ return `Resolve the escalation, then run ${command}`;
253
+ }
254
+
193
255
  export function getActiveTurnOrThrow(state) {
194
256
  const turns = Object.values(getActiveTurns(state));
195
257
  if (turns.length === 0) {
@@ -222,6 +284,91 @@ function attachLegacyCurrentTurnAlias(state) {
222
284
  return state;
223
285
  }
224
286
 
287
+ function formatBudgetRecoveryAction(isReadyToResume) {
288
+ return isReadyToResume
289
+ ? 'Run agentxchain resume to assign the next turn'
290
+ : 'Increase per_run_max_usd in agentxchain.json, then run agentxchain resume';
291
+ }
292
+
293
+ function formatBudgetRecoveryDetail(spentUsd, limitUsd, remainingUsd, isReadyToResume) {
294
+ if (limitUsd == null) {
295
+ return isReadyToResume
296
+ ? `Budget recovery ready: spent $${spentUsd.toFixed(2)} with per_run_max_usd disabled`
297
+ : `Run budget exhausted: spent $${spentUsd.toFixed(2)} with no configured per_run_max_usd limit`;
298
+ }
299
+
300
+ if (isReadyToResume) {
301
+ return `Budget recovery ready: spent $${spentUsd.toFixed(2)} of $${limitUsd.toFixed(2)} limit ($${remainingUsd.toFixed(2)} remaining)`;
302
+ }
303
+
304
+ return `Run budget exhausted: spent $${spentUsd.toFixed(2)} of $${limitUsd.toFixed(2)} limit ($${Math.abs(remainingUsd).toFixed(2)} over)`;
305
+ }
306
+
307
+ export function reconcileBudgetStatusWithConfig(state, config) {
308
+ if (!state || typeof state !== 'object') {
309
+ return { state, changed: false };
310
+ }
311
+
312
+ const baseState = stripLegacyCurrentTurn(state);
313
+ const budgetStatus = baseState.budget_status && typeof baseState.budget_status === 'object' && !Array.isArray(baseState.budget_status)
314
+ ? baseState.budget_status
315
+ : {};
316
+ const spentUsd = Number.isFinite(budgetStatus.spent_usd) ? budgetStatus.spent_usd : 0;
317
+ const limitUsd = Number.isFinite(config?.budget?.per_run_max_usd) ? config.budget.per_run_max_usd : null;
318
+ const remainingUsd = limitUsd != null ? limitUsd - spentUsd : null;
319
+ const nextBudgetStatus = {
320
+ ...budgetStatus,
321
+ spent_usd: spentUsd,
322
+ remaining_usd: remainingUsd,
323
+ };
324
+
325
+ if (remainingUsd != null && remainingUsd <= 0) {
326
+ nextBudgetStatus.exhausted = true;
327
+ if (budgetStatus.exhausted_at) {
328
+ nextBudgetStatus.exhausted_at = budgetStatus.exhausted_at;
329
+ }
330
+ if (budgetStatus.exhausted_after_turn) {
331
+ nextBudgetStatus.exhausted_after_turn = budgetStatus.exhausted_after_turn;
332
+ }
333
+ } else {
334
+ delete nextBudgetStatus.exhausted;
335
+ delete nextBudgetStatus.exhausted_at;
336
+ delete nextBudgetStatus.exhausted_after_turn;
337
+ }
338
+
339
+ let nextState = {
340
+ ...baseState,
341
+ budget_status: nextBudgetStatus,
342
+ };
343
+
344
+ const isBudgetBlocked = nextState.blocked_on === 'budget:exhausted' || nextState.blocked_reason?.category === 'budget_exhausted';
345
+ if (isBudgetBlocked) {
346
+ const isReadyToResume = remainingUsd == null || remainingUsd > 0;
347
+ nextState = {
348
+ ...nextState,
349
+ blocked_on: 'budget:exhausted',
350
+ blocked_reason: buildBlockedReason({
351
+ category: 'budget_exhausted',
352
+ recovery: {
353
+ typed_reason: 'budget_exhausted',
354
+ owner: 'human',
355
+ recovery_action: formatBudgetRecoveryAction(isReadyToResume),
356
+ turn_retained: false,
357
+ detail: formatBudgetRecoveryDetail(spentUsd, limitUsd, remainingUsd, isReadyToResume),
358
+ },
359
+ turnId: nextState.blocked_reason?.turn_id ?? nextState.last_completed_turn_id ?? null,
360
+ blockedAt: nextState.blocked_reason?.blocked_at,
361
+ }),
362
+ };
363
+ }
364
+
365
+ const changed = JSON.stringify(baseState) !== JSON.stringify(nextState);
366
+ return {
367
+ state: changed ? nextState : baseState,
368
+ changed,
369
+ };
370
+ }
371
+
225
372
  function normalizeV1toV1_1(state) {
226
373
  const hadLegacyCurrentTurn = Object.prototype.hasOwnProperty.call(state, 'current_turn');
227
374
  const activeTurns = normalizeActiveTurns(state.active_turns);
@@ -789,6 +936,66 @@ function normalizeRecoveryDescriptor(recovery, turnRetained, detail) {
789
936
  };
790
937
  }
791
938
 
939
+ export function reconcileEscalationRecoveryWithConfig(state, config) {
940
+ if (!state || typeof state !== 'object' || state.status !== 'blocked' || !config) {
941
+ return { state, changed: false };
942
+ }
943
+
944
+ const recovery = state.blocked_reason?.recovery;
945
+ const typedReason = recovery?.typed_reason;
946
+ if (typedReason !== 'operator_escalation' && typedReason !== 'retries_exhausted') {
947
+ return { state, changed: false };
948
+ }
949
+
950
+ const currentAction = recovery?.recovery_action || null;
951
+ const shouldRefresh = typedReason === 'retries_exhausted' || isLegacyEscalationRecoveryAction(currentAction);
952
+ if (!shouldRefresh) {
953
+ return { state, changed: false };
954
+ }
955
+
956
+ const turnRetained = typeof recovery?.turn_retained === 'boolean'
957
+ ? recovery.turn_retained
958
+ : getActiveTurnCount(state) > 0;
959
+ const nextAction = deriveEscalationRecoveryAction(state, config, {
960
+ turnRetained,
961
+ turnId: state.blocked_reason?.turn_id ?? state.escalation?.from_turn_id ?? null,
962
+ });
963
+
964
+ let nextState = state;
965
+ let changed = false;
966
+
967
+ if (recovery && currentAction !== nextAction) {
968
+ nextState = {
969
+ ...nextState,
970
+ blocked_reason: {
971
+ ...nextState.blocked_reason,
972
+ recovery: {
973
+ ...recovery,
974
+ recovery_action: nextAction,
975
+ },
976
+ },
977
+ };
978
+ changed = true;
979
+ }
980
+
981
+ if (
982
+ nextState.escalation?.source === 'operator'
983
+ && isLegacyEscalationRecoveryAction(nextState.escalation.recovery_action)
984
+ && nextState.escalation.recovery_action !== nextAction
985
+ ) {
986
+ nextState = {
987
+ ...nextState,
988
+ escalation: {
989
+ ...nextState.escalation,
990
+ recovery_action: nextAction,
991
+ },
992
+ };
993
+ changed = true;
994
+ }
995
+
996
+ return { state: nextState, changed };
997
+ }
998
+
792
999
  function inferBlockedReasonFromState(state) {
793
1000
  if (!state || typeof state !== 'object') {
794
1001
  return null;
@@ -818,9 +1025,10 @@ function inferBlockedReasonFromState(state) {
818
1025
 
819
1026
  if (state.blocked_on.startsWith('escalation:')) {
820
1027
  const isOperatorEscalation = isOperatorEscalationBlockedOn(state.blocked_on) || state.escalation?.source === 'operator';
821
- const recoveryAction = turnRetained
822
- ? 'Resolve the escalation, then run agentxchain step --resume'
823
- : 'Resolve the escalation, then run agentxchain step';
1028
+ const recoveryAction = deriveEscalationRecoveryAction(state, null, {
1029
+ turnRetained,
1030
+ turnId: activeTurn?.turn_id ?? state.escalation?.from_turn_id ?? null,
1031
+ });
824
1032
  return buildBlockedReason({
825
1033
  category: isOperatorEscalation ? 'operator_escalation' : 'retries_exhausted',
826
1034
  recovery: {
@@ -1001,10 +1209,11 @@ export function raiseOperatorEscalation(root, config, details) {
1001
1209
  }
1002
1210
 
1003
1211
  const turnRetained = Boolean(targetTurn);
1004
- const recoveryAction = details.action
1005
- || (turnRetained
1006
- ? 'Resolve the escalation, then run agentxchain step --resume'
1007
- : 'Resolve the escalation, then run agentxchain step');
1212
+ const recoveryAction = deriveEscalationRecoveryAction(state, config, {
1213
+ turnRetained,
1214
+ turnId: targetTurn?.turn_id || null,
1215
+ overrideAction: details.action,
1216
+ });
1008
1217
  const detail = typeof details.detail === 'string' && details.detail.trim()
1009
1218
  ? details.detail.trim()
1010
1219
  : reason;
@@ -1161,11 +1370,17 @@ export function initializeGovernedRun(root, config) {
1161
1370
  * @returns {{ ok: boolean, error?: string, warnings?: string[], state?: object }}
1162
1371
  */
1163
1372
  export function assignGovernedTurn(root, config, roleId) {
1164
- const state = readState(root);
1373
+ let state = readState(root);
1165
1374
  if (!state) {
1166
1375
  return { ok: false, error: 'No governed state.json found' };
1167
1376
  }
1168
1377
 
1378
+ const reconciledBudget = reconcileBudgetStatusWithConfig(state, config);
1379
+ if (reconciledBudget.changed) {
1380
+ state = reconciledBudget.state;
1381
+ writeState(root, state);
1382
+ }
1383
+
1169
1384
  // DEC-PARALLEL-007: No new assignment while run is blocked
1170
1385
  if (state.status === 'blocked') {
1171
1386
  return { ok: false, error: 'Cannot assign turn: run is blocked. Resolve the blocked state before assigning new turns.' };
@@ -1206,6 +1421,11 @@ export function assignGovernedTurn(root, config, roleId) {
1206
1421
  return { ok: false, error: `Cannot assign turn: ${activeCount} active turn(s) already at capacity (max_concurrent_turns = ${maxConcurrent})` };
1207
1422
  }
1208
1423
 
1424
+ // DEC-BUDGET-ENFORCE-001: Pre-assignment budget exhaustion guard
1425
+ if (state.budget_status?.remaining_usd != null && state.budget_status.remaining_usd <= 0) {
1426
+ return { ok: false, error: `Cannot assign turn: run budget exhausted (spent $${(state.budget_status.spent_usd || 0).toFixed(2)} of $${((state.budget_status.spent_usd || 0) + state.budget_status.remaining_usd).toFixed(2)} limit). Increase per_run_max_usd in agentxchain.json, then run agentxchain resume` };
1427
+ }
1428
+
1209
1429
  // DEC-PARALLEL-011: Budget reservation
1210
1430
  const warnings = [];
1211
1431
  const reservations = { ...(state.budget_reservations || {}) };
@@ -1807,6 +2027,44 @@ function _acceptGovernedTurnLocked(root, config, opts) {
1807
2027
  });
1808
2028
  }
1809
2029
 
2030
+ // DEC-BUDGET-ENFORCE-001: Post-acceptance budget exhaustion check
2031
+ // Per-turn overrun warning (advisory only)
2032
+ let budgetWarning = null;
2033
+ const turnReservation = state.budget_reservations?.[currentTurn.turn_id];
2034
+ if (turnReservation && costUsd > turnReservation.reserved_usd) {
2035
+ budgetWarning = `Actual cost $${costUsd.toFixed(2)} exceeded reservation $${turnReservation.reserved_usd.toFixed(2)} for this turn`;
2036
+ }
2037
+ // Budget exhaustion enforcement
2038
+ if (
2039
+ updatedState.budget_status.remaining_usd != null &&
2040
+ updatedState.budget_status.remaining_usd <= 0 &&
2041
+ updatedState.status !== 'blocked' &&
2042
+ updatedState.status !== 'completed'
2043
+ ) {
2044
+ const onExceed = config.budget?.on_exceed || 'pause_and_escalate';
2045
+ if (onExceed === 'pause_and_escalate') {
2046
+ const limit = (updatedState.budget_status.spent_usd + updatedState.budget_status.remaining_usd);
2047
+ const overBy = Math.abs(updatedState.budget_status.remaining_usd);
2048
+ updatedState.status = 'blocked';
2049
+ updatedState.blocked_on = 'budget:exhausted';
2050
+ updatedState.blocked_reason = buildBlockedReason({
2051
+ category: 'budget_exhausted',
2052
+ recovery: {
2053
+ typed_reason: 'budget_exhausted',
2054
+ owner: 'human',
2055
+ recovery_action: 'Increase per_run_max_usd in agentxchain.json, then run agentxchain resume',
2056
+ turn_retained: false,
2057
+ detail: `Run budget exhausted: spent $${updatedState.budget_status.spent_usd.toFixed(2)} of $${limit.toFixed(2)} limit ($${overBy.toFixed(2)} over)`,
2058
+ },
2059
+ turnId: currentTurn.turn_id,
2060
+ blockedAt: now,
2061
+ });
2062
+ updatedState.budget_status.exhausted = true;
2063
+ updatedState.budget_status.exhausted_at = now;
2064
+ updatedState.budget_status.exhausted_after_turn = currentTurn.turn_id;
2065
+ }
2066
+ }
2067
+
1810
2068
  let gateResult = null;
1811
2069
  let completionResult = null;
1812
2070
  const hasRemainingTurns = Object.keys(remainingTurns).length > 0;
@@ -2030,6 +2288,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2030
2288
  gateResult,
2031
2289
  completionResult,
2032
2290
  hookResults,
2291
+ ...(budgetWarning ? { budget_warning: budgetWarning } : {}),
2033
2292
  };
2034
2293
  }
2035
2294
 
@@ -2171,7 +2430,7 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
2171
2430
  }
2172
2431
 
2173
2432
  // Retries exhausted — escalate
2174
- const updatedState = {
2433
+ const exhaustedEscalationState = {
2175
2434
  ...state,
2176
2435
  status: 'blocked',
2177
2436
  active_turns: {
@@ -2185,12 +2444,19 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
2185
2444
  },
2186
2445
  },
2187
2446
  blocked_on: `escalation:retries-exhausted:${currentTurn.assigned_role}`,
2447
+ };
2448
+
2449
+ const updatedState = {
2450
+ ...exhaustedEscalationState,
2188
2451
  blocked_reason: buildBlockedReason({
2189
2452
  category: 'retries_exhausted',
2190
2453
  recovery: {
2191
2454
  typed_reason: 'retries_exhausted',
2192
2455
  owner: 'human',
2193
- recovery_action: 'Resolve the escalation, then run agentxchain step --resume',
2456
+ recovery_action: deriveEscalationRecoveryAction(exhaustedEscalationState, config, {
2457
+ turnRetained: true,
2458
+ turnId: currentTurn.turn_id,
2459
+ }),
2194
2460
  turn_retained: true,
2195
2461
  detail: `escalation:retries-exhausted:${currentTurn.assigned_role}`,
2196
2462
  },
@@ -2218,7 +2484,10 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
2218
2484
  if (hooksConfig.on_escalation?.length > 0) {
2219
2485
  _fireOnEscalationHooks(root, hooksConfig, {
2220
2486
  blocked_reason: 'retries_exhausted',
2221
- recovery_action: 'Resolve the escalation, then run agentxchain step --resume',
2487
+ recovery_action: deriveEscalationRecoveryAction(updatedState, config, {
2488
+ turnRetained: true,
2489
+ turnId: currentTurn.turn_id,
2490
+ }),
2222
2491
  failed_turn_id: currentTurn.turn_id,
2223
2492
  failed_role: currentTurn.assigned_role,
2224
2493
  attempt_count: currentAttempt,