agentxchain 2.116.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.
- package/README.md +1 -1
- package/bin/agentxchain.js +24 -0
- package/package.json +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 +165 -19
- package/src/commands/status.js +52 -0
- package/src/commands/unblock.js +67 -0
- package/src/lib/continuous-run.js +448 -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/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 +43 -0
- package/src/lib/vision-reader.js +229 -0
|
@@ -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
|
|
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,
|