agentxchain 2.117.0 → 2.119.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.117.0",
3
+ "version": "2.119.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -70,7 +70,7 @@ function extractAggregateEvidenceLine(text) {
70
70
  return best;
71
71
  }, null);
72
72
 
73
- return aggregate.line.replace(/\*\*/g, '').replace(/`/g, '').trim();
73
+ return aggregate.line.replace(/\*\*/g, '').replace(/`/g, '').replace(/,/g, '').trim();
74
74
  }
75
75
 
76
76
  function getPreviousVersionTag(repoRoot, version) {
@@ -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);
@@ -15,6 +15,15 @@ import {
15
15
  } from '../lib/run-schedule.js';
16
16
  import { consumePreemptionMarker } from '../lib/intake.js';
17
17
  import { executeGovernedRun } from './run.js';
18
+ import {
19
+ readContinuousSession,
20
+ writeContinuousSession,
21
+ advanceContinuousRunOnce,
22
+ resolveContinuousOptions,
23
+ } from '../lib/continuous-run.js';
24
+ import { resolveVisionPath } from '../lib/vision-reader.js';
25
+ import { existsSync } from 'node:fs';
26
+ import { randomUUID } from 'node:crypto';
18
27
 
19
28
  function loadScheduleContext() {
20
29
  const context = loadProjectContext();
@@ -227,6 +236,10 @@ async function runDueSchedules(context, opts = {}) {
227
236
  const results = [];
228
237
 
229
238
  for (const entry of resolved.entries) {
239
+ // Skip entries handled by the continuous session manager
240
+ if (opts.excludeSchedule && entry.id === opts.excludeSchedule) {
241
+ continue;
242
+ }
230
243
  if (!entry.enabled) {
231
244
  results.push({ id: entry.id, action: 'disabled' });
232
245
  continue;
@@ -311,6 +324,166 @@ async function runDueSchedules(context, opts = {}) {
311
324
  return { ok: true, exitCode: 0, results };
312
325
  }
313
326
 
327
+ // ---------------------------------------------------------------------------
328
+ // Schedule-owned continuous session management
329
+ // ---------------------------------------------------------------------------
330
+
331
+ function isSessionTerminal(session) {
332
+ return ['completed', 'idle_exit', 'failed', 'stopped'].includes(session?.status);
333
+ }
334
+
335
+ export function selectContinuousScheduleEntry(root, config, opts = {}) {
336
+ const entries = listSchedules(root, config, { at: opts.at });
337
+ const continuousEntries = entries.filter((entry) => config?.schedules?.[entry.id]?.continuous?.enabled === true);
338
+
339
+ if (continuousEntries.length === 0) {
340
+ return null;
341
+ }
342
+
343
+ if (opts.scheduleId) {
344
+ const selected = continuousEntries.find((entry) => entry.id === opts.scheduleId);
345
+ return selected
346
+ ? { id: selected.id, schedule: config.schedules[selected.id], due: selected.due }
347
+ : null;
348
+ }
349
+
350
+ const activeSession = readContinuousSession(root);
351
+ if (activeSession && !isSessionTerminal(activeSession) && activeSession.owner_type === 'schedule') {
352
+ const ownerEntry = continuousEntries.find((entry) => entry.id === activeSession.owner_id);
353
+ if (!ownerEntry) {
354
+ return {
355
+ id: activeSession.owner_id,
356
+ error: `active continuous session owned by unknown schedule "${activeSession.owner_id}"`,
357
+ };
358
+ }
359
+ return { id: ownerEntry.id, schedule: config.schedules[ownerEntry.id], due: ownerEntry.due };
360
+ }
361
+
362
+ const dueEntry = continuousEntries.find((entry) => entry.due);
363
+ if (!dueEntry) {
364
+ return null;
365
+ }
366
+
367
+ return { id: dueEntry.id, schedule: config.schedules[dueEntry.id], due: dueEntry.due };
368
+ }
369
+
370
+ function createScheduleOwnedSession(schedule, scheduleId) {
371
+ return {
372
+ session_id: `cont-${randomUUID().slice(0, 8)}`,
373
+ started_at: new Date().toISOString(),
374
+ vision_path: schedule.continuous.vision_path,
375
+ runs_completed: 0,
376
+ max_runs: schedule.continuous.max_runs,
377
+ idle_cycles: 0,
378
+ max_idle_cycles: schedule.continuous.max_idle_cycles,
379
+ current_run_id: null,
380
+ current_vision_objective: null,
381
+ status: 'running',
382
+ owner_type: 'schedule',
383
+ owner_id: scheduleId,
384
+ per_session_max_usd: schedule.continuous.per_session_max_usd || null,
385
+ cumulative_spent_usd: 0,
386
+ budget_exhausted: false,
387
+ };
388
+ }
389
+
390
+ async function advanceScheduleContinuousSession(context, entry, opts = {}) {
391
+ const { root, config } = context;
392
+ const scheduleId = entry.id;
393
+ const schedule = entry.schedule;
394
+ const contConfig = schedule.continuous;
395
+ const log = opts.json ? () => {} : console.log;
396
+
397
+ // Read existing session
398
+ let session = readContinuousSession(root);
399
+
400
+ // If there's an active session owned by a different schedule, fail closed
401
+ if (session && !isSessionTerminal(session) && session.owner_type === 'schedule' && session.owner_id !== scheduleId) {
402
+ return {
403
+ ok: false,
404
+ action: 'skipped',
405
+ reason: `continuous session owned by schedule "${session.owner_id}"`,
406
+ };
407
+ }
408
+
409
+ // Determine if we need a new session
410
+ const needsNewSession = !session || isSessionTerminal(session) || session.owner_id !== scheduleId;
411
+
412
+ if (needsNewSession) {
413
+ // Only start a new session if the schedule is due
414
+ if (!opts.isDue) {
415
+ return { ok: true, action: 'not_due', reason: 'waiting_interval' };
416
+ }
417
+
418
+ // Check launch eligibility
419
+ const eligibility = evaluateScheduleLaunchEligibility(root, config);
420
+ if (!eligibility.ok) {
421
+ return { ok: false, action: 'skipped', reason: eligibility.reason };
422
+ }
423
+
424
+ // Validate vision path
425
+ const absVision = resolveVisionPath(root, contConfig.vision_path);
426
+ if (!existsSync(absVision)) {
427
+ return { ok: false, action: 'failed', reason: `VISION.md not found at ${absVision}` };
428
+ }
429
+
430
+ session = createScheduleOwnedSession(schedule, scheduleId);
431
+ writeContinuousSession(root, session);
432
+ log(chalk.cyan(`Started schedule-owned continuous session: ${session.session_id} (schedule: ${scheduleId})`));
433
+
434
+ // Record schedule start
435
+ updateScheduleState(root, config, scheduleId, (record) => ({
436
+ ...record,
437
+ last_started_at: new Date().toISOString(),
438
+ last_status: 'continuous_running',
439
+ last_continuous_session_id: session.session_id,
440
+ }));
441
+ }
442
+
443
+ // Build contOpts from schedule continuous config
444
+ const contOpts = {
445
+ visionPath: contConfig.vision_path,
446
+ maxRuns: contConfig.max_runs,
447
+ maxIdleCycles: contConfig.max_idle_cycles,
448
+ triageApproval: contConfig.triage_approval,
449
+ perSessionMaxUsd: contConfig.per_session_max_usd || null,
450
+ };
451
+
452
+ // Advance one step
453
+ const step = await advanceContinuousRunOnce(context, session, contOpts, executeGovernedRun, log);
454
+
455
+ // Update schedule state based on step result
456
+ const statusMap = {
457
+ completed: 'continuous_completed',
458
+ idle_exit: 'continuous_idle_exit',
459
+ failed: 'continuous_failed',
460
+ blocked: 'continuous_blocked',
461
+ running: 'continuous_running',
462
+ };
463
+ let schedStatus = statusMap[step.status] || 'continuous_running';
464
+ if (step.action === 'session_budget_exhausted') {
465
+ schedStatus = 'continuous_session_budget_exhausted';
466
+ }
467
+
468
+ updateScheduleState(root, config, scheduleId, (record) => ({
469
+ ...record,
470
+ last_finished_at: new Date().toISOString(),
471
+ last_status: schedStatus,
472
+ last_run_id: step.run_id || record.last_run_id,
473
+ last_continuous_session_id: session.session_id,
474
+ }));
475
+
476
+ return {
477
+ ok: step.ok,
478
+ action: step.action,
479
+ status: step.status,
480
+ session_id: session.session_id,
481
+ run_id: step.run_id || null,
482
+ intent_id: step.intent_id || null,
483
+ runs_completed: session.runs_completed,
484
+ };
485
+ }
486
+
314
487
  export async function scheduleListCommand(opts) {
315
488
  const context = loadScheduleContext();
316
489
  if (!context) return;
@@ -480,11 +653,66 @@ export async function scheduleDaemonCommand(opts) {
480
653
  while (true) {
481
654
  cycle += 1;
482
655
  daemonState.last_cycle_started_at = new Date().toISOString();
483
- const result = await runDueSchedules(context, {
484
- ...opts,
485
- continueActiveScheduleRuns: true,
486
- tolerateBlockedRun: true,
656
+
657
+ // Check for continuous schedule entries first
658
+ const contEntry = selectContinuousScheduleEntry(context.root, context.config, {
659
+ scheduleId: opts.schedule || null,
660
+ at: opts.at,
487
661
  });
662
+ let result;
663
+
664
+ if (contEntry?.error) {
665
+ result = {
666
+ ok: false,
667
+ exitCode: 1,
668
+ results: [{
669
+ id: contEntry.id,
670
+ action: 'failed',
671
+ continuous: true,
672
+ reason: contEntry.error,
673
+ }],
674
+ };
675
+ } else if (contEntry) {
676
+ const isDue = contEntry.due ?? false;
677
+
678
+ const contResult = await advanceScheduleContinuousSession(context, contEntry, {
679
+ isDue,
680
+ json: opts.json,
681
+ at: opts.at,
682
+ });
683
+
684
+ // Run non-continuous schedules normally alongside
685
+ const nonContResult = await runDueSchedules(context, {
686
+ ...opts,
687
+ continueActiveScheduleRuns: true,
688
+ tolerateBlockedRun: true,
689
+ excludeSchedule: contEntry.id,
690
+ });
691
+
692
+ // Merge results
693
+ const contResultEntry = {
694
+ id: contEntry.id,
695
+ action: contResult.action,
696
+ continuous: true,
697
+ session_id: contResult.session_id || null,
698
+ status: contResult.status || null,
699
+ run_id: contResult.run_id || null,
700
+ runs_completed: contResult.runs_completed ?? null,
701
+ };
702
+ if (contResult.reason) contResultEntry.reason = contResult.reason;
703
+
704
+ result = {
705
+ ok: contResult.ok !== false && nonContResult.ok,
706
+ exitCode: (contResult.ok === false || !nonContResult.ok) ? 1 : 0,
707
+ results: [contResultEntry, ...nonContResult.results],
708
+ };
709
+ } else {
710
+ result = await runDueSchedules(context, {
711
+ ...opts,
712
+ continueActiveScheduleRuns: true,
713
+ tolerateBlockedRun: true,
714
+ });
715
+ }
488
716
 
489
717
  updateDaemonHeartbeat(context.root, daemonState, result);
490
718
 
@@ -206,12 +206,26 @@ function renderGovernedStatus(context, opts) {
206
206
  console.log(chalk.dim(` Vision: ${continuousSession.vision_path}`));
207
207
  console.log(` Status: ${chalk.cyan(continuousSession.status || 'unknown')}`);
208
208
  console.log(` Runs: ${continuousSession.runs_completed || 0}/${continuousSession.max_runs || '?'}`);
209
+ if (continuousSession.owner_type === 'schedule') {
210
+ console.log(chalk.dim(` Owner: schedule:${continuousSession.owner_id}`));
211
+ }
209
212
  if (continuousSession.current_vision_objective) {
210
213
  console.log(` Objective: ${chalk.yellow(continuousSession.current_vision_objective)}`);
211
214
  }
212
215
  if (continuousSession.idle_cycles > 0) {
213
216
  console.log(chalk.dim(` Idle cycles: ${continuousSession.idle_cycles}/${continuousSession.max_idle_cycles}`));
214
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
+ }
215
229
  console.log(chalk.dim(' ' + '─'.repeat(44)));
216
230
  console.log('');
217
231
  }
@@ -54,7 +54,7 @@ export function removeContinuousSession(root) {
54
54
  }
55
55
  }
56
56
 
57
- function createSession(visionPath, maxRuns, maxIdleCycles) {
57
+ function createSession(visionPath, maxRuns, maxIdleCycles, perSessionMaxUsd) {
58
58
  return {
59
59
  session_id: `cont-${randomUUID().slice(0, 8)}`,
60
60
  started_at: new Date().toISOString(),
@@ -66,9 +66,25 @@ function createSession(visionPath, maxRuns, maxIdleCycles) {
66
66
  current_run_id: null,
67
67
  current_vision_objective: null,
68
68
  status: 'running',
69
+ per_session_max_usd: perSessionMaxUsd || null,
70
+ cumulative_spent_usd: 0,
71
+ budget_exhausted: false,
69
72
  };
70
73
  }
71
74
 
75
+ function describeContinuousTerminalStep(step, contOpts) {
76
+ if (step.action === 'max_runs_reached') {
77
+ return `Max runs reached (${contOpts.maxRuns}). Stopping.`;
78
+ }
79
+ if (step.action === 'session_budget_exhausted') {
80
+ return 'Session budget exhausted. Stopping.';
81
+ }
82
+ if (step.status === 'idle_exit') {
83
+ return `All vision goals appear addressed (${contOpts.maxIdleCycles} consecutive idle cycles). Stopping.`;
84
+ }
85
+ return null;
86
+ }
87
+
72
88
  // ---------------------------------------------------------------------------
73
89
  // Intake queue check
74
90
  // ---------------------------------------------------------------------------
@@ -274,11 +290,174 @@ export function resolveContinuousOptions(opts, config) {
274
290
  maxIdleCycles: opts.maxIdleCycles ?? configCont.max_idle_cycles ?? 3,
275
291
  triageApproval: configCont.triage_approval ?? 'auto',
276
292
  cooldownSeconds: opts.cooldownSeconds ?? configCont.cooldown_seconds ?? 5,
293
+ perSessionMaxUsd: opts.sessionBudget ?? configCont.per_session_max_usd ?? null,
277
294
  };
278
295
  }
279
296
 
280
297
  // ---------------------------------------------------------------------------
281
- // Main continuous loop
298
+ // Single-step continuous advancement primitive
299
+ // ---------------------------------------------------------------------------
300
+
301
+ /**
302
+ * Advance a continuous session by exactly one step.
303
+ *
304
+ * This is the shared primitive used by both `run --continuous` (CLI-owned loop)
305
+ * and `schedule daemon` (daemon-owned poll). Neither caller embeds a nested
306
+ * poll/sleep loop — the caller owns cadence, this function owns one step.
307
+ *
308
+ * @param {object} context - { root, config }
309
+ * @param {object} session - mutable session object (read/written by caller)
310
+ * @param {object} contOpts - resolved continuous options (visionPath, maxRuns, maxIdleCycles, triageApproval)
311
+ * @param {Function} executeGovernedRun - the run executor function
312
+ * @param {Function} [log] - logging function
313
+ * @returns {Promise<{ ok: boolean, status: string, action: string, run_id?: string, intent_id?: string, stop_reason?: string }>}
314
+ */
315
+ export async function advanceContinuousRunOnce(context, session, contOpts, executeGovernedRun, log = console.log) {
316
+ const { root } = context;
317
+ const absVisionPath = resolveVisionPath(root, contOpts.visionPath);
318
+
319
+ // Terminal checks
320
+ if (session.runs_completed >= contOpts.maxRuns) {
321
+ session.status = 'completed';
322
+ writeContinuousSession(root, session);
323
+ return { ok: true, status: 'completed', action: 'max_runs_reached', stop_reason: 'max_runs' };
324
+ }
325
+
326
+ if (session.idle_cycles >= contOpts.maxIdleCycles) {
327
+ session.status = 'completed';
328
+ writeContinuousSession(root, session);
329
+ return { ok: true, status: 'idle_exit', action: 'max_idle_reached', stop_reason: 'idle_exit' };
330
+ }
331
+
332
+ // Session budget check (cumulative spend across all runs)
333
+ const sessionBudget = session.per_session_max_usd ?? contOpts.perSessionMaxUsd ?? null;
334
+ if (sessionBudget != null && (session.cumulative_spent_usd || 0) >= sessionBudget) {
335
+ session.status = 'completed';
336
+ session.budget_exhausted = true;
337
+ writeContinuousSession(root, session);
338
+ log(`Session budget exhausted: $${(session.cumulative_spent_usd || 0).toFixed(2)} spent of $${sessionBudget.toFixed(2)} limit.`);
339
+ return { ok: true, status: 'completed', action: 'session_budget_exhausted', stop_reason: 'session_budget' };
340
+ }
341
+
342
+ // Validate vision file
343
+ if (!existsSync(absVisionPath)) {
344
+ session.status = 'failed';
345
+ writeContinuousSession(root, session);
346
+ return { ok: false, status: 'failed', action: 'vision_missing', stop_reason: `VISION.md not found at ${absVisionPath}` };
347
+ }
348
+
349
+ // Step 1: Check intake queue for pending work
350
+ const queued = findNextQueuedIntent(root);
351
+ let targetIntentId = null;
352
+ let visionObjective = null;
353
+
354
+ if (queued.ok) {
355
+ targetIntentId = queued.intentId;
356
+ session.idle_cycles = 0;
357
+ log(`Found queued intent: ${queued.intentId} (${queued.status})`);
358
+ } else {
359
+ // Step 2: Derive from vision
360
+ const seeded = seedFromVision(root, absVisionPath, {
361
+ triageApproval: contOpts.triageApproval,
362
+ });
363
+
364
+ if (!seeded.ok) {
365
+ log(`Vision scan error: ${seeded.error}`);
366
+ session.status = 'failed';
367
+ writeContinuousSession(root, session);
368
+ return { ok: false, status: 'failed', action: 'vision_scan_error', stop_reason: seeded.error };
369
+ }
370
+
371
+ if (seeded.idle) {
372
+ session.idle_cycles += 1;
373
+ log(`Idle cycle ${session.idle_cycles}/${contOpts.maxIdleCycles} — no derivable work from vision.`);
374
+ writeContinuousSession(root, session);
375
+ return { ok: true, status: 'running', action: 'no_work_found' };
376
+ }
377
+
378
+ // If triage_approval is "human", the intent is in "triaged" state — don't auto-start
379
+ if (contOpts.triageApproval === 'human') {
380
+ log(`Vision-derived intent ${seeded.intentId} left in triaged state (triage_approval: human).`);
381
+ session.idle_cycles += 1;
382
+ writeContinuousSession(root, session);
383
+ return { ok: true, status: 'running', action: 'waited_for_human', intent_id: seeded.intentId };
384
+ }
385
+
386
+ targetIntentId = seeded.intentId;
387
+ visionObjective = `${seeded.section}: ${seeded.goal}`;
388
+ session.idle_cycles = 0;
389
+ log(`Vision-derived: ${visionObjective}`);
390
+ }
391
+
392
+ // Prepare intent through intake lifecycle
393
+ const provenance = buildContinuousProvenance(targetIntentId, {
394
+ trigger: visionObjective ? 'vision_scan' : 'intake',
395
+ triggerReason: visionObjective || readIntent(root, targetIntentId)?.charter || null,
396
+ });
397
+ const preparedIntent = prepareIntentForRun(root, targetIntentId, { provenance });
398
+ if (!preparedIntent.ok) {
399
+ log(`Continuous start error: ${preparedIntent.error}`);
400
+ session.status = 'failed';
401
+ writeContinuousSession(root, session);
402
+ return { ok: false, status: 'failed', action: 'prepare_failed', stop_reason: preparedIntent.error, intent_id: targetIntentId };
403
+ }
404
+
405
+ // Execute the governed run
406
+ session.current_run_id = preparedIntent.runId;
407
+ session.current_vision_objective = visionObjective || preparedIntent.intent?.charter || null;
408
+ session.status = 'running';
409
+ writeContinuousSession(root, session);
410
+
411
+ const execution = await executeGovernedRun(context, {
412
+ autoApprove: true,
413
+ report: true,
414
+ log,
415
+ });
416
+
417
+ session.runs_completed += 1;
418
+ session.current_run_id = execution.result?.state?.run_id || null;
419
+
420
+ // Accumulate cost from this run into the session total
421
+ const runSpentUsd = execution.result?.state?.budget_status?.spent_usd || 0;
422
+ session.cumulative_spent_usd = (session.cumulative_spent_usd || 0) + runSpentUsd;
423
+
424
+ const stopReason = execution.result?.stop_reason;
425
+ log(`Run ${session.runs_completed}/${contOpts.maxRuns} completed: ${stopReason || 'unknown'}`);
426
+
427
+ // Resolve the consumed intent
428
+ const resolved = resolveIntent(root, targetIntentId);
429
+ if (!resolved.ok) {
430
+ log(`Continuous resolve error: ${resolved.error}`);
431
+ session.status = 'failed';
432
+ writeContinuousSession(root, session);
433
+ return { ok: false, status: 'failed', action: 'resolve_failed', stop_reason: resolved.error, intent_id: targetIntentId };
434
+ }
435
+
436
+ if (stopReason === 'blocked') {
437
+ session.status = 'paused';
438
+ log('Run blocked — continuous loop paused. Use `agentxchain unblock <id>` to resume.');
439
+ writeContinuousSession(root, session);
440
+ return { ok: true, status: 'blocked', action: 'run_blocked', run_id: session.current_run_id, intent_id: targetIntentId };
441
+ }
442
+
443
+ if (stopReason === 'priority_preempted') {
444
+ log('Priority preemption detected — consuming injected work next cycle.');
445
+ writeContinuousSession(root, session);
446
+ return { ok: true, status: 'running', action: 'consumed_injected_priority', run_id: session.current_run_id, intent_id: targetIntentId };
447
+ }
448
+
449
+ writeContinuousSession(root, session);
450
+ return {
451
+ ok: true,
452
+ status: 'running',
453
+ action: visionObjective ? 'seeded_from_vision' : 'started_run',
454
+ run_id: session.current_run_id,
455
+ intent_id: targetIntentId,
456
+ };
457
+ }
458
+
459
+ // ---------------------------------------------------------------------------
460
+ // Main continuous loop (CLI-owned, built on advanceContinuousRunOnce)
282
461
  // ---------------------------------------------------------------------------
283
462
 
284
463
  /**
@@ -293,7 +472,6 @@ export function resolveContinuousOptions(opts, config) {
293
472
  export async function executeContinuousRun(context, contOpts, executeGovernedRun, log = console.log) {
294
473
  const { root } = context;
295
474
  const absVisionPath = resolveVisionPath(root, contOpts.visionPath);
296
- let exitCode = 0;
297
475
 
298
476
  // Validate vision file exists
299
477
  if (!existsSync(absVisionPath)) {
@@ -302,7 +480,7 @@ export async function executeContinuousRun(context, contOpts, executeGovernedRun
302
480
  return { exitCode: 1, session: null };
303
481
  }
304
482
 
305
- const session = createSession(contOpts.visionPath, contOpts.maxRuns, contOpts.maxIdleCycles);
483
+ const session = createSession(contOpts.visionPath, contOpts.maxRuns, contOpts.maxIdleCycles, contOpts.perSessionMaxUsd);
306
484
  writeContinuousSession(root, session);
307
485
 
308
486
  // SIGINT handler
@@ -315,122 +493,25 @@ export async function executeContinuousRun(context, contOpts, executeGovernedRun
315
493
 
316
494
  try {
317
495
  while (!stopping) {
318
- // Check max runs
319
- if (session.runs_completed >= contOpts.maxRuns) {
320
- session.status = 'completed';
321
- log(`Max runs reached (${contOpts.maxRuns}). Stopping.`);
322
- break;
323
- }
496
+ const step = await advanceContinuousRunOnce(context, session, contOpts, executeGovernedRun, log);
324
497
 
325
- // Check max idle cycles
326
- if (session.idle_cycles >= contOpts.maxIdleCycles) {
327
- session.status = 'completed';
328
- log(`All vision goals appear addressed (${contOpts.maxIdleCycles} consecutive idle cycles). Stopping.`);
329
- break;
330
- }
331
-
332
- // Step 1: Check intake queue for pending work
333
- const queued = findNextQueuedIntent(root);
334
- let targetIntentId = null;
335
- let visionObjective = null;
336
- let preparedIntent = null;
337
-
338
- if (queued.ok) {
339
- targetIntentId = queued.intentId;
340
- session.idle_cycles = 0;
341
- log(`Found queued intent: ${queued.intentId} (${queued.status})`);
342
- } else {
343
- // Step 2: Derive from vision
344
- const seeded = seedFromVision(root, absVisionPath, {
345
- triageApproval: contOpts.triageApproval,
346
- });
347
-
348
- if (!seeded.ok) {
349
- log(`Vision scan error: ${seeded.error}`);
350
- session.status = 'stopped';
351
- exitCode = 1;
352
- break;
353
- }
354
-
355
- if (seeded.idle) {
356
- session.idle_cycles += 1;
357
- log(`Idle cycle ${session.idle_cycles}/${contOpts.maxIdleCycles} — no derivable work from vision.`);
358
- writeContinuousSession(root, session);
359
- if (session.idle_cycles >= contOpts.maxIdleCycles) continue;
360
- await new Promise(r => setTimeout(r, contOpts.pollSeconds * 1000));
361
- continue;
362
- }
363
-
364
- // If triage_approval is "human", the intent is in "triaged" state — don't auto-start
365
- if (contOpts.triageApproval === 'human') {
366
- log(`Vision-derived intent ${seeded.intentId} left in triaged state (triage_approval: human).`);
367
- session.idle_cycles += 1;
368
- writeContinuousSession(root, session);
369
- await new Promise(r => setTimeout(r, contOpts.pollSeconds * 1000));
370
- continue;
498
+ // Terminal states
499
+ if (step.status === 'completed' || step.status === 'idle_exit' || step.status === 'failed' || step.status === 'blocked') {
500
+ const terminalMessage = describeContinuousTerminalStep(step, contOpts);
501
+ if (terminalMessage) {
502
+ log(terminalMessage);
371
503
  }
372
-
373
- targetIntentId = seeded.intentId;
374
- visionObjective = `${seeded.section}: ${seeded.goal}`;
375
- session.idle_cycles = 0;
376
- log(`Vision-derived: ${visionObjective}`);
377
- }
378
-
379
- const provenance = buildContinuousProvenance(targetIntentId, {
380
- trigger: visionObjective ? 'vision_scan' : 'intake',
381
- triggerReason: visionObjective || readIntent(root, targetIntentId)?.charter || null,
382
- });
383
- preparedIntent = prepareIntentForRun(root, targetIntentId, { provenance });
384
- if (!preparedIntent.ok) {
385
- log(`Continuous start error: ${preparedIntent.error}`);
386
- session.status = 'stopped';
387
- exitCode = 1;
388
- break;
504
+ return { exitCode: step.ok ? 0 : 1, session };
389
505
  }
390
506
 
391
- // Step 3: Execute the prepared governed run.
392
- session.current_run_id = preparedIntent.runId;
393
- session.current_vision_objective = visionObjective || preparedIntent.intent?.charter || null;
394
- session.status = 'running';
395
- writeContinuousSession(root, session);
396
-
397
- const execution = await executeGovernedRun(context, {
398
- autoApprove: true,
399
- report: true,
400
- log,
401
- });
402
-
403
- session.runs_completed += 1;
404
- session.current_run_id = execution.result?.state?.run_id || null;
405
-
406
- const stopReason = execution.result?.stop_reason;
407
- log(`Run ${session.runs_completed}/${contOpts.maxRuns} completed: ${stopReason || 'unknown'}`);
408
-
409
- const resolved = resolveIntent(root, targetIntentId);
410
- if (!resolved.ok) {
411
- log(`Continuous resolve error: ${resolved.error}`);
412
- session.status = 'stopped';
413
- writeContinuousSession(root, session);
414
- return { exitCode: 1, session };
415
- }
416
-
417
- if (stopReason === 'blocked') {
418
- session.status = 'paused';
419
- log('Run blocked — continuous loop paused. Use `agentxchain unblock <id>` to resume.');
420
- writeContinuousSession(root, session);
421
- break;
422
- }
423
-
424
- if (stopReason === 'priority_preempted') {
425
- log('Priority preemption detected — consuming injected work next cycle.');
426
- }
427
-
428
- writeContinuousSession(root, session);
429
-
430
- // Brief cooldown between runs
431
- const cooldownMs = (contOpts.cooldownSeconds ?? 5) * 1000;
432
- if (!stopping && session.runs_completed < contOpts.maxRuns && cooldownMs > 0) {
433
- await new Promise(r => setTimeout(r, cooldownMs));
507
+ // Non-terminal: sleep before next step
508
+ if (!stopping) {
509
+ const sleepMs = step.action === 'no_work_found' || step.action === 'waited_for_human'
510
+ ? contOpts.pollSeconds * 1000
511
+ : (contOpts.cooldownSeconds ?? 5) * 1000;
512
+ if (sleepMs > 0) {
513
+ await new Promise(r => setTimeout(r, sleepMs));
514
+ }
434
515
  }
435
516
  }
436
517
 
@@ -440,7 +521,7 @@ export async function executeContinuousRun(context, contOpts, executeGovernedRun
440
521
  }
441
522
 
442
523
  writeContinuousSession(root, session);
443
- return { exitCode, session };
524
+ return { exitCode: 0, session };
444
525
 
445
526
  } finally {
446
527
  process.removeListener('SIGINT', sigHandler);
@@ -689,6 +689,37 @@ export function validateSchedulesConfig(schedules, roles) {
689
689
  errors.push(`Schedule "${scheduleId}": initial_role "${schedule.initial_role}" is not a defined role`);
690
690
  }
691
691
  }
692
+
693
+ // Continuous mode validation
694
+ if ('continuous' in schedule && schedule.continuous != null) {
695
+ const cont = schedule.continuous;
696
+ if (typeof cont !== 'object' || Array.isArray(cont)) {
697
+ errors.push(`Schedule "${scheduleId}": continuous must be an object`);
698
+ } else {
699
+ if ('enabled' in cont && typeof cont.enabled !== 'boolean') {
700
+ errors.push(`Schedule "${scheduleId}": continuous.enabled must be a boolean`);
701
+ }
702
+ if (cont.enabled === true && (!cont.vision_path || typeof cont.vision_path !== 'string' || !cont.vision_path.trim())) {
703
+ errors.push(`Schedule "${scheduleId}": continuous.vision_path is required when continuous.enabled is true`);
704
+ }
705
+ if ('max_runs' in cont && (!Number.isInteger(cont.max_runs) || cont.max_runs < 1)) {
706
+ errors.push(`Schedule "${scheduleId}": continuous.max_runs must be an integer >= 1`);
707
+ }
708
+ if ('max_idle_cycles' in cont && (!Number.isInteger(cont.max_idle_cycles) || cont.max_idle_cycles < 1)) {
709
+ errors.push(`Schedule "${scheduleId}": continuous.max_idle_cycles must be an integer >= 1`);
710
+ }
711
+ if ('triage_approval' in cont && cont.triage_approval !== 'auto' && cont.triage_approval !== 'human') {
712
+ errors.push(`Schedule "${scheduleId}": continuous.triage_approval must be "auto" or "human"`);
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
+ }
721
+ }
722
+ }
692
723
  }
693
724
 
694
725
  return { ok: errors.length === 0, errors };
@@ -1120,6 +1151,21 @@ export function normalizeV4(raw) {
1120
1151
  };
1121
1152
  }
1122
1153
 
1154
+ function normalizeContinuousConfig(raw) {
1155
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
1156
+ if (raw.enabled !== true) return null;
1157
+ return {
1158
+ enabled: true,
1159
+ vision_path: raw.vision_path || '.planning/VISION.md',
1160
+ max_runs: Number.isInteger(raw.max_runs) && raw.max_runs >= 1 ? raw.max_runs : 50,
1161
+ max_idle_cycles: Number.isInteger(raw.max_idle_cycles) && raw.max_idle_cycles >= 1 ? raw.max_idle_cycles : 5,
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,
1166
+ };
1167
+ }
1168
+
1123
1169
  function normalizeSchedules(rawSchedules) {
1124
1170
  if (!rawSchedules || typeof rawSchedules !== 'object' || Array.isArray(rawSchedules)) {
1125
1171
  return {};
@@ -1135,6 +1181,7 @@ function normalizeSchedules(rawSchedules) {
1135
1181
  max_turns: schedule?.max_turns ?? 50,
1136
1182
  initial_role: schedule?.initial_role || null,
1137
1183
  trigger_reason: schedule?.trigger_reason?.trim() || `schedule:${scheduleId}`,
1184
+ continuous: normalizeContinuousConfig(schedule?.continuous),
1138
1185
  },
1139
1186
  ]),
1140
1187
  );
@@ -27,6 +27,7 @@ function normalizeScheduleStateRecord(value) {
27
27
  last_status: null,
28
28
  last_skip_at: null,
29
29
  last_skip_reason: null,
30
+ last_continuous_session_id: null,
30
31
  };
31
32
  }
32
33
 
@@ -37,6 +38,7 @@ function normalizeScheduleStateRecord(value) {
37
38
  last_status: typeof value.last_status === 'string' ? value.last_status : null,
38
39
  last_skip_at: typeof value.last_skip_at === 'string' ? value.last_skip_at : null,
39
40
  last_skip_reason: typeof value.last_skip_reason === 'string' ? value.last_skip_reason : null,
41
+ last_continuous_session_id: typeof value.last_continuous_session_id === 'string' ? value.last_continuous_session_id : null,
40
42
  };
41
43
  }
42
44