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.
@@ -0,0 +1,499 @@
1
+ /**
2
+ * Continuous Vision-Driven Run — lights-out governed execution loop.
3
+ *
4
+ * When the intake queue is empty, derives candidate intents from VISION.md
5
+ * and feeds them through the existing intake pipeline. Chains governed runs
6
+ * back-to-back until max_runs, max_idle_cycles, or operator stop.
7
+ *
8
+ * Spec: .planning/VISION_DRIVEN_CONTINUOUS_SPEC.md
9
+ * Decision: DEC-VISION-CONTINUOUS-001
10
+ */
11
+
12
+ import { existsSync, readFileSync, readdirSync, mkdirSync, unlinkSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { randomUUID } from 'node:crypto';
15
+ import { resolveVisionPath, deriveVisionCandidates } from './vision-reader.js';
16
+ import {
17
+ recordEvent,
18
+ triageIntent,
19
+ approveIntent,
20
+ planIntent,
21
+ startIntent,
22
+ resolveIntent,
23
+ } from './intake.js';
24
+ import { safeWriteJson } from './safe-write.js';
25
+
26
+ const CONTINUOUS_SESSION_PATH = '.agentxchain/continuous-session.json';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Session state
30
+ // ---------------------------------------------------------------------------
31
+
32
+ export function readContinuousSession(root) {
33
+ const p = join(root, CONTINUOUS_SESSION_PATH);
34
+ if (!existsSync(p)) return null;
35
+ try {
36
+ return JSON.parse(readFileSync(p, 'utf8'));
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ export function writeContinuousSession(root, session) {
43
+ const dir = join(root, '.agentxchain');
44
+ mkdirSync(dir, { recursive: true });
45
+ safeWriteJson(join(root, CONTINUOUS_SESSION_PATH), session);
46
+ }
47
+
48
+ export function removeContinuousSession(root) {
49
+ const p = join(root, CONTINUOUS_SESSION_PATH);
50
+ try {
51
+ if (existsSync(p)) unlinkSync(p);
52
+ } catch {
53
+ // best-effort cleanup
54
+ }
55
+ }
56
+
57
+ function createSession(visionPath, maxRuns, maxIdleCycles) {
58
+ return {
59
+ session_id: `cont-${randomUUID().slice(0, 8)}`,
60
+ started_at: new Date().toISOString(),
61
+ vision_path: visionPath,
62
+ runs_completed: 0,
63
+ max_runs: maxRuns,
64
+ idle_cycles: 0,
65
+ max_idle_cycles: maxIdleCycles,
66
+ current_run_id: null,
67
+ current_vision_objective: null,
68
+ status: 'running',
69
+ };
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Intake queue check
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Find the next approved or planned intent in the intake queue.
78
+ *
79
+ * @param {string} root
80
+ * @returns {{ ok: boolean, intentId?: string, status?: string }}
81
+ */
82
+ export function findNextQueuedIntent(root) {
83
+ const intentsDir = join(root, '.agentxchain', 'intake', 'intents');
84
+ if (!existsSync(intentsDir)) return { ok: false };
85
+
86
+ const files = readdirSync(intentsDir).filter(f => f.endsWith('.json') && !f.startsWith('.tmp-'));
87
+
88
+ // Priority order: planned > approved (planned is closer to execution)
89
+ let bestPlanned = null;
90
+ let bestApproved = null;
91
+
92
+ for (const file of files) {
93
+ try {
94
+ const intent = JSON.parse(readFileSync(join(intentsDir, file), 'utf8'));
95
+ if (intent.status === 'planned' && !bestPlanned) {
96
+ bestPlanned = { intentId: intent.intent_id, status: 'planned' };
97
+ } else if (intent.status === 'approved' && !bestApproved) {
98
+ bestApproved = { intentId: intent.intent_id, status: 'approved' };
99
+ }
100
+ } catch {
101
+ // skip corrupt
102
+ }
103
+ }
104
+
105
+ if (bestPlanned) return { ok: true, ...bestPlanned };
106
+ if (bestApproved) return { ok: true, ...bestApproved };
107
+ return { ok: false };
108
+ }
109
+
110
+ function readIntent(root, intentId) {
111
+ const intentPath = join(root, '.agentxchain', 'intake', 'intents', `${intentId}.json`);
112
+ if (!existsSync(intentPath)) return null;
113
+ try {
114
+ return JSON.parse(readFileSync(intentPath, 'utf8'));
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ function buildContinuousProvenance(intentId, options = {}) {
121
+ const { trigger = 'intake', triggerReason = null } = options;
122
+ return {
123
+ trigger,
124
+ intake_intent_id: intentId,
125
+ trigger_reason: triggerReason,
126
+ created_by: 'continuous_loop',
127
+ };
128
+ }
129
+
130
+ function prepareIntentForRun(root, intentId, options = {}) {
131
+ let intent = readIntent(root, intentId);
132
+ if (!intent) {
133
+ return { ok: false, error: `intent ${intentId} not found` };
134
+ }
135
+
136
+ if (intent.status === 'approved') {
137
+ const planned = planIntent(root, intentId);
138
+ if (!planned.ok) {
139
+ return { ok: false, error: `plan failed: ${planned.error}` };
140
+ }
141
+ intent = planned.intent;
142
+ }
143
+
144
+ if (intent.status === 'planned') {
145
+ const started = startIntent(root, intentId, {
146
+ allowTerminalRestart: true,
147
+ provenance: options.provenance,
148
+ });
149
+ if (!started.ok) {
150
+ return { ok: false, error: `start failed: ${started.error}` };
151
+ }
152
+ intent = started.intent;
153
+ return {
154
+ ok: true,
155
+ intent,
156
+ runId: started.run_id,
157
+ turnId: started.turn_id,
158
+ };
159
+ }
160
+
161
+ if (intent.status === 'executing') {
162
+ return {
163
+ ok: true,
164
+ intent,
165
+ runId: intent.target_run || null,
166
+ turnId: intent.target_turn || null,
167
+ };
168
+ }
169
+
170
+ return {
171
+ ok: false,
172
+ error: `intent ${intentId} is in unsupported status "${intent.status}" for continuous execution`,
173
+ };
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Vision-to-intake pipeline
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /**
181
+ * Derive the next vision candidate and record it through the intake pipeline.
182
+ *
183
+ * @param {string} root
184
+ * @param {string} visionPath - Absolute path to VISION.md
185
+ * @param {{ triageApproval?: string }} options
186
+ * @returns {{ ok: boolean, intentId?: string, section?: string, goal?: string, error?: string, idle?: boolean }}
187
+ */
188
+ export function seedFromVision(root, visionPath, options = {}) {
189
+ const result = deriveVisionCandidates(root, visionPath);
190
+ if (!result.ok) {
191
+ return { ok: false, error: result.error };
192
+ }
193
+
194
+ if (result.candidates.length === 0) {
195
+ return { ok: true, idle: true };
196
+ }
197
+
198
+ // Take the first unaddressed candidate
199
+ const candidate = result.candidates[0];
200
+
201
+ // Record event through intake
202
+ const eventResult = recordEvent(root, {
203
+ source: 'vision_scan',
204
+ category: 'vision_derived',
205
+ signal: {
206
+ description: candidate.goal,
207
+ vision_section: candidate.section,
208
+ derived: true,
209
+ },
210
+ evidence: [
211
+ { type: 'text', value: `Vision section: ${candidate.section} — Goal: ${candidate.goal}` },
212
+ ],
213
+ });
214
+
215
+ if (!eventResult.ok) {
216
+ // Deduplication is normal — means this goal already has an intent
217
+ if (eventResult.deduplicated) {
218
+ return { ok: true, idle: true };
219
+ }
220
+ return { ok: false, error: `intake record failed: ${eventResult.error}` };
221
+ }
222
+
223
+ if (eventResult.deduplicated) {
224
+ return { ok: true, idle: true };
225
+ }
226
+
227
+ const intentId = eventResult.intent.intent_id;
228
+
229
+ // Triage
230
+ const triageResult = triageIntent(root, intentId, {
231
+ priority: candidate.priority,
232
+ template: 'generic',
233
+ charter: `[vision] ${candidate.section}: ${candidate.goal}`,
234
+ acceptance_contract: [`Vision goal addressed: ${candidate.goal}`],
235
+ });
236
+
237
+ if (!triageResult.ok) {
238
+ return { ok: false, error: `triage failed: ${triageResult.error}` };
239
+ }
240
+
241
+ // Auto-approve if configured
242
+ const triageApproval = options.triageApproval || 'auto';
243
+ if (triageApproval === 'auto') {
244
+ const approveResult = approveIntent(root, intentId, {
245
+ approver: 'continuous_loop',
246
+ reason: 'vision-derived auto-approval',
247
+ });
248
+ if (!approveResult.ok) {
249
+ return { ok: false, error: `approve failed: ${approveResult.error}` };
250
+ }
251
+ }
252
+
253
+ return {
254
+ ok: true,
255
+ idle: false,
256
+ intentId,
257
+ section: candidate.section,
258
+ goal: candidate.goal,
259
+ };
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Resolve continuous options from CLI flags + config
264
+ // ---------------------------------------------------------------------------
265
+
266
+ export function resolveContinuousOptions(opts, config) {
267
+ const configCont = config?.run_loop?.continuous || {};
268
+
269
+ return {
270
+ enabled: opts.continuous ?? configCont.enabled ?? false,
271
+ visionPath: opts.vision ?? configCont.vision_path ?? '.planning/VISION.md',
272
+ maxRuns: opts.maxRuns ?? configCont.max_runs ?? 100,
273
+ pollSeconds: opts.pollSeconds ?? configCont.poll_seconds ?? 30,
274
+ maxIdleCycles: opts.maxIdleCycles ?? configCont.max_idle_cycles ?? 3,
275
+ triageApproval: configCont.triage_approval ?? 'auto',
276
+ cooldownSeconds: opts.cooldownSeconds ?? configCont.cooldown_seconds ?? 5,
277
+ };
278
+ }
279
+
280
+ // ---------------------------------------------------------------------------
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)
430
+ // ---------------------------------------------------------------------------
431
+
432
+ /**
433
+ * Execute the continuous vision-driven run loop.
434
+ *
435
+ * @param {object} context - { root, config }
436
+ * @param {object} contOpts - resolved continuous options
437
+ * @param {Function} executeGovernedRun - the run executor function
438
+ * @param {Function} [log] - logging function
439
+ * @returns {Promise<{ exitCode: number, session: object }>}
440
+ */
441
+ export async function executeContinuousRun(context, contOpts, executeGovernedRun, log = console.log) {
442
+ const { root } = context;
443
+ const absVisionPath = resolveVisionPath(root, contOpts.visionPath);
444
+
445
+ // Validate vision file exists
446
+ if (!existsSync(absVisionPath)) {
447
+ log(`Error: VISION.md not found at ${absVisionPath}`);
448
+ log(`Create a .planning/VISION.md for your project to enable vision-driven operation.`);
449
+ return { exitCode: 1, session: null };
450
+ }
451
+
452
+ const session = createSession(contOpts.visionPath, contOpts.maxRuns, contOpts.maxIdleCycles);
453
+ writeContinuousSession(root, session);
454
+
455
+ // SIGINT handler
456
+ let stopping = false;
457
+ const sigHandler = () => {
458
+ stopping = true;
459
+ log('\nStopping continuous loop (finishing current work)...');
460
+ };
461
+ process.on('SIGINT', sigHandler);
462
+
463
+ try {
464
+ while (!stopping) {
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.`);
473
+ }
474
+ return { exitCode: step.ok ? 0 : 1, session };
475
+ }
476
+
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
+ }
485
+ }
486
+ }
487
+
488
+ if (stopping) {
489
+ session.status = 'stopped';
490
+ log('Continuous loop stopped by operator.');
491
+ }
492
+
493
+ writeContinuousSession(root, session);
494
+ return { exitCode: 0, session };
495
+
496
+ } finally {
497
+ process.removeListener('SIGINT', sigHandler);
498
+ }
499
+ }
@@ -44,6 +44,11 @@ import { emitRunEvent } from './run-events.js';
44
44
  import { writeSessionCheckpoint } from './session-checkpoint.js';
45
45
  import { recordRunHistory } from './run-history.js';
46
46
  import { buildDefaultRunProvenance } from './run-provenance.js';
47
+ import {
48
+ ensureHumanEscalation,
49
+ findCurrentHumanEscalation,
50
+ resolveHumanEscalation,
51
+ } from './human-escalations.js';
47
52
  import {
48
53
  getActiveRepoDecisions,
49
54
  appendRepoDecision,
@@ -161,11 +166,21 @@ function buildGateFailureRecord({
161
166
  }
162
167
 
163
168
  function emitBlockedNotification(root, config, state, details = {}, turn = null) {
169
+ const recovery = state?.blocked_reason?.recovery || details.recovery || null;
170
+ const humanEscalation = ensureHumanEscalation(root, state, turn);
171
+
164
172
  if (!config?.notifications?.webhooks?.length) {
165
173
  return;
166
174
  }
167
175
 
168
- const recovery = state?.blocked_reason?.recovery || details.recovery || null;
176
+ const escalationPayload = humanEscalation?.record ? {
177
+ escalation_id: humanEscalation.record.escalation_id,
178
+ type: humanEscalation.record.type,
179
+ service: humanEscalation.record.service,
180
+ action: humanEscalation.record.action,
181
+ resolution_command: humanEscalation.record.resolution_command,
182
+ } : null;
183
+
169
184
  emitNotifications(root, config, state, 'run_blocked', {
170
185
  category: state?.blocked_reason?.category || details.category || 'unknown_block',
171
186
  blocked_on: state?.blocked_on || details.blockedOn || null,
@@ -173,7 +188,12 @@ function emitBlockedNotification(root, config, state, details = {}, turn = null)
173
188
  owner: recovery?.owner || null,
174
189
  recovery_action: recovery?.recovery_action || null,
175
190
  detail: recovery?.detail || null,
191
+ human_escalation: escalationPayload,
176
192
  }, turn);
193
+
194
+ if (humanEscalation?.created && escalationPayload) {
195
+ emitNotifications(root, config, state, 'human_escalation_raised', escalationPayload, turn);
196
+ }
177
197
  }
178
198
 
179
199
  function emitPendingLifecycleNotification(root, config, state, eventType, payload, turn = null) {
@@ -1910,6 +1930,7 @@ export function reactivateGovernedRun(root, state, details = {}) {
1910
1930
 
1911
1931
  const now = new Date().toISOString();
1912
1932
  const wasEscalation = state.status === 'blocked' && typeof state.blocked_on === 'string' && state.blocked_on.startsWith('escalation:');
1933
+ const humanEscalation = findCurrentHumanEscalation(root, state);
1913
1934
  const nextState = {
1914
1935
  ...state,
1915
1936
  status: 'active',
@@ -1920,6 +1941,21 @@ export function reactivateGovernedRun(root, state, details = {}) {
1920
1941
 
1921
1942
  writeState(root, nextState);
1922
1943
 
1944
+ if (humanEscalation) {
1945
+ resolveHumanEscalation(root, humanEscalation.escalation_id, {
1946
+ resolved_at: now,
1947
+ resolved_via: details.via || 'unknown',
1948
+ resolution_notes: details.note || null,
1949
+ });
1950
+
1951
+ emitPendingLifecycleNotification(details.root || root, details.notificationConfig, nextState, 'human_escalation_resolved', {
1952
+ escalation_id: humanEscalation.escalation_id,
1953
+ type: humanEscalation.type,
1954
+ service: humanEscalation.service,
1955
+ resolved_via: details.via || 'unknown',
1956
+ }, getActiveTurn(state));
1957
+ }
1958
+
1923
1959
  if (wasEscalation) {
1924
1960
  appendJsonl(root, LEDGER_PATH, {
1925
1961
  timestamp: now,