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.
- package/bin/agentxchain.js +1 -0
- package/package.json +1 -1
- package/scripts/render-github-release-body.mjs +1 -1
- package/src/commands/run.js +7 -0
- package/src/commands/schedule.js +232 -4
- package/src/commands/status.js +14 -0
- package/src/lib/continuous-run.js +198 -117
- package/src/lib/normalized-config.js +47 -0
- package/src/lib/run-schedule.js +2 -0
package/bin/agentxchain.js
CHANGED
|
@@ -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
|
@@ -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) {
|
package/src/commands/run.js
CHANGED
|
@@ -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);
|
package/src/commands/schedule.js
CHANGED
|
@@ -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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
|
package/src/commands/status.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
326
|
-
if (
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
//
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
);
|
package/src/lib/run-schedule.js
CHANGED
|
@@ -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
|
|