agentxchain 2.128.0 → 2.130.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 +2 -0
- package/bin/agentxchain.js +38 -4
- package/package.json +1 -1
- package/scripts/verify-post-publish.sh +55 -5
- package/src/commands/accept-turn.js +14 -0
- package/src/commands/checkpoint-turn.js +35 -0
- package/src/commands/connector.js +17 -2
- package/src/commands/doctor.js +151 -1
- package/src/commands/events.js +7 -1
- package/src/commands/init.js +42 -11
- package/src/commands/inject.js +1 -1
- package/src/commands/mission.js +803 -7
- package/src/commands/reissue-turn.js +122 -0
- package/src/commands/reject-turn.js +60 -6
- package/src/commands/restart.js +81 -10
- package/src/commands/resume.js +20 -9
- package/src/commands/run.js +13 -0
- package/src/commands/status.js +58 -4
- package/src/commands/step.js +49 -10
- package/src/commands/validate.js +78 -20
- package/src/lib/cli-version.js +106 -0
- package/src/lib/connector-probe.js +146 -5
- package/src/lib/continuous-run.js +22 -87
- package/src/lib/coordinator-dispatch.js +25 -0
- package/src/lib/dispatch-bundle.js +39 -0
- package/src/lib/governed-state.js +624 -11
- package/src/lib/governed-templates.js +1 -0
- package/src/lib/intake.js +233 -77
- package/src/lib/mission-plans.js +510 -6
- package/src/lib/missions.js +65 -6
- package/src/lib/normalized-config.js +50 -15
- package/src/lib/repo-observer.js +8 -2
- package/src/lib/run-events.js +5 -0
- package/src/lib/run-loop.js +25 -0
- package/src/lib/runner-interface.js +2 -0
- package/src/lib/session-checkpoint.js +18 -2
- package/src/lib/turn-checkpoint.js +221 -0
- package/src/templates/governed/full-local-cli.json +71 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reissue-turn command — unified turn invalidation + reissue against current state.
|
|
3
|
+
*
|
|
4
|
+
* Covers all drift recovery scenarios:
|
|
5
|
+
* - Baseline drift (HEAD changed after dispatch)
|
|
6
|
+
* - Runtime drift (agentxchain.json rebinding after dispatch)
|
|
7
|
+
* - Authority drift (write_authority changed on assigned role)
|
|
8
|
+
* - Operator-initiated (explicit redo from current state)
|
|
9
|
+
*
|
|
10
|
+
* BUG-7 fix: single command, multiple trigger reasons.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import chalk from 'chalk';
|
|
14
|
+
import { readFileSync } from 'fs';
|
|
15
|
+
import { join } from 'path';
|
|
16
|
+
import { loadProjectContext, loadProjectState } from '../lib/config.js';
|
|
17
|
+
import {
|
|
18
|
+
getActiveTurns,
|
|
19
|
+
getActiveTurn,
|
|
20
|
+
reissueTurn,
|
|
21
|
+
} from '../lib/governed-state.js';
|
|
22
|
+
import { writeDispatchBundle } from '../lib/dispatch-bundle.js';
|
|
23
|
+
|
|
24
|
+
export async function reissueTurnCommand(opts) {
|
|
25
|
+
const context = loadProjectContext();
|
|
26
|
+
if (!context) {
|
|
27
|
+
console.log(chalk.red('No agentxchain.json found. Run `agentxchain init` first.'));
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { root, config } = context;
|
|
32
|
+
|
|
33
|
+
if (config.protocol_mode !== 'governed') {
|
|
34
|
+
console.log(chalk.red('The reissue-turn command is only available for governed projects.'));
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let state = loadProjectState(root, config);
|
|
39
|
+
if (!state) {
|
|
40
|
+
console.log(chalk.red('No governed state.json found.'));
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Resolve target turn
|
|
45
|
+
const activeTurns = getActiveTurns(state);
|
|
46
|
+
const activeCount = Object.keys(activeTurns).length;
|
|
47
|
+
|
|
48
|
+
if (activeCount === 0) {
|
|
49
|
+
console.log(chalk.red('No active turns to reissue.'));
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let targetTurn;
|
|
54
|
+
if (opts.turn) {
|
|
55
|
+
targetTurn = activeTurns[opts.turn];
|
|
56
|
+
if (!targetTurn) {
|
|
57
|
+
console.log(chalk.red(`No active turn found for --turn ${opts.turn}`));
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
} else if (activeCount === 1) {
|
|
61
|
+
targetTurn = Object.values(activeTurns)[0];
|
|
62
|
+
} else {
|
|
63
|
+
console.log(chalk.red('Multiple active turns exist. Use --turn <id> to specify which to reissue.'));
|
|
64
|
+
for (const turn of Object.values(activeTurns)) {
|
|
65
|
+
console.log(` ${chalk.yellow('●')} ${turn.turn_id} — ${chalk.bold(turn.assigned_role)} (${turn.status})`);
|
|
66
|
+
}
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const reason = opts.reason || 'operator-initiated reissue';
|
|
71
|
+
|
|
72
|
+
console.log(chalk.cyan(`Reissuing turn: ${targetTurn.turn_id} (${targetTurn.assigned_role})`));
|
|
73
|
+
console.log(chalk.dim(`Reason: ${reason}`));
|
|
74
|
+
|
|
75
|
+
const result = reissueTurn(root, config, {
|
|
76
|
+
turnId: targetTurn.turn_id,
|
|
77
|
+
reason,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (!result.ok) {
|
|
81
|
+
console.log(chalk.red(`Failed to reissue turn: ${result.error}`));
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Write dispatch bundle for the reissued turn
|
|
86
|
+
const bundleResult = writeDispatchBundle(root, result.state, config, {
|
|
87
|
+
turnId: result.newTurn.turn_id,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (!bundleResult.ok) {
|
|
91
|
+
console.log(chalk.red(`Turn reissued but dispatch bundle failed: ${bundleResult.error}`));
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Print summary
|
|
96
|
+
console.log('');
|
|
97
|
+
console.log(chalk.green(' Turn Reissued'));
|
|
98
|
+
console.log(chalk.dim(' ' + '─'.repeat(44)));
|
|
99
|
+
console.log('');
|
|
100
|
+
console.log(` ${chalk.dim('Old turn:')} ${targetTurn.turn_id}`);
|
|
101
|
+
console.log(` ${chalk.dim('New turn:')} ${result.newTurn.turn_id}`);
|
|
102
|
+
console.log(` ${chalk.dim('Role:')} ${result.newTurn.assigned_role}`);
|
|
103
|
+
console.log(` ${chalk.dim('Attempt:')} ${result.newTurn.attempt}`);
|
|
104
|
+
console.log(` ${chalk.dim('Reason:')} ${reason}`);
|
|
105
|
+
|
|
106
|
+
// Show baseline delta
|
|
107
|
+
if (result.baselineDelta) {
|
|
108
|
+
const delta = result.baselineDelta;
|
|
109
|
+
if (delta.head_changed) {
|
|
110
|
+
console.log(` ${chalk.dim('HEAD:')} ${chalk.yellow(delta.old_head?.slice(0, 12) || '?')} → ${chalk.green(delta.new_head?.slice(0, 12) || '?')}`);
|
|
111
|
+
}
|
|
112
|
+
if (delta.runtime_changed) {
|
|
113
|
+
console.log(` ${chalk.dim('Runtime:')} ${chalk.yellow(delta.old_runtime || '?')} → ${chalk.green(delta.new_runtime || '?')}`);
|
|
114
|
+
}
|
|
115
|
+
if (delta.dirty_files_changed) {
|
|
116
|
+
console.log(` ${chalk.dim('Workspace:')} ${delta.added_dirty_files?.length || 0} new dirty file(s), ${delta.removed_dirty_files?.length || 0} resolved`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.log('');
|
|
121
|
+
console.log(chalk.dim('Run: agentxchain step --resume to dispatch the reissued turn.'));
|
|
122
|
+
}
|
|
@@ -107,11 +107,31 @@ function buildRejectionValidation(root, state, config, opts) {
|
|
|
107
107
|
return resolution;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
// BUG-9 fix: --reassign should work for any rejected turn, not just conflicted ones.
|
|
111
|
+
// For drift-induced failures with no conflict_state, redirect to reissue-turn.
|
|
110
112
|
if (opts.reassign && !resolution.turn.conflict_state) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
// Detect if baseline drift exists
|
|
114
|
+
const currentHead = (() => {
|
|
115
|
+
try {
|
|
116
|
+
return require('child_process').execSync('git rev-parse HEAD', {
|
|
117
|
+
cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
118
|
+
}).trim();
|
|
119
|
+
} catch { return null; }
|
|
120
|
+
})();
|
|
121
|
+
const turnHead = resolution.turn.baseline?.head_ref;
|
|
122
|
+
const hasDrift = currentHead && turnHead && currentHead !== turnHead;
|
|
123
|
+
|
|
124
|
+
if (hasDrift) {
|
|
125
|
+
console.log(chalk.yellow(`Baseline drift detected: HEAD moved from ${turnHead?.slice(0, 12)} to ${currentHead?.slice(0, 12)}.`));
|
|
126
|
+
console.log(chalk.dim(`Use: agentxchain reissue-turn --turn ${resolution.turn.turn_id} --reason "baseline drift"`));
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
error: `--reassign detected baseline drift. Use reissue-turn instead for a clean reissue from current HEAD.`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// No drift, no conflict_state — just do a normal reject + reassign
|
|
134
|
+
// (treat it as a fresh retry with refreshed baseline, which BUG-8 now handles)
|
|
115
135
|
}
|
|
116
136
|
|
|
117
137
|
if (resolution.turn.conflict_state) {
|
|
@@ -133,8 +153,21 @@ function buildRejectionValidation(root, state, config, opts) {
|
|
|
133
153
|
[resolution.turn.turn_id]: resolution.turn,
|
|
134
154
|
},
|
|
135
155
|
};
|
|
156
|
+
const stagingPath = resolveStagingPath(root, resolution.turn.turn_id);
|
|
157
|
+
// BUG-22: If resolveStagingPath returns null, a stale result from another turn
|
|
158
|
+
// was detected. Reject with a clear diagnostic instead of consuming it.
|
|
159
|
+
if (stagingPath === null) {
|
|
160
|
+
return {
|
|
161
|
+
ok: true,
|
|
162
|
+
turn: resolution.turn,
|
|
163
|
+
validationResult: {
|
|
164
|
+
errors: [`Stale staging data: .agentxchain/staging/turn-result.json belongs to a different turn. Clean up with: rm .agentxchain/staging/turn-result.json`],
|
|
165
|
+
failed_stage: 'stale_staging',
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
136
169
|
const validation = validateStagedTurnResult(root, projectedState, config, {
|
|
137
|
-
stagingPath
|
|
170
|
+
stagingPath,
|
|
138
171
|
});
|
|
139
172
|
if (!validation.ok) {
|
|
140
173
|
return {
|
|
@@ -193,8 +226,29 @@ function resolveTargetTurn(state, turnId) {
|
|
|
193
226
|
}
|
|
194
227
|
|
|
195
228
|
function resolveStagingPath(root, turnId) {
|
|
229
|
+
// BUG-22: Prefer turn-scoped staging path. Only fall back to legacy global
|
|
230
|
+
// staging if the global result's turn_id matches the active turn.
|
|
196
231
|
const turnScopedPath = getTurnStagingResultPath(turnId);
|
|
197
|
-
|
|
232
|
+
if (existsSync(join(root, turnScopedPath))) {
|
|
233
|
+
return turnScopedPath;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const legacyPath = '.agentxchain/staging/turn-result.json';
|
|
237
|
+
const legacyAbs = join(root, legacyPath);
|
|
238
|
+
if (existsSync(legacyAbs)) {
|
|
239
|
+
try {
|
|
240
|
+
const raw = JSON.parse(require('fs').readFileSync(legacyAbs, 'utf8'));
|
|
241
|
+
if (raw.turn_id && raw.turn_id !== turnId) {
|
|
242
|
+
// Stale result from a different turn — do not consume
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
// Parse error — let the validator handle it
|
|
247
|
+
}
|
|
248
|
+
return legacyPath;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return legacyPath; // File doesn't exist — validator will report "not found"
|
|
198
252
|
}
|
|
199
253
|
|
|
200
254
|
function printDispatchBundleWarnings(bundleResult) {
|
package/src/commands/restart.js
CHANGED
|
@@ -19,10 +19,15 @@ import {
|
|
|
19
19
|
getActiveTurns,
|
|
20
20
|
getActiveTurnCount,
|
|
21
21
|
reactivateGovernedRun,
|
|
22
|
+
detectStateBundleDesync,
|
|
22
23
|
STATE_PATH,
|
|
23
24
|
HISTORY_PATH,
|
|
24
25
|
LEDGER_PATH,
|
|
25
26
|
} from '../lib/governed-state.js';
|
|
27
|
+
import { writeDispatchBundle } from '../lib/dispatch-bundle.js';
|
|
28
|
+
import { getDispatchTurnDir } from '../lib/turn-paths.js';
|
|
29
|
+
import { consumeNextApprovedIntent } from '../lib/intake.js';
|
|
30
|
+
import { loadProjectState } from '../lib/config.js';
|
|
26
31
|
import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
|
|
27
32
|
import { deriveRecommendedContinuityAction } from '../lib/continuity-status.js';
|
|
28
33
|
import { readSessionCheckpoint, writeSessionCheckpoint, captureBaselineRef, SESSION_PATH } from '../lib/session-checkpoint.js';
|
|
@@ -213,6 +218,25 @@ export async function restartCommand(opts) {
|
|
|
213
218
|
process.exit(1);
|
|
214
219
|
}
|
|
215
220
|
|
|
221
|
+
// ── BUG-18: State/bundle integrity check ─────────────────────────────────
|
|
222
|
+
const desync = detectStateBundleDesync(root, state);
|
|
223
|
+
if (!desync.ok) {
|
|
224
|
+
console.log(chalk.red('State/bundle integrity failure detected:'));
|
|
225
|
+
for (const entry of desync.desynced) {
|
|
226
|
+
console.log(chalk.red(` Active turn ${entry.turn_id} (${entry.role}) has no dispatch bundle at ${entry.expected_path}`));
|
|
227
|
+
}
|
|
228
|
+
console.log('');
|
|
229
|
+
console.log(chalk.dim('This is a ghost turn — state references an active turn but the dispatch files are missing.'));
|
|
230
|
+
console.log(chalk.dim('Recovery options:'));
|
|
231
|
+
for (const entry of desync.desynced) {
|
|
232
|
+
console.log(` ${chalk.cyan(`agentxchain reissue-turn --turn ${entry.turn_id} --reason "missing dispatch bundle"`)}`);
|
|
233
|
+
}
|
|
234
|
+
console.log(` ${chalk.cyan('agentxchain reject-turn --reason "ghost turn — missing dispatch bundle"')}`);
|
|
235
|
+
console.log('');
|
|
236
|
+
console.log(chalk.dim('Run `agentxchain doctor` for a full diagnostic.'));
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
|
|
216
240
|
// ── Repo-drift detection ────────────────────────────────────────────────
|
|
217
241
|
const driftWarnings = [];
|
|
218
242
|
if (checkpoint?.baseline_ref) {
|
|
@@ -265,10 +289,17 @@ export async function restartCommand(opts) {
|
|
|
265
289
|
console.log(chalk.yellow(`Warning: ${activeTurnCount} turn(s) were assigned but never completed: ${turnIds.join(', ')}`));
|
|
266
290
|
console.log(chalk.dim('These turns will be available for the next agent to complete.'));
|
|
267
291
|
|
|
268
|
-
// Fail closed if retained turn + irreconcilable drift
|
|
292
|
+
// Fail closed if retained turn + irreconcilable drift — BUG-10 fix: surface actionable recovery
|
|
269
293
|
if (driftWarnings.length > 0) {
|
|
270
294
|
console.log(chalk.yellow('Active turns exist with repo drift since checkpoint. Reconnecting with warnings.'));
|
|
271
|
-
console.log(
|
|
295
|
+
console.log('');
|
|
296
|
+
console.log(chalk.dim('Recovery options:'));
|
|
297
|
+
for (const turnId of turnIds) {
|
|
298
|
+
const turn = activeTurns[turnId];
|
|
299
|
+
console.log(` ${chalk.cyan(`agentxchain reissue-turn --turn ${turnId} --reason "baseline drift"`)} — reissue ${turn.assigned_role} from current HEAD`);
|
|
300
|
+
}
|
|
301
|
+
console.log(` ${chalk.cyan('agentxchain reject-turn --reason "baseline drift"')} — reject and retry with refreshed baseline`);
|
|
302
|
+
console.log(` ${chalk.dim('Continue as-is if the drift does not affect the retained turns.')}`);
|
|
272
303
|
}
|
|
273
304
|
}
|
|
274
305
|
|
|
@@ -309,21 +340,61 @@ export async function restartCommand(opts) {
|
|
|
309
340
|
|
|
310
341
|
// Assign next turn if no active turn exists
|
|
311
342
|
if (activeTurnCount === 0) {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
343
|
+
// BUG-21 fix: consume approved intents (same as resume path) so intent_id
|
|
344
|
+
// propagates into turn metadata and all lifecycle events.
|
|
345
|
+
const consumed = consumeNextApprovedIntent(root, { role: roleId });
|
|
346
|
+
let assignedState;
|
|
347
|
+
let turnId;
|
|
348
|
+
let assignedRole = roleId;
|
|
349
|
+
|
|
350
|
+
if (consumed.ok) {
|
|
351
|
+
// Intake path handled the turn assignment with intakeContext
|
|
352
|
+
assignedState = loadProjectState(root, config);
|
|
353
|
+
if (!assignedState) {
|
|
354
|
+
console.log(chalk.red('Failed to reload governed state after intake binding.'));
|
|
355
|
+
process.exit(1);
|
|
356
|
+
}
|
|
357
|
+
turnId = consumed.turn_id;
|
|
358
|
+
assignedRole = consumed.role || roleId;
|
|
359
|
+
console.log(chalk.green(`Bound approved intent to next turn: ${consumed.intentId}`));
|
|
360
|
+
} else {
|
|
361
|
+
// No approved intents — plain assignment
|
|
362
|
+
const assignment = assignGovernedTurn(root, config, roleId);
|
|
363
|
+
if (!assignment.ok) {
|
|
364
|
+
console.log(chalk.red(`Failed to assign turn: ${assignment.error}`));
|
|
365
|
+
process.exit(1);
|
|
366
|
+
}
|
|
367
|
+
for (const warning of assignment.warnings || []) {
|
|
368
|
+
console.log(chalk.yellow(`Warning: ${warning}`));
|
|
369
|
+
}
|
|
370
|
+
assignedState = assignment.state;
|
|
371
|
+
turnId = assignment.turn?.turn_id || assignment.turn?.id;
|
|
372
|
+
assignedRole = assignment.turn?.assigned_role || roleId;
|
|
316
373
|
}
|
|
317
|
-
|
|
318
|
-
|
|
374
|
+
|
|
375
|
+
// BUG-17 fix: write dispatch bundle AFTER state assignment succeeds.
|
|
376
|
+
// The bundle must exist on disk before we report success, otherwise the
|
|
377
|
+
// operator sees a "ghost turn" in state with no dispatch directory.
|
|
378
|
+
if (turnId) {
|
|
379
|
+
const bundleResult = writeDispatchBundle(root, assignedState, config, { turnId });
|
|
380
|
+
if (!bundleResult.ok) {
|
|
381
|
+
console.log(chalk.red(`Turn assigned but dispatch bundle write failed: ${bundleResult.error}`));
|
|
382
|
+
console.log(chalk.dim('The turn is assigned in state but has no dispatch context.'));
|
|
383
|
+
console.log(chalk.dim('Run `agentxchain reissue-turn` to reissue with a fresh bundle.'));
|
|
384
|
+
process.exit(1);
|
|
385
|
+
}
|
|
386
|
+
for (const bw of bundleResult.warnings || []) {
|
|
387
|
+
console.log(chalk.yellow(`Dispatch bundle warning: ${bw}`));
|
|
388
|
+
}
|
|
319
389
|
}
|
|
320
390
|
|
|
321
391
|
// assignGovernedTurn already writes a checkpoint at turn_assigned
|
|
322
392
|
|
|
323
393
|
console.log(chalk.green(`✓ Restarted run ${state.run_id}`));
|
|
324
394
|
console.log(chalk.dim(` Phase: ${phase}`));
|
|
325
|
-
console.log(chalk.dim(` Turn: ${
|
|
326
|
-
console.log(chalk.dim(` Role: ${
|
|
395
|
+
console.log(chalk.dim(` Turn: ${turnId || 'assigned'}`));
|
|
396
|
+
console.log(chalk.dim(` Role: ${assignedRole || 'routing default'}`));
|
|
397
|
+
console.log(chalk.dim(` Dispatch: ${getDispatchTurnDir(turnId || 'unknown')}/`));
|
|
327
398
|
if (checkpoint) {
|
|
328
399
|
console.log(chalk.dim(` Last checkpoint: ${checkpoint.checkpoint_reason} at ${checkpoint.last_checkpoint_at}`));
|
|
329
400
|
}
|
package/src/commands/resume.js
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
|
|
40
40
|
import { runHooks } from '../lib/hook-runner.js';
|
|
41
41
|
import { summarizeRunProvenance } from '../lib/run-provenance.js';
|
|
42
|
+
import { consumeNextApprovedIntent } from '../lib/intake.js';
|
|
42
43
|
|
|
43
44
|
export async function resumeCommand(opts) {
|
|
44
45
|
const context = loadProjectContext();
|
|
@@ -265,17 +266,27 @@ export async function resumeCommand(opts) {
|
|
|
265
266
|
process.exit(1);
|
|
266
267
|
}
|
|
267
268
|
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
if (
|
|
271
|
-
|
|
272
|
-
|
|
269
|
+
const shouldBindIntent = opts.intent !== false;
|
|
270
|
+
const consumed = shouldBindIntent ? consumeNextApprovedIntent(root, { role: roleId }) : { ok: false };
|
|
271
|
+
if (consumed.ok) {
|
|
272
|
+
state = loadProjectState(root, config);
|
|
273
|
+
if (!state) {
|
|
274
|
+
console.log(chalk.red('Failed to reload governed state after intake binding.'));
|
|
275
|
+
process.exit(1);
|
|
273
276
|
}
|
|
274
|
-
console.log(chalk.
|
|
275
|
-
|
|
277
|
+
console.log(chalk.green(`Bound approved intent to next turn: ${consumed.intentId}`));
|
|
278
|
+
} else {
|
|
279
|
+
const assignResult = assignGovernedTurn(root, config, roleId);
|
|
280
|
+
if (!assignResult.ok) {
|
|
281
|
+
if (assignResult.error_code?.startsWith('hook_') || assignResult.error_code === 'hook_blocked') {
|
|
282
|
+
printAssignmentHookFailure(assignResult, roleId, config);
|
|
283
|
+
}
|
|
284
|
+
console.log(chalk.red(`Failed to assign turn: ${assignResult.error}`));
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
printAssignmentWarnings(assignResult);
|
|
288
|
+
state = assignResult.state;
|
|
276
289
|
}
|
|
277
|
-
printAssignmentWarnings(assignResult);
|
|
278
|
-
state = assignResult.state;
|
|
279
290
|
|
|
280
291
|
// Write dispatch bundle
|
|
281
292
|
const bundleResult = writeDispatchBundle(root, state, config);
|
package/src/commands/run.js
CHANGED
|
@@ -48,6 +48,7 @@ import { resolveChainOptions, executeChainedRun } from '../lib/run-chain.js';
|
|
|
48
48
|
import { resolveContinuousOptions, executeContinuousRun } from '../lib/continuous-run.js';
|
|
49
49
|
import { createDispatchProgressTracker } from '../lib/dispatch-progress.js';
|
|
50
50
|
import { emitRunEvent } from '../lib/run-events.js';
|
|
51
|
+
import { checkpointAcceptedTurn } from '../lib/turn-checkpoint.js';
|
|
51
52
|
|
|
52
53
|
export async function runCommand(opts) {
|
|
53
54
|
const context = loadProjectContext();
|
|
@@ -148,6 +149,7 @@ export async function executeGovernedRun(context, opts = {}) {
|
|
|
148
149
|
|
|
149
150
|
const maxTurns = opts.maxTurns || 50;
|
|
150
151
|
const autoApprove = !!opts.autoApprove;
|
|
152
|
+
const autoCheckpoint = opts.autoCheckpoint === true;
|
|
151
153
|
const verbose = !!opts.verbose;
|
|
152
154
|
const overrideResolution = opts.role
|
|
153
155
|
? resolveGovernedRole({ override: opts.role, state: null, config })
|
|
@@ -499,6 +501,17 @@ export async function executeGovernedRun(context, opts = {}) {
|
|
|
499
501
|
return approved;
|
|
500
502
|
},
|
|
501
503
|
|
|
504
|
+
async afterAccept({ turn }) {
|
|
505
|
+
if (!autoCheckpoint) {
|
|
506
|
+
return { ok: true };
|
|
507
|
+
}
|
|
508
|
+
const checkpoint = checkpointAcceptedTurn(root, { turnId: turn.turn_id });
|
|
509
|
+
if (!checkpoint.ok) {
|
|
510
|
+
return { ok: false, error: checkpoint.error || `checkpoint failed for ${turn.turn_id}` };
|
|
511
|
+
}
|
|
512
|
+
return { ok: true };
|
|
513
|
+
},
|
|
514
|
+
|
|
502
515
|
onEvent(event) {
|
|
503
516
|
switch (event.type) {
|
|
504
517
|
case 'turn_assigned':
|
package/src/commands/status.js
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
deriveRecoveryDescriptor,
|
|
9
9
|
deriveRuntimeBlockedGuidance,
|
|
10
10
|
} from '../lib/blocked-state.js';
|
|
11
|
-
import { getActiveTurn, getActiveTurnCount, getActiveTurns } from '../lib/governed-state.js';
|
|
11
|
+
import { getActiveTurn, getActiveTurnCount, getActiveTurns, detectActiveTurnBindingDrift, detectStateBundleDesync } from '../lib/governed-state.js';
|
|
12
12
|
import { getContinuityStatus } from '../lib/continuity-status.js';
|
|
13
13
|
import { getConnectorHealth } from '../lib/connector-health.js';
|
|
14
14
|
import { readRepoDecisions, summarizeRepoDecisions } from '../lib/repo-decisions.js';
|
|
@@ -21,7 +21,7 @@ import { deriveConflictedTurnResolutionActions } from '../lib/conflict-actions.j
|
|
|
21
21
|
import { summarizeLatestGateActionAttempt } from '../lib/gate-actions.js';
|
|
22
22
|
import { findCurrentHumanEscalation } from '../lib/human-escalations.js';
|
|
23
23
|
import { getDashboardPid, getDashboardSession } from './dashboard.js';
|
|
24
|
-
import { readPreemptionMarker } from '../lib/intake.js';
|
|
24
|
+
import { readPreemptionMarker, findPendingApprovedIntents } from '../lib/intake.js';
|
|
25
25
|
import { readContinuousSession } from '../lib/continuous-run.js';
|
|
26
26
|
import { readAllDispatchProgress } from '../lib/dispatch-progress.js';
|
|
27
27
|
|
|
@@ -133,6 +133,7 @@ function renderGovernedStatus(context, opts) {
|
|
|
133
133
|
const workflowKitArtifacts = deriveWorkflowKitArtifacts(root, config, state);
|
|
134
134
|
const humanEscalation = findCurrentHumanEscalation(root, state);
|
|
135
135
|
const preemptionMarker = readPreemptionMarker(root);
|
|
136
|
+
const pendingIntents = findPendingApprovedIntents(root);
|
|
136
137
|
const continuousSession = readContinuousSession(root);
|
|
137
138
|
const gateActionAttempt = state?.pending_phase_transition
|
|
138
139
|
? summarizeLatestGateActionAttempt(root, 'phase_transition', state.pending_phase_transition.gate)
|
|
@@ -175,10 +176,13 @@ function renderGovernedStatus(context, opts) {
|
|
|
175
176
|
dispatch_progress: dispatchProgress,
|
|
176
177
|
human_escalation: humanEscalation,
|
|
177
178
|
preemption_marker: preemptionMarker,
|
|
179
|
+
pending_intents: pendingIntents,
|
|
178
180
|
continuous_session: continuousSession,
|
|
179
181
|
gate_action_attempt: gateActionAttempt,
|
|
180
182
|
workflow_kit_artifacts: workflowKitArtifacts,
|
|
181
183
|
dashboard_session: dashboardSessionObj,
|
|
184
|
+
binding_drift: detectActiveTurnBindingDrift(state, config),
|
|
185
|
+
bundle_integrity: detectStateBundleDesync(root, state),
|
|
182
186
|
}, null, 2));
|
|
183
187
|
return;
|
|
184
188
|
}
|
|
@@ -204,6 +208,21 @@ function renderGovernedStatus(context, opts) {
|
|
|
204
208
|
console.log('');
|
|
205
209
|
}
|
|
206
210
|
|
|
211
|
+
// Pending injected intents (BUG-15)
|
|
212
|
+
if (pendingIntents.length > 0) {
|
|
213
|
+
console.log(chalk.yellow.bold(' 📋 Pending injected intents (will drive next turn):'));
|
|
214
|
+
for (const pi of pendingIntents) {
|
|
215
|
+
const priorityColor = pi.priority === 'p0' ? chalk.red.bold : pi.priority === 'p1' ? chalk.yellow.bold : chalk.dim;
|
|
216
|
+
const charterSnippet = pi.charter
|
|
217
|
+
? (pi.charter.length > 60 ? pi.charter.slice(0, 57) + '...' : pi.charter)
|
|
218
|
+
: '(no charter)';
|
|
219
|
+
console.log(` ${priorityColor(`[${pi.priority}]`)} ${chalk.dim(pi.intent_id)} — ${charterSnippet}`);
|
|
220
|
+
console.log(chalk.dim(` Acceptance: ${pi.acceptance_count} item${pi.acceptance_count !== 1 ? 's' : ''}`));
|
|
221
|
+
}
|
|
222
|
+
console.log(chalk.dim(' ' + '─'.repeat(44)));
|
|
223
|
+
console.log('');
|
|
224
|
+
}
|
|
225
|
+
|
|
207
226
|
// Continuous session banner
|
|
208
227
|
if (continuousSession) {
|
|
209
228
|
console.log(chalk.cyan.bold(' 🔄 Continuous Vision-Driven Session'));
|
|
@@ -266,18 +285,31 @@ function renderGovernedStatus(context, opts) {
|
|
|
266
285
|
renderConnectorHealthStatus(connectorHealth);
|
|
267
286
|
renderRecentEventSummary(recentEventSummary);
|
|
268
287
|
|
|
288
|
+
// BUG-18: State/bundle integrity check
|
|
289
|
+
const desync = detectStateBundleDesync(root, state);
|
|
290
|
+
if (!desync.ok) {
|
|
291
|
+
console.log(chalk.red.bold(' ⚠ Ghost turn(s) detected — dispatch bundle missing'));
|
|
292
|
+
for (const entry of desync.desynced) {
|
|
293
|
+
console.log(chalk.red(` ${entry.turn_id} (${entry.role}): ${entry.expected_path} not found`));
|
|
294
|
+
}
|
|
295
|
+
console.log(chalk.dim(' Run `agentxchain reissue-turn` to recover, or `agentxchain doctor` for diagnostics.'));
|
|
296
|
+
console.log('');
|
|
297
|
+
}
|
|
298
|
+
|
|
269
299
|
const activeTurnCount = getActiveTurnCount(state);
|
|
270
300
|
const singleActiveTurn = getActiveTurn(state);
|
|
271
301
|
const approvalPending = Boolean(state?.pending_phase_transition || state?.pending_run_completion);
|
|
272
302
|
if (activeTurnCount > 1) {
|
|
273
303
|
console.log(` ${chalk.dim('Turns:')} ${activeTurnCount} active`);
|
|
274
304
|
for (const turn of Object.values(activeTurns)) {
|
|
275
|
-
const marker = turn.status === 'conflicted'
|
|
305
|
+
const marker = (turn.status === 'conflicted' || turn.status === 'failed_acceptance')
|
|
276
306
|
? chalk.red('✗')
|
|
277
307
|
: chalk.yellow('●');
|
|
278
308
|
const statusLabel = turn.status === 'conflicted'
|
|
279
309
|
? chalk.red('conflicted')
|
|
280
|
-
: turn.status
|
|
310
|
+
: turn.status === 'failed_acceptance'
|
|
311
|
+
? chalk.red('failed_acceptance')
|
|
312
|
+
: turn.status;
|
|
281
313
|
let elapsedTag = '';
|
|
282
314
|
if (turn.started_at) {
|
|
283
315
|
const elMs = Date.now() - new Date(turn.started_at).getTime();
|
|
@@ -318,6 +350,11 @@ function renderGovernedStatus(context, opts) {
|
|
|
318
350
|
console.log(` ${chalk.dim('Resolve:')} ${chalk.cyan(reassignAction.command)}`);
|
|
319
351
|
console.log(` ${chalk.dim(' or:')} ${chalk.cyan(mergeAction.command)}`);
|
|
320
352
|
}
|
|
353
|
+
if (turn.status === 'failed_acceptance') {
|
|
354
|
+
console.log(` ${chalk.dim('Reason:')} ${turn.failure_reason || 'unknown'}`);
|
|
355
|
+
console.log(` ${chalk.dim('Recover:')} ${chalk.cyan(`agentxchain reject-turn --turn ${turn.turn_id}`)} — reject and retry`);
|
|
356
|
+
console.log(` ${chalk.dim(' or:')} ${chalk.cyan(`agentxchain accept-turn --turn ${turn.turn_id}`)} — re-attempt acceptance`);
|
|
357
|
+
}
|
|
321
358
|
}
|
|
322
359
|
} else if (singleActiveTurn) {
|
|
323
360
|
console.log(` ${chalk.dim('Turn:')} ${singleActiveTurn.turn_id}`);
|
|
@@ -372,6 +409,23 @@ function renderGovernedStatus(context, opts) {
|
|
|
372
409
|
console.log(` ${chalk.dim('Turn:')} ${chalk.yellow('No active turn')}`);
|
|
373
410
|
}
|
|
374
411
|
|
|
412
|
+
// Runtime/authority binding drift detection (B-7)
|
|
413
|
+
const bindingDrifts = detectActiveTurnBindingDrift(state, config);
|
|
414
|
+
if (bindingDrifts.length > 0) {
|
|
415
|
+
console.log('');
|
|
416
|
+
console.log(` ${chalk.red.bold('⚠ Stale binding detected')}`);
|
|
417
|
+
for (const drift of bindingDrifts) {
|
|
418
|
+
if (drift.runtime_changed) {
|
|
419
|
+
console.log(` ${chalk.dim('Turn:')} ${drift.turn_id} (${drift.role_id})`);
|
|
420
|
+
console.log(` ${chalk.dim('Runtime:')} ${chalk.yellow(drift.old_runtime)} → ${chalk.green(drift.new_runtime)} (config changed)`);
|
|
421
|
+
}
|
|
422
|
+
if (drift.authority_changed) {
|
|
423
|
+
console.log(` ${chalk.dim('Authority:')} ${chalk.yellow(drift.old_authority)} → ${chalk.green(drift.new_authority)} (config changed)`);
|
|
424
|
+
}
|
|
425
|
+
console.log(` ${chalk.dim('Recover:')} ${chalk.cyan(drift.recovery_command)}`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
375
429
|
// Queued phase/completion requests
|
|
376
430
|
if (state?.queued_phase_transition) {
|
|
377
431
|
const qt = state.queued_phase_transition;
|
package/src/commands/step.js
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
getActiveTurnCount,
|
|
36
36
|
getActiveTurns,
|
|
37
37
|
reactivateGovernedRun,
|
|
38
|
+
refreshTurnBaselineSnapshot,
|
|
38
39
|
STATE_PATH,
|
|
39
40
|
} from '../lib/governed-state.js';
|
|
40
41
|
import { getMaxConcurrentTurns } from '../lib/normalized-config.js';
|
|
@@ -68,6 +69,7 @@ import { finalizeDispatchManifest, verifyDispatchManifest } from '../lib/dispatc
|
|
|
68
69
|
import { resolveGovernedRole } from '../lib/role-resolution.js';
|
|
69
70
|
import { shouldSuggestManualQaFallback } from '../lib/manual-qa-fallback.js';
|
|
70
71
|
import { evaluateApprovalSlaReminders } from '../lib/notification-runner.js';
|
|
72
|
+
import { consumeNextApprovedIntent } from '../lib/intake.js';
|
|
71
73
|
|
|
72
74
|
export async function stepCommand(opts) {
|
|
73
75
|
const context = loadProjectContext();
|
|
@@ -213,6 +215,17 @@ export async function stepCommand(opts) {
|
|
|
213
215
|
process.exit(1);
|
|
214
216
|
}
|
|
215
217
|
|
|
218
|
+
// If the target turn failed acceptance, print recovery guidance (BUG-3 fix)
|
|
219
|
+
if (targetTurn.status === 'failed_acceptance') {
|
|
220
|
+
console.log(chalk.red(`Turn ${targetTurn.turn_id} (${targetTurn.assigned_role}) failed acceptance.`));
|
|
221
|
+
console.log(chalk.dim(`Reason: ${targetTurn.failure_reason || 'unknown'}`));
|
|
222
|
+
console.log('');
|
|
223
|
+
console.log(chalk.dim('Recovery options:'));
|
|
224
|
+
console.log(` ${chalk.cyan(`agentxchain reject-turn --turn ${targetTurn.turn_id}`)} — reject and retry`);
|
|
225
|
+
console.log(` ${chalk.cyan(`agentxchain accept-turn --turn ${targetTurn.turn_id}`)} — re-attempt acceptance after fixing`);
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
|
|
216
229
|
console.log(chalk.yellow(`Re-dispatching blocked turn: ${targetTurn.turn_id}`));
|
|
217
230
|
const reactivated = reactivateGovernedRun(root, state, { via: 'step --resume', notificationConfig: config });
|
|
218
231
|
if (!reactivated.ok) {
|
|
@@ -222,6 +235,10 @@ export async function stepCommand(opts) {
|
|
|
222
235
|
state = reactivated.state;
|
|
223
236
|
skipAssignment = true;
|
|
224
237
|
|
|
238
|
+
// BUG-1 fix: refresh baseline snapshot to capture files dirtied between assignment and dispatch
|
|
239
|
+
refreshTurnBaselineSnapshot(root, targetTurn.turn_id);
|
|
240
|
+
state = JSON.parse(readFileSync(join(root, '.agentxchain/state.json'), 'utf8'));
|
|
241
|
+
|
|
225
242
|
const bundleResult = writeDispatchBundle(root, state, config);
|
|
226
243
|
if (!bundleResult.ok) {
|
|
227
244
|
console.log(chalk.red(`Failed to write dispatch bundle: ${bundleResult.error}`));
|
|
@@ -245,6 +262,10 @@ export async function stepCommand(opts) {
|
|
|
245
262
|
state = reactivated.state;
|
|
246
263
|
skipAssignment = true;
|
|
247
264
|
|
|
265
|
+
// BUG-1 fix: refresh baseline snapshot to capture files dirtied between assignment and dispatch
|
|
266
|
+
refreshTurnBaselineSnapshot(root, pausedTurn.turn_id);
|
|
267
|
+
state = JSON.parse(readFileSync(join(root, '.agentxchain/state.json'), 'utf8'));
|
|
268
|
+
|
|
248
269
|
const bundleResult = writeDispatchBundle(root, state, config);
|
|
249
270
|
if (!bundleResult.ok) {
|
|
250
271
|
console.log(chalk.red(`Failed to write dispatch bundle: ${bundleResult.error}`));
|
|
@@ -294,16 +315,26 @@ export async function stepCommand(opts) {
|
|
|
294
315
|
process.exit(1);
|
|
295
316
|
}
|
|
296
317
|
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
318
|
+
const shouldBindIntent = opts.intent !== false;
|
|
319
|
+
const consumed = shouldBindIntent ? consumeNextApprovedIntent(root, { role: roleId }) : { ok: false };
|
|
320
|
+
if (consumed.ok) {
|
|
321
|
+
state = loadProjectState(root, config);
|
|
322
|
+
if (!state) {
|
|
323
|
+
console.log(chalk.red('Failed to reload governed state after intake binding.'));
|
|
324
|
+
process.exit(1);
|
|
301
325
|
}
|
|
302
|
-
|
|
303
|
-
|
|
326
|
+
} else {
|
|
327
|
+
const assignResult = assignGovernedTurn(root, config, roleId);
|
|
328
|
+
if (!assignResult.ok) {
|
|
329
|
+
if (assignResult.error_code?.startsWith('hook_') || assignResult.error_code === 'hook_blocked') {
|
|
330
|
+
printAssignmentHookFailure(assignResult, roleId, config);
|
|
331
|
+
}
|
|
332
|
+
console.log(chalk.red(`Failed to assign turn: ${assignResult.error}`));
|
|
333
|
+
process.exit(1);
|
|
334
|
+
}
|
|
335
|
+
printAssignmentWarnings(assignResult);
|
|
336
|
+
state = assignResult.state;
|
|
304
337
|
}
|
|
305
|
-
printAssignmentWarnings(assignResult);
|
|
306
|
-
state = assignResult.state;
|
|
307
338
|
|
|
308
339
|
const bundleResult = writeDispatchBundle(root, state, config);
|
|
309
340
|
if (!bundleResult.ok) {
|
|
@@ -587,13 +618,21 @@ export async function stepCommand(opts) {
|
|
|
587
618
|
console.log(chalk.yellow(`The subprocess must independently read from .agentxchain/dispatch/turns/${turn.turn_id}/PROMPT.md`));
|
|
588
619
|
console.log(chalk.dim('To enable automatic prompt delivery, set prompt_transport to "argv" or "stdin" in the runtime config.'));
|
|
589
620
|
}
|
|
621
|
+
// BUG-6: always show log file path so operators know where to watch
|
|
622
|
+
const logPath = `.agentxchain/dispatch/turns/${turn.turn_id}/stdout.log`;
|
|
623
|
+
console.log(chalk.dim(`Log: ${logPath}`));
|
|
624
|
+
if (!opts.stream && !opts.verbose) {
|
|
625
|
+
console.log(chalk.dim(` Watch live: tail -f ${logPath}`));
|
|
626
|
+
}
|
|
590
627
|
console.log(chalk.dim('Press Ctrl+C to abort and leave the turn assigned.'));
|
|
591
628
|
console.log('');
|
|
592
629
|
|
|
630
|
+
// BUG-6: stream subprocess output by default (--stream or --verbose), suppress with --quiet
|
|
631
|
+
const shouldStream = opts.stream || opts.verbose || false;
|
|
593
632
|
const cliResult = await dispatchLocalCli(root, state, config, {
|
|
594
633
|
signal: controller.signal,
|
|
595
|
-
onStdout:
|
|
596
|
-
onStderr:
|
|
634
|
+
onStdout: shouldStream ? (text) => process.stdout.write(chalk.dim(text)) : undefined,
|
|
635
|
+
onStderr: shouldStream ? (text) => process.stderr.write(chalk.yellow(text)) : undefined,
|
|
597
636
|
verifyManifest: true,
|
|
598
637
|
});
|
|
599
638
|
|