agentxchain 2.118.0 → 2.120.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.
@@ -676,6 +676,7 @@ program
676
676
  .option('--max-runs <n>', 'Maximum consecutive governed runs in continuous mode (default: 100)', parseInt)
677
677
  .option('--poll-seconds <n>', 'Seconds between idle-detection cycles in continuous mode (default: 30)', parseInt)
678
678
  .option('--max-idle-cycles <n>', 'Stop after N consecutive idle cycles with no derivable work (default: 3)', parseInt)
679
+ .option('--session-budget <usd>', 'Cumulative session-level budget cap in USD for continuous mode', parseFloat)
679
680
  .action(runCommand);
680
681
 
681
682
  program
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.118.0",
3
+ "version": "2.120.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -57,10 +57,17 @@ export async function runCommand(opts) {
57
57
  // Continuous vision-driven mode
58
58
  const contOpts = resolveContinuousOptions(opts, context.config);
59
59
  if (contOpts.enabled) {
60
+ if (contOpts.perSessionMaxUsd != null && (!Number.isFinite(contOpts.perSessionMaxUsd) || contOpts.perSessionMaxUsd <= 0)) {
61
+ console.log(chalk.red('--session-budget must be a finite number greater than 0'));
62
+ process.exit(1);
63
+ }
60
64
  console.log(chalk.cyan.bold('agentxchain run --continuous'));
61
65
  console.log(chalk.dim(` Vision: ${contOpts.visionPath}`));
62
66
  console.log(chalk.dim(` Max runs: ${contOpts.maxRuns}, Poll: ${contOpts.pollSeconds}s, Idle limit: ${contOpts.maxIdleCycles}`));
63
67
  console.log(chalk.dim(` Triage approval: ${contOpts.triageApproval}`));
68
+ if (contOpts.perSessionMaxUsd != null) {
69
+ console.log(chalk.dim(` Session budget: $${contOpts.perSessionMaxUsd.toFixed(2)}`));
70
+ }
64
71
  console.log('');
65
72
  const { exitCode } = await executeContinuousRun(context, contOpts, executeGovernedRun);
66
73
  process.exit(exitCode);
@@ -194,10 +201,10 @@ export async function executeGovernedRun(context, opts = {}) {
194
201
  const onSigint = () => {
195
202
  sigintCount++;
196
203
  if (sigintCount >= 2) {
204
+ controller.abort();
197
205
  process.exit(130);
198
206
  }
199
207
  aborted = true;
200
- controller.abort();
201
208
  log(chalk.yellow('\nSIGINT received — finishing current turn, then stopping.'));
202
209
  };
203
210
  process.on('SIGINT', onSigint);
@@ -234,10 +234,12 @@ async function runDueSchedules(context, opts = {}) {
234
234
 
235
235
  const nowIso = opts.at || new Date().toISOString();
236
236
  const results = [];
237
+ const excludedSchedules = new Set(opts.excludeSchedules || []);
237
238
 
238
239
  for (const entry of resolved.entries) {
239
- // Skip entries handled by the continuous session manager
240
- if (opts.excludeSchedule && entry.id === opts.excludeSchedule) {
240
+ // Skip entries handled by the continuous session manager.
241
+ if ((opts.excludeSchedule && entry.id === opts.excludeSchedule)
242
+ || excludedSchedules.has(entry.id)) {
241
243
  continue;
242
244
  }
243
245
  if (!entry.enabled) {
@@ -332,6 +334,12 @@ function isSessionTerminal(session) {
332
334
  return ['completed', 'idle_exit', 'failed', 'stopped'].includes(session?.status);
333
335
  }
334
336
 
337
+ function getContinuousEnabledScheduleIds(config) {
338
+ return Object.entries(config?.schedules || {})
339
+ .filter(([, schedule]) => schedule?.continuous?.enabled === true)
340
+ .map(([id]) => id);
341
+ }
342
+
335
343
  export function selectContinuousScheduleEntry(root, config, opts = {}) {
336
344
  const entries = listSchedules(root, config, { at: opts.at });
337
345
  const continuousEntries = entries.filter((entry) => config?.schedules?.[entry.id]?.continuous?.enabled === true);
@@ -381,6 +389,9 @@ function createScheduleOwnedSession(schedule, scheduleId) {
381
389
  status: 'running',
382
390
  owner_type: 'schedule',
383
391
  owner_id: scheduleId,
392
+ per_session_max_usd: schedule.continuous.per_session_max_usd || null,
393
+ cumulative_spent_usd: 0,
394
+ budget_exhausted: false,
384
395
  };
385
396
  }
386
397
 
@@ -443,6 +454,7 @@ async function advanceScheduleContinuousSession(context, entry, opts = {}) {
443
454
  maxRuns: contConfig.max_runs,
444
455
  maxIdleCycles: contConfig.max_idle_cycles,
445
456
  triageApproval: contConfig.triage_approval,
457
+ perSessionMaxUsd: contConfig.per_session_max_usd || null,
446
458
  };
447
459
 
448
460
  // Advance one step
@@ -456,7 +468,10 @@ async function advanceScheduleContinuousSession(context, entry, opts = {}) {
456
468
  blocked: 'continuous_blocked',
457
469
  running: 'continuous_running',
458
470
  };
459
- const schedStatus = statusMap[step.status] || 'continuous_running';
471
+ let schedStatus = statusMap[step.status] || 'continuous_running';
472
+ if (step.action === 'session_budget_exhausted') {
473
+ schedStatus = 'continuous_session_budget_exhausted';
474
+ }
460
475
 
461
476
  updateScheduleState(root, config, scheduleId, (record) => ({
462
477
  ...record,
@@ -646,6 +661,7 @@ export async function scheduleDaemonCommand(opts) {
646
661
  while (true) {
647
662
  cycle += 1;
648
663
  daemonState.last_cycle_started_at = new Date().toISOString();
664
+ const continuousScheduleIds = getContinuousEnabledScheduleIds(context.config);
649
665
 
650
666
  // Check for continuous schedule entries first
651
667
  const contEntry = selectContinuousScheduleEntry(context.root, context.config, {
@@ -679,7 +695,7 @@ export async function scheduleDaemonCommand(opts) {
679
695
  ...opts,
680
696
  continueActiveScheduleRuns: true,
681
697
  tolerateBlockedRun: true,
682
- excludeSchedule: contEntry.id,
698
+ excludeSchedules: continuousScheduleIds,
683
699
  });
684
700
 
685
701
  // Merge results
@@ -704,6 +720,7 @@ export async function scheduleDaemonCommand(opts) {
704
720
  ...opts,
705
721
  continueActiveScheduleRuns: true,
706
722
  tolerateBlockedRun: true,
723
+ excludeSchedules: continuousScheduleIds,
707
724
  });
708
725
  }
709
726
 
@@ -215,6 +215,17 @@ function renderGovernedStatus(context, opts) {
215
215
  if (continuousSession.idle_cycles > 0) {
216
216
  console.log(chalk.dim(` Idle cycles: ${continuousSession.idle_cycles}/${continuousSession.max_idle_cycles}`));
217
217
  }
218
+ if (continuousSession.per_session_max_usd != null) {
219
+ const spent = (continuousSession.cumulative_spent_usd || 0).toFixed(2);
220
+ const limit = continuousSession.per_session_max_usd.toFixed(2);
221
+ const pct = continuousSession.per_session_max_usd > 0
222
+ ? ((continuousSession.cumulative_spent_usd || 0) / continuousSession.per_session_max_usd * 100).toFixed(1)
223
+ : '0.0';
224
+ const budgetStr = continuousSession.budget_exhausted
225
+ ? chalk.red(`$${spent} / $${limit} (${pct}%) [EXHAUSTED]`)
226
+ : `$${spent} / $${limit} (${pct}%)`;
227
+ console.log(` Budget: ${budgetStr}`);
228
+ }
218
229
  console.log(chalk.dim(' ' + '─'.repeat(44)));
219
230
  console.log('');
220
231
  }
@@ -21,6 +21,7 @@ import {
21
21
  startIntent,
22
22
  resolveIntent,
23
23
  } from './intake.js';
24
+ import { loadProjectState } from './config.js';
24
25
  import { safeWriteJson } from './safe-write.js';
25
26
 
26
27
  const CONTINUOUS_SESSION_PATH = '.agentxchain/continuous-session.json';
@@ -54,7 +55,7 @@ export function removeContinuousSession(root) {
54
55
  }
55
56
  }
56
57
 
57
- function createSession(visionPath, maxRuns, maxIdleCycles) {
58
+ function createSession(visionPath, maxRuns, maxIdleCycles, perSessionMaxUsd) {
58
59
  return {
59
60
  session_id: `cont-${randomUUID().slice(0, 8)}`,
60
61
  started_at: new Date().toISOString(),
@@ -66,9 +67,40 @@ function createSession(visionPath, maxRuns, maxIdleCycles) {
66
67
  current_run_id: null,
67
68
  current_vision_objective: null,
68
69
  status: 'running',
70
+ per_session_max_usd: perSessionMaxUsd || null,
71
+ cumulative_spent_usd: 0,
72
+ budget_exhausted: false,
69
73
  };
70
74
  }
71
75
 
76
+ function describeContinuousTerminalStep(step, contOpts) {
77
+ if (step.action === 'max_runs_reached') {
78
+ return `Max runs reached (${contOpts.maxRuns}). Stopping.`;
79
+ }
80
+ if (step.action === 'session_budget_exhausted') {
81
+ return 'Session budget exhausted. Stopping.';
82
+ }
83
+ if (step.action === 'operator_stopped') {
84
+ return 'Continuous loop stopped by operator.';
85
+ }
86
+ if (step.status === 'idle_exit') {
87
+ return `All vision goals appear addressed (${contOpts.maxIdleCycles} consecutive idle cycles). Stopping.`;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ function getExecutionRunSpentUsd(execution) {
93
+ return execution?.result?.state?.budget_status?.spent_usd || 0;
94
+ }
95
+
96
+ function isBlockedContinuousExecution(execution) {
97
+ const stopReason = execution?.result?.stop_reason || null;
98
+ const stateStatus = execution?.result?.state?.status || null;
99
+ return stateStatus === 'blocked'
100
+ || stopReason === 'blocked'
101
+ || stopReason === 'reject_exhausted';
102
+ }
103
+
72
104
  // ---------------------------------------------------------------------------
73
105
  // Intake queue check
74
106
  // ---------------------------------------------------------------------------
@@ -274,6 +306,7 @@ export function resolveContinuousOptions(opts, config) {
274
306
  maxIdleCycles: opts.maxIdleCycles ?? configCont.max_idle_cycles ?? 3,
275
307
  triageApproval: configCont.triage_approval ?? 'auto',
276
308
  cooldownSeconds: opts.cooldownSeconds ?? configCont.cooldown_seconds ?? 5,
309
+ perSessionMaxUsd: opts.sessionBudget ?? configCont.per_session_max_usd ?? null,
277
310
  };
278
311
  }
279
312
 
@@ -312,6 +345,66 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
312
345
  return { ok: true, status: 'idle_exit', action: 'max_idle_reached', stop_reason: 'idle_exit' };
313
346
  }
314
347
 
348
+ // Session budget check (cumulative spend across all runs)
349
+ const sessionBudget = session.per_session_max_usd ?? contOpts.perSessionMaxUsd ?? null;
350
+ if (sessionBudget != null && (session.cumulative_spent_usd || 0) >= sessionBudget) {
351
+ session.status = 'completed';
352
+ session.budget_exhausted = true;
353
+ writeContinuousSession(root, session);
354
+ log(`Session budget exhausted: $${(session.cumulative_spent_usd || 0).toFixed(2)} spent of $${sessionBudget.toFixed(2)} limit.`);
355
+ return { ok: true, status: 'completed', action: 'session_budget_exhausted', stop_reason: 'session_budget' };
356
+ }
357
+
358
+ // Paused-session guard: if session is paused (blocked run awaiting unblock),
359
+ // check governed state before attempting to advance. Without this guard, the
360
+ // loop would try to startIntent() on a blocked project, hit the blocked-state
361
+ // rejection, and permanently fail the session instead of staying paused.
362
+ if (session.status === 'paused') {
363
+ const governedState = loadProjectState(root, context.config);
364
+ if (governedState?.status === 'blocked') {
365
+ // Still blocked — stay paused, do not attempt new work
366
+ writeContinuousSession(root, session);
367
+ return { ok: true, status: 'blocked', action: 'still_blocked', run_id: session.current_run_id };
368
+ }
369
+ // Unblocked — resume by continuing the existing governed run directly.
370
+ // Skip the intake pipeline: the run is already in progress, and startIntent
371
+ // would reject because the governed state is active.
372
+ session.status = 'running';
373
+ log('Blocked run resolved — resuming continuous session.');
374
+ writeContinuousSession(root, session);
375
+
376
+ let execution;
377
+ try {
378
+ execution = await executeGovernedRun(context, { autoApprove: true, report: true, log });
379
+ } catch (err) {
380
+ session.status = 'failed';
381
+ writeContinuousSession(root, session);
382
+ return { ok: false, status: 'failed', action: 'run_failed', stop_reason: err.message, run_id: session.current_run_id };
383
+ }
384
+
385
+ session.cumulative_spent_usd = (session.cumulative_spent_usd || 0) + getExecutionRunSpentUsd(execution);
386
+ const resumeStopReason = execution.result?.stop_reason;
387
+
388
+ if (isBlockedContinuousExecution(execution)) {
389
+ session.status = 'paused';
390
+ log('Resumed run blocked again — continuous loop re-paused.');
391
+ writeContinuousSession(root, session);
392
+ return { ok: true, status: 'blocked', action: 'run_blocked', run_id: session.current_run_id };
393
+ }
394
+
395
+ if (execution.exitCode !== 0 || !execution.result) {
396
+ session.status = 'failed';
397
+ writeContinuousSession(root, session);
398
+ return { ok: false, status: 'failed', action: 'run_failed', stop_reason: resumeStopReason || `exit_code_${execution.exitCode}`, run_id: session.current_run_id };
399
+ }
400
+
401
+ session.runs_completed += 1;
402
+ session.current_run_id = execution.result?.state?.run_id || session.current_run_id;
403
+ log(`Resumed run completed (${session.runs_completed}/${contOpts.maxRuns}): ${resumeStopReason || 'completed'}`);
404
+ writeContinuousSession(root, session);
405
+ return { ok: true, status: 'running', action: 'resumed_after_unblock', run_id: session.current_run_id };
406
+ }
407
+
315
408
  // Validate vision file
316
409
  if (!existsSync(absVisionPath)) {
317
410
  session.status = 'failed';
@@ -381,38 +474,94 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
381
474
  session.status = 'running';
382
475
  writeContinuousSession(root, session);
383
476
 
384
- const execution = await executeGovernedRun(context, {
385
- autoApprove: true,
386
- report: true,
387
- log,
388
- });
477
+ let execution;
478
+ try {
479
+ execution = await executeGovernedRun(context, {
480
+ autoApprove: true,
481
+ report: true,
482
+ log,
483
+ });
484
+ } catch (err) {
485
+ session.status = 'failed';
486
+ writeContinuousSession(root, session);
487
+ log(`Governed run threw during continuous execution: ${err.message}`);
488
+ return {
489
+ ok: false,
490
+ status: 'failed',
491
+ action: 'run_failed',
492
+ stop_reason: err.message,
493
+ run_id: preparedIntent.runId || null,
494
+ intent_id: targetIntentId,
495
+ };
496
+ }
389
497
 
390
- session.runs_completed += 1;
391
- session.current_run_id = execution.result?.state?.run_id || null;
498
+ session.current_run_id = execution.result?.state?.run_id || preparedIntent.runId || null;
499
+ session.cumulative_spent_usd = (session.cumulative_spent_usd || 0) + getExecutionRunSpentUsd(execution);
392
500
 
393
501
  const stopReason = execution.result?.stop_reason;
394
- log(`Run ${session.runs_completed}/${contOpts.maxRuns} completed: ${stopReason || 'unknown'}`);
395
502
 
396
- // Resolve the consumed intent
397
- const resolved = resolveIntent(root, targetIntentId);
398
- if (!resolved.ok) {
399
- log(`Continuous resolve error: ${resolved.error}`);
400
- session.status = 'failed';
503
+ if (stopReason === 'priority_preempted') {
504
+ log('Priority preemption detected consuming injected work next cycle.');
401
505
  writeContinuousSession(root, session);
402
- return { ok: false, status: 'failed', action: 'resolve_failed', stop_reason: resolved.error, intent_id: targetIntentId };
506
+ return {
507
+ ok: true,
508
+ status: 'running',
509
+ action: 'consumed_injected_priority',
510
+ run_id: session.current_run_id,
511
+ intent_id: targetIntentId,
512
+ };
403
513
  }
404
514
 
405
- if (stopReason === 'blocked') {
515
+ if (isBlockedContinuousExecution(execution)) {
516
+ const resolved = resolveIntent(root, targetIntentId);
517
+ if (!resolved.ok) {
518
+ log(`Continuous resolve error: ${resolved.error}`);
519
+ session.status = 'failed';
520
+ writeContinuousSession(root, session);
521
+ return { ok: false, status: 'failed', action: 'resolve_failed', stop_reason: resolved.error, intent_id: targetIntentId };
522
+ }
406
523
  session.status = 'paused';
407
524
  log('Run blocked — continuous loop paused. Use `agentxchain unblock <id>` to resume.');
408
525
  writeContinuousSession(root, session);
409
526
  return { ok: true, status: 'blocked', action: 'run_blocked', run_id: session.current_run_id, intent_id: targetIntentId };
410
527
  }
411
528
 
412
- if (stopReason === 'priority_preempted') {
413
- log('Priority preemption detected — consuming injected work next cycle.');
529
+ if (stopReason === 'caller_stopped') {
530
+ session.status = 'stopped';
531
+ writeContinuousSession(root, session);
532
+ return {
533
+ ok: true,
534
+ status: 'stopped',
535
+ action: 'operator_stopped',
536
+ run_id: session.current_run_id,
537
+ intent_id: targetIntentId,
538
+ };
539
+ }
540
+
541
+ if (execution.exitCode !== 0 || !execution.result) {
542
+ session.status = 'failed';
543
+ writeContinuousSession(root, session);
544
+ log(`Governed run failed during continuous execution: ${stopReason || `exit_code_${execution.exitCode}`}.`);
545
+ return {
546
+ ok: false,
547
+ status: 'failed',
548
+ action: 'run_failed',
549
+ stop_reason: stopReason || `exit_code_${execution.exitCode}`,
550
+ run_id: session.current_run_id,
551
+ intent_id: targetIntentId,
552
+ };
553
+ }
554
+
555
+ session.runs_completed += 1;
556
+ log(`Run ${session.runs_completed}/${contOpts.maxRuns} completed: ${stopReason || 'unknown'}`);
557
+
558
+ // Resolve the consumed intent
559
+ const resolved = resolveIntent(root, targetIntentId);
560
+ if (!resolved.ok) {
561
+ log(`Continuous resolve error: ${resolved.error}`);
562
+ session.status = 'failed';
414
563
  writeContinuousSession(root, session);
415
- return { ok: true, status: 'running', action: 'consumed_injected_priority', run_id: session.current_run_id, intent_id: targetIntentId };
564
+ return { ok: false, status: 'failed', action: 'resolve_failed', stop_reason: resolved.error, intent_id: targetIntentId };
416
565
  }
417
566
 
418
567
  writeContinuousSession(root, session);
@@ -449,7 +598,7 @@ export async function executeContinuousRun(context, contOpts, executeGovernedRun
449
598
  return { exitCode: 1, session: null };
450
599
  }
451
600
 
452
- const session = createSession(contOpts.visionPath, contOpts.maxRuns, contOpts.maxIdleCycles);
601
+ const session = createSession(contOpts.visionPath, contOpts.maxRuns, contOpts.maxIdleCycles, contOpts.perSessionMaxUsd);
453
602
  writeContinuousSession(root, session);
454
603
 
455
604
  // SIGINT handler
@@ -465,11 +614,10 @@ export async function executeContinuousRun(context, contOpts, executeGovernedRun
465
614
  const step = await advanceContinuousRunOnce(context, session, contOpts, executeGovernedRun, log);
466
615
 
467
616
  // Terminal states
468
- if (step.status === 'completed' || step.status === 'idle_exit' || step.status === 'failed' || step.status === 'blocked') {
469
- if (step.status === 'completed') {
470
- log(`Max runs reached (${contOpts.maxRuns}). Stopping.`);
471
- } else if (step.status === 'idle_exit') {
472
- log(`All vision goals appear addressed (${contOpts.maxIdleCycles} consecutive idle cycles). Stopping.`);
617
+ if (step.status === 'completed' || step.status === 'idle_exit' || step.status === 'failed' || step.status === 'blocked' || step.status === 'stopped') {
618
+ const terminalMessage = describeContinuousTerminalStep(step, contOpts);
619
+ if (terminalMessage) {
620
+ log(terminalMessage);
473
621
  }
474
622
  return { exitCode: step.ok ? 0 : 1, session };
475
623
  }
@@ -711,6 +711,13 @@ export function validateSchedulesConfig(schedules, roles) {
711
711
  if ('triage_approval' in cont && cont.triage_approval !== 'auto' && cont.triage_approval !== 'human') {
712
712
  errors.push(`Schedule "${scheduleId}": continuous.triage_approval must be "auto" or "human"`);
713
713
  }
714
+ if ('per_session_max_usd' in cont && cont.per_session_max_usd != null) {
715
+ if (typeof cont.per_session_max_usd !== 'number' || !Number.isFinite(cont.per_session_max_usd)) {
716
+ errors.push(`Schedule "${scheduleId}": continuous.per_session_max_usd must be a finite number when provided`);
717
+ } else if (cont.per_session_max_usd <= 0) {
718
+ errors.push(`Schedule "${scheduleId}": continuous.per_session_max_usd must be greater than 0 when provided`);
719
+ }
720
+ }
714
721
  }
715
722
  }
716
723
  }
@@ -1153,6 +1160,9 @@ function normalizeContinuousConfig(raw) {
1153
1160
  max_runs: Number.isInteger(raw.max_runs) && raw.max_runs >= 1 ? raw.max_runs : 50,
1154
1161
  max_idle_cycles: Number.isInteger(raw.max_idle_cycles) && raw.max_idle_cycles >= 1 ? raw.max_idle_cycles : 5,
1155
1162
  triage_approval: raw.triage_approval === 'human' ? 'human' : 'auto',
1163
+ per_session_max_usd: Number.isFinite(raw.per_session_max_usd) && raw.per_session_max_usd > 0
1164
+ ? raw.per_session_max_usd
1165
+ : null,
1156
1166
  };
1157
1167
  }
1158
1168