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/README.md +1 -1
- package/bin/agentxchain.js +24 -0
- package/package.json +1 -1
- package/scripts/render-github-release-body.mjs +1 -1
- package/src/commands/events.js +8 -1
- package/src/commands/inject.js +81 -0
- package/src/commands/resume.js +6 -4
- package/src/commands/run.js +13 -0
- package/src/commands/schedule.js +386 -19
- package/src/commands/status.js +55 -0
- package/src/commands/unblock.js +67 -0
- package/src/lib/continuous-run.js +499 -0
- package/src/lib/governed-state.js +37 -1
- package/src/lib/human-escalations.js +434 -0
- package/src/lib/intake.js +243 -11
- package/src/lib/normalized-config.js +37 -0
- package/src/lib/notification-runner.js +3 -1
- package/src/lib/run-events.js +2 -0
- package/src/lib/run-loop.js +17 -0
- package/src/lib/run-provenance.js +4 -0
- package/src/lib/run-schedule.js +45 -0
- package/src/lib/vision-reader.js +229 -0
|
@@ -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
|
|
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,
|