agentxchain 2.115.0 → 2.117.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,448 @@
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
+ // Main continuous loop
282
+ // ---------------------------------------------------------------------------
283
+
284
+ /**
285
+ * Execute the continuous vision-driven run loop.
286
+ *
287
+ * @param {object} context - { root, config }
288
+ * @param {object} contOpts - resolved continuous options
289
+ * @param {Function} executeGovernedRun - the run executor function
290
+ * @param {Function} [log] - logging function
291
+ * @returns {Promise<{ exitCode: number, session: object }>}
292
+ */
293
+ export async function executeContinuousRun(context, contOpts, executeGovernedRun, log = console.log) {
294
+ const { root } = context;
295
+ const absVisionPath = resolveVisionPath(root, contOpts.visionPath);
296
+ let exitCode = 0;
297
+
298
+ // Validate vision file exists
299
+ if (!existsSync(absVisionPath)) {
300
+ log(`Error: VISION.md not found at ${absVisionPath}`);
301
+ log(`Create a .planning/VISION.md for your project to enable vision-driven operation.`);
302
+ return { exitCode: 1, session: null };
303
+ }
304
+
305
+ const session = createSession(contOpts.visionPath, contOpts.maxRuns, contOpts.maxIdleCycles);
306
+ writeContinuousSession(root, session);
307
+
308
+ // SIGINT handler
309
+ let stopping = false;
310
+ const sigHandler = () => {
311
+ stopping = true;
312
+ log('\nStopping continuous loop (finishing current work)...');
313
+ };
314
+ process.on('SIGINT', sigHandler);
315
+
316
+ try {
317
+ 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;
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;
371
+ }
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;
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));
434
+ }
435
+ }
436
+
437
+ if (stopping) {
438
+ session.status = 'stopped';
439
+ log('Continuous loop stopped by operator.');
440
+ }
441
+
442
+ writeContinuousSession(root, session);
443
+ return { exitCode, session };
444
+
445
+ } finally {
446
+ process.removeListener('SIGINT', sigHandler);
447
+ }
448
+ }
@@ -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,