agentxchain 2.5.0 → 2.7.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/bin/agentxchain.js +26 -1
- package/package.json +1 -1
- package/src/commands/escalate.js +63 -0
- package/src/commands/export.js +63 -0
- package/src/commands/resume.js +72 -2
- package/src/commands/step.js +22 -15
- package/src/commands/verify.js +60 -0
- package/src/lib/blocked-state.js +7 -3
- package/src/lib/export-verifier.js +426 -0
- package/src/lib/export.js +305 -0
- package/src/lib/governed-state.js +255 -6
- package/src/lib/normalized-config.js +9 -0
- package/src/lib/notification-runner.js +330 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { join, relative, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { loadProjectContext, loadProjectState } from './config.js';
|
|
6
|
+
import { loadCoordinatorConfig, COORDINATOR_CONFIG_FILE } from './coordinator-config.js';
|
|
7
|
+
import { loadCoordinatorState } from './coordinator-state.js';
|
|
8
|
+
|
|
9
|
+
const EXPORT_SCHEMA_VERSION = '0.2';
|
|
10
|
+
|
|
11
|
+
const COORDINATOR_INCLUDED_ROOTS = [
|
|
12
|
+
'agentxchain-multi.json',
|
|
13
|
+
'.agentxchain/multirepo/state.json',
|
|
14
|
+
'.agentxchain/multirepo/history.jsonl',
|
|
15
|
+
'.agentxchain/multirepo/barriers.json',
|
|
16
|
+
'.agentxchain/multirepo/decision-ledger.jsonl',
|
|
17
|
+
'.agentxchain/multirepo/barrier-ledger.jsonl',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const INCLUDED_ROOTS = [
|
|
21
|
+
'agentxchain.json',
|
|
22
|
+
'.agentxchain/state.json',
|
|
23
|
+
'.agentxchain/history.jsonl',
|
|
24
|
+
'.agentxchain/decision-ledger.jsonl',
|
|
25
|
+
'.agentxchain/hook-audit.jsonl',
|
|
26
|
+
'.agentxchain/hook-annotations.jsonl',
|
|
27
|
+
'.agentxchain/notification-audit.jsonl',
|
|
28
|
+
'.agentxchain/dispatch',
|
|
29
|
+
'.agentxchain/staging',
|
|
30
|
+
'.agentxchain/transactions/accept',
|
|
31
|
+
'.agentxchain/intake',
|
|
32
|
+
'.agentxchain/multirepo',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
function sha256(buffer) {
|
|
36
|
+
return createHash('sha256').update(buffer).digest('hex');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function collectPaths(root, relPath) {
|
|
40
|
+
const absPath = join(root, relPath);
|
|
41
|
+
if (!existsSync(absPath)) {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const stats = statSync(absPath);
|
|
46
|
+
if (stats.isFile()) {
|
|
47
|
+
return [relPath];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!stats.isDirectory()) {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const entries = readdirSync(absPath, { withFileTypes: true })
|
|
55
|
+
.sort((a, b) => a.name.localeCompare(b.name, 'en'));
|
|
56
|
+
|
|
57
|
+
const files = [];
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
const childRelPath = `${relPath}/${entry.name}`;
|
|
60
|
+
if (entry.isDirectory()) {
|
|
61
|
+
files.push(...collectPaths(root, childRelPath));
|
|
62
|
+
} else if (entry.isFile()) {
|
|
63
|
+
files.push(childRelPath);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return files;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseJsonl(relPath, raw) {
|
|
70
|
+
if (!raw.trim()) {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return raw
|
|
75
|
+
.split('\n')
|
|
76
|
+
.filter((line) => line.trim())
|
|
77
|
+
.map((line, index) => {
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(line);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
throw new Error(`${relPath}: invalid JSONL at line ${index + 1}: ${error.message}`);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseFile(root, relPath) {
|
|
87
|
+
const absPath = join(root, relPath);
|
|
88
|
+
const buffer = readFileSync(absPath);
|
|
89
|
+
const raw = buffer.toString('utf8');
|
|
90
|
+
|
|
91
|
+
let format = 'text';
|
|
92
|
+
let data = raw;
|
|
93
|
+
|
|
94
|
+
if (relPath.endsWith('.json')) {
|
|
95
|
+
try {
|
|
96
|
+
data = JSON.parse(raw);
|
|
97
|
+
format = 'json';
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw new Error(`${relPath}: invalid JSON: ${error.message}`);
|
|
100
|
+
}
|
|
101
|
+
} else if (relPath.endsWith('.jsonl')) {
|
|
102
|
+
data = parseJsonl(relPath, raw);
|
|
103
|
+
format = 'jsonl';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
format,
|
|
108
|
+
bytes: buffer.byteLength,
|
|
109
|
+
sha256: sha256(buffer),
|
|
110
|
+
content_base64: buffer.toString('base64'),
|
|
111
|
+
data,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function countJsonl(files, relPath) {
|
|
116
|
+
return Array.isArray(files[relPath]?.data) ? files[relPath].data.length : 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function countDirectoryFiles(files, prefix) {
|
|
120
|
+
return Object.keys(files).filter((path) => path.startsWith(`${prefix}/`)).length;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function buildRunExport(startDir = process.cwd()) {
|
|
124
|
+
const context = loadProjectContext(startDir);
|
|
125
|
+
if (!context) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
error: 'No governed project found. Run this inside an AgentXchain governed project.',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (context.config.protocol_mode !== 'governed') {
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
error: 'Run export only supports governed projects in this slice.',
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const { root, rawConfig, config, version } = context;
|
|
140
|
+
const state = loadProjectState(root, config);
|
|
141
|
+
|
|
142
|
+
const collectedPaths = [...new Set(INCLUDED_ROOTS.flatMap((relPath) => collectPaths(root, relPath)))]
|
|
143
|
+
.sort((a, b) => a.localeCompare(b, 'en'));
|
|
144
|
+
|
|
145
|
+
const files = {};
|
|
146
|
+
for (const relPath of collectedPaths) {
|
|
147
|
+
files[relPath] = parseFile(root, relPath);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const activeTurns = Object.keys(state?.active_turns || {}).sort((a, b) => a.localeCompare(b, 'en'));
|
|
151
|
+
const retainedTurns = Object.keys(state?.retained_turns || {}).sort((a, b) => a.localeCompare(b, 'en'));
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
ok: true,
|
|
155
|
+
export: {
|
|
156
|
+
schema_version: EXPORT_SCHEMA_VERSION,
|
|
157
|
+
export_kind: 'agentxchain_run_export',
|
|
158
|
+
exported_at: new Date().toISOString(),
|
|
159
|
+
project_root: relative(process.cwd(), root) || '.',
|
|
160
|
+
project: {
|
|
161
|
+
id: config.project.id,
|
|
162
|
+
name: config.project.name,
|
|
163
|
+
template: config.template || 'generic',
|
|
164
|
+
protocol_mode: config.protocol_mode,
|
|
165
|
+
schema_version: version,
|
|
166
|
+
},
|
|
167
|
+
summary: {
|
|
168
|
+
run_id: state?.run_id || null,
|
|
169
|
+
status: state?.status || null,
|
|
170
|
+
phase: state?.phase || null,
|
|
171
|
+
active_turn_ids: activeTurns,
|
|
172
|
+
retained_turn_ids: retainedTurns,
|
|
173
|
+
history_entries: countJsonl(files, '.agentxchain/history.jsonl'),
|
|
174
|
+
decision_entries: countJsonl(files, '.agentxchain/decision-ledger.jsonl'),
|
|
175
|
+
hook_audit_entries: countJsonl(files, '.agentxchain/hook-audit.jsonl'),
|
|
176
|
+
notification_audit_entries: countJsonl(files, '.agentxchain/notification-audit.jsonl'),
|
|
177
|
+
dispatch_artifact_files: countDirectoryFiles(files, '.agentxchain/dispatch'),
|
|
178
|
+
staging_artifact_files: countDirectoryFiles(files, '.agentxchain/staging'),
|
|
179
|
+
intake_present: Object.keys(files).some((path) => path.startsWith('.agentxchain/intake/')),
|
|
180
|
+
coordinator_present: Object.keys(files).some((path) => path.startsWith('.agentxchain/multirepo/')),
|
|
181
|
+
},
|
|
182
|
+
files,
|
|
183
|
+
config: rawConfig,
|
|
184
|
+
state,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function buildCoordinatorExport(startDir = process.cwd()) {
|
|
190
|
+
const workspaceRoot = resolve(startDir);
|
|
191
|
+
const configPath = join(workspaceRoot, COORDINATOR_CONFIG_FILE);
|
|
192
|
+
|
|
193
|
+
if (!existsSync(configPath)) {
|
|
194
|
+
return {
|
|
195
|
+
ok: false,
|
|
196
|
+
error: `No ${COORDINATOR_CONFIG_FILE} found at ${workspaceRoot}.`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let rawConfig;
|
|
201
|
+
try {
|
|
202
|
+
rawConfig = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
203
|
+
} catch (err) {
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
error: `Invalid JSON in ${COORDINATOR_CONFIG_FILE}: ${err.message}`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const configResult = loadCoordinatorConfig(workspaceRoot);
|
|
211
|
+
const normalizedConfig = configResult.ok ? configResult.config : null;
|
|
212
|
+
|
|
213
|
+
// Collect coordinator-level files
|
|
214
|
+
const collectedPaths = [...new Set(
|
|
215
|
+
COORDINATOR_INCLUDED_ROOTS.flatMap((relPath) => collectPaths(workspaceRoot, relPath)),
|
|
216
|
+
)].sort((a, b) => a.localeCompare(b, 'en'));
|
|
217
|
+
|
|
218
|
+
const files = {};
|
|
219
|
+
for (const relPath of collectedPaths) {
|
|
220
|
+
files[relPath] = parseFile(workspaceRoot, relPath);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Load coordinator state for summary
|
|
224
|
+
const coordState = loadCoordinatorState(workspaceRoot);
|
|
225
|
+
|
|
226
|
+
// Build repo run statuses from coordinator state
|
|
227
|
+
const repoRunStatuses = {};
|
|
228
|
+
if (coordState?.repo_runs) {
|
|
229
|
+
for (const [repoId, repoRun] of Object.entries(coordState.repo_runs)) {
|
|
230
|
+
repoRunStatuses[repoId] = repoRun.status || 'unknown';
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Count barriers from barriers.json
|
|
235
|
+
let barrierCount = 0;
|
|
236
|
+
const barriersKey = '.agentxchain/multirepo/barriers.json';
|
|
237
|
+
if (files[barriersKey]?.format === 'json' && files[barriersKey]?.data) {
|
|
238
|
+
barrierCount = Object.keys(files[barriersKey].data).length;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Determine repos from config
|
|
242
|
+
const repos = {};
|
|
243
|
+
const repoEntries = rawConfig.repos && typeof rawConfig.repos === 'object'
|
|
244
|
+
? Object.entries(rawConfig.repos)
|
|
245
|
+
: [];
|
|
246
|
+
|
|
247
|
+
for (const [repoId, repoDef] of repoEntries) {
|
|
248
|
+
const repoPath = repoDef?.path || '';
|
|
249
|
+
const resolvedPath = resolve(workspaceRoot, repoPath);
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const childExport = buildRunExport(resolvedPath);
|
|
253
|
+
if (childExport.ok) {
|
|
254
|
+
repos[repoId] = {
|
|
255
|
+
ok: true,
|
|
256
|
+
path: repoPath,
|
|
257
|
+
export: childExport.export,
|
|
258
|
+
};
|
|
259
|
+
} else {
|
|
260
|
+
repos[repoId] = {
|
|
261
|
+
ok: false,
|
|
262
|
+
path: repoPath,
|
|
263
|
+
error: childExport.error,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
} catch (err) {
|
|
267
|
+
repos[repoId] = {
|
|
268
|
+
ok: false,
|
|
269
|
+
path: repoPath,
|
|
270
|
+
error: err.message || String(err),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
ok: true,
|
|
277
|
+
export: {
|
|
278
|
+
schema_version: EXPORT_SCHEMA_VERSION,
|
|
279
|
+
export_kind: 'agentxchain_coordinator_export',
|
|
280
|
+
exported_at: new Date().toISOString(),
|
|
281
|
+
workspace_root: relative(process.cwd(), workspaceRoot) || '.',
|
|
282
|
+
coordinator: {
|
|
283
|
+
project_id: rawConfig.project?.id || null,
|
|
284
|
+
project_name: rawConfig.project?.name || null,
|
|
285
|
+
schema_version: rawConfig.schema_version || null,
|
|
286
|
+
repo_count: repoEntries.length,
|
|
287
|
+
workstream_count: rawConfig.workstreams
|
|
288
|
+
? Object.keys(rawConfig.workstreams).length
|
|
289
|
+
: 0,
|
|
290
|
+
},
|
|
291
|
+
summary: {
|
|
292
|
+
super_run_id: coordState?.super_run_id || null,
|
|
293
|
+
status: coordState?.status || null,
|
|
294
|
+
phase: coordState?.phase || null,
|
|
295
|
+
repo_run_statuses: repoRunStatuses,
|
|
296
|
+
barrier_count: barrierCount,
|
|
297
|
+
history_entries: countJsonl(files, '.agentxchain/multirepo/history.jsonl'),
|
|
298
|
+
decision_entries: countJsonl(files, '.agentxchain/multirepo/decision-ledger.jsonl'),
|
|
299
|
+
},
|
|
300
|
+
files,
|
|
301
|
+
config: rawConfig,
|
|
302
|
+
repos,
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
import { getMaxConcurrentTurns } from './normalized-config.js';
|
|
35
35
|
import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir } from './turn-paths.js';
|
|
36
36
|
import { runHooks } from './hook-runner.js';
|
|
37
|
+
import { emitNotifications } from './notification-runner.js';
|
|
37
38
|
|
|
38
39
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
39
40
|
|
|
@@ -53,6 +54,29 @@ function generateId(prefix) {
|
|
|
53
54
|
return `${prefix}_${randomBytes(8).toString('hex')}`;
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
function emitBlockedNotification(root, config, state, details = {}, turn = null) {
|
|
58
|
+
if (!config?.notifications?.webhooks?.length) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const recovery = state?.blocked_reason?.recovery || details.recovery || null;
|
|
63
|
+
emitNotifications(root, config, state, 'run_blocked', {
|
|
64
|
+
category: state?.blocked_reason?.category || details.category || 'unknown_block',
|
|
65
|
+
blocked_on: state?.blocked_on || details.blockedOn || null,
|
|
66
|
+
typed_reason: recovery?.typed_reason || null,
|
|
67
|
+
owner: recovery?.owner || null,
|
|
68
|
+
recovery_action: recovery?.recovery_action || null,
|
|
69
|
+
detail: recovery?.detail || null,
|
|
70
|
+
}, turn);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function emitPendingLifecycleNotification(root, config, state, eventType, payload, turn = null) {
|
|
74
|
+
if (!config?.notifications?.webhooks?.length) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
emitNotifications(root, config, state, eventType, payload, turn);
|
|
78
|
+
}
|
|
79
|
+
|
|
56
80
|
function normalizeActiveTurns(activeTurns) {
|
|
57
81
|
if (!activeTurns || typeof activeTurns !== 'object' || Array.isArray(activeTurns)) {
|
|
58
82
|
return {};
|
|
@@ -555,6 +579,22 @@ function buildBlockedReason({ category, recovery, turnId, blockedAt = new Date()
|
|
|
555
579
|
};
|
|
556
580
|
}
|
|
557
581
|
|
|
582
|
+
function slugifyEscalationReason(reason) {
|
|
583
|
+
if (typeof reason !== 'string') {
|
|
584
|
+
return 'operator';
|
|
585
|
+
}
|
|
586
|
+
const slug = reason
|
|
587
|
+
.trim()
|
|
588
|
+
.toLowerCase()
|
|
589
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
590
|
+
.replace(/^-+|-+$/g, '');
|
|
591
|
+
return slug || 'operator';
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function isOperatorEscalationBlockedOn(blockedOn) {
|
|
595
|
+
return typeof blockedOn === 'string' && blockedOn.startsWith('escalation:operator:');
|
|
596
|
+
}
|
|
597
|
+
|
|
558
598
|
function canApprovePendingGate(state) {
|
|
559
599
|
return state?.status === 'paused' || state?.status === 'blocked';
|
|
560
600
|
}
|
|
@@ -599,7 +639,7 @@ function deriveHookRecovery(state, { phase, hookName, detail, errorCode, turnId,
|
|
|
599
639
|
};
|
|
600
640
|
}
|
|
601
641
|
|
|
602
|
-
function blockRunForHookIssue(root, state, { phase, turnId, hookName, detail, errorCode, turnRetained }) {
|
|
642
|
+
function blockRunForHookIssue(root, state, { phase, turnId, hookName, detail, errorCode, turnRetained, notificationConfig }) {
|
|
603
643
|
const blockedAt = new Date().toISOString();
|
|
604
644
|
const typedReason = errorCode?.includes('_tamper') ? 'hook_tamper' : 'hook_block';
|
|
605
645
|
const recovery = deriveHookRecovery(state, {
|
|
@@ -622,6 +662,11 @@ function blockRunForHookIssue(root, state, { phase, turnId, hookName, detail, er
|
|
|
622
662
|
}),
|
|
623
663
|
};
|
|
624
664
|
writeState(root, blockedState);
|
|
665
|
+
emitBlockedNotification(root, notificationConfig, blockedState, {
|
|
666
|
+
category: typedReason,
|
|
667
|
+
blockedOn: blockedState.blocked_on,
|
|
668
|
+
recovery,
|
|
669
|
+
}, turnId ? getActiveTurns(blockedState)[turnId] || null : null);
|
|
625
670
|
return attachLegacyCurrentTurnAlias(blockedState);
|
|
626
671
|
}
|
|
627
672
|
|
|
@@ -694,14 +739,18 @@ function inferBlockedReasonFromState(state) {
|
|
|
694
739
|
}
|
|
695
740
|
|
|
696
741
|
if (state.blocked_on.startsWith('escalation:')) {
|
|
742
|
+
const isOperatorEscalation = isOperatorEscalationBlockedOn(state.blocked_on) || state.escalation?.source === 'operator';
|
|
743
|
+
const recoveryAction = turnRetained
|
|
744
|
+
? 'Resolve the escalation, then run agentxchain step --resume'
|
|
745
|
+
: 'Resolve the escalation, then run agentxchain step';
|
|
697
746
|
return buildBlockedReason({
|
|
698
|
-
category: 'retries_exhausted',
|
|
747
|
+
category: isOperatorEscalation ? 'operator_escalation' : 'retries_exhausted',
|
|
699
748
|
recovery: {
|
|
700
|
-
typed_reason: 'retries_exhausted',
|
|
749
|
+
typed_reason: isOperatorEscalation ? 'operator_escalation' : 'retries_exhausted',
|
|
701
750
|
owner: 'human',
|
|
702
|
-
recovery_action:
|
|
751
|
+
recovery_action: recoveryAction,
|
|
703
752
|
turn_retained: turnRetained,
|
|
704
|
-
detail: state.blocked_on,
|
|
753
|
+
detail: state.escalation?.detail || state.escalation?.reason || state.blocked_on,
|
|
705
754
|
},
|
|
706
755
|
turnId: activeTurn?.turn_id ?? null,
|
|
707
756
|
});
|
|
@@ -819,6 +868,12 @@ export function markRunBlocked(root, details) {
|
|
|
819
868
|
|
|
820
869
|
writeState(root, updatedState);
|
|
821
870
|
|
|
871
|
+
emitBlockedNotification(root, details.notificationConfig, updatedState, {
|
|
872
|
+
category: details.category,
|
|
873
|
+
blockedOn: details.blockedOn,
|
|
874
|
+
recovery: details.recovery,
|
|
875
|
+
}, turnId ? getActiveTurns(updatedState)[turnId] || null : null);
|
|
876
|
+
|
|
822
877
|
// Fire on_escalation hooks (advisory-only) after blocked state is persisted.
|
|
823
878
|
// Only fire for non-hook-caused blocks to prevent circular invocations.
|
|
824
879
|
if (details.hooksConfig?.on_escalation?.length > 0) {
|
|
@@ -837,6 +892,141 @@ export function markRunBlocked(root, details) {
|
|
|
837
892
|
return { ok: true, state: updatedState };
|
|
838
893
|
}
|
|
839
894
|
|
|
895
|
+
export function raiseOperatorEscalation(root, config, details) {
|
|
896
|
+
const state = readState(root);
|
|
897
|
+
if (!state) {
|
|
898
|
+
return { ok: false, error: 'No governed state.json found' };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const reason = typeof details.reason === 'string' ? details.reason.trim() : '';
|
|
902
|
+
if (!reason) {
|
|
903
|
+
return { ok: false, error: 'Escalation reason is required.' };
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (state.status !== 'active') {
|
|
907
|
+
return { ok: false, error: `Cannot escalate run: status is "${state.status}", expected "active"` };
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const activeTurns = getActiveTurns(state);
|
|
911
|
+
let targetTurn = null;
|
|
912
|
+
if (details.turnId) {
|
|
913
|
+
targetTurn = activeTurns[details.turnId] || null;
|
|
914
|
+
if (!targetTurn) {
|
|
915
|
+
return { ok: false, error: `No active turn found for --turn ${details.turnId}` };
|
|
916
|
+
}
|
|
917
|
+
} else {
|
|
918
|
+
const turns = Object.values(activeTurns);
|
|
919
|
+
if (turns.length > 1) {
|
|
920
|
+
return { ok: false, error: 'Multiple active turns exist. Use --turn <id> to target the escalation.' };
|
|
921
|
+
}
|
|
922
|
+
targetTurn = turns[0] || null;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const turnRetained = Boolean(targetTurn);
|
|
926
|
+
const recoveryAction = details.action
|
|
927
|
+
|| (turnRetained
|
|
928
|
+
? 'Resolve the escalation, then run agentxchain step --resume'
|
|
929
|
+
: 'Resolve the escalation, then run agentxchain step');
|
|
930
|
+
const detail = typeof details.detail === 'string' && details.detail.trim()
|
|
931
|
+
? details.detail.trim()
|
|
932
|
+
: reason;
|
|
933
|
+
const blockedOn = `escalation:operator:${slugifyEscalationReason(reason)}`;
|
|
934
|
+
const escalatedAt = new Date().toISOString();
|
|
935
|
+
|
|
936
|
+
const escalation = {
|
|
937
|
+
source: 'operator',
|
|
938
|
+
raised_by: 'human',
|
|
939
|
+
from_role: targetTurn?.assigned_role || null,
|
|
940
|
+
from_turn_id: targetTurn?.turn_id || null,
|
|
941
|
+
reason,
|
|
942
|
+
detail,
|
|
943
|
+
recovery_action: recoveryAction,
|
|
944
|
+
escalated_at: escalatedAt,
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
const blocked = markRunBlocked(root, {
|
|
948
|
+
blockedOn,
|
|
949
|
+
category: 'operator_escalation',
|
|
950
|
+
recovery: {
|
|
951
|
+
typed_reason: 'operator_escalation',
|
|
952
|
+
owner: 'human',
|
|
953
|
+
recovery_action: recoveryAction,
|
|
954
|
+
turn_retained: turnRetained,
|
|
955
|
+
detail,
|
|
956
|
+
},
|
|
957
|
+
turnId: targetTurn?.turn_id || null,
|
|
958
|
+
escalation,
|
|
959
|
+
hooksConfig: config?.hooks || {},
|
|
960
|
+
notificationConfig: config,
|
|
961
|
+
});
|
|
962
|
+
if (!blocked.ok) {
|
|
963
|
+
return blocked;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
appendJsonl(root, LEDGER_PATH, {
|
|
967
|
+
timestamp: escalatedAt,
|
|
968
|
+
decision: 'operator_escalated',
|
|
969
|
+
run_id: blocked.state.run_id,
|
|
970
|
+
phase: blocked.state.phase,
|
|
971
|
+
blocked_on: blockedOn,
|
|
972
|
+
escalation,
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
emitPendingLifecycleNotification(root, config, blocked.state, 'operator_escalation_raised', {
|
|
976
|
+
source: 'operator',
|
|
977
|
+
blocked_on: blockedOn,
|
|
978
|
+
reason,
|
|
979
|
+
detail,
|
|
980
|
+
recovery_action: recoveryAction,
|
|
981
|
+
}, targetTurn);
|
|
982
|
+
|
|
983
|
+
return {
|
|
984
|
+
ok: true,
|
|
985
|
+
state: attachLegacyCurrentTurnAlias(blocked.state),
|
|
986
|
+
escalation,
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
export function reactivateGovernedRun(root, state, details = {}) {
|
|
991
|
+
if (!state || typeof state !== 'object') {
|
|
992
|
+
return { ok: false, error: 'State is required.' };
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const now = new Date().toISOString();
|
|
996
|
+
const wasEscalation = state.status === 'blocked' && typeof state.blocked_on === 'string' && state.blocked_on.startsWith('escalation:');
|
|
997
|
+
const nextState = {
|
|
998
|
+
...state,
|
|
999
|
+
status: 'active',
|
|
1000
|
+
blocked_on: null,
|
|
1001
|
+
blocked_reason: null,
|
|
1002
|
+
escalation: null,
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
writeState(root, nextState);
|
|
1006
|
+
|
|
1007
|
+
if (wasEscalation) {
|
|
1008
|
+
appendJsonl(root, LEDGER_PATH, {
|
|
1009
|
+
timestamp: now,
|
|
1010
|
+
decision: 'escalation_resolved',
|
|
1011
|
+
run_id: state.run_id,
|
|
1012
|
+
phase: state.phase,
|
|
1013
|
+
resolved_via: details.via || 'unknown',
|
|
1014
|
+
blocked_on: state.blocked_on,
|
|
1015
|
+
escalation: state.escalation || null,
|
|
1016
|
+
turn_id: state.escalation?.from_turn_id ?? getActiveTurn(state)?.turn_id ?? null,
|
|
1017
|
+
role: state.escalation?.from_role ?? getActiveTurn(state)?.assigned_role ?? null,
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
emitPendingLifecycleNotification(details.root || root, details.notificationConfig, nextState, 'escalation_resolved', {
|
|
1021
|
+
blocked_on: state.blocked_on,
|
|
1022
|
+
resolved_via: details.via || 'unknown',
|
|
1023
|
+
previous_escalation: state.escalation || null,
|
|
1024
|
+
}, state.escalation?.from_turn_id ? getActiveTurns(state)[state.escalation.from_turn_id] || getActiveTurn(state) : getActiveTurn(state));
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
return { ok: true, state: attachLegacyCurrentTurnAlias(nextState) };
|
|
1028
|
+
}
|
|
1029
|
+
|
|
840
1030
|
// ── Core Operations ──────────────────────────────────────────────────────────
|
|
841
1031
|
|
|
842
1032
|
/**
|
|
@@ -1014,6 +1204,7 @@ export function assignGovernedTurn(root, config, roleId) {
|
|
|
1014
1204
|
detail,
|
|
1015
1205
|
errorCode: beforeAssignmentHooks.tamper.error_code,
|
|
1016
1206
|
turnRetained: activeCount > 0,
|
|
1207
|
+
notificationConfig: config,
|
|
1017
1208
|
});
|
|
1018
1209
|
return {
|
|
1019
1210
|
ok: false,
|
|
@@ -1240,6 +1431,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
1240
1431
|
detail,
|
|
1241
1432
|
errorCode: beforeValidationHooks.tamper?.error_code || 'hook_blocked',
|
|
1242
1433
|
turnRetained: true,
|
|
1434
|
+
notificationConfig: config,
|
|
1243
1435
|
});
|
|
1244
1436
|
return {
|
|
1245
1437
|
ok: false,
|
|
@@ -1281,6 +1473,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
1281
1473
|
detail,
|
|
1282
1474
|
errorCode: afterValidationHooks.tamper?.error_code || 'hook_blocked',
|
|
1283
1475
|
turnRetained: true,
|
|
1476
|
+
notificationConfig: config,
|
|
1284
1477
|
});
|
|
1285
1478
|
return {
|
|
1286
1479
|
ok: false,
|
|
@@ -1426,6 +1619,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
1426
1619
|
detail,
|
|
1427
1620
|
errorCode: beforeAcceptanceHooks.tamper?.error_code || 'hook_blocked',
|
|
1428
1621
|
turnRetained: true,
|
|
1622
|
+
notificationConfig: config,
|
|
1429
1623
|
});
|
|
1430
1624
|
return {
|
|
1431
1625
|
ok: false,
|
|
@@ -1698,6 +1892,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
1698
1892
|
detail,
|
|
1699
1893
|
errorCode: hookResults.tamper?.error_code || 'hook_post_commit_error',
|
|
1700
1894
|
turnRetained: Object.keys(getActiveTurns(updatedState)).length > 0,
|
|
1895
|
+
notificationConfig: config,
|
|
1701
1896
|
});
|
|
1702
1897
|
return {
|
|
1703
1898
|
ok: false,
|
|
@@ -1713,6 +1908,39 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
1713
1908
|
}
|
|
1714
1909
|
}
|
|
1715
1910
|
|
|
1911
|
+
if (updatedState.status === 'blocked') {
|
|
1912
|
+
emitBlockedNotification(root, config, updatedState, {
|
|
1913
|
+
category: updatedState.blocked_reason?.category || 'needs_human',
|
|
1914
|
+
blockedOn: updatedState.blocked_on,
|
|
1915
|
+
recovery: updatedState.blocked_reason?.recovery || null,
|
|
1916
|
+
}, currentTurn);
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
if (updatedState.pending_phase_transition) {
|
|
1920
|
+
emitPendingLifecycleNotification(root, config, updatedState, 'phase_transition_pending', {
|
|
1921
|
+
from: updatedState.pending_phase_transition.from,
|
|
1922
|
+
to: updatedState.pending_phase_transition.to,
|
|
1923
|
+
gate: updatedState.pending_phase_transition.gate,
|
|
1924
|
+
requested_by_turn: updatedState.pending_phase_transition.requested_by_turn,
|
|
1925
|
+
}, currentTurn);
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
if (updatedState.pending_run_completion) {
|
|
1929
|
+
emitPendingLifecycleNotification(root, config, updatedState, 'run_completion_pending', {
|
|
1930
|
+
gate: updatedState.pending_run_completion.gate,
|
|
1931
|
+
requested_by_turn: updatedState.pending_run_completion.requested_by_turn,
|
|
1932
|
+
requested_at: updatedState.pending_run_completion.requested_at,
|
|
1933
|
+
}, currentTurn);
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
if (updatedState.status === 'completed') {
|
|
1937
|
+
emitPendingLifecycleNotification(root, config, updatedState, 'run_completed', {
|
|
1938
|
+
completed_at: updatedState.completed_at || now,
|
|
1939
|
+
completed_via: completionResult?.action === 'complete' ? 'accept_turn' : 'accept_turn_direct',
|
|
1940
|
+
requested_by_turn: completionResult?.requested_by_turn || turnResult.turn_id,
|
|
1941
|
+
}, currentTurn);
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1716
1944
|
return {
|
|
1717
1945
|
ok: true,
|
|
1718
1946
|
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
@@ -1898,6 +2126,12 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
|
|
|
1898
2126
|
|
|
1899
2127
|
writeState(root, updatedState);
|
|
1900
2128
|
|
|
2129
|
+
emitBlockedNotification(root, config, updatedState, {
|
|
2130
|
+
category: 'retries_exhausted',
|
|
2131
|
+
blockedOn: updatedState.blocked_on,
|
|
2132
|
+
recovery: updatedState.blocked_reason?.recovery || null,
|
|
2133
|
+
}, updatedState.active_turns[currentTurn.turn_id]);
|
|
2134
|
+
|
|
1901
2135
|
// Fire on_escalation hooks (advisory-only) after blocked state is persisted.
|
|
1902
2136
|
const hooksConfig = config?.hooks || {};
|
|
1903
2137
|
if (hooksConfig.on_escalation?.length > 0) {
|
|
@@ -1977,6 +2211,7 @@ export function approvePhaseTransition(root, config) {
|
|
|
1977
2211
|
detail,
|
|
1978
2212
|
errorCode: gateHooks.tamper?.error_code || 'hook_blocked',
|
|
1979
2213
|
turnRetained: false,
|
|
2214
|
+
notificationConfig: config,
|
|
1980
2215
|
});
|
|
1981
2216
|
return {
|
|
1982
2217
|
ok: false,
|
|
@@ -2067,6 +2302,7 @@ export function approveRunCompletion(root, config) {
|
|
|
2067
2302
|
detail,
|
|
2068
2303
|
errorCode: gateHooks.tamper?.error_code || 'hook_blocked',
|
|
2069
2304
|
turnRetained: false,
|
|
2305
|
+
notificationConfig: config,
|
|
2070
2306
|
});
|
|
2071
2307
|
return {
|
|
2072
2308
|
ok: false,
|
|
@@ -2093,6 +2329,13 @@ export function approveRunCompletion(root, config) {
|
|
|
2093
2329
|
|
|
2094
2330
|
writeState(root, updatedState);
|
|
2095
2331
|
|
|
2332
|
+
emitPendingLifecycleNotification(root, config, updatedState, 'run_completed', {
|
|
2333
|
+
completed_at: updatedState.completed_at,
|
|
2334
|
+
completed_via: 'approve_run_completion',
|
|
2335
|
+
gate: completion.gate,
|
|
2336
|
+
requested_by_turn: completion.requested_by_turn || null,
|
|
2337
|
+
}, completion.requested_by_turn ? getActiveTurns(state)[completion.requested_by_turn] || null : null);
|
|
2338
|
+
|
|
2096
2339
|
return {
|
|
2097
2340
|
ok: true,
|
|
2098
2341
|
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
@@ -2136,4 +2379,10 @@ function deriveNextRecommendedRole(turnResult, state, config) {
|
|
|
2136
2379
|
return routing?.entry_role || null;
|
|
2137
2380
|
}
|
|
2138
2381
|
|
|
2139
|
-
export {
|
|
2382
|
+
export {
|
|
2383
|
+
STATE_PATH,
|
|
2384
|
+
HISTORY_PATH,
|
|
2385
|
+
LEDGER_PATH,
|
|
2386
|
+
STAGING_PATH,
|
|
2387
|
+
TALK_PATH,
|
|
2388
|
+
};
|