agentxchain 2.117.0 → 2.118.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.117.0",
3
+ "version": "2.118.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) {
@@ -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,159 @@ 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
+ };
385
+ }
386
+
387
+ async function advanceScheduleContinuousSession(context, entry, opts = {}) {
388
+ const { root, config } = context;
389
+ const scheduleId = entry.id;
390
+ const schedule = entry.schedule;
391
+ const contConfig = schedule.continuous;
392
+ const log = opts.json ? () => {} : console.log;
393
+
394
+ // Read existing session
395
+ let session = readContinuousSession(root);
396
+
397
+ // If there's an active session owned by a different schedule, fail closed
398
+ if (session && !isSessionTerminal(session) && session.owner_type === 'schedule' && session.owner_id !== scheduleId) {
399
+ return {
400
+ ok: false,
401
+ action: 'skipped',
402
+ reason: `continuous session owned by schedule "${session.owner_id}"`,
403
+ };
404
+ }
405
+
406
+ // Determine if we need a new session
407
+ const needsNewSession = !session || isSessionTerminal(session) || session.owner_id !== scheduleId;
408
+
409
+ if (needsNewSession) {
410
+ // Only start a new session if the schedule is due
411
+ if (!opts.isDue) {
412
+ return { ok: true, action: 'not_due', reason: 'waiting_interval' };
413
+ }
414
+
415
+ // Check launch eligibility
416
+ const eligibility = evaluateScheduleLaunchEligibility(root, config);
417
+ if (!eligibility.ok) {
418
+ return { ok: false, action: 'skipped', reason: eligibility.reason };
419
+ }
420
+
421
+ // Validate vision path
422
+ const absVision = resolveVisionPath(root, contConfig.vision_path);
423
+ if (!existsSync(absVision)) {
424
+ return { ok: false, action: 'failed', reason: `VISION.md not found at ${absVision}` };
425
+ }
426
+
427
+ session = createScheduleOwnedSession(schedule, scheduleId);
428
+ writeContinuousSession(root, session);
429
+ log(chalk.cyan(`Started schedule-owned continuous session: ${session.session_id} (schedule: ${scheduleId})`));
430
+
431
+ // Record schedule start
432
+ updateScheduleState(root, config, scheduleId, (record) => ({
433
+ ...record,
434
+ last_started_at: new Date().toISOString(),
435
+ last_status: 'continuous_running',
436
+ last_continuous_session_id: session.session_id,
437
+ }));
438
+ }
439
+
440
+ // Build contOpts from schedule continuous config
441
+ const contOpts = {
442
+ visionPath: contConfig.vision_path,
443
+ maxRuns: contConfig.max_runs,
444
+ maxIdleCycles: contConfig.max_idle_cycles,
445
+ triageApproval: contConfig.triage_approval,
446
+ };
447
+
448
+ // Advance one step
449
+ const step = await advanceContinuousRunOnce(context, session, contOpts, executeGovernedRun, log);
450
+
451
+ // Update schedule state based on step result
452
+ const statusMap = {
453
+ completed: 'continuous_completed',
454
+ idle_exit: 'continuous_idle_exit',
455
+ failed: 'continuous_failed',
456
+ blocked: 'continuous_blocked',
457
+ running: 'continuous_running',
458
+ };
459
+ const schedStatus = statusMap[step.status] || 'continuous_running';
460
+
461
+ updateScheduleState(root, config, scheduleId, (record) => ({
462
+ ...record,
463
+ last_finished_at: new Date().toISOString(),
464
+ last_status: schedStatus,
465
+ last_run_id: step.run_id || record.last_run_id,
466
+ last_continuous_session_id: session.session_id,
467
+ }));
468
+
469
+ return {
470
+ ok: step.ok,
471
+ action: step.action,
472
+ status: step.status,
473
+ session_id: session.session_id,
474
+ run_id: step.run_id || null,
475
+ intent_id: step.intent_id || null,
476
+ runs_completed: session.runs_completed,
477
+ };
478
+ }
479
+
314
480
  export async function scheduleListCommand(opts) {
315
481
  const context = loadScheduleContext();
316
482
  if (!context) return;
@@ -480,11 +646,66 @@ export async function scheduleDaemonCommand(opts) {
480
646
  while (true) {
481
647
  cycle += 1;
482
648
  daemonState.last_cycle_started_at = new Date().toISOString();
483
- const result = await runDueSchedules(context, {
484
- ...opts,
485
- continueActiveScheduleRuns: true,
486
- tolerateBlockedRun: true,
649
+
650
+ // Check for continuous schedule entries first
651
+ const contEntry = selectContinuousScheduleEntry(context.root, context.config, {
652
+ scheduleId: opts.schedule || null,
653
+ at: opts.at,
487
654
  });
655
+ let result;
656
+
657
+ if (contEntry?.error) {
658
+ result = {
659
+ ok: false,
660
+ exitCode: 1,
661
+ results: [{
662
+ id: contEntry.id,
663
+ action: 'failed',
664
+ continuous: true,
665
+ reason: contEntry.error,
666
+ }],
667
+ };
668
+ } else if (contEntry) {
669
+ const isDue = contEntry.due ?? false;
670
+
671
+ const contResult = await advanceScheduleContinuousSession(context, contEntry, {
672
+ isDue,
673
+ json: opts.json,
674
+ at: opts.at,
675
+ });
676
+
677
+ // Run non-continuous schedules normally alongside
678
+ const nonContResult = await runDueSchedules(context, {
679
+ ...opts,
680
+ continueActiveScheduleRuns: true,
681
+ tolerateBlockedRun: true,
682
+ excludeSchedule: contEntry.id,
683
+ });
684
+
685
+ // Merge results
686
+ const contResultEntry = {
687
+ id: contEntry.id,
688
+ action: contResult.action,
689
+ continuous: true,
690
+ session_id: contResult.session_id || null,
691
+ status: contResult.status || null,
692
+ run_id: contResult.run_id || null,
693
+ runs_completed: contResult.runs_completed ?? null,
694
+ };
695
+ if (contResult.reason) contResultEntry.reason = contResult.reason;
696
+
697
+ result = {
698
+ ok: contResult.ok !== false && nonContResult.ok,
699
+ exitCode: (contResult.ok === false || !nonContResult.ok) ? 1 : 0,
700
+ results: [contResultEntry, ...nonContResult.results],
701
+ };
702
+ } else {
703
+ result = await runDueSchedules(context, {
704
+ ...opts,
705
+ continueActiveScheduleRuns: true,
706
+ tolerateBlockedRun: true,
707
+ });
708
+ }
488
709
 
489
710
  updateDaemonHeartbeat(context.root, daemonState, result);
490
711
 
@@ -206,6 +206,9 @@ 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
  }
@@ -278,7 +278,155 @@ export function resolveContinuousOptions(opts, config) {
278
278
  }
279
279
 
280
280
  // ---------------------------------------------------------------------------
281
- // Main continuous loop
281
+ // Single-step continuous advancement primitive
282
+ // ---------------------------------------------------------------------------
283
+
284
+ /**
285
+ * Advance a continuous session by exactly one step.
286
+ *
287
+ * This is the shared primitive used by both `run --continuous` (CLI-owned loop)
288
+ * and `schedule daemon` (daemon-owned poll). Neither caller embeds a nested
289
+ * poll/sleep loop — the caller owns cadence, this function owns one step.
290
+ *
291
+ * @param {object} context - { root, config }
292
+ * @param {object} session - mutable session object (read/written by caller)
293
+ * @param {object} contOpts - resolved continuous options (visionPath, maxRuns, maxIdleCycles, triageApproval)
294
+ * @param {Function} executeGovernedRun - the run executor function
295
+ * @param {Function} [log] - logging function
296
+ * @returns {Promise<{ ok: boolean, status: string, action: string, run_id?: string, intent_id?: string, stop_reason?: string }>}
297
+ */
298
+ export async function advanceContinuousRunOnce(context, session, contOpts, executeGovernedRun, log = console.log) {
299
+ const { root } = context;
300
+ const absVisionPath = resolveVisionPath(root, contOpts.visionPath);
301
+
302
+ // Terminal checks
303
+ if (session.runs_completed >= contOpts.maxRuns) {
304
+ session.status = 'completed';
305
+ writeContinuousSession(root, session);
306
+ return { ok: true, status: 'completed', action: 'max_runs_reached', stop_reason: 'max_runs' };
307
+ }
308
+
309
+ if (session.idle_cycles >= contOpts.maxIdleCycles) {
310
+ session.status = 'completed';
311
+ writeContinuousSession(root, session);
312
+ return { ok: true, status: 'idle_exit', action: 'max_idle_reached', stop_reason: 'idle_exit' };
313
+ }
314
+
315
+ // Validate vision file
316
+ if (!existsSync(absVisionPath)) {
317
+ session.status = 'failed';
318
+ writeContinuousSession(root, session);
319
+ return { ok: false, status: 'failed', action: 'vision_missing', stop_reason: `VISION.md not found at ${absVisionPath}` };
320
+ }
321
+
322
+ // Step 1: Check intake queue for pending work
323
+ const queued = findNextQueuedIntent(root);
324
+ let targetIntentId = null;
325
+ let visionObjective = null;
326
+
327
+ if (queued.ok) {
328
+ targetIntentId = queued.intentId;
329
+ session.idle_cycles = 0;
330
+ log(`Found queued intent: ${queued.intentId} (${queued.status})`);
331
+ } else {
332
+ // Step 2: Derive from vision
333
+ const seeded = seedFromVision(root, absVisionPath, {
334
+ triageApproval: contOpts.triageApproval,
335
+ });
336
+
337
+ if (!seeded.ok) {
338
+ log(`Vision scan error: ${seeded.error}`);
339
+ session.status = 'failed';
340
+ writeContinuousSession(root, session);
341
+ return { ok: false, status: 'failed', action: 'vision_scan_error', stop_reason: seeded.error };
342
+ }
343
+
344
+ if (seeded.idle) {
345
+ session.idle_cycles += 1;
346
+ log(`Idle cycle ${session.idle_cycles}/${contOpts.maxIdleCycles} — no derivable work from vision.`);
347
+ writeContinuousSession(root, session);
348
+ return { ok: true, status: 'running', action: 'no_work_found' };
349
+ }
350
+
351
+ // If triage_approval is "human", the intent is in "triaged" state — don't auto-start
352
+ if (contOpts.triageApproval === 'human') {
353
+ log(`Vision-derived intent ${seeded.intentId} left in triaged state (triage_approval: human).`);
354
+ session.idle_cycles += 1;
355
+ writeContinuousSession(root, session);
356
+ return { ok: true, status: 'running', action: 'waited_for_human', intent_id: seeded.intentId };
357
+ }
358
+
359
+ targetIntentId = seeded.intentId;
360
+ visionObjective = `${seeded.section}: ${seeded.goal}`;
361
+ session.idle_cycles = 0;
362
+ log(`Vision-derived: ${visionObjective}`);
363
+ }
364
+
365
+ // Prepare intent through intake lifecycle
366
+ const provenance = buildContinuousProvenance(targetIntentId, {
367
+ trigger: visionObjective ? 'vision_scan' : 'intake',
368
+ triggerReason: visionObjective || readIntent(root, targetIntentId)?.charter || null,
369
+ });
370
+ const preparedIntent = prepareIntentForRun(root, targetIntentId, { provenance });
371
+ if (!preparedIntent.ok) {
372
+ log(`Continuous start error: ${preparedIntent.error}`);
373
+ session.status = 'failed';
374
+ writeContinuousSession(root, session);
375
+ return { ok: false, status: 'failed', action: 'prepare_failed', stop_reason: preparedIntent.error, intent_id: targetIntentId };
376
+ }
377
+
378
+ // Execute the governed run
379
+ session.current_run_id = preparedIntent.runId;
380
+ session.current_vision_objective = visionObjective || preparedIntent.intent?.charter || null;
381
+ session.status = 'running';
382
+ writeContinuousSession(root, session);
383
+
384
+ const execution = await executeGovernedRun(context, {
385
+ autoApprove: true,
386
+ report: true,
387
+ log,
388
+ });
389
+
390
+ session.runs_completed += 1;
391
+ session.current_run_id = execution.result?.state?.run_id || null;
392
+
393
+ const stopReason = execution.result?.stop_reason;
394
+ log(`Run ${session.runs_completed}/${contOpts.maxRuns} completed: ${stopReason || 'unknown'}`);
395
+
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';
401
+ writeContinuousSession(root, session);
402
+ return { ok: false, status: 'failed', action: 'resolve_failed', stop_reason: resolved.error, intent_id: targetIntentId };
403
+ }
404
+
405
+ if (stopReason === 'blocked') {
406
+ session.status = 'paused';
407
+ log('Run blocked — continuous loop paused. Use `agentxchain unblock <id>` to resume.');
408
+ writeContinuousSession(root, session);
409
+ return { ok: true, status: 'blocked', action: 'run_blocked', run_id: session.current_run_id, intent_id: targetIntentId };
410
+ }
411
+
412
+ if (stopReason === 'priority_preempted') {
413
+ log('Priority preemption detected — consuming injected work next cycle.');
414
+ writeContinuousSession(root, session);
415
+ return { ok: true, status: 'running', action: 'consumed_injected_priority', run_id: session.current_run_id, intent_id: targetIntentId };
416
+ }
417
+
418
+ writeContinuousSession(root, session);
419
+ return {
420
+ ok: true,
421
+ status: 'running',
422
+ action: visionObjective ? 'seeded_from_vision' : 'started_run',
423
+ run_id: session.current_run_id,
424
+ intent_id: targetIntentId,
425
+ };
426
+ }
427
+
428
+ // ---------------------------------------------------------------------------
429
+ // Main continuous loop (CLI-owned, built on advanceContinuousRunOnce)
282
430
  // ---------------------------------------------------------------------------
283
431
 
284
432
  /**
@@ -293,7 +441,6 @@ export function resolveContinuousOptions(opts, config) {
293
441
  export async function executeContinuousRun(context, contOpts, executeGovernedRun, log = console.log) {
294
442
  const { root } = context;
295
443
  const absVisionPath = resolveVisionPath(root, contOpts.visionPath);
296
- let exitCode = 0;
297
444
 
298
445
  // Validate vision file exists
299
446
  if (!existsSync(absVisionPath)) {
@@ -315,122 +462,26 @@ export async function executeContinuousRun(context, contOpts, executeGovernedRun
315
462
 
316
463
  try {
317
464
  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
- }
324
-
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;
465
+ const step = await advanceContinuousRunOnce(context, session, contOpts, executeGovernedRun, log);
466
+
467
+ // 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.`);
353
473
  }
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;
371
- }
372
-
373
- targetIntentId = seeded.intentId;
374
- visionObjective = `${seeded.section}: ${seeded.goal}`;
375
- session.idle_cycles = 0;
376
- log(`Vision-derived: ${visionObjective}`);
474
+ return { exitCode: step.ok ? 0 : 1, session };
377
475
  }
378
476
 
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;
389
- }
390
-
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));
477
+ // Non-terminal: sleep before next step
478
+ if (!stopping) {
479
+ const sleepMs = step.action === 'no_work_found' || step.action === 'waited_for_human'
480
+ ? contOpts.pollSeconds * 1000
481
+ : (contOpts.cooldownSeconds ?? 5) * 1000;
482
+ if (sleepMs > 0) {
483
+ await new Promise(r => setTimeout(r, sleepMs));
484
+ }
434
485
  }
435
486
  }
436
487
 
@@ -440,7 +491,7 @@ export async function executeContinuousRun(context, contOpts, executeGovernedRun
440
491
  }
441
492
 
442
493
  writeContinuousSession(root, session);
443
- return { exitCode, session };
494
+ return { exitCode: 0, session };
444
495
 
445
496
  } finally {
446
497
  process.removeListener('SIGINT', sigHandler);
@@ -689,6 +689,30 @@ 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
+ }
715
+ }
692
716
  }
693
717
 
694
718
  return { ok: errors.length === 0, errors };
@@ -1120,6 +1144,18 @@ export function normalizeV4(raw) {
1120
1144
  };
1121
1145
  }
1122
1146
 
1147
+ function normalizeContinuousConfig(raw) {
1148
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
1149
+ if (raw.enabled !== true) return null;
1150
+ return {
1151
+ enabled: true,
1152
+ vision_path: raw.vision_path || '.planning/VISION.md',
1153
+ max_runs: Number.isInteger(raw.max_runs) && raw.max_runs >= 1 ? raw.max_runs : 50,
1154
+ max_idle_cycles: Number.isInteger(raw.max_idle_cycles) && raw.max_idle_cycles >= 1 ? raw.max_idle_cycles : 5,
1155
+ triage_approval: raw.triage_approval === 'human' ? 'human' : 'auto',
1156
+ };
1157
+ }
1158
+
1123
1159
  function normalizeSchedules(rawSchedules) {
1124
1160
  if (!rawSchedules || typeof rawSchedules !== 'object' || Array.isArray(rawSchedules)) {
1125
1161
  return {};
@@ -1135,6 +1171,7 @@ function normalizeSchedules(rawSchedules) {
1135
1171
  max_turns: schedule?.max_turns ?? 50,
1136
1172
  initial_role: schedule?.initial_role || null,
1137
1173
  trigger_reason: schedule?.trigger_reason?.trim() || `schedule:${scheduleId}`,
1174
+ continuous: normalizeContinuousConfig(schedule?.continuous),
1138
1175
  },
1139
1176
  ]),
1140
1177
  );
@@ -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