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.
- package/bin/agentxchain.js +70 -0
- package/dashboard/app.js +6 -0
- package/dashboard/components/chain.js +200 -0
- package/dashboard/components/mission.js +177 -0
- package/dashboard/index.html +2 -0
- package/package.json +2 -1
- package/scripts/check-release-alignment.mjs +66 -0
- package/scripts/release-bump.sh +8 -59
- package/scripts/release-preflight.sh +23 -8
- package/src/commands/chain.js +252 -0
- package/src/commands/diff.js +19 -0
- package/src/commands/mission.js +252 -0
- package/src/commands/run.js +112 -97
- package/src/lib/chain-reports.js +54 -0
- package/src/lib/dashboard/bridge-server.js +16 -0
- package/src/lib/dashboard/chain-report-reader.js +15 -0
- package/src/lib/dashboard/file-watcher.js +13 -11
- package/src/lib/dashboard/mission-reader.js +14 -0
- package/src/lib/dashboard/state-reader.js +15 -1
- package/src/lib/export-diff.js +10 -1
- package/src/lib/missions.js +195 -0
- package/src/lib/release-alignment.js +336 -0
- package/src/lib/run-chain.js +296 -0
|
@@ -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
|
+
}
|