agentxchain 2.116.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/src/lib/intake.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
2
2
  import { join, basename, resolve as pathResolve } from 'node:path';
3
3
  import { createHash, randomBytes } from 'node:crypto';
4
4
  import { safeWriteJson } from './safe-write.js';
@@ -18,7 +18,7 @@ import { loadCoordinatorConfig } from './coordinator-config.js';
18
18
  import { loadCoordinatorState, readBarriers } from './coordinator-state.js';
19
19
  import { writeCoordinatorHandoff } from './intake-handoff.js';
20
20
 
21
- const VALID_SOURCES = ['manual', 'ci_failure', 'git_ref_change', 'schedule'];
21
+ const VALID_SOURCES = ['manual', 'ci_failure', 'git_ref_change', 'schedule', 'vision_scan'];
22
22
  const VALID_PRIORITIES = ['p0', 'p1', 'p2', 'p3'];
23
23
  const EVENT_ID_RE = /^evt_\d+_[0-9a-f]{4}$/;
24
24
  const INTENT_ID_RE = /^intent_\d+_[0-9a-f]{4}$/;
@@ -680,6 +680,18 @@ export function startIntent(root, intentId, options = {}) {
680
680
  return { ok: false, error: 'Failed to parse governed state.json', exitCode: 2 };
681
681
  }
682
682
 
683
+ const allowCompletedRestart = options.allowTerminalRestart === true
684
+ && state.status === 'completed'
685
+ && getActiveTurnCount(state) === 0;
686
+
687
+ const startProvenance = {
688
+ trigger: 'intake',
689
+ intake_intent_id: intent.intent_id,
690
+ trigger_reason: intent.charter || null,
691
+ created_by: 'operator',
692
+ ...(options.provenance && typeof options.provenance === 'object' ? options.provenance : {}),
693
+ };
694
+
683
695
  // Check busy-run conditions
684
696
  const activeTurns = getActiveTurns(state);
685
697
  const activeCount = getActiveTurnCount(state);
@@ -698,7 +710,7 @@ export function startIntent(root, intentId, options = {}) {
698
710
  return { ok: false, error: `cannot start: run is blocked (${reason})`, exitCode: 1 };
699
711
  }
700
712
 
701
- if (state.status === 'completed') {
713
+ if (state.status === 'completed' && !allowCompletedRestart) {
702
714
  return {
703
715
  ok: false,
704
716
  error: 'cannot start: governed run is already completed. S3 does not reopen completed runs.',
@@ -719,14 +731,10 @@ export function startIntent(root, intentId, options = {}) {
719
731
  }
720
732
 
721
733
  // Bootstrap: idle with no run → initialize
722
- if (state.status === 'idle' && !state.run_id) {
734
+ if ((state.status === 'idle' && !state.run_id) || allowCompletedRestart) {
723
735
  const initResult = initializeGovernedRun(root, config, {
724
- provenance: {
725
- trigger: 'intake',
726
- intake_intent_id: intent.intent_id,
727
- trigger_reason: intent.charter || null,
728
- created_by: 'operator',
729
- },
736
+ provenance: startProvenance,
737
+ allow_terminal_restart: allowCompletedRestart,
730
738
  });
731
739
  if (!initResult.ok) {
732
740
  return { ok: false, error: `run initialization failed: ${initResult.error}`, exitCode: 1 };
@@ -1370,8 +1378,232 @@ export function scanSource(root, source, snapshot) {
1370
1378
  };
1371
1379
  }
1372
1380
 
1381
+ // ---------------------------------------------------------------------------
1382
+ // Inject — composed record + triage + approve in one operation
1383
+ // ---------------------------------------------------------------------------
1384
+
1385
+ const PREEMPTION_MARKER_PATH = '.agentxchain/intake/injected-priority.json';
1386
+
1387
+ function preemptionMarkerPath(root) {
1388
+ return join(root, PREEMPTION_MARKER_PATH);
1389
+ }
1390
+
1391
+ export function readPreemptionMarker(root) {
1392
+ const p = preemptionMarkerPath(root);
1393
+ if (!existsSync(p)) return null;
1394
+ try {
1395
+ return JSON.parse(readFileSync(p, 'utf8'));
1396
+ } catch {
1397
+ return null;
1398
+ }
1399
+ }
1400
+
1401
+ export function clearPreemptionMarker(root) {
1402
+ const p = preemptionMarkerPath(root);
1403
+ if (existsSync(p)) {
1404
+ try {
1405
+ unlinkSync(p);
1406
+ } catch {
1407
+ // best-effort
1408
+ }
1409
+ }
1410
+ }
1411
+
1412
+ export function consumePreemptionMarker(root, options = {}) {
1413
+ const marker = readPreemptionMarker(root);
1414
+ if (!marker?.intent_id) {
1415
+ return { ok: false, error: 'no preemption marker found', exitCode: 1 };
1416
+ }
1417
+
1418
+ const loadedIntent = readIntent(root, marker.intent_id);
1419
+ if (!loadedIntent.ok) {
1420
+ return {
1421
+ ok: false,
1422
+ error: `preemption marker references missing intent ${marker.intent_id}`,
1423
+ marker,
1424
+ exitCode: 1,
1425
+ };
1426
+ }
1427
+
1428
+ const startingStatus = loadedIntent.intent.status;
1429
+ let intent = loadedIntent.intent;
1430
+ let planned = false;
1431
+ let started = false;
1432
+
1433
+ if (intent.status === 'approved') {
1434
+ const plannedResult = planIntent(root, intent.intent_id, {
1435
+ projectName: options.projectName,
1436
+ force: options.forcePlan === true,
1437
+ });
1438
+ if (!plannedResult.ok) {
1439
+ return {
1440
+ ok: false,
1441
+ error: `failed to plan injected intent ${intent.intent_id}: ${plannedResult.error}`,
1442
+ marker,
1443
+ intent_status: intent.status,
1444
+ exitCode: plannedResult.exitCode || 1,
1445
+ };
1446
+ }
1447
+ intent = plannedResult.intent;
1448
+ planned = true;
1449
+ }
1450
+
1451
+ if (intent.status === 'planned') {
1452
+ const startResult = startIntent(root, intent.intent_id, {
1453
+ role: options.role || undefined,
1454
+ });
1455
+ if (!startResult.ok) {
1456
+ return {
1457
+ ok: false,
1458
+ error: `failed to start injected intent ${intent.intent_id}: ${startResult.error}`,
1459
+ marker,
1460
+ intent_status: intent.status,
1461
+ exitCode: startResult.exitCode || 1,
1462
+ };
1463
+ }
1464
+ clearPreemptionMarker(root);
1465
+ return {
1466
+ ok: true,
1467
+ marker,
1468
+ intent_id: intent.intent_id,
1469
+ starting_status: startingStatus,
1470
+ final_status: startResult.intent.status,
1471
+ planned,
1472
+ started: true,
1473
+ run_id: startResult.run_id,
1474
+ turn_id: startResult.turn_id,
1475
+ role: startResult.role,
1476
+ intent: startResult.intent,
1477
+ exitCode: 0,
1478
+ };
1479
+ }
1480
+
1481
+ if (intent.status === 'executing') {
1482
+ clearPreemptionMarker(root);
1483
+ return {
1484
+ ok: true,
1485
+ marker,
1486
+ intent_id: intent.intent_id,
1487
+ starting_status: startingStatus,
1488
+ final_status: intent.status,
1489
+ planned,
1490
+ started: false,
1491
+ run_id: intent.target_run || null,
1492
+ turn_id: intent.target_turn || null,
1493
+ role: options.role || null,
1494
+ intent,
1495
+ exitCode: 0,
1496
+ };
1497
+ }
1498
+
1499
+ return {
1500
+ ok: false,
1501
+ error: `cannot consume preemption marker from intent status "${intent.status}"`,
1502
+ marker,
1503
+ intent_id: intent.intent_id,
1504
+ intent_status: intent.status,
1505
+ exitCode: 1,
1506
+ };
1507
+ }
1508
+
1509
+ export function injectIntent(root, description, options = {}) {
1510
+ if (!description || typeof description !== 'string' || !description.trim()) {
1511
+ return { ok: false, error: 'description is required', exitCode: 1 };
1512
+ }
1513
+
1514
+ const priority = options.priority || 'p0';
1515
+ if (!VALID_PRIORITIES.includes(priority)) {
1516
+ return { ok: false, error: `priority must be one of: ${VALID_PRIORITIES.join(', ')}`, exitCode: 1 };
1517
+ }
1518
+
1519
+ const template = options.template || 'generic';
1520
+ if (!VALID_GOVERNED_TEMPLATE_IDS.includes(template)) {
1521
+ return { ok: false, error: `template must be one of: ${VALID_GOVERNED_TEMPLATE_IDS.join(', ')}`, exitCode: 1 };
1522
+ }
1523
+
1524
+ const charter = options.charter || description.trim();
1525
+ const acceptance_contract = options.acceptance
1526
+ ? options.acceptance.split(',').map(s => s.trim()).filter(Boolean)
1527
+ : [description.trim()];
1528
+ const approver = options.approver || 'human';
1529
+ const noApprove = options.noApprove === true;
1530
+
1531
+ // Step 1: Record event
1532
+ const recordResult = recordEvent(root, {
1533
+ source: 'manual',
1534
+ category: 'operator_injection',
1535
+ signal: { description: description.trim(), injected: true, priority },
1536
+ evidence: [{ type: 'text', value: description.trim() }],
1537
+ });
1538
+
1539
+ if (!recordResult.ok) {
1540
+ return recordResult;
1541
+ }
1542
+
1543
+ // If deduplicated, return existing intent
1544
+ if (recordResult.deduplicated) {
1545
+ return {
1546
+ ok: true,
1547
+ intent: recordResult.intent,
1548
+ event: recordResult.event,
1549
+ deduplicated: true,
1550
+ preemption_marker: false,
1551
+ exitCode: 0,
1552
+ };
1553
+ }
1554
+
1555
+ const intentId = recordResult.intent.intent_id;
1556
+
1557
+ // Step 2: Triage
1558
+ const triageResult = triageIntent(root, intentId, {
1559
+ priority,
1560
+ template,
1561
+ charter,
1562
+ acceptance_contract,
1563
+ });
1564
+
1565
+ if (!triageResult.ok) {
1566
+ return { ok: false, error: `triage failed: ${triageResult.error}`, exitCode: 1 };
1567
+ }
1568
+
1569
+ // Step 3: Approve (unless --no-approve)
1570
+ if (!noApprove) {
1571
+ const approveResult = approveIntent(root, intentId, { approver, reason: 'operator injection' });
1572
+ if (!approveResult.ok) {
1573
+ return { ok: false, error: `approve failed: ${approveResult.error}`, exitCode: 1 };
1574
+ }
1575
+ }
1576
+
1577
+ // Step 4: Write preemption marker for p0
1578
+ let preemptionMarker = false;
1579
+ if (priority === 'p0' && !noApprove) {
1580
+ const marker = {
1581
+ intent_id: intentId,
1582
+ priority,
1583
+ description: description.trim(),
1584
+ injected_at: nowISO(),
1585
+ };
1586
+ ensureIntakeDirs(root);
1587
+ safeWriteJson(preemptionMarkerPath(root), marker);
1588
+ preemptionMarker = true;
1589
+ }
1590
+
1591
+ // Re-read final intent state
1592
+ const finalRead = readIntent(root, intentId);
1593
+ const finalIntent = finalRead.ok ? finalRead.intent : triageResult.intent;
1594
+
1595
+ return {
1596
+ ok: true,
1597
+ intent: finalIntent,
1598
+ event: recordResult.event,
1599
+ deduplicated: false,
1600
+ preemption_marker: preemptionMarker,
1601
+ exitCode: 0,
1602
+ };
1603
+ }
1604
+
1373
1605
  // ---------------------------------------------------------------------------
1374
1606
  // Exports for testing
1375
1607
  // ---------------------------------------------------------------------------
1376
1608
 
1377
- export { VALID_SOURCES, VALID_PRIORITIES, VALID_TRANSITIONS, S1_STATES, TERMINAL_STATES, SCAN_SOURCES };
1609
+ export { VALID_SOURCES, VALID_PRIORITIES, VALID_TRANSITIONS, S1_STATES, TERMINAL_STATES, SCAN_SOURCES, PREEMPTION_MARKER_PATH };
@@ -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
  );
@@ -10,6 +10,8 @@ export const VALID_NOTIFICATION_EVENTS = [
10
10
  'run_blocked',
11
11
  'operator_escalation_raised',
12
12
  'escalation_resolved',
13
+ 'human_escalation_raised',
14
+ 'human_escalation_resolved',
13
15
  'phase_transition_pending',
14
16
  'run_completion_pending',
15
17
  'run_completed',
@@ -170,7 +172,7 @@ export function validateNotificationsConfig(notifications) {
170
172
  return { ok: false, errors, warnings: [] };
171
173
  }
172
174
 
173
- const allowedKeys = new Set(['webhooks', 'approval_sla']);
175
+ const allowedKeys = new Set(['webhooks', 'approval_sla', 'local']);
174
176
  for (const key of Object.keys(notifications)) {
175
177
  if (!allowedKeys.has(key)) {
176
178
  errors.push(`notifications contains unknown field "${key}"`);
@@ -26,6 +26,8 @@ export const VALID_RUN_EVENTS = [
26
26
  'gate_approved',
27
27
  'gate_failed',
28
28
  'budget_exceeded_warn',
29
+ 'human_escalation_raised',
30
+ 'human_escalation_resolved',
29
31
  ];
30
32
 
31
33
  /**
@@ -36,6 +36,7 @@ import { runAdmissionControl } from './admission-control.js';
36
36
  import { mkdirSync, writeFileSync } from 'fs';
37
37
  import { join, dirname } from 'path';
38
38
  import { evaluateApprovalSlaReminders } from './notification-runner.js';
39
+ import { readPreemptionMarker } from './intake.js';
39
40
 
40
41
  const DEFAULT_MAX_TURNS = 50;
41
42
 
@@ -128,6 +129,22 @@ export async function runLoop(root, config, callbacks, options = {}) {
128
129
  return makeResult(false, 'max_turns_reached', state, turnsExecuted, turnHistory, gatesApproved, errors);
129
130
  }
130
131
 
132
+ // ── Priority preemption check ────────────────────────────────────────
133
+ // If a p0 intent was injected via `agentxchain inject`, yield the run
134
+ // so the scheduler/continuous loop can pick up the injected work.
135
+ // Only preempt when no turns are currently active (avoid mid-dispatch
136
+ // interruption).
137
+ const activeTurnCount = getActiveTurnCount(state);
138
+ if (activeTurnCount === 0) {
139
+ const marker = readPreemptionMarker(root);
140
+ if (marker && marker.priority === 'p0') {
141
+ emit({ type: 'priority_injected', intent_id: marker.intent_id, priority: marker.priority });
142
+ const result = makeResult(false, 'priority_preempted', state, turnsExecuted, turnHistory, gatesApproved, errors);
143
+ result.preempted_by = marker.intent_id;
144
+ return result;
145
+ }
146
+ }
147
+
131
148
  // ── Determine concurrency mode ────────────────────────────────────────
132
149
  const maxConcurrent = getMaxConcurrentTurns(config, state.phase);
133
150
 
@@ -3,12 +3,14 @@ const VALID_TRIGGERS = new Set([
3
3
  'continuation',
4
4
  'recovery',
5
5
  'intake',
6
+ 'vision_scan',
6
7
  'schedule',
7
8
  'coordinator',
8
9
  ]);
9
10
 
10
11
  const VALID_CREATORS = new Set([
11
12
  'operator',
13
+ 'continuous_loop',
12
14
  'coordinator',
13
15
  ]);
14
16
 
@@ -71,6 +73,8 @@ export function summarizeRunProvenance(provenance) {
71
73
  : normalized.trigger;
72
74
  const creatorSuffix = normalized.created_by === 'coordinator'
73
75
  ? ' (created by coordinator)'
76
+ : normalized.created_by === 'continuous_loop'
77
+ ? ' (created by continuous loop)'
74
78
  : '';
75
79
  const reasonSuffix = normalized.trigger_reason
76
80
  ? ` ("${normalized.trigger_reason}")`
@@ -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
 
@@ -161,6 +163,49 @@ export function evaluateScheduleLaunchEligibility(root, config) {
161
163
  return { ok: false, status, reason: `run_${status}` };
162
164
  }
163
165
 
166
+ function resolveScheduleTriggerReason(scheduleId, schedule) {
167
+ return schedule?.trigger_reason || `schedule:${scheduleId}`;
168
+ }
169
+
170
+ export function findContinuableScheduleRun(root, config, { scheduleId = null } = {}) {
171
+ const projectState = loadProjectState(root, config);
172
+ if (!projectState || projectState.status !== 'active' || !projectState.run_id) {
173
+ return { ok: false, reason: 'not_active' };
174
+ }
175
+
176
+ if (projectState.provenance?.trigger !== 'schedule') {
177
+ return { ok: false, reason: 'not_schedule_run' };
178
+ }
179
+
180
+ const scheduleState = readScheduleState(root, config);
181
+ const matches = Object.entries(config?.schedules || {}).filter(([candidateId, schedule]) => {
182
+ if (scheduleId && candidateId !== scheduleId) return false;
183
+
184
+ const record = scheduleState.schedules[candidateId];
185
+ if (record?.last_run_id === projectState.run_id) {
186
+ return true;
187
+ }
188
+
189
+ return projectState.provenance?.trigger_reason === resolveScheduleTriggerReason(candidateId, schedule);
190
+ });
191
+
192
+ if (matches.length === 0) {
193
+ return { ok: false, reason: 'no_matching_schedule' };
194
+ }
195
+
196
+ if (matches.length > 1) {
197
+ return { ok: false, reason: 'ambiguous_schedule_run' };
198
+ }
199
+
200
+ const [matchedId, matchedSchedule] = matches[0];
201
+ return {
202
+ ok: true,
203
+ schedule_id: matchedId,
204
+ schedule: matchedSchedule,
205
+ state: projectState,
206
+ };
207
+ }
208
+
164
209
  // ── Daemon Health State ─────────────────────────────────────────────────────
165
210
 
166
211
  export function readDaemonState(root) {