agentxchain 2.109.0 → 2.111.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.
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Run Chain — auto-chaining governed runs for lights-out operation.
3
+ *
4
+ * When a governed run completes (or reaches another chainable terminal status),
5
+ * this module automatically starts a new run that inherits context from the
6
+ * previous one. Removes the manual `--continue-from` step and enables
7
+ * continuous governed delivery.
8
+ *
9
+ * Spec: .planning/RUN_CHAIN_SPEC.md
10
+ */
11
+
12
+ import { randomUUID } from 'crypto';
13
+ import { mkdirSync, writeFileSync } from 'fs';
14
+ import { join } from 'path';
15
+ import { recordRunHistory, validateParentRun } from './run-history.js';
16
+
17
+ const DEFAULT_MAX_CHAINS = 5;
18
+ const DEFAULT_CHAIN_ON = ['completed'];
19
+ const DEFAULT_COOLDOWN_SECONDS = 5;
20
+
21
+ /**
22
+ * Resolve chain options from CLI flags and config, with CLI flags taking precedence.
23
+ *
24
+ * @param {object} opts - CLI options
25
+ * @param {object} config - agentxchain.json config
26
+ * @returns {{ enabled: boolean, maxChains: number, chainOn: string[], cooldownSeconds: number }}
27
+ */
28
+ export function resolveChainOptions(opts, config) {
29
+ const configChain = config?.run_loop?.chain || {};
30
+
31
+ const enabled = opts.chain ?? configChain.enabled ?? false;
32
+ const maxChains = opts.maxChains ?? configChain.max_chains ?? DEFAULT_MAX_CHAINS;
33
+ const cooldownSeconds = opts.chainCooldown ?? configChain.cooldown_seconds ?? DEFAULT_COOLDOWN_SECONDS;
34
+
35
+ let chainOn;
36
+ if (opts.chainOn) {
37
+ chainOn = typeof opts.chainOn === 'string'
38
+ ? opts.chainOn.split(',').map(s => s.trim()).filter(Boolean)
39
+ : opts.chainOn;
40
+ } else if (Array.isArray(configChain.chain_on)) {
41
+ chainOn = configChain.chain_on;
42
+ } else {
43
+ chainOn = DEFAULT_CHAIN_ON;
44
+ }
45
+
46
+ return { enabled, maxChains, chainOn, cooldownSeconds };
47
+ }
48
+
49
+ /**
50
+ * Execute a chained sequence of governed runs.
51
+ *
52
+ * @param {object} context - { root, config }
53
+ * @param {object} opts - CLI options (passed to executeGovernedRun)
54
+ * @param {object} chainOpts - resolved chain options
55
+ * @param {Function} executeGovernedRun - the run executor function
56
+ * @param {Function} [log] - logging function
57
+ * @returns {Promise<{ exitCode: number, chainReport: object }>}
58
+ */
59
+ export async function executeChainedRun(context, opts, chainOpts, executeGovernedRun, log = console.log) {
60
+ const chainId = `chain-${randomUUID().slice(0, 8)}`;
61
+ const chainOnSet = new Set(chainOpts.chainOn);
62
+ const maxRuns = chainOpts.maxChains + 1; // initial + continuations
63
+ const startedAt = new Date().toISOString();
64
+
65
+ const chainReport = {
66
+ chain_id: chainId,
67
+ started_at: startedAt,
68
+ runs: [],
69
+ total_turns: 0,
70
+ total_duration_ms: 0,
71
+ terminal_reason: null,
72
+ };
73
+
74
+ let previousRunId = null;
75
+ let lastExitCode = 0;
76
+ let aborted = false;
77
+
78
+ // Capture SIGINT to prevent chaining after current run
79
+ const onSigint = () => { aborted = true; };
80
+ process.on('SIGINT', onSigint);
81
+
82
+ try {
83
+ for (let i = 0; i < maxRuns; i++) {
84
+ const runNumber = i + 1;
85
+ log('');
86
+ log(` \u2500\u2500 Chain run ${runNumber}/${maxRuns} ${'─'.repeat(50)}`);
87
+
88
+ // Build continuation options for runs after the first
89
+ const runOpts = { ...opts };
90
+ if (previousRunId) {
91
+ runOpts.continueFrom = previousRunId;
92
+ runOpts.inheritContext = true;
93
+ }
94
+
95
+ const runStart = Date.now();
96
+ const execution = await executeGovernedRun(context, runOpts);
97
+ const runDuration = Date.now() - runStart;
98
+
99
+ const runId = execution.result?.state?.run_id || `unknown-${i}`;
100
+ const stopReason = execution.result?.stop_reason || (execution.result?.ok ? 'completed' : 'unknown');
101
+ const turnsExecuted = execution.result?.turns_executed || 0;
102
+
103
+ chainReport.runs.push(buildRunReportEntry(execution, runDuration, {
104
+ fallbackRunId: runId,
105
+ fallbackStatus: stopReason,
106
+ fallbackTurns: turnsExecuted,
107
+ }));
108
+ chainReport.total_turns += turnsExecuted;
109
+ chainReport.total_duration_ms += runDuration;
110
+
111
+ lastExitCode = execution.exitCode;
112
+ previousRunId = runId;
113
+
114
+ // Check abort
115
+ if (aborted) {
116
+ chainReport.terminal_reason = 'operator_abort';
117
+ break;
118
+ }
119
+
120
+ // Check if this is the last possible run
121
+ if (i === maxRuns - 1) {
122
+ chainReport.terminal_reason = 'chain_limit_reached';
123
+ break;
124
+ }
125
+
126
+ // Check if terminal status is chainable
127
+ if (!chainOnSet.has(stopReason)) {
128
+ chainReport.terminal_reason = 'non_chainable_status';
129
+ break;
130
+ }
131
+
132
+ // Validate parent run exists for continuation
133
+ let validation = validateParentRun(context.root, runId);
134
+ if (!validation.ok && execution.result?.state && (stopReason === 'completed' || stopReason === 'blocked')) {
135
+ const repair = recordRunHistory(context.root, execution.result.state, context.config, stopReason);
136
+ if (repair.ok) {
137
+ validation = validateParentRun(context.root, runId);
138
+ }
139
+ }
140
+ if (!validation.ok) {
141
+ log(` Chain: cannot continue — ${validation.error}`);
142
+ chainReport.terminal_reason = 'parent_validation_failed';
143
+ break;
144
+ }
145
+
146
+ // Cooldown
147
+ const continuationsRemaining = Math.max(0, maxRuns - (runNumber + 1));
148
+ log('');
149
+ log(` Chain: run ${stopReason} \u2192 starting continuation (${continuationsRemaining} remaining)...`);
150
+ if (chainOpts.cooldownSeconds > 0) {
151
+ log(` Waiting ${chainOpts.cooldownSeconds}s...`);
152
+ await sleep(chainOpts.cooldownSeconds * 1000);
153
+ }
154
+
155
+ // Check abort again after cooldown
156
+ if (aborted) {
157
+ chainReport.terminal_reason = 'operator_abort';
158
+ break;
159
+ }
160
+ }
161
+ } finally {
162
+ process.removeListener('SIGINT', onSigint);
163
+ }
164
+
165
+ // If terminal_reason not set, derive from last run
166
+ if (!chainReport.terminal_reason) {
167
+ const lastRun = chainReport.runs[chainReport.runs.length - 1];
168
+ chainReport.terminal_reason = lastRun?.status || 'unknown';
169
+ }
170
+
171
+ chainReport.completed_at = new Date().toISOString();
172
+
173
+ // Write chain report
174
+ writeChainReport(context.root, chainReport);
175
+
176
+ // Print chain summary
177
+ printChainSummary(chainReport, log);
178
+
179
+ return { exitCode: lastExitCode, chainReport };
180
+ }
181
+
182
+ /**
183
+ * Write chain report to .agentxchain/reports/.
184
+ */
185
+ function writeChainReport(root, report) {
186
+ try {
187
+ const reportsDir = join(root, '.agentxchain', 'reports');
188
+ mkdirSync(reportsDir, { recursive: true });
189
+ const reportPath = join(reportsDir, `${report.chain_id}.json`);
190
+ writeFileSync(reportPath, JSON.stringify(report, null, 2));
191
+ } catch {
192
+ // Non-fatal — chain report is advisory
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Print chain summary to terminal.
198
+ */
199
+ function printChainSummary(report, log) {
200
+ log('');
201
+ log(' \u2500\u2500\u2500 Chain Summary \u2500\u2500\u2500');
202
+ log(` Total runs: ${report.runs.length}`);
203
+ log(` Total turns: ${report.total_turns}`);
204
+ log(` Duration: ${formatDuration(report.total_duration_ms)}`);
205
+ log(` Terminal: ${report.terminal_reason}`);
206
+ }
207
+
208
+ /**
209
+ * Format milliseconds to a human-readable duration.
210
+ */
211
+ function formatDuration(ms) {
212
+ if (ms < 1000) return `${ms}ms`;
213
+ const seconds = Math.floor(ms / 1000);
214
+ if (seconds < 60) return `${seconds}s`;
215
+ const minutes = Math.floor(seconds / 60);
216
+ const remainingSeconds = seconds % 60;
217
+ if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
218
+ const hours = Math.floor(minutes / 60);
219
+ const remainingMinutes = minutes % 60;
220
+ return `${hours}h ${remainingMinutes}m`;
221
+ }
222
+
223
+ function sleep(ms) {
224
+ return new Promise(resolve => setTimeout(resolve, ms));
225
+ }
226
+
227
+ function buildRunReportEntry(execution, runDuration, fallback = {}) {
228
+ const state = execution?.result?.state || null;
229
+ const provenance = state?.provenance || null;
230
+
231
+ return {
232
+ run_id: state?.run_id || fallback.fallbackRunId || 'unknown',
233
+ status: execution?.result?.stop_reason || fallback.fallbackStatus || 'unknown',
234
+ turns: execution?.result?.turns_executed || fallback.fallbackTurns || 0,
235
+ duration_ms: runDuration,
236
+ provenance_trigger: provenance?.trigger || null,
237
+ parent_run_id: provenance?.parent_run_id || null,
238
+ inherited_context_summary: summarizeInheritedContext(state?.inherited_context || null),
239
+ };
240
+ }
241
+
242
+ function summarizeInheritedContext(inheritedContext) {
243
+ if (!inheritedContext) return null;
244
+
245
+ return {
246
+ parent_run_id: inheritedContext.parent_run_id || null,
247
+ parent_status: inheritedContext.parent_status || null,
248
+ inherited_at: inheritedContext.inherited_at || null,
249
+ parent_roles_used: Array.isArray(inheritedContext.parent_roles_used)
250
+ ? inheritedContext.parent_roles_used
251
+ : [],
252
+ parent_phases_completed_count: Array.isArray(inheritedContext.parent_phases_completed)
253
+ ? inheritedContext.parent_phases_completed.length
254
+ : 0,
255
+ recent_decisions_count: Array.isArray(inheritedContext.recent_decisions)
256
+ ? inheritedContext.recent_decisions.length
257
+ : 0,
258
+ recent_accepted_turns_count: Array.isArray(inheritedContext.recent_accepted_turns)
259
+ ? inheritedContext.recent_accepted_turns.length
260
+ : 0,
261
+ };
262
+ }