agentxchain 0.8.8 → 2.2.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/README.md +136 -136
- 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 +858 -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,204 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { loadProjectContext, loadProjectState } from '../lib/config.js';
|
|
5
|
+
import { getActiveTurns, rejectGovernedTurn } from '../lib/governed-state.js';
|
|
6
|
+
import { validateStagedTurnResult } from '../lib/turn-result-validator.js';
|
|
7
|
+
import { writeDispatchBundle } from '../lib/dispatch-bundle.js';
|
|
8
|
+
import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
|
|
9
|
+
import { getDispatchTurnDir, getTurnStagingResultPath } from '../lib/turn-paths.js';
|
|
10
|
+
|
|
11
|
+
export async function rejectTurnCommand(opts) {
|
|
12
|
+
const context = loadProjectContext();
|
|
13
|
+
if (!context) {
|
|
14
|
+
console.log(chalk.red('No agentxchain.json found. Run `agentxchain init` first.'));
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const { root, config } = context;
|
|
19
|
+
|
|
20
|
+
if (config.protocol_mode !== 'governed') {
|
|
21
|
+
console.log(chalk.red('The reject-turn command is only available for governed projects.'));
|
|
22
|
+
console.log(chalk.dim('Legacy projects use: agentxchain claim / release'));
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const state = loadProjectState(root, config);
|
|
27
|
+
if (!state) {
|
|
28
|
+
console.log(chalk.red('No governed state.json found. Run `agentxchain init --governed` first.'));
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const validation = buildRejectionValidation(root, state, config, opts);
|
|
33
|
+
if (!validation.ok) {
|
|
34
|
+
console.log(chalk.red(validation.error));
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const result = rejectGovernedTurn(root, config, validation.validationResult, {
|
|
39
|
+
turnId: validation.turn.turn_id,
|
|
40
|
+
reason: opts.reason,
|
|
41
|
+
reassign: Boolean(opts.reassign),
|
|
42
|
+
});
|
|
43
|
+
if (!result.ok) {
|
|
44
|
+
console.log(chalk.red(`Failed to reject turn: ${result.error}`));
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!result.escalated) {
|
|
49
|
+
const bundleResult = writeDispatchBundle(root, result.state, config, { turnId: validation.turn.turn_id });
|
|
50
|
+
if (!bundleResult.ok) {
|
|
51
|
+
console.log(chalk.red(`Turn rejected but dispatch bundle rewrite failed: ${bundleResult.error}`));
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
printDispatchBundleWarnings(bundleResult);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const turn = result.turn || result.state?.active_turns?.[validation.turn.turn_id] || result.state?.current_turn;
|
|
58
|
+
|
|
59
|
+
console.log('');
|
|
60
|
+
if (result.escalated) {
|
|
61
|
+
console.log(chalk.yellow(' Turn Rejected And Escalated'));
|
|
62
|
+
} else {
|
|
63
|
+
console.log(chalk.yellow(' Turn Rejected For Retry'));
|
|
64
|
+
}
|
|
65
|
+
console.log(chalk.dim(' ' + '─'.repeat(44)));
|
|
66
|
+
console.log('');
|
|
67
|
+
console.log(` ${chalk.dim('Turn:')} ${turn?.turn_id || '(unknown)'}`);
|
|
68
|
+
console.log(` ${chalk.dim('Role:')} ${turn?.assigned_role || '(unknown)'}`);
|
|
69
|
+
console.log(` ${chalk.dim('Failed stage:')} ${validation.validationResult.failed_stage || 'unknown'}`);
|
|
70
|
+
if (validation.validationResult.errors?.length && !opts.reassign) {
|
|
71
|
+
console.log(` ${chalk.dim('Reason:')} ${validation.validationResult.errors[0]}`);
|
|
72
|
+
}
|
|
73
|
+
if (opts.reason) {
|
|
74
|
+
console.log(` ${chalk.dim('Operator note:')} ${opts.reason}`);
|
|
75
|
+
}
|
|
76
|
+
console.log('');
|
|
77
|
+
|
|
78
|
+
if (result.escalated) {
|
|
79
|
+
const recovery = deriveRecoveryDescriptor(result.state);
|
|
80
|
+
if (recovery) {
|
|
81
|
+
console.log(` ${chalk.dim('Reason:')} ${recovery.typed_reason}`);
|
|
82
|
+
console.log(` ${chalk.dim('Owner:')} ${recovery.owner}`);
|
|
83
|
+
console.log(` ${chalk.dim('Action:')} ${recovery.recovery_action}`);
|
|
84
|
+
console.log(` ${chalk.dim('Turn:')} ${recovery.turn_retained ? 'retained' : 'cleared'}`);
|
|
85
|
+
if (recovery.detail) {
|
|
86
|
+
console.log(` ${chalk.dim('Detail:')} ${recovery.detail}`);
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
console.log(` ${chalk.dim('Blocked on:')} ${result.state.blocked_on}`);
|
|
90
|
+
console.log(chalk.dim(' Resolve the escalation, then run agentxchain step to re-dispatch the failed turn.'));
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
console.log(` ${chalk.dim('Attempt:')} ${turn?.attempt || '(unknown)'}`);
|
|
94
|
+
console.log(` ${chalk.dim('Dispatch:')} ${getDispatchTurnDir(turn?.turn_id || validation.turn.turn_id)}/`);
|
|
95
|
+
if (opts.reassign) {
|
|
96
|
+
console.log(chalk.dim(' The turn was rejected for conflict and immediately re-dispatched with conflict context.'));
|
|
97
|
+
} else {
|
|
98
|
+
console.log(chalk.dim(' The retry bundle has been rewritten for the same assigned turn.'));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
console.log('');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildRejectionValidation(root, state, config, opts) {
|
|
105
|
+
const resolution = resolveTargetTurn(state, opts.turn);
|
|
106
|
+
if (!resolution.ok) {
|
|
107
|
+
return resolution;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (opts.reassign && !resolution.turn.conflict_state) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
error: '--reassign is only valid for turns with persisted conflict_state.',
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (resolution.turn.conflict_state) {
|
|
118
|
+
return {
|
|
119
|
+
ok: true,
|
|
120
|
+
turn: resolution.turn,
|
|
121
|
+
validationResult: {
|
|
122
|
+
errors: resolution.turn.conflict_state.conflict_error?.conflicting_files?.length
|
|
123
|
+
? [`File conflict detected: ${resolution.turn.conflict_state.conflict_error.conflicting_files.join(', ')}`]
|
|
124
|
+
: ['File conflict detected'],
|
|
125
|
+
failed_stage: 'conflict',
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const projectedState = {
|
|
131
|
+
...state,
|
|
132
|
+
active_turns: {
|
|
133
|
+
[resolution.turn.turn_id]: resolution.turn,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
const validation = validateStagedTurnResult(root, projectedState, config, {
|
|
137
|
+
stagingPath: resolveStagingPath(root, resolution.turn.turn_id),
|
|
138
|
+
});
|
|
139
|
+
if (!validation.ok) {
|
|
140
|
+
return {
|
|
141
|
+
ok: true,
|
|
142
|
+
turn: resolution.turn,
|
|
143
|
+
validationResult: {
|
|
144
|
+
errors: validation.errors,
|
|
145
|
+
failed_stage: validation.stage || 'unknown',
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!opts.reason || !opts.reason.trim()) {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
error: 'Staged turn result validates successfully. Supply --reason to reject it anyway.',
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
ok: true,
|
|
159
|
+
turn: resolution.turn,
|
|
160
|
+
validationResult: {
|
|
161
|
+
errors: [opts.reason.trim()],
|
|
162
|
+
failed_stage: 'human_review',
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function resolveTargetTurn(state, turnId) {
|
|
168
|
+
const activeTurns = getActiveTurns(state);
|
|
169
|
+
|
|
170
|
+
if (turnId) {
|
|
171
|
+
const turn = activeTurns[turnId];
|
|
172
|
+
if (!turn) {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
error: `No active turn found for --turn ${turnId}`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return { ok: true, turn };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const turns = Object.values(activeTurns);
|
|
182
|
+
if (turns.length === 0) {
|
|
183
|
+
return { ok: false, error: 'No active turn to reject' };
|
|
184
|
+
}
|
|
185
|
+
if (turns.length > 1) {
|
|
186
|
+
return {
|
|
187
|
+
ok: false,
|
|
188
|
+
error: 'Multiple active turns are present. Re-run reject-turn with --turn <turn_id>.',
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { ok: true, turn: turns[0] };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function resolveStagingPath(root, turnId) {
|
|
196
|
+
const turnScopedPath = getTurnStagingResultPath(turnId);
|
|
197
|
+
return existsSync(join(root, turnScopedPath)) ? turnScopedPath : '.agentxchain/staging/turn-result.json';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function printDispatchBundleWarnings(bundleResult) {
|
|
201
|
+
for (const warning of bundleResult.warnings || []) {
|
|
202
|
+
console.log(chalk.yellow(`Dispatch bundle warning: ${warning}`));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentxchain resume — governed-only command.
|
|
3
|
+
*
|
|
4
|
+
* Transitions a governed project from idle/paused into an assigned turn
|
|
5
|
+
* with a concrete dispatch bundle. Per §44-§47 of the frozen spec:
|
|
6
|
+
*
|
|
7
|
+
* - governed-only (rejects legacy projects)
|
|
8
|
+
* - resolves target role from routing or --role override
|
|
9
|
+
* - if idle + no run_id → initializeGovernedRun() + assign
|
|
10
|
+
* - if paused + run_id exists → resume same run + assign
|
|
11
|
+
* - if paused + current_turn with failed status → re-dispatch same turn
|
|
12
|
+
* - if active + current_turn exists → reject (no double assignment)
|
|
13
|
+
* - materializes a turn-scoped dispatch bundle under .agentxchain/dispatch/turns/<turn_id>/
|
|
14
|
+
* - exits without waiting for turn completion
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import chalk from 'chalk';
|
|
18
|
+
import { readFileSync, existsSync } from 'fs';
|
|
19
|
+
import { join } from 'path';
|
|
20
|
+
import { loadProjectContext, loadProjectState } from '../lib/config.js';
|
|
21
|
+
import {
|
|
22
|
+
initializeGovernedRun,
|
|
23
|
+
assignGovernedTurn,
|
|
24
|
+
markRunBlocked,
|
|
25
|
+
getActiveTurns,
|
|
26
|
+
getActiveTurnCount,
|
|
27
|
+
STATE_PATH,
|
|
28
|
+
} from '../lib/governed-state.js';
|
|
29
|
+
import { writeDispatchBundle, getDispatchTurnDir, getTurnStagingResultPath } from '../lib/dispatch-bundle.js';
|
|
30
|
+
import { finalizeDispatchManifest } from '../lib/dispatch-manifest.js';
|
|
31
|
+
import {
|
|
32
|
+
getDispatchAssignmentPath,
|
|
33
|
+
getDispatchContextPath,
|
|
34
|
+
getDispatchEffectiveContextPath,
|
|
35
|
+
getDispatchPromptPath,
|
|
36
|
+
} from '../lib/turn-paths.js';
|
|
37
|
+
import { safeWriteJson } from '../lib/safe-write.js';
|
|
38
|
+
import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
|
|
39
|
+
import { runHooks } from '../lib/hook-runner.js';
|
|
40
|
+
|
|
41
|
+
export async function resumeCommand(opts) {
|
|
42
|
+
const context = loadProjectContext();
|
|
43
|
+
if (!context) {
|
|
44
|
+
console.log(chalk.red('No agentxchain.json found. Run `agentxchain init` first.'));
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { root, config } = context;
|
|
49
|
+
|
|
50
|
+
if (config.protocol_mode !== 'governed') {
|
|
51
|
+
console.log(chalk.red('The resume command is only available for governed projects.'));
|
|
52
|
+
console.log(chalk.dim('Legacy projects use: agentxchain start'));
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Load governed state
|
|
57
|
+
const statePath = join(root, STATE_PATH);
|
|
58
|
+
if (!existsSync(statePath)) {
|
|
59
|
+
console.log(chalk.red('No governed state.json found. Run `agentxchain init --governed` first.'));
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let state = loadProjectState(root, config);
|
|
64
|
+
if (!state) {
|
|
65
|
+
let parseError = 'Failed to parse state.json';
|
|
66
|
+
try {
|
|
67
|
+
JSON.parse(readFileSync(statePath, 'utf8'));
|
|
68
|
+
} catch (err) {
|
|
69
|
+
parseError = `Failed to parse state.json: ${err.message}`;
|
|
70
|
+
}
|
|
71
|
+
console.log(chalk.red(parseError));
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// §47: active + turns present → reject (resume assigns new turns, not re-dispatches)
|
|
76
|
+
const activeCount = getActiveTurnCount(state);
|
|
77
|
+
const activeTurns = getActiveTurns(state);
|
|
78
|
+
|
|
79
|
+
if (state.status === 'active' && activeCount > 0) {
|
|
80
|
+
if (activeCount === 1) {
|
|
81
|
+
const turn = Object.values(activeTurns)[0];
|
|
82
|
+
console.log(chalk.yellow('A turn is already active:'));
|
|
83
|
+
console.log(` Turn: ${turn.turn_id}`);
|
|
84
|
+
console.log(` Role: ${turn.assigned_role}`);
|
|
85
|
+
console.log(` Phase: ${state.phase}`);
|
|
86
|
+
console.log('');
|
|
87
|
+
console.log(chalk.dim(`The dispatch bundle is at: ${getDispatchTurnDir(turn.turn_id)}/`));
|
|
88
|
+
} else {
|
|
89
|
+
console.log(chalk.yellow(`${activeCount} turns are already active:`));
|
|
90
|
+
for (const turn of Object.values(activeTurns)) {
|
|
91
|
+
const statusLabel = turn.status === 'conflicted' ? chalk.red('conflicted') : turn.status;
|
|
92
|
+
console.log(` ${chalk.yellow('●')} ${turn.turn_id} — ${chalk.bold(turn.assigned_role)} (${statusLabel})`);
|
|
93
|
+
}
|
|
94
|
+
console.log('');
|
|
95
|
+
}
|
|
96
|
+
console.log(chalk.dim('Complete or accept/reject active turns before resuming.'));
|
|
97
|
+
console.log(chalk.dim('Use agentxchain step --resume to continue waiting for an active turn.'));
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// §47: paused + retained turn with failed/retrying status → re-dispatch same turn
|
|
102
|
+
if (state.status === 'paused' && activeCount > 0) {
|
|
103
|
+
// Resolve which turn to re-dispatch
|
|
104
|
+
let retainedTurn = null;
|
|
105
|
+
if (opts.turn) {
|
|
106
|
+
retainedTurn = activeTurns[opts.turn];
|
|
107
|
+
if (!retainedTurn) {
|
|
108
|
+
console.log(chalk.red(`No active turn found for --turn ${opts.turn}`));
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
} else if (activeCount > 1) {
|
|
112
|
+
console.log(chalk.red('Multiple retained turns exist. Use --turn <id> to specify which to re-dispatch.'));
|
|
113
|
+
for (const turn of Object.values(activeTurns)) {
|
|
114
|
+
console.log(` ${chalk.yellow('●')} ${turn.turn_id} — ${chalk.bold(turn.assigned_role)} (${turn.status})`);
|
|
115
|
+
}
|
|
116
|
+
console.log('');
|
|
117
|
+
console.log(chalk.dim('Example: agentxchain resume --turn <turn_id>'));
|
|
118
|
+
process.exit(1);
|
|
119
|
+
} else {
|
|
120
|
+
retainedTurn = Object.values(activeTurns)[0];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const turnStatus = retainedTurn.status;
|
|
124
|
+
if (turnStatus === 'failed' || turnStatus === 'retrying') {
|
|
125
|
+
console.log(chalk.yellow(`Re-dispatching failed turn: ${retainedTurn.turn_id}`));
|
|
126
|
+
console.log(` Role: ${retainedTurn.assigned_role}`);
|
|
127
|
+
console.log(` Attempt: ${retainedTurn.attempt}`);
|
|
128
|
+
console.log('');
|
|
129
|
+
|
|
130
|
+
// Reactivate the run
|
|
131
|
+
state.status = 'active';
|
|
132
|
+
state.blocked_on = null;
|
|
133
|
+
safeWriteJson(statePath, state);
|
|
134
|
+
|
|
135
|
+
// Write dispatch bundle for the existing turn
|
|
136
|
+
const bundleResult = writeDispatchBundle(root, state, config);
|
|
137
|
+
if (!bundleResult.ok) {
|
|
138
|
+
console.log(chalk.red(`Failed to write dispatch bundle: ${bundleResult.error}`));
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
printDispatchBundleWarnings(bundleResult);
|
|
142
|
+
|
|
143
|
+
// after_dispatch hooks with bundle-core tamper protection
|
|
144
|
+
const hooksConfig = config.hooks || {};
|
|
145
|
+
if (hooksConfig.after_dispatch?.length > 0) {
|
|
146
|
+
const afterDispatchResult = runAfterDispatchHooks(root, hooksConfig, state, retainedTurn);
|
|
147
|
+
if (!afterDispatchResult.ok) {
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
printDispatchSummary(state, config, retainedTurn);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// §47: idle + no run_id → initialize new run
|
|
158
|
+
if (state.status === 'idle' && !state.run_id) {
|
|
159
|
+
const initResult = initializeGovernedRun(root, config);
|
|
160
|
+
if (!initResult.ok) {
|
|
161
|
+
console.log(chalk.red(`Failed to initialize run: ${initResult.error}`));
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
state = initResult.state;
|
|
165
|
+
console.log(chalk.green(`Initialized governed run: ${state.run_id}`));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// §47: paused + run_id exists → resume same run
|
|
169
|
+
if (state.status === 'paused' && state.run_id) {
|
|
170
|
+
state.status = 'active';
|
|
171
|
+
state.blocked_on = null;
|
|
172
|
+
state.escalation = null;
|
|
173
|
+
safeWriteJson(statePath, state);
|
|
174
|
+
console.log(chalk.green(`Resumed governed run: ${state.run_id}`));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Resolve target role
|
|
178
|
+
const roleId = resolveTargetRole(opts, state, config);
|
|
179
|
+
if (!roleId) {
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Assign the turn
|
|
184
|
+
const assignResult = assignGovernedTurn(root, config, roleId);
|
|
185
|
+
if (!assignResult.ok) {
|
|
186
|
+
if (assignResult.error_code?.startsWith('hook_') || assignResult.error_code === 'hook_blocked') {
|
|
187
|
+
printAssignmentHookFailure(assignResult, roleId);
|
|
188
|
+
}
|
|
189
|
+
console.log(chalk.red(`Failed to assign turn: ${assignResult.error}`));
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
state = assignResult.state;
|
|
193
|
+
|
|
194
|
+
// Write dispatch bundle
|
|
195
|
+
const bundleResult = writeDispatchBundle(root, state, config);
|
|
196
|
+
if (!bundleResult.ok) {
|
|
197
|
+
console.log(chalk.red(`Failed to write dispatch bundle: ${bundleResult.error}`));
|
|
198
|
+
process.exit(1);
|
|
199
|
+
}
|
|
200
|
+
printDispatchBundleWarnings(bundleResult);
|
|
201
|
+
|
|
202
|
+
// after_dispatch hooks with bundle-core tamper protection
|
|
203
|
+
const hooksConfig = config.hooks || {};
|
|
204
|
+
const turn = state.current_turn;
|
|
205
|
+
if (hooksConfig.after_dispatch?.length > 0) {
|
|
206
|
+
const afterDispatchResult = runAfterDispatchHooks(root, hooksConfig, state, turn);
|
|
207
|
+
if (!afterDispatchResult.ok) {
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Finalize dispatch manifest (seals bundle after hooks)
|
|
213
|
+
const manifestResult = finalizeDispatchManifest(root, turn.turn_id, {
|
|
214
|
+
run_id: state.run_id,
|
|
215
|
+
role: turn.assigned_role,
|
|
216
|
+
});
|
|
217
|
+
if (!manifestResult.ok) {
|
|
218
|
+
console.log(chalk.red(`Failed to finalize dispatch manifest: ${manifestResult.error}`));
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
printDispatchSummary(state, config);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
function resolveTargetRole(opts, state, config) {
|
|
228
|
+
const phase = state.phase;
|
|
229
|
+
const routing = config.routing?.[phase];
|
|
230
|
+
|
|
231
|
+
if (opts.role) {
|
|
232
|
+
// Validate the override
|
|
233
|
+
if (!config.roles?.[opts.role]) {
|
|
234
|
+
console.log(chalk.red(`Unknown role: "${opts.role}"`));
|
|
235
|
+
console.log(chalk.dim(`Available roles: ${Object.keys(config.roles || {}).join(', ')}`));
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
if (routing?.allowed_next_roles && !routing.allowed_next_roles.includes(opts.role) && opts.role !== 'human') {
|
|
239
|
+
console.log(chalk.yellow(`Warning: role "${opts.role}" is not in allowed_next_roles for phase "${phase}".`));
|
|
240
|
+
console.log(chalk.dim(`Allowed: ${routing.allowed_next_roles.join(', ')}`));
|
|
241
|
+
// Allow it as an override, but warn
|
|
242
|
+
}
|
|
243
|
+
return opts.role;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Default: use the phase's entry_role
|
|
247
|
+
if (routing?.entry_role) {
|
|
248
|
+
return routing.entry_role;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Fallback: first role in config
|
|
252
|
+
const roles = Object.keys(config.roles || {});
|
|
253
|
+
if (roles.length > 0) {
|
|
254
|
+
console.log(chalk.yellow(`No entry_role for phase "${phase}". Defaulting to "${roles[0]}".`));
|
|
255
|
+
return roles[0];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
console.log(chalk.red('No roles defined in config.'));
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function runAfterDispatchHooks(root, hooksConfig, state, turn) {
|
|
263
|
+
const turnId = turn.turn_id;
|
|
264
|
+
const roleId = turn.assigned_role;
|
|
265
|
+
|
|
266
|
+
const afterDispatchHooks = runHooks(root, hooksConfig, 'after_dispatch', {
|
|
267
|
+
turn_id: turnId,
|
|
268
|
+
role_id: roleId,
|
|
269
|
+
bundle_path: getDispatchTurnDir(turnId),
|
|
270
|
+
bundle_files: ['ASSIGNMENT.json', 'PROMPT.md', 'CONTEXT.md'],
|
|
271
|
+
}, {
|
|
272
|
+
run_id: state.run_id,
|
|
273
|
+
turn_id: turnId,
|
|
274
|
+
protectedPaths: [
|
|
275
|
+
getDispatchAssignmentPath(turnId),
|
|
276
|
+
getDispatchPromptPath(turnId),
|
|
277
|
+
getDispatchContextPath(turnId),
|
|
278
|
+
getDispatchEffectiveContextPath(turnId),
|
|
279
|
+
],
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
if (!afterDispatchHooks.ok) {
|
|
283
|
+
const hookName = afterDispatchHooks.blocker?.hook_name
|
|
284
|
+
|| afterDispatchHooks.tamper?.file
|
|
285
|
+
|| afterDispatchHooks.results?.find((e) => e.hook_name)?.hook_name
|
|
286
|
+
|| 'unknown';
|
|
287
|
+
const detail = afterDispatchHooks.blocker?.message
|
|
288
|
+
|| afterDispatchHooks.tamper?.message
|
|
289
|
+
|| `after_dispatch hook blocked dispatch for turn ${turnId}`;
|
|
290
|
+
const errorCode = afterDispatchHooks.tamper?.error_code || 'hook_blocked';
|
|
291
|
+
|
|
292
|
+
markRunBlocked(root, {
|
|
293
|
+
blockedOn: `hook:after_dispatch:${hookName}`,
|
|
294
|
+
category: 'dispatch_error',
|
|
295
|
+
recovery: {
|
|
296
|
+
typed_reason: afterDispatchHooks.tamper ? 'hook_tamper' : 'hook_block',
|
|
297
|
+
owner: 'human',
|
|
298
|
+
recovery_action: `Fix or reconfigure the hook, then rerun agentxchain resume`,
|
|
299
|
+
turn_retained: true,
|
|
300
|
+
detail,
|
|
301
|
+
},
|
|
302
|
+
turnId,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
printDispatchHookFailure({
|
|
306
|
+
turnId,
|
|
307
|
+
roleId,
|
|
308
|
+
hookName,
|
|
309
|
+
error: detail,
|
|
310
|
+
errorCode,
|
|
311
|
+
hookResults: afterDispatchHooks,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
return { ok: false };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return { ok: true };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function printDispatchHookFailure({ turnId, roleId, hookName, error, hookResults }) {
|
|
321
|
+
const isTamper = hookResults?.tamper;
|
|
322
|
+
console.log('');
|
|
323
|
+
console.log(chalk.yellow(' Dispatch Blocked By Hook'));
|
|
324
|
+
console.log(chalk.dim(' ' + '-'.repeat(44)));
|
|
325
|
+
console.log('');
|
|
326
|
+
console.log(` ${chalk.dim('Turn:')} ${turnId || '(unknown)'}`);
|
|
327
|
+
console.log(` ${chalk.dim('Role:')} ${roleId || '(unknown)'}`);
|
|
328
|
+
console.log(` ${chalk.dim('Hook:')} ${hookName}`);
|
|
329
|
+
console.log(` ${chalk.dim('Error:')} ${error}`);
|
|
330
|
+
console.log(` ${chalk.dim('Reason:')} ${isTamper ? 'hook_tamper' : 'hook_block'}`);
|
|
331
|
+
console.log(` ${chalk.dim('Owner:')} human`);
|
|
332
|
+
console.log(` ${chalk.dim('Action:')} Fix or reconfigure the hook, then rerun agentxchain resume`);
|
|
333
|
+
console.log('');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function printDispatchBundleWarnings(bundleResult) {
|
|
337
|
+
for (const warning of bundleResult.warnings || []) {
|
|
338
|
+
console.log(chalk.yellow(`Dispatch bundle warning: ${warning}`));
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function printAssignmentHookFailure(result, roleId) {
|
|
343
|
+
const recovery = deriveRecoveryDescriptor(result.state);
|
|
344
|
+
const hookName = result.hookResults?.blocker?.hook_name
|
|
345
|
+
|| result.hookResults?.results?.find((entry) => entry.hook_name)?.hook_name
|
|
346
|
+
|| '(unknown)';
|
|
347
|
+
|
|
348
|
+
console.log('');
|
|
349
|
+
console.log(chalk.yellow(' Turn Assignment Blocked By Hook'));
|
|
350
|
+
console.log(chalk.dim(' ' + '-'.repeat(44)));
|
|
351
|
+
console.log('');
|
|
352
|
+
console.log(` ${chalk.dim('Role:')} ${roleId || '(unknown)'}`);
|
|
353
|
+
console.log(` ${chalk.dim('Phase:')} ${result.state?.phase || '(unknown)'}`);
|
|
354
|
+
console.log(` ${chalk.dim('Hook:')} ${hookName}`);
|
|
355
|
+
console.log(` ${chalk.dim('Error:')} ${result.error}`);
|
|
356
|
+
if (recovery) {
|
|
357
|
+
console.log(` ${chalk.dim('Reason:')} ${recovery.typed_reason}`);
|
|
358
|
+
console.log(` ${chalk.dim('Owner:')} ${recovery.owner}`);
|
|
359
|
+
console.log(` ${chalk.dim('Action:')} ${recovery.recovery_action}`);
|
|
360
|
+
if (recovery.detail) {
|
|
361
|
+
console.log(` ${chalk.dim('Detail:')} ${recovery.detail}`);
|
|
362
|
+
}
|
|
363
|
+
} else {
|
|
364
|
+
console.log(` ${chalk.dim('Action:')} Fix or reconfigure hook "${hookName}", then rerun agentxchain resume --role ${roleId}`);
|
|
365
|
+
}
|
|
366
|
+
console.log('');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function printDispatchSummary(state, config, explicitTurn) {
|
|
370
|
+
const turn = explicitTurn || state.current_turn;
|
|
371
|
+
const role = config.roles?.[turn.assigned_role];
|
|
372
|
+
|
|
373
|
+
console.log('');
|
|
374
|
+
console.log(chalk.bold(' Turn Assigned'));
|
|
375
|
+
console.log(chalk.dim(' ' + '─'.repeat(44)));
|
|
376
|
+
console.log('');
|
|
377
|
+
console.log(` ${chalk.dim('Turn:')} ${turn.turn_id}`);
|
|
378
|
+
console.log(` ${chalk.dim('Role:')} ${role?.title || turn.assigned_role}`);
|
|
379
|
+
console.log(` ${chalk.dim('Phase:')} ${state.phase}`);
|
|
380
|
+
console.log(` ${chalk.dim('Runtime:')} ${turn.runtime_id}`);
|
|
381
|
+
console.log(` ${chalk.dim('Attempt:')} ${turn.attempt}`);
|
|
382
|
+
console.log('');
|
|
383
|
+
console.log(` ${chalk.dim('Dispatch bundle:')} ${getDispatchTurnDir(turn.turn_id)}/`);
|
|
384
|
+
console.log(` ${chalk.dim('Prompt:')} ${getDispatchTurnDir(turn.turn_id)}/PROMPT.md`);
|
|
385
|
+
console.log(` ${chalk.dim('Submit result to:')} ${getTurnStagingResultPath(turn.turn_id)}`);
|
|
386
|
+
console.log('');
|
|
387
|
+
console.log(chalk.dim(' When done, run: agentxchain validate --mode turn'));
|
|
388
|
+
console.log('');
|
|
389
|
+
}
|