agentxchain 2.46.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.
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Approval Policy evaluator — pure library for conditional auto-approval.
3
+ *
4
+ * Sits between the gate evaluator and the state machine. When a gate returns
5
+ * 'awaiting_human_approval', this module evaluates the configured approval_policy
6
+ * to determine whether the gate should auto-approve or pause for human approval.
7
+ *
8
+ * Invariants:
9
+ * - Only evaluates when gateResult.action === 'awaiting_human_approval'
10
+ * - Can only relax the human-approval requirement, never override gate failures
11
+ * - --auto-approve on run overrides this entirely (handled in run.js)
12
+ * - Absent approval_policy → always require_human (backwards compatible)
13
+ */
14
+
15
+ /**
16
+ * Evaluate approval policy for a gate result.
17
+ *
18
+ * @param {object} params
19
+ * @param {object} params.gateResult - from evaluatePhaseExit or evaluateRunCompletion
20
+ * @param {'phase_transition'|'run_completion'} params.gateType
21
+ * @param {object} params.state - current run state
22
+ * @param {object} params.config - normalized config
23
+ * @returns {{ action: 'auto_approve'|'require_human', matched_rule: object|null, reason: string }}
24
+ */
25
+ export function evaluateApprovalPolicy({ gateResult, gateType, state, config }) {
26
+ const policy = config?.approval_policy;
27
+
28
+ // No policy configured → always require human
29
+ if (!policy) {
30
+ return { action: 'require_human', matched_rule: null, reason: 'no approval_policy configured' };
31
+ }
32
+
33
+ if (gateType === 'run_completion') {
34
+ return evaluateRunCompletionPolicy({ gateResult, state, config, policy });
35
+ }
36
+
37
+ return evaluatePhaseTransitionPolicy({ gateResult, state, config, policy });
38
+ }
39
+
40
+ function evaluateRunCompletionPolicy({ gateResult, state, config, policy }) {
41
+ const rc = policy.run_completion;
42
+ if (!rc || !rc.action) {
43
+ return { action: 'require_human', matched_rule: null, reason: 'no run_completion policy' };
44
+ }
45
+
46
+ if (rc.action === 'require_human') {
47
+ return { action: 'require_human', matched_rule: rc, reason: 'run_completion policy requires human approval' };
48
+ }
49
+
50
+ // action === 'auto_approve' — check conditions
51
+ if (rc.when) {
52
+ const conditionResult = checkConditions(rc.when, { gateResult, state, config });
53
+ if (!conditionResult.ok) {
54
+ return { action: 'require_human', matched_rule: rc, reason: conditionResult.reason };
55
+ }
56
+ }
57
+
58
+ return { action: 'auto_approve', matched_rule: rc, reason: 'run_completion policy auto-approved' };
59
+ }
60
+
61
+ function evaluatePhaseTransitionPolicy({ gateResult, state, config, policy }) {
62
+ const pt = policy.phase_transitions;
63
+ if (!pt) {
64
+ return { action: 'require_human', matched_rule: null, reason: 'no phase_transitions policy' };
65
+ }
66
+
67
+ const fromPhase = state.phase;
68
+ const toPhase = gateResult.next_phase;
69
+
70
+ // Check rules (first match wins)
71
+ if (Array.isArray(pt.rules)) {
72
+ for (const rule of pt.rules) {
73
+ if (ruleMatches(rule, fromPhase, toPhase)) {
74
+ if (rule.action === 'require_human') {
75
+ return { action: 'require_human', matched_rule: rule, reason: `rule matched: ${fromPhase} → ${toPhase} requires human` };
76
+ }
77
+ // action === 'auto_approve' — check conditions
78
+ if (rule.when) {
79
+ const conditionResult = checkConditions(rule.when, { gateResult, state, config });
80
+ if (!conditionResult.ok) {
81
+ return { action: 'require_human', matched_rule: rule, reason: conditionResult.reason };
82
+ }
83
+ }
84
+ return { action: 'auto_approve', matched_rule: rule, reason: `rule matched: ${fromPhase} → ${toPhase} auto-approved` };
85
+ }
86
+ }
87
+ }
88
+
89
+ // No rule matched → use default
90
+ const defaultAction = pt.default || 'require_human';
91
+ return { action: defaultAction, matched_rule: null, reason: `default: ${defaultAction}` };
92
+ }
93
+
94
+ function ruleMatches(rule, fromPhase, toPhase) {
95
+ if (rule.from_phase && rule.from_phase !== fromPhase) return false;
96
+ if (rule.to_phase && rule.to_phase !== toPhase) return false;
97
+ return true;
98
+ }
99
+
100
+ /**
101
+ * Check when conditions. Returns { ok, reason }.
102
+ */
103
+ function checkConditions(when, { gateResult, state, config }) {
104
+ // gate_passed: gate structural predicates must have passed
105
+ if (when.gate_passed === true && !gateResult.passed) {
106
+ return { ok: false, reason: 'condition gate_passed not met: gate did not pass structural predicates' };
107
+ }
108
+
109
+ // roles_participated: specified roles must have accepted turns in the current phase
110
+ if (Array.isArray(when.roles_participated) && when.roles_participated.length > 0) {
111
+ const phase = state.phase;
112
+ const history = Array.isArray(state.history) ? state.history : [];
113
+ for (const roleId of when.roles_participated) {
114
+ const participated = history.some(
115
+ turn => turn.phase === phase && turn.role === roleId,
116
+ );
117
+ if (!participated) {
118
+ return { ok: false, reason: `condition roles_participated not met: role "${roleId}" has no accepted turn in phase "${phase}"` };
119
+ }
120
+ }
121
+ }
122
+
123
+ // all_phases_visited: every routing phase must appear in history
124
+ if (when.all_phases_visited === true) {
125
+ const routingPhases = Object.keys(config.routing || {});
126
+ const visitedPhases = new Set(
127
+ (Array.isArray(state.history) ? state.history : []).map(t => t.phase),
128
+ );
129
+ // Also include current phase
130
+ visitedPhases.add(state.phase);
131
+ for (const phase of routingPhases) {
132
+ if (!visitedPhases.has(phase)) {
133
+ return { ok: false, reason: `condition all_phases_visited not met: phase "${phase}" never visited` };
134
+ }
135
+ }
136
+ }
137
+
138
+ return { ok: true, reason: 'all conditions met' };
139
+ }
@@ -182,6 +182,17 @@ export function deriveRecoveryDescriptor(state, config = null) {
182
182
  };
183
183
  }
184
184
 
185
+ if (state.blocked_on.startsWith('timeout:')) {
186
+ const scope = state.blocked_on.slice('timeout:'.length).trim() || 'unknown';
187
+ return {
188
+ typed_reason: 'timeout',
189
+ owner: 'operator',
190
+ recovery_action: 'agentxchain resume',
191
+ turn_retained: false,
192
+ detail: `${scope} timeout exceeded`,
193
+ };
194
+ }
195
+
185
196
  return {
186
197
  typed_reason: 'unknown_block',
187
198
  owner: 'human',
@@ -18,8 +18,10 @@ import { readResource } from './state-reader.js';
18
18
  import { FileWatcher } from './file-watcher.js';
19
19
  import { approvePendingDashboardGate } from './actions.js';
20
20
  import { readCoordinatorBlockerSnapshot } from './coordinator-blockers.js';
21
+ import { readCoordinatorTimeoutStatus } from './coordinator-timeout-status.js';
21
22
  import { readWorkflowKitArtifacts } from './workflow-kit-artifacts.js';
22
23
  import { readConnectorHealthSnapshot } from './connectors.js';
24
+ import { readTimeoutStatus } from './timeout-status.js';
23
25
  import { queryRunHistory } from '../run-history.js';
24
26
 
25
27
  const MIME_TYPES = {
@@ -282,6 +284,12 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847 }
282
284
  return;
283
285
  }
284
286
 
287
+ if (pathname === '/api/coordinator/timeouts') {
288
+ const result = readCoordinatorTimeoutStatus(workspacePath);
289
+ writeJson(res, result.status, result.body);
290
+ return;
291
+ }
292
+
285
293
  if (pathname === '/api/workflow-kit-artifacts') {
286
294
  const result = readWorkflowKitArtifacts(workspacePath);
287
295
  writeJson(res, result.status, result.body);
@@ -294,6 +302,12 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847 }
294
302
  return;
295
303
  }
296
304
 
305
+ if (pathname === '/api/timeouts') {
306
+ const result = readTimeoutStatus(workspacePath);
307
+ writeJson(res, result.status, result.body);
308
+ return;
309
+ }
310
+
297
311
  if (pathname === '/api/run-history') {
298
312
  const url = new URL(req.url, `http://${req.headers.host}`);
299
313
  const limit = url.searchParams.get('limit') ? parseInt(url.searchParams.get('limit'), 10) : undefined;
@@ -0,0 +1,139 @@
1
+ import { join } from 'path';
2
+ import { loadProjectContext, loadProjectState } from '../config.js';
3
+ import { loadCoordinatorConfig } from '../coordinator-config.js';
4
+ import { loadCoordinatorState } from '../coordinator-state.js';
5
+ import { readJsonlFile } from './state-reader.js';
6
+ import { buildTimeoutConfigSummary, evaluateDashboardTimeoutPressure, extractTimeoutEvents } from './timeout-status.js';
7
+
8
+ function getCoordinatorConfigErrorResponse(errors) {
9
+ const issueList = Array.isArray(errors) ? errors : [];
10
+ const missing = issueList.some((error) => typeof error === 'string' && error.startsWith('config_missing:'));
11
+
12
+ return {
13
+ ok: false,
14
+ status: missing ? 404 : 422,
15
+ body: {
16
+ ok: false,
17
+ code: missing ? 'coordinator_config_missing' : 'coordinator_config_invalid',
18
+ error: missing
19
+ ? 'Coordinator config not found. Run `agentxchain multi init` first.'
20
+ : 'Coordinator config is invalid.',
21
+ errors: issueList,
22
+ },
23
+ };
24
+ }
25
+
26
+ function emptyLiveTimeouts() {
27
+ return { exceeded: [], warnings: [] };
28
+ }
29
+
30
+ function readRepoTimeoutSnapshot(repoId, repo, repoRun) {
31
+ const context = loadProjectContext(repo.resolved_path);
32
+ if (!context) {
33
+ return {
34
+ repo_id: repoId,
35
+ path: repo.path,
36
+ run_id: repoRun?.run_id ?? null,
37
+ status: repoRun?.status ?? null,
38
+ phase: repoRun?.phase ?? null,
39
+ configured: false,
40
+ config: null,
41
+ live: null,
42
+ events: [],
43
+ error: {
44
+ code: 'repo_config_missing',
45
+ error: `Repo "${repoId}" config could not be loaded.`,
46
+ },
47
+ };
48
+ }
49
+
50
+ const state = loadProjectState(context.root, context.config);
51
+ const agentxchainDir = join(context.root, '.agentxchain');
52
+ const events = extractTimeoutEvents(readJsonlFile(agentxchainDir, 'decision-ledger.jsonl'));
53
+ const configured = Boolean(context.config.timeouts);
54
+
55
+ if (!state) {
56
+ return {
57
+ repo_id: repoId,
58
+ path: repo.path,
59
+ run_id: repoRun?.run_id ?? null,
60
+ status: repoRun?.status ?? null,
61
+ phase: repoRun?.phase ?? null,
62
+ configured,
63
+ config: buildTimeoutConfigSummary(context.config.timeouts, context.config.routing),
64
+ live: configured ? emptyLiveTimeouts() : null,
65
+ events,
66
+ error: {
67
+ code: 'repo_state_missing',
68
+ error: `Repo "${repoId}" governed state is missing.`,
69
+ },
70
+ };
71
+ }
72
+
73
+ const live = configured
74
+ ? evaluateDashboardTimeoutPressure(context.config, state, new Date())
75
+ : null;
76
+
77
+ return {
78
+ repo_id: repoId,
79
+ path: repo.path,
80
+ run_id: state.run_id ?? repoRun?.run_id ?? null,
81
+ status: state.status ?? repoRun?.status ?? null,
82
+ phase: state.phase ?? repoRun?.phase ?? null,
83
+ configured,
84
+ config: buildTimeoutConfigSummary(context.config.timeouts, context.config.routing),
85
+ live,
86
+ events,
87
+ error: null,
88
+ };
89
+ }
90
+
91
+ export function readCoordinatorTimeoutStatus(workspacePath) {
92
+ const configResult = loadCoordinatorConfig(workspacePath);
93
+ if (!configResult.ok) {
94
+ return getCoordinatorConfigErrorResponse(configResult.errors);
95
+ }
96
+
97
+ const coordinatorState = loadCoordinatorState(workspacePath);
98
+ if (!coordinatorState) {
99
+ return {
100
+ ok: false,
101
+ status: 404,
102
+ body: {
103
+ ok: false,
104
+ code: 'coordinator_state_missing',
105
+ error: 'Coordinator state not found. Run `agentxchain multi init` first.',
106
+ },
107
+ };
108
+ }
109
+
110
+ const coordinatorDir = join(workspacePath, '.agentxchain', 'multirepo');
111
+ const coordinatorEvents = extractTimeoutEvents(readJsonlFile(coordinatorDir, 'decision-ledger.jsonl'));
112
+ const repos = configResult.config.repo_order.map((repoId) => (
113
+ readRepoTimeoutSnapshot(repoId, configResult.config.repos[repoId], coordinatorState.repo_runs?.[repoId] ?? null)
114
+ ));
115
+
116
+ const summary = {
117
+ repo_count: repos.length,
118
+ configured_repo_count: repos.filter((repo) => repo.configured).length,
119
+ repos_with_live_exceeded: repos.filter((repo) => (repo.live?.exceeded?.length || 0) > 0).length,
120
+ repos_with_live_warnings: repos.filter((repo) => (repo.live?.warnings?.length || 0) > 0).length,
121
+ repo_event_count: repos.reduce((sum, repo) => sum + repo.events.length, 0),
122
+ coordinator_event_count: coordinatorEvents.length,
123
+ };
124
+
125
+ return {
126
+ ok: true,
127
+ status: 200,
128
+ body: {
129
+ ok: true,
130
+ super_run_id: coordinatorState.super_run_id ?? null,
131
+ status: coordinatorState.status ?? null,
132
+ phase: coordinatorState.phase ?? null,
133
+ blocked_reason: coordinatorState.blocked_reason ?? null,
134
+ summary,
135
+ coordinator_events: coordinatorEvents,
136
+ repos,
137
+ },
138
+ };
139
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Timeout status — computed dashboard endpoint.
3
+ *
4
+ * Reads agentxchain.json config, .agentxchain/state.json, and the decision
5
+ * ledger to derive live timeout pressure and persisted timeout events.
6
+ *
7
+ * See: TIMEOUT_DASHBOARD_SURFACE_SPEC.md
8
+ */
9
+
10
+ import { join } from 'path';
11
+ import { loadProjectContext, loadProjectState } from '../config.js';
12
+ import { evaluateTimeouts } from '../timeout-evaluator.js';
13
+ import { readJsonlFile } from './state-reader.js';
14
+
15
+ /**
16
+ * Extract timeout events from raw decision-ledger entries.
17
+ * Mirrors the shape of report.js extractTimeoutEventDigest but operates
18
+ * on in-memory ledger array rather than export artifacts.
19
+ */
20
+ export function extractTimeoutEvents(ledgerEntries) {
21
+ if (!Array.isArray(ledgerEntries) || ledgerEntries.length === 0) return [];
22
+ return ledgerEntries
23
+ .filter((d) => typeof d?.type === 'string' && d.type.startsWith('timeout'))
24
+ .map((d) => ({
25
+ type: d.type,
26
+ scope: d.scope || null,
27
+ phase: d.phase || null,
28
+ turn_id: d.turn_id || null,
29
+ limit_minutes: typeof d.limit_minutes === 'number' ? d.limit_minutes : null,
30
+ elapsed_minutes: typeof d.elapsed_minutes === 'number' ? d.elapsed_minutes : null,
31
+ exceeded_by_minutes: typeof d.exceeded_by_minutes === 'number' ? d.exceeded_by_minutes : null,
32
+ action: d.action || null,
33
+ timestamp: d.timestamp || null,
34
+ }));
35
+ }
36
+
37
+ /**
38
+ * Flatten per-phase routing timeout overrides into a display-friendly array.
39
+ */
40
+ export function flattenPhaseOverrides(routing) {
41
+ if (!routing) return [];
42
+ const overrides = [];
43
+ for (const [phase, route] of Object.entries(routing)) {
44
+ if (route.timeout_minutes || route.timeout_action) {
45
+ overrides.push({
46
+ phase,
47
+ limit_minutes: typeof route.timeout_minutes === 'number' ? route.timeout_minutes : null,
48
+ action: route.timeout_action || null,
49
+ });
50
+ }
51
+ }
52
+ return overrides;
53
+ }
54
+
55
+ /**
56
+ * Read timeout status for the dashboard.
57
+ *
58
+ * @param {string} workspacePath — project root (parent of .agentxchain/)
59
+ * @returns {{ ok: boolean, status: number, body: object }}
60
+ */
61
+ export function buildTimeoutConfigSummary(timeouts, routing) {
62
+ if (!timeouts) return null;
63
+ return {
64
+ per_turn_minutes: timeouts.per_turn_minutes || null,
65
+ per_phase_minutes: timeouts.per_phase_minutes || null,
66
+ per_run_minutes: timeouts.per_run_minutes || null,
67
+ action: timeouts.action || 'escalate',
68
+ phase_overrides: flattenPhaseOverrides(routing),
69
+ };
70
+ }
71
+
72
+ function emptyLiveTimeouts() {
73
+ return { exceeded: [], warnings: [] };
74
+ }
75
+
76
+ function getActiveTurns(state) {
77
+ if (!state?.active_turns || typeof state.active_turns !== 'object' || Array.isArray(state.active_turns)) {
78
+ return [];
79
+ }
80
+ return Object.values(state.active_turns).filter((turn) => turn && typeof turn === 'object');
81
+ }
82
+
83
+ function annotateTurnTimeout(result, state, turn) {
84
+ return {
85
+ ...result,
86
+ phase: result.phase || state?.phase || null,
87
+ turn_id: turn?.turn_id || null,
88
+ role_id: turn?.assigned_role || null,
89
+ };
90
+ }
91
+
92
+ export function evaluateDashboardTimeoutPressure(config, state, now = new Date()) {
93
+ if (!config?.timeouts || state?.status !== 'active') {
94
+ return emptyLiveTimeouts();
95
+ }
96
+
97
+ const base = evaluateTimeouts({ config, state, now });
98
+ const live = {
99
+ exceeded: [...base.exceeded],
100
+ warnings: [...base.warnings],
101
+ };
102
+
103
+ for (const turn of getActiveTurns(state)) {
104
+ const turnEval = evaluateTimeouts({ config, state, turn, now });
105
+ for (const item of turnEval.exceeded) {
106
+ if (item.scope === 'turn') {
107
+ live.exceeded.push(annotateTurnTimeout(item, state, turn));
108
+ }
109
+ }
110
+ for (const item of turnEval.warnings) {
111
+ if (item.scope === 'turn') {
112
+ live.warnings.push(annotateTurnTimeout(item, state, turn));
113
+ }
114
+ }
115
+ }
116
+
117
+ return live;
118
+ }
119
+
120
+ function loadDashboardTimeoutContext(workspacePath) {
121
+ const context = loadProjectContext(workspacePath);
122
+ if (!context || context.config?.protocol_mode !== 'governed') {
123
+ return {
124
+ ok: false,
125
+ status: 404,
126
+ body: {
127
+ ok: false,
128
+ code: 'config_missing',
129
+ error: 'Project config not found. Run `agentxchain init --governed` first.',
130
+ },
131
+ };
132
+ }
133
+
134
+ const { root, config } = context;
135
+ const state = loadProjectState(root, config);
136
+ const agentxchainDir = join(root, '.agentxchain');
137
+ if (!state) {
138
+ return {
139
+ ok: false,
140
+ status: 404,
141
+ body: {
142
+ ok: false,
143
+ code: 'state_missing',
144
+ error: 'Run state not found. Run `agentxchain init --governed` first.',
145
+ },
146
+ };
147
+ }
148
+
149
+ return {
150
+ ok: true,
151
+ root,
152
+ config,
153
+ state,
154
+ agentxchainDir,
155
+ };
156
+ }
157
+
158
+ export function readTimeoutStatus(workspacePath) {
159
+ const contextResult = loadDashboardTimeoutContext(workspacePath);
160
+ if (!contextResult.ok) {
161
+ return contextResult;
162
+ }
163
+
164
+ const { config, state, agentxchainDir } = contextResult;
165
+ const timeouts = config.timeouts;
166
+ if (!timeouts) {
167
+ return {
168
+ ok: true,
169
+ status: 200,
170
+ body: {
171
+ ok: true,
172
+ configured: false,
173
+ config: null,
174
+ live: null,
175
+ events: [],
176
+ },
177
+ };
178
+ }
179
+
180
+ // Config summary
181
+ const configSummary = buildTimeoutConfigSummary(timeouts, config.routing);
182
+
183
+ // Live timeout evaluation — only meaningful when the run is active
184
+ const live = evaluateDashboardTimeoutPressure(config, state, new Date());
185
+
186
+ // Persisted timeout events from the decision ledger
187
+ const ledger = readJsonlFile(agentxchainDir, 'decision-ledger.jsonl');
188
+ const events = extractTimeoutEvents(ledger);
189
+
190
+ return {
191
+ ok: true,
192
+ status: 200,
193
+ body: {
194
+ ok: true,
195
+ configured: true,
196
+ config: configSummary,
197
+ live,
198
+ events,
199
+ },
200
+ };
201
+ }