agentxchain 0.8.8 → 2.1.1
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/README.md +126 -142
- package/bin/agentxchain.js +186 -5
- package/dashboard/app.js +305 -0
- package/dashboard/components/blocked.js +145 -0
- package/dashboard/components/cross-repo.js +126 -0
- package/dashboard/components/gate.js +311 -0
- package/dashboard/components/hooks.js +177 -0
- package/dashboard/components/initiative.js +147 -0
- package/dashboard/components/ledger.js +165 -0
- package/dashboard/components/timeline.js +222 -0
- package/dashboard/index.html +352 -0
- package/package.json +14 -6
- package/scripts/live-api-proxy-preflight-smoke.sh +531 -0
- package/scripts/publish-from-tag.sh +88 -0
- package/scripts/release-postflight.sh +231 -0
- package/scripts/release-preflight.sh +167 -0
- package/src/commands/accept-turn.js +160 -0
- package/src/commands/approve-completion.js +80 -0
- package/src/commands/approve-transition.js +85 -0
- package/src/commands/dashboard.js +70 -0
- package/src/commands/init.js +516 -0
- package/src/commands/migrate.js +348 -0
- package/src/commands/multi.js +549 -0
- package/src/commands/plugin.js +157 -0
- package/src/commands/reject-turn.js +204 -0
- package/src/commands/resume.js +389 -0
- package/src/commands/status.js +196 -3
- package/src/commands/step.js +947 -0
- package/src/commands/template-list.js +33 -0
- package/src/commands/template-set.js +279 -0
- package/src/commands/validate.js +20 -11
- package/src/commands/verify.js +71 -0
- package/src/lib/adapters/api-proxy-adapter.js +1076 -0
- package/src/lib/adapters/local-cli-adapter.js +337 -0
- package/src/lib/adapters/manual-adapter.js +169 -0
- package/src/lib/blocked-state.js +94 -0
- package/src/lib/config.js +97 -1
- package/src/lib/context-compressor.js +121 -0
- package/src/lib/context-section-parser.js +220 -0
- package/src/lib/coordinator-acceptance.js +428 -0
- package/src/lib/coordinator-config.js +461 -0
- package/src/lib/coordinator-dispatch.js +276 -0
- package/src/lib/coordinator-gates.js +487 -0
- package/src/lib/coordinator-hooks.js +239 -0
- package/src/lib/coordinator-recovery.js +523 -0
- package/src/lib/coordinator-state.js +365 -0
- package/src/lib/cross-repo-context.js +247 -0
- package/src/lib/dashboard/bridge-server.js +284 -0
- package/src/lib/dashboard/file-watcher.js +93 -0
- package/src/lib/dashboard/state-reader.js +96 -0
- package/src/lib/dispatch-bundle.js +568 -0
- package/src/lib/dispatch-manifest.js +252 -0
- package/src/lib/gate-evaluator.js +285 -0
- package/src/lib/governed-state.js +2139 -0
- package/src/lib/governed-templates.js +145 -0
- package/src/lib/hook-runner.js +788 -0
- package/src/lib/normalized-config.js +539 -0
- package/src/lib/plugin-config-schema.js +192 -0
- package/src/lib/plugins.js +692 -0
- package/src/lib/protocol-conformance.js +291 -0
- package/src/lib/reference-conformance-adapter.js +717 -0
- package/src/lib/repo-observer.js +597 -0
- package/src/lib/repo.js +0 -31
- package/src/lib/schema.js +121 -0
- package/src/lib/schemas/turn-result.schema.json +205 -0
- package/src/lib/token-budget.js +206 -0
- package/src/lib/token-counter.js +27 -0
- package/src/lib/turn-paths.js +67 -0
- package/src/lib/turn-result-validator.js +496 -0
- package/src/lib/validation.js +137 -0
- package/src/templates/governed/api-service.json +31 -0
- package/src/templates/governed/cli-tool.json +30 -0
- package/src/templates/governed/generic.json +10 -0
- package/src/templates/governed/web-app.json +30 -0
|
@@ -0,0 +1,947 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentxchain step — governed-only single-turn execution command.
|
|
3
|
+
*
|
|
4
|
+
* Composes the full turn lifecycle in one command:
|
|
5
|
+
* 1. Initialize/resume run if needed (like `resume`)
|
|
6
|
+
* 2. Assign a turn and write dispatch bundle
|
|
7
|
+
* 3. Wait for the turn to complete (adapter-specific)
|
|
8
|
+
* 4. Validate the staged result
|
|
9
|
+
* 5. Accept or reject (with retry if applicable)
|
|
10
|
+
* 6. Print outcome summary
|
|
11
|
+
*
|
|
12
|
+
* This is the first truthful single-turn governed operator experience.
|
|
13
|
+
* It does NOT loop — it runs exactly one turn and exits.
|
|
14
|
+
*
|
|
15
|
+
* Adapter support:
|
|
16
|
+
* - manual: prints dispatch instructions, polls for staged result file
|
|
17
|
+
* - local_cli: implemented via subprocess dispatch + staged turn result
|
|
18
|
+
* - api_proxy: implemented for synchronous review-only turns and stages
|
|
19
|
+
* provider-backed JSON before validation/acceptance
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import chalk from 'chalk';
|
|
23
|
+
import { existsSync, readFileSync } from 'fs';
|
|
24
|
+
import { join } from 'path';
|
|
25
|
+
import { loadProjectContext, loadProjectState } from '../lib/config.js';
|
|
26
|
+
import {
|
|
27
|
+
initializeGovernedRun,
|
|
28
|
+
assignGovernedTurn,
|
|
29
|
+
acceptGovernedTurn,
|
|
30
|
+
rejectGovernedTurn,
|
|
31
|
+
markRunBlocked,
|
|
32
|
+
getActiveTurnCount,
|
|
33
|
+
getActiveTurns,
|
|
34
|
+
STATE_PATH,
|
|
35
|
+
} from '../lib/governed-state.js';
|
|
36
|
+
import { getMaxConcurrentTurns } from '../lib/normalized-config.js';
|
|
37
|
+
import { writeDispatchBundle } from '../lib/dispatch-bundle.js';
|
|
38
|
+
import { validateStagedTurnResult } from '../lib/turn-result-validator.js';
|
|
39
|
+
import {
|
|
40
|
+
printManualDispatchInstructions,
|
|
41
|
+
waitForStagedResult,
|
|
42
|
+
} from '../lib/adapters/manual-adapter.js';
|
|
43
|
+
import {
|
|
44
|
+
dispatchLocalCli,
|
|
45
|
+
saveDispatchLogs,
|
|
46
|
+
resolvePromptTransport,
|
|
47
|
+
} from '../lib/adapters/local-cli-adapter.js';
|
|
48
|
+
import {
|
|
49
|
+
getDispatchAssignmentPath,
|
|
50
|
+
getDispatchContextPath,
|
|
51
|
+
getDispatchEffectiveContextPath,
|
|
52
|
+
getDispatchPromptPath,
|
|
53
|
+
getDispatchTurnDir,
|
|
54
|
+
getTurnStagingResultPath,
|
|
55
|
+
} from '../lib/turn-paths.js';
|
|
56
|
+
import { dispatchApiProxy } from '../lib/adapters/api-proxy-adapter.js';
|
|
57
|
+
import { safeWriteJson } from '../lib/safe-write.js';
|
|
58
|
+
import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
|
|
59
|
+
import { runHooks } from '../lib/hook-runner.js';
|
|
60
|
+
import { finalizeDispatchManifest, verifyDispatchManifest } from '../lib/dispatch-manifest.js';
|
|
61
|
+
|
|
62
|
+
export async function stepCommand(opts) {
|
|
63
|
+
const context = loadProjectContext();
|
|
64
|
+
if (!context) {
|
|
65
|
+
console.log(chalk.red('No agentxchain.json found. Run `agentxchain init` first.'));
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const { root, config } = context;
|
|
70
|
+
|
|
71
|
+
if (config.protocol_mode !== 'governed') {
|
|
72
|
+
console.log(chalk.red('The step command is only available for governed projects.'));
|
|
73
|
+
console.log(chalk.dim('Legacy projects use: agentxchain start'));
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Phase 1: Initialize/Resume Run ────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
let state = loadProjectState(root, config);
|
|
80
|
+
if (!state) {
|
|
81
|
+
console.log(chalk.red('No governed state.json found. Run `agentxchain init --governed` first.'));
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Completed runs cannot take more turns
|
|
86
|
+
if (state.status === 'completed') {
|
|
87
|
+
console.log(chalk.green.bold('This run is already completed.'));
|
|
88
|
+
if (state.completed_at) {
|
|
89
|
+
console.log(chalk.dim(` Completed at: ${state.completed_at}`));
|
|
90
|
+
}
|
|
91
|
+
console.log(chalk.dim(' No more turns can be assigned to a completed run.'));
|
|
92
|
+
process.exit(0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// If a turn is already active, decide whether to skip assignment or allow parallel
|
|
96
|
+
let skipAssignment = false;
|
|
97
|
+
let bundleWritten = false;
|
|
98
|
+
let targetTurn = null;
|
|
99
|
+
const maxConcurrent = getMaxConcurrentTurns(config, state.phase);
|
|
100
|
+
const activeCount = getActiveTurnCount(state);
|
|
101
|
+
const activeTurns = getActiveTurns(state);
|
|
102
|
+
|
|
103
|
+
if (state.status === 'active' && activeCount > 0) {
|
|
104
|
+
if (opts.resume) {
|
|
105
|
+
// Resolve the target turn for resume
|
|
106
|
+
if (opts.turn) {
|
|
107
|
+
targetTurn = activeTurns[opts.turn];
|
|
108
|
+
if (!targetTurn) {
|
|
109
|
+
console.log(chalk.red(`No active turn found for --turn ${opts.turn}`));
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
} else if (activeCount > 1) {
|
|
113
|
+
console.log(chalk.red('Multiple active turns exist. Use --turn <id> to specify which turn to resume.'));
|
|
114
|
+
console.log('');
|
|
115
|
+
for (const turn of Object.values(activeTurns)) {
|
|
116
|
+
const statusLabel = turn.status === 'conflicted' ? chalk.red('conflicted') : turn.status;
|
|
117
|
+
console.log(` ${chalk.yellow('●')} ${turn.turn_id} — ${chalk.bold(turn.assigned_role)} (${statusLabel})`);
|
|
118
|
+
}
|
|
119
|
+
console.log('');
|
|
120
|
+
console.log(chalk.dim('Example: agentxchain step --resume --turn <turn_id>'));
|
|
121
|
+
process.exit(1);
|
|
122
|
+
} else {
|
|
123
|
+
targetTurn = Object.values(activeTurns)[0];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// If the target turn is conflicted, print recovery paths instead of resuming
|
|
127
|
+
if (targetTurn.status === 'conflicted') {
|
|
128
|
+
console.log(chalk.yellow(`Turn ${targetTurn.turn_id} is conflicted. Resolve the conflict before resuming.`));
|
|
129
|
+
console.log('');
|
|
130
|
+
console.log(chalk.dim('Recovery options:'));
|
|
131
|
+
console.log(` ${chalk.cyan(`agentxchain reject-turn --turn ${targetTurn.turn_id} --reassign`)} — reject and re-dispatch with conflict context`);
|
|
132
|
+
console.log(` ${chalk.cyan(`agentxchain accept-turn --turn ${targetTurn.turn_id} --resolution human_merge`)} — manually merge and re-accept`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
skipAssignment = true;
|
|
137
|
+
console.log(chalk.yellow(`Resuming active turn: ${targetTurn.turn_id}`));
|
|
138
|
+
} else if (activeCount >= maxConcurrent) {
|
|
139
|
+
// At capacity — cannot assign more
|
|
140
|
+
if (activeCount === 1) {
|
|
141
|
+
const turn = Object.values(activeTurns)[0];
|
|
142
|
+
console.log(chalk.yellow('A turn is already active:'));
|
|
143
|
+
console.log(` Turn: ${turn.turn_id}`);
|
|
144
|
+
console.log(` Role: ${turn.assigned_role}`);
|
|
145
|
+
} else {
|
|
146
|
+
console.log(chalk.yellow(`${activeCount} turns are active (at capacity ${maxConcurrent}):`));
|
|
147
|
+
for (const turn of Object.values(activeTurns)) {
|
|
148
|
+
const statusLabel = turn.status === 'conflicted' ? chalk.red('conflicted') : turn.status;
|
|
149
|
+
console.log(` ${chalk.yellow('●')} ${turn.turn_id} — ${chalk.bold(turn.assigned_role)} (${statusLabel})`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
console.log('');
|
|
153
|
+
console.log(chalk.dim('Use agentxchain step --resume to continue waiting for an active turn.'));
|
|
154
|
+
console.log(chalk.dim('Or run: agentxchain accept-turn / reject-turn'));
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
// else: under capacity, fall through to assignment
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!skipAssignment) {
|
|
161
|
+
if (state.status === 'paused' && (state.pending_phase_transition || state.pending_run_completion)) {
|
|
162
|
+
printRecoverySummary(state, 'This run is paused for approval.');
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (state.status === 'blocked' && activeCount > 0) {
|
|
167
|
+
if (!opts.resume) {
|
|
168
|
+
printRecoverySummary(state, 'This run is blocked on a retained turn.');
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Resolve target for blocked resume
|
|
173
|
+
if (!targetTurn) {
|
|
174
|
+
if (opts.turn) {
|
|
175
|
+
targetTurn = activeTurns[opts.turn];
|
|
176
|
+
if (!targetTurn) {
|
|
177
|
+
console.log(chalk.red(`No active turn found for --turn ${opts.turn}`));
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
} else if (activeCount > 1) {
|
|
181
|
+
console.log(chalk.red('Multiple retained turns exist. Use --turn <id> to specify which to resume.'));
|
|
182
|
+
for (const turn of Object.values(activeTurns)) {
|
|
183
|
+
console.log(` ${chalk.yellow('●')} ${turn.turn_id} — ${chalk.bold(turn.assigned_role)} (${turn.status})`);
|
|
184
|
+
}
|
|
185
|
+
console.log('');
|
|
186
|
+
console.log(chalk.dim('Example: agentxchain step --resume --turn <turn_id>'));
|
|
187
|
+
process.exit(1);
|
|
188
|
+
} else {
|
|
189
|
+
targetTurn = Object.values(activeTurns)[0];
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// If the target turn is conflicted, print recovery paths
|
|
194
|
+
if (targetTurn.status === 'conflicted') {
|
|
195
|
+
console.log(chalk.yellow(`Turn ${targetTurn.turn_id} is conflicted. Resolve the conflict before resuming.`));
|
|
196
|
+
console.log('');
|
|
197
|
+
console.log(chalk.dim('Recovery options:'));
|
|
198
|
+
console.log(` ${chalk.cyan(`agentxchain reject-turn --turn ${targetTurn.turn_id} --reassign`)} — reject and re-dispatch with conflict context`);
|
|
199
|
+
console.log(` ${chalk.cyan(`agentxchain accept-turn --turn ${targetTurn.turn_id} --resolution human_merge`)} — manually merge and re-accept`);
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
console.log(chalk.yellow(`Re-dispatching blocked turn: ${targetTurn.turn_id}`));
|
|
204
|
+
state = clearBlockedState(state);
|
|
205
|
+
safeWriteJson(join(root, STATE_PATH), state);
|
|
206
|
+
skipAssignment = true;
|
|
207
|
+
|
|
208
|
+
const bundleResult = writeDispatchBundle(root, state, config);
|
|
209
|
+
if (!bundleResult.ok) {
|
|
210
|
+
console.log(chalk.red(`Failed to write dispatch bundle: ${bundleResult.error}`));
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
bundleWritten = true;
|
|
214
|
+
printDispatchBundleWarnings(bundleResult);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Handle paused + failed/retrying turn → re-dispatch
|
|
218
|
+
if (!skipAssignment && state.status === 'paused' && activeCount > 0) {
|
|
219
|
+
const pausedTurn = targetTurn || Object.values(activeTurns)[0];
|
|
220
|
+
const turnStatus = pausedTurn?.status;
|
|
221
|
+
if (turnStatus === 'failed' || turnStatus === 'retrying') {
|
|
222
|
+
console.log(chalk.yellow(`Re-dispatching failed turn: ${pausedTurn.turn_id}`));
|
|
223
|
+
state.status = 'active';
|
|
224
|
+
state.blocked_on = null;
|
|
225
|
+
state.blocked_reason = null;
|
|
226
|
+
safeWriteJson(join(root, STATE_PATH), state);
|
|
227
|
+
skipAssignment = true;
|
|
228
|
+
|
|
229
|
+
const bundleResult = writeDispatchBundle(root, state, config);
|
|
230
|
+
if (!bundleResult.ok) {
|
|
231
|
+
console.log(chalk.red(`Failed to write dispatch bundle: ${bundleResult.error}`));
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
bundleWritten = true;
|
|
235
|
+
printDispatchBundleWarnings(bundleResult);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// idle → initialize run
|
|
240
|
+
if (!skipAssignment && state.status === 'idle' && !state.run_id) {
|
|
241
|
+
const initResult = initializeGovernedRun(root, config);
|
|
242
|
+
if (!initResult.ok) {
|
|
243
|
+
console.log(chalk.red(`Failed to initialize run: ${initResult.error}`));
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
state = initResult.state;
|
|
247
|
+
console.log(chalk.green(`Initialized governed run: ${state.run_id}`));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// paused → resume
|
|
251
|
+
if (!skipAssignment && state.status === 'blocked' && state.run_id) {
|
|
252
|
+
state = clearBlockedState(state);
|
|
253
|
+
safeWriteJson(join(root, STATE_PATH), state);
|
|
254
|
+
console.log(chalk.green(`Resumed blocked run: ${state.run_id}`));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!skipAssignment && state.status === 'paused' && state.run_id) {
|
|
258
|
+
state.status = 'active';
|
|
259
|
+
state.blocked_on = null;
|
|
260
|
+
state.blocked_reason = null;
|
|
261
|
+
state.escalation = null;
|
|
262
|
+
safeWriteJson(join(root, STATE_PATH), state);
|
|
263
|
+
console.log(chalk.green(`Resumed governed run: ${state.run_id}`));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Assign the turn
|
|
267
|
+
if (!skipAssignment) {
|
|
268
|
+
const roleId = resolveTargetRole(opts, state, config);
|
|
269
|
+
if (!roleId) {
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const assignResult = assignGovernedTurn(root, config, roleId);
|
|
274
|
+
if (!assignResult.ok) {
|
|
275
|
+
if (assignResult.error_code?.startsWith('hook_') || assignResult.error_code === 'hook_blocked') {
|
|
276
|
+
printAssignmentHookFailure(assignResult, roleId);
|
|
277
|
+
}
|
|
278
|
+
console.log(chalk.red(`Failed to assign turn: ${assignResult.error}`));
|
|
279
|
+
process.exit(1);
|
|
280
|
+
}
|
|
281
|
+
state = assignResult.state;
|
|
282
|
+
|
|
283
|
+
const bundleResult = writeDispatchBundle(root, state, config);
|
|
284
|
+
if (!bundleResult.ok) {
|
|
285
|
+
console.log(chalk.red(`Failed to write dispatch bundle: ${bundleResult.error}`));
|
|
286
|
+
process.exit(1);
|
|
287
|
+
}
|
|
288
|
+
bundleWritten = true;
|
|
289
|
+
printDispatchBundleWarnings(bundleResult);
|
|
290
|
+
}
|
|
291
|
+
} else {
|
|
292
|
+
const bundleResult = writeDispatchBundle(root, state, config);
|
|
293
|
+
if (!bundleResult.ok) {
|
|
294
|
+
console.log(chalk.red(`Failed to write dispatch bundle: ${bundleResult.error}`));
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
bundleWritten = true;
|
|
298
|
+
printDispatchBundleWarnings(bundleResult);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── Phase 2: Dispatch — adapter-specific ──────────────────────────────────
|
|
302
|
+
|
|
303
|
+
const turn = targetTurn || state.current_turn;
|
|
304
|
+
const roleId = turn.assigned_role;
|
|
305
|
+
const role = config.roles?.[roleId];
|
|
306
|
+
const runtimeId = turn.runtime_id;
|
|
307
|
+
const runtime = config.runtimes?.[runtimeId];
|
|
308
|
+
const runtimeType = runtime?.type || role?.runtime_class || 'manual';
|
|
309
|
+
const hooksConfig = config.hooks || {};
|
|
310
|
+
|
|
311
|
+
if (bundleWritten && hooksConfig.after_dispatch?.length > 0) {
|
|
312
|
+
const afterDispatchHooks = runHooks(root, hooksConfig, 'after_dispatch', {
|
|
313
|
+
turn_id: turn.turn_id,
|
|
314
|
+
role_id: roleId,
|
|
315
|
+
bundle_path: getDispatchTurnDir(turn.turn_id),
|
|
316
|
+
bundle_files: ['ASSIGNMENT.json', 'PROMPT.md', 'CONTEXT.md'],
|
|
317
|
+
}, {
|
|
318
|
+
run_id: state.run_id,
|
|
319
|
+
turn_id: turn.turn_id,
|
|
320
|
+
protectedPaths: [
|
|
321
|
+
getDispatchAssignmentPath(turn.turn_id),
|
|
322
|
+
getDispatchPromptPath(turn.turn_id),
|
|
323
|
+
getDispatchContextPath(turn.turn_id),
|
|
324
|
+
getDispatchEffectiveContextPath(turn.turn_id),
|
|
325
|
+
],
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
if (!afterDispatchHooks.ok) {
|
|
329
|
+
const blocked = blockStepForHookIssue(root, turn, {
|
|
330
|
+
hookResults: afterDispatchHooks,
|
|
331
|
+
phase: 'after_dispatch',
|
|
332
|
+
defaultDetail: `after_dispatch hook blocked dispatch for turn ${turn.turn_id}`,
|
|
333
|
+
});
|
|
334
|
+
printLifecycleHookFailure('Dispatch Blocked By Hook', blocked.result, {
|
|
335
|
+
turnId: turn.turn_id,
|
|
336
|
+
roleId,
|
|
337
|
+
action: `Fix or reconfigure the hook, then rerun agentxchain step --resume${turn.turn_id ? ` --turn ${turn.turn_id}` : ''}`,
|
|
338
|
+
});
|
|
339
|
+
process.exit(1);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ── Phase 2b: Finalize dispatch manifest ────────────────────────────────
|
|
344
|
+
if (bundleWritten) {
|
|
345
|
+
const manifestResult = finalizeDispatchManifest(root, turn.turn_id, {
|
|
346
|
+
run_id: state.run_id,
|
|
347
|
+
role: roleId,
|
|
348
|
+
});
|
|
349
|
+
if (!manifestResult.ok) {
|
|
350
|
+
console.log(chalk.red(`Failed to finalize dispatch manifest: ${manifestResult.error}`));
|
|
351
|
+
process.exit(1);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const controller = new AbortController();
|
|
356
|
+
process.on('SIGINT', () => {
|
|
357
|
+
controller.abort();
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
if (runtimeType === 'api_proxy') {
|
|
361
|
+
console.log(chalk.cyan(`Dispatching to API proxy: ${runtime?.provider || '(unknown)'} / ${runtime?.model || '(unknown)'}`));
|
|
362
|
+
console.log(chalk.dim(`Turn: ${turn.turn_id} Role: ${roleId} Phase: ${state.phase}`));
|
|
363
|
+
|
|
364
|
+
const apiResult = await dispatchApiProxy(root, state, config, {
|
|
365
|
+
signal: controller.signal,
|
|
366
|
+
onStatus: (msg) => console.log(chalk.dim(` ${msg}`)),
|
|
367
|
+
verifyManifest: true,
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
if (!apiResult.ok) {
|
|
371
|
+
const blocked = markRunBlocked(root, {
|
|
372
|
+
blockedOn: `dispatch:${apiResult.classified?.error_class || 'api_proxy_failure'}`,
|
|
373
|
+
category: 'dispatch_error',
|
|
374
|
+
recovery: {
|
|
375
|
+
typed_reason: 'dispatch_error',
|
|
376
|
+
owner: 'human',
|
|
377
|
+
recovery_action: 'Resolve the dispatch issue, then run agentxchain step --resume',
|
|
378
|
+
turn_retained: true,
|
|
379
|
+
detail: apiResult.classified?.recovery || apiResult.error,
|
|
380
|
+
},
|
|
381
|
+
turnId: turn.turn_id,
|
|
382
|
+
hooksConfig,
|
|
383
|
+
});
|
|
384
|
+
if (blocked.ok) {
|
|
385
|
+
state = blocked.state;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
console.log('');
|
|
389
|
+
if (apiResult.attempts_made > 1) {
|
|
390
|
+
console.log(chalk.red(`API proxy dispatch failed after ${apiResult.attempts_made} attempts: ${apiResult.error}`));
|
|
391
|
+
} else {
|
|
392
|
+
console.log(chalk.red(`API proxy dispatch failed: ${apiResult.error}`));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (apiResult.classified) {
|
|
396
|
+
const c = apiResult.classified;
|
|
397
|
+
console.log(chalk.yellow(` Error class: ${c.error_class}${c.retryable ? ' (retryable)' : ''}`));
|
|
398
|
+
console.log(chalk.yellow(` Recovery: ${c.recovery}`));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (apiResult.preflight_artifacts) {
|
|
402
|
+
console.log(chalk.dim(` Token budget report: ${apiResult.preflight_artifacts.token_budget}`));
|
|
403
|
+
console.log(chalk.dim(` Effective context: ${apiResult.preflight_artifacts.effective_context}`));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (apiResult.retry_trace_path) {
|
|
407
|
+
console.log(chalk.dim(` Retry trace: ${apiResult.retry_trace_path}`));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
console.log(chalk.dim('The turn remains assigned. You can:'));
|
|
411
|
+
console.log(chalk.dim(' - Fix the issue and retry: agentxchain step --resume'));
|
|
412
|
+
console.log(chalk.dim(' - Complete manually: edit .agentxchain/staging/turn-result.json'));
|
|
413
|
+
console.log(chalk.dim(' - Reject: agentxchain reject-turn --reason "api proxy failed"'));
|
|
414
|
+
process.exit(1);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (apiResult.attempts_made > 1) {
|
|
418
|
+
console.log(chalk.green(`API proxy completed after ${apiResult.attempts_made} attempts. Staged result detected.`));
|
|
419
|
+
} else {
|
|
420
|
+
console.log(chalk.green('API proxy completed. Staged result detected.'));
|
|
421
|
+
}
|
|
422
|
+
if (apiResult.usage) {
|
|
423
|
+
console.log(chalk.dim(` Tokens: ${apiResult.usage.input_tokens || 0} in / ${apiResult.usage.output_tokens || 0} out`));
|
|
424
|
+
}
|
|
425
|
+
console.log('');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ── Phase 3: Wait for turn completion ─────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
if (runtimeType === 'api_proxy') {
|
|
431
|
+
// api_proxy is synchronous — result already staged in Phase 2
|
|
432
|
+
} else if (runtimeType === 'local_cli') {
|
|
433
|
+
// ── Local CLI adapter: spawn subprocess ──
|
|
434
|
+
const transport = runtime ? resolvePromptTransport(runtime) : 'dispatch_bundle_only';
|
|
435
|
+
console.log(chalk.cyan(`Dispatching to local CLI: ${runtime?.command || '(default)'}`));
|
|
436
|
+
console.log(chalk.dim(`Turn: ${turn.turn_id} Role: ${roleId} Phase: ${state.phase} Transport: ${transport}`));
|
|
437
|
+
if (transport === 'dispatch_bundle_only') {
|
|
438
|
+
console.log(chalk.yellow('Warning: prompt_transport is "dispatch_bundle_only" — the prompt will NOT be delivered to the subprocess automatically.'));
|
|
439
|
+
console.log(chalk.yellow(`The subprocess must independently read from .agentxchain/dispatch/turns/${turn.turn_id}/PROMPT.md`));
|
|
440
|
+
console.log(chalk.dim('To enable automatic prompt delivery, set prompt_transport to "argv" or "stdin" in the runtime config.'));
|
|
441
|
+
}
|
|
442
|
+
console.log(chalk.dim('Press Ctrl+C to abort and leave the turn assigned.'));
|
|
443
|
+
console.log('');
|
|
444
|
+
|
|
445
|
+
const cliResult = await dispatchLocalCli(root, state, config, {
|
|
446
|
+
signal: controller.signal,
|
|
447
|
+
onStdout: opts.verbose ? (text) => process.stdout.write(chalk.dim(text)) : undefined,
|
|
448
|
+
onStderr: opts.verbose ? (text) => process.stderr.write(chalk.yellow(text)) : undefined,
|
|
449
|
+
verifyManifest: true,
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// Save logs for auditability
|
|
453
|
+
if (cliResult.logs?.length) {
|
|
454
|
+
saveDispatchLogs(root, turn.turn_id, cliResult.logs);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (cliResult.aborted) {
|
|
458
|
+
console.log('');
|
|
459
|
+
console.log(chalk.yellow('Aborted. Turn remains assigned.'));
|
|
460
|
+
console.log(chalk.dim('Resume later with: agentxchain step --resume'));
|
|
461
|
+
console.log(chalk.dim('Or accept/reject manually: agentxchain accept-turn / reject-turn'));
|
|
462
|
+
process.exit(0);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (cliResult.timedOut) {
|
|
466
|
+
const blocked = markRunBlocked(root, {
|
|
467
|
+
blockedOn: 'dispatch:timeout',
|
|
468
|
+
category: 'dispatch_error',
|
|
469
|
+
recovery: {
|
|
470
|
+
typed_reason: 'dispatch_error',
|
|
471
|
+
owner: 'human',
|
|
472
|
+
recovery_action: 'Resolve the dispatch issue, then run agentxchain step --resume',
|
|
473
|
+
turn_retained: true,
|
|
474
|
+
detail: 'Subprocess timed out before staging a turn result.',
|
|
475
|
+
},
|
|
476
|
+
turnId: turn.turn_id,
|
|
477
|
+
hooksConfig,
|
|
478
|
+
});
|
|
479
|
+
if (blocked.ok) {
|
|
480
|
+
state = blocked.state;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
console.log('');
|
|
484
|
+
console.log(chalk.red('Turn timed out. Subprocess was terminated.'));
|
|
485
|
+
console.log(chalk.dim('The turn remains assigned. You can:'));
|
|
486
|
+
console.log(chalk.dim(' - Re-dispatch: agentxchain step --resume'));
|
|
487
|
+
console.log(chalk.dim(' - Reject and retry: agentxchain reject-turn --reason "timeout"'));
|
|
488
|
+
process.exit(1);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (!cliResult.ok) {
|
|
492
|
+
const blocked = markRunBlocked(root, {
|
|
493
|
+
blockedOn: `dispatch:${cliResult.exitCode != null ? `exit-${cliResult.exitCode}` : 'subprocess_failed'}`,
|
|
494
|
+
category: 'dispatch_error',
|
|
495
|
+
recovery: {
|
|
496
|
+
typed_reason: 'dispatch_error',
|
|
497
|
+
owner: 'human',
|
|
498
|
+
recovery_action: 'Resolve the dispatch issue, then run agentxchain step --resume',
|
|
499
|
+
turn_retained: true,
|
|
500
|
+
detail: cliResult.error,
|
|
501
|
+
},
|
|
502
|
+
turnId: turn.turn_id,
|
|
503
|
+
hooksConfig,
|
|
504
|
+
});
|
|
505
|
+
if (blocked.ok) {
|
|
506
|
+
state = blocked.state;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
console.log('');
|
|
510
|
+
console.log(chalk.red(`Subprocess failed: ${cliResult.error}`));
|
|
511
|
+
if (cliResult.exitCode != null) {
|
|
512
|
+
console.log(chalk.dim(`Exit code: ${cliResult.exitCode}`));
|
|
513
|
+
}
|
|
514
|
+
console.log(chalk.dim('The turn remains assigned. You can:'));
|
|
515
|
+
console.log(chalk.dim(' - Fix and retry: agentxchain step --resume'));
|
|
516
|
+
console.log(chalk.dim(' - Reject: agentxchain reject-turn --reason "subprocess failed"'));
|
|
517
|
+
process.exit(1);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
console.log(chalk.green('Subprocess completed. Staged result detected.'));
|
|
521
|
+
console.log('');
|
|
522
|
+
} else {
|
|
523
|
+
// ── Manual adapter: poll for staged result ──
|
|
524
|
+
console.log(printManualDispatchInstructions(state, config));
|
|
525
|
+
console.log(chalk.dim('Waiting for turn result...'));
|
|
526
|
+
console.log(chalk.dim(`Polling: .agentxchain/staging/${turn.turn_id}/turn-result.json (every ${opts.poll || 2}s)`));
|
|
527
|
+
console.log(chalk.dim('Press Ctrl+C to abort and leave the turn assigned.'));
|
|
528
|
+
console.log('');
|
|
529
|
+
|
|
530
|
+
const pollIntervalMs = (parseInt(opts.poll, 10) || 2) * 1000;
|
|
531
|
+
const timeoutMs = turn.deadline_at
|
|
532
|
+
? Math.max(0, new Date(turn.deadline_at).getTime() - Date.now())
|
|
533
|
+
: 1200000; // 20 minutes default
|
|
534
|
+
|
|
535
|
+
const waitResult = await waitForStagedResult(root, {
|
|
536
|
+
pollIntervalMs,
|
|
537
|
+
timeoutMs,
|
|
538
|
+
signal: controller.signal,
|
|
539
|
+
turnId: turn.turn_id,
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
if (waitResult.aborted) {
|
|
543
|
+
console.log('');
|
|
544
|
+
console.log(chalk.yellow('Aborted. Turn remains assigned.'));
|
|
545
|
+
console.log(chalk.dim('Resume later with: agentxchain step --resume'));
|
|
546
|
+
console.log(chalk.dim('Or accept/reject manually: agentxchain accept-turn / reject-turn'));
|
|
547
|
+
process.exit(0);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (waitResult.timedOut) {
|
|
551
|
+
console.log('');
|
|
552
|
+
console.log(chalk.red('Turn timed out. No staged result found.'));
|
|
553
|
+
console.log(chalk.dim('The turn remains assigned. You can:'));
|
|
554
|
+
console.log(chalk.dim(' - Continue working and run: agentxchain step --resume'));
|
|
555
|
+
console.log(chalk.dim(' - Reject and retry: agentxchain reject-turn --reason "timeout"'));
|
|
556
|
+
process.exit(1);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
console.log(chalk.green('Staged result detected.'));
|
|
560
|
+
console.log('');
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ── Phase 4: Validate and accept/reject ───────────────────────────────────
|
|
564
|
+
|
|
565
|
+
// Reload state (it may have been modified by the agent in local_cli mode)
|
|
566
|
+
state = loadProjectState(root, config);
|
|
567
|
+
|
|
568
|
+
// Resolve staging path: prefer turn-scoped, fall back to flat
|
|
569
|
+
const turnStaging = getTurnStagingResultPath(turn.turn_id);
|
|
570
|
+
const resolvedStaging = existsSync(join(root, turnStaging)) ? turnStaging : undefined;
|
|
571
|
+
const stagedTurn = loadHookStagedTurn(root, resolvedStaging || getTurnStagingResultPath(turn.turn_id));
|
|
572
|
+
|
|
573
|
+
if (hooksConfig.before_validation?.length > 0) {
|
|
574
|
+
const beforeValidationHooks = runHooks(root, hooksConfig, 'before_validation', {
|
|
575
|
+
turn_id: turn.turn_id,
|
|
576
|
+
role_id: roleId,
|
|
577
|
+
staging_path: resolvedStaging || getTurnStagingResultPath(turn.turn_id),
|
|
578
|
+
turn_result: stagedTurn.turnResult ?? null,
|
|
579
|
+
...(stagedTurn.parse_error ? { parse_error: stagedTurn.parse_error } : {}),
|
|
580
|
+
...(stagedTurn.read_error ? { read_error: stagedTurn.read_error } : {}),
|
|
581
|
+
}, {
|
|
582
|
+
run_id: state.run_id,
|
|
583
|
+
turn_id: turn.turn_id,
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
if (!beforeValidationHooks.ok) {
|
|
587
|
+
const blocked = blockStepForHookIssue(root, turn, {
|
|
588
|
+
hookResults: beforeValidationHooks,
|
|
589
|
+
phase: 'before_validation',
|
|
590
|
+
defaultDetail: `before_validation hook blocked validation for turn ${turn.turn_id}`,
|
|
591
|
+
});
|
|
592
|
+
printLifecycleHookFailure('Validation Blocked By Hook', blocked.result, {
|
|
593
|
+
turnId: turn.turn_id,
|
|
594
|
+
roleId,
|
|
595
|
+
action: `Fix or reconfigure the hook, then rerun agentxchain step --resume${turn.turn_id ? ` --turn ${turn.turn_id}` : ''}`,
|
|
596
|
+
});
|
|
597
|
+
process.exit(1);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const validation = validateStagedTurnResult(root, state, config, resolvedStaging ? { stagingPath: resolvedStaging } : {});
|
|
602
|
+
|
|
603
|
+
if (hooksConfig.after_validation?.length > 0) {
|
|
604
|
+
const afterValidationHooks = runHooks(root, hooksConfig, 'after_validation', {
|
|
605
|
+
turn_id: turn.turn_id,
|
|
606
|
+
role_id: roleId,
|
|
607
|
+
validation_ok: validation.ok,
|
|
608
|
+
validation_stage: validation.stage,
|
|
609
|
+
errors: validation.errors,
|
|
610
|
+
warnings: validation.warnings,
|
|
611
|
+
turn_result: validation.turnResult ?? stagedTurn.turnResult ?? null,
|
|
612
|
+
}, {
|
|
613
|
+
run_id: state.run_id,
|
|
614
|
+
turn_id: turn.turn_id,
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
if (!afterValidationHooks.ok) {
|
|
618
|
+
const blocked = blockStepForHookIssue(root, turn, {
|
|
619
|
+
hookResults: afterValidationHooks,
|
|
620
|
+
phase: 'after_validation',
|
|
621
|
+
defaultDetail: `after_validation hook blocked acceptance for turn ${turn.turn_id}`,
|
|
622
|
+
});
|
|
623
|
+
printLifecycleHookFailure('Validation Blocked By Hook', blocked.result, {
|
|
624
|
+
turnId: turn.turn_id,
|
|
625
|
+
roleId,
|
|
626
|
+
action: `Fix or reconfigure the hook, then rerun agentxchain step --resume${turn.turn_id ? ` --turn ${turn.turn_id}` : ''}`,
|
|
627
|
+
});
|
|
628
|
+
process.exit(1);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (validation.ok) {
|
|
633
|
+
// Accept the turn
|
|
634
|
+
const acceptResult = acceptGovernedTurn(root, config, { turnId: turn.turn_id });
|
|
635
|
+
if (!acceptResult.ok) {
|
|
636
|
+
if (acceptResult.accepted && acceptResult.error_code?.startsWith('hook_')) {
|
|
637
|
+
printAcceptedHookFailure(acceptResult);
|
|
638
|
+
} else {
|
|
639
|
+
console.log(chalk.red(`Acceptance failed: ${acceptResult.error}`));
|
|
640
|
+
}
|
|
641
|
+
process.exit(1);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
printAcceptSummary(acceptResult);
|
|
645
|
+
} else {
|
|
646
|
+
// Reject and potentially retry
|
|
647
|
+
console.log(chalk.yellow('Validation failed:'));
|
|
648
|
+
console.log(` Stage: ${validation.stage}`);
|
|
649
|
+
for (const err of validation.errors) {
|
|
650
|
+
console.log(` Error: ${err}`);
|
|
651
|
+
}
|
|
652
|
+
console.log('');
|
|
653
|
+
|
|
654
|
+
if (opts.autoReject) {
|
|
655
|
+
const rejectResult = rejectGovernedTurn(root, config, {
|
|
656
|
+
errors: validation.errors,
|
|
657
|
+
failed_stage: validation.stage,
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
if (!rejectResult.ok) {
|
|
661
|
+
console.log(chalk.red(`Rejection failed: ${rejectResult.error}`));
|
|
662
|
+
process.exit(1);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (rejectResult.escalated) {
|
|
666
|
+
printEscalationSummary(rejectResult);
|
|
667
|
+
} else {
|
|
668
|
+
console.log(chalk.yellow('Turn rejected for retry.'));
|
|
669
|
+
console.log(` Attempt: ${rejectResult.state?.current_turn?.attempt}`);
|
|
670
|
+
console.log('');
|
|
671
|
+
console.log(chalk.dim('The retry dispatch bundle has been written.'));
|
|
672
|
+
console.log(chalk.dim('Run: agentxchain step --resume to continue.'));
|
|
673
|
+
}
|
|
674
|
+
} else {
|
|
675
|
+
console.log(chalk.dim('The staged result failed validation.'));
|
|
676
|
+
console.log(chalk.dim('Review the errors above, then:'));
|
|
677
|
+
console.log(chalk.dim(' - Fix the staged result and run: agentxchain accept-turn'));
|
|
678
|
+
console.log(chalk.dim(' - Reject and retry: agentxchain reject-turn'));
|
|
679
|
+
console.log(chalk.dim(' - Auto-reject on failure: agentxchain step --auto-reject'));
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
685
|
+
|
|
686
|
+
function loadHookStagedTurn(root, stagingRel) {
|
|
687
|
+
const stagingAbs = join(root, stagingRel);
|
|
688
|
+
if (!existsSync(stagingAbs)) {
|
|
689
|
+
return { turnResult: null };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
let raw;
|
|
693
|
+
try {
|
|
694
|
+
raw = readFileSync(stagingAbs, 'utf8');
|
|
695
|
+
} catch (err) {
|
|
696
|
+
return { turnResult: null, read_error: err.message };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
try {
|
|
700
|
+
return { turnResult: JSON.parse(raw) };
|
|
701
|
+
} catch (err) {
|
|
702
|
+
return { turnResult: null, parse_error: err.message };
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function blockStepForHookIssue(root, turn, { hookResults, phase, defaultDetail }) {
|
|
707
|
+
const hookName = hookResults.blocker?.hook_name
|
|
708
|
+
|| hookResults.results?.find((entry) => entry.hook_name)?.hook_name
|
|
709
|
+
|| 'unknown';
|
|
710
|
+
const detail = hookResults.blocker?.message
|
|
711
|
+
|| hookResults.tamper?.message
|
|
712
|
+
|| defaultDetail;
|
|
713
|
+
const errorCode = hookResults.tamper?.error_code || 'hook_blocked';
|
|
714
|
+
const blocked = markRunBlocked(root, {
|
|
715
|
+
blockedOn: `hook:${phase}:${hookName}`,
|
|
716
|
+
category: phase === 'after_dispatch' ? 'dispatch_error' : 'validation_error',
|
|
717
|
+
recovery: {
|
|
718
|
+
typed_reason: hookResults.tamper ? 'hook_tamper' : 'hook_block',
|
|
719
|
+
owner: 'human',
|
|
720
|
+
recovery_action: 'Fix or reconfigure the hook, then run agentxchain step --resume',
|
|
721
|
+
turn_retained: true,
|
|
722
|
+
detail,
|
|
723
|
+
},
|
|
724
|
+
turnId: turn.turn_id,
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
return {
|
|
728
|
+
result: {
|
|
729
|
+
ok: false,
|
|
730
|
+
error: detail,
|
|
731
|
+
error_code: errorCode,
|
|
732
|
+
state: blocked.ok ? blocked.state : null,
|
|
733
|
+
hookResults,
|
|
734
|
+
},
|
|
735
|
+
hookName,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function printLifecycleHookFailure(title, result, { turnId, roleId, action }) {
|
|
740
|
+
const recovery = deriveRecoveryDescriptor(result.state);
|
|
741
|
+
const hookName = result.hookResults?.blocker?.hook_name
|
|
742
|
+
|| result.hookResults?.results?.find((entry) => entry.hook_name)?.hook_name
|
|
743
|
+
|| '(unknown)';
|
|
744
|
+
|
|
745
|
+
console.log('');
|
|
746
|
+
console.log(chalk.yellow(` ${title}`));
|
|
747
|
+
console.log(chalk.dim(' ' + '-'.repeat(44)));
|
|
748
|
+
console.log('');
|
|
749
|
+
console.log(` ${chalk.dim('Turn:')} ${turnId || '(unknown)'}`);
|
|
750
|
+
console.log(` ${chalk.dim('Role:')} ${roleId || '(unknown)'}`);
|
|
751
|
+
console.log(` ${chalk.dim('Hook:')} ${hookName}`);
|
|
752
|
+
console.log(` ${chalk.dim('Error:')} ${result.error}`);
|
|
753
|
+
if (recovery) {
|
|
754
|
+
console.log(` ${chalk.dim('Reason:')} ${recovery.typed_reason}`);
|
|
755
|
+
console.log(` ${chalk.dim('Owner:')} ${recovery.owner}`);
|
|
756
|
+
console.log(` ${chalk.dim('Action:')} ${recovery.recovery_action}`);
|
|
757
|
+
if (recovery.detail) {
|
|
758
|
+
console.log(` ${chalk.dim('Detail:')} ${recovery.detail}`);
|
|
759
|
+
}
|
|
760
|
+
} else if (action) {
|
|
761
|
+
console.log(` ${chalk.dim('Action:')} ${action}`);
|
|
762
|
+
}
|
|
763
|
+
console.log('');
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function resolveTargetRole(opts, state, config) {
|
|
767
|
+
const phase = state.phase;
|
|
768
|
+
const routing = config.routing?.[phase];
|
|
769
|
+
|
|
770
|
+
if (opts.role) {
|
|
771
|
+
if (!config.roles?.[opts.role]) {
|
|
772
|
+
console.log(chalk.red(`Unknown role: "${opts.role}"`));
|
|
773
|
+
console.log(chalk.dim(`Available roles: ${Object.keys(config.roles || {}).join(', ')}`));
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
if (routing?.allowed_next_roles && !routing.allowed_next_roles.includes(opts.role) && opts.role !== 'human') {
|
|
777
|
+
console.log(chalk.yellow(`Warning: role "${opts.role}" is not in allowed_next_roles for phase "${phase}".`));
|
|
778
|
+
}
|
|
779
|
+
return opts.role;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Use stored next_recommended_role if routing-legal
|
|
783
|
+
if (state.next_recommended_role) {
|
|
784
|
+
const recommended = state.next_recommended_role;
|
|
785
|
+
if (config.roles?.[recommended]) {
|
|
786
|
+
const isLegal = !routing?.allowed_next_roles || routing.allowed_next_roles.includes(recommended);
|
|
787
|
+
if (isLegal) {
|
|
788
|
+
console.log(chalk.dim(`Using recommended role: ${recommended} (from previous turn)`));
|
|
789
|
+
return recommended;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (routing?.entry_role) {
|
|
795
|
+
return routing.entry_role;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const roles = Object.keys(config.roles || {});
|
|
799
|
+
if (roles.length > 0) {
|
|
800
|
+
console.log(chalk.yellow(`No entry_role for phase "${phase}". Defaulting to "${roles[0]}".`));
|
|
801
|
+
return roles[0];
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
console.log(chalk.red('No roles defined in config.'));
|
|
805
|
+
return null;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function clearBlockedState(state) {
|
|
809
|
+
return {
|
|
810
|
+
...state,
|
|
811
|
+
status: 'active',
|
|
812
|
+
blocked_on: null,
|
|
813
|
+
blocked_reason: null,
|
|
814
|
+
escalation: null,
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function printRecoverySummary(state, heading) {
|
|
819
|
+
const recovery = deriveRecoveryDescriptor(state);
|
|
820
|
+
console.log(chalk.yellow(heading));
|
|
821
|
+
if (!recovery) {
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
console.log(` ${chalk.dim('Reason:')} ${recovery.typed_reason}`);
|
|
825
|
+
console.log(` ${chalk.dim('Action:')} ${recovery.recovery_action}`);
|
|
826
|
+
if (recovery.detail) {
|
|
827
|
+
console.log(` ${chalk.dim('Detail:')} ${recovery.detail}`);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function printDispatchBundleWarnings(bundleResult) {
|
|
832
|
+
for (const warning of bundleResult.warnings || []) {
|
|
833
|
+
console.log(chalk.yellow(`Dispatch bundle warning: ${warning}`));
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function printAssignmentHookFailure(result, roleId) {
|
|
838
|
+
const recovery = deriveRecoveryDescriptor(result.state);
|
|
839
|
+
const hookName = result.hookResults?.blocker?.hook_name
|
|
840
|
+
|| result.hookResults?.results?.find((entry) => entry.hook_name)?.hook_name
|
|
841
|
+
|| '(unknown)';
|
|
842
|
+
|
|
843
|
+
console.log('');
|
|
844
|
+
console.log(chalk.yellow(' Turn Assignment Blocked By Hook'));
|
|
845
|
+
console.log(chalk.dim(' ' + '-'.repeat(44)));
|
|
846
|
+
console.log('');
|
|
847
|
+
console.log(` ${chalk.dim('Role:')} ${roleId || '(unknown)'}`);
|
|
848
|
+
console.log(` ${chalk.dim('Phase:')} ${result.state?.phase || '(unknown)'}`);
|
|
849
|
+
console.log(` ${chalk.dim('Hook:')} ${hookName}`);
|
|
850
|
+
console.log(` ${chalk.dim('Error:')} ${result.error}`);
|
|
851
|
+
if (recovery) {
|
|
852
|
+
console.log(` ${chalk.dim('Reason:')} ${recovery.typed_reason}`);
|
|
853
|
+
console.log(` ${chalk.dim('Owner:')} ${recovery.owner}`);
|
|
854
|
+
console.log(` ${chalk.dim('Action:')} ${recovery.recovery_action}`);
|
|
855
|
+
if (recovery.detail) {
|
|
856
|
+
console.log(` ${chalk.dim('Detail:')} ${recovery.detail}`);
|
|
857
|
+
}
|
|
858
|
+
} else {
|
|
859
|
+
console.log(` ${chalk.dim('Action:')} Fix or reconfigure hook "${hookName}", then rerun agentxchain step --role ${roleId}`);
|
|
860
|
+
}
|
|
861
|
+
console.log('');
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function printAcceptedHookFailure(result) {
|
|
865
|
+
const recovery = deriveRecoveryDescriptor(result.state);
|
|
866
|
+
const hookName = result.hookResults?.results?.find((entry) => entry.hook_name)?.hook_name || '(unknown)';
|
|
867
|
+
|
|
868
|
+
console.log('');
|
|
869
|
+
console.log(chalk.yellow(' Turn Accepted, Hook Failure Detected'));
|
|
870
|
+
console.log(chalk.dim(' ' + '-'.repeat(44)));
|
|
871
|
+
console.log('');
|
|
872
|
+
console.log(` ${chalk.dim('Turn:')} ${result.accepted?.turn_id || '(unknown)'}`);
|
|
873
|
+
console.log(` ${chalk.dim('Role:')} ${result.accepted?.role || '(unknown)'}`);
|
|
874
|
+
console.log(` ${chalk.dim('Status:')} ${result.accepted?.status || '(unknown)'}`);
|
|
875
|
+
console.log(` ${chalk.dim('Hook:')} ${hookName}`);
|
|
876
|
+
console.log(` ${chalk.dim('Error:')} ${result.error}`);
|
|
877
|
+
if (recovery) {
|
|
878
|
+
console.log(` ${chalk.dim('Reason:')} ${recovery.typed_reason}`);
|
|
879
|
+
console.log(` ${chalk.dim('Owner:')} ${recovery.owner}`);
|
|
880
|
+
console.log(` ${chalk.dim('Action:')} ${recovery.recovery_action}`);
|
|
881
|
+
if (recovery.detail) {
|
|
882
|
+
console.log(` ${chalk.dim('Detail:')} ${recovery.detail}`);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
console.log('');
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function printAcceptSummary(result) {
|
|
889
|
+
const accepted = result.accepted;
|
|
890
|
+
const recovery = deriveRecoveryDescriptor(result.state);
|
|
891
|
+
console.log('');
|
|
892
|
+
console.log(chalk.green(' Turn Accepted'));
|
|
893
|
+
console.log(chalk.dim(' ' + '-'.repeat(44)));
|
|
894
|
+
console.log('');
|
|
895
|
+
console.log(` ${chalk.dim('Turn:')} ${accepted?.turn_id || '(unknown)'}`);
|
|
896
|
+
console.log(` ${chalk.dim('Role:')} ${accepted?.role || '(unknown)'}`);
|
|
897
|
+
console.log(` ${chalk.dim('Status:')} ${accepted?.status || 'completed'}`);
|
|
898
|
+
console.log(` ${chalk.dim('Summary:')} ${accepted?.summary || '(none)'}`);
|
|
899
|
+
if (accepted?.proposed_next_role) {
|
|
900
|
+
console.log(` ${chalk.dim('Proposed:')} ${accepted.proposed_next_role}`);
|
|
901
|
+
}
|
|
902
|
+
if (accepted?.cost?.usd != null) {
|
|
903
|
+
console.log(` ${chalk.dim('Cost:')} $${(accepted.cost.usd || 0).toFixed(2)}`);
|
|
904
|
+
}
|
|
905
|
+
console.log('');
|
|
906
|
+
|
|
907
|
+
if (result.state?.status === 'completed') {
|
|
908
|
+
console.log(chalk.green.bold(' \u2713 Run completed'));
|
|
909
|
+
if (result.state.completed_at) {
|
|
910
|
+
console.log(chalk.dim(` Completed at: ${result.state.completed_at}`));
|
|
911
|
+
}
|
|
912
|
+
} else if (recovery) {
|
|
913
|
+
console.log(` ${chalk.dim('Reason:')} ${recovery.typed_reason}`);
|
|
914
|
+
console.log(` ${chalk.dim('Owner:')} ${recovery.owner}`);
|
|
915
|
+
console.log(` ${chalk.dim('Action:')} ${recovery.recovery_action}`);
|
|
916
|
+
console.log(` ${chalk.dim('Turn:')} ${recovery.turn_retained ? 'retained' : 'cleared'}`);
|
|
917
|
+
if (recovery.detail) {
|
|
918
|
+
console.log(` ${chalk.dim('Detail:')} ${recovery.detail}`);
|
|
919
|
+
}
|
|
920
|
+
} else if (accepted?.proposed_next_role && accepted.proposed_next_role !== 'human') {
|
|
921
|
+
console.log(chalk.dim(` Next: agentxchain step --role ${accepted.proposed_next_role}`));
|
|
922
|
+
} else {
|
|
923
|
+
console.log(chalk.dim(' Next: review state, then run agentxchain step when ready.'));
|
|
924
|
+
}
|
|
925
|
+
console.log('');
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function printEscalationSummary(result) {
|
|
929
|
+
const recovery = deriveRecoveryDescriptor(result.state);
|
|
930
|
+
console.log('');
|
|
931
|
+
console.log(chalk.red(' Turn Escalated'));
|
|
932
|
+
console.log(chalk.dim(' ' + '-'.repeat(44)));
|
|
933
|
+
console.log('');
|
|
934
|
+
if (recovery) {
|
|
935
|
+
console.log(` ${chalk.dim('Reason:')} ${recovery.typed_reason}`);
|
|
936
|
+
console.log(` ${chalk.dim('Owner:')} ${recovery.owner}`);
|
|
937
|
+
console.log(` ${chalk.dim('Action:')} ${recovery.recovery_action}`);
|
|
938
|
+
console.log(` ${chalk.dim('Turn:')} ${recovery.turn_retained ? 'retained' : 'cleared'}`);
|
|
939
|
+
if (recovery.detail) {
|
|
940
|
+
console.log(` ${chalk.dim('Detail:')} ${recovery.detail}`);
|
|
941
|
+
}
|
|
942
|
+
} else {
|
|
943
|
+
console.log(` ${chalk.dim('Blocked on:')} ${result.state?.blocked_on}`);
|
|
944
|
+
console.log(chalk.dim(' Resolve the escalation, then run agentxchain step to re-dispatch.'));
|
|
945
|
+
}
|
|
946
|
+
console.log('');
|
|
947
|
+
}
|