agentxchain 2.45.0 → 2.46.2
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/dashboard/app.js +6 -0
- package/dashboard/components/coordinator-timeouts.js +220 -0
- package/dashboard/components/timeouts.js +201 -0
- package/dashboard/index.html +2 -0
- package/package.json +1 -1
- package/scripts/publish-from-tag.sh +33 -4
- package/src/commands/accept-turn.js +30 -0
- package/src/commands/init.js +8 -1
- package/src/commands/migrate.js +1 -0
- package/src/commands/status.js +49 -0
- package/src/lib/approval-policy.js +139 -0
- package/src/lib/blocked-state.js +35 -0
- package/src/lib/dashboard/bridge-server.js +14 -0
- package/src/lib/dashboard/coordinator-timeout-status.js +139 -0
- package/src/lib/dashboard/timeout-status.js +201 -0
- package/src/lib/governed-state.js +530 -25
- package/src/lib/governed-templates.js +2 -0
- package/src/lib/normalized-config.js +132 -0
- package/src/lib/policy-evaluator.js +330 -0
- package/src/lib/reference-conformance-adapter.js +1 -0
- package/src/lib/repo-observer.js +132 -1
- package/src/lib/report.js +323 -6
- package/src/lib/schema.js +47 -0
- package/src/lib/timeout-evaluator.js +234 -0
- package/src/templates/governed/enterprise-app.json +20 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeout evaluator for governed runs.
|
|
3
|
+
*
|
|
4
|
+
* Checks per-turn, per-phase, and per-run time limits at governance boundaries.
|
|
5
|
+
* Returns timeout results that callers use to escalate, warn, or skip.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const VALID_TIMEOUT_ACTIONS = ['escalate', 'warn', 'skip_phase'];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Evaluate all configured timeouts against the current state.
|
|
12
|
+
*
|
|
13
|
+
* @param {object} options
|
|
14
|
+
* @param {object} options.config - Normalized config with optional `timeouts` section
|
|
15
|
+
* @param {object} options.state - Current governed state
|
|
16
|
+
* @param {object} [options.turn] - Active turn metadata being accepted (for per-turn check)
|
|
17
|
+
* @param {object} [options.turnResult] - Accepted turn result (legacy fallback for tests)
|
|
18
|
+
* @param {Date|string} [options.now] - Override for current time (testing)
|
|
19
|
+
* @returns {{ exceeded: Array<TimeoutResult>, warnings: Array<TimeoutResult> }}
|
|
20
|
+
*/
|
|
21
|
+
export function evaluateTimeouts({ config, state, turn = null, turnResult = null, now = new Date() }) {
|
|
22
|
+
const timeouts = config.timeouts;
|
|
23
|
+
if (!timeouts) return { exceeded: [], warnings: [] };
|
|
24
|
+
|
|
25
|
+
const nowMs = typeof now === 'string' ? new Date(now).getTime() : now.getTime();
|
|
26
|
+
const exceeded = [];
|
|
27
|
+
const warnings = [];
|
|
28
|
+
|
|
29
|
+
// Per-turn timeout
|
|
30
|
+
if (timeouts.per_turn_minutes) {
|
|
31
|
+
const startedAt = turn?.started_at || turn?.assigned_at || turnResult?.dispatched_at || turnResult?.assigned_at;
|
|
32
|
+
if (startedAt) {
|
|
33
|
+
const dispatchMs = new Date(startedAt).getTime();
|
|
34
|
+
const limitMs = timeouts.per_turn_minutes * 60 * 1000;
|
|
35
|
+
const elapsedMs = nowMs - dispatchMs;
|
|
36
|
+
if (elapsedMs > limitMs) {
|
|
37
|
+
const result = {
|
|
38
|
+
scope: 'turn',
|
|
39
|
+
limit_minutes: timeouts.per_turn_minutes,
|
|
40
|
+
elapsed_minutes: Math.round(elapsedMs / 60000),
|
|
41
|
+
exceeded_by_minutes: Math.round((elapsedMs - limitMs) / 60000),
|
|
42
|
+
action: resolveAction(timeouts.action, 'turn'),
|
|
43
|
+
};
|
|
44
|
+
if (result.action === 'warn') {
|
|
45
|
+
warnings.push(result);
|
|
46
|
+
} else {
|
|
47
|
+
exceeded.push(result);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Per-phase timeout
|
|
54
|
+
const phaseLimit = resolvePhaseLimit(timeouts, config.routing, state.phase);
|
|
55
|
+
const phaseAction = resolvePhaseAction(timeouts, config.routing, state.phase);
|
|
56
|
+
if (phaseLimit) {
|
|
57
|
+
const phaseEnteredAt = findPhaseEntryTime(state);
|
|
58
|
+
if (phaseEnteredAt) {
|
|
59
|
+
const entryMs = new Date(phaseEnteredAt).getTime();
|
|
60
|
+
const limitMs = phaseLimit * 60 * 1000;
|
|
61
|
+
const elapsedMs = nowMs - entryMs;
|
|
62
|
+
if (elapsedMs > limitMs) {
|
|
63
|
+
const result = {
|
|
64
|
+
scope: 'phase',
|
|
65
|
+
phase: state.phase,
|
|
66
|
+
limit_minutes: phaseLimit,
|
|
67
|
+
elapsed_minutes: Math.round(elapsedMs / 60000),
|
|
68
|
+
exceeded_by_minutes: Math.round((elapsedMs - limitMs) / 60000),
|
|
69
|
+
action: phaseAction,
|
|
70
|
+
};
|
|
71
|
+
if (result.action === 'warn') {
|
|
72
|
+
warnings.push(result);
|
|
73
|
+
} else {
|
|
74
|
+
exceeded.push(result);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Per-run timeout
|
|
81
|
+
if (timeouts.per_run_minutes) {
|
|
82
|
+
const createdAt = state.created_at;
|
|
83
|
+
if (createdAt) {
|
|
84
|
+
const createMs = new Date(createdAt).getTime();
|
|
85
|
+
const limitMs = timeouts.per_run_minutes * 60 * 1000;
|
|
86
|
+
const elapsedMs = nowMs - createMs;
|
|
87
|
+
if (elapsedMs > limitMs) {
|
|
88
|
+
const result = {
|
|
89
|
+
scope: 'run',
|
|
90
|
+
limit_minutes: timeouts.per_run_minutes,
|
|
91
|
+
elapsed_minutes: Math.round(elapsedMs / 60000),
|
|
92
|
+
exceeded_by_minutes: Math.round((elapsedMs - limitMs) / 60000),
|
|
93
|
+
action: resolveAction(timeouts.action, 'run'),
|
|
94
|
+
};
|
|
95
|
+
if (result.action === 'warn') {
|
|
96
|
+
warnings.push(result);
|
|
97
|
+
} else {
|
|
98
|
+
exceeded.push(result);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { exceeded, warnings };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve the phase-level timeout limit.
|
|
109
|
+
* Per-phase routing override takes precedence over global per_phase_minutes.
|
|
110
|
+
*/
|
|
111
|
+
function resolvePhaseLimit(timeouts, routing, phase) {
|
|
112
|
+
if (routing && routing[phase] && typeof routing[phase].timeout_minutes === 'number') {
|
|
113
|
+
return routing[phase].timeout_minutes;
|
|
114
|
+
}
|
|
115
|
+
return timeouts.per_phase_minutes || null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolve the phase-level timeout action.
|
|
120
|
+
* Per-phase routing override takes precedence over global action.
|
|
121
|
+
*/
|
|
122
|
+
function resolvePhaseAction(timeouts, routing, phase) {
|
|
123
|
+
if (routing && routing[phase] && routing[phase].timeout_action) {
|
|
124
|
+
return routing[phase].timeout_action;
|
|
125
|
+
}
|
|
126
|
+
return timeouts.action || 'escalate';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolve the effective action, enforcing that skip_phase is only valid for phase scope.
|
|
131
|
+
*/
|
|
132
|
+
function resolveAction(action, scope) {
|
|
133
|
+
if (action === 'skip_phase' && scope !== 'phase') {
|
|
134
|
+
return 'escalate';
|
|
135
|
+
}
|
|
136
|
+
return action || 'escalate';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Find when the current phase was entered.
|
|
141
|
+
* Uses phase_entered_at if available, otherwise falls back to created_at for the first phase.
|
|
142
|
+
*/
|
|
143
|
+
function findPhaseEntryTime(state) {
|
|
144
|
+
if (state.phase_entered_at) return state.phase_entered_at;
|
|
145
|
+
// Fallback for first phase: use run creation time
|
|
146
|
+
return state.created_at || null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Validate the timeouts config section.
|
|
151
|
+
* Returns { ok, errors }.
|
|
152
|
+
*/
|
|
153
|
+
export function validateTimeoutsConfig(timeouts, routing) {
|
|
154
|
+
const errors = [];
|
|
155
|
+
|
|
156
|
+
if (timeouts === null || timeouts === undefined) {
|
|
157
|
+
return { ok: true, errors: [] };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (typeof timeouts !== 'object' || Array.isArray(timeouts)) {
|
|
161
|
+
errors.push('timeouts must be an object');
|
|
162
|
+
return { ok: false, errors };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if ('per_turn_minutes' in timeouts) {
|
|
166
|
+
if (typeof timeouts.per_turn_minutes !== 'number' || timeouts.per_turn_minutes < 1) {
|
|
167
|
+
errors.push('timeouts.per_turn_minutes must be a number >= 1');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if ('per_phase_minutes' in timeouts) {
|
|
172
|
+
if (typeof timeouts.per_phase_minutes !== 'number' || timeouts.per_phase_minutes < 1) {
|
|
173
|
+
errors.push('timeouts.per_phase_minutes must be a number >= 1');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if ('per_run_minutes' in timeouts) {
|
|
178
|
+
if (typeof timeouts.per_run_minutes !== 'number' || timeouts.per_run_minutes < 1) {
|
|
179
|
+
errors.push('timeouts.per_run_minutes must be a number >= 1');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if ('action' in timeouts) {
|
|
184
|
+
if (!VALID_TIMEOUT_ACTIONS.includes(timeouts.action)) {
|
|
185
|
+
errors.push(`timeouts.action must be one of: escalate, warn`);
|
|
186
|
+
}
|
|
187
|
+
if (timeouts.action === 'skip_phase') {
|
|
188
|
+
// skip_phase is only valid as a per-phase override, not as a global default
|
|
189
|
+
// because it makes no sense for per-turn or per-run timeouts
|
|
190
|
+
errors.push('timeouts.action cannot be "skip_phase" at the global level — use per-phase timeout_action override in routing instead');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Per-phase timeout overrides in routing
|
|
195
|
+
if (routing) {
|
|
196
|
+
for (const [phase, route] of Object.entries(routing)) {
|
|
197
|
+
if ('timeout_minutes' in route) {
|
|
198
|
+
if (typeof route.timeout_minutes !== 'number' || route.timeout_minutes < 1) {
|
|
199
|
+
errors.push(`Routing "${phase}": timeout_minutes must be a number >= 1`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if ('timeout_action' in route) {
|
|
203
|
+
if (!VALID_TIMEOUT_ACTIONS.includes(route.timeout_action)) {
|
|
204
|
+
errors.push(`Routing "${phase}": timeout_action must be one of: ${VALID_TIMEOUT_ACTIONS.join(', ')}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { ok: errors.length === 0, errors };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Build a blocked_reason descriptor for a timeout.
|
|
215
|
+
*/
|
|
216
|
+
export function buildTimeoutBlockedReason(timeoutResult, options = {}) {
|
|
217
|
+
const scopeLabel = timeoutResult.scope === 'turn'
|
|
218
|
+
? 'Turn timeout'
|
|
219
|
+
: timeoutResult.scope === 'phase'
|
|
220
|
+
? `Phase timeout (${timeoutResult.phase || 'unknown'})`
|
|
221
|
+
: 'Run timeout';
|
|
222
|
+
const turnRetained = options.turnRetained === true;
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
category: 'timeout',
|
|
226
|
+
recovery: {
|
|
227
|
+
typed_reason: 'timeout',
|
|
228
|
+
owner: 'operator',
|
|
229
|
+
recovery_action: 'agentxchain resume',
|
|
230
|
+
turn_retained: turnRetained,
|
|
231
|
+
detail: `${scopeLabel}: limit was ${timeoutResult.limit_minutes}m, elapsed ${timeoutResult.elapsed_minutes}m (exceeded by ${timeoutResult.exceeded_by_minutes}m)`,
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
@@ -142,6 +142,26 @@
|
|
|
142
142
|
"requires_human_approval": true
|
|
143
143
|
}
|
|
144
144
|
},
|
|
145
|
+
"policies": [
|
|
146
|
+
{
|
|
147
|
+
"id": "phase-turn-cap",
|
|
148
|
+
"rule": "max_turns_per_phase",
|
|
149
|
+
"params": { "limit": 15 },
|
|
150
|
+
"action": "escalate"
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"id": "total-turn-cap",
|
|
154
|
+
"rule": "max_total_turns",
|
|
155
|
+
"params": { "limit": 60 },
|
|
156
|
+
"action": "escalate"
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"id": "no-role-monopoly",
|
|
160
|
+
"rule": "max_consecutive_same_role",
|
|
161
|
+
"params": { "limit": 4 },
|
|
162
|
+
"action": "block"
|
|
163
|
+
}
|
|
164
|
+
],
|
|
145
165
|
"workflow_kit": {
|
|
146
166
|
"phases": {
|
|
147
167
|
"planning": {
|