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